Compare commits

...

32 Commits
v1.0 ... 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
Maff 9e0be50515 Update README with notes on installation and usage 2019-05-03 20:05:25 +01:00
Maff 92c3fe8c72 Further slight placation 2019-05-03 19:44:43 +01:00
Maff c53aa77758 pylint placations 2019-05-03 19:43:39 +01:00
Maff faeece7f1d More slight refactoring 2019-05-03 19:42:00 +01:00
Maff bad666456c Slight refactor 2019-05-02 21:33:38 +01:00
Maff 29d1acddc1 Update 'trashtalker.py' 2019-05-01 15:45:57 +00:00
Maff 45b806fbda Add example config to go with shitty systemd unit file 2019-05-01 11:53:52 +00:00
Maff 61ad66a70c Update 'trashtalker@.service' 2019-05-01 11:52:00 +00:00
Maff 5c2903c2c0 Update 'trashtalker.py' 2019-05-01 11:47:41 +00:00
Maff 357ecf994c Update 'trashtalker.py' 2019-05-01 11:42:03 +00:00
Maff b9b5fd4e5f Update 'trashtalker.py' 2019-05-01 10:06:51 +00:00
Maff 1cd89ef312 Update 'trashtalker.py' 2019-05-01 09:53:06 +00:00
Maff 664cf3beb2 Update 'trashtalker.py' 2019-04-29 14:07:46 +00:00
Maff 032214b5a0 Update 'trashtalker.py' 2019-04-29 13:56:26 +00:00
Maff b128aa14fd Update 'trashtalker.py' 2019-04-29 13:47:59 +00:00
Maff c60d5872d0 Update 'trashtalker.py' 2019-04-29 13:34:36 +00:00
Maff f1675fd1f5 Update 'trashtalker.py' 2019-04-29 13:33:02 +00:00
Maff 5d3209ce0c Update 'trashtalker.py' 2019-04-29 13:31:00 +00:00
Maff f88bff4c93 Update 'trashtalker.py' 2019-04-29 13:21:28 +00:00
Maff ca22b1f1db Update 'trashtalker.py' 2019-04-29 13:19:19 +00:00
Maff 9bf56721f2 Update 'trashtalker.py' 2019-04-29 13:18:28 +00:00
5 changed files with 276 additions and 143 deletions

2
.gitignore vendored
View File

@ -114,3 +114,5 @@ dmypy.json
# Pyre type checker
.pyre/
.DS_Store
.vscode/

View File

@ -1,3 +1,43 @@
# trashtalker
Python-based script that, via SIP, plays WAV files indefinitely.
Python-based script that, via SIP, plays WAV files indefinitely.
## Notes
This application was written to work in tandem with 3CX, but should fit essentially any use-case.
Any incoming call will immediately be answered, regardless of the user segment of the incoming URI.
## Installing
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/`
* modify the contents of example.conf to match your needs
* `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`
* 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`"
* 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
* Place a call which matches your newly-created outbound route. You should hear your choice of media.
## See (hear) it in action
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

6
example.conf Normal file
View File

@ -0,0 +1,6 @@
#Place this file in /opt/.tt/your-instance-name.conf
#Then run `systemctl enable trashtalker@your-instance-name` to enable this instance
#Followed by `service trashtalker@your-instance-name start` to start up this instance
TT_MEDIA_SOURCE=/path/to/your/media
TT_LISTEN_PORT=5062

View File

