Compare commits

...

11 Commits
v1.1 ... master

Author SHA1 Message Date
Maff 0b7849b016 additional state validation 2019-05-07 20:43:03 +01:00
Maff c86ec2c23c more refactoring, trashtalker is now more OOP 2019-05-06 19:45:52 +01:00
Matthew Connelly afb2ac83b6 Update documentation 2019-05-06 17:56:20 +01:00
Matthew Connelly 8ff683ef6d minor refactor 2019-05-06 17:52:53 +01:00
Matthew Connelly 16dad09773 shockingly, this worked. 2019-05-06 13:41:31 +01:00
Matthew Connelly 0e8e5b5be5 add a lil bit of logging 2019-05-06 13:40:26 +01:00
Matthew Connelly f5e5f710d4 further goofed it 2019-05-06 13:37:55 +01:00
Matthew Connelly c06c725a7e goofed it 2019-05-06 13:36:42 +01:00
Matthew Connelly aab1198ec2 here's a wild idea: live-shuffle the playlist via dtmf 2019-05-06 13:35:21 +01:00
Matthew Connelly eecf8ed026 mild refactor again, add dtmf handler (rudimentary) 2019-05-06 13:22:03 +01:00
Matthew Connelly 0e4bc692ab fix acceptable pylint warnings 2019-05-06 13:01:10 +01:00
2 changed files with 188 additions and 133 deletions

View File

