diff --git a/ChangeLog b/ChangeLog new file mode 100644 index 0000000..9e68e35 --- /dev/null +++ b/ChangeLog @@ -0,0 +1,78 @@ +2006-11-28 Matthew Nicholson + + * UPGRADE: Tweaked formatting. + +2006-10-30 Matthew Nicholson + + * ChangeLog: Fixed previous entry. + +2006-10-30 Matthew Nicholson + + * TODO: Updated. + * asterisk/agi.py (AGI.control_stream_file): Changed default skipms + and quoted arguments. + +2006-10-24 Matthew Nicholson + + * asterisk/agi.py: Added get_variable_full command. + +2006-10-18 Matthew Nicholson + + * asterisk/agitb.py: Make error output default to sys.stderr instead + of sys.stdout. + +2006-09-19 Matthew Nicholson + + * debian/control: Removed XS-Python-Versions header to make it default + to all python versions. + +2006-09-19 Matthew Nicholson + + * setup.py: Updated version. + +2006-09-19 Matthew Nicholson + + * debian/rules: Changed to use pysupport. + * debian/control: Changed to use pysupport and changed arch to all. + +2006-09-19 Matthew Nicholson + + * MANIFEST.in: Added NEWS to manifest. + +2006-09-19 Matthew Nicholson + + * debian/rules: Updated to reflect new python policy. + * debian/control: Updated to reflect new python policy. + * debian/changelog: Updated. + +2006-08-23 Matthew Nicholson + + * UPGRADE: Updated. + +2006-08-23 Matthew Nicholson + + * asterisk/manager.py (unregister_event): Added. + +2006-08-23 Matthew Nicholson + + * NEWS: Added. + +2006-07-14 Matthew Nicholson + + * asterisk/agi.py (wait_for_digit): Only catch ValueError, not all + exceptions. + +2006-07-14 Matthew Nicholson + + * TODO: Updated. + * asterisk/agi.py (set_variable): Documentation changes. + * asterisk/agi.py (get_variable): Changed to return and empty string + instead of throwing an exception when a channel variable is not set. + * UPGRADE: Added. + +2006-07-14 Matthew Nicholson + + * ChangeLog: Added. + * TODO: Added. + * MANIFEST.in: Added ChangeLog and TODO. + diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..76300ac --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,12 @@ +include debian/watch +include debian/rules +include debian/changelog +include debian/control +include debian/compat +include debian/copyright +include rpm/python-pyst.spec +include ChangeLog +include TODO +include UPGRADE +include NEWS +include MANIFEST.in diff --git a/NEWS b/NEWS new file mode 100644 index 0000000..e69de29 diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..3ace5c0 --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 1.0 +Name: pyst +Version: 0.2 +Summary: Asterisk related utility modules. +Home-page: http://www.sourceforge.net/projects/pyst/ +Author: Karl Putland +Author-email: kputland@users.sourceforge.net +License: Python Software Foundation License (agitb), Lesser General Public License +Description: UNKNOWN +Platform: UNIX diff --git a/TODO b/TODO new file mode 100644 index 0000000..b320136 --- /dev/null +++ b/TODO @@ -0,0 +1,22 @@ += Things to do for pyst = + += ChangeLog = + +The ChangeLog needs to be updated from the monotone logs. + += Documentation = + +All of pyst's inline documentation needs to be updated. + += manager.py = + +This should be convereted to be single threaded. Also there is a race +condition when a user calls manager.logoff() followed by manager.close(). The +close() function may still call logoff again if the socket thread has not yet +cleared the _connected flag. + +A class should be made for each manager action rather than having a function in +a manager class. The manager class should be adapted to have a send method +that know the general format of the classes. + +## vim: set fo=awlq: diff --git a/UPGRADE b/UPGRADE new file mode 100644 index 0000000..3a204ca --- /dev/null +++ b/UPGRADE @@ -0,0 +1,7 @@ + +If upgrading from... + +0.1.0: + * agi.get_variable no longer throws an exception, instead it returns an + empty string when a channel variable is not set. + * manager.quit() has be renamed to manager.close() diff --git a/asterisk/__init__.py b/asterisk/__init__.py new file mode 100644 index 0000000..6156039 --- /dev/null +++ b/asterisk/__init__.py @@ -0,0 +1,14 @@ +""" pyst - A set of interfaces and libraries to allow programming of asterisk from python. + +The pyst project includes several python modules to assist in programming +asterisk with python: + +agi - python wrapper for agi +agitb - a module to assist in agi debugging, like cgitb +config - a module for parsing asterisk config files +manager - a module for interacting with the asterisk manager interface + +""" + +__all__ = ['agi', 'agitb', 'config', 'manager'] + diff --git a/asterisk/agi.py b/asterisk/agi.py new file mode 100644 index 0000000..d9b7910 --- /dev/null +++ b/asterisk/agi.py @@ -0,0 +1,666 @@ +#!/usr/bin/env python2 +# vim: set et sw=4: +"""agi + +This module contains functions and classes to implment AGI scripts in python. +pyvr + +{'agi_callerid' : 'mars.putland.int', + 'agi_channel' : 'IAX[kputland@kputland]/119', + 'agi_context' : 'default', + 'agi_dnid' : '666', + 'agi_enhanced' : '0.0', + 'agi_extension': '666', + 'agi_language' : 'en', + 'agi_priority' : '1', + 'agi_rdnis' : '', + 'agi_request' : 'pyst', + 'agi_type' : 'IAX'} + +""" + +import sys, pprint, re +from types import ListType +import signal + +DEFAULT_TIMEOUT = 2000 # 2sec timeout used as default for functions that take timeouts +DEFAULT_RECORD = 20000 # 20sec record time + +re_code = re.compile(r'(^\d*)\s*(.*)') +re_kv = re.compile(r'(?P\w+)=(?P[^\s]+)\s*(?:\((?P.*)\))*') + +class AGIException(Exception): pass +class AGIError(AGIException): pass + +class AGIUnknownError(AGIError): pass + +class AGIAppError(AGIError): pass + +# there are several different types of hangups we can detect +# they all are derrived from AGIHangup +class AGIHangup(AGIAppError): pass +class AGISIGHUPHangup(AGIHangup): pass +class AGISIGPIPEHangup(AGIHangup): pass +class AGIResultHangup(AGIHangup): pass + +class AGIDBError(AGIAppError): pass + +class AGIUsageError(AGIError): pass +class AGIInvalidCommand(AGIError): pass + +class AGI: + """ + This class encapsulates communication between Asterisk an a python script. + It handles encoding commands to Asterisk and parsing responses from + Asterisk. + """ + + def __init__(self): + self._got_sighup = False + signal.signal(signal.SIGHUP, self._handle_sighup) # handle SIGHUP + sys.stderr.write('ARGS: ') + sys.stderr.write(str(sys.argv)) + sys.stderr.write('\n') + self.env = {} + self._get_agi_env() + + def _get_agi_env(self): + while 1: + line = sys.stdin.readline().strip() + sys.stderr.write('ENV LINE: ') + sys.stderr.write(line) + sys.stderr.write('\n') + if line == '': + #blank line signals end + break + key,data = line.split(':')[0], ':'.join(line.split(':')[1:]) + key = key.strip() + data = data.strip() + if key <> '': + self.env[key] = data + sys.stderr.write('class AGI: self.env = ') + sys.stderr.write(pprint.pformat(self.env)) + sys.stderr.write('\n') + + def _quote(self, string): + return ''.join(['"', str(string), '"']) + + def _handle_sighup(self, signum, frame): + """Handle the SIGHUP signal""" + self._got_sighup = True + + def test_hangup(self): + """This function throws AGIHangup if we have recieved a SIGHUP""" + if self._got_sighup: + raise AGISIGHUPHangup("Received SIGHUP from Asterisk") + + def execute(self, command, *args): + self.test_hangup() + + try: + self.send_command(command, *args) + return self.get_result() + except IOError,e: + if e.errno == 32: + # Broken Pipe * let us go + raise AGISIGPIPEHangup("Received SIGPIPE") + else: + raise + + def send_command(self, command, *args): + """Send a command to Asterisk""" + command = command.strip() + command = '%s %s' % (command, ' '.join(map(str,args))) + command = command.strip() + if command[-1] != '\n': + command += '\n' + sys.stderr.write(' COMMAND: %s' % command) + sys.stdout.write(command) + sys.stdout.flush() + + def get_result(self, stdin=sys.stdin): + """Read the result of a command from Asterisk""" + code = 0 + result = {'result':('','')} + line = stdin.readline().strip() + sys.stderr.write(' RESULT_LINE: %s\n' % line) + m = re_code.search(line) + if m: + code, response = m.groups() + code = int(code) + + if code == 200: + for key,value,data in re_kv.findall(response): + result[key] = (value, data) + + # If user hangs up... we get 'hangup' in the data + if data == 'hangup': + raise AGIResultHangup("User hungup during execution") + + if key == 'result' and value == '-1': + raise AGIAppError("Error executing application, or hangup") + + sys.stderr.write(' RESULT_DICT: %s\n' % pprint.pformat(result)) + return result + elif code == 510: + raise AGIInvalidCommand(response) + elif code == 520: + usage = [line] + line = stdin.readline().strip() + while line[:3] != '520': + usage.append(line) + line = stdin.readline().strip() + usage.append(line) + usage = '%s\n' % '\n'.join(usage) + raise AGIUsageError(usage) + else: + raise AGIUnknownError(code, 'Unhandled code or undefined response') + + def _process_digit_list(self, digits): + if type(digits) == ListType: + digits = ''.join(map(str, digits)) + return self._quote(digits) + + def answer(self): + """agi.answer() --> None + Answer channel if not already in answer state. + """ + self.execute('ANSWER')['result'][0] + + def wait_for_digit(self, timeout=DEFAULT_TIMEOUT): + """agi.wait_for_digit(timeout=DEFAULT_TIMEOUT) --> digit + Waits for up to 'timeout' milliseconds for a channel to receive a DTMF + digit. Returns digit dialed + Throws AGIError on channel falure + """ + res = self.execute('WAIT FOR DIGIT', timeout)['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except ValueError: + raise AGIError('Unable to convert result to digit: %s' % res) + + def send_text(self, text=''): + """agi.send_text(text='') --> None + Sends the given text on a channel. Most channels do not support the + transmission of text. + Throws AGIError on error/hangup + """ + self.execute('SEND TEXT', self._quote(text))['result'][0] + + def receive_char(self, timeout=DEFAULT_TIMEOUT): + """agi.receive_char(timeout=DEFAULT_TIMEOUT) --> chr + Receives a character of text on a channel. Specify timeout to be the + maximum time to wait for input in milliseconds, or 0 for infinite. Most channels + do not support the reception of text. + """ + res = self.execute('RECEIVE CHAR', timeout)['result'][0] + + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def tdd_mode(self, mode='off'): + """agi.tdd_mode(mode='on'|'off') --> None + Enable/Disable TDD transmission/reception on a channel. + Throws AGIAppError if channel is not TDD-capable. + """ + res = self.execute('TDD MODE', mode)['result'][0] + if res == '0': + raise AGIAppError('Channel %s is not TDD-capable') + + def stream_file(self, filename, escape_digits='', sample_offset=0): + """agi.stream_file(filename, escape_digits='', sample_offset=0) --> digit + Send the given file, allowing playback to be interrupted by the given + digits, if any. escape_digits is a string '12345' or a list of + ints [1,2,3,4,5] or strings ['1','2','3'] or mixed [1,'2',3,'4'] + If sample offset is provided then the audio will seek to sample + offset before play starts. Returns digit if one was pressed. + Throws AGIError if the channel was disconnected. Remember, the file + extension must not be included in the filename. + """ + escape_digits = self._process_digit_list(escape_digits) + response = self.execute('STREAM FILE', filename, escape_digits, sample_offset) + res = response['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def control_stream_file(self, filename, escape_digits='', skipms=3000, fwd='', rew='', pause=''): + """ + Send the given file, allowing playback to be interrupted by the given + digits, if any. escape_digits is a string '12345' or a list of + ints [1,2,3,4,5] or strings ['1','2','3'] or mixed [1,'2',3,'4'] + If sample offset is provided then the audio will seek to sample + offset before play starts. Returns digit if one was pressed. + Throws AGIError if the channel was disconnected. Remember, the file + extension must not be included in the filename. + """ + escape_digits = self._process_digit_list(escape_digits) + response = self.execute('CONTROL STREAM FILE', self._quote(filename), escape_digits, self._quote(skipms), self._quote(fwd), self._quote(rew), self._quote(pause)) + res = response['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def send_image(self, filename): + """agi.send_image(filename) --> None + Sends the given image on a channel. Most channels do not support the + transmission of images. Image names should not include extensions. + Throws AGIError on channel failure + """ + res = self.execute('SEND IMAGE', filename)['result'][0] + if res != '0': + raise AGIAppError('Channel falure on channel %s' % self.env.get('agi_channel','UNKNOWN')) + + def say_digits(self, digits, escape_digits=''): + """agi.say_digits(digits, escape_digits='') --> digit + Say a given digit string, returning early if any of the given DTMF digits + are received on the channel. + Throws AGIError on channel failure + """ + digits = self._process_digit_list(digits) + escape_digits = self._process_digit_list(escape_digits) + res = self.execute('SAY DIGITS', digits, escape_digits)['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def say_number(self, number, escape_digits=''): + """agi.say_number(number, escape_digits='') --> digit + Say a given digit string, returning early if any of the given DTMF digits + are received on the channel. + Throws AGIError on channel failure + """ + number = self._process_digit_list(number) + escape_digits = self._process_digit_list(escape_digits) + res = self.execute('SAY NUMBER', number, escape_digits)['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def say_alpha(self, characters, escape_digits=''): + """agi.say_alpha(string, escape_digits='') --> digit + Say a given character string, returning early if any of the given DTMF + digits are received on the channel. + Throws AGIError on channel failure + """ + characters = self._process_digit_list(characters) + escape_digits = self._process_digit_list(escape_digits) + res = self.execute('SAY ALPHA', characters, escape_digits)['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def say_phonetic(self, characters, escape_digits=''): + """agi.say_phonetic(string, escape_digits='') --> digit + Phonetically say a given character string, returning early if any of + the given DTMF digits are received on the channel. + Throws AGIError on channel failure + """ + characters = self._process_digit_list(characters) + escape_digits = self._process_digit_list(escape_digits) + res = self.execute('SAY PHONETIC', characters, escape_digits)['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def say_date(self, seconds, escape_digits=''): + """agi.say_date(seconds, escape_digits='') --> digit + Say a given date, returning early if any of the given DTMF digits are + pressed. The date should be in seconds since the UNIX Epoch (Jan 1, 1970 00:00:00) + """ + escape_digits = self._process_digit_list(escape_digits) + res = self.execute('SAY DATE', seconds, escape_digits)['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def say_time(self, seconds, escape_digits=''): + """agi.say_time(seconds, escape_digits='') --> digit + Say a given time, returning early if any of the given DTMF digits are + pressed. The time should be in seconds since the UNIX Epoch (Jan 1, 1970 00:00:00) + """ + escape_digits = self._process_digit_list(escape_digits) + res = self.execute('SAY TIME', seconds, escape_digits)['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def say_datetime(self, seconds, escape_digits='', format='', zone=''): + """agi.say_datetime(seconds, escape_digits='', format='', zone='') --> digit + Say a given date in the format specfied (see voicemail.conf), returning + early if any of the given DTMF digits are pressed. The date should be + in seconds since the UNIX Epoch (Jan 1, 1970 00:00:00). + """ + escape_digits = self._process_digit_list(escape_digits) + if format: format = self._quote(format) + res = self.execute('SAY DATETIME', seconds, escape_digits, format, zone)['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def get_data(self, filename, timeout=DEFAULT_TIMEOUT, max_digits=255): + """agi.get_data(filename, timeout=DEFAULT_TIMEOUT, max_digits=255) --> digits + Stream the given file and receive dialed digits + """ + result = self.execute('GET DATA', filename, timeout, max_digits) + res, value = result['result'] + return res + + def get_option(self, filename, escape_digits='', timeout=0): + """agi.get_option(filename, escape_digits='', timeout=0) --> digit + Send the given file, allowing playback to be interrupted by the given + digits, if any. escape_digits is a string '12345' or a list of + ints [1,2,3,4,5] or strings ['1','2','3'] or mixed [1,'2',3,'4'] + Returns digit if one was pressed. + Throws AGIError if the channel was disconnected. Remember, the file + extension must not be included in the filename. + """ + escape_digits = self._process_digit_list(escape_digits) + if timeout: + response = self.execute('GET OPTION', filename, escape_digits, timeout) + else: + response = self.execute('GET OPTION', filename, escape_digits) + + res = response['result'][0] + if res == '0': + return '' + else: + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to char: %s' % res) + + def set_context(self, context): + """agi.set_context(context) + Sets the context for continuation upon exiting the application. + No error appears to be produced. Does not set exten or priority + Use at your own risk. Ensure that you specify a valid context. + """ + self.execute('SET CONTEXT', context) + + def set_extension(self, extension): + """agi.set_extension(extension) + Sets the extension for continuation upon exiting the application. + No error appears to be produced. Does not set context or priority + Use at your own risk. Ensure that you specify a valid extension. + """ + self.execute('SET EXTENSION', extension) + + def set_priority(self, priority): + """agi.set_priority(priority) + Sets the priority for continuation upon exiting the application. + No error appears to be produced. Does not set exten or context + Use at your own risk. Ensure that you specify a valid priority. + """ + self.execute('set priority', priority) + + def goto_on_exit(self, context='', extension='', priority=''): + context = context or self.env['agi_context'] + extension = extension or self.env['agi_extension'] + priority = priority or self.env['agi_priority'] + self.set_context(context) + self.set_extension(extension) + self.set_priority(priority) + + def record_file(self, filename, format='gsm', escape_digits='#', timeout=DEFAULT_RECORD, offset=0, beep='beep'): + """agi.record_file(filename, format, escape_digits, timeout=DEFAULT_TIMEOUT, offset=0, beep='beep') --> None + Record to a file until a given dtmf digit in the sequence is received + The format will specify what kind of file will be recorded. The timeout + is the maximum record time in milliseconds, or -1 for no timeout. Offset + samples is optional, and if provided will seek to the offset without + exceeding the end of the file + """ + escape_digits = self._process_digit_list(escape_digits) + res = self.execute('RECORD FILE', self._quote(filename), format, escape_digits, timeout, offset, beep)['result'][0] + try: + return chr(int(res)) + except: + raise AGIError('Unable to convert result to digit: %s' % res) + + def set_autohangup(self, secs): + """agi.set_autohangup(secs) --> None + Cause the channel to automatically hangup at