@ -1,185 +1,269 @@
#!/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
from signal import signal, SIGTERM
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
## NOTE:
## This library uses the PJSUA library which is officially deprecated
## The reason for this is that I couldn't get this to work with equivalent
## code for PJSUA2.
## At time of publishing, the only library version of pjsua available
## in the repos for Debian 9 is the deprecated PJSUA (python-pjsua)
## 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
## This script is designed to run either on the same machine or firewalled/segregated network segment
## as the telephony appliance(s) that will use it. While there should be no security risk to doing so,
## you should not have the SIP endpoint exposed by this application reachable on the public internet.
## This script should be configured to run automatically, and your telephony appliance should be configured
## to treat it as a no-authentication or "IP-based authentication" SIP trunk. Any number or name will
## be recognised and answered automatically by this script.
##
## If you can get this working using PJSUA2, a pull request would be greatly
## appreciated.
# Configuration
LOG_LEVEL=1
sourcepath="/opt/media/"
sipport=5062
# End configuration
# Application scaffolding
# logger functions
def pjlog(level, str, len):
olog(level+1, "pjsip", str)
def elog(sev, source, line):
print("%s %s: %s" % ("!"*sev, source, line))
sys.stdout.flush()
def olog(sev, source, line):
print("%s %s: %s" % ("*"*sev, source, line))
sys.stdout.flush()
## This script uses the PJSUA library which is officially deprecated
## The reason for this is that I couldn't get this to work with equivalent code for PJSUA2.
## At time of publishing, the only library version of pjsua available in the repos for Debian 9
## is the deprecated PJSUA (python-pjsua)
## 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 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:
class States:
done=1
preinit=4
media_init=8
init=16
lib=None
running=False
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):
global state
state.Log(level+1, "pjsip", line)
# Signal handling
def sighandle(_signo, _stack_frame):
global mainloop
mainloop=False
global state
state.Log(1, "sighandler", "caught signal %s" % _signo)
if _signo == 1:
#SIGHUP
state.Log(1, "sighandler", "SIGHUP invoked playlist reload")
state.media_init()
elif _signo == 2:
#SIGINT
state.Log(1, "sighandler", "SIGINT invoked current call flush")
state.lib.hangup_all()
elif _signo == 15:
#SIGTERM
state.Log(1, "sighandler", "SIGTERM invoked app shutdown")
state.stop()
pass
# Classes
class SIPStates:
ringing=180
answer=200
# Account Callback class
class AccountCb(pj.AccountCallback):
def __init__(self, account=None):
pj.AccountCallback.__init__(self, account)
def on_incoming_call(self, call):
olog(2, "event-call-in", "caller %s dialled in" % call.info().remote_uri)
call.set_callback(CallCb(call))
call.answer(SIPStates.ringing)
sleep(0.3)
call.answer(SIPStates.answer)
def on_incoming_call(self, call):
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(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):
olog(3, "event-state-change", "SIP/2.0 %s (%s), call %s in call with party %s" %
global state
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))
if self.call.info().state == pj.CallState.CONFIRMED:
global files
self.playlist=files
shuffle(self.playlist)
olog(3, "event-call-state-confirmed", "answered call")
self.confslot=self.call.info().conf_slot
self.playlist_instance=pj.Lib.instance().create_playlist(
loop=True, filelist=self.playlist, label="trashtalklist")
self.playlistslot=pj.Lib.instance().playlist_get_slot(self.playlist_instance)
pj.Lib.instance().conf_connect(self.playlistslot, self.confslot)
olog(3, "event-call-conf-joined", "joined trashtalk to call")
#EARLY media state allows us to init the playlist while the call establishes
if self.call.info().state == pj.CallState.EARLY:
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(state.sip_answer)
#CONFIRMED state indicates the call is connected
elif self.call.info().state == pj.CallState.CONFIRMED:
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:
olog(3, "event-call-state-disconnected", "call disconnected")
pj.Lib.instance().conf_disconnect(self.playlistslot, self.confslot)
pj.Lib.instance().playlist_destroy(self.playlist_instance)
olog(3, "event-call-conf-left", "removed trashtalk 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:
olog(4, "event-media-state-change", "Media State transitioned to ACTIVE")
state.Log(4, "event-media-state-change", "Media State transitioned to ACTIVE")
else:
olog(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 PjInit():
global lib
global LOG_LEVEL
lib=pj.Lib()
cfg_ua=pj.UAConfig()
cfg_ua.max_calls=32
cfg_ua.user_agent="TrashTalker/1.0"
cfg_media=pj.MediaConfig()
cfg_media.no_vad=True
cfg_media.enable_ice=False
lib.init(ua_cfg=cfg_ua, media_cfg=cfg_media,
log_cfg=pj.LogConfig(level=LOG_LEVEL, callback=pjlog))
lib.set_null_snd_dev()
lib.start(with_thread=True)
def PjMediaInit():
global transport
global acct
global sipport
global lib
transport=lib.create_transport(pj.TransportType.UDP,
pj.TransportConfig(sipport))
acct=lib.create_account_for_transport(transport, cb=AccountCb)
def TrashTalkerInit():
global mainloop
global sipuri
sipuri="sip:%s:%s" % (transport.info().host, transport.info().port)
while mainloop:
sleep(0.2)
def PjDeinit():
global transport
global acct
global sipport
global lib
lib.hangup_all()
acct.delete()
lib.destroy()
acct=None
transport=None
lib=None
def main():
olog(1, "init", "initialising trashtalker")
global mainloop
mainloop=True
global state
#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)
#Try to initialise media
try:
global files
files=listdir(sourcepath)
files[:]=[sourcepath+file for file in files]
assert (len(files) > 1), "Playlist path must contain more than one audio file"
olog(1, "playlist-load",
"load playlist from %s, got %s files" % (sourcepath, len(files)))
state.media_init()
assert (state.status==(State.States.media_init & State.States.done))
except:
elog(1, "playlist-load", "exception encountered while loading playlist from path %s" % sourcepath)
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:
PjInit()
state.init()
assert (state.status==(State.States.init & State.States.done))
except:
elog(1, "pj-init", "Unable to initialise pjsip library")
raise Exception("Unable to initialise pjsip library")
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")
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:
PjMediaInit()
except:
elog(1, "pj-media-init", "Unable to initialise pjsip media or transport")
raise Exception("Unable to initialise pjsip media or transport")
olog(1, "init-complete", "trashtalker listening on uri %s" % sipuri)
try:
TrashTalkerInit()
state.run()
except pj.Error as e:
elog(1, "pjsip-error", "trashtalker encountered pjsip exception %s" % str(e))
mainloop=False
state.Log(2, "pjsip-error", "trashtalker encountered pjsip exception %s" % str(e), error=True)
state.stop()
pass
olog(1, "deinit", "main loop exited, shutting down")
PjDeinit()
olog(1, "deinit-complete", "trashtalker has shut down")
lib=None
acct=None
transport=None
sipuri=None
mainloop=False
files=()
except KeyboardInterrupt:
state.stop()
pass
state.Log(1, "deinit", "main loop exited, shutting down")
state.deinit()
state.Log(1, "deinit", "trashtalker has shut down")
if __name__ == "__main__":
main()
main()

View File

@ -1,5 +1,5 @@
[Unit]
Description=TrashTalker
Description=TrashTalker (%i)
Wants=network.target
After=network.target
@ -8,6 +8,7 @@ KillMode=process
Type=simple
NotifyAccess=all
LimitNOFILE=8192
EnvironmentFile=/opt/.tt/%i.conf
ExecStart=/usr/local/bin/trashtalker.py
Restart=on-failure
User=nobody