@ -11,26 +11,26 @@ Any incoming call will immediately be answered, regardless of the user segment o
As noted above, this application was written to work in tandem with 3CX. As such, installation notes are geared towards the 3CX distribution of Debian Linux 9.
The general process is as follows:
* apt update
* apt install -y python-pjproject
* wget https://git.maff.scot/maff/trashtalker/archive/v1.1.tar.gz
* tar xaf v1.1.tar.gz
* rm v1.1.tar.gz
* cd trashtalker
* mv trashtalker.py /usr/local/bin/
* mv trashtalker@.service /etc/systemd/system/
* mkdir /opt/.tt
* mv example.conf /opt/.tt/
* `apt update`
* `apt install -y python-pjproject`
* `wget https://git.maff.scot/maff/trashtalker/archive/v1.1.tar.gz`
* `tar xaf v1.1.tar.gz`
* `rm v1.1.tar.gz`
* `cd trashtalker`
* `mv trashtalker.py /usr/local/bin/`
* `mv trashtalker@.service /etc/systemd/system/`
* `mkdir /opt/.tt`
* `mv example.conf /opt/.tt/`
* modify the contents of example.conf to match your needs
* systemctl enable trashtalker@example
* service trashtalker@example start
* `systemctl enable trashtalker@example`
* `service trashtalker@example start`
Within 3CX:
* Create a new SIP trunk (country: Generic, provider: Generic SIP Trunk, main no: any number of your choice, it doesn't matter)
* Name the new trunk something of your choice
* Define the registrar and outbound proxy IPs as 127.0.0.1
* Define the registrar and outbound proxy IPs as `127.0.0.1`
* Set the port for both of these to match the particular instance of TrashTalker you're configuring
* Leave the authentication settings to "Do not require - IP Based"
* Leave the authentication settings to "`Do not require - IP Based`"
* Click OK to save the trunk
* Create an outbound dial route with parameters of your preference, and set the first route to be the SIP trunk you created above. Ensure you do not set any other route entries for this outbound dial route.
* Click OK to save the rule
@ -40,3 +40,4 @@ Within 3CX:
This application currently operates the PR Gnusline, which can be dialled at the following number(s):
* +44 (0) 1337 515 404
* +1 (412) 406-9141

View File

@ -1,8 +1,12 @@
#!/usr/bin/python2.7
import sys
#It shouldn't be a surprise that pjsua wouldn't be available on the local machine.
#pylint: disable=import-error
import pjsua as pj
from time import sleep
from os import listdir, getenv
#It also shouldn't be a surprise that certain members of the signal library wouldn't be available on certain OSes (Windows).
#pylint: disable=no-name-in-module
from signal import signal, SIGHUP, SIGINT, SIGTERM
from random import shuffle
@ -21,46 +25,118 @@ from random import shuffle
## Please also be aware that, by default, playlist length is limited to 64 items. I can find no reason
## for this limitation, and it is specific to the python bindings for the PJSUA library.
## If you'd like to have a playlist longer than 64 items, you will need to recompile python-pjsua
## with the appropriate adjustment to _pjsua.c line 2515
## with the appropriate adjustment to pjsip-apps/src/python/_pjsua.c line 2515, in the definition for
## PyObject py_pjsua_playlist_create(self, args): pj_str_t files[64];
## A possible idea would be to make this configurable.
##
## If you can get this working using PJSUA2, a pull request would be greatly appreciated.
# Utility classes, used basically as enums or generics
class State(object):
class State:
class States:
done=1
preinit=4
media_init=8
init=16
lib=None
running=False
class PJStates:
init=0
deinit=1
class SIPStates:
ringing=180
answer=200
status=0
sip_ringing=180
sip_answer=200
def Log(level, source, line, error=False):
pfx='*'
if error:
pfx='!'
print("%s %s: %s" % (pfx*level, source, line))
sys.stdout.flush()
def preinit(self):
self.Log(1, "preinit", "initialising from environment")
self.status=States.preinit
self.LOG_LEVEL=int(getenv('TT_LOG_LEVEL', 0))
self.port=int(getenv('TT_LISTEN_PORT', 55060))
self.source=getenv('TT_MEDIA_SOURCE', '/opt/media/')
assert self.source.startswith('/'), "TT_MEDIA_SOURCE must specify an absolute path!"
self.status&=States.done
def init(self):
self.status=States.init
self.lib=pj.Lib()
self.cfg_ua=pj.UAConfig()
self.cfg_md=pj.MediaConfig()
self.cfg_ua.max_calls, self.cfg_ua.user_agent = 32, "TrashTalker/1.0"
self.cfg_md.no_vad, self.cfg_md.enable_ice = True, False
self.cfg_lg=pj.LogConfig(
level=self.LOG_LEVEL,
callback=PJLog)
self.lib.init(
ua_cfg=cfg_ua,
media_cfg=cfg_md,
log_cfg=cfg_lg)
self.lib.set_null_snd_dev()
self.lib.start(
with_thread=True)
self.transport=self.lib.create_transport(
pj.TransportType.UDP,
pj.TransportConfig(self.port))
self.account=self.lib.create_account_for_transport(
self.transport,
cb=AccountCb())
self.uri="sip:%s:%s" % (self.transport.info().host, self.transport.info().port)
self.status&=States.done
def media_init(self):
self.Log(3, "playlist-load", "loading playlist files from media path %s" % self.source)
self.status=States.media_init
if not self.source.endswith('/'):
self.Log(4, "playlist-load", "appending trailing / to TT_MEDIA_SOURCE")
self.source="%s/" % self.source
self.playlist=listdir(self.source)
self.playlist[:]=[self.source+file for file in self.playlist]
assert (len(self.playlist) > 1), "playlist path %s must contain more than one audio file" % self.source
self.Log(3, "playlist-load", "loaded %s media items from path %s" % (len(self.playlist), self.source))
self.status&=States.done
def deinit(self):
assert (self.status==(States.init & States.done)), "State.deinit cannot be called when not fully initialised"
self.lib.hangup_all()
self.lib.handle_events(timeout=250)
try:
self.account.delete()
self.lib.destroy()
self.lib=self.account=self.transport=None
except AttributeError:
self.Log(1, "deinit", "Got an AttributeError exception during shutdown.", error=True)
pass
except pj.Error as e:
self.Log(1, "deinit", "Got a PJError exception during shutdown: %s" % str(e), error=True)
pass
self.status=0
def run(self):
self.running=True
while self.running:
sleep(0.2)
def stop(self):
self.running=False
#Utility and state definitions
state=State()
# Logging
def PJLog(level, line, length):
Log(level+1, "pjsip", line)
def Log(level, source, line, error=False):
pfx='*'
if error:
pfx='!'
print("%s %s: %s" % (pfx*level, source, line))
sys.stdout.flush()
global state
state.Log(level+1, "pjsip", line)
# Signal handling
def sighandle(_signo, _stack_frame):
global state
Log(1, "sighandler", "caught signal %s" % _signo)
state.Log(1, "sighandler", "caught signal %s" % _signo)
if _signo == 1:
#SIGHUP
Log(1, "sighandler", "SIGHUP invoked playlist reload")
MediaLoadPlaylist()
state.Log(1, "sighandler", "SIGHUP invoked playlist reload")
state.media_init()
elif _signo == 2:
#SIGINT
Log(1, "sighandler", "SIGINT invoked current call flush")
state.Log(1, "sighandler", "SIGINT invoked current call flush")
state.lib.hangup_all()
elif _signo == 15:
#SIGTERM
Log(1, "sighandler", "SIGTERM invoked app shutdown")
state.running=False
state.Log(1, "sighandler", "SIGTERM invoked app shutdown")
state.stop()
pass
# Classes
@ -70,146 +146,124 @@ class AccountCb(pj.AccountCallback):
pj.AccountCallback.__init__(self, account)
def on_incoming_call(self, call):
Log(2, "event-call-in", "caller %s dialled in" % call.info().remote_uri)
global state
state.Log(2, "event-call-in", "caller %s dialled in" % call.info().remote_uri)
call.set_callback(CallCb(call))
#Answer call with SIP/2.0 180 RINGING
#This kicks in the EARLY media state, allowing us to initialise the playlist before the call connects
call.answer(SIPStates.ringing)
call.answer(state.sip_ringing)
# Call Callback class
class CallCb(pj.CallCallback):
def __init__(self, call=None):
pj.CallCallback.__init__(self, call)
def create_media(self):
global state
self.playlist=state.playlist
shuffle(self.playlist)
self.instmedia=state.lib.create_playlist(
loop=True, filelist=self.playlist, label="trashtalklist")
self.slotmedia=state.lib.playlist_get_slot(self.instmedia)
state.Log(4, "call-media-create", "created playlist for current call")
def connect_media(self):
global state
self.slotcall=self.call.info().conf_slot
state.lib.conf_connect(self.slotmedia, self.slotcall)
state.Log(4, "call-media-connect", "connected playlist media endpoint to call")
def disconnect_media(self):
global state
state.lib.conf_disconnect(self.slotmedia, self.slotcall)
state.Log(4, "call-media-disconnect", "disconnected playlist media endpoint from call")
def destroy_media(self):
global state
state.lib.playlist_destroy(self.instmedia)
self.instmedia=None
self.slotmedia=None
self.playlist=None
state.Log(4, "call-media-destroy", "destroyed playlist endpoint and object")
def on_state(self):
global state
Log(2, "event-state-change", "SIP/2.0 %s (%s), call %s in call with party %s" %
state.Log(2, "event-state-change", "SIP/2.0 %s (%s), call %s in call with party %s" %
(self.call.info().last_code, self.call.info().last_reason,
self.call.info().state_text, self.call.info().remote_uri))
#EARLY media state allows us to init the playlist while the call establishes
if self.call.info().state == pj.CallState.EARLY:
self.playlist=state.playlist
shuffle(self.playlist)
self.instmedia=state.lib.create_playlist(
loop=True, filelist=self.playlist, label="trashtalklist")
self.slotmedia=state.lib.playlist_get_slot(self.instmedia)
Log(3, "event-call-state-early", "initialised new trashtalk playlist instance")
self.create_media()
state.Log(3, "event-call-state-early", "initialised new trashtalk playlist instance")
#answer the call once playlist is prepared
self.call.answer(SIPStates.answer)
self.call.answer(state.sip_answer)
#CONFIRMED state indicates the call is connected
elif self.call.info().state == pj.CallState.CONFIRMED:
Log(3, "event-call-state-confirmed", "answered call")
self.slotcall=self.call.info().conf_slot
state.lib.conf_connect(self.slotmedia, self.slotcall)
Log(3, "event-call-conf-joined", "joined trashtalk to call")
state.Log(3, "event-call-state-confirmed", "answered call")
self.connect_media()
state.Log(3, "event-call-conf-joined", "joined trashtalk to call")
#DISCONNECTED state indicates the call has ended (whether on our end or the caller's)
elif self.call.info().state == pj.CallState.DISCONNECTED:
Log(3, "event-call-state-disconnected", "call disconnected")
state.lib.conf_disconnect(self.slotmedia, self.slotcall)
state.lib.playlist_destroy(self.instmedia)
Log(3, "event-call-conf-left", "removed trashtalk instance from call and destroyed it")
state.Log(3, "event-call-state-disconnected", "call disconnected")
self.disconnect_media()
self.destroy_media()
state.Log(3, "event-call-conf-left", "removed trashtalk instance from call and destroyed it")
def on_dtmf_digit(self, digit):
global state
state.Log(3, "dtmf-digit", "received DTMF signal: %s" % digit)
if digit == '*':
self.disconnect_media()
self.destroy_media()
self.create_media()
self.connect_media()
#I'm not sure what this is for, as all media handling is actually done within the SIP events above
def on_media_state(self):
global state
if self.call.info().media_state == pj.MediaState.ACTIVE:
Log(4, "event-media-state-change", "Media State transitioned to ACTIVE")
state.Log(4, "event-media-state-change", "Media State transitioned to ACTIVE")
else:
Log(4, "event-media-state-change", "Media State transitioned to INACTIVE")
state.Log(4, "event-media-state-change", "Media State transitioned to INACTIVE")
# Main logic functions
def PJControl(action):
global state
if action == PJStates.init:
state.lib=pj.Lib()
state.cfg_ua=pj.UAConfig()
state.cfg_md=pj.MediaConfig()
state.cfg_ua.max_calls, state.cfg_ua.user_agent = 32, "TrashTalker/1.0"
state.cfg_md.no_vad, state.cfg_md.enable_ice = True, False
state.lib.init(
ua_cfg=state.cfg_ua,
media_cfg=state.cfg_md,
log_cfg=pj.LogConfig(
level=state.LOG_LEVEL,
callback=PJLog
)
)
state.lib.set_null_snd_dev()
state.lib.start(with_thread=True)
state.transport=state.lib.create_transport(
pj.TransportType.UDP,
pj.TransportConfig(state.port)
)
state.account=state.lib.create_account_for_transport(
state.transport,
cb=AccountCb()
)
state.uri="sip:%s:%s" % (state.transport.info().host, state.transport.info().port)
elif action == PJStates.deinit:
state.lib.hangup_all()
# allow time for cleanup before destroying objects
state.lib.handle_events(timeout=250)
try:
state.account.delete()
state.lib.destroy()
state.lib=state.account=state.transport=None
except AttributeError:
Log(1, "deinit", "AttributeError when clearing down pjsip, this is likely fine", error=True)
pass
except pj.Error as e:
Log(1, "deinit", "pjsip error when clearing down: %s" % str(e), error=True)
pass
def WaitLoop():
global state
while state.running:
sleep(0.2)
def MediaLoadPlaylist():
Log(3, "playlist-load", "loading playlist files")
global state
if not state.source.endswith('/'):
Log(4, "playlist-load", "appending trailing / to TT_MEDIA_SOURCE")
state.source="%s/" % state.source
state.playlist=listdir(state.source)
state.playlist[:]=[state.source+file for file in state.playlist]
assert (len(state.playlist) > 1), "playlist path %s must contain more than one audio file" % state.source
Log(3, "playlist-load",
"load playlist from %s, got %s files" % (state.source, len(state.playlist)))
def main():
global state
Log(1, "init", "initialising trashtalker")
state.LOG_LEVEL=int(getenv('TT_LOG_LEVEL', 0))
#TT_MEDIA_SOURCE and TT_LISTEN_PORT can be configured via env. variables
state.source=getenv('TT_MEDIA_SOURCE', '/opt/media/')
state.port=int(getenv('TT_LISTEN_PORT', 55060))
state.running=True
#Try to pre-init
try:
state.preinit()
assert (state.status==(State.States.preinit & State.States.done))
except AssertionError as e:
state.Log(1, "preinit", "AssertionError while pre-initialising: %s" % str(e))
raise Exception("Unable to start up TrashTalker. Check all configuration parameters are correct, and review logs.")
#Attach signal handlers
signal(SIGHUP, sighandle)
signal(SIGINT, sighandle)
signal(SIGTERM, sighandle)
assert state.source.startswith('/'), "Environment variable TT_MEDIA_PATH must be an absolute path!"
#Try to initialise media
try:
MediaLoadPlaylist()
state.media_init()
assert (state.status==(State.States.media_init & State.States.done))
except:
Log(2, "playlist-load", "exception encountered while loading playlist from path %s" % state.source, error=True)
state.Log(2, "playlist-load", "exception encountered while loading playlist from path %s" % state.source, error=True)
raise Exception("Unable to load playlist")
#Try to initialise main process; only fault here should be if the configured listening port is unavailable to us
try:
PJControl(PJStates.init)
state.init()
assert (state.status==(State.States.init & State.States.done))
except:
Log(2, "pj-init", "Unable to initialise pjsip library; please check media path and SIP listening port are correct", error=True)
state.Log(2, "pj-init", "Unable to initialise pjsip library; please check media path and SIP listening port are correct", error=True)
raise Exception("Unable to initialise pjsip library; please check media path and SIP listening port are correct")
Log(1, "init-complete", "trashtalker listening on uri %s and serving %s media items from %s" % (state.uri, len(state.playlist), state.source))
state.Log(1, "init-complete", "trashtalker listening on uri %s and serving %s media items from %s" % (state.uri, len(state.playlist), state.source))
#Enter main loop
try:
WaitLoop()
state.run()
except pj.Error as e:
Log(2, "pjsip-error", "trashtalker encountered pjsip exception %s" % str(e), error=True)
state.running=False
state.Log(2, "pjsip-error", "trashtalker encountered pjsip exception %s" % str(e), error=True)
state.stop()
pass
except KeyboardInterrupt:
state.running=False
state.stop()
pass
Log(1, "deinit", "main loop exited, shutting down")
PJControl(PJStates.deinit)
Log(1, "deinit-complete", "trashtalker has shut down")
state.Log(1, "deinit", "main loop exited, shutting down")
state.deinit()
state.Log(1, "deinit", "trashtalker has shut down")
if __name__ == "__main__":
main()