Compare commits
11 Commits
Author | SHA1 | Date |
---|---|---|
Maff | 0b7849b016 | |
Maff | c86ec2c23c | |
Matthew Connelly | afb2ac83b6 | |
Matthew Connelly | 8ff683ef6d | |
Matthew Connelly | 16dad09773 | |
Matthew Connelly | 0e8e5b5be5 | |
Matthew Connelly | f5e5f710d4 | |
Matthew Connelly | c06c725a7e | |
Matthew Connelly | aab1198ec2 | |
Matthew Connelly | eecf8ed026 | |
Matthew Connelly | 0e4bc692ab |
29
README.md
29
README.md
|
@ -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
|
||||
|
|
292
trashtalker.py
292
trashtalker.py
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue