diff --git a/sip-publish-presence b/sip-publish-presence index b19452e..fa58939 100755 --- a/sip-publish-presence +++ b/sip-publish-presence @@ -1,837 +1,839 @@ #!/usr/bin/env python3 import hashlib import os import random import select import sys import termios import traceback import uuid from collections import deque from datetime import datetime from optparse import OptionParser from threading import RLock, Thread from time import time from application import log from application.notification import IObserver, NotificationCenter, NotificationData from application.python.queue import EventQueue from eventlib.twistedutil import join_reactor from twisted.internet import reactor from twisted.internet.error import ReactorNotRunning from zope.interface import implementer -from sipsimple.core import Engine, FromHeader, Publication, PublicationError, RouteHeader, SIPCoreError, SIPURI +from sipsimple.core import Engine, FromHeader, Publication, PublicationError, RouteHeader, SIPCoreError, SIPURI, PJSIPError from sipsimple.account import Account, AccountManager, BonjourAccount from sipsimple.application import SIPApplication from sipsimple.configuration import ConfigurationError, ConfigurationManager from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.lookup import DNSLookup from sipsimple.payloads import BuilderError from sipsimple.payloads.pidf import Contact, Device, DeviceInfo, DMNote, DeviceTimestamp, Person, PIDFNote, PersonTimestamp, PIDF, Service, ServiceTimestamp, Status from sipsimple.payloads.rpid import Activities, ActivityRegistry, Mood, MoodRegistry, RPIDNote, TimeOffset from sipsimple.storage import FileStorage from sipsimple.threading import run_in_twisted_thread from sipclient.configuration import config_directory from sipclient.configuration.account import AccountExtension from sipclient.configuration.settings import SIPSimpleSettingsExtension from sipclient.log import Logger class KeyBinding(object): def __init__(self, description, handler): self.description = description self.handler = handler class Prompt(str): """Used to make a distinction between normal strings written to the console and prompts.""" class Menu(object): key_bindings = {} def __init__(self, interface): self.interface = interface def print_prompt(self): buf = ['Commands:'] for key, binding in list(self.key_bindings.items()): buf.append(' %s: %s' % (key, binding.description)) self.interface.application.output.put('\n'+'\n'.join(buf)+'\n') def _exit(self): self.interface.exit_menu() def _exit_program(self): self.interface.application.stop() # Mood manipulation menu class MoodMenu(Menu): def _show_moods(self): person = self.interface.application.person buf = ['Moods:'] if person.mood is not None: for m in list(person.mood): buf.append(' %s' % str(m)) self.interface.application.output.put('\n'.join(buf)) def _add_mood(self): person = self.interface.application.person buf = ['Possible moods:'] values = list(MoodRegistry.names) values.sort() max_len = max(len(s) for s in values)+2 format = ' %%02d) %%-%ds' % max_len num_line = 80/(max_len+5) i = 0 text = '' for val in values: text += format % (i+1, val) i += 1 if i % num_line == 0: buf.append(text) text = '' buf.append(text) self.interface.application.output.put('\n'.join(buf)+'\n') m = self.interface.getstring('Select mood to add (any non-number will return)') try: m = int(m) if m not in range(len(values)): raise ValueError except ValueError: self.interface.application.output.put('Invalid input') else: if person.mood is None: person.mood = Mood() person.mood.add(values[m-1]) person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.application.output.put('Mood added') self.interface.show_top_level() def _del_mood(self): person = self.interface.application.person if person.mood is None: self.interface.application.output.put('There is no current mood set') self.print_prompt() return buf = ['Current moods:'] values = list(person.mood) values.sort() max_len = max(len(s) for s in values)+2 format = " %%02d) %%-%ds" % max_len num_line = 80/(max_len+5) i = 0 text = '' for val in values: text += format % (i+1, val) i += 1 if i % num_line == 0: buf.append(text) text = '' buf.append(text) self.interface.application.output.put('\n'.join(buf)+'\n') m = self.interface.getstring('Select mood to delete (any non-number will return)') try: m = int(m) except ValueError: self.interface.application.output.put('Invalid input') else: person.mood.remove(values[m-1]) person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.application.output.put('Mood deleted') self.interface.show_top_level() def _clear_moods(self): person = self.interface.application.person if person.mood is None: self.interface.application.output.put('There is no current mood set') self.print_prompt() return person.mood = None person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.application.output.put('Mood information cleared') self.interface.show_top_level() def _set_note(self): person = self.interface.application.person if person.mood is not None and person.mood.notes: a_note = next(iter(person.mood.notes)) self.interface.application.output.put('Current note: %s' % a_note) note = self.interface.getstring('Set note (press return to delete)') if note == '': person.mood.notes.clear() self.interface.application.output.put('Note removed') else: if person.mood is None: person.mood = Mood() person.mood.notes.add(RPIDNote(note, lang='en')) self.interface.application.output.put('Note set') person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.show_top_level() def _set_random(self): person = self.interface.application.person values = list(value for value in MoodRegistry.names if value != 'unknown') random.shuffle(values) if person.mood is None: person.mood = Mood() else: person.mood.clear() values = values[:3] for mood in values: person.mood.add(mood) person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.application.output.put('You are now ' + ', '.join(values)) self.interface.show_top_level() key_bindings = {'s': KeyBinding(description='show current moods', handler=_show_moods), 'a': KeyBinding(description='add a mood', handler=_add_mood), 'd': KeyBinding(description='delete a mood', handler=_del_mood), 'c': KeyBinding(description='clear all mood data', handler=_clear_moods), 'n': KeyBinding(description='set mood note', handler=_set_note), 'r': KeyBinding(description='set random mood', handler=_set_random), 'x': KeyBinding(description='exit to upper level menu', handler=Menu._exit), 'q': KeyBinding(description='quit program', handler=Menu._exit_program)} # Activities manipulation menu class ActivitiesMenu(Menu): def _show_activity(self): person = self.interface.application.person buf = ["Activity:"] if person.activities is not None: for a in list(person.activities): buf.append(" %s" % str(a)) self.interface.application.output.put('\n'.join(buf)) def _set_activity(self): person = self.interface.application.person buf = ["Possible activities:"] values = list(ActivityRegistry.names) values.sort() max_len = max(len(s) for s in values)+2 format = " %%02d) %%-%ds" % max_len num_line = 80/(max_len+5) i = 0 text = '' for val in values: text += format % (i+1, val) i += 1 if i % num_line == 0: buf.append(text) text = '' self.interface.application.output.put('\n'.join(buf)+'\n') a = self.interface.getstring('Select activity to add (any non-number will return)') try: a = int(a) if a-1 not in range(len(values)): raise ValueError except ValueError: self.interface.application.output.put('Invalid input') else: if person.activities is None: person.activities = Activities() else: person.activities.clear() person.activities.add(values[a-1]) person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.application.output.put('Activity set') self.interface.show_top_level() def _del_activity(self): person = self.interface.application.person if person.activities is None or len(person.activities.values) == 0: self.interface.application.output.put('There is no current activity set') return person.activities.clear() person.activities.add('unknown') person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.application.output.put('Activity deleted') self.interface.show_top_level() def _clear_activity(self): person = self.interface.application.person if person.activities is None: self.interface.application.output.put('There is no current activity set') return person.activities = None person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.application.output.put('Activities information cleared') self.interface.show_top_level() def _set_note(self): person = self.interface.application.person if person.activities is not None and person.activities.notes: a_note = next(iter(person.activities.notes)) self.interface.application.output.put('Current note: %s' % a_note) note = self.interface.getstring('Set note (press return to delete)') if note == '': person.activities.notes.clear() self.interface.application.output.put('Note deleted') else: if person.activities is None: person.activities = Activities() person.activities.add('unknown') person.activities.notes.add(RPIDNote(note, lang='en')) self.interface.application.output.put('Note set') person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.show_top_level() def _set_random(self): person = self.interface.application.person values = list(value for value in ActivityRegistry.names if value != 'unknown') activity = random.choice(values) if person.activities is None: person.activities = Activities() else: person.activities.clear() person.activities.add(activity) person.timestamp = PersonTimestamp(datetime.now()) self.interface.application.publish() self.interface.application.output.put('You are now %s' % activity) self.interface.show_top_level() key_bindings = {'s': KeyBinding(description='show current activity', handler=_show_activity), 'a': KeyBinding(description='set activity', handler=_set_activity), 'd': KeyBinding(description='delete activity', handler=_del_activity), 'c': KeyBinding(description='clear all acitivity data', handler=_clear_activity), 'n': KeyBinding(description='set activity note', handler=_set_note), 'r': KeyBinding(description='set random activity', handler=_set_random), 'x': KeyBinding(description='exit to upper level menu', handler=Menu._exit), 'q': KeyBinding(description='quit program', handler=Menu._exit_program)} # Extended status manipulation menu class ExtendedStatusMenu(Menu): def _show_extended_status(self): service = self.interface.application.service buf = ["Extended status:"] if service.status.extended is not None: buf.append(" %s" % str(service.status.extended)) self.interface.application.output.put('\n'.join(buf)) def _set_extended_status(self): service = self.interface.application.service buf = ["Possible statuses:"] values = ['available', 'away', 'extended-away', 'busy', 'offline'] max_len = max(len(s) for s in values)+2 format = " %%02d) %%-%ds" % max_len num_line = 80/(max_len+5) i = 0 text = '' for val in values: text += format % (i+1, val) i += 1 if i % num_line == 0: buf.append(text) text = '' buf.append(text) self.interface.application.output.put('\n'.join(buf)+'\n') a = self.interface.getstring('Select status (any non-number will return)') try: a = int(a) if a-1 not in range(len(values)): raise ValueError except ValueError: self.interface.application.output.put('Invalid input') else: status = values[a-1] if status == 'offline': service.status.basic = 'closed' else: service.status.basic = 'open' service.status.extended = status self.interface.application.publish() self.interface.application.output.put('Extended atatus set') self.interface.show_top_level() def _del_extended_status(self): self.interface.application.service.status.extended = None self.interface.application.publish() self.interface.application.output.put('Activity deleted') self.interface.show_top_level() key_bindings = {'s': KeyBinding(description='show current extended status', handler=_show_extended_status), 'e': KeyBinding(description='set extended status', handler=_set_extended_status), 'd': KeyBinding(description='delete extended status', handler=_del_extended_status), 'x': KeyBinding(description='exit to upper level menu', handler=Menu._exit), 'q': KeyBinding(description='quit program', handler=Menu._exit_program)} class TopLevelMenu(Menu): def _show_pidf(self): try: pidf = self.interface.application.pidf.toxml(pretty_print=True) except BuilderError as e: print("PIDF as currently defined is invalid: %s" % str(e)) except: traceback.print_exc() else: self.interface.application.output.put(pidf) self.print_prompt() def _set_mood_info(self): mood_menu = MoodMenu(self.interface) self.interface.add_menu(mood_menu) def _set_activity_info(self): activities_menu = ActivitiesMenu(self.interface) self.interface.add_menu(activities_menu) def _toggle_basic(self): service = self.interface.application.service if service.status.basic == 'open': service.status.basic = 'closed' else: service.status.basic = 'open' service.timestamp = ServiceTimestamp(datetime.now()) self.interface.application.output.put("Your basic status is now '%s'" % service.status.basic) self.interface.application.publish() self.print_prompt() def _set_extended_status(self): extended_status_menu = ExtendedStatusMenu(self.interface) self.interface.add_menu(extended_status_menu) def _set_note(self): service = self.interface.application.service if service.notes: a_note = next(iter(service.notes)) self.interface.application.output.put('Current note: %s' % a_note) note = self.interface.getstring('Set note (press return to delete)') if note == '': if service.notes: service.notes.clear() self.interface.application.output.put('Note removed') else: service.notes.add(PIDFNote(note, lang='en')) self.interface.application.output.put('Note added') self.interface.application.publish() self.print_prompt() key_bindings = {'s': KeyBinding(description='show PIDF', handler=_show_pidf), 'm': KeyBinding(description='set mood information', handler=_set_mood_info), 'a': KeyBinding(description='set activities information', handler=_set_activity_info), 'b': KeyBinding(description='toggle basic status', handler=_toggle_basic), 'e': KeyBinding(description='set extended status', handler=_set_extended_status), 'n': KeyBinding(description='set note', handler=_set_note), 'q': KeyBinding(description='quit program', handler=Menu._exit_program)} class UserInterface(Thread): def __init__(self, application): Thread.__init__(self) self.application = application self.daemon = True self.menu_stack = deque([TopLevelMenu(self)]) self._old_terminal_settings = None def run(self): self.menu_stack[-1].print_prompt() notification_center = NotificationCenter() while True: for char in self._getchars(): menu = self.menu_stack[-1] if char == '\x04': self.application.stop() return elif char in menu.key_bindings: menu.key_bindings[char].handler(menu) else: notification_center.post_notification('SAInputWasReceived', sender=self, data=NotificationData(input=char)) def stop(self): self._termios_restore() def add_menu(self, menu): self.menu_stack.append(menu) menu.print_prompt() def show_top_level(self): main = self.menu_stack[0] self.menu_stack.clear() self.menu_stack.append(main) main.print_prompt() def exit_menu(self): if len(self.menu_stack) > 1: self.menu_stack.pop() self.menu_stack[-1].print_prompt() def getstring(self, prompt='selection'): self.application.output.put(Prompt(prompt)) return sys.stdin.readline().strip() def _termios_restore(self): if self._old_terminal_settings is not None: termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_terminal_settings) def _getchars(self): fd = sys.stdin.fileno() if os.isatty(fd): self._old_terminal_settings = termios.tcgetattr(fd) new = termios.tcgetattr(fd) new[3] = new[3] & ~termios.ICANON & ~termios.ECHO new[6][termios.VMIN] = b'\000' try: termios.tcsetattr(fd, termios.TCSADRAIN, new) if select.select([fd], [], [], None)[0]: return sys.stdin.read(4192) finally: self._termios_restore() else: return os.read(fd, 4192) @implementer(IObserver) class PublicationApplication(object): def __init__(self, account_name, trace_sip, trace_pjsip, trace_notifications): self.account_name = account_name self.interface = UserInterface(self) self.output = EventQueue(self._output_handler) self.logger = Logger(sip_to_stdout=trace_sip, pjsip_to_stdout=trace_pjsip, notifications_to_stdout=trace_notifications) self.lookup = DNSLookup() self.publication_lock = RLock() self.success = False self.account = None self.publication = None self.pidf = None self.service = None self.person = None self.device = None self.stopping = False self.publishing = False self._publication_routes = None self._publication_timeout = 0.0 self._publication_wait = 0.5 account_manager = AccountManager() engine = Engine() notification_center = NotificationCenter() notification_center.add_observer(self, sender=account_manager) notification_center.add_observer(self, sender=engine) notification_center.add_observer(self, sender=self.interface) notification_center.add_observer(self, sender=self.lookup) log.level.current = log.level.WARNING def run(self): account_manager = AccountManager() configuration = ConfigurationManager() engine = Engine() notification_center = NotificationCenter() # start output thread self.output.start() # startup configuration Account.register_extension(AccountExtension) BonjourAccount.register_extension(AccountExtension) SIPSimpleSettings.register_extension(SIPSimpleSettingsExtension) SIPApplication.storage = FileStorage(config_directory) try: configuration.start() except ConfigurationError as e: raise RuntimeError("Failed to load sipclient's configuration: %s\nIf an old configuration file is in place, delete it or move it and recreate the configuration using the sip_settings script." % str(e)) account_manager.load() if self.account_name is None: self.account = account_manager.default_account else: possible_accounts = [account for account in account_manager.iter_accounts() if self.account_name in account.id and account.enabled] if len(possible_accounts) > 1: raise RuntimeError("More than one account exists which matches %s: %s" % (self.account_name, ", ".join(sorted(account.id for account in possible_accounts)))) if len(possible_accounts) == 0: raise RuntimeError("No enabled account that matches %s was found. Available and enabled accounts: %s" % (self.account_name, ", ".join(sorted(account.id for account in account_manager.get_accounts() if account.enabled)))) self.account = possible_accounts[0] if self.account is None: raise RuntimeError("Unknown account %s. Available enabled accounts: %s" % (self.account_name, ', '.join(sorted(account.id for account in account_manager.iter_accounts() if account.enabled)))) elif self.account == BonjourAccount(): raise RuntimeError("Cannot use bonjour account to publish presence information") elif not self.account.presence.enabled: raise RuntimeError("Presence is not enabled for account %s" % self.account.id) for account in account_manager.iter_accounts(): if account == self.account: account.sip.register = False else: account.enabled = False self.output.put('Using account %s' % self.account.id) settings = SIPSimpleSettings() # start logging self.logger.start() # start the engine engine.start( udp_port=settings.sip.udp_port if "udp" in settings.sip.transport_list else None, tcp_port=settings.sip.tcp_port if "tcp" in settings.sip.transport_list else None, tls_port=settings.sip.tls_port if "tls" in settings.sip.transport_list else None, tls_verify_server=self.account.tls.verify_server, tls_ca_file=os.path.expanduser(settings.tls.ca_list) if settings.tls.ca_list else None, tls_cert_file=os.path.expanduser(self.account.tls.certificate) if self.account.tls.certificate else None, tls_privkey_file=os.path.expanduser(self.account.tls.certificate) if self.account.tls.certificate else None, user_agent=settings.user_agent, sample_rate=settings.audio.sample_rate, rtp_port_range=(settings.rtp.port_range.start, settings.rtp.port_range.end), trace_sip=settings.logs.trace_sip or self.logger.sip_to_stdout, log_level=settings.logs.pjsip_level if (settings.logs.trace_pjsip or self.logger.pjsip_to_stdout) else 0 ) # initialize pidf self.pidf = PIDF(entity=self.account.id) # entity will be determined when account is selected # initialize top level elements device_id = settings.instance_id self.service = Service("ID-"+str(uuid.UUID(hex=settings.instance_id))) self.service.status = Status(basic='open') self.service.status.extended = 'available' contact = "sip:%s;gr=%s" % (self.account.id, device_id) self.service.contact = Contact(contact) self.service.contact.priority = 0 self.service.device_id = device_id self.service.device_info = DeviceInfo(device_id) self.service.timestamp = ServiceTimestamp(datetime.now()) self.pidf.add(self.service) self.person = Person("ID-"+hashlib.md5(self.account.id.encode()).hexdigest()) self.person.time_offset = TimeOffset() self.person.timestamp = PersonTimestamp(datetime.now()) self.pidf.add(self.person) self.device = Device(''.join(chr(random.randint(97, 122)) for i in range(8))) self.device.device_id = device_id self.device.notes.add(DMNote('Powered by %s' % settings.user_agent, lang='en')) self.device.timestamp = DeviceTimestamp(datetime.now()) self.pidf.add(self.device) # start the interface thread self.interface.start() # initialize publication object self.publication = Publication(FromHeader(self.account.uri, self.account.display_name), "presence", "application/pidf+xml", credentials=self.account.credentials, duration=self.account.sip.publish_interval) notification_center.add_observer(self, sender=self.publication) reactor.callLater(0, self.publish) # start twisted try: reactor.run() finally: self.interface.stop() # stop the output self.output.stop() self.output.join() self.logger.stop() return 0 if self.success else 1 def stop(self): self.stopping = True if self.publication is not None: self.service.status.basic = 'closed' self.service.status.extended = 'offline' self.publish() else: engine = Engine() engine.stop() def print_help(self): message = 'Available control keys:\n' message += ' t: toggle SIP trace on the console\n' message += ' j: toggle PJSIP trace on the console\n' message += ' n: toggle notifications trace on the console\n' message += ' Ctrl-d: quit the program\n' message += ' ?: display this help message\n' self.output.put('\n'+message) def publish(self): with self.publication_lock: if self.publishing: return settings = SIPSimpleSettings() self._publication_timeout = time() + 30 if self.account.sip.outbound_proxy is not None: uri = SIPURI(host=self.account.sip.outbound_proxy.host, port=self.account.sip.outbound_proxy.port, parameters={'transport': self.account.sip.outbound_proxy.transport}) else: uri = SIPURI(host=self.account.id.domain) self.lookup.lookup_sip_proxy(uri, settings.sip.transport_list) self.publishing = True def unpublish(self): try: self.publication.end(timeout=1) return except PublicationError: pass engine = Engine() engine.stop() def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, None) if handler is not None: handler(notification) def _NH_SIPPublicationDidSucceed(self, notification): with self.publication_lock: self._publication_routes = None self._publication_wait = 0.5 self.success = True self.publishing = False if self.stopping: self.unpublish() return def _NH_SIPPublicationDidFail(self, notification): with self.publication_lock: self.success = False self.output.put('Publishing failed: %d %s' % (notification.data.code, notification.data.reason)) if notification.data.code in (401, 403, 407): self.publishing = False self.stop() else: if self.stopping: self.unpublish() return if not self._publication_routes or time() > self._publication_timeout: self._publication_wait = min(self._publication_wait*2, 30) timeout = random.uniform(self._publication_wait, 2*self._publication_wait) reactor.callFromThread(reactor.callLater, timeout, self.publish) self.publishing = False else: route = self._publication_routes.popleft() self._do_publish(route) def _NH_SIPPublicationWillExpire(self, notification): # For now, just re-publish the whole document instead of sending a refresh self.publish() def _NH_SIPPublicationDidNotEnd(self, notification): self.success = False engine = Engine() engine.stop() def _NH_SIPPublicationDidEnd(self, notification): if notification.data.expired: self.output.put('Publication expired') else: self.output.put('Unpublished') if self.stopping: self.success = True engine = Engine() engine.stop() else: self.publish() def _NH_DNSLookupDidSucceed(self, notification): with self.publication_lock: self._publication_routes = deque(notification.data.result) route = self._publication_routes.popleft() self._do_publish(route) def _NH_DNSLookupDidFail(self, notification): with self.publication_lock: self.output.put('DNS lookup failed: %s' % notification.data.error) timeout = random.uniform(1.0, 2.0) reactor.callLater(timeout, self.publish) self.publishing = False def _NH_SAInputWasReceived(self, notification): engine = Engine() settings = SIPSimpleSettings() key = notification.data.input if key == 't': self.logger.sip_to_stdout = not self.logger.sip_to_stdout engine.trace_sip = self.logger.sip_to_stdout or settings.logs.trace_sip self.output.put('SIP tracing to console is now %s.' % ('activated' if self.logger.sip_to_stdout else 'deactivated')) elif key == 'j': self.logger.pjsip_to_stdout = not self.logger.pjsip_to_stdout engine.log_level = settings.logs.pjsip_level if (self.logger.pjsip_to_stdout or settings.logs.trace_pjsip) else 0 self.output.put('PJSIP tracing to console is now %s.' % ('activated' if self.logger.pjsip_to_stdout else 'deactivated')) elif key == 'n': self.logger.notifications_to_stdout = not self.logger.notifications_to_stdout self.output.put('Notification tracing to console is now %s.' % ('activated' if self.logger.notifications_to_stdout else 'deactivated')) elif key == '?': self.print_help() @run_in_twisted_thread def _NH_SIPEngineDidEnd(self, notification): self._stop_reactor() @run_in_twisted_thread def _NH_SIPEngineDidFail(self, notification): self.output.put('Engine failed.') self._stop_reactor() def _NH_SIPEngineGotException(self, notification): self.output.put('An exception occured within the SIP core:\n'+notification.data.traceback) def _stop_reactor(self): try: reactor.stop() except ReactorNotRunning: pass def _do_publish(self, route): try: route_header = RouteHeader(route.uri) self.publication.publish(self.pidf.toxml(), route_header, timeout=5) except BuilderError as e: self.output.put("PIDF as currently defined is invalid: %s" % str(e)) self.publishing = False + except PJSIPError as e: + self.output.put("Publish failed: %s" % str(e)) except: traceback.print_exc() self.publishing = False def _output_handler(self, event): if isinstance(event, Prompt): sys.stdout.write(event+'> ') sys.stdout.flush() else: sys.stdout.write(event+'\n') if __name__ == "__main__": description = "This script publishes the presence event package to a SIP Presence Agent for the given SIP account, the presence information can be changed using a menu-driven interface." usage = "%prog [options]" parser = OptionParser(usage=usage, description=description) parser.print_usage = parser.print_help parser.add_option("-a", "--account-name", type="string", dest="account_name", help="The name of the account to use.") parser.add_option("-s", "--trace-sip", action="store_true", dest="trace_sip", default=False, help="Dump the raw contents of incoming and outgoing SIP messages (disabled by default).") parser.add_option("-j", "--trace-pjsip", action="store_true", dest="trace_pjsip", default=False, help="Print PJSIP logging output (disabled by default).") parser.add_option("-n", "--trace-notifications", action="store_true", dest="trace_notifications", default=False, help="Print all notifications (disabled by default).") options, args = parser.parse_args() try: application = PublicationApplication(options.account_name, options.trace_sip, options.trace_pjsip, options.trace_notifications) return_code = application.run() except RuntimeError as e: print("Error: %s" % str(e)) sys.exit(1) except SIPCoreError as e: print("Error: %s" % str(e)) sys.exit(1) else: sys.exit(return_code) diff --git a/sip-subscribe-presence b/sip-subscribe-presence index b412ddd..d1d86e2 100755 --- a/sip-subscribe-presence +++ b/sip-subscribe-presence @@ -1,620 +1,625 @@ #!/usr/bin/env python3 import datetime import os import random import select import sys import termios import urllib.request, urllib.parse, urllib.error from collections import deque from optparse import OptionParser from threading import Thread from time import time from application import log from application.notification import IObserver, NotificationCenter, NotificationData from application.python.queue import EventQueue from eventlib.twistedutil import join_reactor from twisted.internet import reactor from twisted.internet.error import ReactorNotRunning from zope.interface import implementer from sipsimple.account import Account, AccountManager, BonjourAccount from sipsimple.application import SIPApplication -from sipsimple.lookup import DNSLookup +from sipsimple.lookup import DNSLookup, DNSManager from sipsimple.configuration import ConfigurationError, ConfigurationManager from sipsimple.configuration.settings import SIPSimpleSettings -from sipsimple.core import ContactHeader, Engine, FromHeader, RouteHeader, SIPCoreError, SIPURI, Subscription, ToHeader, Route +from sipsimple.core import ContactHeader, Engine, FromHeader, RouteHeader, SIPCoreError, SIPURI, Subscription, ToHeader, Route, PJSIPError from sipsimple.payloads import ParserError from sipsimple.payloads import rpid # needed to register RPID extensions from sipsimple.payloads.pidf import Device, Person, Service, PIDF, PIDFDocument from sipsimple.storage import FileStorage from sipsimple.threading import run_in_twisted_thread from sipclient.configuration import config_directory from sipclient.configuration.account import AccountExtension from sipclient.configuration.settings import SIPSimpleSettingsExtension from sipclient.log import Logger class InputThread(Thread): def __init__(self, application): Thread.__init__(self) self.application = application self.daemon = True self._old_terminal_settings = None def run(self): notification_center = NotificationCenter() while True: for char in self._getchars(): if char == "\x04": self.application.stop() return else: notification_center.post_notification('SAInputWasReceived', sender=self, data=NotificationData(input=char)) def stop(self): self._termios_restore() def _termios_restore(self): if self._old_terminal_settings is not None: termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_terminal_settings) def _getchars(self): fd = sys.stdin.fileno() if os.isatty(fd): self._old_terminal_settings = termios.tcgetattr(fd) new = termios.tcgetattr(fd) new[3] = new[3] & ~termios.ICANON & ~termios.ECHO new[6][termios.VMIN] = b'\000' try: termios.tcsetattr(fd, termios.TCSADRAIN, new) if select.select([fd], [], [], None)[0]: return sys.stdin.read(4192) finally: self._termios_restore() else: return os.read(fd, 4192) @implementer(IObserver) class SubscriptionApplication(object): def __init__(self, account_name, target, trace_sip, trace_pjsip, trace_notifications): self.account_name = account_name self.target = target self.input = InputThread(self) self.output = EventQueue(self._write) self.logger = Logger(sip_to_stdout=trace_sip, pjsip_to_stdout=trace_pjsip, notifications_to_stdout=trace_notifications) self.success = False self.account = None self.subscription = None self.stopping = False self._subscription_routes = None self._subscription_timeout = 0.0 self._subscription_wait = 0.5 account_manager = AccountManager() engine = Engine() notification_center = NotificationCenter() notification_center.add_observer(self, sender=account_manager) notification_center.add_observer(self, sender=engine) notification_center.add_observer(self, sender=self.input) log.level.current = log.level.WARNING def _write(self, message): if isinstance(message, str): message = message.encode(sys.getfilesystemencoding()) sys.stdout.write(message.decode()+'\n') def run(self): account_manager = AccountManager() configuration = ConfigurationManager() - engine = Engine() + self.engine = Engine() # start output thread self.output.start() # startup configuration Account.register_extension(AccountExtension) BonjourAccount.register_extension(AccountExtension) SIPSimpleSettings.register_extension(SIPSimpleSettingsExtension) SIPApplication.storage = FileStorage(config_directory) try: configuration.start() except ConfigurationError as e: raise RuntimeError("failed to load sipclient's configuration: %s\nIf an old configuration file is in place, delete it or move it and recreate the configuration using the sip_settings script." % str(e)) account_manager.load() if self.account_name is None: self.account = account_manager.default_account else: possible_accounts = [account for account in account_manager.iter_accounts() if self.account_name in account.id and account.enabled] if len(possible_accounts) > 1: raise RuntimeError("More than one account exists which matches %s: %s" % (self.account_name, ", ".join(sorted(account.id for account in possible_accounts)))) if len(possible_accounts) == 0: raise RuntimeError("No enabled account that matches %s was found. Available and enabled accounts: %s" % (self.account_name, ", ".join(sorted(account.id for account in account_manager.get_accounts() if account.enabled)))) self.account = possible_accounts[0] if self.account is None: raise RuntimeError("Unknown account %s. Available accounts: %s" % (self.account_name, ', '.join(account.id for account in account_manager.iter_accounts()))) elif self.account == BonjourAccount(): raise RuntimeError("Cannot use bonjour account for presence subscription") elif not self.account.presence.enabled: raise RuntimeError("Presence is not enabled for account %s" % self.account.id) for account in account_manager.iter_accounts(): if account == self.account: account.sip.register = False else: account.enabled = False self.output.put('Using account %s' % self.account.id) settings = SIPSimpleSettings() # start logging self.logger.start() # start the engine - engine.start( + self.engine.start( auto_sound=False, - events={'presence': [PIDFDocument.content_type]}, + events={b"presence": [PIDFDocument.content_type.encode()]}, udp_port=settings.sip.udp_port if "udp" in settings.sip.transport_list else None, tcp_port=settings.sip.tcp_port if "tcp" in settings.sip.transport_list else None, tls_port=settings.sip.tls_port if "tls" in settings.sip.transport_list else None, tls_verify_server=self.account.tls.verify_server, tls_ca_file=os.path.expanduser(settings.tls.ca_list) if settings.tls.ca_list else None, tls_cert_file=os.path.expanduser(self.account.tls.certificate) if self.account.tls.certificate else None, tls_privkey_file=os.path.expanduser(self.account.tls.certificate) if self.account.tls.certificate else None, user_agent=settings.user_agent, sample_rate=settings.audio.sample_rate, rtp_port_range=(settings.rtp.port_range.start, settings.rtp.port_range.end), trace_sip=settings.logs.trace_sip or self.logger.sip_to_stdout, log_level=settings.logs.pjsip_level if (settings.logs.trace_pjsip or self.logger.pjsip_to_stdout) else 0 ) if self.target is None: self.target = ToHeader(SIPURI(user=self.account.id.username, host=self.account.id.domain)) else: if '@' not in self.target: self.target = '%s@%s' % (self.target, self.account.id.domain) if not self.target.startswith('sip:') and not self.target.startswith('sips:'): self.target = 'sip:' + self.target try: self.target = ToHeader(SIPURI.parse(self.target)) except SIPCoreError: self.output.put('Illegal SIP URI: %s' % self.target) - engine.stop() + self.engine.stop() return 1 self.output.put('Subscribing to %s for the presence event' % self.target.uri) # start the input thread self.input.start() reactor.callLater(0, self._subscribe) # start twisted try: reactor.run() finally: self.input.stop() # stop the output self.output.stop() self.output.join() self.logger.stop() return 0 if self.success else 1 def stop(self): self.stopping = True if self.subscription is not None and self.subscription.state.lower() in ('accepted', 'pending', 'active'): self.subscription.end(timeout=1) else: - engine = Engine() - engine.stop() + self.engine.stop() def print_help(self): message = 'Available control keys:\n' message += ' t: toggle SIP trace on the console\n' message += ' j: toggle PJSIP trace on the console\n' message += ' n: toggle notifications trace on the console\n' message += ' Ctrl-d: quit the program\n' message += ' ?: display this help message\n' self.output.put('\n'+message) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, None) if handler is not None: handler(notification) + def _NH_SIPEngineDidStart(self, notification): + dns_manager = DNSManager() + self.engine.set_nameservers(dns_manager.nameservers) + def _NH_SIPSubscriptionDidStart(self, notification): route = Route(notification.sender.route_header.uri.host, notification.sender.route_header.uri.port, notification.sender.route_header.uri.parameters.get('transport', 'udp')) self._subscription_routes = None self._subscription_wait = 0.5 self.output.put('Subscription succeeded at %s:%d;transport=%s' % (route.address, route.port, route.transport)) self.success = True def _NH_SIPSubscriptionChangedState(self, notification): route = Route(notification.sender.route_header.uri.host, notification.sender.route_header.uri.port, notification.sender.route_header.uri.parameters.get('transport', 'udp')) if notification.data.state.lower() == "pending": self.output.put('Subscription pending at %s:%d;transport=%s' % (route.address, route.port, route.transport)) elif notification.data.state.lower() == "active": self.output.put('Subscription active at %s:%d;transport=%s' % (route.address, route.port, route.transport)) def _NH_SIPSubscriptionDidEnd(self, notification): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=notification.sender) self.subscription = None route = Route(notification.sender.route_header.uri.host, notification.sender.route_header.uri.port, notification.sender.route_header.uri.parameters.get('transport', 'udp')) self.output.put('Unsubscribed from %s:%d;transport=%s' % (route.address, route.port, route.transport)) self.stop() def _NH_SIPSubscriptionDidFail(self, notification): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=notification.sender) self.subscription = None route = Route(notification.sender.route_header.uri.host, notification.sender.route_header.uri.port, notification.sender.route_header.uri.parameters.get('transport', 'udp')) if notification.data.code: status = ': %d %s' % (notification.data.code, notification.data.reason) else: status = ': %s' % notification.data.reason self.output.put('Subscription failed at %s:%d;transport=%s%s' % (route.address, route.port, route.transport, status)) if self.stopping or notification.data.code in (401, 403, 407) or self.success: self.success = False self.stop() else: if not self._subscription_routes or time() > self._subscription_timeout: self._subscription_wait = min(self._subscription_wait*2, 30) timeout = random.uniform(self._subscription_wait, 2*self._subscription_wait) reactor.callFromThread(reactor.callLater, timeout, self._subscribe) else: route = self._subscription_routes.popleft() route_header = RouteHeader(route.uri) self.subscription = Subscription(self.target.uri, FromHeader(self.account.uri, self.account.display_name), self.target, ContactHeader(self.account.contact[route]), - "presence", + b"presence", route_header, credentials=self.account.credentials, refresh=self.account.sip.subscribe_interval) notification_center.add_observer(self, sender=self.subscription) self.subscription.subscribe(timeout=5) def _NH_SIPSubscriptionGotNotify(self, notification): if notification.data.content_type == PIDFDocument.content_type: self.output.put('Received NOTIFY:') try: pidf = PIDF.parse(notification.data.body) except ParserError as e: self.output.put('Got illegal PIDF document: %s\n%s' % (str(e), notification.data.body)) else: self._display_pidf(pidf) self.print_help() def _NH_DNSLookupDidSucceed(self, notification): # create subscription and register to get notifications from it self._subscription_routes = deque(notification.data.result) route = self._subscription_routes.popleft() route_header = RouteHeader(route.uri) self.subscription = Subscription(self.target.uri, FromHeader(self.account.uri, self.account.display_name), self.target, ContactHeader(self.account.contact[route]), - "presence", + b"presence", route_header, credentials=self.account.credentials, refresh=self.account.sip.subscribe_interval) notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.subscription) - self.subscription.subscribe(timeout=5) + try: + self.subscription.subscribe(timeout=5) + except PJSIPError as e: + self.output.put('Subscription failed: %s' % str(e)) def _NH_DNSLookupDidFail(self, notification): self.output.put('DNS lookup failed: %s' % notification.data.error) timeout = random.uniform(1.0, 2.0) reactor.callLater(timeout, self._subscribe) def _NH_SAInputWasReceived(self, notification): - engine = Engine() settings = SIPSimpleSettings() key = notification.data.input if key == 't': self.logger.sip_to_stdout = not self.logger.sip_to_stdout - engine.trace_sip = self.logger.sip_to_stdout or settings.logs.trace_sip + self.engine.trace_sip = self.logger.sip_to_stdout or settings.logs.trace_sip self.output.put('SIP tracing to console is now %s.' % ('activated' if self.logger.sip_to_stdout else 'deactivated')) elif key == 'j': self.logger.pjsip_to_stdout = not self.logger.pjsip_to_stdout - engine.log_level = settings.logs.pjsip_level if (self.logger.pjsip_to_stdout or settings.logs.trace_pjsip) else 0 + self.engine.log_level = settings.logs.pjsip_level if (self.logger.pjsip_to_stdout or settings.logs.trace_pjsip) else 0 self.output.put('PJSIP tracing to console is now %s.' % ('activated' if self.logger.pjsip_to_stdout else 'deactivated')) elif key == 'n': self.logger.notifications_to_stdout = not self.logger.notifications_to_stdout self.output.put('Notification tracing to console is now %s.' % ('activated' if self.logger.notifications_to_stdout else 'deactivated')) elif key == '?': self.print_help() @run_in_twisted_thread def _NH_SIPEngineDidEnd(self, notification): self._stop_reactor() @run_in_twisted_thread def _NH_SIPEngineDidFail(self, notification): self.output.put('Engine failed.') self._stop_reactor() def _NH_SIPEngineGotException(self, notification): self.output.put('An exception occured within the SIP core:\n'+notification.data.traceback) def _stop_reactor(self): try: reactor.stop() except ReactorNotRunning: pass def _subscribe(self): settings = SIPSimpleSettings() self._subscription_timeout = time()+30 lookup = DNSLookup() notification_center = NotificationCenter() notification_center.add_observer(self, sender=lookup) if self.account.sip.outbound_proxy is not None: uri = SIPURI(host=self.account.sip.outbound_proxy.host, port=self.account.sip.outbound_proxy.port, parameters={'transport': self.account.sip.outbound_proxy.transport}) elif self.account.sip.always_use_my_proxy: uri = SIPURI(host=self.account.id.domain) else: uri = self.target.uri lookup.lookup_sip_proxy(uri, settings.sip.transport_list) def _format_note(self, note): text = "Note" if note.lang is not None: text += "(%s)" % note.lang text += ": %s" % note return text def _format_person(self, person, pidf): buf = [] # display class if person.rpid_class is not None: buf.append(" Class: %s" % person.rpid_class) # display timestamp if person.timestamp is not None: buf.append(" Timestamp: %s" % person.timestamp) # display notes if person.notes: for note in person.notes: buf.append(" %s" % self._format_note(note)) elif pidf.notes: for note in pidf.notes: buf.append(" %s" % self._format_note(note)) # display activities if person.activities is not None: activities = list(person.activities) if len(activities) > 0: text = " Activities" if person.activities.since is not None or person.activities.until is not None: text += " valid" if person.activities.since is not None: text += " from %s" % person.activities.since if person.activities.until is not None: text += " until %s" % person.activities.until text += ": %s" % ', '.join(str(activity) for activity in activities) buf.append(text) if len(person.activities.notes) > 0: for note in person.activities.notes: buf.append(" %s" % self._format_note(note)) elif len(person.activities.notes) > 0: buf.append(" Activities") for note in person.activities.notes: buf.append(" %s" % self._format_note(note)) # display mood if person.mood is not None: moods = list(person.mood) if len(moods) > 0: text = " Mood" if person.mood.since is not None or person.mood.until is not None: text += " valid" if person.mood.since is not None: text += " from %s" % person.mood.since if person.mood.until is not None: text += " until %s" % person.mood.until text += ": %s" % ', '.join(str(mood) for mood in moods) buf.append(text) if len(person.mood.notes) > 0: for note in person.mood.notes: buf.append(" %s" % self._format_note(note)) # display place is if person.place_is is not None: place_info = ', '.join('%s %s' % (key.capitalize(), getattr(person.place_is, key).value) for key in ('audio', 'video', 'text') if getattr(person.place_is, key) and getattr(person.place_is, key).value) if place_info != '': buf.append(" Place information: " + place_info) # display privacy if person.privacy is not None: text = " Private conversation possible with: " private = [] if person.privacy.audio: private.append("Audio") if person.privacy.video: private.append("Video") if person.privacy.text: private.append("Text") if len(private) > 0: text += ", ".join(private) else: text += "None" buf.append(text) # display sphere if person.sphere is not None: timeinfo = [] if person.sphere.since is not None: timeinfo.append('from %s' % str(person.sphere.since)) if person.sphere.until is not None: timeinfo.append('until %s' % str(person.sphere.until)) if len(timeinfo) != 0: timeinfo = ' (' + ', '.join(timeinfo) + ')' else: timeinfo = '' buf.append(" Current sphere%s: %s" % (timeinfo, person.sphere.value)) # display status icon if person.status_icon is not None: buf.append(" Status icon: %s" % person.status_icon) # display time and time offset if person.time_offset is not None: ctime = datetime.datetime.utcnow() + datetime.timedelta(minutes=int(person.time_offset)) time_offset = int(person.time_offset)/60.0 if time_offset == int(time_offset): offset_info = '(UTC+%d%s)' % (time_offset, (person.time_offset.description is not None and (' (%s)' % person.time_offset.description) or '')) else: offset_info = '(UTC+%.1f%s)' % (time_offset, (person.time_offset.description is not None and (' (%s)' % person.time_offset.description) or '')) buf.append(" Current user time: %s %s" % (ctime.strftime("%H:%M"), offset_info)) # display user input if person.user_input is not None: buf.append(" User is %s" % person.user_input) if person.user_input.last_input: buf.append(" Last input at: %s" % person.user_input.last_input) if person.user_input.idle_threshold: buf.append(" Idle threshold: %s seconds" % person.user_input.idle_threshold) return buf def _format_service(self, service, pidf): buf = [] # display class if service.rpid_class is not None: buf.append(" Class: %s" % service.rpid_class) # display timestamp if service.timestamp is not None: buf.append(" Timestamp: %s" % service.timestamp) # display notes for note in service.notes: buf.append(" %s" % self._format_note(note)) # display status if service.status is not None: if service.status.basic is not None: buf.append(" Basic status: %s" % service.status.basic) if service.status.extended is not None: buf.append(" Extended status: %s" % service.status.extended) # display contact if service.contact is not None: buf.append(" Contact%s: %s" % ((service.contact.priority is not None) and (' priority %s' % service.contact.priority) or '', urllib.parse.unquote(service.contact.value))) # display device ID if service.device_info is not None: description = " (%s)" % urllib.parse.unquote(service.device_info.description.value).decode('utf-8') if service.device_info.description else "" buf.append(" Service offered by device: %s%s" % (service.device_info.id, description)) # display relationship if service.relationship is not None: buf.append(" Relationship: %s" % service.relationship.value) # display service-class if service.service_class is not None: buf.append(" Service class: %s" % service.service_class.value) # display status icon if service.status_icon is not None: buf.append(" Status icon: %s" % service.status_icon) # display user input if service.user_input is not None: buf.append(" Service is %s" % service.user_input) if service.user_input.last_input: buf.append(" Last input at: %s" % service.user_input.last_input) if service.user_input.idle_threshold: buf.append(" Idle threshold: %s seconds" % service.user_input.idle_threshold) return buf def _format_device(self, device, pidf): buf = [] # display device ID if device.device_id is not None: buf.append(" Device id: %s" % device.device_id) # display class if device.rpid_class is not None: buf.append(" Class: %s" % device.rpid_class) # display timestamp if device.timestamp is not None: buf.append(" Timestamp: %s" % device.timestamp) # display notes for note in device.notes: buf.append(" %s" % self._format_note(note)) # display user input if device.user_input is not None: buf.append(" Device is %s" % device.user_input) if device.user_input.last_input: buf.append(" Last input at: %s" % device.user_input.last_input) if device.user_input.idle_threshold: buf.append(" Idle threshold: %s seconds" % device.user_input.idle_threshold) return buf def _display_pidf(self, pidf): buf = ["-"*16] buf.append("Presence for %s:" % urllib.parse.unquote(pidf.entity)) persons = {} devices = {} services = {} printed_sep = True for child in pidf: if isinstance(child, Person): persons[child.id] = child elif isinstance(child, Device): devices[child.id] = child elif isinstance(child, Service): services[child.id] = child # handle person information if len(persons) == 0: if list(pidf.notes): buf.append(" Person information:") for note in pidf.notes: buf.append(" %s" % self._format_note(note)) printed_sep = False else: for person in list(persons.values()): buf.append(" Person: %s" % person.id) buf.extend(self._format_person(person, pidf)) printed_sep = False # handle services informaation if len(services) > 0: if not printed_sep: buf.append(" " + "-"*3) for service in list(services.values()): buf.append(" Service: %s" % service.id) buf.extend(self._format_service(service, pidf)) # handle devices informaation if len(devices) > 0: if not printed_sep: buf.append(" " + "-"*3) for device in list(devices.values()): buf.append(" Device: %s" % device.id) buf.extend(self._format_device(device, pidf)) buf.append("-"*16) # push the data self.output.put('\n'.join(buf)) if __name__ == "__main__": description = "This script subscribes to the presence event package published by the specified SIP target. If a SIP target is not specified, it will subscribe to its own address. It will then interprete PIDF bodies contained in NOTIFYs and display their meaning. The program will un-SUBSCRIBE and quit when CTRL+D is pressed." usage = "%prog [options] [target-user@target-domain.com]" parser = OptionParser(usage=usage, description=description) parser.print_usage = parser.print_help parser.add_option("-a", "--account-name", type="string", dest="account_name", help="The name of the account to use.") parser.add_option("-s", "--trace-sip", action="store_true", dest="trace_sip", default=False, help="Dump the raw contents of incoming and outgoing SIP messages (disabled by default).") parser.add_option("-j", "--trace-pjsip", action="store_true", dest="trace_pjsip", default=False, help="Print PJSIP logging output (disabled by default).") parser.add_option("-n", "--trace-notifications", action="store_true", dest="trace_notifications", default=False, help="Print all notifications (disabled by default).") options, args = parser.parse_args() try: application = SubscriptionApplication(options.account_name, args[0] if args else None, options.trace_sip, options.trace_pjsip, options.trace_notifications) return_code = application.run() except RuntimeError as e: print("Error: %s" % str(e)) sys.exit(1) except SIPCoreError as e: print("Error: %s" % str(e)) sys.exit(1) else: sys.exit(return_code)