diff --git a/sipsimple/account/__init__.py b/sipsimple/account/__init__.py index cf70b834..4a43c855 100644 --- a/sipsimple/account/__init__.py +++ b/sipsimple/account/__init__.py @@ -1,846 +1,843 @@ """ Implements a SIP Account management system that allows the definition of multiple SIP accounts and their properties. """ __all__ = ['Account', 'BonjourAccount', 'AccountManager'] from itertools import chain from threading import Lock from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.decorator import execute_once from application.python.descriptor import classproperty from application.python.types import Singleton from application.system import host as Host from eventlib import coros, proc from gnutls.crypto import X509Certificate, X509PrivateKey from gnutls.interfaces.twisted import X509Credentials -from zope.interface import implements +from zope.interface import implementer from sipsimple.account.bonjour import BonjourServices, _bonjour from sipsimple.account.publication import PresencePublisher, DialogPublisher from sipsimple.account.registration import Registrar from sipsimple.account.subscription import MWISubscriber, PresenceWinfoSubscriber, DialogWinfoSubscriber, PresenceSubscriber, SelfPresenceSubscriber, DialogSubscriber from sipsimple.account.xcap import XCAPManager from sipsimple.core import Credentials, SIPURI, ContactURIFactory from sipsimple.configuration import ConfigurationManager, Setting, SettingsGroup, SettingsObject, SettingsObjectID from sipsimple.configuration.datatypes import AudioCodecList, MSRPConnectionModel, MSRPRelayAddress, MSRPTransport, NonNegativeInteger, Path, SIPAddress, SIPProxyAddress, SRTPKeyNegotiation, STUNServerAddressList, VideoCodecList, XCAPRoot from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.payloads import ParserError from sipsimple.payloads.messagesummary import MessageSummary from sipsimple.payloads.pidf import PIDFDocument from sipsimple.payloads.rlsnotify import RLSNotify from sipsimple.payloads.watcherinfo import WatcherInfoDocument from sipsimple.threading import call_in_thread from sipsimple.threading.green import call_in_green_thread, run_in_green_thread from sipsimple.util import user_info class AuthSettings(SettingsGroup): username = Setting(type=str, default=None, nillable=True) password = Setting(type=str, default='') class SIPSettings(SettingsGroup): always_use_my_proxy = Setting(type=bool, default=False) outbound_proxy = Setting(type=SIPProxyAddress, default=None, nillable=True) register = Setting(type=bool, default=True) register_interval = Setting(type=NonNegativeInteger, default=3600) subscribe_interval = Setting(type=NonNegativeInteger, default=3600) publish_interval = Setting(type=NonNegativeInteger, default=3600) class SRTPEncryptionSettings(SettingsGroup): enabled = Setting(type=bool, default=True) key_negotiation = Setting(type=SRTPKeyNegotiation, default='opportunistic') class RTPSettings(SettingsGroup): audio_codec_list = Setting(type=AudioCodecList, default=None, nillable=True) video_codec_list = Setting(type=VideoCodecList, default=None, nillable=True) encryption = SRTPEncryptionSettings class NATTraversalSettings(SettingsGroup): use_ice = Setting(type=bool, default=False) stun_server_list = Setting(type=STUNServerAddressList, default=None, nillable=True) msrp_relay = Setting(type=MSRPRelayAddress, default=None, nillable=True) use_msrp_relay_for_outbound = Setting(type=bool, default=False) class MessageSummarySettings(SettingsGroup): enabled = Setting(type=bool, default=False) voicemail_uri = Setting(type=SIPAddress, default=None, nillable=True) class XCAPSettings(SettingsGroup): enabled = Setting(type=bool, default=False) discovered = Setting(type=bool, default=False) xcap_root = Setting(type=XCAPRoot, default=None, nillable=True) class PresenceSettings(SettingsGroup): enabled = Setting(type=bool, default=False) class TLSSettings(SettingsGroup): certificate = Setting(type=Path, default=None, nillable=True) verify_server = Setting(type=bool, default=False) class MSRPSettings(SettingsGroup): transport = Setting(type=MSRPTransport, default='tls') connection_model = Setting(type=MSRPConnectionModel, default='relay') +@implementer(IObserver) class Account(SettingsObject): """ Object representing a SIP account. Contains configuration settings and attributes for accessing SIP related objects. When the account is active, it will register, publish its presence and subscribe to watcher-info events depending on its settings. If the object is un-pickled and its enabled flag was set, it will automatically activate. When the save method is called, depending on the value of the enabled flag, the account will activate/deactivate. Notifications sent by instances of Account: * CFGSettingsObjectWasCreated * CFGSettingsObjectWasActivated * CFGSettingsObjectWasDeleted * CFGSettingsObjectDidChange * SIPAccountWillActivate * SIPAccountDidActivate * SIPAccountWillDeactivate * SIPAccountDidDeactivate """ - implements(IObserver) - __group__ = 'Accounts' __id__ = SettingsObjectID(type=SIPAddress) id = __id__ enabled = Setting(type=bool, default=False) display_name = Setting(type=str, default=None, nillable=True) auth = AuthSettings sip = SIPSettings rtp = RTPSettings nat_traversal = NATTraversalSettings message_summary = MessageSummarySettings msrp = MSRPSettings presence = PresenceSettings xcap = XCAPSettings tls = TLSSettings def __new__(cls, id): with AccountManager.load.lock: if not AccountManager.load.called: raise RuntimeError("cannot instantiate %s before calling AccountManager.load" % cls.__name__) return SettingsObject.__new__(cls, id) def __init__(self, id): self.contact = ContactURIFactory() self.xcap_manager = XCAPManager(self) self._started = False self._deleted = False self._active = False self._activation_lock = coros.Semaphore(1) self._registrar = Registrar(self) self._mwi_subscriber = MWISubscriber(self) self._pwi_subscriber = PresenceWinfoSubscriber(self) self._dwi_subscriber = DialogWinfoSubscriber(self) self._presence_subscriber = PresenceSubscriber(self) self._self_presence_subscriber = SelfPresenceSubscriber(self) self._dialog_subscriber = DialogSubscriber(self) self._presence_publisher = PresencePublisher(self) self._dialog_publisher = DialogPublisher(self) self._mwi_voicemail_uri = None self._pwi_version = None self._dwi_version = None self._presence_version = None self._dialog_version = None def start(self): if self._started or self._deleted: return self._started = True notification_center = NotificationCenter() notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=self) notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) notification_center.add_observer(self, name='XCAPManagerDidDiscoverServerCapabilities', sender=self.xcap_manager) notification_center.add_observer(self, sender=self._mwi_subscriber) notification_center.add_observer(self, sender=self._pwi_subscriber) notification_center.add_observer(self, sender=self._dwi_subscriber) notification_center.add_observer(self, sender=self._presence_subscriber) notification_center.add_observer(self, sender=self._self_presence_subscriber) notification_center.add_observer(self, sender=self._dialog_subscriber) self.xcap_manager.init() if self.enabled: self._activate() def stop(self): if not self._started: return self._started = False self._deactivate() notification_center = NotificationCenter() notification_center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=self) notification_center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) notification_center.remove_observer(self, name='XCAPManagerDidDiscoverServerCapabilities', sender=self.xcap_manager) notification_center.remove_observer(self, sender=self._mwi_subscriber) notification_center.remove_observer(self, sender=self._pwi_subscriber) notification_center.remove_observer(self, sender=self._dwi_subscriber) notification_center.remove_observer(self, sender=self._presence_subscriber) notification_center.remove_observer(self, sender=self._self_presence_subscriber) notification_center.remove_observer(self, sender=self._dialog_subscriber) def delete(self): if self._deleted: return self._deleted = True self.stop() self._registrar = None self._mwi_subscriber = None self._pwi_subscriber = None self._dwi_subscriber = None self._presence_subscriber = None self._self_presence_subscriber = None self._dialog_subscriber = None self._presence_publisher = None self._dialog_publisher = None self.xcap_manager = None SettingsObject.delete(self) @run_in_green_thread def reregister(self): if self._started: self._registrar.reregister() @run_in_green_thread def resubscribe(self): if self._started: self._mwi_subscriber.resubscribe() self._pwi_subscriber.resubscribe() self._dwi_subscriber.resubscribe() self._presence_subscriber.resubscribe() self._self_presence_subscriber.resubscribe() self._dialog_subscriber.resubscribe() @property def credentials(self): return Credentials(self.auth.username or self.id.username, self.auth.password) @property def registered(self): try: return self._registrar.registered except AttributeError: return False @property def mwi_active(self): try: return self._mwi_subscriber.subscribed except AttributeError: return False @property def tls_credentials(self): # This property can be optimized to cache the credentials it loads from disk, # however this is not a time consuming operation (~ 3000 req/sec). -Luci settings = SIPSimpleSettings() if self.tls.certificate is not None: certificate_data = open(self.tls.certificate.normalized).read() certificate = X509Certificate(certificate_data) private_key = X509PrivateKey(certificate_data) else: certificate = None private_key = None if settings.tls.ca_list is not None: # we should read all certificates in the file, rather than just the first -Luci trusted = [X509Certificate(open(settings.tls.ca_list.normalized).read())] else: trusted = [] credentials = X509Credentials(certificate, private_key, trusted) credentials.verify_peer = self.tls.verify_server return credentials @property def uri(self): return SIPURI(user=self.id.username, host=self.id.domain) @property def voicemail_uri(self): return self._mwi_voicemail_uri or self.message_summary.voicemail_uri @property def presence_state(self): try: return self._presence_publisher.state except AttributeError: return None @presence_state.setter def presence_state(self, state): try: self._presence_publisher.state = state except AttributeError: pass @property def dialog_state(self): try: return self._dialog_publisher.state except AttributeError: return None @dialog_state.setter def dialog_state(self, state): try: self._dialog_publisher.state = state except AttributeError: pass def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) @run_in_green_thread def _NH_CFGSettingsObjectDidChange(self, notification): if self._started and 'enabled' in notification.data.modified: if self.enabled: self._activate() else: self._deactivate() def _NH_XCAPManagerDidDiscoverServerCapabilities(self, notification): if self._started and self.xcap.discovered is False: self.xcap.discovered = True self.save() notification.center.post_notification('SIPAccountDidDiscoverXCAPSupport', sender=self) def _NH_MWISubscriberDidDeactivate(self, notification): self._mwi_voicemail_uri = None def _NH_MWISubscriptionGotNotify(self, notification): if notification.data.body and notification.data.content_type == MessageSummary.content_type: try: message_summary = MessageSummary.parse(notification.data.body) except ParserError: pass else: self._mwi_voicemail_uri = message_summary.message_account and SIPAddress(message_summary.message_account.replace('sip:', '', 1)) or None notification.center.post_notification('SIPAccountGotMessageSummary', sender=self, data=NotificationData(message_summary=message_summary)) def _NH_PresenceWinfoSubscriptionGotNotify(self, notification): if notification.data.body and notification.data.content_type == WatcherInfoDocument.content_type: try: watcher_info = WatcherInfoDocument.parse(notification.data.body) watcher_list = watcher_info['sip:' + self.id] except (ParserError, KeyError): pass else: if watcher_list.package != 'presence': return if self._pwi_version is None: if watcher_info.state == 'partial': self._pwi_subscriber.resubscribe() elif watcher_info.version <= self._pwi_version: return elif watcher_info.state == 'partial' and watcher_info.version > self._pwi_version + 1: self._pwi_subscriber.resubscribe() self._pwi_version = watcher_info.version data = NotificationData(version=watcher_info.version, state=watcher_info.state, watcher_list=watcher_list) notification.center.post_notification('SIPAccountGotPresenceWinfo', sender=self, data=data) def _NH_PresenceWinfoSubscriptionDidEnd(self, notification): self._pwi_version = None def _NH_PresenceWinfoSubscriptionDidFail(self, notification): self._pwi_version = None def _NH_DialogWinfoSubscriptionGotNotify(self, notification): if notification.data.body and notification.data.content_type == WatcherInfoDocument.content_type: try: watcher_info = WatcherInfoDocument.parse(notification.data.body) watcher_list = watcher_info['sip:' + self.id] except (ParserError, KeyError): pass else: if watcher_list.package != 'dialog': return if self._dwi_version is None: if watcher_info.state == 'partial': self._dwi_subscriber.resubscribe() elif watcher_info.version <= self._dwi_version: return elif watcher_info.state == 'partial' and watcher_info.version > self._dwi_version + 1: self._dwi_subscriber.resubscribe() self._dwi_version = watcher_info.version data = NotificationData(version=watcher_info.version, state=watcher_info.state, watcher_list=watcher_list) notification.center.post_notification('SIPAccountGotDialogWinfo', sender=self, data=data) def _NH_DialogWinfoSubscriptionDidEnd(self, notification): self._dwi_version = None def _NH_DialogWinfoSubscriptionDidFail(self, notification): self._dwi_version = None def _NH_PresenceSubscriptionGotNotify(self, notification): if notification.data.body and notification.data.content_type == RLSNotify.content_type: try: rls_notify = RLSNotify.parse('{content_type}\r\n\r\n{body}'.format(content_type=notification.data.headers['Content-Type'], body=notification.data.body)) except ParserError: pass else: if rls_notify.uri != self.xcap_manager.rls_presence_uri: return if self._presence_version is None: if not rls_notify.full_state: self._presence_subscriber.resubscribe() elif rls_notify.version <= self._presence_version: return elif not rls_notify.full_state and rls_notify.version > self._presence_version + 1: self._presence_subscriber.resubscribe() self._presence_version = rls_notify.version data = NotificationData(version=rls_notify.version, full_state=rls_notify.full_state, resource_map=dict((resource.uri, resource) for resource in rls_notify)) notification.center.post_notification('SIPAccountGotPresenceState', sender=self, data=data) def _NH_PresenceSubscriptionDidEnd(self, notification): self._presence_version = None def _NH_PresenceSubscriptionDidFail(self, notification): self._presence_version = None def _NH_SelfPresenceSubscriptionGotNotify(self, notification): if notification.data.body and notification.data.content_type == PIDFDocument.content_type: try: pidf_doc = PIDFDocument.parse(notification.data.body) except ParserError: pass else: if pidf_doc.entity.partition('sip:')[2] != self.id: return notification.center.post_notification('SIPAccountGotSelfPresenceState', sender=self, data=NotificationData(pidf=pidf_doc)) def _NH_DialogSubscriptionGotNotify(self, notification): if notification.data.body and notification.data.content_type == RLSNotify.content_type: try: rls_notify = RLSNotify.parse('{content_type}\r\n\r\n{body}'.format(content_type=notification.data.headers['Content-Type'], body=notification.data.body)) except ParserError: pass else: if rls_notify.uri != self.xcap_manager.rls_dialog_uri: return if self._dialog_version is None: if not rls_notify.full_state: self._dialog_subscriber.resubscribe() elif rls_notify.version <= self._dialog_version: return elif not rls_notify.full_state and rls_notify.version > self._dialog_version + 1: self._dialog_subscriber.resubscribe() self._dialog_version = rls_notify.version data = NotificationData(version=rls_notify.version, full_state=rls_notify.full_state, resource_map=dict((resource.uri, resource) for resource in rls_notify)) notification.center.post_notification('SIPAccountGotDialogState', sender=self, data=data) def _NH_DialogSubscriptionDidEnd(self, notification): self._dialog_version = None def _NH_DialogSubscriptionDidFail(self, notification): self._dialog_version = None def _activate(self): with self._activation_lock: if self._active: return notification_center = NotificationCenter() notification_center.post_notification('SIPAccountWillActivate', sender=self) self._active = True self._registrar.start() self._mwi_subscriber.start() self._pwi_subscriber.start() self._dwi_subscriber.start() self._presence_subscriber.start() self._self_presence_subscriber.start() self._dialog_subscriber.start() self._presence_publisher.start() self._dialog_publisher.start() if self.xcap.enabled: self.xcap_manager.start() notification_center.post_notification('SIPAccountDidActivate', sender=self) def _deactivate(self): with self._activation_lock: if not self._active: return notification_center = NotificationCenter() notification_center.post_notification('SIPAccountWillDeactivate', sender=self) self._active = False handlers = [self._registrar, self._mwi_subscriber, self._pwi_subscriber, self._dwi_subscriber, self._presence_subscriber, self._self_presence_subscriber, self._dialog_subscriber, self._presence_publisher, self._dialog_publisher, self.xcap_manager] proc.waitall([proc.spawn(handler.stop) for handler in handlers]) notification_center.post_notification('SIPAccountDidDeactivate', sender=self) def __repr__(self): return '%s(%r)' % (self.__class__.__name__, self.id) def __setstate__(self, data): # This restores the password from its previous location as a top level setting # after it was moved under the auth group. SettingsObject.__setstate__(self, data) if not data.get('auth', {}).get('password') and data.get('password'): self.auth.password = data.pop('password') self.save() class BonjourMSRPSettings(SettingsGroup): transport = Setting(type=MSRPTransport, default='tcp') class BonjourAccountEnabledSetting(Setting): def __get__(self, obj, objtype): if obj is None: return self return _bonjour.available and self.values.get(obj, self.default) def __set__(self, obj, value): if not _bonjour.available: raise RuntimeError('mdns support is not available') Setting.__set__(self, obj, value) +@implementer(IObserver) class BonjourAccount(SettingsObject): """ Object representing a bonjour account. Contains configuration settings and attributes for accessing bonjour related options. When the account is active, it will send broadcast its contact address on the LAN. If the object is un-pickled and its enabled flag was set, it will automatically activate. When the save method is called, depending on the value of the enabled flag, the account will activate/deactivate. Notifications sent by instances of Account: * CFGSettingsObjectWasCreated * CFGSettingsObjectWasActivated * CFGSettingsObjectWasDeleted * CFGSettingsObjectDidChange * SIPAccountWillActivate * SIPAccountDidActivate * SIPAccountWillDeactivate * SIPAccountDidDeactivate """ - implements(IObserver) - __group__ = 'Accounts' __id__ = SIPAddress('bonjour@local') id = property(lambda self: self.__id__) enabled = BonjourAccountEnabledSetting(type=bool, default=True) display_name = Setting(type=str, default=user_info.fullname, nillable=False) msrp = BonjourMSRPSettings presence = PresenceSettings rtp = RTPSettings tls = TLSSettings def __new__(cls): with AccountManager.load.lock: if not AccountManager.load.called: raise RuntimeError("cannot instantiate %s before calling AccountManager.load" % cls.__name__) return SettingsObject.__new__(cls) def __init__(self): self.contact = ContactURIFactory() self.credentials = None self._started = False self._active = False self._activation_lock = coros.Semaphore(1) self._bonjour_services = BonjourServices(self) # initialize fake settings (these are here to make the bonjour account quack like a duck) self.nat_traversal = NATTraversalSettings() self.nat_traversal.use_ice = False self.nat_traversal.msrp_relay = None self.nat_traversal.use_msrp_relay_for_outbound = False self.xcap = XCAPSettings() self.xcap.enabled = False self.xcap.discovered = False self.xcap.xcap_root = None def __repr__(self): return '%s()' % self.__class__.__name__ def start(self): if self._started: return self._started = True notification_center = NotificationCenter() notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=self) notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) self._bonjour_services.start() if self.enabled: self._activate() def stop(self): if not self._started: return self._started = False self._deactivate() self._bonjour_services.stop() notification_center = NotificationCenter() notification_center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=self) notification_center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) @classproperty def mdns_available(cls): return _bonjour.available @property def registered(self): return False @property def tls_credentials(self): # This property can be optimized to cache the credentials it loads from disk, # however this is not a time consuming operation (~ 3000 req/sec). -Luci settings = SIPSimpleSettings() if self.tls.certificate is not None: certificate_data = open(self.tls.certificate.normalized).read() certificate = X509Certificate(certificate_data) private_key = X509PrivateKey(certificate_data) else: certificate = None private_key = None if settings.tls.ca_list is not None: # we should read all certificates in the file, rather than just the first -Luci trusted = [X509Certificate(open(settings.tls.ca_list.normalized).read())] else: trusted = [] credentials = X509Credentials(certificate, private_key, trusted) credentials.verify_peer = self.tls.verify_server return credentials @property def uri(self): return SIPURI(user=self.contact.username, host=Host.default_ip or '127.0.0.1') @property def presence_state(self): return self._bonjour_services.presence_state @presence_state.setter def presence_state(self, state): self._bonjour_services.presence_state = state def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) @run_in_green_thread def _NH_CFGSettingsObjectDidChange(self, notification): if self._started: if 'enabled' in notification.data.modified: if self.enabled: self._activate() else: self._deactivate() elif self.enabled: if 'display_name' in notification.data.modified: self._bonjour_services.update_registrations() if {'sip.transport_list', 'tls.certificate'}.intersection(notification.data.modified): self._bonjour_services.update_registrations() self._bonjour_services.restart_discovery() def _activate(self): with self._activation_lock: if self._active: return notification_center = NotificationCenter() notification_center.post_notification('SIPAccountWillActivate', sender=self) self._active = True self._bonjour_services.activate() notification_center.post_notification('SIPAccountDidActivate', sender=self) def _deactivate(self): with self._activation_lock: if not self._active: return notification_center = NotificationCenter() notification_center.post_notification('SIPAccountWillDeactivate', sender=self) self._active = False self._bonjour_services.deactivate() notification_center.post_notification('SIPAccountDidDeactivate', sender=self) +@implementer(IObserver) class AccountManager(object, metaclass=Singleton): """ This is a singleton object which manages all the SIP accounts. It is also used to manage the default account (the one used for outbound sessions) using the default_account attribute: manager = AccountManager() manager.default_account = manager.get_account('alice@example.net') The following notifications are sent: * SIPAccountManagerDidRemoveAccount * SIPAccountManagerDidAddAccount * SIPAccountManagerDidChangeDefaultAccount """ - implements(IObserver) - def __init__(self): self._lock = Lock() self.accounts = {} notification_center = NotificationCenter() notification_center.add_observer(self, name='CFGSettingsObjectWasActivated') notification_center.add_observer(self, name='CFGSettingsObjectWasCreated') @execute_once def load(self): """ Load all accounts from the configuration. The accounts will not be started until the start method is called. """ configuration = ConfigurationManager() bonjour_account = BonjourAccount() names = configuration.get_names([Account.__group__]) [Account(id) for id in names if id != bonjour_account.id] default_account = self.default_account if default_account is None or not default_account.enabled: try: self.default_account = next((account for account in list(self.accounts.values()) if account.enabled)) except StopIteration: self.default_account = None def start(self): """ Start the accounts, which will determine the ones with the enabled flag set to activate. """ notification_center = NotificationCenter() notification_center.post_notification('SIPAccountManagerWillStart', sender=self) proc.waitall([proc.spawn(account.start) for account in list(self.accounts.values())]) notification_center.post_notification('SIPAccountManagerDidStart', sender=self) def stop(self): """ Stop the accounts, which will determine the ones that were enabled to deactivate. This method returns only once the accounts were stopped successfully or they timed out trying. """ notification_center = NotificationCenter() notification_center.post_notification('SIPAccountManagerWillEnd', sender=self) proc.waitall([proc.spawn(account.stop) for account in list(self.accounts.values())]) notification_center.post_notification('SIPAccountManagerDidEnd', sender=self) def has_account(self, id): return id in self.accounts def get_account(self, id): return self.accounts[id] def get_accounts(self): return list(self.accounts.values()) def iter_accounts(self): return iter(list(self.accounts.values())) def find_account(self, contact_uri): # compare contact_address with account contact exact_matches = (account for account in list(self.accounts.values()) if account.enabled and account.contact.username==contact_uri.user) # compare username in contact URI with account username loose_matches = (account for account in list(self.accounts.values()) if account.enabled and account.id.username==contact_uri.user) return next(chain(exact_matches, loose_matches, [None])) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_CFGSettingsObjectWasActivated(self, notification): if isinstance(notification.sender, Account) or (isinstance(notification.sender, BonjourAccount) and _bonjour.available): account = notification.sender self.accounts[account.id] = account notification.center.add_observer(self, sender=account, name='CFGSettingsObjectDidChange') notification.center.add_observer(self, sender=account, name='CFGSettingsObjectWasDeleted') notification.center.post_notification('SIPAccountManagerDidAddAccount', sender=self, data=NotificationData(account=account)) from sipsimple.application import SIPApplication if SIPApplication.running: call_in_green_thread(account.start) def _NH_CFGSettingsObjectWasCreated(self, notification): if isinstance(notification.sender, Account): account = notification.sender if account.enabled and self.default_account is None: self.default_account = account def _NH_CFGSettingsObjectWasDeleted(self, notification): account = notification.sender del self.accounts[account.id] notification.center.remove_observer(self, sender=account, name='CFGSettingsObjectDidChange') notification.center.remove_observer(self, sender=account, name='CFGSettingsObjectWasDeleted') notification.center.post_notification('SIPAccountManagerDidRemoveAccount', sender=self, data=NotificationData(account=account)) def _NH_CFGSettingsObjectDidChange(self, notification): account = notification.sender if '__id__' in notification.data.modified: modified_id = notification.data.modified['__id__'] self.accounts[modified_id.new] = self.accounts.pop(modified_id.old) if 'enabled' in notification.data.modified: if account.enabled and self.default_account is None: self.default_account = account elif not account.enabled and self.default_account is account: try: self.default_account = next((account for account in list(self.accounts.values()) if account.enabled)) except StopIteration: self.default_account = None @property def default_account(self): settings = SIPSimpleSettings() return self.accounts.get(settings.default_account, None) @default_account.setter def default_account(self, account): if account is not None and not account.enabled: raise ValueError("account %s is not enabled" % account.id) notification_center = NotificationCenter() settings = SIPSimpleSettings() with self._lock: old_account = self.accounts.get(settings.default_account, None) if account is old_account: return if account is None: settings.default_account = None else: settings.default_account = account.id settings.save() # we need to post the notification in the file-io thread in order to have it serialized after the # SIPAccountManagerDidAddAccount notification that is triggered when the account is saved the first # time, because save is executed in the file-io thread while this runs in the current thread. -Dan call_in_thread('file-io', notification_center.post_notification, 'SIPAccountManagerDidChangeDefaultAccount', sender=self, data=NotificationData(old_account=old_account, account=account)) diff --git a/sipsimple/account/bonjour/__init__.py b/sipsimple/account/bonjour/__init__.py index c5d09465..45e9962b 100644 --- a/sipsimple/account/bonjour/__init__.py +++ b/sipsimple/account/bonjour/__init__.py @@ -1,488 +1,488 @@ """Implements Bonjour service handlers""" __all__ = ['BonjourServices'] import re import uuid from threading import Lock from weakref import WeakKeyDictionary from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from eventlib import api, coros, proc from eventlib.green import select from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sipsimple import log from sipsimple.account.bonjour import _bonjour from sipsimple.core import FrozenSIPURI, SIPCoreError, NoGRUU from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.threading import call_in_twisted_thread, run_in_twisted_thread from sipsimple.threading.green import Command class RestartSelect(Exception): pass class BonjourFile(object): __instances__ = WeakKeyDictionary() def __new__(cls, file): if cls is BonjourFile: raise TypeError("BonjourFile cannot be instantiated directly") instance = cls.__instances__.get(file) if instance is None: instance = object.__new__(cls) instance.file = file instance.active = False cls.__instances__[file] = instance return instance def fileno(self): return self.file.fileno() if not self.closed else -1 def close(self): self.file.close() self.file = None @property def closed(self): return self.file is None @classmethod def find_by_file(cls, file): """Return the instance matching the given DNSServiceRef file""" try: return cls.__instances__[file] except KeyError: raise KeyError("cannot find a %s matching the given DNSServiceRef file" % cls.__name__) class BonjourDiscoveryFile(BonjourFile): def __new__(cls, file, transport): instance = BonjourFile.__new__(cls, file) instance.transport = transport return instance class BonjourRegistrationFile(BonjourFile): def __new__(cls, file, transport): instance = BonjourFile.__new__(cls, file) instance.transport = transport return instance class BonjourResolutionFile(BonjourFile): def __new__(cls, file, discovery_file, service_description): instance = BonjourFile.__new__(cls, file) instance.discovery_file = discovery_file instance.service_description = service_description return instance @property def transport(self): return self.discovery_file.transport class BonjourServiceDescription(object): def __init__(self, name, type, domain): self.name = name self.type = type self.domain = domain def __repr__(self): return "%s(%r, %r, %r)" % (self.__class__.__name__, self.name, self.type, self.domain) def __hash__(self): return hash(self.name) def __eq__(self, other): if isinstance(other, BonjourServiceDescription): return self.name==other.name and self.type==other.type and self.domain==other.domain return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal class BonjourNeighbourPresence(object): def __init__(self, state, note): self.state = state self.note = note class BonjourNeighbourRecord(object): def __init__(self, service_description, host, txtrecord): self.id = txtrecord.get('instance_id', None) self.name = txtrecord.get('name', '').decode('utf-8') or None self.host = re.match(r'^(?P.*?)(\.local)?\.?$', host).group('host') self.uri = FrozenSIPURI.parse(txtrecord.get('contact', service_description.name)) self.presence = BonjourNeighbourPresence(txtrecord.get('state', txtrecord.get('status', None)), txtrecord.get('note', '').decode('utf-8') or None) # status is read for legacy (remove later) -Dan +@implementer(IObserver) class BonjourServices(object): - implements(IObserver) def __init__(self, account): self.account = account self._started = False self._files = [] self._neighbours = {} self._command_channel = coros.queue() self._select_proc = None self._discover_timer = None self._register_timer = None self._update_timer = None self._lock = Lock() self.__dict__['presence_state'] = None def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, name='NetworkConditionsDidChange') self._select_proc = proc.spawn(self._process_files) proc.spawn(self._handle_commands) def stop(self): notification_center = NotificationCenter() notification_center.remove_observer(self, name='NetworkConditionsDidChange') self._select_proc.kill() self._command_channel.send_exception(api.GreenletExit) def activate(self): self._started = True self._command_channel.send(Command('register')) self._command_channel.send(Command('discover')) def deactivate(self): command = Command('stop') self._command_channel.send(command) command.wait() self._started = False def restart_discovery(self): self._command_channel.send(Command('discover')) def restart_registration(self): self._command_channel.send(Command('unregister')) self._command_channel.send(Command('register')) def update_registrations(self): self._command_channel.send(Command('update_registrations')) @property def presence_state(self): return self.__dict__['presence_state'] @presence_state.setter def presence_state(self, state): if state is not None and not isinstance(state, BonjourPresenceState): raise ValueError("state must be a BonjourPresenceState instance or None") with self._lock: old_state = self.__dict__['presence_state'] self.__dict__['presence_state'] = state if state != old_state: call_in_twisted_thread(self.update_registrations) def _register_cb(self, file, flags, error_code, name, regtype, domain): notification_center = NotificationCenter() file = BonjourRegistrationFile.find_by_file(file) if error_code == _bonjour.kDNSServiceErr_NoError: notification_center.post_notification('BonjourAccountRegistrationDidSucceed', sender=self.account, data=NotificationData(name=name, transport=file.transport)) else: error = _bonjour.BonjourError(error_code) notification_center.post_notification('BonjourAccountRegistrationDidFail', sender=self.account, data=NotificationData(reason=str(error), transport=file.transport)) self._files.remove(file) self._select_proc.kill(RestartSelect) file.close() if self._register_timer is None: self._register_timer = reactor.callLater(1, self._command_channel.send, Command('register')) def _browse_cb(self, file, flags, interface_index, error_code, service_name, regtype, reply_domain): notification_center = NotificationCenter() file = BonjourDiscoveryFile.find_by_file(file) service_description = BonjourServiceDescription(service_name, regtype, reply_domain) if error_code != _bonjour.kDNSServiceErr_NoError: error = _bonjour.BonjourError(error_code) notification_center.post_notification('BonjourAccountDiscoveryDidFail', sender=self.account, data=NotificationData(reason=str(error), transport=file.transport)) removed_files = [file] + [f for f in self._files if isinstance(f, BonjourResolutionFile) and f.discovery_file==file] for f in removed_files: self._files.remove(f) self._select_proc.kill(RestartSelect) for f in removed_files: f.close() if self._discover_timer is None: self._discover_timer = reactor.callLater(1, self._command_channel.send, Command('discover')) return if reply_domain != 'local.': return if flags & _bonjour.kDNSServiceFlagsAdd: try: resolution_file = next((f for f in self._files if isinstance(f, BonjourResolutionFile) and f.discovery_file==file and f.service_description==service_description)) except StopIteration: try: resolution_file = _bonjour.DNSServiceResolve(0, interface_index, service_name, regtype, reply_domain, self._resolve_cb) except _bonjour.BonjourError as e: notification_center.post_notification('BonjourAccountDiscoveryFailure', sender=self.account, data=NotificationData(error=str(e), transport=file.transport)) else: resolution_file = BonjourResolutionFile(resolution_file, discovery_file=file, service_description=service_description) self._files.append(resolution_file) self._select_proc.kill(RestartSelect) else: try: resolution_file = next((f for f in self._files if isinstance(f, BonjourResolutionFile) and f.discovery_file==file and f.service_description==service_description)) except StopIteration: pass else: self._files.remove(resolution_file) self._select_proc.kill(RestartSelect) resolution_file.close() service_description = resolution_file.service_description if service_description in self._neighbours: record = self._neighbours.pop(service_description) notification_center.post_notification('BonjourAccountDidRemoveNeighbour', sender=self.account, data=NotificationData(neighbour=service_description, record=record)) def _resolve_cb(self, file, flags, interface_index, error_code, fullname, host_target, port, txtrecord): notification_center = NotificationCenter() settings = SIPSimpleSettings() file = BonjourResolutionFile.find_by_file(file) if error_code == _bonjour.kDNSServiceErr_NoError: service_description = file.service_description try: record = BonjourNeighbourRecord(service_description, host_target, _bonjour.TXTRecord.parse(txtrecord)) except SIPCoreError: pass else: transport = record.uri.transport supported_transport = transport in settings.sip.transport_list and (transport!='tls' or self.account.tls.certificate is not None) if not supported_transport and service_description in self._neighbours: record = self._neighbours.pop(service_description) notification_center.post_notification('BonjourAccountDidRemoveNeighbour', sender=self.account, data=NotificationData(neighbour=service_description, record=record)) elif supported_transport: try: our_contact_uri = self.account.contact[NoGRUU, transport] except KeyError: return if record.uri != our_contact_uri: had_neighbour = service_description in self._neighbours self._neighbours[service_description] = record notification_name = 'BonjourAccountDidUpdateNeighbour' if had_neighbour else 'BonjourAccountDidAddNeighbour' notification_data = NotificationData(neighbour=service_description, record=record) notification_center.post_notification(notification_name, sender=self.account, data=notification_data) else: self._files.remove(file) self._select_proc.kill(RestartSelect) file.close() error = _bonjour.BonjourError(error_code) notification_center.post_notification('BonjourAccountDiscoveryFailure', sender=self.account, data=NotificationData(error=str(error), transport=file.transport)) # start a new resolve process here? -Dan def _process_files(self): while True: try: ready = select.select([f for f in self._files if not f.active and not f.closed], [], [])[0] except RestartSelect: continue else: for file in ready: file.active = True self._command_channel.send(Command('process_results', files=[f for f in ready if not f.closed])) def _handle_commands(self): while True: command = self._command_channel.wait() if self._started: handler = getattr(self, '_CH_%s' % command.name) handler(command) def _CH_unregister(self, command): if self._register_timer is not None and self._register_timer.active(): self._register_timer.cancel() self._register_timer = None if self._update_timer is not None and self._update_timer.active(): self._update_timer.cancel() self._update_timer = None old_files = [] for file in (f for f in self._files[:] if isinstance(f, BonjourRegistrationFile)): old_files.append(file) self._files.remove(file) self._select_proc.kill(RestartSelect) for file in old_files: file.close() notification_center = NotificationCenter() for transport in set(file.transport for file in self._files): notification_center.post_notification('BonjourAccountRegistrationDidEnd', sender=self.account, data=NotificationData(transport=transport)) command.signal() def _CH_register(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() if self._register_timer is not None and self._register_timer.active(): self._register_timer.cancel() self._register_timer = None supported_transports = set(transport for transport in settings.sip.transport_list if transport!='tls' or self.account.tls.certificate is not None) registered_transports = set(file.transport for file in self._files if isinstance(file, BonjourRegistrationFile)) missing_transports = supported_transports - registered_transports added_transports = set() for transport in missing_transports: notification_center.post_notification('BonjourAccountWillRegister', sender=self.account, data=NotificationData(transport=transport)) try: contact = self.account.contact[NoGRUU, transport] instance_id = str(uuid.UUID(settings.instance_id)) txtdata = dict(txtvers=1, name=self.account.display_name.encode('utf-8'), contact="<%s>" % str(contact), instance_id=instance_id) state = self.account.presence_state if self.account.presence.enabled and state is not None: txtdata['state'] = state.state txtdata['note'] = state.note.encode('utf-8') file = _bonjour.DNSServiceRegister(name=str(contact), regtype="_sipuri._%s" % (transport if transport == 'udp' else 'tcp'), port=contact.port, callBack=self._register_cb, txtRecord=_bonjour.TXTRecord(items=txtdata)) except (_bonjour.BonjourError, KeyError) as e: notification_center.post_notification('BonjourAccountRegistrationDidFail', sender=self.account, data=NotificationData(reason=str(e), transport=transport)) else: self._files.append(BonjourRegistrationFile(file, transport)) added_transports.add(transport) if added_transports: self._select_proc.kill(RestartSelect) if added_transports != missing_transports: self._register_timer = reactor.callLater(1, self._command_channel.send, Command('register', command.event)) else: command.signal() def _CH_update_registrations(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() if self._update_timer is not None and self._update_timer.active(): self._update_timer.cancel() self._update_timer = None available_transports = settings.sip.transport_list old_files = [] for file in (f for f in self._files[:] if isinstance(f, BonjourRegistrationFile) and f.transport not in available_transports): old_files.append(file) self._files.remove(file) self._select_proc.kill(RestartSelect) for file in old_files: file.close() update_failure = False for file in (f for f in self._files if isinstance(f, BonjourRegistrationFile)): try: contact = self.account.contact[NoGRUU, file.transport] instance_id = str(uuid.UUID(settings.instance_id)) txtdata = dict(txtvers=1, name=self.account.display_name.encode('utf-8'), contact="<%s>" % str(contact), instance_id=instance_id) state = self.account.presence_state if self.account.presence.enabled and state is not None: txtdata['state'] = state.state txtdata['note'] = state.note.encode('utf-8') _bonjour.DNSServiceUpdateRecord(file.file, None, flags=0, rdata=_bonjour.TXTRecord(items=txtdata), ttl=0) except (_bonjour.BonjourError, KeyError) as e: notification_center.post_notification('BonjourAccountRegistrationUpdateDidFail', sender=self.account, data=NotificationData(reason=str(e), transport=file.transport)) update_failure = True self._command_channel.send(Command('register')) if update_failure: self._update_timer = reactor.callLater(1, self._command_channel.send, Command('update_registrations', command.event)) else: command.signal() def _CH_discover(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() if self._discover_timer is not None and self._discover_timer.active(): self._discover_timer.cancel() self._discover_timer = None supported_transports = set(transport for transport in settings.sip.transport_list if transport!='tls' or self.account.tls.certificate is not None) discoverable_transports = set('tcp' if transport=='tls' else transport for transport in supported_transports) old_files = [] for file in (f for f in self._files[:] if isinstance(f, (BonjourDiscoveryFile, BonjourResolutionFile)) and f.transport not in discoverable_transports): old_files.append(file) self._files.remove(file) self._select_proc.kill(RestartSelect) for file in old_files: file.close() for service_description in [service for service, record in list(self._neighbours.items()) if record.uri.transport not in supported_transports]: record = self._neighbours.pop(service_description) notification_center.post_notification('BonjourAccountDidRemoveNeighbour', sender=self.account, data=NotificationData(neighbour=service_description, record=record)) discovered_transports = set(file.transport for file in self._files if isinstance(file, BonjourDiscoveryFile)) missing_transports = discoverable_transports - discovered_transports added_transports = set() for transport in missing_transports: notification_center.post_notification('BonjourAccountWillInitiateDiscovery', sender=self.account, data=NotificationData(transport=transport)) try: file = _bonjour.DNSServiceBrowse(regtype="_sipuri._%s" % transport, callBack=self._browse_cb) except _bonjour.BonjourError as e: notification_center.post_notification('BonjourAccountDiscoveryDidFail', sender=self.account, data=NotificationData(reason=str(e), transport=transport)) else: self._files.append(BonjourDiscoveryFile(file, transport)) added_transports.add(transport) if added_transports: self._select_proc.kill(RestartSelect) if added_transports != missing_transports: self._discover_timer = reactor.callLater(1, self._command_channel.send, Command('discover', command.event)) else: command.signal() def _CH_process_results(self, command): for file in (f for f in command.files if not f.closed): try: _bonjour.DNSServiceProcessResult(file.file) except: # Should we close the file? The documentation doesn't say anything about this. -Luci log.exception() for file in command.files: file.active = False self._files = [f for f in self._files if not f.closed] self._select_proc.kill(RestartSelect) def _CH_stop(self, command): if self._discover_timer is not None and self._discover_timer.active(): self._discover_timer.cancel() self._discover_timer = None if self._register_timer is not None and self._register_timer.active(): self._register_timer.cancel() self._register_timer = None if self._update_timer is not None and self._update_timer.active(): self._update_timer.cancel() self._update_timer = None files = self._files neighbours = self._neighbours self._files = [] self._select_proc.kill(RestartSelect) self._neighbours = {} for file in files: file.close() notification_center = NotificationCenter() for neighbour, record in list(neighbours.items()): notification_center.post_notification('BonjourAccountDidRemoveNeighbour', sender=self.account, data=NotificationData(neighbour=neighbour, record=record)) for transport in set(file.transport for file in files): notification_center.post_notification('BonjourAccountRegistrationDidEnd', sender=self.account, data=NotificationData(transport=transport)) command.signal() @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_NetworkConditionsDidChange(self, notification): if self._files: self.restart_discovery() self.restart_registration() class BonjourPresenceState(object): def __init__(self, state, note=None): self.state = state self.note = note or '' def __eq__(self, other): if isinstance(other, BonjourPresenceState): return self.state == other.state and self.note == other.note return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal diff --git a/sipsimple/account/publication.py b/sipsimple/account/publication.py index 1d84d26f..d99ae4da 100644 --- a/sipsimple/account/publication.py +++ b/sipsimple/account/publication.py @@ -1,390 +1,390 @@ """Implements the publisher handlers""" __all__ = ['Publisher', 'PresencePublisher', 'DialogPublisher'] import random from abc import ABCMeta, abstractproperty from threading import Lock from time import time from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null, limit from application.python.types import MarkerType from eventlib import coros, proc from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sipsimple.core import FromHeader, Publication, PublicationETagError, RouteHeader, SIPURI, SIPCoreError from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.payloads.dialoginfo import DialogInfoDocument from sipsimple.payloads.pidf import PIDFDocument from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, run_in_green_thread Command.register_defaults('publish', refresh_interval=None) class SameState(metaclass=MarkerType): pass class SIPPublicationDidFail(Exception): def __init__(self, data): self.data = data class SIPPublicationDidNotEnd(Exception): def __init__(self, data): self.data = data class PublicationError(Exception): def __init__(self, error, retry_after, refresh_interval=None): self.error = error self.retry_after = retry_after self.refresh_interval = refresh_interval class PublisherNickname(dict): def __missing__(self, name): return self.setdefault(name, name[:-9] if name.endswith('Publisher') else name) def __get__(self, obj, objtype): return self[objtype.__name__] def __set__(self, obj, value): raise AttributeError('cannot set attribute') def __delete__(self, obj): raise AttributeError('cannot delete attribute') +@implementer(IObserver) class Publisher(object, metaclass=ABCMeta): __nickname__ = PublisherNickname() __transports__ = frozenset(['tls', 'tcp', 'udp']) - implements(IObserver) def __init__(self, account): self.account = account self.started = False self.active = False self.publishing = False self._lock = Lock() self._command_proc = None self._command_channel = coros.queue() self._data_channel = coros.queue() self._publication = None self._dns_wait = 1 self._publish_wait = 1 self._publication_timer = None self.__dict__['state'] = None @abstractproperty def event(self): return None @abstractproperty def payload_type(self): return None @property def extra_headers(self): return [] @property def state(self): return self.__dict__['state'] @state.setter def state(self, state): if state is not None and not isinstance(state, self.payload_type.root_element): raise ValueError("state must be a %s document or None" % self.payload_type.root_element.__name__) with self._lock: old_state = self.__dict__['state'] self.__dict__['state'] = state if state == old_state: return self._publish(state) def start(self): if self.started: return self.started = True notification_center = NotificationCenter() notification_center.add_observer(self, sender=self) notification_center.post_notification(self.__class__.__name__ + 'WillStart', sender=self) notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=self.account) notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) notification_center.add_observer(self, name='NetworkConditionsDidChange') self._command_proc = proc.spawn(self._run) notification_center.post_notification(self.__class__.__name__ + 'DidStart', sender=self) notification_center.remove_observer(self, sender=self) def stop(self): if not self.started: return self.started = False self.active = False notification_center = NotificationCenter() notification_center.add_observer(self, sender=self) notification_center.post_notification(self.__class__.__name__ + 'WillEnd', sender=self) notification_center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=self.account) notification_center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) notification_center.remove_observer(self, name='NetworkConditionsDidChange') command = Command('terminate') self._command_channel.send(command) command.wait() self._command_proc = None notification_center.post_notification(self.__class__.__name__ + 'DidDeactivate', sender=self) notification_center.post_notification(self.__class__.__name__ + 'DidEnd', sender=self) notification_center.remove_observer(self, sender=self) def activate(self): if not self.started: raise RuntimeError("not started") self.active = True self._command_channel.send(Command('publish', state=self.state)) notification_center = NotificationCenter() notification_center.post_notification(self.__class__.__name__ + 'DidActivate', sender=self) def deactivate(self): if not self.started: raise RuntimeError("not started") self.active = False self._command_channel.send(Command('unpublish')) notification_center = NotificationCenter() notification_center.post_notification(self.__class__.__name__ + 'DidDeactivate', sender=self) @run_in_twisted_thread def _publish(self, state): if not self.active: return if state is None: self._command_channel.send(Command('unpublish')) else: self._command_channel.send(Command('publish', state=state)) def _run(self): while True: command = self._command_channel.wait() handler = getattr(self, '_CH_%s' % command.name) handler(command) def _CH_publish(self, command): if command.state is None or self._publication is None and command.state is SameState: command.signal() return notification_center = NotificationCenter() settings = SIPSimpleSettings() if self._publication_timer is not None and self._publication_timer.active(): self._publication_timer.cancel() self._publication_timer = None if self._publication is None: duration = command.refresh_interval or self.account.sip.publish_interval from_header = FromHeader(self.account.uri, self.account.display_name) self._publication = Publication(from_header, self.event, self.payload_type.content_type, credentials=self.account.credentials, duration=duration, extra_headers=self.extra_headers) notification_center.add_observer(self, sender=self._publication) notification_center.post_notification(self.__class__.__name__ + 'WillPublish', sender=self, data=NotificationData(state=command.state, duration=duration)) else: notification_center.post_notification(self.__class__.__name__ + 'WillRefresh', sender=self, data=NotificationData(state=command.state)) try: # Lookup routes valid_transports = self.__transports__.intersection(settings.sip.transport_list) if self.account.sip.outbound_proxy is not None and self.account.sip.outbound_proxy.transport in valid_transports: 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) lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, valid_transports).wait() except DNSLookupError as e: retry_after = random.uniform(self._dns_wait, 2*self._dns_wait) self._dns_wait = limit(2*self._dns_wait, max=30) raise PublicationError('DNS lookup failed: %s' % e, retry_after=retry_after) else: self._dns_wait = 1 body = None if command.state is SameState else command.state.toxml() # Publish by trying each route in turn publish_timeout = time() + 30 for route in routes: remaining_time = publish_timeout-time() if remaining_time > 0: try: try: self._publication.publish(body, RouteHeader(route.uri), timeout=limit(remaining_time, min=1, max=10)) except ValueError as e: # this happens for an initial PUBLISH with body=None raise PublicationError(str(e), retry_after=0) except PublicationETagError: state = self.state # access self.state only once to avoid race conditions if state is not None: self._publication.publish(state.toxml(), RouteHeader(route.uri), timeout=limit(remaining_time, min=1, max=10)) else: command.signal() return except SIPCoreError: raise PublicationError('Internal error', retry_after=5) try: while True: notification = self._data_channel.wait() if notification.name == 'SIPPublicationDidSucceed': break if notification.name == 'SIPPublicationDidEnd': raise PublicationError('Publication expired', retry_after=0) # publication expired while we were trying to re-publish except SIPPublicationDidFail as e: if e.data.code == 407: # Authentication failed, so retry the publication in some time raise PublicationError('Authentication failed', retry_after=random.uniform(60, 120)) elif e.data.code == 412: raise PublicationError('Conditional request failed', retry_after=0) elif e.data.code == 423: # Get the value of the Min-Expires header if e.data.min_expires is not None and e.data.min_expires > self.account.sip.publish_interval: refresh_interval = e.data.min_expires else: refresh_interval = None raise PublicationError('Interval too short', retry_after=random.uniform(60, 120), refresh_interval=refresh_interval) elif e.data.code in (405, 406, 489): raise PublicationError('Method or event not supported', retry_after=3600) else: # Otherwise just try the next route continue else: self.publishing = True self._publish_wait = 1 command.signal() break else: # There are no more routes to try, reschedule the publication retry_after = random.uniform(self._publish_wait, 2*self._publish_wait) self._publish_wait = limit(self._publish_wait*2, max=30) raise PublicationError('No more routes to try', retry_after=retry_after) except PublicationError as e: self.publishing = False notification_center.remove_observer(self, sender=self._publication) def publish(): if self.active: self._command_channel.send(Command('publish', event=command.event, state=self.state, refresh_interval=e.refresh_interval)) else: command.signal() self._publication_timer = None self._publication_timer = reactor.callLater(e.retry_after, publish) self._publication = None notification_center.post_notification(self.__nickname__ + 'PublicationDidFail', sender=self, data=NotificationData(reason=e.error)) else: notification_center.post_notification(self.__nickname__ + 'PublicationDidSucceed', sender=self) def _CH_unpublish(self, command): # Cancel any timer which would restart the publication process if self._publication_timer is not None and self._publication_timer.active(): self._publication_timer.cancel() self._publication_timer = None publishing = self.publishing self.publishing = False if self._publication is not None: notification_center = NotificationCenter() if publishing: self._publication.end(timeout=2) try: while True: notification = self._data_channel.wait() if notification.name == 'SIPPublicationDidEnd': break except (SIPPublicationDidFail, SIPPublicationDidNotEnd): notification_center.post_notification(self.__nickname__ + 'PublicationDidNotEnd', sender=self) else: notification_center.post_notification(self.__nickname__ + 'PublicationDidEnd', sender=self) notification_center.remove_observer(self, sender=self._publication) self._publication = None command.signal() def _CH_terminate(self, command): self._CH_unpublish(command) raise proc.ProcExit @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPPublicationDidSucceed(self, notification): if notification.sender is self._publication: self._data_channel.send(notification) def _NH_SIPPublicationDidFail(self, notification): if notification.sender is self._publication: self._data_channel.send_exception(SIPPublicationDidFail(notification.data)) def _NH_SIPPublicationDidEnd(self, notification): if notification.sender is self._publication: self._data_channel.send(notification) def _NH_SIPPublicationDidNotEnd(self, notification): if notification.sender is self._publication: self._data_channel.send_exception(SIPPublicationDidNotEnd(notification.data)) def _NH_SIPPublicationWillExpire(self, notification): if notification.sender is self._publication: self._publish(SameState) @run_in_green_thread def _NH_CFGSettingsObjectDidChange(self, notification): if not self.started: return if 'enabled' in notification.data.modified: return # global account activation is handled separately by the account itself elif 'presence.enabled' in notification.data.modified: if self.account.presence.enabled: self.activate() else: self.deactivate() elif self.active and {'__id__', 'auth.password', 'auth.username', 'sip.outbound_proxy', 'sip.transport_list', 'sip.publish_interval'}.intersection(notification.data.modified): self._command_channel.send(Command('unpublish')) self._command_channel.send(Command('publish', state=self.state)) def _NH_NetworkConditionsDidChange(self, notification): if self.active: self._command_channel.send(Command('unpublish')) self._command_channel.send(Command('publish', state=self.state)) class PresencePublisher(Publisher): """A publisher for presence state""" @property def event(self): return 'presence' @property def payload_type(self): return PIDFDocument def _NH_PresencePublisherDidStart(self, notification): if self.account.presence.enabled: self.activate() class DialogPublisher(Publisher): """A publisher for dialog info state""" @property def event(self): return 'dialog' @property def payload_type(self): return DialogInfoDocument def _NH_DialogPublisherDidStart(self, notification): if self.account.presence.enabled: self.activate() diff --git a/sipsimple/account/registration.py b/sipsimple/account/registration.py index 6b9ca5d6..0a4b88b9 100644 --- a/sipsimple/account/registration.py +++ b/sipsimple/account/registration.py @@ -1,302 +1,302 @@ """Implements the registration handler""" __all__ = ['Registrar'] import random from time import time from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null, limit from eventlib import coros, proc from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sipsimple.core import ContactHeader, FromHeader, Header, Registration, RouteHeader, SIPURI, SIPCoreError, NoGRUU from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, run_in_green_thread Command.register_defaults('register', refresh_interval=None) class SIPRegistrationDidFail(Exception): def __init__(self, data): self.data = data class SIPRegistrationDidNotEnd(Exception): def __init__(self, data): self.data = data class RegistrationError(Exception): def __init__(self, error, retry_after, refresh_interval=None): self.error = error self.retry_after = retry_after self.refresh_interval = refresh_interval +@implementer(IObserver) class Registrar(object): - implements(IObserver) def __init__(self, account): self.account = account self.started = False self.active = False self.registered = False self._command_proc = None self._command_channel = coros.queue() self._data_channel = coros.queue() self._registration = None self._dns_wait = 1 self._register_wait = 1 self._registration_timer = None def start(self): if self.started: return self.started = True notification_center = NotificationCenter() notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=self.account) notification_center.add_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) notification_center.add_observer(self, name='NetworkConditionsDidChange') self._command_proc = proc.spawn(self._run) if self.account.sip.register: self.activate() def stop(self): if not self.started: return self.started = False self.active = False notification_center = NotificationCenter() notification_center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=self.account) notification_center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) notification_center.remove_observer(self, name='NetworkConditionsDidChange') command = Command('terminate') self._command_channel.send(command) command.wait() self._command_proc = None def activate(self): if not self.started: raise RuntimeError("not started") self.active = True self._command_channel.send(Command('register')) def deactivate(self): if not self.started: raise RuntimeError("not started") self.active = False self._command_channel.send(Command('unregister')) def reregister(self): if self.active: self._command_channel.send(Command('unregister')) self._command_channel.send(Command('register')) def _run(self): while True: command = self._command_channel.wait() handler = getattr(self, '_CH_%s' % command.name) handler(command) def _CH_register(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() if self._registration_timer is not None and self._registration_timer.active(): self._registration_timer.cancel() self._registration_timer = None # Initialize the registration if self._registration is None: duration = command.refresh_interval or self.account.sip.register_interval self._registration = Registration(FromHeader(self.account.uri, self.account.display_name), credentials=self.account.credentials, duration=duration, extra_headers=[Header('Supported', 'gruu')]) notification_center.add_observer(self, sender=self._registration) notification_center.post_notification('SIPAccountWillRegister', sender=self.account) else: notification_center.post_notification('SIPAccountRegistrationWillRefresh', sender=self.account) try: # Lookup routes if self.account.sip.outbound_proxy is not None and self.account.sip.outbound_proxy.transport in settings.sip.transport_list: 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) lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError as e: retry_after = random.uniform(self._dns_wait, 2*self._dns_wait) self._dns_wait = limit(2*self._dns_wait, max=30) raise RegistrationError('DNS lookup failed: %s' % e, retry_after=retry_after) else: self._dns_wait = 1 # Register by trying each route in turn register_timeout = time() + 30 for route in routes: remaining_time = register_timeout-time() if remaining_time > 0: try: contact_uri = self.account.contact[NoGRUU, route] except KeyError: continue contact_header = ContactHeader(contact_uri) contact_header.parameters['+sip.instance'] = '"<%s>"' % settings.instance_id if self.account.nat_traversal.use_ice: contact_header.parameters['+sip.ice'] = None route_header = RouteHeader(route.uri) try: self._registration.register(contact_header, route_header, timeout=limit(remaining_time, min=1, max=10)) except SIPCoreError: raise RegistrationError('Internal error', retry_after=5) try: while True: notification = self._data_channel.wait() if notification.name == 'SIPRegistrationDidSucceed': break if notification.name == 'SIPRegistrationDidEnd': raise RegistrationError('Registration expired', retry_after=0) # registration expired while we were trying to re-register except SIPRegistrationDidFail as e: notification_data = NotificationData(code=e.data.code, reason=e.data.reason, registration=self._registration, registrar=route) notification_center.post_notification('SIPAccountRegistrationGotAnswer', sender=self.account, data=notification_data) if e.data.code == 401: # Authentication failed, so retry the registration in some time raise RegistrationError('Authentication failed', retry_after=random.uniform(60, 120)) elif e.data.code == 423: # Get the value of the Min-Expires header if e.data.min_expires is not None and e.data.min_expires > self.account.sip.register_interval: refresh_interval = e.data.min_expires else: refresh_interval = None raise RegistrationError('Interval too short', retry_after=random.uniform(60, 120), refresh_interval=refresh_interval) else: # Otherwise just try the next route continue else: notification_data = NotificationData(code=notification.data.code, reason=notification.data.reason, registration=self._registration, registrar=route) notification_center.post_notification('SIPAccountRegistrationGotAnswer', sender=self.account, data=notification_data) self.registered = True # Save GRUU try: header = next(header for header in notification.data.contact_header_list if header.parameters.get('+sip.instance', '').strip('"<>') == settings.instance_id) except StopIteration: self.account.contact.public_gruu = None self.account.contact.temporary_gruu = None else: public_gruu = header.parameters.get('pub-gruu', None) temporary_gruu = header.parameters.get('temp-gruu', None) try: self.account.contact.public_gruu = SIPURI.parse(public_gruu.strip('"')) except (AttributeError, SIPCoreError): self.account.contact.public_gruu = None try: self.account.contact.temporary_gruu = SIPURI.parse(temporary_gruu.strip('"')) except (AttributeError, SIPCoreError): self.account.contact.temporary_gruu = None notification_data = NotificationData(contact_header=notification.data.contact_header, contact_header_list=notification.data.contact_header_list, expires=notification.data.expires_in, registrar=route) notification_center.post_notification('SIPAccountRegistrationDidSucceed', sender=self.account, data=notification_data) self._register_wait = 1 command.signal() break else: # There are no more routes to try, reschedule the registration retry_after = random.uniform(self._register_wait, 2*self._register_wait) self._register_wait = limit(self._register_wait*2, max=30) raise RegistrationError('No more routes to try', retry_after=retry_after) except RegistrationError as e: self.registered = False notification_center.remove_observer(self, sender=self._registration) notification_center.post_notification('SIPAccountRegistrationDidFail', sender=self.account, data=NotificationData(error=e.error, retry_after=e.retry_after)) def register(): if self.active: self._command_channel.send(Command('register', command.event, refresh_interval=e.refresh_interval)) self._registration_timer = None self._registration_timer = reactor.callLater(e.retry_after, register) self._registration = None self.account.contact.public_gruu = None self.account.contact.temporary_gruu = None def _CH_unregister(self, command): # Cancel any timer which would restart the registration process if self._registration_timer is not None and self._registration_timer.active(): self._registration_timer.cancel() self._registration_timer = None registered = self.registered self.registered = False if self._registration is not None: notification_center = NotificationCenter() if registered: self._registration.end(timeout=2) try: while True: notification = self._data_channel.wait() if notification.name == 'SIPRegistrationDidEnd': break except (SIPRegistrationDidFail, SIPRegistrationDidNotEnd) as e: notification_center.post_notification('SIPAccountRegistrationDidNotEnd', sender=self.account, data=NotificationData(code=e.data.code, reason=e.data.reason, registration=self._registration)) else: notification_center.post_notification('SIPAccountRegistrationDidEnd', sender=self.account, data=NotificationData(registration=self._registration)) notification_center.remove_observer(self, sender=self._registration) self._registration = None self.account.contact.public_gruu = None self.account.contact.temporary_gruu = None command.signal() def _CH_terminate(self, command): self._CH_unregister(command) raise proc.ProcExit @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPRegistrationDidSucceed(self, notification): if notification.sender is self._registration: self._data_channel.send(notification) def _NH_SIPRegistrationDidFail(self, notification): if notification.sender is self._registration: self._data_channel.send_exception(SIPRegistrationDidFail(notification.data)) def _NH_SIPRegistrationDidEnd(self, notification): if notification.sender is self._registration: self._data_channel.send(notification) def _NH_SIPRegistrationDidNotEnd(self, notification): if notification.sender is self._registration: self._data_channel.send_exception(SIPRegistrationDidNotEnd(notification.data)) def _NH_SIPRegistrationWillExpire(self, notification): if self.active: self._command_channel.send(Command('register')) @run_in_green_thread def _NH_CFGSettingsObjectDidChange(self, notification): if not self.started: return if 'enabled' in notification.data.modified: return # global account activation is handled separately by the account itself elif 'sip.register' in notification.data.modified: if self.account.sip.register: self.activate() else: self.deactivate() elif self.active and {'__id__', 'auth.password', 'auth.username', 'nat_traversal.use_ice', 'sip.outbound_proxy', 'sip.transport_list', 'sip.register_interval'}.intersection(notification.data.modified): self._command_channel.send(Command('unregister')) self._command_channel.send(Command('register')) def _NH_NetworkConditionsDidChange(self, notification): if self.active: self._command_channel.send(Command('unregister')) self._command_channel.send(Command('register')) diff --git a/sipsimple/account/subscription.py b/sipsimple/account/subscription.py index c363933a..1373c7fe 100644 --- a/sipsimple/account/subscription.py +++ b/sipsimple/account/subscription.py @@ -1,494 +1,494 @@ """Implements the subscription handlers""" __all__ = ['Subscriber', 'MWISubscriber', 'PresenceWinfoSubscriber', 'DialogWinfoSubscriber', 'PresenceSubscriber', 'SelfPresenceSubscriber', 'DialogSubscriber'] import random from abc import ABCMeta, abstractproperty from time import time from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null, limit from eventlib import coros, proc from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sipsimple.core import ContactHeader, FromHeader, Header, RouteHeader, SIPURI, Subscription, ToHeader, SIPCoreError, NoGRUU from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, run_in_green_thread Command.register_defaults('subscribe', refresh_interval=None) class SIPSubscriptionDidFail(Exception): def __init__(self, data): self.data = data class SubscriptionError(Exception): def __init__(self, error, retry_after, refresh_interval=None): self.error = error self.retry_after = retry_after self.refresh_interval = refresh_interval class InterruptSubscription(Exception): pass class TerminateSubscription(Exception): pass class Content(object): def __init__(self, body, type): self.body = body self.type = type class SubscriberNickname(dict): def __missing__(self, name): return self.setdefault(name, name[:-10] if name.endswith('Subscriber') else name) def __get__(self, obj, objtype): return self[objtype.__name__] def __set__(self, obj, value): raise AttributeError('cannot set attribute') def __delete__(self, obj): raise AttributeError('cannot delete attribute') +@implementer(IObserver) class Subscriber(object, metaclass=ABCMeta): __nickname__ = SubscriberNickname() __transports__ = frozenset(['tls', 'tcp', 'udp']) - implements(IObserver) def __init__(self, account): self.account = account self.started = False self.active = False self.subscribed = False self._command_proc = None self._command_channel = coros.queue() self._data_channel = coros.queue() self._subscription = None self._subscription_proc = None self._subscription_timer = None @abstractproperty def event(self): return None @property def subscription_uri(self): return self.account.id @property def content(self): return Content(None, None) @property def extra_headers(self): return [] def start(self): if self.started: return self.started = True notification_center = NotificationCenter() notification_center.add_observer(self, sender=self) notification_center.post_notification(self.__class__.__name__ + 'WillStart', sender=self) notification_center.add_observer(self, name='NetworkConditionsDidChange') self._command_proc = proc.spawn(self._run) notification_center.post_notification(self.__class__.__name__ + 'DidStart', sender=self) notification_center.remove_observer(self, sender=self) def stop(self): if not self.started: return self.started = False self.active = False notification_center = NotificationCenter() notification_center.add_observer(self, sender=self) notification_center.post_notification(self.__class__.__name__ + 'WillEnd', sender=self) notification_center.remove_observer(self, name='NetworkConditionsDidChange') command = Command('terminate') self._command_channel.send(command) command.wait() self._command_proc = None notification_center.post_notification(self.__class__.__name__ + 'DidDeactivate', sender=self) notification_center.post_notification(self.__class__.__name__ + 'DidEnd', sender=self) notification_center.remove_observer(self, sender=self) def activate(self): if not self.started: raise RuntimeError("not started") self.active = True self._command_channel.send(Command('subscribe')) notification_center = NotificationCenter() notification_center.post_notification(self.__class__.__name__ + 'DidActivate', sender=self) def deactivate(self): if not self.started: raise RuntimeError("not started") self.active = False self._command_channel.send(Command('unsubscribe')) notification_center = NotificationCenter() notification_center.post_notification(self.__class__.__name__ + 'DidDeactivate', sender=self) def resubscribe(self): if self.active: self._command_channel.send(Command('subscribe')) def _run(self): while True: command = self._command_channel.wait() handler = getattr(self, '_CH_%s' % command.name) handler(command) def _CH_subscribe(self, command): if self._subscription_timer is not None and self._subscription_timer.active(): self._subscription_timer.cancel() self._subscription_timer = None if self._subscription_proc is not None: subscription_proc = self._subscription_proc subscription_proc.kill(InterruptSubscription) subscription_proc.wait() self._subscription_proc = proc.spawn(self._subscription_handler, command) def _CH_unsubscribe(self, command): # Cancel any timer which would restart the subscription process if self._subscription_timer is not None and self._subscription_timer.active(): self._subscription_timer.cancel() self._subscription_timer = None if self._subscription_proc is not None: subscription_proc = self._subscription_proc subscription_proc.kill(TerminateSubscription) subscription_proc.wait() self._subscription_proc = None command.signal() def _CH_terminate(self, command): self._CH_unsubscribe(command) raise proc.ProcExit def _subscription_handler(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() subscription_uri = self.subscription_uri refresh_interval = command.refresh_interval or self.account.sip.subscribe_interval valid_transports = self.__transports__.intersection(settings.sip.transport_list) try: # Lookup routes if self.account.sip.outbound_proxy is not None and self.account.sip.outbound_proxy.transport in valid_transports: 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 = SIPURI(host=subscription_uri.domain) lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, valid_transports).wait() except DNSLookupError as e: raise SubscriptionError('DNS lookup failed: %s' % e, retry_after=random.uniform(15, 30)) subscription_uri = SIPURI(user=subscription_uri.username, host=subscription_uri.domain) content = self.content timeout = time() + 30 for route in routes: remaining_time = timeout - time() if remaining_time > 0: try: contact_uri = self.account.contact[NoGRUU, route] except KeyError: continue subscription = Subscription(subscription_uri, FromHeader(self.account.uri, self.account.display_name), ToHeader(subscription_uri), ContactHeader(contact_uri), self.event, RouteHeader(route.uri), credentials=self.account.credentials, refresh=refresh_interval) notification_center.add_observer(self, sender=subscription) try: subscription.subscribe(body=content.body, content_type=content.type, extra_headers=self.extra_headers, timeout=limit(remaining_time, min=1, max=5)) except SIPCoreError: notification_center.remove_observer(self, sender=subscription) raise SubscriptionError('Internal error', retry_after=5) self._subscription = subscription try: while True: notification = self._data_channel.wait() if notification.name == 'SIPSubscriptionDidStart': break except SIPSubscriptionDidFail as e: notification_center.remove_observer(self, sender=subscription) self._subscription = None if e.data.code == 407: # Authentication failed, so retry the subscription in some time raise SubscriptionError('Authentication failed', retry_after=random.uniform(60, 120)) elif e.data.code == 423: # Get the value of the Min-Expires header if e.data.min_expires is not None and e.data.min_expires > self.account.sip.subscribe_interval: refresh_interval = e.data.min_expires else: refresh_interval = None raise SubscriptionError('Interval too short', retry_after=random.uniform(60, 120), refresh_interval=refresh_interval) elif e.data.code in (405, 406, 489): raise SubscriptionError('Method or event not supported', retry_after=3600) elif e.data.code == 1400: raise SubscriptionError(e.data.reason, retry_after=3600) else: # Otherwise just try the next route continue else: self.subscribed = True command.signal() break else: # There are no more routes to try, reschedule the subscription raise SubscriptionError('No more routes to try', retry_after=random.uniform(60, 180)) # At this point it is subscribed. Handle notifications and ending/failures. notification_center.post_notification(self.__nickname__ + 'SubscriptionDidStart', sender=self) try: while True: notification = self._data_channel.wait() if notification.name == 'SIPSubscriptionGotNotify': notification_center.post_notification(self.__nickname__ + 'SubscriptionGotNotify', sender=self, data=notification.data) elif notification.name == 'SIPSubscriptionDidEnd': notification_center.post_notification(self.__nickname__ + 'SubscriptionDidEnd', sender=self, data=NotificationData(originator='remote')) if self.active: self._command_channel.send(Command('subscribe')) break except SIPSubscriptionDidFail: notification_center.post_notification(self.__nickname__ + 'SubscriptionDidFail', sender=self) if self.active: self._command_channel.send(Command('subscribe')) notification_center.remove_observer(self, sender=self._subscription) except InterruptSubscription as e: if not self.subscribed: command.signal(e) if self._subscription is not None: notification_center.remove_observer(self, sender=self._subscription) try: self._subscription.end(timeout=2) except SIPCoreError: pass finally: notification_center.post_notification(self.__nickname__ + 'SubscriptionDidEnd', sender=self, data=NotificationData(originator='local')) except TerminateSubscription as e: if not self.subscribed: command.signal(e) if self._subscription is not None: try: self._subscription.end(timeout=2) except SIPCoreError: pass else: try: while True: notification = self._data_channel.wait() if notification.name == 'SIPSubscriptionDidEnd': break except SIPSubscriptionDidFail: pass finally: notification_center.remove_observer(self, sender=self._subscription) notification_center.post_notification(self.__nickname__ + 'SubscriptionDidEnd', sender=self, data=NotificationData(originator='local')) except SubscriptionError as e: def subscribe(): if self.active: self._command_channel.send(Command('subscribe', command.event, refresh_interval=e.refresh_interval)) self._subscription_timer = None self._subscription_timer = reactor.callLater(e.retry_after, subscribe) notification_center.post_notification(self.__nickname__ + 'SubscriptionDidFail', sender=self) finally: self.subscribed = False self._subscription = None self._subscription_proc = None @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSubscriptionDidStart(self, notification): if notification.sender is self._subscription: self._data_channel.send(notification) def _NH_SIPSubscriptionDidEnd(self, notification): if notification.sender is self._subscription: self._data_channel.send(notification) def _NH_SIPSubscriptionDidFail(self, notification): if notification.sender is self._subscription: self._data_channel.send_exception(SIPSubscriptionDidFail(notification.data)) def _NH_SIPSubscriptionGotNotify(self, notification): if notification.sender is self._subscription: self._data_channel.send(notification) def _NH_NetworkConditionsDidChange(self, notification): if self.active: self._command_channel.send(Command('subscribe')) class MWISubscriber(Subscriber): """Message Waiting Indicator subscriber""" @property def event(self): return 'message-summary' @property def subscription_uri(self): return self.account.message_summary.voicemail_uri or self.account.id def _NH_MWISubscriberWillStart(self, notification): notification.center.add_observer(self, name='CFGSettingsObjectDidChange', sender=self.account) notification.center.add_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) def _NH_MWISubscriberWillEnd(self, notification): notification.center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=self.account) notification.center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) def _NH_MWISubscriberDidStart(self, notification): if self.account.message_summary.enabled: self.activate() @run_in_green_thread def _NH_CFGSettingsObjectDidChange(self, notification): if not self.started: return if 'enabled' in notification.data.modified: return # global account activation is handled separately by the account itself elif 'message_summary.enabled' in notification.data.modified: if self.account.message_summary.enabled: self.activate() else: self.deactivate() elif self.active and {'__id__', 'auth.password', 'auth.username', 'message_summary.voicemail_uri', 'sip.always_use_my_proxy', 'sip.outbound_proxy', 'sip.subscribe_interval', 'sip.transport_list'}.intersection(notification.data.modified): self._command_channel.send(Command('subscribe')) class AbstractPresenceSubscriber(Subscriber): """Abstract class defining behavior for all presence subscribers""" __transports__ = frozenset(['tls', 'tcp']) def _NH_AbstractPresenceSubscriberWillStart(self, notification): notification.center.add_observer(self, name='SIPAccountDidDiscoverXCAPSupport', sender=self.account) notification.center.add_observer(self, name='CFGSettingsObjectDidChange', sender=self.account) notification.center.add_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) def _NH_AbstractPresenceSubscriberWillEnd(self, notification): notification.center.remove_observer(self, name='SIPAccountDidDiscoverXCAPSupport', sender=self.account) notification.center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=self.account) notification.center.remove_observer(self, name='CFGSettingsObjectDidChange', sender=SIPSimpleSettings()) def _NH_AbstractPresenceSubscriberDidStart(self, notification): if self.account.presence.enabled and self.account.xcap.discovered: self.activate() def _NH_SIPAccountDidDiscoverXCAPSupport(self, notification): if self.account.presence.enabled and not self.active: self.activate() @run_in_green_thread def _NH_CFGSettingsObjectDidChange(self, notification): if not self.started or not self.account.xcap.discovered: return if 'enabled' in notification.data.modified: return # global account activation is handled separately by the account itself elif 'presence.enabled' in notification.data.modified: if self.account.presence.enabled: self.activate() else: self.deactivate() elif self.active and {'__id__', 'auth.password', 'auth.username', 'sip.always_use_my_proxy', 'sip.outbound_proxy', 'sip.subscribe_interval', 'sip.transport_list'}.intersection(notification.data.modified): self._command_channel.send(Command('subscribe')) class PresenceWinfoSubscriber(AbstractPresenceSubscriber): """Presence Watcher Info subscriber""" _NH_PresenceWinfoSubscriberWillStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillStart _NH_PresenceWinfoSubscriberWillEnd = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillEnd _NH_PresenceWinfoSubscriberDidStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberDidStart @property def event(self): return 'presence.winfo' class DialogWinfoSubscriber(AbstractPresenceSubscriber): """Dialog Watcher Info subscriber""" _NH_DialogWinfoSubscriberWillStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillStart _NH_DialogWinfoSubscriberWillEnd = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillEnd _NH_DialogWinfoSubscriberDidStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberDidStart @property def event(self): return 'dialog.winfo' class PresenceSubscriber(AbstractPresenceSubscriber): """Presence subscriber""" _NH_PresenceSubscriberWillStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillStart _NH_PresenceSubscriberWillEnd = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillEnd _NH_PresenceSubscriberDidStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberDidStart @property def event(self): return 'presence' @property def subscription_uri(self): return self.account.xcap_manager.rls_presence_uri @property def extra_headers(self): return [Header('Supported', 'eventlist')] class SelfPresenceSubscriber(AbstractPresenceSubscriber): """Self presence subscriber""" _NH_SelfPresenceSubscriberWillStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillStart _NH_SelfPresenceSubscriberWillEnd = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillEnd _NH_SelfPresenceSubscriberDidStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberDidStart @property def event(self): return 'presence' @property def subscription_uri(self): return self.account.id class DialogSubscriber(AbstractPresenceSubscriber): """Dialog subscriber""" _NH_DialogSubscriberWillStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillStart _NH_DialogSubscriberWillEnd = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberWillEnd _NH_DialogSubscriberDidStart = AbstractPresenceSubscriber._NH_AbstractPresenceSubscriberDidStart @property def event(self): return 'dialog' @property def subscription_uri(self): return self.account.xcap_manager.rls_dialog_uri @property def extra_headers(self): return [Header('Supported', 'eventlist')] diff --git a/sipsimple/account/xcap/__init__.py b/sipsimple/account/xcap/__init__.py index d82cd2d2..c8105c7f 100644 --- a/sipsimple/account/xcap/__init__.py +++ b/sipsimple/account/xcap/__init__.py @@ -1,1765 +1,1765 @@ """High-level management of XCAP documents based on OMA specifications""" __all__ = ['Group', 'Contact', 'ContactURI', 'EventHandling', 'Policy', 'Icon', 'OfflineStatus', 'XCAPManager', 'XCAPTransaction'] import base64 import pickle import os import random import socket import weakref from io import StringIO from collections import OrderedDict from datetime import datetime from itertools import chain from operator import attrgetter from urllib.error import URLError from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.decorator import execute_once from eventlib import api, coros, proc from eventlib.green.httplib import BadStatusLine from twisted.internet.error import ConnectionLost from xcaplib.green import XCAPClient from xcaplib.error import HTTPError -from zope.interface import implements +from zope.interface import implementer from sipsimple import log from sipsimple.account.subscription import Subscriber, Content from sipsimple.account.xcap.storage import IXCAPStorage, XCAPStorageError from sipsimple.configuration.datatypes import SIPAddress from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.payloads import ParserError, IterateTypes, IterateIDs, IterateItems, All from sipsimple.payloads import addressbook, commonpolicy, dialogrules, omapolicy, pidf, prescontent, presrules, resourcelists, rlsservices, xcapcaps, xcapdiff from sipsimple.payloads import rpid; del rpid # needs to be imported to register its namespace from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, Worker, run_in_green_thread class XCAPError(Exception): pass class FetchRequiredError(XCAPError): pass class Document(object): name = None application = None payload_type = None default_namespace = None global_tree = None filename = None cached = True def __init__(self, manager): self.manager = weakref.proxy(manager) self.content = None self.etag = None self.fetch_time = datetime.fromtimestamp(0) self.update_time = datetime.fromtimestamp(0) self.dirty = False self.supported = False def __bool__(self): return self.content is not None @property def dirty(self): return self.__dict__['dirty'] or (self.content is not None and self.content.__dirty__) @dirty.setter def dirty(self, dirty): if self.content is not None and not dirty: self.content.__dirty__ = dirty self.__dict__['dirty'] = dirty @property def relative_url(self): return self.url[len(self.manager.xcap_root):].lstrip('/') @property def url(self): return self.manager.client.get_url(self.application, None, globaltree=self.global_tree, filename=self.filename) def load_from_cache(self): if not self.cached: return try: document = StringIO(self.manager.storage.load(self.name)) self.etag = document.readline().strip() or None self.content = self.payload_type.parse(document) self.__dict__['dirty'] = False except (XCAPStorageError, ParserError): self.etag = None self.content = None self.dirty = False self.fetch_time = datetime.utcnow() def initialize(self, server_caps): self.supported = self.application in server_caps.auids if not self.supported: self.reset() def reset(self): if self.cached and self.content is not None: try: self.manager.storage.delete(self.name) except XCAPStorageError: pass self.content = None self.etag = None self.dirty = False def fetch(self): try: document = self.manager.client.get(self.application, etagnot=self.etag, globaltree=self.global_tree, headers={'Accept': self.payload_type.content_type}, filename=self.filename) self.content = self.payload_type.parse(document) self.etag = document.etag self.__dict__['dirty'] = False except (BadStatusLine, ConnectionLost, URLError, socket.error) as e: raise XCAPError("failed to fetch %s document: %s" % (self.name, e)) except HTTPError as e: if e.status == 404: # Not Found if self.content is not None: self.reset() self.fetch_time = datetime.utcnow() elif e.status != 304: # Other than Not Modified: raise XCAPError("failed to fetch %s document: %s" % (self.name, e)) except ParserError as e: raise XCAPError("failed to parse %s document: %s" % (self.name, e)) else: self.fetch_time = datetime.utcnow() if self.cached: try: self.manager.storage.save(self.name, self.etag + os.linesep + document) except XCAPStorageError: pass def update(self): if not self.dirty: return data = self.content.toxml() if self.content is not None else None try: kw = dict(etag=self.etag) if self.etag is not None else dict(etagnot='*') if data is not None: response = self.manager.client.put(self.application, data, globaltree=self.global_tree, filename=self.filename, headers={'Content-Type': self.payload_type.content_type}, **kw) else: response = self.manager.client.delete(self.application, data, globaltree=self.global_tree, filename=self.filename, **kw) except (BadStatusLine, ConnectionLost, URLError) as e: raise XCAPError("failed to update %s document: %s" % (self.name, e)) except HTTPError as e: if e.status == 412: # Precondition Failed raise FetchRequiredError("document %s was modified externally" % self.name) elif e.status == 404 and data is None: # attempted to delete a document that did't exist in the first place pass else: raise XCAPError("failed to update %s document: %s" % (self.name, e)) self.etag = response.etag if data is not None else None self.dirty = False self.update_time = datetime.utcnow() if self.cached: try: if data is not None: self.manager.storage.save(self.name, self.etag + os.linesep + data) else: self.manager.storage.delete(self.name) except XCAPStorageError: pass class DialogRulesDocument(Document): name = 'dialog-rules' application = 'org.openxcap.dialog-rules' payload_type = dialogrules.DialogRulesDocument default_namespace = dialogrules.namespace global_tree = False filename = 'index' class PresRulesDocument(Document): name = 'pres-rules' application = 'org.openmobilealliance.pres-rules' payload_type = presrules.PresRulesDocument default_namespace = presrules.namespace global_tree = False filename = 'index' class ResourceListsDocument(Document): name = 'resource-lists' application = 'resource-lists' payload_type = resourcelists.ResourceListsDocument default_namespace = resourcelists.namespace global_tree = False filename = 'index' def update(self): if self.content is not None: sipsimple_addressbook = self.content['sipsimple_addressbook'] groups = ItemCollection(sipsimple_addressbook[addressbook.Group, IterateItems]) contacts = ItemCollection(sipsimple_addressbook[addressbook.Contact, IterateItems]) policies = ItemCollection(sipsimple_addressbook[addressbook.Policy, IterateItems]) for group, missing_id in ((group, missing_id) for group in groups for missing_id in [id for id in group.contacts if id not in contacts]): group.contacts.remove(missing_id) if any(item.__dirty__ for item in chain(contacts, policies)): oma_grantedcontacts = self.content['oma_grantedcontacts'] oma_blockedcontacts = self.content['oma_blockedcontacts'] dialog_grantedcontacts = self.content['dialog_grantedcontacts'] dialog_blockedcontacts = self.content['dialog_blockedcontacts'] sipsimple_presence_rls = self.content['sipsimple_presence_rls'] sipsimple_dialog_rls = self.content['sipsimple_dialog_rls'] all_contact_uris = set(uri.uri for contact in contacts for uri in contact.uris) contact_allow_presence_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.presence.policy=='allow') contact_block_presence_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.presence.policy=='block') contact_allow_dialog_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.dialog.policy=='allow') contact_block_dialog_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.dialog.policy=='block') contact_subscribe_presence_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.presence.subscribe==True) contact_subscribe_dialog_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.dialog.subscribe==True) policy_allow_presence_uris = set(policy.uri for policy in policies if policy.presence.policy=='allow') policy_block_presence_uris = set(policy.uri for policy in policies if policy.presence.policy=='block') policy_allow_dialog_uris = set(policy.uri for policy in policies if policy.dialog.policy=='allow') policy_block_dialog_uris = set(policy.uri for policy in policies if policy.dialog.policy=='block') policy_subscribe_presence_uris = set(policy.uri for policy in policies if policy.presence.subscribe==True) policy_subscribe_dialog_uris = set(policy.uri for policy in policies if policy.dialog.subscribe==True) allowed_presence_uris = contact_allow_presence_uris - contact_block_presence_uris | policy_allow_presence_uris - policy_block_presence_uris - all_contact_uris blocked_presence_uris = contact_block_presence_uris | policy_block_presence_uris - all_contact_uris allowed_dialog_uris = contact_allow_dialog_uris - contact_block_dialog_uris | policy_allow_dialog_uris - policy_block_dialog_uris - all_contact_uris blocked_dialog_uris = contact_block_dialog_uris | policy_block_dialog_uris - all_contact_uris subscribe_presence_uris = contact_subscribe_presence_uris | policy_subscribe_presence_uris - all_contact_uris subscribe_dialog_uris = contact_subscribe_dialog_uris | policy_subscribe_dialog_uris - all_contact_uris if allowed_presence_uris != set(entry.uri for entry in oma_grantedcontacts): oma_grantedcontacts.clear() oma_grantedcontacts.update(resourcelists.Entry(uri) for uri in allowed_presence_uris) if blocked_presence_uris != set(entry.uri for entry in oma_blockedcontacts): oma_blockedcontacts.clear() oma_blockedcontacts.update(resourcelists.Entry(uri) for uri in blocked_presence_uris) if allowed_dialog_uris != set(entry.uri for entry in dialog_grantedcontacts): dialog_grantedcontacts.clear() dialog_grantedcontacts.update(resourcelists.Entry(uri) for uri in allowed_dialog_uris) if blocked_dialog_uris != set(entry.uri for entry in dialog_blockedcontacts): dialog_blockedcontacts.clear() dialog_blockedcontacts.update(resourcelists.Entry(uri) for uri in blocked_dialog_uris) if subscribe_presence_uris != set(entry.uri for entry in sipsimple_presence_rls): sipsimple_presence_rls.clear() sipsimple_presence_rls.update(resourcelists.Entry(uri) for uri in subscribe_presence_uris) if subscribe_dialog_uris != set(entry.uri for entry in sipsimple_dialog_rls): sipsimple_dialog_rls.clear() sipsimple_dialog_rls.update(resourcelists.Entry(uri) for uri in subscribe_dialog_uris) super(ResourceListsDocument, self).update() class RLSServicesDocument(Document): name = 'rls-services' application = 'rls-services' payload_type = rlsservices.RLSServicesDocument default_namespace = rlsservices.namespace global_tree = False filename = 'index' class XCAPCapsDocument(Document): name = 'xcap-caps' application = 'xcap-caps' payload_type = xcapcaps.XCAPCapabilitiesDocument default_namespace = xcapcaps.namespace global_tree = True filename = 'index' cached = False def initialize(self): self.supported = True class StatusIconDocument(Document): name = 'status-icon' application = 'org.openmobilealliance.pres-content' payload_type = prescontent.PresenceContentDocument default_namespace = prescontent.namespace global_tree = False filename = 'oma_status-icon/index' class PIDFManipulationDocument(Document): name = 'pidf-manipulation' application = 'pidf-manipulation' payload_type = pidf.PIDFDocument default_namespace = pidf.pidf_namespace global_tree = False filename = 'index' class ItemCollection(object): def __init__(self, items): self.items = OrderedDict((item.id, item) for item in items) def __getitem__(self, key): return self.items[key] def __contains__(self, key): return key in self.items def __iter__(self): return iter(list(self.items.values())) def __reversed__(self): return (self[id] for id in reversed(self.items)) def __len__(self): return len(self.items) def __eq__(self, other): if isinstance(other, ItemCollection): return self.items == other.items return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __repr__(self): return "%s(%r)" % (self.__class__.__name__, list(self.items.values())) def ids(self): return list(self.items.keys()) def iterids(self): return iter(list(self.items.keys())) def get(self, key, default=None): return self.items.get(key, default) def add(self, item): self.items[item.id] = item def remove(self, item): del self.items[item.id] class ContactList(ItemCollection): pass class ContactURIList(ItemCollection): def __init__(self, items, default=None): super(ContactURIList, self).__init__(items) self.default = default def __eq__(self, other): if isinstance(other, ContactURIList): return self.items == other.items and self.default == other.default return NotImplemented def __repr__(self): return "%s(%r, default=%r)" % (self.__class__.__name__, list(self.items.values()), self.default) class Group(object): def __init__(self, id, name, contacts, **attributes): self.id = id self.name = name self.contacts = contacts self.attributes = attributes def __eq__(self, other): if isinstance(other, Group): return self is other or (self.id == other.id and self.name == other.name and self.contacts.ids() == other.contacts.ids() and self.attributes == other.attributes) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __setattr__(self, name, value): if name == 'contacts' and not isinstance(value, ContactList): value = ContactList(value) object.__setattr__(self, name, value) class ContactURI(object): def __init__(self, id, uri, type, **attributes): self.id = id self.uri = uri self.type = type self.attributes = attributes def __eq__(self, other): if isinstance(other, ContactURI): return self is other or (self.id == other.id and self.uri == other.uri and self.type == other.type and self.attributes == other.attributes) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal class EventHandling(object): def __init__(self, policy, subscribe): self.policy = policy self.subscribe = subscribe def __eq__(self, other): if isinstance(other, EventHandling): return self is other or (self.policy == other.policy and self.subscribe == other.subscribe) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __repr__(self): return '%s(%r, %r)' % (self.__class__.__name__, self.policy, self.subscribe) class Contact(object): def __init__(self, id, name, uris, presence_handling=None, dialog_handling=None, **attributes): self.id = id self.name = name self.uris = uris self.dialog = dialog_handling or EventHandling(policy='default', subscribe=False) self.presence = presence_handling or EventHandling(policy='default', subscribe=False) self.attributes = attributes def __eq__(self, other): if isinstance(other, Contact): return self is other or (self.id == other.id and self.name == other.name and self.uris == other.uris and self.dialog == other.dialog and self.presence == other.presence and self.attributes == other.attributes) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __setattr__(self, name, value): if name == 'uris' and not isinstance(value, ContactURIList): value = ContactURIList(value) object.__setattr__(self, name, value) class Policy(object): def __init__(self, id, uri, name, presence_handling=None, dialog_handling=None, **attributes): self.id = id self.uri = uri self.name = name self.dialog = dialog_handling or EventHandling(policy='default', subscribe=False) self.presence = presence_handling or EventHandling(policy='default', subscribe=False) self.attributes = attributes def __eq__(self, other): if isinstance(other, Policy): return self is other or (self.id == other.id and self.uri == other.uri and self.name == other.name and self.dialog == other.dialog and self.presence == other.presence and self.attributes == other.attributes) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal class Addressbook(object): def __init__(self, contacts, groups, policies): self.contacts = contacts self.groups = groups self.policies = policies def __eq__(self, other): if isinstance(other, Addressbook): return self is other or (self.contacts == other.contacts and self.groups == other.groups and self.policies == other.policies) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal @classmethod def from_payload(cls, payload): def payload_to_contact(payload): uris = ContactURIList((ContactURI(uri.id, uri.uri, uri.type, **(uri.attributes or {})) for uri in payload.uris), default=payload.uris.default) presence_handling = EventHandling(payload.presence.policy.value, payload.presence.subscribe.value) dialog_handling = EventHandling(payload.dialog.policy.value, payload.dialog.subscribe.value) return Contact(payload.id, payload.name.value, uris, presence_handling, dialog_handling, **(payload.attributes or {})) def payload_to_group(payload): return Group(payload.id, payload.name.value, [contacts[contact_id] for contact_id in payload.contacts], **(payload.attributes or {})) def payload_to_policy(payload): presence_handling = EventHandling(payload.presence.policy.value, payload.presence.subscribe.value) dialog_handling = EventHandling(payload.dialog.policy.value, payload.dialog.subscribe.value) return Policy(payload.id, payload.uri, payload.name.value, presence_handling, dialog_handling, **(payload.attributes or {})) contacts = ItemCollection(payload_to_contact(item) for item in payload[addressbook.Contact, IterateItems]) groups = ItemCollection(payload_to_group(item) for item in payload[addressbook.Group, IterateItems]) policies = ItemCollection(payload_to_policy(item) for item in payload[addressbook.Policy, IterateItems]) return cls(contacts, groups, policies) class PresenceRules(object): def __init__(self, default_policy): self.default_policy = default_policy def __eq__(self, other): if isinstance(other, PresenceRules): return self is other or (self.default_policy == other.default_policy) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal @classmethod def from_payload(cls, default_rule): default_policy = next(item for item in default_rule.actions if isinstance(item, presrules.SubHandling)).value return cls(default_policy) class DialogRules(object): def __init__(self, default_policy): self.default_policy = default_policy def __eq__(self, other): if isinstance(other, DialogRules): return self is other or (self.default_policy == other.default_policy) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal @classmethod def from_payload(cls, default_rule): if default_rule is not None: default_policy = next(item for item in default_rule.actions if isinstance(item, dialogrules.SubHandling)).value else: default_policy = None return cls(default_policy) class Icon(object): __mimetypes__ = ('image/jpeg', 'image/png', 'image/gif') def __init__(self, data, mime_type, description=None): self.data = data self.mime_type = mime_type self.description = description self.url = None self.etag = None def __eq__(self, other): if isinstance(other, Icon): return self is other or (self.data == other.data and self.mime_type == other.mime_type and self.description == other.description) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __setattr__(self, name, value): if name == 'mime_type' and value not in self.__mimetypes__: raise ValueError("invalid mime type: '%s'. Should be one of: %s" % (value, ', '.join(self.__mimetypes__))) object.__setattr__(self, name, value) @classmethod def from_payload(cls, payload): try: data = base64.decodestring(payload.data.value) except Exception: return None else: description = payload.description.value if payload.description else None return cls(data, payload.mime_type.value, description) class OfflineStatus(object): __slots__ = ('pidf',) def __init__(self, pidf): self.pidf = pidf def __setattr__(self, name, value): if name == 'pidf' and not isinstance(value, pidf.PIDF): raise ValueError("pidf must be a PIDF payload") object.__setattr__(self, name, value) def __eq__(self, other): if isinstance(other, OfflineStatus): return self is other or (self.pidf == other.pidf) return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __getstate__(self): return {'pidf': self.pidf.toxml()} def __setstate__(self, state): self.pidf = pidf.PIDFDocument.parse(state['pidf']) class Operation(object): __params__ = () def __init__(self, **params): for name, value in list(params.items()): setattr(self, name, value) for param in set(self.__params__).difference(params): raise ValueError("missing operation parameter: '%s'" % param) self.applied = False self.timestamp = datetime.utcnow() class NormalizeOperation(Operation): __params__ = () class AddContactOperation(Operation): __params__ = ('contact',) class UpdateContactOperation(Operation): __params__ = ('contact', 'attributes') class RemoveContactOperation(Operation): __params__ = ('contact',) class AddContactURIOperation(Operation): __params__ = ('contact', 'uri') class UpdateContactURIOperation(Operation): __params__ = ('contact', 'uri', 'attributes') class RemoveContactURIOperation(Operation): __params__ = ('contact', 'uri') class AddGroupOperation(Operation): __params__ = ('group',) class UpdateGroupOperation(Operation): __params__ = ('group', 'attributes') class RemoveGroupOperation(Operation): __params__ = ('group',) class AddGroupMemberOperation(Operation): __params__ = ('group', 'contact') class RemoveGroupMemberOperation(Operation): __params__ = ('group', 'contact') class AddPolicyOperation(Operation): __params__ = ('policy',) class UpdatePolicyOperation(Operation): __params__ = ('policy', 'attributes') class RemovePolicyOperation(Operation): __params__ = ('policy',) class SetDefaultPresencePolicyOperation(Operation): __params__ = ('policy',) class SetDefaultDialogPolicyOperation(Operation): __params__ = ('policy',) class SetStatusIconOperation(Operation): __params__ = ('icon',) class SetOfflineStatusOperation(Operation): __params__ = ('status',) class XCAPSubscriber(Subscriber): __transports__ = frozenset(['tls', 'tcp']) @property def event(self): return 'xcap-diff' @property def content(self): rlist = resourcelists.List() for document in (doc for doc in self.account.xcap_manager.documents if doc.supported): rlist.add(resourcelists.Entry(document.relative_url)) return Content(resourcelists.ResourceLists([rlist]).toxml(), resourcelists.ResourceListsDocument.content_type) +@implementer(IObserver) class XCAPManager(object): - implements(IObserver) def __init__(self, account): from sipsimple.application import SIPApplication if SIPApplication.storage is None: raise RuntimeError("SIPApplication.storage must be defined before instantiating XCAPManager") storage = SIPApplication.storage.xcap_storage_factory(account.id) if not IXCAPStorage.providedBy(storage): raise TypeError("storage must implement the IXCAPStorage interface") self.account = account self.storage = storage self.storage_factory = SIPApplication.storage.xcap_storage_factory self.client = None self.command_proc = None self.command_channel = coros.queue() self.last_fetch_time = datetime.fromtimestamp(0) self.last_update_time = datetime.fromtimestamp(0) self.not_executed_fetch = None self.state = 'stopped' self.timer = None self.transaction_level = 0 self.xcap_subscriber = None self.server_caps = XCAPCapsDocument(self) self.dialog_rules = DialogRulesDocument(self) self.pidf_manipulation = PIDFManipulationDocument(self) self.pres_rules = PresRulesDocument(self) self.resource_lists = ResourceListsDocument(self) self.rls_services = RLSServicesDocument(self) self.status_icon = StatusIconDocument(self) for document in self.documents: document.load_from_cache() try: journal = self.storage.load('journal') except XCAPStorageError: self.journal = [] else: try: self.journal = pickle.loads(journal) except Exception: self.journal = [] for operation in self.journal: operation.applied = False notification_center = NotificationCenter() notification_center.add_observer(self, sender=account, name='CFGSettingsObjectDidChange') notification_center.add_observer(self, sender=account, name='CFGSettingsObjectWasDeleted') @property def state(self): return self.__dict__['state'] @state.setter def state(self, value): old_value = self.__dict__.get('state', Null) self.__dict__['state'] = value if old_value != value and old_value is not Null: notification_center = NotificationCenter() notification_center.post_notification('XCAPManagerDidChangeState', sender=self, data=NotificationData(prev_state=old_value, state=value)) @property def documents(self): return [self.resource_lists, self.rls_services, self.pres_rules, self.dialog_rules, self.pidf_manipulation, self.status_icon] @property def document_names(self): return [document.name for document in self.documents] @property def xcap_root(self): return getattr(self.client, 'root', None) @property def rls_presence_uri(self): return SIPAddress('%s+presence@%s' % (self.account.id.username, self.account.id.domain)) @property def rls_dialog_uri(self): return SIPAddress('%s+dialog@%s' % (self.account.id.username, self.account.id.domain)) @execute_once def init(self): """ Initializes the XCAP manager before it can be started. Needs to be called before any other method and in a green thread. """ self.command_proc = proc.spawn(self._run) def start(self): """ Starts the XCAP manager. This method needs to be called in a green thread. """ command = Command('start') self.command_channel.send(command) command.wait() def stop(self): """ Stops the XCAP manager. This method blocks until all the operations are stopped and needs to be called in a green thread. """ command = Command('stop') self.command_channel.send(command) command.wait() def transaction(self): return XCAPTransaction(self) @run_in_twisted_thread def start_transaction(self): self.transaction_level += 1 @run_in_twisted_thread def commit_transaction(self): if self.transaction_level == 0: return self.transaction_level -= 1 if self.transaction_level == 0 and self.journal: self._save_journal() self.command_channel.send(Command('update')) def add_contact(self, contact): self._schedule_operation(AddContactOperation(contact=contact)) def update_contact(self, contact, attributes): self._schedule_operation(UpdateContactOperation(contact=contact, attributes=attributes)) def remove_contact(self, contact): self._schedule_operation(RemoveContactOperation(contact=contact)) def add_contact_uri(self, contact, uri): self._schedule_operation(AddContactURIOperation(contact=contact, uri=uri)) def update_contact_uri(self, contact, uri, attributes): self._schedule_operation(UpdateContactURIOperation(contact=contact, uri=uri, attributes=attributes)) def remove_contact_uri(self, contact, uri): self._schedule_operation(RemoveContactURIOperation(contact=contact, uri=uri)) def add_group(self, group): self._schedule_operation(AddGroupOperation(group=group)) def update_group(self, group, attributes): self._schedule_operation(UpdateGroupOperation(group=group, attributes=attributes)) def remove_group(self, group): self._schedule_operation(RemoveGroupOperation(group=group)) def add_group_member(self, group, contact): self._schedule_operation(AddGroupMemberOperation(group=group, contact=contact)) def remove_group_member(self, group, contact): self._schedule_operation(RemoveGroupMemberOperation(group=group, contact=contact)) def add_policy(self, policy): self._schedule_operation(AddPolicyOperation(policy=policy)) def update_policy(self, policy, attributes): self._schedule_operation(UpdatePolicyOperation(policy=policy, attributes=attributes)) def remove_policy(self, policy): self._schedule_operation(RemovePolicyOperation(policy=policy)) def set_default_presence_policy(self, policy): self._schedule_operation(SetDefaultPresencePolicyOperation(policy=presrules.SubHandlingValue(policy))) def set_default_dialog_policy(self, policy): self._schedule_operation(SetDefaultDialogPolicyOperation(policy=dialogrules.SubHandlingValue(policy))) def set_status_icon(self, icon): self._schedule_operation(SetStatusIconOperation(icon=icon)) def set_offline_status(self, status): self._schedule_operation(SetOfflineStatusOperation(status=status)) @run_in_twisted_thread def _schedule_operation(self, operation): self.journal.append(operation) if self.transaction_level == 0: self._save_journal() self.command_channel.send(Command('update')) def _run(self): while True: command = self.command_channel.wait() try: handler = getattr(self, '_CH_%s' % command.name) handler(command) except: self.command_proc = None raise # Command handlers # def _CH_start(self, command): if self.state != 'stopped': command.signal() return self.state = 'initializing' self.xcap_subscriber = XCAPSubscriber(self.account) notification_center = NotificationCenter() notification_center.post_notification('XCAPManagerWillStart', sender=self) notification_center.add_observer(self, sender=self.xcap_subscriber) notification_center.add_observer(self, sender=SIPSimpleSettings(), name='CFGSettingsObjectDidChange') self.xcap_subscriber.start() self.command_channel.send(Command('initialize')) notification_center.post_notification('XCAPManagerDidStart', sender=self) command.signal() def _CH_stop(self, command): if self.state in ('stopped', 'terminated'): command.signal() return notification_center = NotificationCenter() notification_center.post_notification('XCAPManagerWillEnd', sender=self) notification_center.remove_observer(self, sender=self.xcap_subscriber) notification_center.remove_observer(self, sender=SIPSimpleSettings(), name='CFGSettingsObjectDidChange') if self.timer is not None and self.timer.active(): self.timer.cancel() self.timer = None self.xcap_subscriber.stop() self.xcap_subscriber = None self.client = None self.state = 'stopped' self._save_journal() notification_center.post_notification('XCAPManagerDidEnd', sender=self) command.signal() def _CH_cleanup(self, command): if self.state != 'stopped': command.signal() return try: self.storage.purge() except XCAPStorageError: pass self.journal = [] self.state = 'terminated' command.signal() raise proc.ProcExit def _CH_initialize(self, command): self.state = 'initializing' if self.timer is not None and self.timer.active(): self.timer.cancel() self.timer = None if self.account.xcap.xcap_root: self.client = XCAPClient(self.account.xcap.xcap_root, self.account.id, password=self.account.auth.password) else: try: lookup = DNSLookup() xcap_root = random.choice(lookup.lookup_xcap_server(self.account.uri).wait()) except DNSLookupError: self.timer = self._schedule_command(60, Command('initialize', command.event)) return else: self.client = XCAPClient(xcap_root, self.account.id, password=self.account.auth.password) try: self.server_caps.fetch() except XCAPError: self.timer = self._schedule_command(60, Command('initialize', command.event)) return else: if self.server_caps.content is None: # XCAP server must always return some content for xcap-caps self.timer = self._schedule_command(60, Command('initialize', command.event)) return if not set(self.server_caps.content.auids).issuperset(('resource-lists', 'rls-services', 'org.openmobilealliance.pres-rules')): # Server must support at least resource-lists, rls-services and org.openmobilealliance.pres-rules self.timer = self._schedule_command(3600, Command('initialize', command.event)) return self.server_caps.initialize() for document in self.documents: document.initialize(self.server_caps.content) notification_center = NotificationCenter() notification_center.post_notification('XCAPManagerDidDiscoverServerCapabilities', sender=self, data=NotificationData(auids=self.server_caps.content.auids)) self.state = 'fetching' self.command_channel.send(Command('fetch', documents=set(self.document_names))) self.xcap_subscriber.activate() def _CH_reload(self, command): if self.state == 'terminated': command.signal() return if '__id__' in command.modified: try: self.storage.purge() except XCAPStorageError: pass self.storage = self.storage_factory(self.account.id) self.journal = [] self._save_journal() if {'__id__', 'xcap.xcap_root'}.intersection(command.modified): for document in self.documents: document.reset() if self.state == 'stopped': command.signal() return if {'__id__', 'auth.username', 'auth.password', 'xcap.xcap_root'}.intersection(command.modified): self.state = 'initializing' self.command_channel.send(Command('initialize')) else: self.xcap_subscriber.resubscribe() command.signal() def _CH_fetch(self, command): if self.state not in ('insync', 'fetching'): if self.not_executed_fetch is not None: command.documents.update(self.not_executed_fetch.documents) self.not_executed_fetch = command return if self.not_executed_fetch is not None: command.documents.update(self.not_executed_fetch.documents) self.not_executed_fetch = None self.state = 'fetching' if self.timer is not None and self.timer.active(): command.documents.update(self.timer.command.documents) self.timer.cancel() self.timer = None try: self._fetch_documents(command.documents) except XCAPError: self.timer = self._schedule_command(60, Command('fetch', command.event, documents=command.documents)) return if not self.journal and self.last_fetch_time > datetime.fromtimestamp(0) and all(doc.fetch_time < command.timestamp for doc in self.documents): self.last_fetch_time = datetime.utcnow() self.state = 'insync' return else: self.last_fetch_time = datetime.utcnow() self.state = 'updating' if not self.journal or type(self.journal[0]) is not NormalizeOperation: self.journal.insert(0, NormalizeOperation()) self.command_channel.send(Command('update', command.event)) def _CH_update(self, command): if self.state not in ('insync', 'updating'): return if self.transaction_level != 0: return self.state = 'updating' if self.timer is not None and self.timer.active(): self.timer.cancel() self.timer = None journal = self.journal[:] for operation in (operation for operation in journal if not operation.applied): handler = getattr(self, '_OH_%s' % operation.__class__.__name__) try: handler(operation) except Exception: # Error while applying operation, needs to be logged -Luci log.exception() operation.applied = True api.sleep(0) # Operations are quite CPU intensive try: for document in (doc for doc in self.documents if doc.dirty and doc.supported): document.update() except FetchRequiredError: for document in (doc for doc in self.documents if doc.dirty and doc.supported): document.reset() for operation in journal: operation.applied = False self.state = 'fetching' self.command_channel.send(Command('fetch', documents=set(self.document_names))) # Try to fetch them all just in case except XCAPError: self.timer = self._schedule_command(60, Command('update')) else: del self.journal[:len(journal)] if not self.journal: self.state = 'insync' if any(max(doc.update_time, doc.fetch_time) > self.last_update_time for doc in self.documents): self._load_data() self.last_update_time = datetime.utcnow() command.signal() if self.not_executed_fetch is not None: self.command_channel.send(self.not_executed_fetch) self.not_executed_fetch = None self._save_journal() # Operation handlers # def _OH_NormalizeOperation(self, operation): # Normalize resource-lists # if self.resource_lists.content is None: self.resource_lists.content = resourcelists.ResourceLists() resource_lists = self.resource_lists.content try: oma_buddylist = resource_lists['oma_buddylist'] except KeyError: oma_buddylist = resourcelists.List(name='oma_buddylist') resource_lists.add(oma_buddylist) try: oma_grantedcontacts = resource_lists['oma_grantedcontacts'] except KeyError: oma_grantedcontacts = resourcelists.List(name='oma_grantedcontacts') resource_lists.add(oma_grantedcontacts) try: oma_blockedcontacts = resource_lists['oma_blockedcontacts'] except KeyError: oma_blockedcontacts = resourcelists.List(name='oma_blockedcontacts') resource_lists.add(oma_blockedcontacts) try: oma_allcontacts = resource_lists['oma_allcontacts'] except KeyError: oma_allcontacts = resourcelists.List(name='oma_allcontacts') oma_allcontacts.add(resourcelists.External(self.resource_lists.url + '/~~' + resource_lists.get_xpath(oma_buddylist))) oma_allcontacts.add(resourcelists.External(self.resource_lists.url + '/~~' + resource_lists.get_xpath(oma_grantedcontacts))) oma_allcontacts.add(resourcelists.External(self.resource_lists.url + '/~~' + resource_lists.get_xpath(oma_blockedcontacts))) resource_lists.add(oma_allcontacts) try: dialog_grantedcontacts = resource_lists['dialog_grantedcontacts'] except KeyError: dialog_grantedcontacts = resourcelists.List(name='dialog_grantedcontacts') resource_lists.add(dialog_grantedcontacts) try: dialog_blockedcontacts = resource_lists['dialog_blockedcontacts'] except KeyError: dialog_blockedcontacts = resourcelists.List(name='dialog_blockedcontacts') resource_lists.add(dialog_blockedcontacts) try: sipsimple_presence_rls = resource_lists['sipsimple_presence_rls'] except KeyError: sipsimple_presence_rls = resourcelists.List(name='sipsimple_presence_rls') resource_lists.add(sipsimple_presence_rls) try: sipsimple_dialog_rls = resource_lists['sipsimple_dialog_rls'] except KeyError: sipsimple_dialog_rls = resourcelists.List(name='sipsimple_dialog_rls') resource_lists.add(sipsimple_dialog_rls) try: sipsimple_addressbook = resource_lists['sipsimple_addressbook'] except KeyError: sipsimple_addressbook = resourcelists.List(name='sipsimple_addressbook') resource_lists.add(sipsimple_addressbook) for cls in (cls for cls in sipsimple_addressbook[IterateTypes] if cls not in (addressbook.Contact, addressbook.Group, addressbook.Policy)): del sipsimple_addressbook[cls, All] for cls in (cls for cls in oma_grantedcontacts[IterateTypes] if cls is not resourcelists.Entry): del oma_grantedcontacts[cls, All] for cls in (cls for cls in oma_blockedcontacts[IterateTypes] if cls is not resourcelists.Entry): del oma_blockedcontacts[cls, All] for cls in (cls for cls in dialog_grantedcontacts[IterateTypes] if cls is not resourcelists.Entry): del dialog_grantedcontacts[cls, All] for cls in (cls for cls in dialog_blockedcontacts[IterateTypes] if cls is not resourcelists.Entry): del dialog_blockedcontacts[cls, All] for cls in (cls for cls in sipsimple_presence_rls[IterateTypes] if cls is not resourcelists.Entry): del sipsimple_presence_rls[cls, All] for cls in (cls for cls in sipsimple_dialog_rls[IterateTypes] if cls is not resourcelists.Entry): del sipsimple_dialog_rls[cls, All] groups = ItemCollection(sipsimple_addressbook[addressbook.Group, IterateItems]) contacts = ItemCollection(sipsimple_addressbook[addressbook.Contact, IterateItems]) policies = ItemCollection(sipsimple_addressbook[addressbook.Policy, IterateItems]) for group, missing_id in [(group, missing_id) for group in groups for missing_id in (id for id in group.contacts if id not in contacts)]: group.contacts.remove(missing_id) all_contact_uris = set(uri.uri for contact in contacts for uri in contact.uris) contact_allow_presence_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.presence.policy=='allow') contact_block_presence_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.presence.policy=='block') contact_allow_dialog_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.dialog.policy=='allow') contact_block_dialog_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.dialog.policy=='block') contact_subscribe_presence_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.presence.subscribe==True) contact_subscribe_dialog_uris = set(uri.uri for contact in contacts for uri in contact.uris if contact.dialog.subscribe==True) policy_allow_presence_uris = set(policy.uri for policy in policies if policy.presence.policy=='allow') policy_block_presence_uris = set(policy.uri for policy in policies if policy.presence.policy=='block') policy_allow_dialog_uris = set(policy.uri for policy in policies if policy.dialog.policy=='allow') policy_block_dialog_uris = set(policy.uri for policy in policies if policy.dialog.policy=='block') policy_subscribe_presence_uris = set(policy.uri for policy in policies if policy.presence.subscribe==True) policy_subscribe_dialog_uris = set(policy.uri for policy in policies if policy.dialog.subscribe==True) allowed_presence_uris = contact_allow_presence_uris - contact_block_presence_uris | policy_allow_presence_uris - policy_block_presence_uris - all_contact_uris blocked_presence_uris = contact_block_presence_uris | policy_block_presence_uris - all_contact_uris allowed_dialog_uris = contact_allow_dialog_uris - contact_block_dialog_uris | policy_allow_dialog_uris - policy_block_dialog_uris - all_contact_uris blocked_dialog_uris = contact_block_dialog_uris | policy_block_dialog_uris - all_contact_uris subscribe_presence_uris = contact_subscribe_presence_uris | policy_subscribe_presence_uris - all_contact_uris subscribe_dialog_uris = contact_subscribe_dialog_uris | policy_subscribe_dialog_uris - all_contact_uris if allowed_presence_uris != set(entry.uri for entry in oma_grantedcontacts): oma_grantedcontacts.clear() oma_grantedcontacts.update(resourcelists.Entry(uri) for uri in allowed_presence_uris) if blocked_presence_uris != set(entry.uri for entry in oma_blockedcontacts): oma_blockedcontacts.clear() oma_blockedcontacts.update(resourcelists.Entry(uri) for uri in blocked_presence_uris) if allowed_dialog_uris != set(entry.uri for entry in dialog_grantedcontacts): dialog_grantedcontacts.clear() dialog_grantedcontacts.update(resourcelists.Entry(uri) for uri in allowed_dialog_uris) if blocked_dialog_uris != set(entry.uri for entry in dialog_blockedcontacts): dialog_blockedcontacts.clear() dialog_blockedcontacts.update(resourcelists.Entry(uri) for uri in blocked_dialog_uris) if subscribe_presence_uris != set(entry.uri for entry in sipsimple_presence_rls): sipsimple_presence_rls.clear() sipsimple_presence_rls.update(resourcelists.Entry(uri) for uri in subscribe_presence_uris) if subscribe_dialog_uris != set(entry.uri for entry in sipsimple_dialog_rls): sipsimple_dialog_rls.clear() sipsimple_dialog_rls.update(resourcelists.Entry(uri) for uri in subscribe_dialog_uris) # Normalize rls-services # if self.rls_services.content is None: self.rls_services.content = rlsservices.RLSServices() rls_services = self.rls_services.content rls_presence_uri = 'sip:' + self.rls_presence_uri rls_dialog_uri = 'sip:' + self.rls_dialog_uri rls_presence_list = rlsservices.ResourceList(self.resource_lists.url + '/~~' + resource_lists.get_xpath(sipsimple_presence_rls)) rls_dialog_list = rlsservices.ResourceList(self.resource_lists.url + '/~~' + resource_lists.get_xpath(sipsimple_dialog_rls)) try: rls_presence_service = rls_services[rls_presence_uri] except KeyError: rls_presence_service = rlsservices.Service(rls_presence_uri, list=rls_presence_list, packages=['presence']) rls_services.add(rls_presence_service) else: if rls_presence_service.list != rls_presence_list: rls_presence_service.list = rls_presence_list if list(rls_presence_service.packages) != ['presence']: rls_presence_service.packages = ['presence'] try: rls_dialog_service = rls_services[rls_dialog_uri] except KeyError: rls_dialog_service = rlsservices.Service(rls_dialog_uri, list=rls_dialog_list, packages=['dialog']) rls_services.add(rls_dialog_service) else: if rls_dialog_service.list != rls_dialog_list: rls_dialog_service.list = rls_dialog_list if list(rls_dialog_service.packages) != ['dialog']: rls_dialog_service.packages = ['dialog'] # Normalize pres-rules # if self.pres_rules.content is None: self.pres_rules.content = presrules.PresRules() def fix_subhandling(rule, valid_values=[]): subhandling_elements = sorted((item for item in rule.actions if isinstance(item, presrules.SubHandling)), key=attrgetter('value.priority')) if not subhandling_elements: subhandling_elements = [presrules.SubHandling('block')] # spec specifies that missing SubHandling means block rule.actions.update(subhandling_elements) subhandling = subhandling_elements.pop() for item in subhandling_elements: # remove any extraneous SubHandling elements rule.actions.remove(item) if subhandling.value not in valid_values: subhandling.value = valid_values[0] pres_rules = self.pres_rules.content oma_grantedcontacts_ref = omapolicy.ExternalList([self.resource_lists.url + '/~~' + resource_lists.get_xpath(oma_grantedcontacts)]) oma_blockedcontacts_ref = omapolicy.ExternalList([self.resource_lists.url + '/~~' + resource_lists.get_xpath(oma_blockedcontacts)]) try: wp_prs_grantedcontacts = pres_rules['wp_prs_grantedcontacts'] except KeyError: wp_prs_grantedcontacts = commonpolicy.Rule('wp_prs_grantedcontacts', conditions=[oma_grantedcontacts_ref], actions=[presrules.SubHandling('allow')]) pres_rules.add(wp_prs_grantedcontacts) else: fix_subhandling(wp_prs_grantedcontacts, valid_values=['allow']) if list(wp_prs_grantedcontacts.conditions) != [oma_grantedcontacts_ref]: wp_prs_grantedcontacts.conditions = [oma_grantedcontacts_ref] if wp_prs_grantedcontacts.transformations: wp_prs_grantedcontacts.transformations = None try: wp_prs_blockedcontacts = pres_rules['wp_prs_blockedcontacts'] except KeyError: wp_prs_blockedcontacts = commonpolicy.Rule('wp_prs_blockedcontacts', conditions=[oma_blockedcontacts_ref], actions=[presrules.SubHandling('polite-block')]) pres_rules.add(wp_prs_blockedcontacts) else: fix_subhandling(wp_prs_blockedcontacts, valid_values=['polite-block']) if list(wp_prs_blockedcontacts.conditions) != [oma_blockedcontacts_ref]: wp_prs_blockedcontacts.conditions = [oma_blockedcontacts_ref] if wp_prs_blockedcontacts.transformations: wp_prs_blockedcontacts.transformations = None wp_prs_unlisted = pres_rules.get('wp_prs_unlisted', None) wp_prs_allow_unlisted = pres_rules.get('wp_prs_allow_unlisted', None) if wp_prs_unlisted is not None and wp_prs_allow_unlisted is not None: pres_rules.remove(wp_prs_allow_unlisted) wp_prs_allow_unlisted = None wp_prs_unlisted_rule = wp_prs_unlisted or wp_prs_allow_unlisted if wp_prs_unlisted_rule is None: wp_prs_unlisted = commonpolicy.Rule('wp_prs_unlisted', conditions=[omapolicy.OtherIdentity()], actions=[presrules.SubHandling('confirm')]) pres_rules.add(wp_prs_unlisted) wp_prs_unlisted_rule = wp_prs_unlisted else: if wp_prs_unlisted_rule is wp_prs_unlisted: fix_subhandling(wp_prs_unlisted_rule, valid_values=['confirm', 'block', 'polite-block']) else: fix_subhandling(wp_prs_unlisted_rule, valid_values=['allow']) if list(wp_prs_unlisted_rule.conditions) != [omapolicy.OtherIdentity()]: wp_prs_unlisted_rule.conditions = [omapolicy.OtherIdentity()] if wp_prs_unlisted_rule.transformations: wp_prs_unlisted_rule.transformations = None match_anonymous = omapolicy.AnonymousRequest() try: wp_prs_block_anonymous = pres_rules['wp_prs_block_anonymous'] except KeyError: wp_prs_block_anonymous = commonpolicy.Rule('wp_prs_block_anonymous', conditions=[match_anonymous], actions=[presrules.SubHandling('block')]) pres_rules.add(wp_prs_block_anonymous) else: fix_subhandling(wp_prs_block_anonymous, valid_values=['block', 'polite-block']) if list(wp_prs_block_anonymous.conditions) != [match_anonymous]: wp_prs_block_anonymous.conditions = [match_anonymous] if wp_prs_block_anonymous.transformations: wp_prs_block_anonymous.transformations = None match_self = commonpolicy.Identity([commonpolicy.IdentityOne('sip:' + self.account.id)]) try: wp_prs_allow_own = pres_rules['wp_prs_allow_own'] except KeyError: wp_prs_allow_own = commonpolicy.Rule('wp_prs_allow_own', conditions=[match_self], actions=[presrules.SubHandling('allow')]) pres_rules.add(wp_prs_allow_own) else: fix_subhandling(wp_prs_allow_own, valid_values=['allow']) if list(wp_prs_allow_own.conditions) != [match_self]: wp_prs_allow_own.conditions = [match_self] if wp_prs_allow_own.transformations: wp_prs_allow_own.transformations = None # Remove any other rules all_rule_names = set(pres_rules[IterateIDs]) known_rule_names = {'wp_prs_grantedcontacts', 'wp_prs_blockedcontacts', 'wp_prs_unlisted', 'wp_prs_allow_unlisted', 'wp_prs_block_anonymous', 'wp_prs_allow_own'} for name in all_rule_names - known_rule_names: del pres_rules[name] del fix_subhandling # Normalize dialog-rules # if self.dialog_rules.supported: if self.dialog_rules.content is None: self.dialog_rules.content = dialogrules.DialogRules() elif self.dialog_rules.content.element.nsmap.get('dr') != dialogrules.namespace: # TODO: this elif branch should be removed in a later version as it is self.dialog_rules.content = dialogrules.DialogRules() # only used to discard documents created with the old namespace. -Dan def fix_subhandling(rule, valid_values=()): subhandling_elements = sorted((item for item in rule.actions if isinstance(item, dialogrules.SubHandling)), key=attrgetter('value.priority')) if not subhandling_elements: subhandling_elements = [dialogrules.SubHandling('block')] # spec specifies that missing SubHandling means block rule.actions.update(subhandling_elements) subhandling = subhandling_elements.pop() for item in subhandling_elements: # remove any extraneous SubHandling elements rule.actions.remove(item) if subhandling.value not in valid_values: subhandling.value = valid_values[0] dialog_rules = self.dialog_rules.content dialog_grantedcontacts_ref = omapolicy.ExternalList([self.resource_lists.url + '/~~' + resource_lists.get_xpath(dialog_grantedcontacts)]) dialog_blockedcontacts_ref = omapolicy.ExternalList([self.resource_lists.url + '/~~' + resource_lists.get_xpath(dialog_blockedcontacts)]) try: wp_dlg_grantedcontacts = dialog_rules['wp_dlg_grantedcontacts'] except KeyError: wp_dlg_grantedcontacts = commonpolicy.Rule('wp_dlg_grantedcontacts', conditions=[dialog_grantedcontacts_ref], actions=[dialogrules.SubHandling('allow')]) dialog_rules.add(wp_dlg_grantedcontacts) else: fix_subhandling(wp_dlg_grantedcontacts, valid_values=['allow']) if list(wp_dlg_grantedcontacts.conditions) != [dialog_grantedcontacts_ref]: wp_dlg_grantedcontacts.conditions = [dialog_grantedcontacts_ref] if wp_dlg_grantedcontacts.transformations: wp_dlg_grantedcontacts.transformations = None try: wp_dlg_blockedcontacts = dialog_rules['wp_dlg_blockedcontacts'] except KeyError: wp_dlg_blockedcontacts = commonpolicy.Rule('wp_dlg_blockedcontacts', conditions=[dialog_blockedcontacts_ref], actions=[dialogrules.SubHandling('polite-block')]) dialog_rules.add(wp_dlg_blockedcontacts) else: fix_subhandling(wp_dlg_blockedcontacts, valid_values=['polite-block']) if list(wp_dlg_blockedcontacts.conditions) != [dialog_blockedcontacts_ref]: wp_dlg_blockedcontacts.conditions = [dialog_blockedcontacts_ref] if wp_dlg_blockedcontacts.transformations: wp_dlg_blockedcontacts.transformations = None wp_dlg_unlisted = dialog_rules.get('wp_dlg_unlisted', None) wp_dlg_allow_unlisted = dialog_rules.get('wp_dlg_allow_unlisted', None) if wp_dlg_unlisted is not None and wp_dlg_allow_unlisted is not None: dialog_rules.remove(wp_dlg_allow_unlisted) wp_dlg_allow_unlisted = None wp_dlg_unlisted_rule = wp_dlg_unlisted or wp_dlg_allow_unlisted if wp_dlg_unlisted_rule is None: wp_dlg_unlisted = commonpolicy.Rule('wp_dlg_unlisted', conditions=[omapolicy.OtherIdentity()], actions=[dialogrules.SubHandling('confirm')]) dialog_rules.add(wp_dlg_unlisted) wp_dlg_unlisted_rule = wp_dlg_unlisted else: if wp_dlg_unlisted_rule is wp_dlg_unlisted: fix_subhandling(wp_dlg_unlisted_rule, valid_values=['confirm', 'block', 'polite-block']) else: fix_subhandling(wp_dlg_unlisted_rule, valid_values=['allow']) if list(wp_dlg_unlisted_rule.conditions) != [omapolicy.OtherIdentity()]: wp_dlg_unlisted_rule.conditions = [omapolicy.OtherIdentity()] if wp_dlg_unlisted_rule.transformations: wp_dlg_unlisted_rule.transformations = None match_anonymous = omapolicy.AnonymousRequest() try: wp_dlg_block_anonymous = dialog_rules['wp_dlg_block_anonymous'] except KeyError: wp_dlg_block_anonymous = commonpolicy.Rule('wp_dlg_block_anonymous', conditions=[match_anonymous], actions=[dialogrules.SubHandling('block')]) dialog_rules.add(wp_dlg_block_anonymous) else: fix_subhandling(wp_dlg_block_anonymous, valid_values=['block', 'polite-block']) if list(wp_dlg_block_anonymous.conditions) != [match_anonymous]: wp_dlg_block_anonymous.conditions = [match_anonymous] if wp_dlg_block_anonymous.transformations: wp_dlg_block_anonymous.transformations = None match_self = commonpolicy.Identity([commonpolicy.IdentityOne('sip:' + self.account.id)]) try: wp_dlg_allow_own = dialog_rules['wp_dlg_allow_own'] except KeyError: wp_dlg_allow_own = commonpolicy.Rule('wp_dlg_allow_own', conditions=[match_self], actions=[dialogrules.SubHandling('allow')]) dialog_rules.add(wp_dlg_allow_own) else: fix_subhandling(wp_dlg_allow_own, valid_values=['allow']) if list(wp_dlg_allow_own.conditions) != [match_self]: wp_dlg_allow_own.conditions = [match_self] if wp_dlg_allow_own.transformations: wp_dlg_allow_own.transformations = None # Remove any other rules all_rule_names = set(dialog_rules[IterateIDs]) known_rule_names = {'wp_dlg_grantedcontacts', 'wp_dlg_blockedcontacts', 'wp_dlg_unlisted', 'wp_dlg_allow_unlisted', 'wp_dlg_block_anonymous', 'wp_dlg_allow_own'} for name in all_rule_names - known_rule_names: del dialog_rules[name] # Normalize status icon # if self.status_icon.supported and self.status_icon.content is not None: content = self.status_icon.content if None in (content.encoding, content.mime_type) or content.encoding.value.lower() != 'base64' or content.mime_type.value.lower() not in Icon.__mimetypes__: self.status_icon.content = None self.status_icon.dirty = True def _OH_AddContactOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] contact = operation.contact presence_handling = addressbook.PresenceHandling(contact.presence.policy, contact.presence.subscribe) dialog_handling = addressbook.DialogHandling(contact.dialog.policy, contact.dialog.subscribe) xml_contact = addressbook.Contact(contact.id, contact.name, presence_handling=presence_handling, dialog_handling=dialog_handling) for uri in contact.uris: contact_uri = addressbook.ContactURI(uri.id, uri.uri, uri.type) contact_uri.attributes = addressbook.ContactURI.attributes.type(uri.attributes) xml_contact.uris.add(contact_uri) xml_contact.uris.default = contact.uris.default xml_contact.attributes = addressbook.Contact.attributes.type(contact.attributes) sipsimple_addressbook.add(xml_contact) def _OH_UpdateContactOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: contact = sipsimple_addressbook[addressbook.Contact, operation.contact.id] except KeyError: return attributes = dict(operation.attributes) attributes.pop('id', None) # id is never modified attributes.pop('uris', None) # uris are modified using dedicated methods if 'name' in attributes: contact.name = attributes.pop('name') if 'uris.default' in attributes: contact.uris.default = attributes.pop('uris.default') if 'presence.policy' in attributes: contact.presence.policy = attributes.pop('presence.policy') if 'presence.subscribe' in attributes: contact.presence.subscribe = attributes.pop('presence.subscribe') if 'dialog.policy' in attributes: contact.dialog.policy = attributes.pop('dialog.policy') if 'dialog.subscribe' in attributes: contact.dialog.subscribe = attributes.pop('dialog.subscribe') if contact.attributes is None: contact.attributes = addressbook.Contact.attributes.type() contact.attributes.update(attributes) def _OH_RemoveContactOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] for group in (group for group in sipsimple_addressbook[addressbook.Group, IterateItems] if operation.contact.id in group.contacts): group.contacts.remove(operation.contact.id) try: del sipsimple_addressbook[addressbook.Contact, operation.contact.id] except KeyError: pass def _OH_AddContactURIOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: contact = sipsimple_addressbook[addressbook.Contact, operation.contact.id] except KeyError: return uri = addressbook.ContactURI(operation.uri.id, operation.uri.uri, operation.uri.type) uri.attributes = addressbook.ContactURI.attributes.type(operation.uri.attributes) contact.uris.add(uri) def _OH_UpdateContactURIOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: contact = sipsimple_addressbook[addressbook.Contact, operation.contact.id] uri = contact.uris[operation.uri.id] except KeyError: return attributes = dict(operation.attributes) attributes.pop('id', None) # id is never modified if 'uri' in attributes: uri.uri = attributes.pop('uri') if 'type' in attributes: uri.type = attributes.pop('type') if uri.attributes is None: uri.attributes = addressbook.ContactURI.attributes.type() uri.attributes.update(attributes) def _OH_RemoveContactURIOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: contact = sipsimple_addressbook[addressbook.Contact, operation.contact.id] del contact.uris[operation.uri.id] except KeyError: pass def _OH_AddGroupOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] group = addressbook.Group(operation.group.id, operation.group.name, [contact.id for contact in operation.group.contacts]) group.attributes = addressbook.Group.attributes.type(operation.group.attributes) sipsimple_addressbook.add(group) def _OH_UpdateGroupOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: group = sipsimple_addressbook[addressbook.Group, operation.group.id] except KeyError: return attributes = dict(operation.attributes) attributes.pop('id', None) # id is never modified attributes.pop('contacts', None) # contacts are added/removed using dedicated methods if 'name' in attributes: group.name = attributes.pop('name') if group.attributes is None: group.attributes = addressbook.Group.attributes.type() group.attributes.update(attributes) def _OH_RemoveGroupOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: del sipsimple_addressbook[addressbook.Group, operation.group.id] except KeyError: pass def _OH_AddGroupMemberOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: group = sipsimple_addressbook[addressbook.Group, operation.group.id] except KeyError: return if operation.contact.id in group.contacts: return group.contacts.add(operation.contact.id) def _OH_RemoveGroupMemberOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: group = sipsimple_addressbook[addressbook.Group, operation.group.id] group.contacts.remove(operation.contact.id) except KeyError: return def _OH_AddPolicyOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] presence_handling = addressbook.PresenceHandling(operation.policy.presence.policy, operation.policy.presence.subscribe) dialog_handling = addressbook.DialogHandling(operation.policy.dialog.policy, operation.policy.dialog.subscribe) policy = addressbook.Policy(operation.policy.id, operation.policy.uri, operation.policy.name, presence_handling=presence_handling, dialog_handling=dialog_handling) policy.attributes = addressbook.Policy.attributes.type(operation.policy.attributes) sipsimple_addressbook.add(policy) def _OH_UpdatePolicyOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: policy = sipsimple_addressbook[addressbook.Policy, operation.policy.id] except KeyError: return attributes = dict(operation.attributes) attributes.pop('id', None) # id is never modified if 'uri' in attributes: policy.uri = attributes.pop('uri') if 'name' in attributes: policy.name = attributes.pop('name') if 'presence.policy' in attributes: policy.presence.policy = attributes.pop('presence.policy') if 'presence.subscribe' in attributes: policy.presence.subscribe = attributes.pop('presence.subscribe') if 'dialog.policy' in attributes: policy.dialog.policy = attributes.pop('dialog.policy') if 'dialog.subscribe' in attributes: policy.dialog.subscribe = attributes.pop('dialog.subscribe') if policy.attributes is None: policy.attributes = addressbook.Policy.attributes.type() policy.attributes.update(attributes) def _OH_RemovePolicyOperation(self, operation): sipsimple_addressbook = self.resource_lists.content['sipsimple_addressbook'] try: del sipsimple_addressbook[addressbook.Policy, operation.policy.id] except KeyError: pass def _OH_SetStatusIconOperation(self, operation): if not self.status_icon.supported: return icon = operation.icon if icon is None or not icon.data: self.status_icon.dirty = self.status_icon.content is not None self.status_icon.content = None else: content = prescontent.PresenceContent(data=base64.encodestring(icon.data), mime_type=icon.mime_type, encoding='base64', description=icon.description) if self.status_icon.content == content: return self.status_icon.content = content def _OH_SetOfflineStatusOperation(self, operation): pidf = operation.status.pidf if operation.status is not None else None if not self.pidf_manipulation.supported or pidf == self.pidf_manipulation.content: return self.pidf_manipulation.content = pidf self.pidf_manipulation.dirty = True def _OH_SetDefaultPresencePolicyOperation(self, operation): pres_rules = self.pres_rules.content if operation.policy == 'allow': rule_id, other_rule_id = 'wp_prs_allow_unlisted', 'wp_prs_unlisted' else: rule_id, other_rule_id = 'wp_prs_unlisted', 'wp_prs_allow_unlisted' try: del pres_rules[other_rule_id] except KeyError: rule = pres_rules[rule_id] subhandling = next(item for item in rule.actions if isinstance(item, presrules.SubHandling)) subhandling.value = operation.policy else: rule = commonpolicy.Rule(rule_id, conditions=[omapolicy.OtherIdentity()], actions=[presrules.SubHandling(operation.policy)]) pres_rules.add(rule) def _OH_SetDefaultDialogPolicyOperation(self, operation): if not self.dialog_rules.supported: return dialog_rules = self.dialog_rules.content if operation.policy == 'allow': rule_id, other_rule_id = 'wp_dlg_allow_unlisted', 'wp_dlg_unlisted' else: rule_id, other_rule_id = 'wp_dlg_unlisted', 'wp_dlg_allow_unlisted' try: del dialog_rules[other_rule_id] except KeyError: rule = dialog_rules[rule_id] subhandling = next(item for item in rule.actions if isinstance(item, dialogrules.SubHandling)) subhandling.value = operation.policy else: rule = commonpolicy.Rule(rule_id, conditions=[omapolicy.OtherIdentity()], actions=[dialogrules.SubHandling(operation.policy)]) dialog_rules.add(rule) # Notification handlers # @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) @run_in_green_thread def _NH_CFGSettingsObjectDidChange(self, notification): if {'__id__', 'xcap.xcap_root', 'auth.username', 'auth.password', 'sip.subscribe_interval', 'sip.transport_list'}.intersection(notification.data.modified): self.command_channel.send(Command('reload', modified=notification.data.modified)) if 'enabled' in notification.data.modified: return # global account activation is handled separately by the account itself if self.account.enabled and 'xcap.enabled' in notification.data.modified: if self.account.xcap.enabled: self.start() else: self.stop() def _NH_CFGSettingsObjectWasDeleted(self, notification): notification.center.remove_observer(self, sender=self.account, name='CFGSettingsObjectDidChange') notification.center.remove_observer(self, sender=self.account, name='CFGSettingsObjectWasDeleted') self.command_channel.send(Command('stop')) self.command_channel.send(Command('cleanup')) def _NH_XCAPSubscriptionDidStart(self, notification): self.command_channel.send(Command('fetch', documents=set(self.document_names))) def _NH_XCAPSubscriptionDidFail(self, notification): self.command_channel.send(Command('fetch', documents=set(self.document_names))) def _NH_XCAPSubscriptionGotNotify(self, notification): if notification.data.content_type == xcapdiff.XCAPDiffDocument.content_type: try: xcap_diff = xcapdiff.XCAPDiffDocument.parse(notification.data.body) except ParserError: self.command_channel.send(Command('fetch', documents=set(self.document_names))) else: applications = set(child.selector.auid for child in xcap_diff if isinstance(child, xcapdiff.Document)) documents = set(document.name for document in self.documents if document.application in applications) self.command_channel.send(Command('fetch', documents=documents)) def _load_data(self): addressbook = Addressbook.from_payload(self.resource_lists.content['sipsimple_addressbook']) default_presence_rule = self.pres_rules.content.get('wp_prs_unlisted', None) or self.pres_rules.content.get('wp_prs_allow_unlisted', None) if self.dialog_rules.supported: default_dialog_rule = self.dialog_rules.content.get('wp_dlg_unlisted', None) or self.dialog_rules.content.get('wp_dlg_allow_unlisted', None) else: default_dialog_rule = None presence_rules = PresenceRules.from_payload(default_presence_rule) dialog_rules = DialogRules.from_payload(default_dialog_rule) if self.status_icon.supported and self.status_icon.content: status_icon = Icon.from_payload(self.status_icon.content) status_icon.url = self.status_icon.url status_icon.etag = self.status_icon.etag else: status_icon = None if self.pidf_manipulation.supported and self.pidf_manipulation.content: offline_status = OfflineStatus(self.pidf_manipulation.content) else: offline_status = None data=NotificationData(addressbook=addressbook, presence_rules=presence_rules, dialog_rules=dialog_rules, status_icon=status_icon, offline_status=offline_status) NotificationCenter().post_notification('XCAPManagerDidReloadData', sender=self, data=data) def _fetch_documents(self, documents): workers = [Worker.spawn(document.fetch) for document in (doc for doc in self.documents if doc.name in documents and doc.supported)] try: while workers: worker = workers.pop() worker.wait() finally: for worker in workers: worker.wait_ex() def _save_journal(self): try: self.storage.save('journal', pickle.dumps(self.journal)) except XCAPStorageError: pass def _schedule_command(self, timeout, command): from twisted.internet import reactor timer = reactor.callLater(timeout, self.command_channel.send, command) timer.command = command return timer class XCAPTransaction(object): def __init__(self, xcap_manager): self.xcap_manager = xcap_manager def __enter__(self): self.xcap_manager.start_transaction() return self def __exit__(self, type, value, traceback): self.xcap_manager.commit_transaction() diff --git a/sipsimple/account/xcap/storage/file.py b/sipsimple/account/xcap/storage/file.py index 0867c574..52e9b959 100644 --- a/sipsimple/account/xcap/storage/file.py +++ b/sipsimple/account/xcap/storage/file.py @@ -1,87 +1,87 @@ """XCAP backend for storing data in files""" __all__ = ["FileStorage"] import errno import os import platform import random from application.system import makedirs, openfile, unlink -from zope.interface import implements +from zope.interface import implementer from sipsimple.account.xcap.storage import IXCAPStorage, XCAPStorageError +@implementer(IXCAPStorage) class FileStorage(object): """Implementation of an XCAP backend that stores data in files.""" - implements(IXCAPStorage) def __init__(self, directory, account_id): """Initialize the storage for the specified directory and account ID""" self.directory = directory self.account_id = account_id self.names = set() def load(self, name): """Read the file given by name and return its content.""" try: document = open(os.path.join(self.directory, self.account_id, name)).read() except (IOError, OSError) as e: raise XCAPStorageError("failed to load XCAP data for %s/%s: %s" % (self.account_id, name, str(e))) else: self.names.add(name) return document def save(self, name, data): """Write the data in a file identified by name.""" filename = os.path.join(self.directory, self.account_id, name) tmp_filename = '%s.%d.%08X' % (filename, os.getpid(), random.getrandbits(32)) try: makedirs(os.path.join(self.directory, self.account_id)) file = openfile(tmp_filename, 'wb', permissions=0o600) file.write(data) file.close() if platform.system() == 'Windows': # os.rename does not work on Windows if the destination file already exists. # It seems there is no atomic way to do this on Windows. unlink(filename) os.rename(tmp_filename, filename) except (IOError, OSError) as e: raise XCAPStorageError("failed to save XCAP data for %s/%s: %s" % (self.account_id, name, str(e))) else: self.names.add(name) def delete(self, name): """Delete the data stored in the file identified by name""" try: os.unlink(os.path.join(self.directory, self.account_id, name)) except OSError as e: if e.errno == errno.ENOENT: self.names.discard(name) return raise XCAPStorageError("failed to delete XCAP data for %s/%s: %s" % (self.account_id, name, str(e))) else: self.names.remove(name) def purge(self): """Delete all the files stored by the backend""" failed = [] for name in self.names: try: os.unlink(os.path.join(self.directory, self.account_id, name)) except OSError as e: if e.errno == errno.ENOENT: continue failed.append(name) self.names.clear() try: os.rmdir(os.path.join(self.directory, self.account_id)) except OSError: pass if failed: raise XCAPStorageError("the following files could not be deleted for %s: %s" % (self.account_id, ', '.join(failed))) diff --git a/sipsimple/account/xcap/storage/memory.py b/sipsimple/account/xcap/storage/memory.py index f30d3faf..0a612c9b 100644 --- a/sipsimple/account/xcap/storage/memory.py +++ b/sipsimple/account/xcap/storage/memory.py @@ -1,39 +1,39 @@ """XCAP backend for storing data in memory""" __all__ = ["MemoryStorage"] -from zope.interface import implements +from zope.interface import implementer from sipsimple.account.xcap.storage import IXCAPStorage, XCAPStorageError +@implementer(IXCAPStorage) class MemoryStorage(object): """Implementation of an XCAP backend that stores data in memory""" - implements(IXCAPStorage) def __init__(self, account_id): """Initialize the backend for the specified account ID""" self.account_id = account_id self.data = {} def load(self, name): """Return the data given by name""" try: return self.data[name] except KeyError: raise XCAPStorageError("missing entry: %s/%s" % (self.account_id, name)) def save(self, name, data): """Store the data under a key given by name""" self.data[name] = data def delete(self, name): """Delete the data identified by name""" self.data.pop(name, None) def purge(self): """Delete all the data that is stored in the backend""" self.data.clear() diff --git a/sipsimple/addressbook.py b/sipsimple/addressbook.py index 528dc8d2..e5e2174b 100644 --- a/sipsimple/addressbook.py +++ b/sipsimple/addressbook.py @@ -1,1365 +1,1365 @@ """Implementation of an addressbook management system""" __all__ = ['AddressbookManager', 'Contact', 'ContactURI', 'Group', 'Policy', 'SharedSetting', 'ContactExtension', 'ContactURIExtension', 'GroupExtension', 'PolicyExtension'] from functools import reduce from operator import attrgetter from random import randint from .threading import Lock from time import time -from zope.interface import implements +from zope.interface import implementer from .application.notification import IObserver, NotificationCenter, NotificationData from .application.python import Null from .application.python.decorator import execute_once from .application.python.types import Singleton, MarkerType from .application.python.weakref import defaultweakobjectmap from sipsimple import log from sipsimple.account import xcap, AccountManager from sipsimple.configuration import ConfigurationManager, ObjectNotFoundError, DuplicateIDError, PersistentKey, ModifiedValue, ModifiedList from sipsimple.configuration import AbstractSetting, RuntimeSetting, SettingsObjectImmutableID, SettingsGroup, SettingsGroupMeta, SettingsState, ItemCollection, ItemManagement from sipsimple.payloads.addressbook import PolicyValue, ElementAttributes from sipsimple.payloads.datatypes import ID from sipsimple.payloads.resourcelists import ResourceListsDocument from sipsimple.threading import run_in_thread def unique_id(prefix='id'): return "%s%d%06d" % (prefix, time()*1e6, randint(0, 999999)) def recursive_getattr(obj, name): return reduce(getattr, name.split('.'), obj) class Local(object, metaclass=MarkerType): pass class Remote(object): def __init__(self, account, xcap_object): self.account = account self.xcap_object = xcap_object def __repr__(self): return "%s(%r, %r)" % (self.__class__.__name__, self.account, self.xcap_object) class Setting(AbstractSetting): """ Descriptor representing a setting in an addressbook object. Unlike a standard Setting, this one will only use the default value as a template to fill in a missing value and explicitly set it when saving if it was not specified explicitly prior to that. """ def __init__(self, type, default=None, nillable=False): if default is None and not nillable: raise TypeError("default must be specified if object is not nillable") self.type = type self.default = default self.nillable = nillable self.values = defaultweakobjectmap(lambda: default) self.oldvalues = defaultweakobjectmap(lambda: default) self.dirty = defaultweakobjectmap(bool) self.lock = Lock() def __get__(self, obj, objtype): if obj is None: return self with self.lock: return self.values[obj] def __set__(self, obj, value): if value is None and not self.nillable: raise ValueError("setting attribute is not nillable") if value is not None and not isinstance(value, self.type): value = self.type(value) with self.lock: self.values[obj] = value self.dirty[obj] = value != self.oldvalues[obj] def __getstate__(self, obj): with self.lock: value = self.values[obj] if value is None: pass elif issubclass(self.type, bool): value = 'true' if value else 'false' elif issubclass(self.type, (int, int, str)): value = str(value) elif hasattr(value, '__getstate__'): value = value.__getstate__() else: value = str(value) return value def __setstate__(self, obj, value): if value is None and not self.nillable: raise ValueError("setting attribute is not nillable") if value is None: pass elif issubclass(self.type, bool): if value.lower() in ('true', 'yes', 'on', '1'): value = True elif value.lower() in ('false', 'no', 'off', '0'): value = False else: raise ValueError("invalid boolean value: %s" % (value,)) elif issubclass(self.type, (int, int, str)): value = self.type(value) elif hasattr(self.type, '__setstate__'): object = self.type.__new__(self.type) object.__setstate__(value) value = object else: value = self.type(value) with self.lock: self.oldvalues[obj] = self.values[obj] = value self.dirty[obj] = False def get_modified(self, obj): with self.lock: try: if self.dirty[obj]: return ModifiedValue(old=self.oldvalues[obj], new=self.values[obj]) else: return None finally: self.oldvalues[obj] = self.values[obj] self.dirty[obj] = False def get_old(self, obj): with self.lock: return self.oldvalues[obj] def undo(self, obj): with self.lock: self.values[obj] = self.oldvalues[obj] self.dirty[obj] = False class SharedSetting(Setting): """A setting that is shared by being also stored remotely in XCAP""" __namespace__ = None @classmethod def set_namespace(cls, namespace): """ Set the XML namespace to be used for the extra shared attributes of a contact, when storing it in XCAP """ if cls.__namespace__ is not None: raise RuntimeError("namespace already set to %s" % cls.__namespace__) cls.__namespace__ = namespace class ApplicationElementAttributes(ElementAttributes): _xml_namespace = 'urn:%s:xml:ns:addressbook' % namespace ResourceListsDocument.unregister_namespace(ElementAttributes._xml_namespace) ResourceListsDocument.register_namespace(ApplicationElementAttributes._xml_namespace, prefix=namespace.rpartition(':')[2]) for cls, attribute_name in ((cls, name) for cls in list(ResourceListsDocument.element_map.values()) for name, elem in list(cls._xml_element_children.items()) if elem.type is ElementAttributes): cls.unregister_extension(attribute_name) cls.register_extension(attribute_name, ApplicationElementAttributes) class AddressbookKey(object): def __init__(self, section): self.group = 'Addressbook' self.section = section def __get__(self, obj, objtype): if obj is None: return [self.group, self.section] else: return [self.group, self.section, PersistentKey(obj.__id__)] def __set__(self, obj, value): raise AttributeError('cannot set attribute') def __delete__(self, obj): raise AttributeError('cannot delete attribute') class MultiAccountTransaction(object): def __init__(self, accounts): self.accounts = accounts def __enter__(self): for account in self.accounts: account.xcap_manager.start_transaction() return self def __exit__(self, exc_type, exc_value, traceback): for account in self.accounts: account.xcap_manager.commit_transaction() def __iter__(self): return iter(self.accounts) class XCAPGroup(xcap.Group): """An XCAP Group with attributes normalized to unicode""" __attributes__ = set() def __init__(self, id, name, contacts, **attributes): normalized_attributes = dict((name, str(value) if value is not None else None) for name, value in list(attributes.items()) if name in self.__attributes__) contacts = [XCAPContact.normalize(contact) for contact in contacts] super(XCAPGroup, self).__init__(id, name, contacts, **normalized_attributes) @classmethod def normalize(cls, group): return cls(group.id, group.name, group.contacts, **group.attributes) def get_modified(self, modified_keys): names = {'name'} attributes = dict((name, getattr(self, name)) for name in names.intersection(modified_keys)) attributes.update((name, self.attributes[name]) for name in self.__attributes__.intersection(modified_keys)) return attributes class XCAPContactURI(xcap.ContactURI): """An XCAP ContactURI with attributes normalized to unicode""" __attributes__ = set() def __init__(self, id, uri, type, **attributes): normalized_attributes = dict((name, str(value) if value is not None else None) for name, value in list(attributes.items()) if name in self.__attributes__) super(XCAPContactURI, self).__init__(id, uri, type, **normalized_attributes) @classmethod def normalize(cls, uri): return cls(uri.id, uri.uri, uri.type, **uri.attributes) def get_modified(self, modified_keys): names = {'uri', 'type'} attributes = dict((name, getattr(self, name)) for name in names.intersection(modified_keys)) attributes.update((name, self.attributes[name]) for name in self.__attributes__.intersection(modified_keys)) return attributes class XCAPContact(xcap.Contact): """An XCAP Contact with attributes normalized to unicode""" __attributes__ = set() def __init__(self, id, name, uris, presence_handling=None, dialog_handling=None, **attributes): normalized_attributes = dict((name, str(value) if value is not None else None) for name, value in list(attributes.items()) if name in self.__attributes__) uris = xcap.ContactURIList((XCAPContactURI.normalize(uri) for uri in uris), default=getattr(uris, 'default', None)) super(XCAPContact, self).__init__(id, name, uris, presence_handling, dialog_handling, **normalized_attributes) @classmethod def normalize(cls, contact): return cls(contact.id, contact.name, contact.uris, contact.presence, contact.dialog, **contact.attributes) def get_modified(self, modified_keys): names = {'name', 'uris.default', 'presence.policy', 'presence.subscribe', 'dialog.policy', 'dialog.subscribe'} attributes = dict((name, recursive_getattr(self, name)) for name in names.intersection(modified_keys)) attributes.update((name, self.attributes[name]) for name in self.__attributes__.intersection(modified_keys)) return attributes class XCAPPolicy(xcap.Policy): """An XCAP Policy with attributes normalized to unicode""" __attributes__ = set() def __init__(self, id, uri, name, presence_handling=None, dialog_handling=None, **attributes): normalized_attributes = dict((name, str(value) if value is not None else None) for name, value in list(attributes.items()) if name in self.__attributes__) super(XCAPPolicy, self).__init__(id, uri, name, presence_handling, dialog_handling, **normalized_attributes) @classmethod def normalize(cls, policy): return cls(policy.id, policy.uri, policy.name, policy.presence, policy.dialog, **policy.attributes) def get_modified(self, modified_keys): names = {'uri', 'name', 'presence.policy', 'presence.subscribe', 'dialog.policy', 'dialog.subscribe'} attributes = dict((name, recursive_getattr(self, name)) for name in names.intersection(modified_keys)) attributes.update((name, self.attributes[name]) for name in self.__attributes__.intersection(modified_keys)) return attributes class ContactListDescriptor(AbstractSetting): def __init__(self): self.values = defaultweakobjectmap(ContactList) self.oldvalues = defaultweakobjectmap(ContactList) self.lock = Lock() def __get__(self, obj, objtype): if obj is None: return self with self.lock: return self.values[obj] def __set__(self, obj, value): if value is None: raise ValueError("setting attribute is not nillable") elif not isinstance(value, ContactList): value = ContactList(value) with self.lock: self.values[obj] = value def __getstate__(self, obj): with self.lock: return self.values[obj].__getstate__() def __setstate__(self, obj, value): if value is None: raise ValueError("setting attribute is not nillable") object = ContactList.__new__(ContactList) object.__setstate__(value) with self.lock: self.values[obj] = object self.oldvalues[obj] = ContactList(object) def get_modified(self, obj): with self.lock: old = self.oldvalues[obj] new = self.values[obj] with new.lock: old_ids = set(old.ids()) new_ids = set(new.ids()) added_contacts = [new[id] for id in new_ids - old_ids] removed_contacts = [old[id] for id in old_ids - new_ids] try: if added_contacts or removed_contacts: return ModifiedList(added=added_contacts, removed=removed_contacts, modified=None) else: return None finally: self.oldvalues[obj] = ContactList(new) def get_old(self, obj): with self.lock: return self.oldvalues[obj] def undo(self, obj): with self.lock: self.values[obj] = ContactList(self.oldvalues[obj]) class ContactList(object): def __new__(cls, contacts=None): instance = object.__new__(cls) instance.lock = Lock() return instance def __init__(self, contacts=None): self.contacts = dict((contact.id, contact) for contact in contacts or [] if contact.__state__ != 'deleted') def __getitem__(self, key): return self.contacts[key] def __contains__(self, key): return key in self.contacts def __iter__(self): return iter(sorted(list(self.contacts.values()), key=attrgetter('id'))) def __reversed__(self): return iter(sorted(list(self.contacts.values()), key=attrgetter('id'), reverse=True)) __hash__ = None def __len__(self): return len(self.contacts) def __eq__(self, other): if isinstance(other, ContactList): return self.contacts == other.contacts return NotImplemented def __ne__(self, other): equal = self.__eq__(other) return NotImplemented if equal is NotImplemented else not equal def __repr__(self): return "%s(%r)" % (self.__class__.__name__, sorted(list(self.contacts.values()), key=attrgetter('id'))) def __getstate__(self): return list(self.contacts.keys()) def __setstate__(self, value): addressbook_manager = AddressbookManager() for id in [id for id in value if not addressbook_manager.has_contact(id)]: value.remove(id) with self.lock: self.contacts = dict((id, addressbook_manager.get_contact(id)) for id in value) def ids(self): return sorted(self.contacts.keys()) def add(self, contact): if contact.__state__ == 'deleted': return with self.lock: self.contacts[contact.id] = contact def remove(self, contact): with self.lock: self.contacts.pop(contact.id, None) class Group(SettingsState): __key__ = AddressbookKey('Groups') __id__ = SettingsObjectImmutableID(type=ID) id = __id__ name = Setting(type=str, default='') contacts = ContactListDescriptor() def __new__(cls, id=None): with AddressbookManager.load.lock: if not AddressbookManager.load.called: raise RuntimeError("cannot instantiate %s before calling AddressbookManager.load" % cls.__name__) if id is None: id = unique_id() elif not isinstance(id, str): raise TypeError("id needs to be a string or unicode object") instance = SettingsState.__new__(cls) instance.__id__ = id instance.__state__ = 'new' instance.__xcapgroup__ = None configuration = ConfigurationManager() try: data = configuration.get(instance.__key__) except ObjectNotFoundError: pass else: instance.__setstate__(data) instance.__state__ = 'loaded' instance.__xcapgroup__ = instance.__toxcap__() return instance def __establish__(self): if self.__state__ == 'loaded': self.__state__ = 'active' notification_center = NotificationCenter() notification_center.post_notification('AddressbookGroupWasActivated', sender=self) def __repr__(self): return "%s(id=%r)" % (self.__class__.__name__, self.id) def __toxcap__(self): xcap_contacts = [contact.__xcapcontact__ for contact in self.contacts] attributes = dict((name, getattr(self, name)) for name, attr in list(vars(self.__class__).items()) if isinstance(attr, SharedSetting)) return XCAPGroup(self.id, self.name, xcap_contacts, **attributes) @run_in_thread('file-io') def _internal_save(self, originator): if self.__state__ == 'deleted': return for contact in [contact for contact in self.contacts if contact.__state__ == 'deleted']: self.contacts.remove(contact) modified_settings = self.get_modified() if not modified_settings and self.__state__ != 'new': return account_manager = AccountManager() configuration = ConfigurationManager() notification_center = NotificationCenter() if originator is Local: originator_account = None previous_xcapgroup = self.__xcapgroup__ else: originator_account = originator.account previous_xcapgroup = originator.xcap_object xcap_accounts = [account for account in account_manager.get_accounts() if account.xcap.discovered] self.__xcapgroup__ = self.__toxcap__() if self.__state__ == 'new': configuration.update(self.__key__, self.__getstate__()) self.__state__ = 'active' for account in (account for account in xcap_accounts if account is not originator_account): account.xcap_manager.add_group(self.__xcapgroup__) modified_data = None notification_center.post_notification('AddressbookGroupWasActivated', sender=self) notification_center.post_notification('AddressbookGroupWasCreated', sender=self) elif all(isinstance(self.__settings__[key], RuntimeSetting) for key in modified_settings): notification_center.post_notification('AddressbookGroupDidChange', sender=self, data=NotificationData(modified=modified_settings)) return else: configuration.update(self.__key__, self.__getstate__()) attributes = self.__xcapgroup__.get_modified(modified_settings) if 'contacts' in modified_settings: added_contacts = [contact.__xcapcontact__ for contact in modified_settings['contacts'].added] removed_contacts = [contact.__xcapcontact__ for contact in modified_settings['contacts'].removed] else: added_contacts = [] removed_contacts = [] if self.__xcapgroup__ != previous_xcapgroup: outofsync_accounts = xcap_accounts elif originator is Local: outofsync_accounts = [] else: outofsync_accounts = list(account for account in xcap_accounts if account is not originator_account) with MultiAccountTransaction(outofsync_accounts): for account in outofsync_accounts: xcap_manager = account.xcap_manager for xcapcontact in added_contacts: xcap_manager.add_group_member(self.__xcapgroup__, xcapcontact) for xcapcontact in removed_contacts: xcap_manager.remove_group_member(self.__xcapgroup__, xcapcontact) if attributes: xcap_manager.update_group(self.__xcapgroup__, attributes) notification_center.post_notification('AddressbookGroupDidChange', sender=self, data=NotificationData(modified=modified_settings)) modified_data = modified_settings try: configuration.save() except Exception as e: log.exception() notification_center.post_notification('CFGManagerSaveFailed', sender=configuration, data=NotificationData(object=self, operation='save', modified=modified_data, exception=e)) @run_in_thread('file-io') def _internal_delete(self, originator): if self.__state__ == 'deleted': return self.__state__ = 'deleted' configuration = ConfigurationManager() account_manager = AccountManager() notification_center = NotificationCenter() if originator is Local: originator_account = None else: originator_account = originator.account configuration.delete(self.__key__) for account in (account for account in account_manager.iter_accounts() if account.xcap.discovered and account is not originator_account): account.xcap_manager.remove_group(self.__xcapgroup__) notification_center.post_notification('AddressbookGroupWasDeleted', sender=self) try: configuration.save() except Exception as e: log.exception() notification_center.post_notification('CFGManagerSaveFailed', sender=configuration, data=NotificationData(object=self, operation='delete', exception=e)) def save(self): """ Store the group into persistent storage (local and xcap). This method will post the AddressbookGroupWasCreated and AddressbookGroupWasActivated notifications on the first save or a AddressbookGroupDidChange notification on subsequent saves, regardless of whether the contact has been saved to persistent storage or not. A CFGManagerSaveFailed notification is posted if saving to the persistent configuration storage fails. """ self._internal_save(originator=Local) def delete(self): """Remove the group from the persistent storage.""" self._internal_delete(originator=Local) def clone(self, new_id=None): """Create a copy of this group and all its sub-settings.""" raise NotImplementedError @classmethod def register_extension(cls, extension): """ Register an extension for this class. All Settings and SettingsGroups defined in the extension will be added to this class, overwriting any attributes with the same name. Other attributes in the extension are ignored. """ if not issubclass(extension, GroupExtension): raise TypeError("expected subclass of GroupExtension, got %r" % (extension,)) for name in dir(extension): attribute = getattr(extension, name, None) if isinstance(attribute, SharedSetting): if SharedSetting.__namespace__ is None: raise RuntimeError("cannot use SharedSetting attributes without first calling SharedSetting.set_namespace") XCAPGroup.__attributes__.add(name) if isinstance(attribute, (AbstractSetting, SettingsGroupMeta)): setattr(cls, name, attribute) class GroupExtension(object): """Base class for extensions of Groups""" def __new__(cls, *args, **kw): raise TypeError("GroupExtension subclasses cannot be instantiated") class ContactURI(SettingsState): __id__ = SettingsObjectImmutableID(type=ID) id = __id__ uri = Setting(type=str, default='') type = Setting(type=str, default=None, nillable=True) def __new__(cls, id=None, **state): if id is None: id = unique_id() elif not isinstance(id, str): raise TypeError("id needs to be a string or unicode object") instance = SettingsState.__new__(cls) instance.__id__ = id instance.__setstate__(state) return instance def __repr__(self): return "%s(id=%r)" % (self.__class__.__name__, self.id) def __toxcap__(self): attributes = dict((name, getattr(self, name)) for name, attr in list(vars(self.__class__).items()) if isinstance(attr, SharedSetting)) return XCAPContactURI(self.id, self.uri, self.type, **attributes) @classmethod def register_extension(cls, extension): """ Register an extension for this class. All Settings and SettingsGroups defined in the extension will be added to this class, overwriting any attributes with the same name. Other attributes in the extension are ignored. """ if not issubclass(extension, ContactURIExtension): raise TypeError("expected subclass of ContactURIExtension, got %r" % (extension,)) for name in dir(extension): attribute = getattr(extension, name, None) if isinstance(attribute, SharedSetting): if SharedSetting.__namespace__ is None: raise RuntimeError("cannot use SharedSetting attributes without first calling SharedSetting.set_namespace") XCAPContactURI.__attributes__.add(name) if isinstance(attribute, (AbstractSetting, SettingsGroupMeta)): setattr(cls, name, attribute) class ContactURIExtension(object): """Base class for extensions of ContactURIs""" def __new__(cls, *args, **kw): raise TypeError("ContactURIExtension subclasses cannot be instantiated") class DefaultContactURI(Setting): def __init__(self): super(DefaultContactURI, self).__init__(type=str, default=None, nillable=True) def __get__(self, obj, objtype): value = super(DefaultContactURI, self).__get__(obj, objtype) return value if value in (self, None) else obj._item_map.get(value) def __set__(self, obj, value): if value is not None: if not isinstance(value, ContactURI): raise TypeError("the default URI must be a ContactURI instance or None") with obj._lock: if value.id not in obj._item_map: raise ValueError("the default URI can only be set to one of the URIs of the contact") super(DefaultContactURI, self).__set__(obj, value.id) else: super(DefaultContactURI, self).__set__(obj, None) def get_modified(self, obj): modified_value = super(DefaultContactURI, self).get_modified(obj) if modified_value is not None: old_uri = obj._item_map.old.get(modified_value.old) if modified_value.old is not None else None new_uri = obj._item_map.get(modified_value.new) if modified_value.new is not None else None modified_value = ModifiedValue(old=old_uri, new=new_uri) return modified_value def get_old(self, obj): value = super(DefaultContactURI, self).get_old(obj) return value if value is None else obj._item_map.old.get(value) class ContactURIManagement(ItemManagement): def remove_item(self, item, collection): if collection.default is item: collection.default = None def set_items(self, items, collection): if collection.default is not None and collection.default not in items: collection.default = None class ContactURIList(ItemCollection): _item_type = ContactURI _item_management = ContactURIManagement() default = DefaultContactURI() class DialogSettings(SettingsGroup): policy = Setting(type=PolicyValue, default='default') subscribe = Setting(type=bool, default=False) class PresenceSettings(SettingsGroup): policy = Setting(type=PolicyValue, default='default') subscribe = Setting(type=bool, default=False) class Contact(SettingsState): __key__ = AddressbookKey('Contacts') __id__ = SettingsObjectImmutableID(type=ID) id = __id__ name = Setting(type=str, default='') uris = ContactURIList dialog = DialogSettings presence = PresenceSettings def __new__(cls, id=None): with AddressbookManager.load.lock: if not AddressbookManager.load.called: raise RuntimeError("cannot instantiate %s before calling AddressbookManager.load" % cls.__name__) if id is None: id = unique_id() elif not isinstance(id, str): raise TypeError("id needs to be a string or unicode object") instance = SettingsState.__new__(cls) instance.__id__ = id instance.__state__ = 'new' instance.__xcapcontact__ = None configuration = ConfigurationManager() try: data = configuration.get(instance.__key__) except ObjectNotFoundError: pass else: instance.__setstate__(data) instance.__state__ = 'loaded' instance.__xcapcontact__ = instance.__toxcap__() return instance def __establish__(self): if self.__state__ == 'loaded': self.__state__ = 'active' notification_center = NotificationCenter() notification_center.post_notification('AddressbookContactWasActivated', sender=self) def __repr__(self): return "%s(id=%r)" % (self.__class__.__name__, self.id) def __toxcap__(self): contact_uris = xcap.ContactURIList((uri.__toxcap__() for uri in self.uris), default=self.uris.default.id if self.uris.default is not None else None) dialog_handling = xcap.EventHandling(self.dialog.policy, self.dialog.subscribe) presence_handling = xcap.EventHandling(self.presence.policy, self.presence.subscribe) attributes = dict((name, getattr(self, name)) for name, attr in list(vars(self.__class__).items()) if isinstance(attr, SharedSetting)) return XCAPContact(self.id, self.name, contact_uris, presence_handling, dialog_handling, **attributes) @run_in_thread('file-io') def _internal_save(self, originator): if self.__state__ == 'deleted': return modified_settings = self.get_modified() if not modified_settings and self.__state__ != 'new': return account_manager = AccountManager() configuration = ConfigurationManager() notification_center = NotificationCenter() if originator is Local: originator_account = None previous_xcapcontact = self.__xcapcontact__ else: originator_account = originator.account previous_xcapcontact = originator.xcap_object xcap_accounts = [account for account in account_manager.get_accounts() if account.xcap.discovered] self.__xcapcontact__ = self.__toxcap__() if self.__state__ == 'new': configuration.update(self.__key__, self.__getstate__()) self.__state__ = 'active' for account in (account for account in xcap_accounts if account is not originator_account): account.xcap_manager.add_contact(self.__xcapcontact__) modified_data = None notification_center.post_notification('AddressbookContactWasActivated', sender=self) notification_center.post_notification('AddressbookContactWasCreated', sender=self) elif all(isinstance(self.__settings__[key], RuntimeSetting) for key in modified_settings): notification_center.post_notification('AddressbookContactDidChange', sender=self, data=NotificationData(modified=modified_settings)) return else: configuration.update(self.__key__, self.__getstate__()) contact_attributes = self.__xcapcontact__.get_modified(modified_settings) if 'uris' in modified_settings: xcap_uris = self.__xcapcontact__.uris added_uris = [xcap_uris[uri.id] for uri in modified_settings['uris'].added] removed_uris = [uri.__toxcap__() for uri in modified_settings['uris'].removed] modified_uris = dict((xcap_uris[id], xcap_uris[id].get_modified(changemap)) for id, changemap in list(modified_settings['uris'].modified.items())) else: added_uris = [] removed_uris = [] modified_uris = {} if self.__xcapcontact__ != previous_xcapcontact: outofsync_accounts = xcap_accounts elif originator is Local: outofsync_accounts = [] else: outofsync_accounts = list(account for account in xcap_accounts if account is not originator_account) with MultiAccountTransaction(outofsync_accounts): for account in outofsync_accounts: xcap_manager = account.xcap_manager for xcapuri in added_uris: xcap_manager.add_contact_uri(self.__xcapcontact__, xcapuri) for xcapuri in removed_uris: xcap_manager.remove_contact_uri(self.__xcapcontact__, xcapuri) for xcapuri, uri_attributes in list(modified_uris.items()): xcap_manager.update_contact_uri(self.__xcapcontact__, xcapuri, uri_attributes) if contact_attributes: xcap_manager.update_contact(self.__xcapcontact__, contact_attributes) notification_center.post_notification('AddressbookContactDidChange', sender=self, data=NotificationData(modified=modified_settings)) modified_data = modified_settings try: configuration.save() except Exception as e: log.exception() notification_center.post_notification('CFGManagerSaveFailed', sender=configuration, data=NotificationData(object=self, operation='save', modified=modified_data, exception=e)) @run_in_thread('file-io') def _internal_delete(self, originator): if self.__state__ == 'deleted': return self.__state__ = 'deleted' configuration = ConfigurationManager() account_manager = AccountManager() addressbook_manager = AddressbookManager() notification_center = NotificationCenter() if originator is Local: originator_account = None else: originator_account = originator.account configuration.delete(self.__key__) xcap_accounts = [account for account in account_manager.get_accounts() if account.xcap.discovered] with MultiAccountTransaction(xcap_accounts): for group in (group for group in addressbook_manager.get_groups() if self.id in group.contacts): group.contacts.remove(self) group.save() for account in (account for account in xcap_accounts if account is not originator_account): account.xcap_manager.remove_contact(self.__xcapcontact__) notification_center.post_notification('AddressbookContactWasDeleted', sender=self) try: configuration.save() except Exception as e: log.exception() notification_center.post_notification('CFGManagerSaveFailed', sender=configuration, data=NotificationData(object=self, operation='delete', exception=e)) def save(self): """ Store the contact into persistent storage (local and xcap). This method will post the AddressbookContactWasCreated and AddressbookContactWasActivated notifications on the first save or a AddressbookContactDidChange notification on subsequent saves, regardless of whether the contact has been saved to persistent storage or not. A CFGManagerSaveFailed notification is posted if saving to the persistent configuration storage fails. """ self._internal_save(originator=Local) def delete(self): """Remove the contact from the persistent storage.""" self._internal_delete(originator=Local) def clone(self, new_id=None): """Create a copy of this contact and all its sub-settings.""" raise NotImplementedError @classmethod def register_extension(cls, extension): """ Register an extension for this class. All Settings and SettingsGroups defined in the extension will be added to this class, overwriting any attributes with the same name. Other attributes in the extension are ignored. """ if not issubclass(extension, ContactExtension): raise TypeError("expected subclass of ContactExtension, got %r" % (extension,)) for name in dir(extension): attribute = getattr(extension, name, None) if isinstance(attribute, SharedSetting): if SharedSetting.__namespace__ is None: raise RuntimeError("cannot use SharedSetting attributes without first calling SharedSetting.set_namespace") XCAPContact.__attributes__.add(name) if isinstance(attribute, (AbstractSetting, SettingsGroupMeta)): setattr(cls, name, attribute) class ContactExtension(object): """Base class for extensions of Contacts""" def __new__(cls, *args, **kw): raise TypeError("ContactExtension subclasses cannot be instantiated") class Policy(SettingsState): __key__ = AddressbookKey('Policies') __id__ = SettingsObjectImmutableID(type=ID) id = __id__ uri = Setting(type=str, default='') name = Setting(type=str, default='') dialog = DialogSettings presence = PresenceSettings def __new__(cls, id=None): with AddressbookManager.load.lock: if not AddressbookManager.load.called: raise RuntimeError("cannot instantiate %s before calling AddressbookManager.load" % cls.__name__) if id is None: id = unique_id() elif not isinstance(id, str): raise TypeError("id needs to be a string or unicode object") instance = SettingsState.__new__(cls) instance.__id__ = id instance.__state__ = 'new' instance.__xcappolicy__ = None configuration = ConfigurationManager() try: data = configuration.get(instance.__key__) except ObjectNotFoundError: pass else: instance.__setstate__(data) instance.__state__ = 'loaded' instance.__xcappolicy__ = instance.__toxcap__() return instance def __establish__(self): if self.__state__ == 'loaded': self.__state__ = 'active' notification_center = NotificationCenter() notification_center.post_notification('AddressbookPolicyWasActivated', sender=self) def __repr__(self): return "%s(id=%r)" % (self.__class__.__name__, self.id) def __toxcap__(self): dialog_handling = xcap.EventHandling(self.dialog.policy, self.dialog.subscribe) presence_handling = xcap.EventHandling(self.presence.policy, self.presence.subscribe) attributes = dict((name, getattr(self, name)) for name, attr in list(vars(self.__class__).items()) if isinstance(attr, SharedSetting)) return XCAPPolicy(self.id, self.uri, self.name, presence_handling, dialog_handling, **attributes) @run_in_thread('file-io') def _internal_save(self, originator): if self.__state__ == 'deleted': return modified_settings = self.get_modified() if not modified_settings and self.__state__ != 'new': return account_manager = AccountManager() configuration = ConfigurationManager() notification_center = NotificationCenter() if originator is Local: originator_account = None previous_xcappolicy = self.__xcappolicy__ else: originator_account = originator.account previous_xcappolicy = originator.xcap_object xcap_accounts = [account for account in account_manager.get_accounts() if account.xcap.discovered] self.__xcappolicy__ = self.__toxcap__() if self.__state__ == 'new': configuration.update(self.__key__, self.__getstate__()) self.__state__ = 'active' for account in (account for account in xcap_accounts if account is not originator_account): account.xcap_manager.add_policy(self.__xcappolicy__) modified_data = None notification_center.post_notification('AddressbookPolicyWasActivated', sender=self) notification_center.post_notification('AddressbookPolicyWasCreated', sender=self) elif all(isinstance(self.__settings__[key], RuntimeSetting) for key in modified_settings): notification_center.post_notification('AddressbookPolicyDidChange', sender=self, data=NotificationData(modified=modified_settings)) return else: configuration.update(self.__key__, self.__getstate__()) attributes = self.__xcappolicy__.get_modified(modified_settings) if self.__xcappolicy__ != previous_xcappolicy: outofsync_accounts = xcap_accounts elif originator is Local: outofsync_accounts = [] else: outofsync_accounts = list(account for account in xcap_accounts if account is not originator_account) for account in outofsync_accounts: account.xcap_manager.update_policy(self.__xcappolicy__, attributes) notification_center.post_notification('AddressbookPolicyDidChange', sender=self, data=NotificationData(modified=modified_settings)) modified_data = modified_settings try: configuration.save() except Exception as e: log.exception() notification_center.post_notification('CFGManagerSaveFailed', sender=configuration, data=NotificationData(object=self, operation='save', modified=modified_data, exception=e)) @run_in_thread('file-io') def _internal_delete(self, originator): if self.__state__ == 'deleted': return self.__state__ = 'deleted' configuration = ConfigurationManager() account_manager = AccountManager() notification_center = NotificationCenter() if originator is Local: originator_account = None else: originator_account = originator.account configuration.delete(self.__key__) for account in (account for account in account_manager.iter_accounts() if account.xcap.discovered and account is not originator_account): account.xcap_manager.remove_policy(self.__xcappolicy__) notification_center.post_notification('AddressbookPolicyWasDeleted', sender=self) try: configuration.save() except Exception as e: log.exception() notification_center.post_notification('CFGManagerSaveFailed', sender=configuration, data=NotificationData(object=self, operation='delete', exception=e)) def save(self): """ Store the policy into persistent storage (local and xcap). It will post the AddressbookPolicyWasCreated and AddressbookPolicyWasActivated notifications on the first save or a AddressbookPolicyDidChange notification on subsequent saves, regardless of whether the policy has been saved to persistent storage or not. A CFGManagerSaveFailed notification is posted if saving to the persistent configuration storage fails. """ self._internal_save(originator=Local) def delete(self): """Remove the policy from the persistent storage.""" self._internal_delete(originator=Local) def clone(self, new_id=None): """Create a copy of this policy and all its sub-settings.""" raise NotImplementedError @classmethod def register_extension(cls, extension): """ Register an extension for this class. All Settings and SettingsGroups defined in the extension will be added to this class, overwriting any attributes with the same name. Other attributes in the extension are ignored. """ if not issubclass(extension, PolicyExtension): raise TypeError("expected subclass of PolicyExtension, got %r" % (extension,)) for name in dir(extension): attribute = getattr(extension, name, None) if isinstance(attribute, SharedSetting): if SharedSetting.__namespace__ is None: raise RuntimeError("cannot use SharedSetting attributes without first calling SharedSetting.set_namespace") XCAPPolicy.__attributes__.add(name) if isinstance(attribute, (AbstractSetting, SettingsGroupMeta)): setattr(cls, name, attribute) class PolicyExtension(object): """Base class for extensions of Policies""" def __new__(cls, *args, **kw): raise TypeError("PolicyExtension subclasses cannot be instantiated") +@implementer(IObserver) class AddressbookManager(object, metaclass=Singleton): - implements(IObserver) def __init__(self): self.contacts = {} self.groups = {} self.policies = {} self.__xcapaddressbook__ = None notification_center = NotificationCenter() notification_center.add_observer(self, name='AddressbookContactWasActivated') notification_center.add_observer(self, name='AddressbookContactWasDeleted') notification_center.add_observer(self, name='AddressbookGroupWasActivated') notification_center.add_observer(self, name='AddressbookGroupWasDeleted') notification_center.add_observer(self, name='AddressbookPolicyWasActivated') notification_center.add_observer(self, name='AddressbookPolicyWasDeleted') notification_center.add_observer(self, name='SIPAccountDidDiscoverXCAPSupport') notification_center.add_observer(self, name='XCAPManagerDidReloadData') @execute_once def load(self): configuration = ConfigurationManager() # temporary workaround to migrate contacts to the new format. to be removed later. -Dan if 'Contacts' in configuration.data or 'ContactGroups' in configuration.data: account_manager = AccountManager() old_data = dict(contacts=configuration.data.pop('Contacts', {}), groups=configuration.data.pop('ContactGroups', {})) if any(account.enabled and account.xcap.enabled and account.xcap.discovered for account in account_manager.get_accounts()): self.__old_data = old_data else: self.__migrate_contacts(old_data) return [Contact(id=id) for id in configuration.get_names(Contact.__key__)] [Group(id=id) for id in configuration.get_names(Group.__key__)] [Policy(id=id) for id in configuration.get_names(Policy.__key__)] def start(self): pass def stop(self): pass def has_contact(self, id): return id in self.contacts def get_contact(self, id): return self.contacts[id] def get_contacts(self): return list(self.contacts.values()) def has_group(self, id): return id in self.groups def get_group(self, id): return self.groups[id] def get_groups(self): return list(self.groups.values()) def has_policy(self, id): return id in self.policies def get_policy(self, id): return self.policies[id] def get_policies(self): return list(self.policies.values()) @classmethod def transaction(cls): account_manager = AccountManager() xcap_accounts = [account for account in account_manager.get_accounts() if account.xcap.discovered] return MultiAccountTransaction(xcap_accounts) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_AddressbookContactWasActivated(self, notification): contact = notification.sender self.contacts[contact.id] = contact notification.center.post_notification('AddressbookManagerDidAddContact', sender=self, data=NotificationData(contact=contact)) def _NH_AddressbookContactWasDeleted(self, notification): contact = notification.sender del self.contacts[contact.id] notification.center.post_notification('AddressbookManagerDidRemoveContact', sender=self, data=NotificationData(contact=contact)) def _NH_AddressbookGroupWasActivated(self, notification): group = notification.sender self.groups[group.id] = group notification.center.post_notification('AddressbookManagerDidAddGroup', sender=self, data=NotificationData(group=group)) def _NH_AddressbookGroupWasDeleted(self, notification): group = notification.sender del self.groups[group.id] notification.center.post_notification('AddressbookManagerDidRemoveGroup', sender=self, data=NotificationData(group=group)) def _NH_AddressbookPolicyWasActivated(self, notification): policy = notification.sender self.policies[policy.id] = policy notification.center.post_notification('AddressbookManagerDidAddPolicy', sender=self, data=NotificationData(policy=policy)) def _NH_AddressbookPolicyWasDeleted(self, notification): policy = notification.sender del self.policies[policy.id] notification.center.post_notification('AddressbookManagerDidRemovePolicy', sender=self, data=NotificationData(policy=policy)) @run_in_thread('file-io') def _NH_SIPAccountDidDiscoverXCAPSupport(self, notification): xcap_manager = notification.sender.xcap_manager with xcap_manager.transaction(): for contact in list(self.contacts.values()): xcap_manager.add_contact(contact.__xcapcontact__) for group in list(self.groups.values()): xcap_manager.add_group(group.__xcapgroup__) for policy in list(self.policies.values()): xcap_manager.add_policy(policy.__xcappolicy__) @run_in_thread('file-io') def _NH_XCAPManagerDidReloadData(self, notification): if notification.data.addressbook == self.__xcapaddressbook__: return self.__xcapaddressbook__ = notification.data.addressbook xcap_manager = notification.sender xcap_contacts = notification.data.addressbook.contacts xcap_groups = notification.data.addressbook.groups xcap_policies = notification.data.addressbook.policies account_manager = AccountManager() xcap_accounts = [account for account in account_manager.get_accounts() if account.xcap.discovered] # temporary workaround to migrate contacts to the new format. to be removed later. -Dan if hasattr(self, '_AddressbookManager__old_data'): old_data = self.__old_data del self.__old_data if not xcap_contacts and not xcap_groups: self.__migrate_contacts(old_data) return with MultiAccountTransaction(xcap_accounts): # because groups depend on contacts, operation order is add/update contacts, add/update/remove groups & policies, remove contacts -Dan for xcap_contact in xcap_contacts: xcap_contact = XCAPContact.normalize(xcap_contact) try: contact = self.contacts[xcap_contact.id] except KeyError: try: contact = Contact(xcap_contact.id) except DuplicateIDError: log.exception() continue contact.name = xcap_contact.name contact.presence.policy = xcap_contact.presence.policy contact.presence.subscribe = xcap_contact.presence.subscribe contact.dialog.policy = xcap_contact.dialog.policy contact.dialog.subscribe = xcap_contact.dialog.subscribe for name, value in list(xcap_contact.attributes.items()): setattr(contact, name, value) for xcap_uri in xcap_contact.uris: xcap_uri = XCAPContactURI.normalize(xcap_uri) try: uri = contact.uris[xcap_uri.id] except KeyError: try: uri = ContactURI(xcap_uri.id) except DuplicateIDError: log.exception() continue contact.uris.add(uri) uri.uri = xcap_uri.uri uri.type = xcap_uri.type for name, value in list(xcap_uri.attributes.items()): setattr(uri, name, value) for uri in (uri for uri in list(contact.uris) if uri.id not in xcap_contact.uris): contact.uris.remove(uri) contact.uris.default = contact.uris.get(xcap_contact.uris.default, None) contact._internal_save(originator=Remote(xcap_manager.account, xcap_contact)) for xcap_group in xcap_groups: xcap_group = XCAPGroup.normalize(xcap_group) try: group = self.groups[xcap_group.id] except KeyError: try: group = Group(xcap_group.id) except DuplicateIDError: log.exception() continue group.name = xcap_group.name for name, value in list(xcap_group.attributes.items()): setattr(group, name, value) old_contact_ids = set(group.contacts.ids()) new_contact_ids = set(xcap_group.contacts.ids()) for contact in (self.contacts[id] for id in new_contact_ids - old_contact_ids): group.contacts.add(contact) for contact in (group.contacts[id] for id in old_contact_ids - new_contact_ids): group.contacts.remove(contact) group._internal_save(originator=Remote(xcap_manager.account, xcap_group)) for xcap_policy in xcap_policies: xcap_policy = XCAPPolicy.normalize(xcap_policy) try: policy = self.policies[xcap_policy.id] except KeyError: try: policy = Policy(xcap_policy.id) except DuplicateIDError: log.exception() continue policy.uri = xcap_policy.uri policy.name = xcap_policy.name policy.presence.policy = xcap_policy.presence.policy policy.presence.subscribe = xcap_policy.presence.subscribe policy.dialog.policy = xcap_policy.dialog.policy policy.dialog.subscribe = xcap_policy.dialog.subscribe for name, value in list(xcap_policy.attributes.items()): setattr(policy, name, value) policy._internal_save(originator=Remote(xcap_manager.account, xcap_policy)) originator = Remote(xcap_manager.account, None) for policy in (policy for policy in list(self.policies.values()) if policy.id not in xcap_policies): policy._internal_delete(originator=originator) for group in (group for group in list(self.groups.values()) if group.id not in xcap_groups): group._internal_delete(originator=originator) for contact in (contact for contact in list(self.contacts.values()) if contact.id not in xcap_contacts): contact._internal_delete(originator=originator) def __migrate_contacts(self, old_data): account_manager = AccountManager() xcap_accounts = [account for account in account_manager.get_accounts() if account.xcap.discovered] with MultiAccountTransaction(xcap_accounts): # restore the old contacts and groups old_groups = old_data['groups'] old_contacts = old_data['contacts'] group_idmap = {} for group_id, group_state in list(old_groups.items()): group_idmap[group_id] = group = Group() for name, value in list(group_state.items()): try: setattr(group, name, value) except (ValueError, TypeError): pass for account_id, account_contacts in list(old_contacts.items()): for group_id, contact_map in list(account_contacts.items()): for uri, contact_data in list(contact_map.items()): contact = Contact() for name, value in list(contact_data.items()): try: setattr(contact, name, value) except (ValueError, TypeError): pass contact.uris.add(ContactURI(uri=uri)) contact.save() group = group_idmap.get(group_id, Null) group.contacts.add(contact) for group in list(group_idmap.values()): group.save() diff --git a/sipsimple/application.py b/sipsimple/application.py index 1798f1cd..2376a952 100644 --- a/sipsimple/application.py +++ b/sipsimple/application.py @@ -1,527 +1,527 @@ """ Implements a high-level application responsible for starting and stopping various sub-systems required to implement a fully featured SIP User Agent application. """ __all__ = ["SIPApplication"] import os from .application.notification import IObserver, NotificationCenter, NotificationData from .application.python import Null from .application.python.descriptor import classproperty from .application.python.types import Singleton from eventlib import proc from operator import attrgetter from .threading import RLock, Thread from twisted.internet import reactor from uuid import uuid4 from xcaplib import client as xcap_client -from zope.interface import implements +from zope.interface import implementer from sipsimple.account import AccountManager from sipsimple.addressbook import AddressbookManager from sipsimple.audio import AudioDevice, RootAudioBridge from sipsimple.configuration import ConfigurationManager from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import AudioMixer, Engine from sipsimple.lookup import DNSManager from sipsimple.session import SessionManager from sipsimple.storage import ISIPSimpleStorage, ISIPSimpleApplicationDataStorage from sipsimple.threading import ThreadManager, run_in_thread, run_in_twisted_thread from sipsimple.threading.green import run_in_green_thread from sipsimple.video import VideoDevice class ApplicationAttribute(object): def __init__(self, value): self.value = value def __get__(self, obj, objtype): return self.value def __set__(self, obj, value): self.value = value def __delete__(self, obj): raise AttributeError('cannot delete attribute') +@implementer(IObserver) class SIPApplication(object, metaclass=Singleton): - implements(IObserver) storage = ApplicationAttribute(value=None) engine = ApplicationAttribute(value=None) thread = ApplicationAttribute(value=None) state = ApplicationAttribute(value=None) alert_audio_device = ApplicationAttribute(value=None) alert_audio_bridge = ApplicationAttribute(value=None) voice_audio_device = ApplicationAttribute(value=None) voice_audio_bridge = ApplicationAttribute(value=None) video_device = ApplicationAttribute(value=None) _lock = ApplicationAttribute(value=RLock()) _timer = ApplicationAttribute(value=None) _stop_pending = ApplicationAttribute(value=False) running = classproperty(lambda cls: cls.state == 'started') alert_audio_mixer = classproperty(lambda cls: cls.alert_audio_bridge.mixer if cls.alert_audio_bridge else None) voice_audio_mixer = classproperty(lambda cls: cls.voice_audio_bridge.mixer if cls.voice_audio_bridge else None) def start(self, storage): if not ISIPSimpleStorage.providedBy(storage): raise TypeError("storage must implement the ISIPSimpleStorage interface") with self._lock: if self.state is not None: raise RuntimeError("SIPApplication cannot be started from '%s' state" % self.state) self.state = 'starting' self.engine = Engine() self.storage = storage thread_manager = ThreadManager() thread_manager.start() configuration_manager = ConfigurationManager() addressbook_manager = AddressbookManager() account_manager = AccountManager() # load configuration and initialize core try: configuration_manager.start() SIPSimpleSettings() account_manager.load() addressbook_manager.load() except: self.engine = None self.state = None self.storage = None raise # run the reactor thread self.thread = Thread(name='Reactor Thread', target=self._run_reactor) self.thread.start() def stop(self): with self._lock: if self.state in (None, 'stopping', 'stopped'): return elif self.state == 'starting': self._stop_pending = True return self.state = 'stopping' notification_center = NotificationCenter() notification_center.post_notification('SIPApplicationWillEnd', sender=self) self._shutdown_subsystems() def _run_reactor(self): from eventlib.twistedutil import join_reactor; del join_reactor # imported for the side effect of making the twisted reactor green notification_center = NotificationCenter() notification_center.post_notification('SIPApplicationWillStart', sender=self) with self._lock: stop_pending = self._stop_pending if stop_pending: self.state = 'stopping' if stop_pending: notification_center.post_notification('SIPApplicationWillEnd', sender=self) else: self._initialize_core() reactor.run(installSignalHandlers=False) with self._lock: self.state = 'stopped' notification_center.post_notification('SIPApplicationDidEnd', sender=self) def _initialize_core(self): notification_center = NotificationCenter() settings = SIPSimpleSettings() # initialize core options = dict(# general user_agent=settings.user_agent, # SIP detect_sip_loops=True, 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=None, # TLS tls_verify_server=False, tls_ca_file=None, tls_cert_file=None, tls_privkey_file=None, # rtp rtp_port_range=(settings.rtp.port_range.start, settings.rtp.port_range.end), # audio codecs=list(settings.rtp.audio_codec_list), # video video_codecs=list(settings.rtp.video_codec_list), # logging log_level=settings.logs.pjsip_level if settings.logs.trace_pjsip else 0, trace_sip=settings.logs.trace_sip) notification_center.add_observer(self, sender=self.engine) self.engine.start(**options) def _initialize_tls(self): settings = SIPSimpleSettings() account_manager = AccountManager() account = account_manager.default_account if account is not None: try: self.engine.set_tls_options(port=settings.sip.tls_port, verify_server=account.tls.verify_server, ca_file=settings.tls.ca_list.normalized if settings.tls.ca_list else None, cert_file=account.tls.certificate.normalized if account.tls.certificate else None, privkey_file=account.tls.certificate.normalized if account.tls.certificate else None) except Exception as e: notification_center = NotificationCenter() notification_center.post_notification('SIPApplicationFailedToStartTLS', sender=self, data=NotificationData(error=e)) @run_in_green_thread def _initialize_subsystems(self): notification_center = NotificationCenter() with self._lock: stop_pending = self._stop_pending if stop_pending: self.state = 'stopping' if stop_pending: notification_center.post_notification('SIPApplicationWillEnd', sender=self) # stop the subsystems we already started: threads, engine and reactor self.engine.stop() self.engine.join(timeout=5) thread_manager = ThreadManager() thread_manager.stop() reactor.stop() return account_manager = AccountManager() addressbook_manager = AddressbookManager() dns_manager = DNSManager() session_manager = SessionManager() settings = SIPSimpleSettings() xcap_client.DEFAULT_HEADERS = {'User-Agent': settings.user_agent} # initialize TLS self._initialize_tls() # initialize PJSIP internal resolver self.engine.set_nameservers(dns_manager.nameservers) # initialize audio objects alert_device = settings.audio.alert_device if alert_device not in (None, 'system_default') and alert_device not in self.engine.output_devices: alert_device = 'system_default' input_device = settings.audio.input_device if input_device not in (None, 'system_default') and input_device not in self.engine.input_devices: input_device = 'system_default' output_device = settings.audio.output_device if output_device not in (None, 'system_default') and output_device not in self.engine.output_devices: output_device = 'system_default' tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0 voice_mixer = AudioMixer(input_device, output_device, settings.audio.sample_rate, tail_length) voice_mixer.muted = settings.audio.muted self.voice_audio_device = AudioDevice(voice_mixer) self.voice_audio_bridge = RootAudioBridge(voice_mixer) self.voice_audio_bridge.add(self.voice_audio_device) alert_mixer = AudioMixer(None, alert_device, settings.audio.sample_rate, 0) if settings.audio.silent: alert_mixer.output_volume = 0 self.alert_audio_device = AudioDevice(alert_mixer) self.alert_audio_bridge = RootAudioBridge(alert_mixer) self.alert_audio_bridge.add(self.alert_audio_device) settings.audio.input_device = voice_mixer.input_device settings.audio.output_device = voice_mixer.output_device settings.audio.alert_device = alert_mixer.output_device # initialize video self.video_device = VideoDevice(settings.video.device, settings.video.resolution, settings.video.framerate) self.video_device.muted = settings.video.muted settings.video.device = self.video_device.name self.engine.set_video_options(settings.video.resolution, settings.video.framerate, settings.video.max_bitrate) self.engine.set_h264_options(settings.video.h264.profile, settings.video.h264.level) # initialize instance id if not settings.instance_id: settings.instance_id = uuid4().urn # initialize path for ZRTP cache file if ISIPSimpleApplicationDataStorage.providedBy(self.storage): self.engine.zrtp_cache = os.path.join(self.storage.directory, 'zrtp.db') # save settings in case something was modified during startup settings.save() # initialize middleware components dns_manager.start() account_manager.start() addressbook_manager.start() session_manager.start() notification_center.add_observer(self, name='CFGSettingsObjectDidChange') notification_center.add_observer(self, name='DNSNameserversDidChange') notification_center.add_observer(self, name='SystemIPAddressDidChange') notification_center.add_observer(self, name='SystemDidWakeUpFromSleep') with self._lock: self.state = 'started' stop_pending = self._stop_pending notification_center.post_notification('SIPApplicationDidStart', sender=self) if stop_pending: self.stop() @run_in_green_thread def _shutdown_subsystems(self): # cleanup internals if self._timer is not None and self._timer.active(): self._timer.cancel() self._timer = None # shutdown middleware components dns_manager = DNSManager() account_manager = AccountManager() addressbook_manager = AddressbookManager() session_manager = SessionManager() procs = [proc.spawn(dns_manager.stop), proc.spawn(account_manager.stop), proc.spawn(addressbook_manager.stop), proc.spawn(session_manager.stop)] proc.waitall(procs) # stop video device self.video_device.producer.close() # shutdown engine self.engine.stop() self.engine.join(timeout=5) # stop threads thread_manager = ThreadManager() thread_manager.stop() # stop the reactor reactor.stop() def _network_conditions_changed(self): if self.running and self._timer is None: def notify(): if self.running: settings = SIPSimpleSettings() if 'tcp' in settings.sip.transport_list: self.engine.set_tcp_port(None) self.engine.set_tcp_port(settings.sip.tcp_port) if 'tls' in settings.sip.transport_list: self._initialize_tls() notification_center = NotificationCenter() notification_center.post_notification('NetworkConditionsDidChange', sender=self) self._timer = None self._timer = reactor.callLater(5, notify) @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPEngineDidStart(self, notification): self._initialize_subsystems() def _NH_SIPEngineDidFail(self, notification): with self._lock: if self.state == 'stopping': return self.state = 'stopping' notification.center.post_notification('SIPApplicationWillEnd', sender=self) # # In theory we need to stop the subsystems here, based on what subsystems are already running according to our state, # but in practice the majority of those subsystems need the engine even to stop and the engine has failed. # # Even the ThreadManager might have threads that try to execute operations on the engine, which could block indefinitely # waiting for an answer that will no longer arrive, thus blocking the ThreadManager stop operation. # # As a result the safest thing to do is to just stop the engine thread and the reactor, which means in this case we # will not cleanup properly (the engine thread should already have ended as a result of the failure, so stopping it # is technically a no-op). # self.engine.stop() self.engine.join(timeout=5) reactor.stop() def _NH_SIPEngineGotException(self, notification): notification.center.post_notification('SIPApplicationGotFatalError', sender=self, data=notification.data) @run_in_thread('device-io') def _NH_CFGSettingsObjectDidChange(self, notification): settings = SIPSimpleSettings() account_manager = AccountManager() if notification.sender is settings: if 'audio.sample_rate' in notification.data.modified: alert_device = settings.audio.alert_device if alert_device not in (None, 'system_default') and alert_device not in self.engine.output_devices: alert_device = 'system_default' input_device = settings.audio.input_device if input_device not in (None, 'system_default') and input_device not in self.engine.input_devices: input_device = 'system_default' output_device = settings.audio.output_device if output_device not in (None, 'system_default') and output_device not in self.engine.output_devices: output_device = 'system_default' tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0 voice_mixer = AudioMixer(input_device, output_device, settings.audio.sample_rate, tail_length) voice_mixer.muted = settings.audio.muted self.voice_audio_device = AudioDevice(voice_mixer) self.voice_audio_bridge = RootAudioBridge(voice_mixer) self.voice_audio_bridge.add(self.voice_audio_device) alert_mixer = AudioMixer(None, alert_device, settings.audio.sample_rate, 0) self.alert_audio_device = AudioDevice(alert_mixer) self.alert_audio_bridge = RootAudioBridge(alert_mixer) self.alert_audio_bridge.add(self.alert_audio_device) if settings.audio.silent: alert_mixer.output_volume = 0 settings.audio.input_device = voice_mixer.input_device settings.audio.output_device = voice_mixer.output_device settings.audio.alert_device = alert_mixer.output_device settings.save() else: if {'audio.input_device', 'audio.output_device', 'audio.alert_device', 'audio.echo_canceller.enabled', 'audio.echo_canceller.tail_length'}.intersection(notification.data.modified): input_device = settings.audio.input_device if input_device not in (None, 'system_default') and input_device not in self.engine.input_devices: input_device = 'system_default' output_device = settings.audio.output_device if output_device not in (None, 'system_default') and output_device not in self.engine.output_devices: output_device = 'system_default' tail_length = settings.audio.echo_canceller.tail_length if settings.audio.echo_canceller.enabled else 0 if (input_device, output_device, tail_length) != attrgetter('input_device', 'output_device', 'ec_tail_length')(self.voice_audio_bridge.mixer): self.voice_audio_bridge.mixer.set_sound_devices(input_device, output_device, tail_length) settings.audio.input_device = self.voice_audio_bridge.mixer.input_device settings.audio.output_device = self.voice_audio_bridge.mixer.output_device settings.save() alert_device = settings.audio.alert_device if alert_device not in (None, 'system_default') and alert_device not in self.engine.output_devices: alert_device = 'system_default' if alert_device != self.alert_audio_bridge.mixer.output_device: self.alert_audio_bridge.mixer.set_sound_devices(None, alert_device, 0) settings.audio.alert_device = self.alert_audio_bridge.mixer.output_device settings.save() if 'audio.muted' in notification.data.modified: self.voice_audio_bridge.mixer.muted = settings.audio.muted if 'audio.silent' in notification.data.modified: if settings.audio.silent: self.alert_audio_bridge.mixer.output_volume = 0 else: self.alert_audio_bridge.mixer.output_volume = 100 if 'video.muted' in notification.data.modified: self.video_device.muted = settings.video.muted if {'video.h264.profile', 'video.h264.level'}.intersection(notification.data.modified): self.engine.set_h264_options(settings.video.h264.profile, settings.video.h264.level) if {'video.device', 'video.resolution', 'video.framerate', 'video.max_bitrate'}.intersection(notification.data.modified): if {'video.device', 'video.resolution', 'video.framerate'}.intersection(notification.data.modified) or settings.video.device != self.video_device.name: self.video_device.set_camera(settings.video.device, settings.video.resolution, settings.video.framerate) settings.video.device = self.video_device.name settings.save() self.engine.set_video_options(settings.video.resolution, settings.video.framerate, settings.video.max_bitrate) if 'user_agent' in notification.data.modified: self.engine.user_agent = settings.user_agent if 'sip.udp_port' in notification.data.modified: self.engine.set_udp_port(settings.sip.udp_port) if 'sip.tcp_port' in notification.data.modified: self.engine.set_tcp_port(settings.sip.tcp_port) if {'sip.tls_port', 'tls.ca_list', 'default_account'}.intersection(notification.data.modified): self._initialize_tls() if 'rtp.port_range' in notification.data.modified: self.engine.rtp_port_range = (settings.rtp.port_range.start, settings.rtp.port_range.end) if 'rtp.audio_codec_list' in notification.data.modified: self.engine.codecs = list(settings.rtp.audio_codec_list) if 'logs.trace_sip' in notification.data.modified: self.engine.trace_sip = settings.logs.trace_sip if {'logs.trace_pjsip', 'logs.pjsip_level'}.intersection(notification.data.modified): self.engine.log_level = settings.logs.pjsip_level if settings.logs.trace_pjsip else 0 elif notification.sender is account_manager.default_account: if {'tls.verify_server', 'tls.certificate'}.intersection(notification.data.modified): self._initialize_tls() @run_in_thread('device-io') def _NH_DefaultAudioDeviceDidChange(self, notification): if None in (self.voice_audio_bridge, self.alert_audio_bridge): return settings = SIPSimpleSettings() current_input_device = self.voice_audio_bridge.mixer.input_device current_output_device = self.voice_audio_bridge.mixer.output_device current_alert_device = self.alert_audio_bridge.mixer.output_device ec_tail_length = self.voice_audio_bridge.mixer.ec_tail_length if notification.data.changed_input and 'system_default' in (current_input_device, settings.audio.input_device): self.voice_audio_bridge.mixer.set_sound_devices('system_default', current_output_device, ec_tail_length) if notification.data.changed_output and 'system_default' in (current_output_device, settings.audio.output_device): self.voice_audio_bridge.mixer.set_sound_devices(current_input_device, 'system_default', ec_tail_length) if notification.data.changed_output and 'system_default' in (current_alert_device, settings.audio.alert_device): self.alert_audio_bridge.mixer.set_sound_devices(None, 'system_default', 0) @run_in_thread('device-io') def _NH_AudioDevicesDidChange(self, notification): old_devices = set(notification.data.old_devices) new_devices = set(notification.data.new_devices) removed_devices = old_devices - new_devices if not removed_devices: return input_device = self.voice_audio_bridge.mixer.input_device output_device = self.voice_audio_bridge.mixer.output_device alert_device = self.alert_audio_bridge.mixer.output_device if self.voice_audio_bridge.mixer.real_input_device in removed_devices: input_device = 'system_default' if new_devices else None if self.voice_audio_bridge.mixer.real_output_device in removed_devices: output_device = 'system_default' if new_devices else None if self.alert_audio_bridge.mixer.real_output_device in removed_devices: alert_device = 'system_default' if new_devices else None self.voice_audio_bridge.mixer.set_sound_devices(input_device, output_device, self.voice_audio_bridge.mixer.ec_tail_length) self.alert_audio_bridge.mixer.set_sound_devices(None, alert_device, 0) settings = SIPSimpleSettings() settings.audio.input_device = self.voice_audio_bridge.mixer.input_device settings.audio.output_device = self.voice_audio_bridge.mixer.output_device settings.audio.alert_device = self.alert_audio_bridge.mixer.output_device settings.save() @run_in_thread('device-io') def _NH_VideoDevicesDidChange(self, notification): old_devices = set(notification.data.old_devices) new_devices = set(notification.data.new_devices) removed_devices = old_devices - new_devices if not removed_devices: return device = self.video_device.name if self.video_device.real_name in removed_devices: device = 'system_default' if new_devices else None settings = SIPSimpleSettings() self.video_device.set_camera(device, settings.video.resolution, settings.video.framerate) settings.video.device = self.video_device.name settings.save() def _NH_DNSNameserversDidChange(self, notification): if self.running: self.engine.set_nameservers(notification.data.nameservers) notification.center.post_notification('NetworkConditionsDidChange', sender=self) def _NH_SystemIPAddressDidChange(self, notification): self._network_conditions_changed() def _NH_SystemDidWakeUpFromSleep(self, notification): self._network_conditions_changed() diff --git a/sipsimple/audio.py b/sipsimple/audio.py index a77edf58..7790f7f8 100644 --- a/sipsimple/audio.py +++ b/sipsimple/audio.py @@ -1,541 +1,537 @@ """Audio support""" __all__ = ['IAudioPort', 'AudioDevice', 'AudioBridge', 'RootAudioBridge', 'AudioConference', 'WavePlayer', 'WavePlayerError', 'WaveRecorder'] import os import weakref from functools import partial from itertools import combinations from .threading import RLock from .application.notification import IObserver, NotificationCenter, NotificationData, ObserverWeakrefProxy from .application.system import makedirs from eventlib import coros from twisted.internet import reactor -from zope.interface import Attribute, Interface, implements +from zope.interface import Attribute, Interface, implementer from sipsimple.core import MixerPort, RecordingWaveFile, SIPCoreError, WaveFile from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, run_in_green_thread, run_in_waitable_green_thread class WavePlayerError(Exception): pass class IAudioPort(Interface): """ Interface describing an object which can produce and/or consume audio data. If an object cannot produce audio data, its producer_slot attribute must be None; similarly, if an object cannot consume audio data, its consumer_slot attribute must be None. As part of the interface, whenever an IAudioPort implementation changes its slot attributes, it must send a AudioPortDidChangeSlots notification with the following attributes in the notification data: * consumer_slot_changed * producer_slot_changed * old_consumer_slot (only required if consumer_slot_changed is True) * new_consumer_slot (only required if consumer_slot_changed is True) * old_producer_slot (only required if producer_slot_changed is True) * new_producer_slot (only required if producer_slot_changed is True) All attributes of this interface are read-only. """ mixer = Attribute("The mixer that is responsible for mixing the audio data to/from this audio port") consumer_slot = Attribute("The slot to which audio data can be written") producer_slot = Attribute("The slot from which audio data can be read") +@implementer(IAudioPort) class AudioDevice(object): """ Objects of this class represent an audio device which can be used in an AudioBridge as they implement the IAudioPort interface. Since a mixer is connected to an audio device which provides the mixer's clock, an AudioDevice constructed for a specific mixer represents the device that mixer is using. """ - implements(IAudioPort) - def __init__(self, mixer, input_muted=False, output_muted=False): self.mixer = mixer self.__dict__['input_muted'] = input_muted self.__dict__['output_muted'] = output_muted @property def consumer_slot(self): return 0 if not self.output_muted else None @property def producer_slot(self): return 0 if not self.input_muted else None @property def input_muted(self): return self.__dict__['input_muted'] @input_muted.setter def input_muted(self, value): if not isinstance(value, bool): raise ValueError('illegal value for input_muted property: %r' % (value,)) if value == self.input_muted: return old_producer_slot = self.producer_slot self.__dict__['input_muted'] = value notification_center = NotificationCenter() notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=False, producer_slot_changed=True, old_producer_slot=old_producer_slot, new_producer_slot=self.producer_slot)) @property def output_muted(self): return self.__dict__['output_muted'] @output_muted.setter def output_muted(self, value): if not isinstance(value, bool): raise ValueError('illegal value for output_muted property: %r' % (value,)) if value == self.output_muted: return old_consumer_slot = self.consumer_slot self.__dict__['output_muted'] = value notification_center = NotificationCenter() notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=True, producer_slot_changed=False, old_consumer_slot=old_consumer_slot, new_consumer_slot=self.consumer_slot)) +@implementer(IAudioPort, IObserver) class AudioBridge(object): """ An AudioBridge is a container for objects providing the IAudioPort interface. It connects all such objects in a full-mesh such that all audio producers are connected to all consumers. AudioBridge implements the IAudioPort interface which means a bridge can contain another bridge. This must be done such that the resulting structure is a tree (i.e. no loops are allowed). All leafs of the tree will be connected as if they were the children of a single bridge. """ - implements(IAudioPort, IObserver) - def __init__(self, mixer): self._lock = RLock() self.ports = set() self.mixer = mixer self.multiplexer = MixerPort(mixer) self.demultiplexer = MixerPort(mixer) self.multiplexer.start() self.demultiplexer.start() notification_center = NotificationCenter() notification_center.add_observer(ObserverWeakrefProxy(self), name='AudioPortDidChangeSlots') def __del__(self): self.multiplexer.stop() self.demultiplexer.stop() if len(self.ports) >= 2: for port1, port2 in ((wr1(), wr2()) for wr1, wr2 in combinations(self.ports, 2)): if port1 is None or port2 is None: continue if port1.producer_slot is not None and port2.consumer_slot is not None: self.mixer.disconnect_slots(port1.producer_slot, port2.consumer_slot) if port2.producer_slot is not None and port1.consumer_slot is not None: self.mixer.disconnect_slots(port2.producer_slot, port1.consumer_slot) self.ports.clear() def __contains__(self, port): return weakref.ref(port) in self.ports @property def consumer_slot(self): return self.demultiplexer.slot if self.demultiplexer.is_active else None @property def producer_slot(self): return self.multiplexer.slot if self.multiplexer.is_active else None def add(self, port): with self._lock: if not IAudioPort.providedBy(port): raise TypeError("expected object implementing IAudioPort, got %s" % port.__class__.__name__) if port.mixer is not self.mixer: raise ValueError("expected port with Mixer %r, got %r" % (self.mixer, port.mixer)) if weakref.ref(port) in self.ports: return if port.consumer_slot is not None and self.demultiplexer.slot is not None: self.mixer.connect_slots(self.demultiplexer.slot, port.consumer_slot) if port.producer_slot is not None and self.multiplexer.slot is not None: self.mixer.connect_slots(port.producer_slot, self.multiplexer.slot) for other in (wr() for wr in self.ports): if other is None: continue if other.producer_slot is not None and port.consumer_slot is not None: self.mixer.connect_slots(other.producer_slot, port.consumer_slot) if port.producer_slot is not None and other.consumer_slot is not None: self.mixer.connect_slots(port.producer_slot, other.consumer_slot) # This hack is required because a weakly referenced object keeps a # strong reference to weak references of itself and thus to any # callbacks registered in those weak references. To be more # precise, we don't want the port to have a strong reference to # ourselves. -Luci self.ports.add(weakref.ref(port, partial(self._remove_port, weakref.ref(self)))) def remove(self, port): with self._lock: if weakref.ref(port) not in self.ports: raise ValueError("port %r is not part of this bridge" % port) if port.consumer_slot is not None and self.demultiplexer.slot is not None: self.mixer.disconnect_slots(self.demultiplexer.slot, port.consumer_slot) if port.producer_slot is not None and self.multiplexer.slot is not None: self.mixer.disconnect_slots(port.producer_slot, self.multiplexer.slot) for other in (wr() for wr in self.ports): if other is None: continue if other.producer_slot is not None and port.consumer_slot is not None: self.mixer.disconnect_slots(other.producer_slot, port.consumer_slot) if port.producer_slot is not None and other.consumer_slot is not None: self.mixer.disconnect_slots(port.producer_slot, other.consumer_slot) self.ports.remove(weakref.ref(port)) def stop(self): with self._lock: for port1 in (wr() for wr in self.ports): if port1 is None: continue for port2 in (wr() for wr in self.ports): if port2 is None or port2 is port1: continue if port1.producer_slot is not None and port2.consumer_slot is not None: self.mixer.disconnect_slots(port1.producer_slot, port2.consumer_slot) if port2.producer_slot is not None and port1.consumer_slot is not None: self.mixer.disconnect_slots(port2.producer_slot, port1.consumer_slot) self.ports.clear() self.multiplexer.stop() self.demultiplexer.stop() def handle_notification(self, notification): with self._lock: if weakref.ref(notification.sender) not in self.ports: return if notification.data.consumer_slot_changed: if notification.data.old_consumer_slot is not None and self.demultiplexer.slot is not None: self.mixer.disconnect_slots(self.demultiplexer.slot, notification.data.old_consumer_slot) if notification.data.new_consumer_slot is not None and self.demultiplexer.slot is not None: self.mixer.connect_slots(self.demultiplexer.slot, notification.data.new_consumer_slot) for other in (wr() for wr in self.ports): if other is None or other is notification.sender or other.producer_slot is None: continue if notification.data.old_consumer_slot is not None: self.mixer.disconnect_slots(other.producer_slot, notification.data.old_consumer_slot) if notification.data.new_consumer_slot is not None: self.mixer.connect_slots(other.producer_slot, notification.data.new_consumer_slot) if notification.data.producer_slot_changed: if notification.data.old_producer_slot is not None and self.multiplexer.slot is not None: self.mixer.disconnect_slots(notification.data.old_producer_slot, self.multiplexer.slot) if notification.data.new_producer_slot is not None and self.multiplexer.slot is not None: self.mixer.connect_slots(notification.data.new_producer_slot, self.multiplexer.slot) for other in (wr() for wr in self.ports): if other is None or other is notification.sender or other.consumer_slot is None: continue if notification.data.old_producer_slot is not None: self.mixer.disconnect_slots(notification.data.old_producer_slot, other.consumer_slot) if notification.data.new_producer_slot is not None: self.mixer.connect_slots(notification.data.new_producer_slot, other.consumer_slot) @staticmethod def _remove_port(selfwr, portwr): self = selfwr() if self is not None: with self._lock: self.ports.discard(portwr) +@implementer(IObserver) class RootAudioBridge(object): """ A RootAudioBridge is a container for objects providing the IAudioPort interface. It connects all such objects in a full-mesh such that all audio producers are connected to all consumers. The difference between a RootAudioBridge and an AudioBridge is that the RootAudioBridge does not implement the IAudioPort interface. This makes it more efficient. """ - implements(IObserver) - def __init__(self, mixer): self.mixer = mixer self.ports = set() self._lock = RLock() notification_center = NotificationCenter() notification_center.add_observer(ObserverWeakrefProxy(self), name='AudioPortDidChangeSlots') def __del__(self): if len(self.ports) >= 2: for port1, port2 in ((wr1(), wr2()) for wr1, wr2 in combinations(self.ports, 2)): if port1 is None or port2 is None: continue if port1.producer_slot is not None and port2.consumer_slot is not None: self.mixer.disconnect_slots(port1.producer_slot, port2.consumer_slot) if port2.producer_slot is not None and port1.consumer_slot is not None: self.mixer.disconnect_slots(port2.producer_slot, port1.consumer_slot) self.ports.clear() def __contains__(self, port): return weakref.ref(port) in self.ports def add(self, port): with self._lock: if not IAudioPort.providedBy(port): raise TypeError("expected object implementing IAudioPort, got %s" % port.__class__.__name__) if port.mixer is not self.mixer: raise ValueError("expected port with Mixer %r, got %r" % (self.mixer, port.mixer)) if weakref.ref(port) in self.ports: return for other in (wr() for wr in self.ports): if other is None: continue if other.producer_slot is not None and port.consumer_slot is not None: self.mixer.connect_slots(other.producer_slot, port.consumer_slot) if port.producer_slot is not None and other.consumer_slot is not None: self.mixer.connect_slots(port.producer_slot, other.consumer_slot) # This hack is required because a weakly referenced object keeps a # strong reference to weak references of itself and thus to any # callbacks registered in those weak references. To be more # precise, we don't want the port to have a strong reference to # ourselves. -Luci self.ports.add(weakref.ref(port, partial(self._remove_port, weakref.ref(self)))) def remove(self, port): with self._lock: if weakref.ref(port) not in self.ports: raise ValueError("port %r is not part of this bridge" % port) for other in (wr() for wr in self.ports): if other is None: continue if other.producer_slot is not None and port.consumer_slot is not None: self.mixer.disconnect_slots(other.producer_slot, port.consumer_slot) if port.producer_slot is not None and other.consumer_slot is not None: self.mixer.disconnect_slots(port.producer_slot, other.consumer_slot) self.ports.remove(weakref.ref(port)) def handle_notification(self, notification): with self._lock: if weakref.ref(notification.sender) not in self.ports: return if notification.data.consumer_slot_changed: for other in (wr() for wr in self.ports): if other is None or other is notification.sender or other.producer_slot is None: continue if notification.data.old_consumer_slot is not None: self.mixer.disconnect_slots(other.producer_slot, notification.data.old_consumer_slot) if notification.data.new_consumer_slot is not None: self.mixer.connect_slots(other.producer_slot, notification.data.new_consumer_slot) if notification.data.producer_slot_changed: for other in (wr() for wr in self.ports): if other is None or other is notification.sender or other.consumer_slot is None: continue if notification.data.old_producer_slot is not None: self.mixer.disconnect_slots(notification.data.old_producer_slot, other.consumer_slot) if notification.data.new_producer_slot is not None: self.mixer.connect_slots(notification.data.new_producer_slot, other.consumer_slot) @staticmethod def _remove_port(selfwr, portwr): self = selfwr() if self is not None: with self._lock: self.ports.discard(portwr) class AudioConference(object): def __init__(self): from sipsimple.application import SIPApplication mixer = SIPApplication.voice_audio_mixer self.bridge = RootAudioBridge(mixer) self.device = AudioDevice(mixer) self.on_hold = False self.streams = [] self._lock = RLock() self.bridge.add(self.device) def add(self, stream): with self._lock: if stream in self.streams: return stream.bridge.remove(stream.device) self.bridge.add(stream.bridge) self.streams.append(stream) def remove(self, stream): with self._lock: self.streams.remove(stream) self.bridge.remove(stream.bridge) stream.bridge.add(stream.device) def hold(self): with self._lock: if self.on_hold: return self.bridge.remove(self.device) self.on_hold = True def unhold(self): with self._lock: if not self.on_hold: return self.bridge.add(self.device) self.on_hold = False +@implementer(IAudioPort, IObserver) class WavePlayer(object): """ An object capable of playing a WAV file. It can be used as part of an AudioBridge as it implements the IAudioPort interface. """ - implements(IAudioPort, IObserver) def __init__(self, mixer, filename, volume=100, loop_count=1, pause_time=0, initial_delay=0): self.mixer = mixer self.filename = filename self.initial_delay = initial_delay self.loop_count = loop_count self.pause_time = pause_time self.volume = volume self._channel = None self._current_loop = 0 self._state = 'stopped' self._wave_file = None @property def is_active(self): return self._state == "started" @property def consumer_slot(self): return None @property def producer_slot(self): return self._wave_file.slot if self._wave_file else None def start(self): self.play() @run_in_green_thread # run stop in a green thread in order to be similar with start/play. this avoids start/stop running out of order. def stop(self): if self._state != 'started': return self._channel.send(Command('stop')) @run_in_waitable_green_thread def play(self): if self._state != 'stopped': raise WavePlayerError('already playing') self._state = 'started' self._channel = coros.queue() self._current_loop = 0 if self.initial_delay: reactor.callLater(self.initial_delay, self._channel.send, Command('play')) else: self._channel.send(Command('play')) self._run().wait() @run_in_waitable_green_thread def _run(self): notification_center = NotificationCenter() try: while True: command = self._channel.wait() if command.name == 'play': self._wave_file = WaveFile(self.mixer, self.filename) notification_center.add_observer(self, sender=self._wave_file, name='WaveFileDidFinishPlaying') self._wave_file.volume = self.volume try: self._wave_file.start() except SIPCoreError as e: notification_center.post_notification('WavePlayerDidFail', sender=self, data=NotificationData(error=e)) raise WavePlayerError(e) else: if self._current_loop == 0: notification_center.post_notification('WavePlayerDidStart', sender=self) notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=False, producer_slot_changed=True, old_producer_slot=None, new_producer_slot=self._wave_file.slot)) elif command.name == 'reschedule': self._current_loop += 1 notification_center.remove_observer(self, sender=self._wave_file, name='WaveFileDidFinishPlaying') self._wave_file = None notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=False, producer_slot_changed=True, old_producer_slot=None, new_producer_slot=None)) if self.loop_count == 0 or self._current_loop < self.loop_count: reactor.callLater(self.pause_time, self._channel.send, Command('play')) else: notification_center.post_notification('WavePlayerDidEnd', sender=self) break elif command.name == 'stop': if self._wave_file is not None: notification_center.remove_observer(self, sender=self._wave_file, name='WaveFileDidFinishPlaying') self._wave_file.stop() self._wave_file = None notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=False, producer_slot_changed=True, old_producer_slot=None, new_producer_slot=None)) notification_center.post_notification('WavePlayerDidEnd', sender=self) break finally: self._channel = None self._state = 'stopped' @run_in_twisted_thread def handle_notification(self, notification): if self._channel is not None: self._channel.send(Command('reschedule')) +@implementer(IAudioPort) class WaveRecorder(object): """ An object capable of recording to a WAV file. It can be used as part of an AudioBridge as it implements the IAudioPort interface. """ - implements(IAudioPort) - def __init__(self, mixer, filename): self.mixer = mixer self.filename = filename self._recording_wave_file = None @property def is_active(self): return bool(self._recording_wave_file and self._recording_wave_file.is_active) @property def consumer_slot(self): return self._recording_wave_file.slot if self._recording_wave_file else None @property def producer_slot(self): return None def start(self): # There is still a race condition here in that the directory can be removed # before the PJSIP opens the file. There's nothing that can be done about # it as long as PJSIP doesn't accept an already open file descriptor. -Luci makedirs(os.path.dirname(self.filename)) self._recording_wave_file = RecordingWaveFile(self.mixer, self.filename) self._recording_wave_file.start() notification_center = NotificationCenter() notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=True, producer_slot_changed=False, old_consumer_slot=None, new_consumer_slot=self._recording_wave_file.slot)) def stop(self): old_slot = self.consumer_slot self._recording_wave_file.stop() self._recording_wave_file = None notification_center = NotificationCenter() notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=True, producer_slot_changed=False, old_consumer_slot=old_slot, new_consumer_slot=None)) diff --git a/sipsimple/configuration/backend/file.py b/sipsimple/configuration/backend/file.py index d3f99ac9..15e78a93 100644 --- a/sipsimple/configuration/backend/file.py +++ b/sipsimple/configuration/backend/file.py @@ -1,231 +1,231 @@ """Configuration backend for storing settings in a simple plain text format""" __all__ = ["FileParserError", "FileBuilderError", "FileBackend"] import errno import os import re import platform import random from collections import deque from application.system import makedirs, openfile, unlink -from zope.interface import implements +from zope.interface import implementer from sipsimple.configuration.backend import IConfigurationBackend, ConfigurationBackendError class FileParserError(ConfigurationBackendError): """Error raised when the configuration file cannot be parsed.""" class FileBuilderError(ConfigurationBackendError): """Error raised when the configuration data cannot be saved.""" class GroupState(object): """ Internal class used for keeping track of the containing groups while parsing. """ def __init__(self, indentation): self.indentation = indentation self.data = {} class Line(object): """Internal representation of lines in a configuration file""" def __init__(self, indentation, name, separator, value): self.indentation = indentation self.name = name self.separator = separator self.value = value def __repr__(self): return "%s(%r, %r, %r, %r)" % (self.__class__.__name__, self.indentation, self.name, self.separator, self.value) +@implementer(IConfigurationBackend) class FileBackend(object): """ Implementation of a configuration backend that stores data in a simple plain text format. """ - implements(IConfigurationBackend) escape_characters_re = re.compile(r"""[,"'=: #\\\t\x0b\x0c\n\r]""") def __init__(self, filename, encoding='utf-8'): """ Initialize the configuration backend with the specified file. The file is not read at this time, but rather each time the load method is called. """ self.filename = filename self.encoding = encoding def load(self): """ Read the file configured with this backend and parse it, returning a dictionary conforming to the IConfigurationBackend specification. """ try: file = open(self.filename) except IOError as e: if e.errno == errno.ENOENT: return {} else: raise ConfigurationBackendError("failed to read configuration file: %s" % str(e)) state_stack = deque() state_stack.appendleft(GroupState(-1)) for lineno, line in enumerate(file, 1): line = self._parse_line(line, lineno) if not line.name: continue # find the container for this declaration while state_stack[0].indentation >= line.indentation: state_stack.popleft() if line.separator == ':': new_group_state = GroupState(line.indentation) state_stack[0].data[line.name] = new_group_state.data state_stack.appendleft(new_group_state) elif line.separator == '=': state_stack[0].data[line.name] = line.value return state_stack[-1].data def save(self, data): """ Given a dictionary conforming to the IConfigurationBackend specification, write the data to the file configured with this backend in a format suitable to be read back using load(). """ lines = self._build_group(data, 0) config_directory = os.path.dirname(self.filename) tmp_filename = '%s.%d.%08X' % (self.filename, os.getpid(), random.getrandbits(32)) try: if config_directory: makedirs(config_directory) file = openfile(tmp_filename, 'wb', permissions=0o600) file.write((os.linesep.join(lines)+os.linesep).encode(self.encoding)) file.close() if platform.system() == 'Windows': # os.rename does not work on Windows if the destination file already exists. # It seems there is no atomic way to do this on Windows. unlink(self.filename) os.rename(tmp_filename, self.filename) except (IOError, OSError) as e: raise ConfigurationBackendError("failed to write configuration file: %s" % str(e)) def _parse_line(self, line, lineno): def advance_to_next_token(line): counter = 0 while line and line[0].isspace(): line.popleft() counter += 1 if line and line[0] == '#': line.clear() return counter def token_iterator(line, delimiter=''): quote_char = None while line: if quote_char is None and line[0] in delimiter: break char = line.popleft() if char in "'\"": if quote_char is None: quote_char = char continue elif quote_char == char: quote_char = None continue else: yield char elif char == '\\': if not line: raise FileParserError("unexpected `\\' at end of line %d" % lineno) char = line.popleft() if char == 'n': yield '\n' elif char == 'r': yield '\r' else: yield char elif quote_char is None and char == '#': line.clear() break elif quote_char is None and char.isspace(): break else: yield char if quote_char is not None: raise FileParserError("missing ending quote at line %d" % lineno) line = deque(line.rstrip().decode(self.encoding)) indentation = advance_to_next_token(line) if not line: return Line(indentation, None, None, None) name = ''.join(token_iterator(line, delimiter=':=')) advance_to_next_token(line) if not line or line[0] not in ':=': raise FileParserError("expected one of `:' or `=' at line %d" % lineno) if not name: raise FileParserError("missing setting/section name at line %d" % lineno) separator = line.popleft() advance_to_next_token(line) if not line: return Line(indentation, name, separator, None) elif separator == ':': raise FileParserError("unexpected characters after `:' at line %d" % lineno) value = None value_list = None while line: value = ''.join(token_iterator(line, delimiter=',')) advance_to_next_token(line) if line: if line[0] == ',': line.popleft() advance_to_next_token(line) if value_list is None: value_list = [] else: raise FileParserError("unexpected characters after value at line %d" % lineno) if value_list is not None: value_list.append(value) value = value_list if value_list is not None else value return Line(indentation, name, separator, value) def _build_group(self, group, indentation): setting_lines = [] group_lines = [] indent_spaces = ' '*4*indentation for name, data in sorted(group.items()): if data is None: setting_lines.append('%s%s =' % (indent_spaces, self._escape(name))) elif type(data) is dict: group_lines.append('%s%s:' % (indent_spaces, self._escape(name))) group_lines.extend(self._build_group(data, indentation+1)) group_lines.append('') elif type(data) is list: list_value = ', '.join(self._escape(item) for item in data) if len(data) == 1: list_value += ',' setting_lines.append('%s%s = %s' % (indent_spaces, self._escape(name), list_value)) elif type(data) is str: setting_lines.append('%s%s = %s' % (indent_spaces, self._escape(name), self._escape(data))) else: raise FileBuilderError("expected unicode, dict or list object, got %s" % type(data).__name__) return setting_lines + group_lines def _escape(self, value): if value == '': return '""' elif self.escape_characters_re.search(value): return '"%s"' % value.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\r', '\\r') else: return value diff --git a/sipsimple/configuration/backend/memory.py b/sipsimple/configuration/backend/memory.py index afdb84d4..14627d56 100644 --- a/sipsimple/configuration/backend/memory.py +++ b/sipsimple/configuration/backend/memory.py @@ -1,24 +1,24 @@ """Configuration backend for storing settings in memory""" __all__ = ["MemoryBackend"] -from zope.interface import implements +from zope.interface import implementer from sipsimple.configuration.backend import IConfigurationBackend +@implementer(IConfigurationBackend) class MemoryBackend(object): """Implementation of a configuration backend that stores data in memory.""" - implements(IConfigurationBackend) def __init__(self): self.data = {} def load(self): return self.data def save(self, data): self.data = data diff --git a/sipsimple/core/_primitives.py b/sipsimple/core/_primitives.py index 4c83526b..5f5eeeda 100644 --- a/sipsimple/core/_primitives.py +++ b/sipsimple/core/_primitives.py @@ -1,314 +1,314 @@ """ Implements a high-level mechanism for SIP methods that can be used for non-session based operations like REGISTER, SUBSCRIBE, PUBLISH and MESSAGE. """ __all__ = ["Message", "Registration", "Publication", "PublicationError", "PublicationETagError"] from threading import RLock from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null -from zope.interface import implements +from zope.interface import implementer from sipsimple.core._core import ContactHeader, Header, Request, RouteHeader, SIPCoreError, SIPURI, ToHeader +@implementer(IObserver) class Registration(object): - implements(IObserver) def __init__(self, from_header, credentials=None, duration=300, extra_headers=None): self.from_header = from_header self.credentials = credentials self.duration = duration self.extra_headers = extra_headers or [] self._current_request = None self._last_request = None self._unregistering = False self._lock = RLock() is_registered = property(lambda self: self._last_request is not None) contact_uri = property(lambda self: None if self._last_request is None else self._last_request.contact_uri) expires_in = property(lambda self: 0 if self._last_request is None else self._last_request.expires_in) peer_address = property(lambda self: None if self._last_request is None else self._last_request.peer_address) def register(self, contact_header, route_header, timeout=None): with self._lock: try: self._make_and_send_request(contact_header, route_header, timeout, True) except SIPCoreError as e: notification_center = NotificationCenter() notification_center.post_notification('SIPRegistrationDidFail', sender=self, data=NotificationData(code=0, reason=e.args[0], route_header=route_header)) def end(self, timeout=None): with self._lock: if self._last_request is None: return notification_center = NotificationCenter() notification_center.post_notification('SIPRegistrationWillEnd', sender=self) try: self._make_and_send_request(ContactHeader.new(self._last_request.contact_header), RouteHeader.new(self._last_request.route_header), timeout, False) except SIPCoreError as e: notification_center.post_notification('SIPRegistrationDidNotEnd', sender=self, data=NotificationData(code=0, reason=e.args[0])) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPRequestDidSucceed(self, notification): request = notification.sender with self._lock: if request is not self._current_request: return self._current_request = None if self._unregistering: if self._last_request is not None: self._last_request.end() self._last_request = None notification.center.post_notification('SIPRegistrationDidEnd', sender=self, data=NotificationData(expired=False)) else: self._last_request = request try: contact_header_list = notification.data.headers["Contact"] except KeyError: contact_header_list = [] notification.center.post_notification('SIPRegistrationDidSucceed', sender=self, data=NotificationData(code=notification.data.code, reason=notification.data.reason, contact_header=request.contact_header, contact_header_list=contact_header_list, expires_in=notification.data.expires, route_header=request.route_header)) def _NH_SIPRequestDidFail(self, notification): request = notification.sender with self._lock: if request is not self._current_request: return self._current_request = None if self._unregistering: notification.center.post_notification('SIPRegistrationDidNotEnd', sender=self, data=NotificationData(code=notification.data.code, reason=notification.data.reason)) else: if hasattr(notification.data, 'headers'): min_expires = notification.data.headers.get('Min-Expires', None) else: min_expires = None notification.center.post_notification('SIPRegistrationDidFail', sender=self, data=NotificationData(code=notification.data.code, reason=notification.data.reason, route_header=request.route_header, min_expires=min_expires)) def _NH_SIPRequestWillExpire(self, notification): with self._lock: if notification.sender is not self._last_request: return notification.center.post_notification('SIPRegistrationWillExpire', sender=self, data=NotificationData(expires=notification.data.expires)) def _NH_SIPRequestDidEnd(self, notification): request = notification.sender with self._lock: notification.center.remove_observer(self, sender=request) if request is not self._last_request: return self._last_request = None if self._current_request is not None: self._current_request.end() self._current_request = None notification.center.post_notification('SIPRegistrationDidEnd', sender=self, data=NotificationData(expired=True)) def _make_and_send_request(self, contact_header, route_header, timeout, do_register): notification_center = NotificationCenter() prev_request = self._current_request or self._last_request if prev_request is not None: call_id = prev_request.call_id cseq = prev_request.cseq + 1 else: call_id = None cseq = 1 extra_headers = [] extra_headers.append(Header("Expires", str(int(self.duration) if do_register else 0))) extra_headers.extend(self.extra_headers) request = Request("REGISTER", SIPURI(self.from_header.uri.host), self.from_header, ToHeader.new(self.from_header), route_header, credentials=self.credentials, contact_header=contact_header, call_id=call_id, cseq=cseq, extra_headers=extra_headers) notification_center.add_observer(self, sender=request) if self._current_request is not None: # we are trying to send something already, cancel whatever it is self._current_request.end() self._current_request = None try: request.send(timeout=timeout) except: notification_center.remove_observer(self, sender=request) raise self._unregistering = not do_register self._current_request = request +@implementer(IObserver) class Message(object): - implements(IObserver) def __init__(self, from_header, to_header, route_header, content_type, body, credentials=None, extra_headers=None): self._request = Request("MESSAGE", to_header.uri, from_header, to_header, route_header, credentials=credentials, extra_headers=extra_headers, content_type=content_type, body=body) self._lock = RLock() from_header = property(lambda self: self._request.from_header) to_header = property(lambda self: self._request.to_header) route_header = property(lambda self: self._request.route_header) content_type = property(lambda self: self._request.content_type) body = property(lambda self: self._request.body) credentials = property(lambda self: self._request.credentials) is_sent = property(lambda self: self._request.state != "INIT") in_progress = property(lambda self: self._request.state == "IN_PROGRESS") peer_address = property(lambda self: self._request.peer_address) def send(self, timeout=None): notification_center = NotificationCenter() with self._lock: if self.is_sent: raise RuntimeError("This MESSAGE was already sent") notification_center.add_observer(self, sender=self._request) try: self._request.send(timeout) except: notification_center.remove_observer(self, sender=self._request) raise def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPRequestDidSucceed(self, notification): if notification.data.expires: # this shouldn't happen really notification.sender.end() notification.center.post_notification('SIPMessageDidSucceed', sender=self, data=notification.data) def _NH_SIPRequestDidFail(self, notification): notification.center.post_notification('SIPMessageDidFail', sender=self, data=notification.data) def _NH_SIPRequestDidEnd(self, notification): notification.center.remove_observer(self, sender=notification.sender) class PublicationError(Exception): pass class PublicationETagError(PublicationError): pass +@implementer(IObserver) class Publication(object): - implements(IObserver) def __init__(self, from_header, event, content_type, credentials=None, duration=300, extra_headers=None): self.from_header = from_header self.event = event self.content_type = content_type self.credentials = credentials self.duration = duration self.extra_headers = extra_headers or [] self._last_etag = None self._current_request = None self._last_request = None self._unpublishing = False self._lock = RLock() is_published = property(lambda self: self._last_request is not None) expires_in = property(lambda self: 0 if self._last_request is None else self._last_request.expires_in) peer_address = property(lambda self: None if self._last_request is None else self._last_request.peer_address) def publish(self, body, route_header, timeout=None): with self._lock: if body is None: if self._last_request is None: raise ValueError("Need body for initial PUBLISH") elif self._last_etag is None: raise PublicationETagError("Cannot refresh, last ETag was invalid") self._make_and_send_request(body, route_header, timeout, True) def end(self, timeout=None): with self._lock: if self._last_request is None: return notification_center = NotificationCenter() notification_center.post_notification('SIPPublicationWillEnd', sender=self) try: self._make_and_send_request(None, RouteHeader.new(self._last_request.route_header), timeout, False) except SIPCoreError as e: notification_center.post_notification('SIPPublicationDidNotEnd', sender=self, data=NotificationData(code=0, reason=e.args[0])) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPRequestDidSucceed(self, notification): request = notification.sender with self._lock: if request is not self._current_request: return self._current_request = None if self._unpublishing: if self._last_request is not None: self._last_request.end() self._last_request = None self._last_etag = None notification.center.post_notification('SIPPublicationDidEnd', sender=self, data=NotificationData(expired=False)) else: self._last_request = request self._last_etag = notification.data.headers["SIP-ETag"].body if "SIP-ETag" in notification.data.headers else None # TODO: add more data? notification.center.post_notification('SIPPublicationDidSucceed', sender=self, data=NotificationData(code=notification.data.code, reason=notification.data.reason, expires_in=notification.data.expires, route_header=request.route_header)) def _NH_SIPRequestDidFail(self, notification): request = notification.sender with self._lock: if request is not self._current_request: return self._current_request = None if notification.data.code == 412: self._last_etag = None if self._unpublishing: notification.center.post_notification('SIPPublicationDidNotEnd', sender=self, data=NotificationData(code=notification.data.code, reason=notification.data.reason)) else: notification.center.post_notification('SIPPublicationDidFail', sender=self, data=NotificationData(code=notification.data.code, reason=notification.data.reason, route_header=request.route_header)) def _NH_SIPRequestWillExpire(self, notification): with self._lock: if notification.sender is not self._last_request: return notification.center.post_notification('SIPPublicationWillExpire', sender=self, data=NotificationData(expires=notification.data.expires)) def _NH_SIPRequestDidEnd(self, notification): with self._lock: notification.center.remove_observer(self, sender=notification.sender) if notification.sender is not self._last_request: return self._last_request = None if self._current_request is not None: self._current_request.end() self._current_request = None self._last_etag = None notification.center.post_notification('SIPPublicationDidEnd', sender=self, data=NotificationData(expired=True)) def _make_and_send_request(self, body, route_header, timeout, do_publish): notification_center = NotificationCenter() extra_headers = [] extra_headers.append(Header("Event", self.event)) extra_headers.append(Header("Expires", str(int(self.duration) if do_publish else 0))) if self._last_etag is not None: extra_headers.append(Header("SIP-If-Match", self._last_etag)) extra_headers.extend(self.extra_headers) content_type = (self.content_type if body is not None else None) request = Request("PUBLISH", self.from_header.uri, self.from_header, ToHeader.new(self.from_header), route_header, credentials=self.credentials, cseq=1, extra_headers=extra_headers, content_type=content_type, body=body) notification_center.add_observer(self, sender=request) if self._current_request is not None: # we are trying to send something already, cancel whatever it is self._current_request.end() self._current_request = None try: request.send(timeout=timeout) except: notification_center.remove_observer(self, sender=request) raise self._unpublishing = not do_publish self._current_request = request diff --git a/sipsimple/lookup.py b/sipsimple/lookup.py index 7ef574f1..2333a525 100644 --- a/sipsimple/lookup.py +++ b/sipsimple/lookup.py @@ -1,557 +1,557 @@ """ Implements DNS lookups in the context of SIP, STUN and MSRP relay based on RFC3263 and related standards. This can be used to determine the next hop(s) and failover for routing of SIP messages and reservation of network resources prior the starting of a SIP session. """ import re from itertools import chain from time import time from urllib.parse import urlparse # patch dns.entropy module which is not thread-safe import dns import sys from functools import partial from random import randint, randrange dns.entropy = dns.__class__('dns.entropy') dns.entropy.__file__ = dns.__file__.replace('__init__.py', 'entropy.py') dns.entropy.__builtins__ = dns.__builtins__ dns.entropy.random_16 = partial(randrange, 2**16) dns.entropy.between = randint sys.modules['dns.entropy'] = dns.entropy del partial, randint, randrange, sys # replace standard select and socket modules with versions from eventlib from eventlib import coros, proc from eventlib.green import select from eventlib.green import socket import dns.name import dns.resolver import dns.query dns.resolver.socket = socket dns.query.socket = socket dns.query.select = select dns.query._set_polling_backend(dns.query._select_for) from .application.notification import IObserver, NotificationCenter, NotificationData from .application.python import Null, limit from .application.python.decorator import decorator, preserve_signature from .application.python.types import Singleton from dns import exception, rdatatype from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sipsimple.core import Route from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, InterruptCommand, run_in_waitable_green_thread def domain_iterator(domain): """ A generator which returns the domain and its parent domains. """ while domain not in ('.', ''): yield domain domain = (domain.split('.', 1)+[''])[1] @decorator def post_dns_lookup_notifications(func): @preserve_signature(func) def wrapper(obj, *args, **kwargs): notification_center = NotificationCenter() try: result = func(obj, *args, **kwargs) except DNSLookupError as e: notification_center.post_notification('DNSLookupDidFail', sender=obj, data=NotificationData(error=str(e))) raise else: notification_center.post_notification('DNSLookupDidSucceed', sender=obj, data=NotificationData(result=result)) return result return wrapper class DNSLookupError(Exception): """ The error raised by DNSLookup when a lookup cannot be performed. """ class DNSCache(object): """ A simple DNS cache which uses twisted's timers to invalidate its expired data. """ def __init__(self): self.data = {} def get(self, key): return self.data.get(key, None) def put(self, key, value): expiration = value.expiration-time() if expiration > 0: self.data[key] = value reactor.callLater(limit(expiration, max=3600), self.data.pop, key, None) def flush(self, key=None): if key is not None: self.data.pop(key, None) else: self.data = {} class InternalResolver(dns.resolver.Resolver): def __init__(self, *args, **kw): super(InternalResolver, self).__init__(*args, **kw) if self.domain.to_text().endswith('local.'): self.domain = dns.name.root self.search = [item for item in self.search if not item.to_text().endswith('local.')] class DNSResolver(dns.resolver.Resolver): """ The resolver used by DNSLookup. The lifetime setting on it applies to all the queries made on this resolver. Each time a query is performed, its duration is subtracted from the lifetime value. """ def __init__(self): dns.resolver.Resolver.__init__(self, configure=False) dns_manager = DNSManager() self.search = dns_manager.search self.domain = dns_manager.domain self.nameservers = dns_manager.nameservers def query(self, *args, **kw): start_time = time() try: return dns.resolver.Resolver.query(self, *args, **kw) finally: self.lifetime -= min(self.lifetime, time()-start_time) class SRVResult(object): """ Internal object used to save the result of SRV queries. """ def __init__(self, priority, weight, port, address): self.priority = priority self.weight = weight self.port = port self.address = address class NAPTRResult(object): """ Internal object used to save the result of NAPTR queries. """ def __init__(self, service, order, preference, priority, weight, port, address): self.service = service self.order = order self.preference = preference self.priority = priority self.weight = weight self.port = port self.address = address class DNSLookup(object): cache = DNSCache() @run_in_waitable_green_thread @post_dns_lookup_notifications def lookup_service(self, uri, service, timeout=3.0, lifetime=15.0): """ Performs an SRV query to determine the servers used for the specified service from the domain in uri.host. If this fails and falling back is supported, also performs an A query on uri.host, returning the default port of the service along with the IP addresses in the answer. The services supported are `stun' and 'msrprelay'. The DNSLookupDidSucceed notification contains a result attribute which is a list of (address, port) tuples. The DNSLookupDidFail notification contains an error attribute describing the error encountered. """ service_srv_record_map = {"stun": ("_stun._udp", 3478, False), "msrprelay": ("_msrps._tcp", 2855, True)} log_context = dict(context='lookup_service', service=service, uri=uri) try: service_prefix, service_port, service_fallback = service_srv_record_map[service] except KeyError: raise DNSLookupError("Unknown service: %s" % service) try: # If the host part of the URI is an IP address, we will not do any lookup if re.match("^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", uri.host): return [(uri.host, uri.port or service_port)] resolver = DNSResolver() resolver.cache = self.cache resolver.timeout = timeout resolver.lifetime = lifetime record_name = '%s.%s' % (service_prefix, uri.host) services = self._lookup_srv_records(resolver, [record_name], log_context=log_context) if services[record_name]: return [(result.address, result.port) for result in services[record_name]] elif service_fallback: addresses = self._lookup_a_records(resolver, [uri.host], log_context=log_context) if addresses[uri.host]: return [(addr, service_port) for addr in addresses[uri.host]] except dns.resolver.Timeout: raise DNSLookupError('Timeout in lookup for %s servers for domain %s' % (service, uri.host)) else: raise DNSLookupError('No %s servers found for domain %s' % (service, uri.host)) @run_in_waitable_green_thread @post_dns_lookup_notifications def lookup_sip_proxy(self, uri, supported_transports, timeout=3.0, lifetime=15.0): """ Performs an RFC 3263 compliant lookup of transport/ip/port combinations for a particular SIP URI. As arguments it takes a SIPURI object and a list of supported transports, in order of preference of the application. It returns a list of Route objects that can be used in order of preference. The DNSLookupDidSucceed notification contains a result attribute which is a list of Route objects. The DNSLookupDidFail notification contains an error attribute describing the error encountered. """ naptr_service_transport_map = {"sips+d2t": "tls", "sip+d2t": "tcp", "sip+d2u": "udp"} transport_service_map = {"udp": "_sip._udp", "tcp": "_sip._tcp", "tls": "_sips._tcp"} log_context = dict(context='lookup_sip_proxy', uri=uri) if not supported_transports: raise DNSLookupError("No transports are supported") supported_transports = [transport.lower() for transport in supported_transports] unknown_transports = set(supported_transports).difference(transport_service_map) if unknown_transports: raise DNSLookupError("Unknown transports: %s" % ', '.join(unknown_transports)) try: # If the host part of the URI is an IP address, we will not do any lookup if re.match("^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", uri.host): transport = 'tls' if uri.secure else uri.transport.lower() if transport not in supported_transports: raise DNSLookupError("Transport %s dictated by URI is not supported" % transport) port = uri.port or (5061 if transport=='tls' else 5060) return [Route(address=uri.host, port=port, transport=transport)] resolver = DNSResolver() resolver.cache = self.cache resolver.timeout = timeout resolver.lifetime = lifetime # If the port is specified in the URI, we will only do an A lookup if uri.port: transport = 'tls' if uri.secure else uri.transport.lower() if transport not in supported_transports: raise DNSLookupError("Transport %s dictated by URI is not supported" % transport) addresses = self._lookup_a_records(resolver, [uri.host], log_context=log_context) if addresses[uri.host]: return [Route(address=addr, port=uri.port, transport=transport) for addr in addresses[uri.host]] # If the transport was already set as a parameter on the SIP URI, only do SRV lookups elif 'transport' in uri.parameters: transport = uri.parameters['transport'].lower() if transport not in supported_transports: raise DNSLookupError("Requested lookup for URI with %s transport, but it is not supported" % transport) if uri.secure and transport != 'tls': raise DNSLookupError("Requested lookup for SIPS URI, but with %s transport parameter" % transport) record_name = '%s.%s' % (transport_service_map[transport], uri.host) services = self._lookup_srv_records(resolver, [record_name], log_context=log_context) if services[record_name]: return [Route(address=result.address, port=result.port, transport=transport) for result in services[record_name]] else: # If SRV lookup fails, try A lookup addresses = self._lookup_a_records(resolver, [uri.host], log_context=log_context) port = 5061 if transport=='tls' else 5060 if addresses[uri.host]: return [Route(address=addr, port=port, transport=transport) for addr in addresses[uri.host]] # Otherwise, it means we don't have a numeric IP address, a port isn't specified and neither is a transport. So we have to do a full NAPTR lookup else: # If the URI is a SIPS URI, we only support the TLS transport. if uri.secure: if 'tls' not in supported_transports: raise DNSLookupError("Requested lookup for SIPS URI, but TLS transport is not supported") supported_transports = ['tls'] # First try NAPTR lookup naptr_services = [service for service, transport in list(naptr_service_transport_map.items()) if transport in supported_transports] try: pointers = self._lookup_naptr_record(resolver, uri.host, naptr_services, log_context=log_context) except dns.resolver.Timeout: pointers = [] if pointers: return [Route(address=result.address, port=result.port, transport=naptr_service_transport_map[result.service]) for result in pointers] else: # If that fails, try SRV lookup routes = [] for transport in supported_transports: record_name = '%s.%s' % (transport_service_map[transport], uri.host) try: services = self._lookup_srv_records(resolver, [record_name], log_context=log_context) except dns.resolver.Timeout: continue if services[record_name]: routes.extend(Route(address=result.address, port=result.port, transport=transport) for result in services[record_name]) if routes: return routes else: # If SRV lookup fails, try A lookup transport = 'tls' if uri.secure else 'udp' if transport in supported_transports: addresses = self._lookup_a_records(resolver, [uri.host], log_context=log_context) port = 5061 if transport=='tls' else 5060 if addresses[uri.host]: return [Route(address=addr, port=port, transport=transport) for addr in addresses[uri.host]] except dns.resolver.Timeout: raise DNSLookupError("Timeout in lookup for routes for SIP URI %s" % uri) else: raise DNSLookupError("No routes found for SIP URI %s" % uri) @run_in_waitable_green_thread @post_dns_lookup_notifications def lookup_xcap_server(self, uri, timeout=3.0, lifetime=15.0): """ Performs a TXT query against xcap. and returns all results that look like HTTP URIs. """ log_context = dict(context='lookup_xcap_server', uri=uri) notification_center = NotificationCenter() try: # If the host part of the URI is an IP address, we cannot not do any lookup if re.match("^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", uri.host): raise DNSLookupError("Cannot perform DNS query because the host is an IP address") resolver = DNSResolver() resolver.cache = self.cache resolver.timeout = timeout resolver.lifetime = lifetime record_name = 'xcap.%s' % uri.host results = [] try: answer = resolver.query(record_name, rdatatype.TXT) except dns.resolver.Timeout as e: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='TXT', query_name=str(record_name), nameservers=resolver.nameservers, answer=None, error=e, **log_context)) raise except exception.DNSException as e: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='TXT', query_name=str(record_name), nameservers=resolver.nameservers, answer=None, error=e, **log_context)) else: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='TXT', query_name=str(record_name), nameservers=resolver.nameservers, answer=answer, error=None, **log_context)) for result_uri in list(chain(*(r.strings for r in answer.rrset))): parsed_uri = urlparse(result_uri) if parsed_uri.scheme in ('http', 'https') and parsed_uri.netloc: results.append(result_uri) if not results: raise DNSLookupError('No XCAP servers found for domain %s' % uri.host) return results except dns.resolver.Timeout: raise DNSLookupError('Timeout in lookup for XCAP servers for domain %s' % uri.host) def _lookup_a_records(self, resolver, hostnames, additional_records=[], log_context={}): notification_center = NotificationCenter() additional_addresses = dict((rset.name.to_text(), rset) for rset in additional_records if rset.rdtype == rdatatype.A) addresses = {} for hostname in hostnames: if hostname in additional_addresses: addresses[hostname] = [r.address for r in additional_addresses[hostname]] else: try: answer = resolver.query(hostname, rdatatype.A) except dns.resolver.Timeout as e: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='A', query_name=str(hostname), nameservers=resolver.nameservers, answer=None, error=e, **log_context)) raise except exception.DNSException as e: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='A', query_name=str(hostname), nameservers=resolver.nameservers, answer=None, error=e, **log_context)) addresses[hostname] = [] else: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='A', query_name=str(hostname), nameservers=resolver.nameservers, answer=answer, error=None, **log_context)) addresses[hostname] = [r.address for r in answer.rrset] return addresses def _lookup_srv_records(self, resolver, srv_names, additional_records=[], log_context={}): notification_center = NotificationCenter() additional_services = dict((rset.name.to_text(), rset) for rset in additional_records if rset.rdtype == rdatatype.SRV) services = {} for srv_name in srv_names: services[srv_name] = [] if srv_name in additional_services: addresses = self._lookup_a_records(resolver, [r.target.to_text() for r in additional_services[srv_name]], additional_records) for record in additional_services[srv_name]: services[srv_name].extend(SRVResult(record.priority, record.weight, record.port, addr) for addr in addresses.get(record.target.to_text(), ())) else: try: answer = resolver.query(srv_name, rdatatype.SRV) except dns.resolver.Timeout as e: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='SRV', query_name=str(srv_name), nameservers=resolver.nameservers, answer=None, error=e, **log_context)) raise except exception.DNSException as e: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='SRV', query_name=str(srv_name), nameservers=resolver.nameservers, answer=None, error=e, **log_context)) else: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='SRV', query_name=str(srv_name), nameservers=resolver.nameservers, answer=answer, error=None, **log_context)) addresses = self._lookup_a_records(resolver, [r.target.to_text() for r in answer.rrset], answer.response.additional, log_context) for record in answer.rrset: services[srv_name].extend(SRVResult(record.priority, record.weight, record.port, addr) for addr in addresses.get(record.target.to_text(), ())) services[srv_name].sort(key=lambda result: (result.priority, -result.weight)) return services def _lookup_naptr_record(self, resolver, domain, services, log_context={}): notification_center = NotificationCenter() pointers = [] try: answer = resolver.query(domain, rdatatype.NAPTR) except dns.resolver.Timeout as e: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='NAPTR', query_name=str(domain), nameservers=resolver.nameservers, answer=None, error=e, **log_context)) raise except exception.DNSException as e: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='NAPTR', query_name=str(domain), nameservers=resolver.nameservers, answer=None, error=e, **log_context)) else: notification_center.post_notification('DNSLookupTrace', sender=self, data=NotificationData(query_type='NAPTR', query_name=str(domain), nameservers=resolver.nameservers, answer=answer, error=None, **log_context)) records = [r for r in answer.rrset if r.service.lower() in services] services = self._lookup_srv_records(resolver, [r.replacement.to_text() for r in records], answer.response.additional, log_context) for record in records: pointers.extend(NAPTRResult(record.service.lower(), record.order, record.preference, r.priority, r.weight, r.port, r.address) for r in services.get(record.replacement.to_text(), ())) pointers.sort(key=lambda result: (result.order, result.preference)) return pointers +@implementer(IObserver) class DNSManager(object, metaclass=Singleton): - implements(IObserver) def __init__(self): default_resolver = InternalResolver() self.search = default_resolver.search self.domain = default_resolver.domain self.nameservers = default_resolver.nameservers self.google_nameservers = ['8.8.8.8', '8.8.4.4'] self.probed_domain = 'sip2sip.info.' self._channel = coros.queue() self._proc = None self._timer = None self._wakeup_timer = None notification_center = NotificationCenter() notification_center.add_observer(self, name='SystemIPAddressDidChange') notification_center.add_observer(self, name='SystemDidWakeUpFromSleep') @property def nameservers(self): return self.__dict__['nameservers'] @nameservers.setter def nameservers(self, value): old_value = self.__dict__.get('nameservers', Null) self.__dict__['nameservers'] = value if old_value is Null: NotificationCenter().post_notification('DNSResolverDidInitialize', sender=self, data=NotificationData(nameservers=value)) elif value != old_value: NotificationCenter().post_notification('DNSNameserversDidChange', sender=self, data=NotificationData(nameservers=value)) def start(self): self._proc = proc.spawn(self._run) self._channel.send(Command('probe_dns')) def stop(self): if self._proc is not None: self._proc.kill() self._proc = None if self._timer is not None and self._timer.active(): self._timer.cancel() self._timer = None if self._wakeup_timer is not None and self._wakeup_timer.active(): self._wakeup_timer.cancel() self._wakeup_timer = None def _run(self): while True: try: command = self._channel.wait() handler = getattr(self, '_CH_%s' % command.name) handler(command) except InterruptCommand: pass def _CH_probe_dns(self, command): if self._timer is not None and self._timer.active(): self._timer.cancel() self._timer = None resolver = InternalResolver() self.domain = resolver.domain self.search = resolver.search local_nameservers = resolver.nameservers # probe local resolver resolver.timeout = 1 resolver.lifetime = 3 try: answer = resolver.query(self.probed_domain, rdatatype.NAPTR) if not any(record.rdtype == rdatatype.NAPTR for record in answer.rrset): raise exception.DNSException("No NAPTR records found") answer = resolver.query("_sip._udp.%s" % self.probed_domain, rdatatype.SRV) if not any(record.rdtype == rdatatype.SRV for record in answer.rrset): raise exception.DNSException("No SRV records found") except (dns.resolver.Timeout, exception.DNSException): pass else: self.nameservers = resolver.nameservers return # local resolver failed. probe google resolver resolver.nameservers = self.google_nameservers resolver.timeout = 2 resolver.lifetime = 4 try: answer = resolver.query(self.probed_domain, rdatatype.NAPTR) if not any(record.rdtype == rdatatype.NAPTR for record in answer.rrset): raise exception.DNSException("No NAPTR records found") except (dns.resolver.Timeout, exception.DNSException): pass else: self.nameservers = resolver.nameservers return # google resolver failed. fallback to local resolver and schedule another probe for later self.nameservers = local_nameservers self._timer = reactor.callLater(15, self._channel.send, Command('probe_dns')) @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SystemIPAddressDidChange(self, notification): self._proc.kill(InterruptCommand) self._channel.send(Command('probe_dns')) def _NH_SystemDidWakeUpFromSleep(self, notification): if self._wakeup_timer is None: def wakeup_action(): self._proc.kill(InterruptCommand) self._channel.send(Command('probe_dns')) self._wakeup_timer = None self._wakeup_timer = reactor.callLater(5, wakeup_action) # wait for system to stabilize diff --git a/sipsimple/session.py b/sipsimple/session.py index 609447f6..c2d178ad 100644 --- a/sipsimple/session.py +++ b/sipsimple/session.py @@ -1,2729 +1,2730 @@ """ Implements an asynchronous notification based mechanism for establishment, modification and termination of sessions using Session Initiation Protocol (SIP) standardized in RFC3261. """ __all__ = ['Session', 'SessionManager'] import random from .threading import RLock from time import time from .application.notification import IObserver, Notification, NotificationCenter, NotificationData from .application.python import Null, limit from .application.python.decorator import decorator, preserve_signature from .application.python.types import Singleton from .application.system import host from eventlib import api, coros, proc from twisted.internet import reactor -from zope.interface import implements +from zope.interface import implementer from sipsimple import log from sipsimple.account import AccountManager, BonjourAccount from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import DialogID, Engine, Invitation, Referral, Subscription, PJSIPError, SIPCoreError, SIPCoreInvalidStateError, SIPURI, sip_status_messages, sipfrag_re from sipsimple.core import ContactHeader, FromHeader, Header, ReasonHeader, ReferToHeader, ReplacesHeader, RouteHeader, ToHeader, WarningHeader from sipsimple.core import SDPConnection, SDPMediaStream, SDPSession from sipsimple.core import PublicGRUU, PublicGRUUIfAvailable, NoGRUU from sipsimple.lookup import DNSLookup, DNSLookupError from sipsimple.payloads import ParserError from sipsimple.payloads.conference import ConferenceDocument from sipsimple.streams import MediaStreamRegistry, InvalidStreamError, UnknownStreamError from sipsimple.threading import run_in_twisted_thread from sipsimple.threading.green import Command, run_in_green_thread from sipsimple.util import ISOTimestamp class InvitationDisconnectedError(Exception): def __init__(self, invitation, data): self.invitation = invitation self.data = data class MediaStreamDidNotInitializeError(Exception): def __init__(self, stream, data): self.stream = stream self.data = data class MediaStreamDidFailError(Exception): def __init__(self, stream, data): self.stream = stream self.data = data class SubscriptionError(Exception): def __init__(self, error, timeout, **attributes): self.error = error self.timeout = timeout self.attributes = attributes class SIPSubscriptionDidFail(Exception): def __init__(self, data): self.data = data class InterruptSubscription(Exception): pass class TerminateSubscription(Exception): pass class ReferralError(Exception): def __init__(self, error, code=0): self.error = error self.code = code class TerminateReferral(Exception): pass class SIPReferralDidFail(Exception): def __init__(self, data): self.data = data class IllegalStateError(RuntimeError): pass class IllegalDirectionError(RuntimeError): pass class SIPInvitationTransferDidFail(Exception): def __init__(self, data): self.data = data @decorator def transition_state(required_state, new_state): def state_transitioner(func): @preserve_signature(func) def wrapper(obj, *args, **kwargs): with obj._lock: if obj.state != required_state: raise IllegalStateError('cannot call %s in %s state' % (func.__name__, obj.state)) obj.state = new_state return func(obj, *args, **kwargs) return wrapper return state_transitioner @decorator def check_state(required_states): def state_checker(func): @preserve_signature(func) def wrapper(obj, *args, **kwargs): if obj.state not in required_states: raise IllegalStateError('cannot call %s in %s state' % (func.__name__, obj.state)) return func(obj, *args, **kwargs) return wrapper return state_checker @decorator def check_transfer_state(direction, state): def state_checker(func): @preserve_signature(func) def wrapper(obj, *args, **kwargs): if obj.transfer_handler.direction != direction: raise IllegalDirectionError('cannot transfer in %s direction' % obj.transfer_handler.direction) if obj.transfer_handler.state != state: raise IllegalStateError('cannot transfer in %s state' % obj.transfer_handler.state) return func(obj, *args, **kwargs) return wrapper return state_checker class AddParticipantOperation(object): pass class RemoveParticipantOperation(object): pass + +@implementer(IObserver) class ReferralHandler(object): - implements(IObserver) def __init__(self, session, participant_uri, operation): self.participant_uri = participant_uri if not isinstance(self.participant_uri, SIPURI): if not self.participant_uri.startswith(('sip:', 'sips:')): self.participant_uri = 'sip:%s' % self.participant_uri try: self.participant_uri = SIPURI.parse(self.participant_uri) except SIPCoreError: notification_center = NotificationCenter() if operation is AddParticipantOperation: notification_center.post_notification('SIPConferenceDidNotAddParticipant', sender=session, data=NotificationData(participant=self.participant_uri, code=0, reason='invalid participant URI')) else: notification_center.post_notification('SIPConferenceDidNotRemoveParticipant', sender=session, data=NotificationData(participant=self.participant_uri, code=0, reason='invalid participant URI')) return self.session = session self.operation = operation self.active = False self.route = None self._channel = coros.queue() self._referral = None def start(self): notification_center = NotificationCenter() if not self.session.remote_focus: if self.operation is AddParticipantOperation: notification_center.post_notification('SIPConferenceDidNotAddParticipant', sender=self.session, data=NotificationData(participant=self.participant_uri, code=0, reason='remote endpoint is not a focus')) else: notification_center.post_notification('SIPConferenceDidNotRemoveParticipant', sender=self.session, data=NotificationData(participant=self.participant_uri, code=0, reason='remote endpoint is not a focus')) self.session = None return notification_center.add_observer(self, sender=self.session) notification_center.add_observer(self, name='NetworkConditionsDidChange') proc.spawn(self._run) def _run(self): notification_center = NotificationCenter() settings = SIPSimpleSettings() try: # Lookup routes account = self.session.account if account is BonjourAccount(): uri = SIPURI.new(self.session._invitation.remote_contact_header.uri) elif account.sip.outbound_proxy is not None and account.sip.outbound_proxy.transport in settings.sip.transport_list: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) elif account.sip.always_use_my_proxy: uri = SIPURI(host=account.id.domain) else: uri = SIPURI.new(self.session.remote_identity.uri) lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError as e: timeout = random.uniform(15, 30) raise ReferralError(error='DNS lookup failed: %s' % e) target_uri = SIPURI.new(self.session.remote_identity.uri) timeout = time() + 30 for route in routes: self.route = route remaining_time = timeout - time() if remaining_time > 0: try: contact_uri = account.contact[NoGRUU, route] except KeyError: continue refer_to_header = ReferToHeader(str(self.participant_uri)) refer_to_header.parameters['method'] = 'INVITE' if self.operation is AddParticipantOperation else 'BYE' referral = Referral(target_uri, FromHeader(account.uri, account.display_name), ToHeader(target_uri), refer_to_header, ContactHeader(contact_uri), RouteHeader(route.uri), account.credentials) notification_center.add_observer(self, sender=referral) try: referral.send_refer(timeout=limit(remaining_time, min=1, max=5)) except SIPCoreError: notification_center.remove_observer(self, sender=referral) timeout = 5 raise ReferralError(error='Internal error') self._referral = referral try: while True: notification = self._channel.wait() if notification.name == 'SIPReferralDidStart': break except SIPReferralDidFail as e: notification_center.remove_observer(self, sender=referral) self._referral = None if e.data.code in (403, 405): raise ReferralError(error=sip_status_messages[e.data.code], code=e.data.code) else: # Otherwise just try the next route continue else: break else: self.route = None raise ReferralError(error='No more routes to try') # At this point it is subscribed. Handle notifications and ending/failures. try: self.active = True while True: notification = self._channel.wait() if notification.name == 'SIPReferralGotNotify': if notification.data.event == 'refer' and notification.data.body: match = sipfrag_re.match(notification.data.body) if match: code = int(match.group('code')) reason = match.group('reason') if code/100 > 2: continue if self.operation is AddParticipantOperation: notification_center.post_notification('SIPConferenceGotAddParticipantProgress', sender=self.session, data=NotificationData(participant=self.participant_uri, code=code, reason=reason)) else: notification_center.post_notification('SIPConferenceGotRemoveParticipantProgress', sender=self.session, data=NotificationData(participant=self.participant_uri, code=code, reason=reason)) elif notification.name == 'SIPReferralDidEnd': break except SIPReferralDidFail as e: notification_center.remove_observer(self, sender=self._referral) raise ReferralError(error=e.data.reason, code=e.data.code) else: notification_center.remove_observer(self, sender=self._referral) if self.operation is AddParticipantOperation: notification_center.post_notification('SIPConferenceDidAddParticipant', sender=self.session, data=NotificationData(participant=self.participant_uri)) else: notification_center.post_notification('SIPConferenceDidRemoveParticipant', sender=self.session, data=NotificationData(participant=self.participant_uri)) finally: self.active = False except TerminateReferral: if self._referral is not None: try: self._referral.end(timeout=2) except SIPCoreError: pass else: try: while True: notification = self._channel.wait() if notification.name == 'SIPReferralDidEnd': break except SIPReferralDidFail: pass finally: notification_center.remove_observer(self, sender=self._referral) if self.operation is AddParticipantOperation: notification_center.post_notification('SIPConferenceDidNotAddParticipant', sender=self.session, data=NotificationData(participant=self.participant_uri, code=0, reason='error')) else: notification_center.post_notification('SIPConferenceDidNotRemoveParticipant', sender=self.session, data=NotificationData(participant=self.participant_uri, code=0, reason='error')) except ReferralError as e: if self.operation is AddParticipantOperation: notification_center.post_notification('SIPConferenceDidNotAddParticipant', sender=self.session, data=NotificationData(participant=self.participant_uri, code=e.code, reason=e.error)) else: notification_center.post_notification('SIPConferenceDidNotRemoveParticipant', sender=self.session, data=NotificationData(participant=self.participant_uri, code=e.code, reason=e.error)) finally: notification_center.remove_observer(self, sender=self.session) notification_center.remove_observer(self, name='NetworkConditionsDidChange') self.session = None self._referral = None def _refresh(self): try: contact_header = ContactHeader(self.session.account.contact[NoGRUU, self.route]) except KeyError: pass else: try: self._referral.refresh(contact_header=contact_header, timeout=2) except (SIPCoreError, SIPCoreInvalidStateError): pass @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPReferralDidStart(self, notification): self._channel.send(notification) def _NH_SIPReferralDidEnd(self, notification): self._channel.send(notification) def _NH_SIPReferralDidFail(self, notification): self._channel.send_exception(SIPReferralDidFail(notification.data)) def _NH_SIPReferralGotNotify(self, notification): self._channel.send(notification) def _NH_SIPSessionDidFail(self, notification): self._channel.send_exception(TerminateReferral()) def _NH_SIPSessionWillEnd(self, notification): self._channel.send_exception(TerminateReferral()) def _NH_NetworkConditionsDidChange(self, notification): if self.active: self._refresh() +@implementer(IObserver) class ConferenceHandler(object): - implements(IObserver) def __init__(self, session): self.session = session self.active = False self.subscribed = False self._command_proc = None self._command_channel = coros.queue() self._data_channel = coros.queue() self._subscription = None self._subscription_proc = None self._subscription_timer = None notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) notification_center.add_observer(self, name='NetworkConditionsDidChange') self._command_proc = proc.spawn(self._run) @run_in_green_thread def add_participant(self, participant_uri): referral_handler = ReferralHandler(self.session, participant_uri, AddParticipantOperation) referral_handler.start() @run_in_green_thread def remove_participant(self, participant_uri): referral_handler = ReferralHandler(self.session, participant_uri, RemoveParticipantOperation) referral_handler.start() def _run(self): while True: command = self._command_channel.wait() handler = getattr(self, '_CH_%s' % command.name) handler(command) def _activate(self): self.active = True command = Command('subscribe') self._command_channel.send(command) return command def _deactivate(self): self.active = False command = Command('unsubscribe') self._command_channel.send(command) return command def _resubscribe(self): command = Command('subscribe') self._command_channel.send(command) return command def _terminate(self): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=self.session) notification_center.remove_observer(self, name='NetworkConditionsDidChange') self._deactivate() command = Command('terminate') self._command_channel.send(command) command.wait() self.session = None def _CH_subscribe(self, command): if self._subscription_timer is not None and self._subscription_timer.active(): self._subscription_timer.cancel() self._subscription_timer = None if self._subscription_proc is not None: subscription_proc = self._subscription_proc subscription_proc.kill(InterruptSubscription) subscription_proc.wait() self._subscription_proc = proc.spawn(self._subscription_handler, command) def _CH_unsubscribe(self, command): # Cancel any timer which would restart the subscription process if self._subscription_timer is not None and self._subscription_timer.active(): self._subscription_timer.cancel() self._subscription_timer = None if self._subscription_proc is not None: subscription_proc = self._subscription_proc subscription_proc.kill(TerminateSubscription) subscription_proc.wait() self._subscription_proc = None command.signal() def _CH_terminate(self, command): command.signal() raise proc.ProcExit() def _subscription_handler(self, command): notification_center = NotificationCenter() settings = SIPSimpleSettings() try: # Lookup routes account = self.session.account if account is BonjourAccount(): uri = SIPURI.new(self.session._invitation.remote_contact_header.uri) elif account.sip.outbound_proxy is not None and account.sip.outbound_proxy.transport in settings.sip.transport_list: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) elif account.sip.always_use_my_proxy: uri = SIPURI(host=account.id.domain) else: uri = SIPURI.new(self.session.remote_identity.uri) lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError as e: timeout = random.uniform(15, 30) raise SubscriptionError(error='DNS lookup failed: %s' % e, timeout=timeout) target_uri = SIPURI.new(self.session.remote_identity.uri) default_interval = 600 if account is BonjourAccount() else account.sip.subscribe_interval refresh_interval = getattr(command, 'refresh_interval', default_interval) timeout = time() + 30 for route in routes: remaining_time = timeout - time() if remaining_time > 0: try: contact_uri = account.contact[NoGRUU, route] except KeyError: continue subscription = Subscription(target_uri, FromHeader(account.uri, account.display_name), ToHeader(target_uri), ContactHeader(contact_uri), 'conference', RouteHeader(route.uri), credentials=account.credentials, refresh=refresh_interval) notification_center.add_observer(self, sender=subscription) try: subscription.subscribe(timeout=limit(remaining_time, min=1, max=5)) except SIPCoreError: notification_center.remove_observer(self, sender=subscription) timeout = 5 raise SubscriptionError(error='Internal error', timeout=timeout) self._subscription = subscription try: while True: notification = self._data_channel.wait() if notification.sender is subscription and notification.name == 'SIPSubscriptionDidStart': break except SIPSubscriptionDidFail as e: notification_center.remove_observer(self, sender=subscription) self._subscription = None if e.data.code == 407: # Authentication failed, so retry the subscription in some time timeout = random.uniform(60, 120) raise SubscriptionError(error='Authentication failed', timeout=timeout) elif e.data.code == 423: # Get the value of the Min-Expires header timeout = random.uniform(60, 120) if e.data.min_expires is not None and e.data.min_expires > refresh_interval: raise SubscriptionError(error='Interval too short', timeout=timeout, min_expires=e.data.min_expires) else: raise SubscriptionError(error='Interval too short', timeout=timeout) elif e.data.code in (405, 406, 489, 1400): command.signal(e) return else: # Otherwise just try the next route continue else: self.subscribed = True command.signal() break else: # There are no more routes to try, reschedule the subscription timeout = random.uniform(60, 180) raise SubscriptionError(error='No more routes to try', timeout=timeout) # At this point it is subscribed. Handle notifications and ending/failures. try: while True: notification = self._data_channel.wait() if notification.sender is not self._subscription: continue if notification.name == 'SIPSubscriptionGotNotify': if notification.data.event == 'conference' and notification.data.body: try: conference_info = ConferenceDocument.parse(notification.data.body) except ParserError: pass else: notification_center.post_notification('SIPSessionGotConferenceInfo', sender=self.session, data=NotificationData(conference_info=conference_info)) elif notification.name == 'SIPSubscriptionDidEnd': break except SIPSubscriptionDidFail: self._command_channel.send(Command('subscribe')) notification_center.remove_observer(self, sender=self._subscription) except InterruptSubscription as e: if not self.subscribed: command.signal(e) if self._subscription is not None: notification_center.remove_observer(self, sender=self._subscription) try: self._subscription.end(timeout=2) except SIPCoreError: pass except TerminateSubscription as e: if not self.subscribed: command.signal(e) if self._subscription is not None: try: self._subscription.end(timeout=2) except SIPCoreError: pass else: try: while True: notification = self._data_channel.wait() if notification.sender is self._subscription and notification.name == 'SIPSubscriptionDidEnd': break except SIPSubscriptionDidFail: pass finally: notification_center.remove_observer(self, sender=self._subscription) except SubscriptionError as e: if 'min_expires' in e.attributes: command = Command('subscribe', command.event, refresh_interval=e.attributes['min_expires']) else: command = Command('subscribe', command.event) self._subscription_timer = reactor.callLater(e.timeout, self._command_channel.send, command) finally: self.subscribed = False self._subscription = None self._subscription_proc = None @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSubscriptionDidStart(self, notification): self._data_channel.send(notification) def _NH_SIPSubscriptionDidEnd(self, notification): self._data_channel.send(notification) def _NH_SIPSubscriptionDidFail(self, notification): self._data_channel.send_exception(SIPSubscriptionDidFail(notification.data)) def _NH_SIPSubscriptionGotNotify(self, notification): self._data_channel.send(notification) def _NH_SIPSessionDidStart(self, notification): if self.session.remote_focus: self._activate() @run_in_green_thread def _NH_SIPSessionDidFail(self, notification): self._terminate() @run_in_green_thread def _NH_SIPSessionDidEnd(self, notification): self._terminate() def _NH_SIPSessionDidRenegotiateStreams(self, notification): if self.session.remote_focus and not self.active: self._activate() elif not self.session.remote_focus and self.active: self._deactivate() def _NH_NetworkConditionsDidChange(self, notification): if self.active: self._resubscribe() class TransferInfo(object): def __init__(self, referred_by=None, replaced_dialog_id=None): self.referred_by = referred_by self.replaced_dialog_id = replaced_dialog_id +@implementer(IObserver) class TransferHandler(object): - implements(IObserver) def __init__(self, session): self.state = None self.direction = None self.new_session = None self.session = session notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) notification_center.add_observer(self, sender=self.session._invitation) self._command_channel = coros.queue() self._data_channel = coros.queue() self._proc = proc.spawn(self._run) def _run(self): while True: command = self._command_channel.wait() handler = getattr(self, '_CH_%s' % command.name) handler(command) self.direction = None self.state = None def _CH_incoming_transfer(self, command): self.direction = 'incoming' notification_center = NotificationCenter() refer_to_hdr = command.data.headers.get('Refer-To') target = SIPURI.parse(refer_to_hdr.uri) referred_by_hdr = command.data.headers.get('Referred-By', None) if referred_by_hdr is not None: origin = referred_by_hdr.body else: origin = str(self.session.remote_identity.uri) try: while True: try: notification = self._data_channel.wait() except SIPInvitationTransferDidFail: self.state = 'failed' return else: if notification.name == 'SIPInvitationTransferDidStart': self.state = 'starting' refer_to_uri = SIPURI.new(target) refer_to_uri.headers = {} refer_to_uri.parameters = {} notification_center.post_notification('SIPSessionTransferNewIncoming', self.session, NotificationData(transfer_destination=refer_to_uri)) elif notification.name == 'SIPSessionTransferDidStart': break elif notification.name == 'SIPSessionTransferDidFail': self.state = 'failed' try: self.session._invitation.notify_transfer_progress(notification.data.code, notification.data.reason) except SIPCoreError: return while True: try: notification = self._data_channel.wait() except SIPInvitationTransferDidFail: return self.state = 'started' transfer_info = TransferInfo(referred_by=origin) try: replaces_hdr = target.headers.pop('Replaces') call_id, rest = replaces_hdr.split(';', 1) params = dict((item.split('=') for item in rest.split(';'))) to_tag = params.get('to-tag') from_tag = params.get('from-tag') except (KeyError, ValueError): pass else: transfer_info.replaced_dialog_id = DialogID(call_id, local_tag=from_tag, remote_tag=to_tag) settings = SIPSimpleSettings() account = self.session.account if account is BonjourAccount(): uri = target elif account.sip.outbound_proxy is not None and account.sip.outbound_proxy.transport in settings.sip.transport_list: uri = SIPURI(host=account.sip.outbound_proxy.host, port=account.sip.outbound_proxy.port, parameters={'transport': account.sip.outbound_proxy.transport}) elif account.sip.always_use_my_proxy: uri = SIPURI(host=account.id.domain) else: uri = target lookup = DNSLookup() try: routes = lookup.lookup_sip_proxy(uri, settings.sip.transport_list).wait() except DNSLookupError as e: self.state = 'failed' notification_center.post_notification('SIPSessionTransferDidFail', sender=self.session, data=NotificationData(code=0, reason="DNS lookup failed: {}".format(e))) try: self.session._invitation.notify_transfer_progress(480) except SIPCoreError: return while True: try: self._data_channel.wait() except SIPInvitationTransferDidFail: return return self.new_session = Session(account) notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.new_session) self.new_session.connect(ToHeader(target), routes=routes, streams=[MediaStreamRegistry.AudioStream()], transfer_info=transfer_info) while True: try: notification = self._data_channel.wait() except SIPInvitationTransferDidFail: return if notification.name == 'SIPInvitationTransferDidEnd': return except proc.ProcExit: if self.new_session is not None: notification_center.remove_observer(self, sender=self.new_session) self.new_session = None raise def _CH_outgoing_transfer(self, command): self.direction = 'outgoing' notification_center = NotificationCenter() self.state = 'starting' while True: try: notification = self._data_channel.wait() except SIPInvitationTransferDidFail as e: self.state = 'failed' notification_center.post_notification('SIPSessionTransferDidFail', sender=self.session, data=NotificationData(code=e.data.code, reason=e.data.reason)) return if notification.name == 'SIPInvitationTransferDidStart': self.state = 'started' notification_center.post_notification('SIPSessionTransferDidStart', sender=self.session) elif notification.name == 'SIPInvitationTransferDidEnd': self.state = 'ended' self.session.end() notification_center.post_notification('SIPSessionTransferDidEnd', sender=self.session) return def _terminate(self): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=self.session._invitation) notification_center.remove_observer(self, sender=self.session) self._proc.kill() self._proc = None self._command_channel = None self._data_channel = None self.session = None @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPInvitationTransferNewIncoming(self, notification): self._command_channel.send(Command('incoming_transfer', data=notification.data)) def _NH_SIPInvitationTransferNewOutgoing(self, notification): self._command_channel.send(Command('outgoing_transfer', data=notification.data)) def _NH_SIPInvitationTransferDidStart(self, notification): self._data_channel.send(notification) def _NH_SIPInvitationTransferDidFail(self, notification): self._data_channel.send_exception(SIPInvitationTransferDidFail(notification.data)) def _NH_SIPInvitationTransferDidEnd(self, notification): self._data_channel.send(notification) def _NH_SIPInvitationTransferGotNotify(self, notification): if notification.data.event == 'refer' and notification.data.body: match = sipfrag_re.match(notification.data.body) if match: code = int(match.group('code')) reason = match.group('reason') notification.center.post_notification('SIPSessionTransferGotProgress', sender=self.session, data=NotificationData(code=code, reason=reason)) def _NH_SIPSessionTransferDidStart(self, notification): if notification.sender is self.session and self.state == 'starting': self._data_channel.send(notification) def _NH_SIPSessionTransferDidFail(self, notification): if notification.sender is self.session and self.state == 'starting': self._data_channel.send(notification) def _NH_SIPSessionGotRingIndication(self, notification): if notification.sender is self.new_session and self.session is not None: try: self.session._invitation.notify_transfer_progress(180) except SIPCoreError: pass def _NH_SIPSessionGotProvisionalResponse(self, notification): if notification.sender is self.new_session and self.session is not None: try: self.session._invitation.notify_transfer_progress(notification.data.code, notification.data.reason) except SIPCoreError: pass def _NH_SIPSessionDidStart(self, notification): if notification.sender is self.new_session: notification.center.remove_observer(self, sender=notification.sender) self.new_session = None if self.session is not None: notification.center.post_notification('SIPSessionTransferDidEnd', sender=self.session) if self.state == 'started': try: self.session._invitation.notify_transfer_progress(200) except SIPCoreError: pass self.state = 'ended' self.session.end() def _NH_SIPSessionDidEnd(self, notification): if notification.sender is self.new_session: # If any stream fails to start we won't get SIPSessionDidFail, we'll get here instead notification.center.remove_observer(self, sender=notification.sender) self.new_session = None if self.session is not None: notification.center.post_notification('SIPSessionTransferDidFail', sender=self.session, data=NotificationData(code=500, reason='internal error')) if self.state == 'started': try: self.session._invitation.notify_transfer_progress(500) except SIPCoreError: pass self.state = 'failed' else: self._terminate() def _NH_SIPSessionDidFail(self, notification): if notification.sender is self.new_session: notification.center.remove_observer(self, sender=notification.sender) self.new_session = None if self.session is not None: notification.center.post_notification('SIPSessionTransferDidFail', sender=self.session, data=NotificationData(code=notification.data.code or 500, reason=notification.data.reason)) if self.state == 'started': try: self.session._invitation.notify_transfer_progress(notification.data.code or 500, notification.data.reason) except SIPCoreError: pass self.state = 'failed' else: self._terminate() class OptionalTag(str): def __eq__(self, other): return other is None or super(OptionalTag, self).__eq__(other) def __ne__(self, other): return not self == other def __repr__(self): return '{}({})'.format(self.__class__.__name__, super(OptionalTag, self).__repr__()) +@implementer(IObserver) class SessionReplaceHandler(object): - implements(IObserver) def __init__(self, session): self.session = session def start(self): notification_center = NotificationCenter() notification_center.add_observer(self, sender=self.session) notification_center.add_observer(self, sender=self.session.replaced_session) def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPSessionDidStart(self, notification): notification.center.remove_observer(self, sender=self.session) notification.center.remove_observer(self, sender=self.session.replaced_session) self.session.replaced_session.end() self.session.replaced_session = None self.session = None def _NH_SIPSessionDidFail(self, notification): if notification.sender is self.session: notification.center.remove_observer(self, sender=self.session) notification.center.remove_observer(self, sender=self.session.replaced_session) self.session.replaced_session = None self.session = None _NH_SIPSessionDidEnd = _NH_SIPSessionDidFail +@implementer(IObserver) class Session(object): - implements(IObserver) media_stream_timeout = 15 short_reinvite_timeout = 5 def __init__(self, account): self.account = account self.direction = None self.end_time = None self.on_hold = False self.proposed_streams = None self.route = None self.state = None self.start_time = None self.streams = None self.transport = None self.local_focus = False self.remote_focus = False self.greenlet = None self.conference = None self.replaced_session = None self.transfer_handler = None self.transfer_info = None self._channel = coros.queue() self._hold_in_progress = False self._invitation = None self._local_identity = None self._remote_identity = None self._lock = RLock() def init_incoming(self, invitation, data): notification_center = NotificationCenter() remote_sdp = invitation.sdp.proposed_remote self.proposed_streams = [] if remote_sdp: for index, media_stream in enumerate(remote_sdp.media): if media_stream.port != 0: for stream_type in MediaStreamRegistry: try: stream = stream_type.new_from_sdp(self, remote_sdp, index) except UnknownStreamError: continue except InvalidStreamError as e: log.error("Invalid stream: {}".format(e)) break except Exception as e: log.exception("Exception occurred while setting up stream from SDP: {}".format(e)) break else: stream.index = index self.proposed_streams.append(stream) break self.direction = 'incoming' self.state = 'incoming' self.transport = invitation.transport self._invitation = invitation self.conference = ConferenceHandler(self) self.transfer_handler = TransferHandler(self) if 'isfocus' in invitation.remote_contact_header.parameters: self.remote_focus = True if 'Referred-By' in data.headers or 'Replaces' in data.headers: self.transfer_info = TransferInfo() if 'Referred-By' in data.headers: self.transfer_info.referred_by = data.headers['Referred-By'].body if 'Replaces' in data.headers: replaces_header = data.headers.get('Replaces') # Because we only allow the remote tag to be optional, it can only match established dialogs and early outgoing dialogs, but not early incoming dialogs, # which according to RFC3891 should be rejected with 481 (which will happen automatically by never matching them). if replaces_header.early_only or replaces_header.from_tag == '0': replaced_dialog_id = DialogID(replaces_header.call_id, local_tag=replaces_header.to_tag, remote_tag=OptionalTag(replaces_header.from_tag)) else: replaced_dialog_id = DialogID(replaces_header.call_id, local_tag=replaces_header.to_tag, remote_tag=replaces_header.from_tag) session_manager = SessionManager() try: replaced_session = next(session for session in session_manager.sessions if session.dialog_id == replaced_dialog_id) except StopIteration: invitation.send_response(481) return else: # Any matched dialog at this point is either established, terminated or early outgoing. if replaced_session.state in ('terminating', 'terminated'): invitation.send_response(603) return elif replaced_session.dialog_id.remote_tag is not None and replaces_header.early_only: # The replaced dialog is established, but the early-only flag is set invitation.send_response(486) return self.replaced_session = replaced_session self.transfer_info.replaced_dialog_id = replaced_dialog_id replace_handler = SessionReplaceHandler(self) replace_handler.start() notification_center.add_observer(self, sender=invitation) notification_center.post_notification('SIPSessionNewIncoming', sender=self, data=NotificationData(streams=self.proposed_streams[:], headers=data.headers)) @transition_state(None, 'connecting') @run_in_green_thread def connect(self, to_header, routes, streams, is_focus=False, transfer_info=None, extra_headers=None): self.greenlet = api.getcurrent() notification_center = NotificationCenter() settings = SIPSimpleSettings() connected = False received_code = 0 received_reason = None unhandled_notifications = [] extra_headers = extra_headers or [] if {'to', 'from', 'via', 'contact', 'route', 'record-route'}.intersection(header.name.lower() for header in extra_headers): raise RuntimeError('invalid header in extra_headers: To, From, Via, Contact, Route and Record-Route headers are not allowed') self.direction = 'outgoing' self.proposed_streams = streams self.route = routes[0] self.transport = self.route.transport self.local_focus = is_focus self._invitation = Invitation() self._local_identity = FromHeader(self.account.uri, self.account.display_name) self._remote_identity = to_header self.conference = ConferenceHandler(self) self.transfer_handler = TransferHandler(self) self.transfer_info = transfer_info notification_center.add_observer(self, sender=self._invitation) notification_center.post_notification('SIPSessionNewOutgoing', sender=self, data=NotificationData(streams=streams[:])) for stream in self.proposed_streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='outgoing') try: wait_count = len(self.proposed_streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 try: contact_uri = self.account.contact[PublicGRUUIfAvailable, self.route] local_ip = host.outgoing_ip_for(self.route.address) if local_ip is None: raise ValueError("could not get outgoing IP address") except (KeyError, ValueError) as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='local', code=480, reason=sip_status_messages[480], error=str(e)) return connection = SDPConnection(local_ip) local_sdp = SDPSession(local_ip, name=settings.user_agent) for index, stream in enumerate(self.proposed_streams): stream.index = index media = stream.get_local_media(remote_sdp=None, index=index) if media.connection is None or (media.connection is not None and not media.has_ice_attributes and not media.has_ice_candidates): media.connection = connection local_sdp.media.append(media) from_header = FromHeader(self.account.uri, self.account.display_name) route_header = RouteHeader(self.route.uri) contact_header = ContactHeader(contact_uri) if is_focus: contact_header.parameters['isfocus'] = None if self.transfer_info is not None: if self.transfer_info.referred_by is not None: extra_headers.append(Header('Referred-By', self.transfer_info.referred_by)) if self.transfer_info.replaced_dialog_id is not None: dialog_id = self.transfer_info.replaced_dialog_id extra_headers.append(ReplacesHeader(dialog_id.call_id, dialog_id.local_tag, dialog_id.remote_tag)) self._invitation.send_invite(to_header.uri, from_header, to_header, route_header, contact_header, local_sdp, self.account.credentials, extra_headers) try: with api.timeout(settings.sip.invite_timeout): while True: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp break else: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='remote', code=0, reason=None, error='SDP negotiation failed: %s' % notification.data.error) return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'early': if notification.data.code == 180: notification_center.post_notification('SIPSessionGotRingIndication', self) notification_center.post_notification('SIPSessionGotProvisionalResponse', self, NotificationData(code=notification.data.code, reason=notification.data.reason)) elif notification.data.state == 'connecting': received_code = notification.data.code received_reason = notification.data.reason elif notification.data.state == 'connected': if not connected: connected = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=received_code, reason=received_reason)) else: unhandled_notifications.append(notification) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except api.TimeoutError: self.end() return notification_center.post_notification('SIPSessionWillStart', sender=self) stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, local_media in enumerate(local_sdp.media): remote_media = remote_sdp.media[index] stream = stream_map[index] if remote_media.port: # TODO: check if port is also 0 in local_sdp. In that case PJSIP disabled the stream because # negotiation failed. If there are more streams, however, the negotiation is considered successful as a # whole, so while we built a normal SDP, PJSIP modified it and sent it to the other side. That's kind io # OK, but we cannot really start the stream. -Saul stream.start(local_sdp, remote_sdp, index) else: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() removed_streams = [stream for stream in self.proposed_streams if stream.index >= len(local_sdp.media)] for stream in removed_streams: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() invitation_notifications = [] with api.timeout(self.media_stream_timeout): wait_count = len(self.proposed_streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 elif notification.name == 'SIPInvitationChangedState': invitation_notifications.append(notification) for notification in invitation_notifications: self._channel.send(notification) while not connected or self._channel: notification = self._channel.wait() if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'early': if notification.data.code == 180: notification_center.post_notification('SIPSessionGotRingIndication', self) notification_center.post_notification('SIPSessionGotProvisionalResponse', self, NotificationData(code=notification.data.code, reason=notification.data.reason)) elif notification.data.state == 'connecting': received_code = notification.data.code received_reason = notification.data.reason elif notification.data.state == 'connected': if not connected: connected = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=received_code, reason=received_reason)) else: unhandled_notifications.append(notification) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except (MediaStreamDidNotInitializeError, MediaStreamDidFailError, api.TimeoutError) as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() if isinstance(e, api.TimeoutError): error = 'media stream timed-out while starting' elif isinstance(e, MediaStreamDidNotInitializeError): error = 'media stream did not initialize: %s' % e.data.reason else: error = 'media stream failed: %s' % e.data.reason self._fail(originator='local', code=0, reason=None, error=error) except InvitationDisconnectedError as e: notification_center.remove_observer(self, sender=self._invitation) for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.state = 'terminated' # As weird as it may sound, PJSIP accepts a BYE even without receiving a final response to the INVITE if e.data.prev_state in ('connecting', 'connected') or getattr(e.data, 'method', None) == 'BYE': notification_center.post_notification('SIPSessionWillEnd', self, NotificationData(originator=e.data.originator)) if e.data.originator == 'remote': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method=e.data.method, code=200, reason=sip_status_messages[200])) self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator=e.data.originator, end_reason=e.data.disconnect_reason)) else: if e.data.originator == 'remote': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=e.data.code, reason=e.data.reason)) code = e.data.code reason = e.data.reason elif e.data.disconnect_reason == 'timeout': code = 408 reason = 'timeout' else: # TODO: we should know *exactly* when there are set -Saul code = getattr(e.data, 'code', 0) reason = getattr(e.data, 'reason', 'Session disconnected') if e.data.originator == 'remote' and code // 100 == 3: redirect_identities = e.data.headers.get('Contact', []) else: redirect_identities = None notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator=e.data.originator, code=code, reason=reason, failure_reason=e.data.disconnect_reason, redirect_identities=redirect_identities)) self.greenlet = None except SIPCoreError as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='local', code=0, reason=None, error='SIP core error: %s' % str(e)) else: self.greenlet = None self.state = 'connected' self.streams = self.proposed_streams self.proposed_streams = None self.start_time = ISOTimestamp.now() any_stream_ice = any(getattr(stream, 'ice_active', False) for stream in self.streams) if any_stream_ice: self._reinvite_after_ice() notification_center.post_notification('SIPSessionDidStart', self, NotificationData(streams=self.streams[:])) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() def _reinvite_after_ice(self): # This function does not do any error checking, it's designed to be called at the end of connect and add_stream self.state = 'sending_proposal' self.greenlet = api.getcurrent() notification_center = NotificationCenter() local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for index, stream in enumerate(self.streams): local_sdp.media[index] = stream.get_local_media(remote_sdp=None, index=index) self._invitation.send_reinvite(sdp=local_sdp) received_invitation_state = False received_sdp_update = False try: with api.timeout(self.short_reinvite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for index, stream in enumerate(self.streams): stream.update(local_sdp, remote_sdp, index) else: return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=notification.data.code, reason=notification.data.reason)) elif notification.data.state == 'disconnected': self.end() return except Exception: pass finally: self.state = 'connected' self.greenlet = None @check_state(['incoming', 'received_proposal']) @run_in_green_thread def send_ring_indication(self): try: self._invitation.send_response(180) except SIPCoreInvalidStateError: pass # The INVITE session might have already been cancelled; ignore the error @transition_state('incoming', 'accepting') @run_in_green_thread def accept(self, streams, is_focus=False, extra_headers=None): self.greenlet = api.getcurrent() notification_center = NotificationCenter() settings = SIPSimpleSettings() self.local_focus = is_focus connected = False unhandled_notifications = [] extra_headers = extra_headers or [] if {'to', 'from', 'via', 'contact', 'route', 'record-route'}.intersection(header.name.lower() for header in extra_headers): raise RuntimeError('invalid header in extra_headers: To, From, Via, Contact, Route and Record-Route headers are not allowed') if self.proposed_streams: for stream in self.proposed_streams: if stream in streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='incoming') else: for index, stream in enumerate(streams): notification_center.add_observer(self, sender=stream) stream.index = index stream.initialize(self, direction='outgoing') self.proposed_streams = streams wait_count = len(self.proposed_streams) try: while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 remote_sdp = self._invitation.sdp.proposed_remote sdp_connection = remote_sdp.connection or next((media.connection for media in remote_sdp.media if media.connection is not None)) local_ip = host.outgoing_ip_for(sdp_connection.address) if sdp_connection.address != '0.0.0.0' else sdp_connection.address if local_ip is None: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='local', code=500, reason=sip_status_messages[500], error='could not get local IP address') return connection = SDPConnection(local_ip) local_sdp = SDPSession(local_ip, name=settings.user_agent) if remote_sdp: stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, media in enumerate(remote_sdp.media): stream = stream_map.get(index, None) if stream is not None: media = stream.get_local_media(remote_sdp=remote_sdp, index=index) if not media.has_ice_attributes and not media.has_ice_candidates: media.connection = connection else: media = SDPMediaStream.new(media) media.connection = connection media.port = 0 media.attributes = [] media.bandwidth_info = [] local_sdp.media.append(media) else: for index, stream in enumerate(self.proposed_streams): stream.index = index media = stream.get_local_media(remote_sdp=None, index=index) if media.connection is None or (media.connection is not None and not media.has_ice_attributes and not media.has_ice_candidates): media.connection = connection local_sdp.media.append(media) contact_header = ContactHeader.new(self._invitation.local_contact_header) try: local_contact_uri = self.account.contact[PublicGRUU, self._invitation.transport] except KeyError: pass else: contact_header.uri = local_contact_uri if is_focus: contact_header.parameters['isfocus'] = None self._invitation.send_response(200, contact_header=contact_header, sdp=local_sdp, extra_headers=extra_headers) notification_center.post_notification('SIPSessionWillStart', sender=self) # Local and remote SDPs will be set after the 200 OK is sent while True: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp break else: if not connected: # we could not have got a SIPInvitationGotSDPUpdate if we did not get an ACK connected = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason=sip_status_messages[200], ack_received=True)) for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='remote', code=0, reason=None, error='SDP negotiation failed: %s' % notification.data.error) return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected': if not connected: connected = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason=sip_status_messages[200], ack_received=True)) elif notification.data.prev_state == 'connected': unhandled_notifications.append(notification) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) wait_count = 0 stream_map = dict((stream.index, stream) for stream in self.proposed_streams) for index, local_media in enumerate(local_sdp.media): remote_media = remote_sdp.media[index] stream = stream_map.get(index, None) if stream is not None: if remote_media.port: wait_count += 1 stream.start(local_sdp, remote_sdp, index) else: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() removed_streams = [stream for stream in self.proposed_streams if stream.index >= len(local_sdp.media)] for stream in removed_streams: notification_center.remove_observer(self, sender=stream) self.proposed_streams.remove(stream) del stream_map[stream.index] stream.deactivate() stream.end() with api.timeout(self.media_stream_timeout): while wait_count > 0 or not connected or self._channel: notification = self._channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected': if not connected: connected = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason='OK', ack_received=True)) elif notification.data.prev_state == 'connected': unhandled_notifications.append(notification) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) else: unhandled_notifications.append(notification) except (MediaStreamDidNotInitializeError, MediaStreamDidFailError, api.TimeoutError) as e: if self._invitation.state == 'connecting': ack_received = False if isinstance(e, api.TimeoutError) and wait_count == 0 else 'unknown' # pjsip's invite session object does not inform us whether the ACK was received or not notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason='OK', ack_received=ack_received)) elif self._invitation.state == 'connected' and not connected: # we didn't yet get to process the SIPInvitationChangedState (state -> connected) notification notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason='OK', ack_received=True)) for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() reason_header = None if isinstance(e, api.TimeoutError): if wait_count > 0: error = 'media stream timed-out while starting' else: error = 'No ACK received' reason_header = ReasonHeader('SIP') reason_header.cause = 500 reason_header.text = 'Missing ACK' elif isinstance(e, MediaStreamDidNotInitializeError): error = 'media stream did not initialize: %s' % e.data.reason reason_header = ReasonHeader('SIP') reason_header.cause = 500 reason_header.text = 'media stream did not initialize' else: error = 'media stream failed: %s' % e.data.reason reason_header = ReasonHeader('SIP') reason_header.cause = 500 reason_header.text = 'media stream failed to start' self.start_time = ISOTimestamp.now() if self._invitation.state in ('incoming', 'early'): self._fail(originator='local', code=500, reason=sip_status_messages[500], error=error, reason_header=reason_header) else: self._fail(originator='local', code=0, reason=None, error=error, reason_header=reason_header) except InvitationDisconnectedError as e: notification_center.remove_observer(self, sender=self._invitation) for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.state = 'terminated' if e.data.prev_state in ('incoming', 'early'): notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=487, reason='Session Cancelled', ack_received='unknown')) notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='remote', code=487, reason='Session Cancelled', failure_reason=e.data.disconnect_reason, redirect_identities=None)) elif e.data.prev_state == 'connecting' and e.data.disconnect_reason == 'missing ACK': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason='OK', ack_received=False)) notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=200, reason=sip_status_messages[200], failure_reason=e.data.disconnect_reason, redirect_identities=None)) else: notification_center.post_notification('SIPSessionWillEnd', self, NotificationData(originator='remote')) notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method=getattr(e.data, 'method', 'INVITE'), code=200, reason='OK')) self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator='remote', end_reason=e.data.disconnect_reason)) self.greenlet = None except SIPCoreInvalidStateError: # the only reason for which this error can be thrown is if invitation.send_response was called after the INVITE session was cancelled by the remote party notification_center.remove_observer(self, sender=self._invitation) for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.greenlet = None self.state = 'terminated' notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=487, reason='Session Cancelled', ack_received='unknown')) notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='remote', code=487, reason='Session Cancelled', failure_reason='user request', redirect_identities=None)) except SIPCoreError as e: for stream in self.proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self._fail(originator='local', code=500, reason=sip_status_messages[500], error='SIP core error: %s' % str(e)) else: self.greenlet = None self.state = 'connected' self.streams = self.proposed_streams self.proposed_streams = None self.start_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidStart', self, NotificationData(streams=self.streams[:])) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None @transition_state('incoming', 'terminating') @run_in_green_thread def reject(self, code=603, reason=None): self.greenlet = api.getcurrent() notification_center = NotificationCenter() try: self._invitation.send_response(code, reason) with api.timeout(1): while True: notification = self._channel.wait() if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'disconnected': ack_received = notification.data.disconnect_reason != 'missing ACK' notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=code, reason=sip_status_messages[code], ack_received=ack_received)) break except SIPCoreInvalidStateError: # the only reason for which this error can be thrown is if invitation.send_response was called after the INVITE session was cancelled by the remote party self.greenlet = None except SIPCoreError as e: self._fail(originator='local', code=500, reason=sip_status_messages[500], error='SIP core error: %s' % str(e)) except api.TimeoutError: notification_center.remove_observer(self, sender=self._invitation) self.greenlet = None self.state = 'terminated' notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=code, reason=sip_status_messages[code], ack_received=False)) notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=code, reason=sip_status_messages[code], failure_reason='timeout', redirect_identities=None)) else: notification_center.remove_observer(self, sender=self._invitation) self.greenlet = None self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=code, reason=sip_status_messages[code], failure_reason='user request', redirect_identities=None)) finally: self.greenlet = None @transition_state('received_proposal', 'accepting_proposal') @run_in_green_thread def accept_proposal(self, streams): self.greenlet = api.getcurrent() notification_center = NotificationCenter() unhandled_notifications = [] streams = [stream for stream in streams if stream in self.proposed_streams] for stream in streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='incoming') try: wait_count = len(streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 remote_sdp = self._invitation.sdp.proposed_remote connection = SDPConnection(local_sdp.address) stream_map = dict((stream.index, stream) for stream in streams) for index, media in enumerate(remote_sdp.media): stream = stream_map.get(index, None) if stream is not None: media = stream.get_local_media(remote_sdp=remote_sdp, index=index) if not media.has_ice_attributes and not media.has_ice_candidates: media.connection = connection if index < len(local_sdp.media): local_sdp.media[index] = media else: local_sdp.media.append(media) elif index >= len(local_sdp.media): # actually == is sufficient media = SDPMediaStream.new(media) media.connection = connection media.port = 0 media.attributes = [] media.bandwidth_info = [] local_sdp.media.append(media) self._invitation.send_response(200, sdp=local_sdp) prev_on_hold_streams = set(stream for stream in self.streams if stream.hold_supported and stream.on_hold_by_remote) received_invitation_state = False received_sdp_update = False while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) else: self._fail_proposal(originator='remote', error='SDP negotiation failed: %s' % notification.data.error) return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason=sip_status_messages[200], ack_received='unknown')) received_invitation_state = True elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) on_hold_streams = set(stream for stream in self.streams if stream.hold_supported and stream.on_hold_by_remote) if on_hold_streams != prev_on_hold_streams: hold_supported_streams = (stream for stream in self.streams if stream.hold_supported) notification_center.post_notification('SIPSessionDidChangeHoldState', self, NotificationData(originator='remote', on_hold=bool(on_hold_streams), partial=bool(on_hold_streams) and any(not stream.on_hold_by_remote for stream in hold_supported_streams))) for stream in streams: # TODO: check if port is 0 in local_sdp. In that case PJSIP disabled the stream because # negotiation failed. If there are more streams, however, the negotiation is considered successful as a # whole, so while we built a normal SDP, PJSIP modified it and sent it to the other side. That's kind of # OK, but we cannot really start the stream. -Saul stream.start(local_sdp, remote_sdp, stream.index) with api.timeout(self.media_stream_timeout): wait_count = len(streams) while wait_count > 0 or self._channel: notification = self._channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 else: unhandled_notifications.append(notification) except api.TimeoutError: self._fail_proposal(originator='remote', error='media stream timed-out while starting') except MediaStreamDidNotInitializeError as e: self._fail_proposal(originator='remote', error='media stream did not initialize: {.data.reason}'.format(e)) except MediaStreamDidFailError as e: self._fail_proposal(originator='remote', error='media stream failed: {.data.reason}'.format(e)) except InvitationDisconnectedError as e: self._fail_proposal(originator='remote', error='session ended') notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) except SIPCoreError as e: self._fail_proposal(originator='remote', error='SIP core error: %s' % str(e)) else: proposed_streams = self.proposed_streams self.proposed_streams = None self.streams = self.streams + streams self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalAccepted', self, NotificationData(originator='remote', accepted_streams=streams, proposed_streams=proposed_streams)) notification_center.post_notification('SIPSessionDidRenegotiateStreams', self, NotificationData(originator='remote', added_streams=streams, removed_streams=[])) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None @transition_state('received_proposal', 'rejecting_proposal') @run_in_green_thread def reject_proposal(self, code=488, reason=None): self.greenlet = api.getcurrent() notification_center = NotificationCenter() try: self._invitation.send_response(code, reason) with api.timeout(1, None): while True: notification = self._channel.wait() if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=code, reason=sip_status_messages[code], ack_received='unknown')) break except SIPCoreError as e: self._fail_proposal(originator='remote', error='SIP core error: %s' % str(e)) else: proposed_streams = self.proposed_streams self.proposed_streams = None self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='remote', code=code, reason=sip_status_messages[code], proposed_streams=proposed_streams)) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None def add_stream(self, stream): self.add_streams([stream]) @transition_state('connected', 'sending_proposal') @run_in_green_thread def add_streams(self, streams): streams = list(set(streams).difference(self.streams)) if not streams: self.state = 'connected' return self.greenlet = api.getcurrent() notification_center = NotificationCenter() settings = SIPSimpleSettings() unhandled_notifications = [] self.proposed_streams = streams for stream in self.proposed_streams: notification_center.add_observer(self, sender=stream) stream.initialize(self, direction='outgoing') try: wait_count = len(self.proposed_streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidInitialize': wait_count -= 1 elif notification.name == 'SIPInvitationChangedState': # This is actually the only reason for which this notification could be received if notification.data.state == 'connected' and notification.data.sub_state == 'received_proposal': self._fail_proposal(originator='local', error='received stream proposal') self.handle_notification(notification) return local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for stream in self.proposed_streams: # Try to reuse a disabled media stream to avoid an ever-growing SDP try: index = next(index for index, media in enumerate(local_sdp.media) if media.port == 0) reuse_media = True except StopIteration: index = len(local_sdp.media) reuse_media = False stream.index = index media = stream.get_local_media(remote_sdp=None, index=index) if reuse_media: local_sdp.media[index] = media else: local_sdp.media.append(media) self._invitation.send_reinvite(sdp=local_sdp) notification_center.post_notification('SIPSessionNewProposal', sender=self, data=NotificationData(originator='local', proposed_streams=self.proposed_streams[:])) received_invitation_state = False received_sdp_update = False try: with api.timeout(settings.sip.invite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for s in self.streams: s.update(local_sdp, remote_sdp, s.index) else: self._fail_proposal(originator='local', error='SDP negotiation failed: %s' % notification.data.error) return elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=notification.data.code, reason=notification.data.reason)) if notification.data.code >= 300: proposed_streams = self.proposed_streams for stream in proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='local', code=notification.data.code, reason=notification.data.reason, proposed_streams=proposed_streams)) return elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except api.TimeoutError: self.cancel_proposal() return accepted_streams = [] for stream in self.proposed_streams: try: remote_media = remote_sdp.media[stream.index] except IndexError: self._fail_proposal(originator='local', error='SDP media missing in answer') return else: if remote_media.port: stream.start(local_sdp, remote_sdp, stream.index) accepted_streams.append(stream) else: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() with api.timeout(self.media_stream_timeout): wait_count = len(accepted_streams) while wait_count > 0: notification = self._channel.wait() if notification.name == 'MediaStreamDidStart': wait_count -= 1 except api.TimeoutError: self._fail_proposal(originator='local', error='media stream timed-out while starting') except MediaStreamDidNotInitializeError as e: self._fail_proposal(originator='local', error='media stream did not initialize: {.data.reason}'.format(e)) except MediaStreamDidFailError as e: self._fail_proposal(originator='local', error='media stream failed: {.data.reason}'.format(e)) except InvitationDisconnectedError as e: self._fail_proposal(originator='local', error='session ended') notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) except SIPCoreError as e: self._fail_proposal(originator='local', error='SIP core error: %s' % str(e)) else: self.greenlet = None self.state = 'connected' self.streams += accepted_streams proposed_streams = self.proposed_streams self.proposed_streams = None any_stream_ice = any(getattr(stream, 'ice_active', False) for stream in accepted_streams) if any_stream_ice: self._reinvite_after_ice() notification_center.post_notification('SIPSessionProposalAccepted', self, NotificationData(originator='local', accepted_streams=accepted_streams, proposed_streams=proposed_streams)) notification_center.post_notification('SIPSessionDidRenegotiateStreams', self, NotificationData(originator='local', added_streams=accepted_streams, removed_streams=[])) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None def remove_stream(self, stream): self.remove_streams([stream]) @transition_state('connected', 'sending_proposal') @run_in_green_thread def remove_streams(self, streams): streams = list(set(streams).intersection(self.streams)) if not streams: self.state = 'connected' return self.greenlet = api.getcurrent() notification_center = NotificationCenter() unhandled_notifications = [] try: local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for stream in streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() self.streams.remove(stream) media = local_sdp.media[stream.index] media.port = 0 media.attributes = [] media.bandwidth_info = [] self._invitation.send_reinvite(sdp=local_sdp) received_invitation_state = False received_sdp_update = False with api.timeout(self.short_reinvite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for s in self.streams: s.update(local_sdp, remote_sdp, s.index) elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=notification.data.code, reason=notification.data.reason)) if not (200 <= notification.data.code < 300): break elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except InvitationDisconnectedError as e: for stream in streams: stream.end() self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) except (api.TimeoutError, MediaStreamDidFailError, SIPCoreError): for stream in streams: stream.end() self.end() else: for stream in streams: stream.end() self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionDidRenegotiateStreams', self, NotificationData(originator='local', added_streams=[], removed_streams=streams)) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._send_hold() finally: self.greenlet = None @transition_state('sending_proposal', 'cancelling_proposal') @run_in_green_thread def cancel_proposal(self): if self.greenlet is not None: api.kill(self.greenlet, api.GreenletExit()) self.greenlet = api.getcurrent() notification_center = NotificationCenter() try: self._invitation.cancel_reinvite() while True: try: notification = self._channel.wait() except MediaStreamDidFailError: continue if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=notification.data.code, reason=notification.data.reason)) if notification.data.code == 487: proposed_streams = self.proposed_streams or [] for stream in proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='local', code=notification.data.code, reason=notification.data.reason, proposed_streams=proposed_streams)) elif notification.data.code == 200: self.end() elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) break except SIPCoreError as e: notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', code=0, reason=None, failure_reason='SIP core error: %s' % str(e), redirect_identities=None)) proposed_streams = self.proposed_streams or [] for stream in proposed_streams: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.greenlet = None self.state = 'connected' notification_center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='local', code=0, reason='SIP core error: %s' % str(e), proposed_streams=proposed_streams)) except InvitationDisconnectedError as e: for stream in self.proposed_streams or []: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) else: for stream in self.proposed_streams or []: notification_center.remove_observer(self, sender=stream) stream.deactivate() stream.end() self.proposed_streams = None self.greenlet = None self.state = 'connected' finally: self.greenlet = None if self._hold_in_progress: self._send_hold() @run_in_green_thread def hold(self): if self.on_hold or self._hold_in_progress: return self._hold_in_progress = True streams = (self.streams or []) + (self.proposed_streams or []) if not streams: return for stream in streams: stream.hold() if self.state == 'connected': self._send_hold() @run_in_green_thread def unhold(self): if not self.on_hold and not self._hold_in_progress: return self._hold_in_progress = False streams = (self.streams or []) + (self.proposed_streams or []) if not streams: return for stream in streams: stream.unhold() if self.state == 'connected': self._send_unhold() @run_in_green_thread def end(self): if self.state in (None, 'terminating', 'terminated'): return if self.greenlet is not None: api.kill(self.greenlet, api.GreenletExit()) self.greenlet = None notification_center = NotificationCenter() if self._invitation is None: # The invitation was not yet constructed self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=487, reason='Session Cancelled', failure_reason='user request', redirect_identities=None)) return elif self._invitation.state is None: # The invitation was built but never sent streams = (self.streams or []) + (self.proposed_streams or []) for stream in streams[:]: try: notification_center.remove_observer(self, sender=stream) except KeyError: streams.remove(stream) else: stream.deactivate() stream.end() self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=487, reason='Session Cancelled', failure_reason='user request', redirect_identities=None)) return invitation_state = self._invitation.state if invitation_state in ('disconnecting', 'disconnected'): return self.greenlet = api.getcurrent() self.state = 'terminating' if invitation_state == 'connected': notification_center.post_notification('SIPSessionWillEnd', self, NotificationData(originator='local')) streams = (self.streams or []) + (self.proposed_streams or []) for stream in streams[:]: try: notification_center.remove_observer(self, sender=stream) except KeyError: streams.remove(stream) else: stream.deactivate() cancelling = invitation_state != 'connected' and self.direction == 'outgoing' try: self._invitation.end(timeout=1) while True: try: notification = self._channel.wait() except MediaStreamDidFailError: continue if notification.name == 'SIPInvitationChangedState' and notification.data.state == 'disconnected': if notification.data.disconnect_reason in ('internal error', 'missing ACK'): pass elif notification.data.disconnect_reason == 'timeout': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local' if self.direction=='outgoing' else 'remote', method='INVITE', code=408, reason='Timeout')) elif cancelling: notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=notification.data.code, reason=notification.data.reason)) elif hasattr(notification.data, 'method'): notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method=notification.data.method, code=200, reason=sip_status_messages[200])) elif notification.data.disconnect_reason == 'user request': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='BYE', code=notification.data.code, reason=notification.data.reason)) break except SIPCoreError as e: if cancelling: notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=0, reason=None, failure_reason='SIP core error: %s' % str(e), redirect_identities=None)) else: self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator='local', end_reason='SIP core error: %s' % str(e))) except InvitationDisconnectedError as e: # As it weird as it may sound, PJSIP accepts a BYE even without receiving a final response to the INVITE if e.data.prev_state == 'connected': if e.data.originator == 'remote': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator=e.data.originator, method=e.data.method, code=200, reason=sip_status_messages[200])) self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator=e.data.originator, end_reason=e.data.disconnect_reason)) elif getattr(e.data, 'method', None) == 'BYE' and e.data.originator == 'remote': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator=e.data.originator, method=e.data.method, code=200, reason=sip_status_messages[200])) notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator=e.data.originator, code=0, reason=None, failure_reason=e.data.disconnect_reason, redirect_identities=None)) else: if e.data.originator == 'remote': code = e.data.code reason = e.data.reason elif e.data.disconnect_reason == 'timeout': code = 408 reason = 'timeout' else: code = 0 reason = None if e.data.originator == 'remote' and code // 100 == 3: redirect_identities = e.data.headers.get('Contact', []) else: redirect_identities = None notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=code, reason=reason)) notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator=e.data.originator, code=code, reason=reason, failure_reason=e.data.disconnect_reason, redirect_identities=redirect_identities)) else: if cancelling: notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=487, reason='Session Cancelled', failure_reason='user request', redirect_identities=None)) else: self.end_time = ISOTimestamp.now() notification_center.post_notification('SIPSessionDidEnd', self, NotificationData(originator='local', end_reason='user request')) finally: for stream in streams: stream.end() notification_center.remove_observer(self, sender=self._invitation) self.greenlet = None self.state = 'terminated' @check_state(['connected']) @check_transfer_state(None, None) @run_in_twisted_thread def transfer(self, target_uri, replaced_session=None): notification_center = NotificationCenter() notification_center.post_notification('SIPSessionTransferNewOutgoing', self, NotificationData(transfer_destination=target_uri)) try: self._invitation.transfer(target_uri, replaced_session.dialog_id if replaced_session is not None else None) except SIPCoreError as e: notification_center.post_notification('SIPSessionTransferDidFail', sender=self, data=NotificationData(code=500, reason=str(e))) @check_state(['connected', 'received_proposal', 'sending_proposal', 'accepting_proposal', 'rejecting_proposal', 'cancelling_proposal']) @check_transfer_state('incoming', 'starting') def accept_transfer(self): notification_center = NotificationCenter() notification_center.post_notification('SIPSessionTransferDidStart', sender=self) @check_state(['connected', 'received_proposal', 'sending_proposal', 'accepting_proposal', 'rejecting_proposal', 'cancelling_proposal']) @check_transfer_state('incoming', 'starting') def reject_transfer(self, code=603, reason=None): notification_center = NotificationCenter() notification_center.post_notification('SIPSessionTransferDidFail', self, NotificationData(code=code, reason=reason or sip_status_messages[code])) @property def dialog_id(self): return self._invitation.dialog_id if self._invitation is not None else None @property def local_identity(self): if self._invitation is not None and self._invitation.local_identity is not None: return self._invitation.local_identity else: return self._local_identity @property def peer_address(self): return self._invitation.peer_address if self._invitation is not None else None @property def remote_identity(self): if self._invitation is not None and self._invitation.remote_identity is not None: return self._invitation.remote_identity else: return self._remote_identity @property def remote_user_agent(self): return self._invitation.remote_user_agent if self._invitation is not None else None def _cancel_hold(self): notification_center = NotificationCenter() try: self._invitation.cancel_reinvite() while True: try: notification = self._channel.wait() except MediaStreamDidFailError: continue if notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=notification.data.code, reason=notification.data.reason)) if notification.data.code == 200: self.end() return False elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) break except SIPCoreError as e: notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', code=0, reason=None, failure_reason='SIP core error: %s' % str(e), redirect_identities=None)) except InvitationDisconnectedError as e: self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) return False return True def _send_hold(self): self.state = 'sending_proposal' self.greenlet = api.getcurrent() notification_center = NotificationCenter() unhandled_notifications = [] try: local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for stream in self.streams: local_sdp.media[stream.index] = stream.get_local_media(remote_sdp=None, index=stream.index) self._invitation.send_reinvite(sdp=local_sdp) received_invitation_state = False received_sdp_update = False with api.timeout(self.short_reinvite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=notification.data.code, reason=notification.data.reason)) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except InvitationDisconnectedError as e: self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) return except api.TimeoutError: if not self._cancel_hold(): return except SIPCoreError: pass self.greenlet = None self.on_hold = True self.state = 'connected' hold_supported_streams = (stream for stream in self.streams if stream.hold_supported) notification_center.post_notification('SIPSessionDidChangeHoldState', self, NotificationData(originator='local', on_hold=True, partial=any(not stream.on_hold_by_local for stream in hold_supported_streams))) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: self._hold_in_progress = False else: for stream in self.streams: stream.unhold() self._send_unhold() def _send_unhold(self): self.state = 'sending_proposal' self.greenlet = api.getcurrent() notification_center = NotificationCenter() unhandled_notifications = [] try: local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 for stream in self.streams: local_sdp.media[stream.index] = stream.get_local_media(remote_sdp=None, index=stream.index) self._invitation.send_reinvite(sdp=local_sdp) received_invitation_state = False received_sdp_update = False with api.timeout(self.short_reinvite_timeout): while not received_invitation_state or not received_sdp_update: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=notification.data.code, reason=notification.data.reason)) elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) except InvitationDisconnectedError as e: self.greenlet = None notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = notification_center self.handle_notification(notification) return except api.TimeoutError: if not self._cancel_hold(): return except SIPCoreError: pass self.greenlet = None self.on_hold = False self.state = 'connected' notification_center.post_notification('SIPSessionDidChangeHoldState', self, NotificationData(originator='local', on_hold=False, partial=False)) for notification in unhandled_notifications: self.handle_notification(notification) if self._hold_in_progress: for stream in self.streams: stream.hold() self._send_hold() def _fail(self, originator, code, reason, error, reason_header=None): notification_center = NotificationCenter() prev_inv_state = self._invitation.state self.state = 'terminating' if prev_inv_state not in (None, 'incoming', 'outgoing', 'early', 'connecting'): notification_center.post_notification('SIPSessionWillEnd', self, NotificationData(originator=originator)) if self._invitation.state not in (None, 'disconnecting', 'disconnected'): try: if self._invitation.direction == 'incoming' and self._invitation.state in ('incoming', 'early'): if 400<=code<=699 and reason is not None: self._invitation.send_response(code, extra_headers=[reason_header] if reason_header is not None else []) else: self._invitation.end(extra_headers=[reason_header] if reason_header is not None else []) with api.timeout(1): while True: notification = self._channel.wait() if notification.name == 'SIPInvitationChangedState' and notification.data.state == 'disconnected': if prev_inv_state in ('connecting', 'connected'): if notification.data.disconnect_reason in ('timeout', 'missing ACK'): sip_code = 200 sip_reason = 'OK' originator = 'local' elif hasattr(notification.data, 'method'): sip_code = 200 sip_reason = 'OK' originator = 'remote' else: sip_code = notification.data.code sip_reason = notification.data.reason originator = 'local' notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator=originator, method='BYE', code=sip_code, reason=sip_reason)) elif self._invitation.direction == 'incoming' and prev_inv_state in ('incoming', 'early'): ack_received = notification.data.disconnect_reason != 'missing ACK' notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=code, reason=reason, ack_received=ack_received)) elif self._invitation.direction == 'outgoing' and prev_inv_state in ('outgoing', 'early'): notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='INVITE', code=487, reason='Session Cancelled')) break except SIPCoreError: pass except api.TimeoutError: if prev_inv_state in ('connecting', 'connected'): notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='local', method='BYE', code=408, reason=sip_status_messages[408])) notification_center.remove_observer(self, sender=self._invitation) self.state = 'terminated' notification_center.post_notification('SIPSessionDidFail', self, NotificationData(originator=originator, code=code, reason=reason, failure_reason=error, redirect_identities=None)) self.greenlet = None def _fail_proposal(self, originator, error): notification_center = NotificationCenter() for stream in self.proposed_streams: try: notification_center.remove_observer(self, sender=stream) except KeyError: # _fail_proposal can be called from reject_proposal, which means the stream will # not have been initialized or the session registered as an observer for it. pass else: stream.deactivate() stream.end() if originator == 'remote' and self._invitation.sub_state == 'received_proposal': try: self._invitation.send_response(488 if self.proposed_streams else 500) except SIPCoreError: pass else: notification_center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=500, reason=sip_status_messages[500], ack_received='unknown')) notification_center.post_notification('SIPSessionHadProposalFailure', self, NotificationData(originator=originator, failure_reason=error, proposed_streams=self.proposed_streams[:])) self.state = 'connected' self.proposed_streams = None self.greenlet = None @run_in_green_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_SIPInvitationChangedState(self, notification): if self.state == 'terminated': return if notification.data.originator == 'remote' and notification.data.state not in ('disconnecting', 'disconnected'): contact_header = notification.data.headers.get('Contact', None) if contact_header and 'isfocus' in contact_header[0].parameters: self.remote_focus = True if self.greenlet is not None: if notification.data.state == 'disconnected' and notification.data.prev_state != 'disconnecting': self._channel.send_exception(InvitationDisconnectedError(notification.sender, notification.data)) else: self._channel.send(notification) else: self.greenlet = api.getcurrent() unhandled_notifications = [] try: if notification.data.state == 'connected' and notification.data.sub_state == 'received_proposal': self.state = 'received_proposal' try: proposed_remote_sdp = self._invitation.sdp.proposed_remote active_remote_sdp = self._invitation.sdp.active_remote if len(proposed_remote_sdp.media) < len(active_remote_sdp.media): engine = Engine() self._invitation.send_response(488, extra_headers=[WarningHeader(399, engine.user_agent, 'Streams cannot be deleted from the SDP')]) self.state = 'connected' notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=488, reason=sip_status_messages[488], ack_received='unknown')) return for stream in self.streams: if not stream.validate_update(proposed_remote_sdp, stream.index): engine = Engine() self._invitation.send_response(488, extra_headers=[WarningHeader(399, engine.user_agent, 'Failed to update media stream index %d' % stream.index)]) self.state = 'connected' notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=488, reason=sip_status_messages[488], ack_received='unknown')) return added_media_indexes = set() removed_media_indexes = set() reused_media_indexes = set() for index, media_stream in enumerate(proposed_remote_sdp.media): if index >= len(active_remote_sdp.media): added_media_indexes.add(index) elif media_stream.port == 0 and active_remote_sdp.media[index].port > 0: removed_media_indexes.add(index) elif media_stream.port > 0 and active_remote_sdp.media[index].port == 0: reused_media_indexes.add(index) elif media_stream.media != active_remote_sdp.media[index].media: added_media_indexes.add(index) removed_media_indexes.add(index) if added_media_indexes | reused_media_indexes and removed_media_indexes: engine = Engine() self._invitation.send_response(488, extra_headers=[WarningHeader(399, engine.user_agent, 'Both removing AND adding a media stream is currently not supported')]) self.state = 'connected' notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=488, reason=sip_status_messages[488], ack_received='unknown')) return elif added_media_indexes | reused_media_indexes: self.proposed_streams = [] for index in added_media_indexes | reused_media_indexes: media_stream = proposed_remote_sdp.media[index] if media_stream.port != 0: for stream_type in MediaStreamRegistry: try: stream = stream_type.new_from_sdp(self, proposed_remote_sdp, index) except UnknownStreamError: continue except InvalidStreamError as e: log.error("Invalid stream: {}".format(e)) break except Exception as e: log.exception("Exception occurred while setting up stream from SDP: {}".format(e)) break else: stream.index = index self.proposed_streams.append(stream) break if self.proposed_streams: self._invitation.send_response(100) notification.center.post_notification('SIPSessionNewProposal', sender=self, data=NotificationData(originator='remote', proposed_streams=self.proposed_streams[:])) else: self._invitation.send_response(488) self.state = 'connected' notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=488, reason=sip_status_messages[488], ack_received='unknown')) return else: local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 removed_streams = [stream for stream in self.streams if stream.index in removed_media_indexes] prev_on_hold_streams = set(stream for stream in self.streams if stream.hold_supported and stream.on_hold_by_remote) for stream in removed_streams: notification.center.remove_observer(self, sender=stream) stream.deactivate() media = local_sdp.media[stream.index] media.port = 0 media.attributes = [] media.bandwidth_info = [] for stream in self.streams: local_sdp.media[stream.index] = stream.get_local_media(remote_sdp=proposed_remote_sdp, index=stream.index) try: self._invitation.send_response(200, sdp=local_sdp) except PJSIPError: for stream in removed_streams: self.streams.remove(stream) stream.end() if removed_streams: self.end() return else: try: self._invitation.send_response(488) except PJSIPError: self.end() return else: for stream in removed_streams: self.streams.remove(stream) stream.end() notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason=sip_status_messages[200], ack_received='unknown')) received_invitation_state = False received_sdp_update = False while not received_sdp_update or not received_invitation_state or self._channel: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) else: unhandled_notifications.append(notification) else: unhandled_notifications.append(notification) on_hold_streams = set(stream for stream in self.streams if stream.hold_supported and stream.on_hold_by_remote) if on_hold_streams != prev_on_hold_streams: hold_supported_streams = (stream for stream in self.streams if stream.hold_supported) notification.center.post_notification('SIPSessionDidChangeHoldState', self, NotificationData(originator='remote', on_hold=bool(on_hold_streams), partial=bool(on_hold_streams) and any(not stream.on_hold_by_remote for stream in hold_supported_streams))) if removed_media_indexes: notification.center.post_notification('SIPSessionDidRenegotiateStreams', self, NotificationData(originator='remote', added_streams=[], removed_streams=removed_streams)) except InvitationDisconnectedError as e: self.greenlet = None self.state = 'connected' notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = NotificationCenter() self.handle_notification(notification) except SIPCoreError: self.end() else: self.state = 'connected' elif notification.data.state == 'connected' and notification.data.sub_state == 'received_proposal_request': self.state = 'received_proposal_request' try: # An empty proposal was received, generate an offer self._invitation.send_response(100) local_sdp = SDPSession.new(self._invitation.sdp.active_local) local_sdp.version += 1 connection_address = host.outgoing_ip_for(self._invitation.peer_address.ip) if local_sdp.connection is not None: local_sdp.connection.address = connection_address for index, stream in enumerate(self.streams): stream.reset(index) media = stream.get_local_media(remote_sdp=None, index=index) if media.connection is not None: media.connection.address = connection_address local_sdp.media[stream.index] = media self._invitation.send_response(200, sdp=local_sdp) notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=200, reason=sip_status_messages[200], ack_received='unknown')) received_invitation_state = False received_sdp_update = False while not received_sdp_update or not received_invitation_state or self._channel: notification = self._channel.wait() if notification.name == 'SIPInvitationGotSDPUpdate': received_sdp_update = True if notification.data.succeeded: local_sdp = notification.data.local_sdp remote_sdp = notification.data.remote_sdp for stream in self.streams: stream.update(local_sdp, remote_sdp, stream.index) elif notification.name == 'SIPInvitationChangedState': if notification.data.state == 'connected' and notification.data.sub_state == 'normal': received_invitation_state = True elif notification.data.state == 'disconnected': raise InvitationDisconnectedError(notification.sender, notification.data) else: unhandled_notifications.append(notification) else: unhandled_notifications.append(notification) except InvitationDisconnectedError as e: self.greenlet = None self.state = 'connected' notification = Notification('SIPInvitationChangedState', e.invitation, e.data) notification.center = NotificationCenter() self.handle_notification(notification) except SIPCoreError: raise # FIXME else: self.state = 'connected' elif notification.data.prev_state == notification.data.state == 'connected' and notification.data.prev_sub_state == 'received_proposal' and notification.data.sub_state == 'normal': if notification.data.originator == 'local' and notification.data.code == 487: proposed_streams = self.proposed_streams self.proposed_streams = None self.state = 'connected' notification.center.post_notification('SIPSessionProposalRejected', self, NotificationData(originator='remote', code=notification.data.code, reason=notification.data.reason, proposed_streams=proposed_streams)) if self._hold_in_progress: self._send_hold() elif notification.data.state == 'disconnected': if self.state == 'incoming': self.state = 'terminated' if notification.data.originator == 'remote': notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator='remote', method='INVITE', code=487, reason='Session Cancelled', ack_received='unknown')) notification.center.post_notification('SIPSessionDidFail', self, NotificationData(originator='remote', code=487, reason='Session Cancelled', failure_reason=notification.data.disconnect_reason, redirect_identities=None)) else: # There must have been an error involved notification.center.post_notification('SIPSessionDidFail', self, NotificationData(originator='local', code=0, reason=None, failure_reason=notification.data.disconnect_reason, redirect_identities=None)) else: self.state = 'terminated' notification.center.post_notification('SIPSessionWillEnd', self, NotificationData(originator=notification.data.originator)) for stream in self.streams: notification.center.remove_observer(self, sender=stream) stream.deactivate() stream.end() if notification.data.originator == 'remote': if hasattr(notification.data, 'method'): notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator=notification.data.originator, method=notification.data.method, code=200, reason=sip_status_messages[200])) else: notification.center.post_notification('SIPSessionDidProcessTransaction', self, NotificationData(originator=notification.data.originator, method='INVITE', code=notification.data.code, reason=notification.data.reason)) self.end_time = ISOTimestamp.now() notification.center.post_notification('SIPSessionDidEnd', self, NotificationData(originator=notification.data.originator, end_reason=notification.data.disconnect_reason)) notification.center.remove_observer(self, sender=self._invitation) finally: self.greenlet = None for notification in unhandled_notifications: self.handle_notification(notification) def _NH_SIPInvitationGotSDPUpdate(self, notification): if self.greenlet is not None: self._channel.send(notification) def _NH_MediaStreamDidInitialize(self, notification): if self.greenlet is not None: self._channel.send(notification) def _NH_RTPStreamDidEnableEncryption(self, notification): if notification.sender.type != 'audio': return audio_stream = notification.sender if audio_stream.encryption.type == 'ZRTP': # start ZRTP on the video stream, if applicable try: video_stream = next(stream for stream in self.streams or [] if stream.type=='video') except StopIteration: return if video_stream.encryption.type == 'ZRTP' and not video_stream.encryption.active: video_stream.encryption.zrtp._enable(audio_stream) def _NH_MediaStreamDidStart(self, notification): stream = notification.sender if stream.type == 'audio' and stream.encryption.type == 'ZRTP': stream.encryption.zrtp._enable() elif stream.type == 'video' and stream.encryption.type == 'ZRTP': # start ZRTP on the video stream, if applicable try: audio_stream = next(stream for stream in self.streams or [] if stream.type=='audio') except StopIteration: pass else: if audio_stream.encryption.type == 'ZRTP' and audio_stream.encryption.active: stream.encryption.zrtp._enable(audio_stream) if self.greenlet is not None: self._channel.send(notification) def _NH_MediaStreamDidNotInitialize(self, notification): if self.greenlet is not None and self.state not in ('terminating', 'terminated'): self._channel.send_exception(MediaStreamDidNotInitializeError(notification.sender, notification.data)) def _NH_MediaStreamDidFail(self, notification): if self.greenlet is not None: if self.state not in ('terminating', 'terminated'): self._channel.send_exception(MediaStreamDidFailError(notification.sender, notification.data)) else: stream = notification.sender if self.streams == [stream]: self.end() else: try: self.remove_stream(stream) except IllegalStateError: self.end() +@implementer(IObserver) class SessionManager(object, metaclass=Singleton): - implements(IObserver) def __init__(self): self.sessions = [] self.state = None self._channel = coros.queue() def start(self): self.state = 'starting' notification_center = NotificationCenter() notification_center.post_notification('SIPSessionManagerWillStart', sender=self) notification_center.add_observer(self, 'SIPInvitationChangedState') notification_center.add_observer(self, 'SIPSessionNewIncoming') notification_center.add_observer(self, 'SIPSessionNewOutgoing') notification_center.add_observer(self, 'SIPSessionDidFail') notification_center.add_observer(self, 'SIPSessionDidEnd') self.state = 'started' notification_center.post_notification('SIPSessionManagerDidStart', sender=self) def stop(self): self.state = 'stopping' notification_center = NotificationCenter() notification_center.post_notification('SIPSessionManagerWillEnd', sender=self) for session in self.sessions: session.end() while self.sessions: self._channel.wait() notification_center.remove_observer(self, 'SIPInvitationChangedState') notification_center.remove_observer(self, 'SIPSessionNewIncoming') notification_center.remove_observer(self, 'SIPSessionNewOutgoing') notification_center.remove_observer(self, 'SIPSessionDidFail') notification_center.remove_observer(self, 'SIPSessionDidEnd') self.state = 'stopped' notification_center.post_notification('SIPSessionManagerDidEnd', sender=self) @run_in_twisted_thread def handle_notification(self, notification): if notification.name == 'SIPInvitationChangedState' and notification.data.state == 'incoming': account_manager = AccountManager() account = account_manager.find_account(notification.data.request_uri) if account is None: notification.sender.send_response(404) return notification.sender.send_response(100) session = Session(account) session.init_incoming(notification.sender, notification.data) elif notification.name in ('SIPSessionNewIncoming', 'SIPSessionNewOutgoing'): self.sessions.append(notification.sender) elif notification.name in ('SIPSessionDidFail', 'SIPSessionDidEnd'): self.sessions.remove(notification.sender) if self.state == 'stopping': self._channel.send(notification) diff --git a/sipsimple/storage.py b/sipsimple/storage.py index 6a5600cd..92a00f73 100644 --- a/sipsimple/storage.py +++ b/sipsimple/storage.py @@ -1,50 +1,50 @@ """Definitions and implementations of storage backends""" __all__ = ['ISIPSimpleStorage', 'ISIPSimpleApplicationDataStorage', 'FileStorage', 'MemoryStorage'] import os from functools import partial -from zope.interface import Attribute, Interface, implements +from zope.interface import Attribute, Interface, implementer from sipsimple.account.xcap.storage.file import FileStorage as XCAPFileStorage from sipsimple.account.xcap.storage.memory import MemoryStorage as XCAPMemoryStorage from sipsimple.configuration.backend.file import FileBackend as ConfigurationFileBackend from sipsimple.configuration.backend.memory import MemoryBackend as ConfigurationMemoryBackend class ISIPSimpleStorage(Interface): """Interface describing the backends used for storage throughout SIP Simple""" configuration_backend = Attribute("The backend used for the configuration") xcap_storage_factory = Attribute("The factory used to create XCAP storage backends for each account") class ISIPSimpleApplicationDataStorage(Interface): """Interface describing the directory used for application data storage """ directory = Attribute("The directory used for application data") +@implementer(ISIPSimpleStorage, ISIPSimpleApplicationDataStorage) class FileStorage(object): """Store/read SIP Simple data to/from files""" - implements(ISIPSimpleStorage, ISIPSimpleApplicationDataStorage) def __init__(self, directory): self.configuration_backend = ConfigurationFileBackend(os.path.join(directory, 'config')) self.xcap_storage_factory = partial(XCAPFileStorage, os.path.join(directory, 'xcap')) self.directory = directory +@implementer(ISIPSimpleStorage) class MemoryStorage(object): """Store/read SIP Simple data to/from memory""" - implements(ISIPSimpleStorage) def __init__(self): self.configuration_backend = ConfigurationMemoryBackend() self.xcap_storage_factory = XCAPMemoryStorage diff --git a/sipsimple/streams/msrp/__init__.py b/sipsimple/streams/msrp/__init__.py index 748e57f1..a3805a53 100644 --- a/sipsimple/streams/msrp/__init__.py +++ b/sipsimple/streams/msrp/__init__.py @@ -1,401 +1,401 @@ """ Handling of MSRP media streams according to RFC4975, RFC4976, RFC5547 and RFC3994. """ __all__ = ['MSRPStreamError', 'MSRPStreamBase'] import traceback from application.notification import NotificationCenter, NotificationData, IObserver from application.python import Null from application.system import host from twisted.internet.error import ConnectionDone -from zope.interface import implements +from zope.interface import implementer from eventlib import api from msrplib.connect import DirectConnector, DirectAcceptor, RelayConnection, MSRPRelaySettings from msrplib.protocol import URI from msrplib.session import contains_mime_type from sipsimple.account import Account, BonjourAccount from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import SDPAttribute, SDPConnection, SDPMediaStream from sipsimple.streams import IMediaStream, MediaStreamType, StreamError from sipsimple.threading.green import run_in_green_thread class MSRPStreamError(StreamError): pass +@implementer(IMediaStream, IObserver) class MSRPStreamBase(object, metaclass=MediaStreamType): - implements(IMediaStream, IObserver) # Attributes that need to be defined by each MSRP stream type type = None priority = None msrp_session_class = None media_type = None accept_types = None accept_wrapped_types = None # These attributes are always False for any MSRP stream hold_supported = False on_hold = False on_hold_by_local = False on_hold_by_remote = False def __new__(cls, *args, **kw): if cls is MSRPStreamBase: raise TypeError("MSRPStreamBase cannot be instantiated directly") return object.__new__(cls) def __init__(self, direction='sendrecv'): self.direction = direction self.greenlet = None self.local_media = None self.remote_media = None self.msrp = None # Placeholder for the MSRPTransport that will be set when started self.msrp_connector = None self.cpim_enabled = None # Boolean value. None means it was not negotiated yet self.session = None self.msrp_session = None self.shutting_down = False self.local_role = None self.remote_role = None self.transport = None self.remote_accept_types = None self.remote_accept_wrapped_types = None self._initialize_done = False self._done = False self._failure_reason = None @property def local_uri(self): msrp = self.msrp or self.msrp_connector return msrp.local_uri if msrp is not None else None def _create_local_media(self, uri_path): transport = "TCP/TLS/MSRP" if uri_path[-1].use_tls else "TCP/MSRP" attributes = [SDPAttribute("path", " ".join(str(uri) for uri in uri_path))] if self.direction not in [None, 'sendrecv']: attributes.append(SDPAttribute(self.direction, '')) if self.accept_types is not None: attributes.append(SDPAttribute("accept-types", " ".join(self.accept_types))) if self.accept_wrapped_types is not None: attributes.append(SDPAttribute("accept-wrapped-types", " ".join(self.accept_wrapped_types))) attributes.append(SDPAttribute("setup", self.local_role)) local_ip = uri_path[-1].host connection = SDPConnection(local_ip) return SDPMediaStream(self.media_type, uri_path[-1].port or 2855, transport, connection=connection, formats=["*"], attributes=attributes) # The public API (the IMediaStream interface) # noinspection PyUnusedLocal def get_local_media(self, remote_sdp=None, index=0): return self.local_media def new_from_sdp(self, session, remote_sdp, stream_index): raise NotImplementedError @run_in_green_thread def initialize(self, session, direction): self.greenlet = api.getcurrent() notification_center = NotificationCenter() notification_center.add_observer(self, sender=self) try: self.session = session self.transport = self.session.account.msrp.transport outgoing = direction == 'outgoing' logger = NotificationProxyLogger() if self.session.account is BonjourAccount(): if outgoing: self.msrp_connector = DirectConnector(logger=logger) self.local_role = 'active' else: if self.transport == 'tls' and None in (self.session.account.tls_credentials.cert, self.session.account.tls_credentials.key): raise MSRPStreamError("Cannot accept MSRP connection without a TLS certificate") self.msrp_connector = DirectAcceptor(logger=logger) self.local_role = 'passive' else: if self.session.account.msrp.connection_model == 'relay': if not outgoing and self.remote_role in ('actpass', 'passive'): # 'passive' not allowed by the RFC but play nice for interoperability. -Saul self.msrp_connector = DirectConnector(logger=logger, use_sessmatch=True) self.local_role = 'active' elif outgoing and not self.session.account.nat_traversal.use_msrp_relay_for_outbound: self.msrp_connector = DirectConnector(logger=logger, use_sessmatch=True) self.local_role = 'active' else: if self.session.account.nat_traversal.msrp_relay is None: relay_host = relay_port = None else: if self.transport != self.session.account.nat_traversal.msrp_relay.transport: raise MSRPStreamError("MSRP relay transport conflicts with MSRP transport setting") relay_host = self.session.account.nat_traversal.msrp_relay.host relay_port = self.session.account.nat_traversal.msrp_relay.port relay = MSRPRelaySettings(domain=self.session.account.uri.host, username=self.session.account.uri.user, password=self.session.account.credentials.password, host=relay_host, port=relay_port, use_tls=self.transport=='tls') self.msrp_connector = RelayConnection(relay, 'passive', logger=logger, use_sessmatch=True) self.local_role = 'actpass' if outgoing else 'passive' else: if not outgoing and self.remote_role in ('actpass', 'passive'): # 'passive' not allowed by the RFC but play nice for interoperability. -Saul self.msrp_connector = DirectConnector(logger=logger, use_sessmatch=True) self.local_role = 'active' else: if not outgoing and self.transport == 'tls' and None in (self.session.account.tls_credentials.cert, self.session.account.tls_credentials.key): raise MSRPStreamError("Cannot accept MSRP connection without a TLS certificate") self.msrp_connector = DirectAcceptor(logger=logger, use_sessmatch=True) self.local_role = 'actpass' if outgoing else 'passive' full_local_path = self.msrp_connector.prepare(local_uri=URI(host=host.default_ip, port=0, use_tls=self.transport=='tls', credentials=self.session.account.tls_credentials)) self.local_media = self._create_local_media(full_local_path) except Exception as e: notification_center.post_notification('MediaStreamDidNotInitialize', sender=self, data=NotificationData(reason=str(e))) else: notification_center.post_notification('MediaStreamDidInitialize', sender=self) finally: self._initialize_done = True self.greenlet = None # noinspection PyUnusedLocal @run_in_green_thread def start(self, local_sdp, remote_sdp, stream_index): self.greenlet = api.getcurrent() notification_center = NotificationCenter() context = 'sdp_negotiation' try: remote_media = remote_sdp.media[stream_index] self.remote_media = remote_media self.remote_accept_types = remote_media.attributes.getfirst('accept-types', '').split() self.remote_accept_wrapped_types = remote_media.attributes.getfirst('accept-wrapped-types', '').split() self.cpim_enabled = contains_mime_type(self.accept_types, 'message/cpim') and contains_mime_type(self.remote_accept_types, 'message/cpim') remote_uri_path = remote_media.attributes.getfirst('path') if remote_uri_path is None: raise AttributeError("remote SDP media does not have 'path' attribute") full_remote_path = [URI.parse(uri) for uri in remote_uri_path.split()] remote_transport = 'tls' if full_remote_path[0].use_tls else 'tcp' if self.transport != remote_transport: raise MSRPStreamError("remote transport ('%s') different from local transport ('%s')" % (remote_transport, self.transport)) if isinstance(self.session.account, Account) and self.local_role == 'actpass': remote_setup = remote_media.attributes.getfirst('setup', 'passive') if remote_setup == 'passive': # If actpass is offered connectors are always started as passive # We need to switch to active if the remote answers with passive if self.session.account.msrp.connection_model == 'relay': self.msrp_connector.mode = 'active' else: local_uri = self.msrp_connector.local_uri logger = self.msrp_connector.logger self.msrp_connector = DirectConnector(logger=logger, use_sessmatch=True) self.msrp_connector.prepare(local_uri) context = 'start' self.msrp = self.msrp_connector.complete(full_remote_path) if self.msrp_session_class is not None: self.msrp_session = self.msrp_session_class(self.msrp, accept_types=self.accept_types, on_incoming_cb=self._handle_incoming, automatic_reports=False) self.msrp_connector = None except Exception as e: self._failure_reason = str(e) notification_center.post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context=context, reason=self._failure_reason)) else: notification_center.post_notification('MediaStreamDidStart', sender=self) finally: self.greenlet = None def deactivate(self): self.shutting_down = True @run_in_green_thread def end(self): if self._done: return self._done = True notification_center = NotificationCenter() if not self._initialize_done: # we are in the middle of initialize() try: msrp_connector = self.msrp_connector if self.greenlet is not None: api.kill(self.greenlet) if msrp_connector is not None: msrp_connector.cleanup() finally: notification_center.post_notification('MediaStreamDidNotInitialize', sender=self, data=NotificationData(reason='Interrupted')) notification_center.remove_observer(self, sender=self) self.msrp_connector = None self.greenlet = None else: notification_center.post_notification('MediaStreamWillEnd', sender=self) msrp = self.msrp msrp_session = self.msrp_session msrp_connector = self.msrp_connector try: if self.greenlet is not None: api.kill(self.greenlet) if msrp_session is not None: msrp_session.shutdown() elif msrp is not None: msrp.loseConnection(wait=False) if msrp_connector is not None: msrp_connector.cleanup() finally: notification_center.post_notification('MediaStreamDidEnd', sender=self, data=NotificationData(error=self._failure_reason)) notification_center.remove_observer(self, sender=self) self.msrp = None self.msrp_session = None self.msrp_connector = None self.session = None self.greenlet = None # noinspection PyMethodMayBeStatic,PyUnusedLocal def validate_update(self, remote_sdp, stream_index): return True # TODO def update(self, local_sdp, remote_sdp, stream_index): pass # TODO def hold(self): pass def unhold(self): pass def reset(self, stream_index): pass # Internal IObserver interface def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) # Internal message handlers def _handle_incoming(self, chunk=None, error=None): notification_center = NotificationCenter() if error is not None: if self.shutting_down and isinstance(error.value, ConnectionDone): return self._failure_reason = error.getErrorMessage() notification_center.post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context='reading', reason=self._failure_reason)) elif chunk is not None: method_handler = getattr(self, '_handle_%s' % chunk.method, None) if method_handler is not None: method_handler(chunk) def _handle_REPORT(self, chunk): pass def _handle_SEND(self, chunk): pass # temporary solution. to be replaced later by a better logging system in msrplib -Dan # class ChunkInfo(object): __slots__ = 'content_type', 'header', 'footer', 'data' def __init__(self, content_type, header='', footer='', data=''): self.content_type = content_type self.header = header self.footer = footer self.data = data def __repr__(self): return "{0.__class__.__name__}(content_type={0.content_type!r}, header={0.header!r}, footer={0.footer!r}, data={0.data!r})".format(self) @property def content(self): return self.header + self.data + self.footer @property def normalized_content(self): if not self.data: return self.header + self.footer elif self.content_type == 'message/cpim': headers, sep, body = self.data.partition('\r\n\r\n') if not sep: return self.header + self.data + self.footer mime_headers, mime_sep, mime_body = body.partition('\n\n') if not mime_sep: return self.header + self.data + self.footer for mime_header in mime_headers.lower().splitlines(): if mime_header.startswith('content-type:'): wrapped_content_type = mime_header[13:].partition(';')[0].strip() break else: wrapped_content_type = None if wrapped_content_type is None or wrapped_content_type == 'application/im-iscomposing+xml' or wrapped_content_type.startswith(('text/', 'message/')): data = self.data else: data = headers + sep + mime_headers + mime_sep + '<<>>' return self.header + data + self.footer elif self.content_type is None or self.content_type == 'application/im-iscomposing+xml' or self.content_type.startswith(('text/', 'message/')): return self.header + self.data + self.footer else: return self.header + '<<>>' + self.footer class NotificationProxyLogger(object): def __init__(self): from application import log self.level = log.level self.notification_center = NotificationCenter() self.log_settings = SIPSimpleSettings().logs def received_chunk(self, data, transport): if self.log_settings.trace_msrp: chunk_info = ChunkInfo(data.content_type, header=data.chunk_header, footer=data.chunk_footer, data=data.data) notification_data = NotificationData(direction='incoming', local_address=transport.getHost(), remote_address=transport.getPeer(), data=chunk_info.normalized_content, illegal=False) self.notification_center.post_notification('MSRPTransportTrace', sender=transport, data=notification_data) def sent_chunk(self, data, transport): if self.log_settings.trace_msrp: chunk_info = ChunkInfo(data.content_type, header=data.encoded_header, footer=data.encoded_footer, data=data.data) notification_data = NotificationData(direction='outgoing', local_address=transport.getHost(), remote_address=transport.getPeer(), data=chunk_info.normalized_content, illegal=False) self.notification_center.post_notification('MSRPTransportTrace', sender=transport, data=notification_data) def received_illegal_data(self, data, transport): if self.log_settings.trace_msrp: notification_data = NotificationData(direction='incoming', local_address=transport.getHost(), remote_address=transport.getPeer(), data=data, illegal=True) self.notification_center.post_notification('MSRPTransportTrace', sender=transport, data=notification_data) def debug(self, message, *args, **kw): pass def info(self, message, *args, **kw): if self.log_settings.trace_msrp: self.notification_center.post_notification('MSRPLibraryLog', data=NotificationData(message=message % args if args else message, level=self.level.INFO)) def warning(self, message, *args, **kw): if self.log_settings.trace_msrp: self.notification_center.post_notification('MSRPLibraryLog', data=NotificationData(message=message % args if args else message, level=self.level.WARNING)) warn = warning def error(self, message, *args, **kw): if self.log_settings.trace_msrp: self.notification_center.post_notification('MSRPLibraryLog', data=NotificationData(message=message % args if args else message, level=self.level.ERROR)) def exception(self, message='', *args, **kw): if self.log_settings.trace_msrp: message = message % args if args else message exception = traceback.format_exc() self.notification_center.post_notification('MSRPLibraryLog', data=NotificationData(message=message + '\n' + exception if message else exception, level=self.level.ERROR)) def critical(self, message, *args, **kw): if self.log_settings.trace_msrp: self.notification_center.post_notification('MSRPLibraryLog', data=NotificationData(message=message % args if args else message, level=self.level.CRITICAL)) fatal = critical from sipsimple.streams.msrp import chat, filetransfer, screensharing diff --git a/sipsimple/streams/msrp/chat.py b/sipsimple/streams/msrp/chat.py index ff98fbbf..46f04332 100644 --- a/sipsimple/streams/msrp/chat.py +++ b/sipsimple/streams/msrp/chat.py @@ -1,906 +1,906 @@ """ This module provides classes to parse and generate SDP related to SIP sessions that negotiate Instant Messaging, including CPIM as defined in RFC3862 """ import pickle as pickle import codecs import os import random import re from application.python.descriptor import WriteOnceAttribute from application.notification import IObserver, NotificationCenter, NotificationData from application.python import Null from application.python.types import Singleton from application.system import openfile from collections import defaultdict from email.message import Message as EmailMessage from email.parser import Parser as EmailParser from eventlib.coros import queue from eventlib.proc import spawn, ProcExit from functools import partial from msrplib.protocol import FailureReportHeader, SuccessReportHeader, UseNicknameHeader from msrplib.session import MSRPSession, contains_mime_type from otr import OTRSession, OTRTransport, OTRState, SMPStatus from otr.cryptography import DSAPrivateKey from otr.exceptions import IgnoreMessage, UnencryptedMessage, EncryptedMessageError, OTRError -from zope.interface import implements +from zope.interface import implementer from sipsimple.core import SIPURI, BaseSIPURI from sipsimple.payloads import ParserError from sipsimple.payloads.iscomposing import IsComposingDocument, State, LastActive, Refresh, ContentType from sipsimple.storage import ISIPSimpleApplicationDataStorage from sipsimple.streams import InvalidStreamError, UnknownStreamError from sipsimple.streams.msrp import MSRPStreamError, MSRPStreamBase from sipsimple.threading import run_in_thread, run_in_twisted_thread from sipsimple.threading.green import run_in_green_thread from sipsimple.util import MultilingualText, ISOTimestamp __all__ = ['ChatStream', 'ChatStreamError', 'ChatIdentity', 'CPIMPayload', 'CPIMHeader', 'CPIMNamespace', 'CPIMParserError', 'OTRState', 'SMPStatus'] class OTRTrustedPeer(object): fingerprint = WriteOnceAttribute() # in order to be hashable this needs to be immutable def __init__(self, fingerprint, description='', **kw): if not isinstance(fingerprint, str): raise TypeError("fingerprint must be a string") self.fingerprint = fingerprint self.description = description self.__dict__.update(kw) def __hash__(self): return hash(self.fingerprint) def __eq__(self, other): if isinstance(other, OTRTrustedPeer): return self.fingerprint == other.fingerprint elif isinstance(other, str): return self.fingerprint == other else: return NotImplemented def __ne__(self, other): return not (self == other) def __repr__(self): return "{0.__class__.__name__}({0.fingerprint!r}, description={0.description!r})".format(self) def __reduce__(self): return self.__class__, (self.fingerprint,), self.__dict__ class OTRTrustedPeerSet(object): def __init__(self, iterable=()): self.__data__ = {} self.update(iterable) def __repr__(self): return "{}({})".format(self.__class__.__name__, list(self.__data__.values())) def __contains__(self, item): return item in self.__data__ def __getitem__(self, item): return self.__data__[item] def __iter__(self): return iter(list(self.__data__.values())) def __len__(self): return len(self.__data__) def get(self, item, default=None): return self.__data__.get(item, default) def add(self, item): if not isinstance(item, OTRTrustedPeer): raise TypeError("item should be and instance of OTRTrustedPeer") self.__data__[item.fingerprint] = item def remove(self, item): del self.__data__[item] def discard(self, item): self.__data__.pop(item, None) def update(self, iterable=()): for item in iterable: self.add(item) class OTRCache(object, metaclass=Singleton): def __init__(self): from sipsimple.application import SIPApplication if SIPApplication.storage is None: raise RuntimeError("Cannot access the OTR cache before SIPApplication.storage is defined") if ISIPSimpleApplicationDataStorage.providedBy(SIPApplication.storage): self.key_file = os.path.join(SIPApplication.storage.directory, 'otr.key') self.trusted_file = os.path.join(SIPApplication.storage.directory, 'otr.trusted') try: self.private_key = DSAPrivateKey.load(self.key_file) if self.private_key.key_size != 1024: raise ValueError except (EnvironmentError, ValueError): self.private_key = DSAPrivateKey.generate() self.private_key.save(self.key_file) try: self.trusted_peers = pickle.load(open(self.trusted_file, 'rb')) if not isinstance(self.trusted_peers, OTRTrustedPeerSet) or not all(isinstance(item, OTRTrustedPeer) for item in self.trusted_peers): raise ValueError("invalid OTR trusted peers file") except Exception: self.trusted_peers = OTRTrustedPeerSet() self.save() else: self.key_file = self.trusted_file = None self.private_key = DSAPrivateKey.generate() self.trusted_peers = OTRTrustedPeerSet() # def generate_private_key(self): # self.private_key = DSAPrivateKey.generate() # if self.key_file: # self.private_key.save(self.key_file) @run_in_thread('file-io') def save(self): if self.trusted_file is not None: with openfile(self.trusted_file, 'wb', permissions=0o600) as trusted_file: pickle.dump(self.trusted_peers, trusted_file) +@implementer(IObserver) class OTREncryption(object): - implements(IObserver) def __init__(self, stream): self.stream = stream self.otr_cache = OTRCache() self.otr_session = OTRSession(self.otr_cache.private_key, self.stream, supported_versions={3}) # we need at least OTR-v3 for question based SMP notification_center = NotificationCenter() notification_center.add_observer(self, sender=stream) notification_center.add_observer(self, sender=self.otr_session) @property def active(self): try: return self.otr_session.encrypted except AttributeError: return False @property def cipher(self): return 'AES-128-CTR' if self.active else None @property def key_fingerprint(self): try: return self.otr_session.local_private_key.public_key.fingerprint except AttributeError: return None @property def peer_fingerprint(self): try: return self.otr_session.remote_public_key.fingerprint except AttributeError: return None @property def peer_name(self): try: return self.__dict__['peer_name'] except KeyError: trusted_peer = self.otr_cache.trusted_peers.get(self.peer_fingerprint, None) if trusted_peer is None: return '' else: return self.__dict__.setdefault('peer_name', trusted_peer.description) @peer_name.setter def peer_name(self, name): old_name = self.peer_name new_name = self.__dict__['peer_name'] = name if old_name != new_name: trusted_peer = self.otr_cache.trusted_peers.get(self.peer_fingerprint, None) if trusted_peer is not None: trusted_peer.description = new_name self.otr_cache.save() notification_center = NotificationCenter() notification_center.post_notification("ChatStreamOTRPeerNameChanged", sender=self.stream, data=NotificationData(name=name)) @property def verified(self): return self.peer_fingerprint in self.otr_cache.trusted_peers @verified.setter def verified(self, value): peer_fingerprint = self.peer_fingerprint old_verified = peer_fingerprint in self.otr_cache.trusted_peers new_verified = bool(value) if peer_fingerprint is None or new_verified == old_verified: return if new_verified: self.otr_cache.trusted_peers.add(OTRTrustedPeer(peer_fingerprint, description=self.peer_name)) else: self.otr_cache.trusted_peers.remove(peer_fingerprint) self.otr_cache.save() notification_center = NotificationCenter() notification_center.post_notification("ChatStreamOTRVerifiedStateChanged", sender=self.stream, data=NotificationData(verified=new_verified)) @run_in_twisted_thread def start(self): if self.otr_session is not None: self.otr_session.start() @run_in_twisted_thread def stop(self): if self.otr_session is not None: self.otr_session.stop() @run_in_twisted_thread def smp_verify(self, secret, question=None): self.otr_session.smp_verify(secret, question) @run_in_twisted_thread def smp_answer(self, secret): self.otr_session.smp_answer(secret) @run_in_twisted_thread def smp_abort(self): self.otr_session.smp_abort() def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_MediaStreamDidStart(self, notification): if self.stream.start_otr: self.otr_session.start() def _NH_MediaStreamDidEnd(self, notification): notification.center.remove_observer(self, sender=self.stream) notification.center.remove_observer(self, sender=self.otr_session) self.otr_session.stop() self.otr_session = None self.stream = None _NH_MediaStreamDidNotInitialize = _NH_MediaStreamDidEnd def _NH_OTRSessionStateChanged(self, notification): notification.center.post_notification('ChatStreamOTREncryptionStateChanged', sender=self.stream, data=notification.data) def _NH_OTRSessionSMPVerificationDidStart(self, notification): notification.center.post_notification('ChatStreamSMPVerificationDidStart', sender=self.stream, data=notification.data) def _NH_OTRSessionSMPVerificationDidNotStart(self, notification): notification.center.post_notification('ChatStreamSMPVerificationDidNotStart', sender=self.stream, data=notification.data) def _NH_OTRSessionSMPVerificationDidEnd(self, notification): notification.center.post_notification('ChatStreamSMPVerificationDidEnd', sender=self.stream, data=notification.data) class ChatStreamError(MSRPStreamError): pass class ChatStream(MSRPStreamBase): type = 'chat' priority = 1 msrp_session_class = MSRPSession media_type = 'message' accept_types = ['message/cpim', 'text/*', 'image/*', 'application/im-iscomposing+xml'] accept_wrapped_types = ['text/*', 'image/*', 'application/im-iscomposing+xml'] prefer_cpim = True start_otr = True def __init__(self): super(ChatStream, self).__init__(direction='sendrecv') self.message_queue = queue() self.sent_messages = set() self.incoming_queue = defaultdict(list) self.message_queue_thread = None self.encryption = OTREncryption(self) @classmethod def new_from_sdp(cls, session, remote_sdp, stream_index): remote_stream = remote_sdp.media[stream_index] if remote_stream.media != 'message': raise UnknownStreamError expected_transport = 'TCP/TLS/MSRP' if session.account.msrp.transport=='tls' else 'TCP/MSRP' if remote_stream.transport != expected_transport: raise InvalidStreamError("expected %s transport in chat stream, got %s" % (expected_transport, remote_stream.transport)) if remote_stream.formats != ['*']: raise InvalidStreamError("wrong format list specified") stream = cls() stream.remote_role = remote_stream.attributes.getfirst('setup', 'active') if remote_stream.direction != 'sendrecv': raise InvalidStreamError("Unsupported direction for chat stream: %s" % remote_stream.direction) remote_accept_types = remote_stream.attributes.getfirst('accept-types') if remote_accept_types is None: raise InvalidStreamError("remote SDP media does not have 'accept-types' attribute") if not any(contains_mime_type(cls.accept_types, mime_type) for mime_type in remote_accept_types.split()): raise InvalidStreamError("no compatible media types found") return stream @property def local_identity(self): try: return ChatIdentity(self.session.local_identity.uri, self.session.local_identity.display_name) except AttributeError: return None @property def remote_identity(self): try: return ChatIdentity(self.session.remote_identity.uri, self.session.remote_identity.display_name) except AttributeError: return None @property def private_messages_allowed(self): return 'private-messages' in self.chatroom_capabilities @property def nickname_allowed(self): return 'nickname' in self.chatroom_capabilities @property def chatroom_capabilities(self): try: if self.cpim_enabled and self.session.remote_focus: return ' '.join(self.remote_media.attributes.getall('chatroom')).split() except AttributeError: pass return [] def _NH_MediaStreamDidStart(self, notification): self.message_queue_thread = spawn(self._message_queue_handler) def _NH_MediaStreamDidNotInitialize(self, notification): message_queue, self.message_queue = self.message_queue, queue() while message_queue: message = message_queue.wait() if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream was closed') notification.center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) def _NH_MediaStreamDidEnd(self, notification): if self.message_queue_thread is not None: self.message_queue_thread.kill() else: message_queue, self.message_queue = self.message_queue, queue() while message_queue: message = message_queue.wait() if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream ended') notification.center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) def _handle_REPORT(self, chunk): # in theory, REPORT can come with Byte-Range which would limit the scope of the REPORT to the part of the message. if chunk.message_id in self.sent_messages: self.sent_messages.remove(chunk.message_id) notification_center = NotificationCenter() data = NotificationData(message_id=chunk.message_id, message=chunk, code=chunk.status.code, reason=chunk.status.comment) if chunk.status.code == 200: notification_center.post_notification('ChatStreamDidDeliverMessage', sender=self, data=data) else: notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) def _handle_SEND(self, chunk): if chunk.size == 0: # keep-alive self.msrp_session.send_report(chunk, 200, 'OK') return content_type = chunk.content_type.lower() if not contains_mime_type(self.accept_types, content_type): self.msrp_session.send_report(chunk, 413, 'Unwanted Message') return if chunk.contflag == '#': self.incoming_queue.pop(chunk.message_id, None) self.msrp_session.send_report(chunk, 200, 'OK') return elif chunk.contflag == '+': self.incoming_queue[chunk.message_id].append(chunk.data) self.msrp_session.send_report(chunk, 200, 'OK') return else: data = ''.join(self.incoming_queue.pop(chunk.message_id, [])) + chunk.data if content_type == 'message/cpim': try: payload = CPIMPayload.decode(data) except CPIMParserError: self.msrp_session.send_report(chunk, 400, 'CPIM Parser Error') return else: message = Message(**{name: getattr(payload, name) for name in Message.__slots__}) if not contains_mime_type(self.accept_wrapped_types, message.content_type): self.msrp_session.send_report(chunk, 413, 'Unwanted Message') return if message.timestamp is None: message.timestamp = ISOTimestamp.now() if message.sender is None: message.sender = self.remote_identity private = self.session.remote_focus and len(message.recipients) == 1 and message.recipients[0] != self.remote_identity else: payload = SimplePayload.decode(data, content_type) message = Message(payload.content, payload.content_type, sender=self.remote_identity, recipients=[self.local_identity], timestamp=ISOTimestamp.now()) private = False try: message.content = self.encryption.otr_session.handle_input(message.content, message.content_type) except IgnoreMessage: self.msrp_session.send_report(chunk, 200, 'OK') return except UnencryptedMessage: encrypted = False encryption_active = True except EncryptedMessageError as e: self.msrp_session.send_report(chunk, 400, str(e)) notification_center = NotificationCenter() notification_center.post_notification('ChatStreamOTRError', sender=self, data=NotificationData(error=str(e))) return except OTRError as e: self.msrp_session.send_report(chunk, 200, 'OK') notification_center = NotificationCenter() notification_center.post_notification('ChatStreamOTRError', sender=self, data=NotificationData(error=str(e))) return else: encrypted = encryption_active = self.encryption.active if payload.charset is not None: message.content = message.content.decode(payload.charset) elif payload.content_type.startswith('text/'): message.content.decode('utf8') notification_center = NotificationCenter() if message.content_type.lower() == IsComposingDocument.content_type: try: document = IsComposingDocument.parse(message.content) except ParserError as e: self.msrp_session.send_report(chunk, 400, str(e)) return self.msrp_session.send_report(chunk, 200, 'OK') data = NotificationData(state=document.state.value, refresh=document.refresh.value if document.refresh is not None else 120, content_type=document.content_type.value if document.content_type is not None else None, last_active=document.last_active.value if document.last_active is not None else None, sender=message.sender, recipients=message.recipients, private=private, encrypted=encrypted, encryption_active=encryption_active) notification_center.post_notification('ChatStreamGotComposingIndication', sender=self, data=data) else: self.msrp_session.send_report(chunk, 200, 'OK') data = NotificationData(message=message, private=private, encrypted=encrypted, encryption_active=encryption_active) notification_center.post_notification('ChatStreamGotMessage', sender=self, data=data) def _on_transaction_response(self, message_id, response): if message_id in self.sent_messages and response.code != 200: self.sent_messages.remove(message_id) data = NotificationData(message_id=message_id, message=response, code=response.code, reason=response.comment) NotificationCenter().post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) def _on_nickname_transaction_response(self, message_id, response): notification_center = NotificationCenter() if response.code == 200: notification_center.post_notification('ChatStreamDidSetNickname', sender=self, data=NotificationData(message_id=message_id, response=response)) else: notification_center.post_notification('ChatStreamDidNotSetNickname', sender=self, data=NotificationData(message_id=message_id, message=response, code=response.code, reason=response.comment)) def _message_queue_handler(self): notification_center = NotificationCenter() try: while True: message = self.message_queue.wait() if self.msrp_session is None: if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream ended') notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) break try: if isinstance(message.content, str): message.content = message.content.encode('utf8') charset = 'utf8' else: charset = None if not isinstance(message, QueuedOTRInternalMessage): try: message.content = self.encryption.otr_session.handle_output(message.content, message.content_type) except OTRError as e: raise ChatStreamError(str(e)) message.sender = message.sender or self.local_identity message.recipients = message.recipients or [self.remote_identity] # check if we MUST use CPIM need_cpim = (message.sender != self.local_identity or message.recipients != [self.remote_identity] or message.courtesy_recipients or message.subject or message.timestamp or message.required or message.additional_headers) if need_cpim or not contains_mime_type(self.remote_accept_types, message.content_type): if not contains_mime_type(self.remote_accept_wrapped_types, message.content_type): raise ChatStreamError('Unsupported content_type for outgoing message: %r' % message.content_type) if not self.cpim_enabled: raise ChatStreamError('Additional message meta-data cannot be sent, because the CPIM wrapper is not used') if not self.private_messages_allowed and message.recipients != [self.remote_identity]: raise ChatStreamError('The remote end does not support private messages') if message.timestamp is None: message.timestamp = ISOTimestamp.now() payload = CPIMPayload(charset=charset, **{name: getattr(message, name) for name in Message.__slots__}) elif self.prefer_cpim and self.cpim_enabled and contains_mime_type(self.remote_accept_wrapped_types, message.content_type): if message.timestamp is None: message.timestamp = ISOTimestamp.now() payload = CPIMPayload(charset=charset, **{name: getattr(message, name) for name in Message.__slots__}) else: payload = SimplePayload(message.content, message.content_type, charset) except ChatStreamError as e: if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason=e.args[0]) notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) continue else: content, content_type = payload.encode() message_id = message.id notify_progress = message.notify_progress report = 'yes' if notify_progress else 'no' chunk = self.msrp_session.make_message(content, content_type=content_type, message_id=message_id) chunk.add_header(FailureReportHeader(report)) chunk.add_header(SuccessReportHeader(report)) try: self.msrp_session.send_chunk(chunk, response_cb=partial(self._on_transaction_response, message_id)) except Exception as e: if notify_progress: data = NotificationData(message_id=message_id, message=None, code=0, reason=str(e)) notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) except ProcExit: if notify_progress: data = NotificationData(message_id=message_id, message=None, code=0, reason='Stream ended') notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) raise else: if notify_progress: self.sent_messages.add(message_id) notification_center.post_notification('ChatStreamDidSendMessage', sender=self, data=NotificationData(message=chunk)) finally: self.message_queue_thread = None while self.sent_messages: message_id = self.sent_messages.pop() data = NotificationData(message_id=message_id, message=None, code=0, reason='Stream ended') notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) message_queue, self.message_queue = self.message_queue, queue() while message_queue: message = message_queue.wait() if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream ended') notification_center.post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) @run_in_twisted_thread def _enqueue_message(self, message): if self._done: if message.notify_progress: data = NotificationData(message_id=message.id, message=None, code=0, reason='Stream ended') NotificationCenter().post_notification('ChatStreamDidNotDeliverMessage', sender=self, data=data) else: self.message_queue.send(message) @run_in_green_thread def _set_local_nickname(self, nickname, message_id): if self.msrp_session is None: # should we generate ChatStreamDidNotSetNickname here? return chunk = self.msrp.make_request('NICKNAME') chunk.add_header(UseNicknameHeader(nickname or '')) try: self.msrp_session.send_chunk(chunk, response_cb=partial(self._on_nickname_transaction_response, message_id)) except Exception as e: self._failure_reason = str(e) NotificationCenter().post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context='sending', reason=self._failure_reason)) def inject_otr_message(self, data): message = QueuedOTRInternalMessage(data) self._enqueue_message(message) def send_message(self, content, content_type='text/plain', recipients=None, courtesy_recipients=None, subject=None, timestamp=None, required=None, additional_headers=None): message = QueuedMessage(content, content_type, recipients=recipients, courtesy_recipients=courtesy_recipients, subject=subject, timestamp=timestamp, required=required, additional_headers=additional_headers, notify_progress=True) self._enqueue_message(message) return message.id def send_composing_indication(self, state, refresh=None, last_active=None, recipients=None): content = IsComposingDocument.create(state=State(state), refresh=Refresh(refresh) if refresh is not None else None, last_active=LastActive(last_active) if last_active is not None else None, content_type=ContentType('text')) message = QueuedMessage(content, IsComposingDocument.content_type, recipients=recipients, notify_progress=False) self._enqueue_message(message) return message.id def set_local_nickname(self, nickname): if not self.nickname_allowed: raise ChatStreamError('Setting nickname is not supported') message_id = '%x' % random.getrandbits(64) self._set_local_nickname(nickname, message_id) return message_id OTRTransport.register(ChatStream) # Chat related objects, including CPIM support as defined in RFC3862 # class ChatIdentity(object): _format_re = re.compile(r'^(?:"?(?P[^<]*[^"\s])"?)?\s*<(?Psips?:.+)>$') def __init__(self, uri, display_name=None): self.uri = uri self.display_name = display_name def __eq__(self, other): if isinstance(other, ChatIdentity): return self.uri.user == other.uri.user and self.uri.host == other.uri.host elif isinstance(other, BaseSIPURI): return self.uri.user == other.user and self.uri.host == other.host elif isinstance(other, str): try: other_uri = SIPURI.parse(other) except Exception: return False else: return self.uri.user == other_uri.user and self.uri.host == other_uri.host else: return NotImplemented def __ne__(self, other): return not (self == other) def __repr__(self): return '{0.__class__.__name__}(uri={0.uri!r}, display_name={0.display_name!r})'.format(self) def __str__(self): return self.__unicode__().encode('utf-8') def __unicode__(self): if self.display_name: return '{0.display_name} <{0.uri}>'.format(self) else: return '<{0.uri}>'.format(self) @classmethod def parse(cls, value): match = cls._format_re.match(value) if match is None: raise ValueError('Cannot parse identity value: %r' % value) return cls(SIPURI.parse(match.group('uri')), match.group('display_name')) class Message(object): __slots__ = 'content', 'content_type', 'sender', 'recipients', 'courtesy_recipients', 'subject', 'timestamp', 'required', 'additional_headers' def __init__(self, content, content_type, sender=None, recipients=None, courtesy_recipients=None, subject=None, timestamp=None, required=None, additional_headers=None): self.content = content self.content_type = content_type self.sender = sender self.recipients = recipients or [] self.courtesy_recipients = courtesy_recipients or [] self.subject = subject self.timestamp = ISOTimestamp(timestamp) if timestamp is not None else None self.required = required or [] self.additional_headers = additional_headers or [] class QueuedMessage(Message): __slots__ = 'id', 'notify_progress' def __init__(self, content, content_type, sender=None, recipients=None, courtesy_recipients=None, subject=None, timestamp=None, required=None, additional_headers=None, id=None, notify_progress=True): super(QueuedMessage, self).__init__(content, content_type, sender, recipients, courtesy_recipients, subject, timestamp, required, additional_headers) self.id = id or '%x' % random.getrandbits(64) self.notify_progress = notify_progress class QueuedOTRInternalMessage(QueuedMessage): def __init__(self, content): super(QueuedOTRInternalMessage, self).__init__(content, 'text/plain', notify_progress=False) class SimplePayload(object): def __init__(self, content, content_type, charset=None): if not isinstance(content, bytes): raise TypeError("content should be an instance of bytes") self.content = content self.content_type = content_type self.charset = charset def encode(self): if self.charset is not None: return self.content, '{0.content_type}; charset="{0.charset}"'.format(self) else: return self.content, str(self.content_type) @classmethod def decode(cls, content, content_type): if not isinstance(content, bytes): raise TypeError("content should be an instance of bytes") type_helper = EmailParser().parsestr('Content-Type: {}'.format(content_type)) content_type = type_helper.get_content_type() charset = type_helper.get_content_charset() return cls(content, content_type, charset) class CPIMPayload(object): standard_namespace = 'urn:ietf:params:cpim-headers:' headers_re = re.compile(r'(?:([^:]+?)\.)?(.+?):\s*(.+?)(?:\r\n|$)') subject_re = re.compile(r'^(?:;lang=([a-z]{1,8}(?:-[a-z0-9]{1,8})*)\s+)?(.*)$') namespace_re = re.compile(r'^(?:(\S+) ?)?<(.*)>$') def __init__(self, content, content_type, charset=None, sender=None, recipients=None, courtesy_recipients=None, subject=None, timestamp=None, required=None, additional_headers=None): if not isinstance(content, bytes): raise TypeError("content should be an instance of bytes") self.content = content self.content_type = content_type self.charset = charset self.sender = sender self.recipients = recipients or [] self.courtesy_recipients = courtesy_recipients or [] self.subject = subject if isinstance(subject, (MultilingualText, type(None))) else MultilingualText(subject) self.timestamp = ISOTimestamp(timestamp) if timestamp is not None else None self.required = required or [] self.additional_headers = additional_headers or [] def encode(self): namespaces = {'': CPIMNamespace(self.standard_namespace)} header_list = [] if self.sender is not None: header_list.append('From: {}'.format(self.sender)) header_list.extend('To: {}'.format(recipient) for recipient in self.recipients) header_list.extend('cc: {}'.format(recipient) for recipient in self.courtesy_recipients) if self.subject is not None: header_list.append('Subject: {}'.format(self.subject)) header_list.extend('Subject:;lang={} {}'.format(language, translation) for language, translation in list(self.subject.translations.items())) if self.timestamp is not None: header_list.append('DateTime: {}'.format(self.timestamp)) if self.required: header_list.append('Required: {}'.format(','.join(self.required))) for header in self.additional_headers: if namespaces.get(header.namespace.prefix) != header.namespace: if header.namespace.prefix: header_list.append('NS: {0.namespace.prefix} <{0.namespace}>'.format(header)) else: header_list.append('NS: <{0.namespace}>'.format(header)) namespaces[header.namespace.prefix] = header.namespace if header.namespace.prefix: header_list.append('{0.namespace.prefix}.{0.name}: {0.value}'.format(header)) else: header_list.append('{0.name}: {0.value}'.format(header)) headers = '\r\n'.join(header.encode('cpim-header') for header in header_list) mime_message = EmailMessage() mime_message.set_payload(self.content) mime_message.set_type(self.content_type) if self.charset is not None: mime_message.set_param('charset', self.charset) return headers + '\r\n\r\n' + mime_message.as_string(), 'message/cpim' @classmethod def decode(cls, message): if not isinstance(message, bytes): raise TypeError("message should be an instance of bytes") headers, separator, body = message.partition('\r\n\r\n') if not separator: raise CPIMParserError('Invalid CPIM message') sender = None recipients = [] courtesy_recipients = [] subject = None timestamp = None required = [] additional_headers = [] namespaces = {'': CPIMNamespace(cls.standard_namespace)} subjects = {} for prefix, name, value in cls.headers_re.findall(headers): namespace = namespaces.get(prefix) if namespace is None or '.' in name: continue try: value = value.decode('cpim-header') if namespace == cls.standard_namespace: if name == 'From': sender = ChatIdentity.parse(value) elif name == 'To': recipients.append(ChatIdentity.parse(value)) elif name == 'cc': courtesy_recipients.append(ChatIdentity.parse(value)) elif name == 'Subject': match = cls.subject_re.match(value) if match is None: raise ValueError('Illegal Subject header: %r' % value) lang, subject = match.groups() # language tags must be ASCII subjects[str(lang) if lang is not None else None] = subject elif name == 'DateTime': timestamp = ISOTimestamp(value) elif name == 'Required': required.extend(re.split(r'\s*,\s*', value)) elif name == 'NS': match = cls.namespace_re.match(value) if match is None: raise ValueError('Illegal NS header: %r' % value) prefix, uri = match.groups() namespaces[prefix] = CPIMNamespace(uri, prefix) else: additional_headers.append(CPIMHeader(name, namespace, value)) else: additional_headers.append(CPIMHeader(name, namespace, value)) except ValueError: pass if None in subjects: subject = MultilingualText(subjects.pop(None), **subjects) elif subjects: subject = MultilingualText(**subjects) mime_message = EmailParser().parsestr(body) content_type = mime_message.get_content_type() if content_type is None: raise CPIMParserError("CPIM message missing Content-Type MIME header") content = mime_message.get_payload() charset = mime_message.get_content_charset() return cls(content, content_type, charset, sender, recipients, courtesy_recipients, subject, timestamp, required, additional_headers) class CPIMParserError(Exception): pass class CPIMNamespace(str): def __new__(cls, value, prefix=''): obj = str.__new__(cls, value) obj.prefix = prefix return obj class CPIMHeader(object): def __init__(self, name, namespace, value): self.name = name self.namespace = namespace self.value = value class CPIMCodec(codecs.Codec): character_map = {c: '\\u{:04x}'.format(c) for c in list(range(32)) + [127]} character_map[ord('\\')] = '\\\\' @classmethod def encode(cls, input, errors='strict'): return input.translate(cls.character_map).encode('utf-8', errors), len(input) @classmethod def decode(cls, input, errors='strict'): return input.decode('utf-8', errors).encode('raw-unicode-escape', errors).decode('unicode-escape', errors), len(input) def cpim_codec_search(name): if name.lower() in ('cpim-header', 'cpim_header'): return codecs.CodecInfo(name='CPIM-header', encode=CPIMCodec.encode, decode=CPIMCodec.decode, incrementalencoder=codecs.IncrementalEncoder, incrementaldecoder=codecs.IncrementalDecoder, streamwriter=codecs.StreamWriter, streamreader=codecs.StreamReader) codecs.register(cpim_codec_search) del cpim_codec_search diff --git a/sipsimple/streams/msrp/filetransfer.py b/sipsimple/streams/msrp/filetransfer.py index af2cdd93..d096e98d 100644 --- a/sipsimple/streams/msrp/filetransfer.py +++ b/sipsimple/streams/msrp/filetransfer.py @@ -1,735 +1,735 @@ """ This module provides classes to parse and generate SDP related to SIP sessions that negotiate File Transfer. """ __all__ = ['FileTransferStream', 'FileSelector'] import pickle as pickle import hashlib import mimetypes import os import random import re import time import uuid from abc import ABCMeta, abstractmethod from application.notification import NotificationCenter, NotificationData, IObserver from application.python.threadpool import ThreadPool, run_in_threadpool from application.python.types import MarkerType from application.system import FileExistsError, makedirs, openfile, unlink from itertools import count from msrplib.protocol import FailureReportHeader, SuccessReportHeader, ContentTypeHeader, IntegerHeaderType, MSRPNamedHeader, HeaderParsingError from msrplib.session import MSRPSession from msrplib.transport import make_response from queue import Queue from threading import Event, Lock -from zope.interface import implements +from zope.interface import implementer from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import SDPAttribute from sipsimple.storage import ISIPSimpleApplicationDataStorage from sipsimple.streams import InvalidStreamError, UnknownStreamError from sipsimple.streams.msrp import MSRPStreamBase from sipsimple.threading import run_in_twisted_thread, run_in_thread from sipsimple.util import sha1 HASH = type(hashlib.sha1()) class RandomID(metaclass=MarkerType): pass class FileSelectorHash(str): _hash_re = re.compile(r'^sha-1(:[0-9A-F]{2}){20}$') _byte_re = re.compile(r'..') def __new__(cls, value): if isinstance(value, str): if value.startswith('sha1:'): # backward compatibility hack (sort of). value = 'sha-1' + value[len('sha1'):] if not cls._hash_re.match(value): raise ValueError("Invalid hash value: {!r}".format(value)) return super(FileSelectorHash, cls).__new__(cls, value) elif isinstance(value, (HASH, sha1)): return super(FileSelectorHash, cls).__new__(cls, cls.encode_hash(value)) else: raise ValueError("Invalid hash value: {!r}".format(value)) def __eq__(self, other): if isinstance(other, str): return super(FileSelectorHash, self).__eq__(other) elif isinstance(other, (HASH, sha1)) and other.name.lower() == 'sha1': return super(FileSelectorHash, self).__eq__(self.encode_hash(other)) else: return NotImplemented def __ne__(self, other): return not self == other @classmethod def encode_hash(cls, hash_instance): if hash_instance.name.lower() != 'sha1': raise TypeError("Invalid hash type: {.name} (only sha1 hashes are supported).".format(hash_instance)) # unexpected as it may be, using a regular expression is the fastest method to do this return 'sha-1:' + ':'.join(cls._byte_re.findall(hash_instance.hexdigest().upper())) class FileSelector(object): _name_re = re.compile(r'name:"([^"]+)"') _size_re = re.compile(r'size:(\d+)') _type_re = re.compile(r'type:([^ ]+)') _hash_re = re.compile(r'hash:([^ ]+)') def __init__(self, name=None, type=None, size=None, hash=None, fd=None): # If present, hash should be a sha1 object or a string in the form: sha-1:72:24:5F:E8:65:3D:DA:F3:71:36:2F:86:D4:71:91:3E:E4:A2:CE:2E # According to the specification, only sha1 is supported ATM. self.name = name self.type = type self.size = size self.hash = hash self.fd = fd @property def hash(self): return self.__dict__['hash'] @hash.setter def hash(self, value): self.__dict__['hash'] = None if value is None else FileSelectorHash(value) @classmethod def parse(cls, string): name_match = cls._name_re.search(string) size_match = cls._size_re.search(string) type_match = cls._type_re.search(string) hash_match = cls._hash_re.search(string) name = name_match and name_match.group(1).decode('utf-8') size = size_match and int(size_match.group(1)) type = type_match and type_match.group(1) hash = hash_match and hash_match.group(1) return cls(name, type, size, hash) @classmethod def for_file(cls, path, type=None, hash=None): name = str(path) fd = open(name, 'rb') size = os.fstat(fd.fileno()).st_size if type is None: mime_type, encoding = mimetypes.guess_type(name) if encoding is not None: type = 'application/x-%s' % encoding elif mime_type is not None: type = mime_type else: type = 'application/octet-stream' return cls(name, type, size, hash, fd) @property def sdp_repr(self): items = [('name', self.name and '"%s"' % os.path.basename(self.name).encode('utf-8')), ('type', self.type), ('size', self.size), ('hash', self.hash)] return ' '.join('%s:%s' % (name, value) for name, value in items if value is not None) class UniqueFilenameGenerator(object): @classmethod def generate(cls, name): yield name prefix, extension = os.path.splitext(name) for x in count(1): yield "%s-%d%s" % (prefix, x, extension) class FileMetadataEntry(object): def __init__(self, hash, filename, partial_hash=None): self.hash = hash self.filename = filename self.mtime = os.path.getmtime(self.filename) self.partial_hash = partial_hash @classmethod def from_selector(cls, file_selector): return cls(file_selector.hash.lower(), file_selector.name) class FileTransfersMetadata(object): __filename__ = 'transfer_metadata' __lifetime__ = 60*60*24*7 def __init__(self): self.data = {} self.lock = Lock() self.loaded = False self.directory = None def _load(self): if self.loaded: return from sipsimple.application import SIPApplication if ISIPSimpleApplicationDataStorage.providedBy(SIPApplication.storage): self.directory = SIPApplication.storage.directory if self.directory is not None: try: with open(os.path.join(self.directory, self.__filename__), 'rb') as f: data = pickle.loads(f.read()) except Exception: data = {} now = time.time() for hash, entry in list(data.items()): try: mtime = os.path.getmtime(entry.filename) except OSError: data.pop(hash) else: if mtime != entry.mtime or now - mtime > self.__lifetime__: data.pop(hash) self.data.update(data) self.loaded = True @run_in_thread('file-io') def _save(self, data): if self.directory is not None: with open(os.path.join(self.directory, self.__filename__), 'wb') as f: f.write(data) def __enter__(self): self.lock.acquire() self._load() return self.data def __exit__(self, exc_type, exc_val, exc_tb): if None is exc_type is exc_val is exc_tb: self._save(pickle.dumps(self.data)) self.lock.release() +@implementer(IObserver) class FileTransferHandler(object, metaclass=ABCMeta): - implements(IObserver) threadpool = ThreadPool(name='FileTransfers', min_threads=0, max_threads=100) threadpool.start() def __init__(self): self.stream = None self.session = None self._started = False self._session_started = False self._initialize_done = False self._initialize_successful = False def initialize(self, stream, session): self.stream = stream self.session = session notification_center = NotificationCenter() notification_center.add_observer(self, sender=stream) notification_center.add_observer(self, sender=session) notification_center.add_observer(self, sender=self) @property def filename(self): return self.stream.file_selector.name if self.stream is not None else None @abstractmethod def start(self): raise NotImplementedError @abstractmethod def end(self): raise NotImplementedError @abstractmethod def process_chunk(self, chunk): raise NotImplementedError def __terminate(self): notification_center = NotificationCenter() notification_center.remove_observer(self, sender=self.stream) notification_center.remove_observer(self, sender=self.session) notification_center.remove_observer(self, sender=self) try: self.stream.file_selector.fd.close() except AttributeError: # when self.stream.file_selector.fd is None pass except IOError: # we can get this if we try to close while another thread is reading from it pass self.stream = None self.session = None @run_in_twisted_thread def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, None) if handler is not None: handler(notification) def _NH_MediaStreamDidNotInitialize(self, notification): if not self._initialize_done: self.end() self.__terminate() def _NH_MediaStreamDidStart(self, notification): self._started = True self.start() def _NH_MediaStreamWillEnd(self, notification): if self._started: self.end() elif self._session_started: notification.center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=True, reason='Refused')) def _NH_SIPSessionWillStart(self, notification): self._session_started = True def _NH_SIPSessionDidFail(self, notification): if not self._session_started and self._initialize_successful: if notification.data.code == 487: reason = 'Cancelled' else: reason = notification.data.reason or 'Failed' notification.center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=True, reason=reason)) def _NH_FileTransferHandlerDidInitialize(self, notification): self._initialize_done = True self._initialize_successful = True def _NH_FileTransferHandlerDidNotInitialize(self, notification): self._initialize_done = True self._initialize_successful = False def _NH_FileTransferHandlerDidEnd(self, notification): self.__terminate() class OffsetHeader(MSRPNamedHeader): name = 'Offset' type = IntegerHeaderType class EndTransfer(metaclass=MarkerType): pass class IncomingFileTransferHandler(FileTransferHandler): metadata = FileTransfersMetadata() def __init__(self): super(IncomingFileTransferHandler, self).__init__() self.hash = sha1() self.queue = Queue() self.offset = 0 self.received_chunks = 0 @property def save_directory(self): return self.__dict__.get('save_directory') @save_directory.setter def save_directory(self, value): if self.stream is not None: raise AttributeError('cannot set save_directory, transfer is in progress') self.__dict__['save_directory'] = value def initialize(self, stream, session): super(IncomingFileTransferHandler, self).initialize(stream, session) try: directory = self.save_directory or SIPSimpleSettings().file_transfer.directory.normalized makedirs(directory) with self.metadata as metadata: try: prev_file = metadata.pop(stream.file_selector.hash.lower()) mtime = os.path.getmtime(prev_file.filename) if mtime != prev_file.mtime: raise ValueError('file was modified') filename = os.path.join(directory, os.path.basename(stream.file_selector.name)) try: os.link(prev_file.filename, filename) except (AttributeError, OSError): stream.file_selector.name = prev_file.filename else: stream.file_selector.name = filename unlink(prev_file.filename) stream.file_selector.fd = openfile(stream.file_selector.name, 'ab') # open doesn't seek to END in append mode on win32 until first write, but openfile does self.offset = stream.file_selector.fd.tell() self.hash = prev_file.partial_hash except (KeyError, EnvironmentError, ValueError): for name in UniqueFilenameGenerator.generate(os.path.join(directory, os.path.basename(stream.file_selector.name))): try: stream.file_selector.fd = openfile(name, 'xb') except FileExistsError: continue else: stream.file_selector.name = name break except Exception as e: NotificationCenter().post_notification('FileTransferHandlerDidNotInitialize', sender=self, data=NotificationData(reason=str(e))) else: NotificationCenter().post_notification('FileTransferHandlerDidInitialize', sender=self) def end(self): self.queue.put(EndTransfer) def process_chunk(self, chunk): if chunk.method == 'SEND': if not self.received_chunks and chunk.byte_range.start == 1: self.stream.file_selector.fd.truncate(0) self.stream.file_selector.fd.seek(0) self.hash = sha1() self.offset = 0 self.received_chunks += 1 self.queue.put(chunk) elif chunk.method == 'FILE_OFFSET': if self.received_chunks > 0: response = make_response(chunk, 413, 'Unwanted message') else: offset = self.stream.file_selector.fd.tell() response = make_response(chunk, 200, 'OK') response.add_header(OffsetHeader(offset)) self.stream.msrp_session.send_chunk(response) @run_in_threadpool(FileTransferHandler.threadpool) def start(self): notification_center = NotificationCenter() notification_center.post_notification('FileTransferHandlerDidStart', sender=self) file_selector = self.stream.file_selector fd = file_selector.fd while True: chunk = self.queue.get() if chunk is EndTransfer: break try: fd.write(chunk.data) except EnvironmentError as e: fd.close() notification_center.post_notification('FileTransferHandlerError', sender=self, data=NotificationData(error=str(e))) notification_center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=True, reason=str(e))) return self.hash.update(chunk.data) self.offset += chunk.size transferred_bytes = chunk.byte_range.start + chunk.size - 1 total_bytes = file_selector.size = chunk.byte_range.total notification_center.post_notification('FileTransferHandlerProgress', sender=self, data=NotificationData(transferred_bytes=transferred_bytes, total_bytes=total_bytes)) if transferred_bytes == total_bytes: break fd.close() # Transfer is finished if self.offset != self.stream.file_selector.size: notification_center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=True, reason='Incomplete file')) return if self.hash != self.stream.file_selector.hash: unlink(self.filename) # something got corrupted, better delete the file notification_center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=True, reason='File hash mismatch')) return notification_center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=False, reason=None)) def _NH_MediaStreamDidNotInitialize(self, notification): if self.stream.file_selector.fd is not None: position = self.stream.file_selector.fd.tell() self.stream.file_selector.fd.close() if position == 0: unlink(self.stream.file_selector.name) super(IncomingFileTransferHandler, self)._NH_MediaStreamDidNotInitialize(notification) def _NH_FileTransferHandlerDidEnd(self, notification): if notification.data.error and self.stream.file_selector.hash is not None: if os.path.getsize(self.stream.file_selector.name) == 0: unlink(self.stream.file_selector.name) else: with self.metadata as metadata: entry = FileMetadataEntry.from_selector(self.stream.file_selector) entry.partial_hash = self.hash metadata[entry.hash] = entry super(IncomingFileTransferHandler, self)._NH_FileTransferHandlerDidEnd(notification) class OutgoingFileTransferHandler(FileTransferHandler): file_part_size = 64*1024 def __init__(self): super(OutgoingFileTransferHandler, self).__init__() self.stop_event = Event() self.finished_event = Event() self.file_offset_event = Event() self.message_id = '%x' % random.getrandbits(64) self.offset = 0 def initialize(self, stream, session): super(OutgoingFileTransferHandler, self).initialize(stream, session) if stream.file_selector.fd is None: NotificationCenter().post_notification('FileTransferHandlerDidNotInitialize', sender=self, data=NotificationData(reason='file descriptor not specified')) return if stream.file_selector.size == 0: NotificationCenter().post_notification('FileTransferHandlerDidNotInitialize', sender=self, data=NotificationData(reason='file is empty')) return if stream.file_selector.hash is None: self._calculate_file_hash() else: NotificationCenter().post_notification('FileTransferHandlerDidInitialize', sender=self) @run_in_threadpool(FileTransferHandler.threadpool) def _calculate_file_hash(self): file_hash = hashlib.sha1() processed = 0 notification_center = NotificationCenter() notification_center.post_notification('FileTransferHandlerHashProgress', sender=self, data=NotificationData(processed=0, total=self.stream.file_selector.size)) file_selector = self.stream.file_selector fd = file_selector.fd while not self.stop_event.is_set(): try: content = fd.read(self.file_part_size) except EnvironmentError as e: fd.close() notification_center.post_notification('FileTransferHandlerDidNotInitialize', sender=self, data=NotificationData(reason=str(e))) return if not content: file_selector.hash = file_hash notification_center.post_notification('FileTransferHandlerDidInitialize', sender=self) break file_hash.update(content) processed += len(content) notification_center.post_notification('FileTransferHandlerHashProgress', sender=self, data=NotificationData(processed=processed, total=file_selector.size)) else: fd.close() notification_center.post_notification('FileTransferHandlerDidNotInitialize', sender=self, data=NotificationData(reason='Interrupted transfer')) def end(self): self.stop_event.set() self.file_offset_event.set() # in case we are busy waiting on it @run_in_threadpool(FileTransferHandler.threadpool) def start(self): notification_center = NotificationCenter() notification_center.post_notification('FileTransferHandlerDidStart', sender=self) if self.stream.file_offset_supported: self._send_file_offset_chunk() self.file_offset_event.wait() finished = False failure_reason = None fd = self.stream.file_selector.fd fd.seek(self.offset) try: while not self.stop_event.is_set(): try: data = fd.read(self.file_part_size) except EnvironmentError as e: failure_reason = str(e) break if not data: finished = True break self._send_chunk(data) finally: fd.close() if not finished: notification_center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=True, reason=failure_reason or 'Interrupted transfer')) return # Wait until the stream ends or we get all reports self.stop_event.wait() if self.finished_event.is_set(): notification_center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=False, reason=None)) else: notification_center.post_notification('FileTransferHandlerDidEnd', sender=self, data=NotificationData(error=True, reason='Incomplete transfer')) def _on_transaction_response(self, response): if self.stop_event.is_set(): return if response.code != 200: NotificationCenter().post_notification('FileTransferHandlerError', sender=self, data=NotificationData(error=response.comment)) self.end() @run_in_twisted_thread def _send_chunk(self, data): if self.stop_event.is_set(): return data_len = len(data) chunk = self.stream.msrp.make_send_request(message_id=self.message_id, data=data, start=self.offset+1, end=self.offset+data_len, length=self.stream.file_selector.size) chunk.add_header(ContentTypeHeader(self.stream.file_selector.type)) chunk.add_header(SuccessReportHeader('yes')) chunk.add_header(FailureReportHeader('yes')) try: self.stream.msrp_session.send_chunk(chunk, response_cb=self._on_transaction_response) except Exception as e: NotificationCenter().post_notification('FileTransferHandlerError', sender=self, data=NotificationData(error=str(e))) else: self.offset += data_len @run_in_twisted_thread def _send_file_offset_chunk(self): def response_cb(response): if not self.stop_event.is_set() and response.code == 200: try: offset = response.headers['Offset'].decoded except (KeyError, HeaderParsingError): offset = 0 self.offset = offset self.file_offset_event.set() if self.stop_event.is_set(): self.file_offset_event.set() return chunk = self.stream.msrp.make_request('FILE_OFFSET') # TODO: _ is illegal in MSRP method names according to RFC 4975 try: self.stream.msrp_session.send_chunk(chunk, response_cb=response_cb) except Exception as e: NotificationCenter().post_notification('FileTransferHandlerError', sender=self, data=NotificationData(error=str(e))) def process_chunk(self, chunk): # here we process the REPORT chunks notification_center = NotificationCenter() if chunk.status.code == 200: transferred_bytes = chunk.byte_range.end total_bytes = chunk.byte_range.total notification_center.post_notification('FileTransferHandlerProgress', sender=self, data=NotificationData(transferred_bytes=transferred_bytes, total_bytes=total_bytes)) if transferred_bytes == total_bytes: self.finished_event.set() self.end() else: notification_center.post_notification('FileTransferHandlerError', sender=self, data=NotificationData(error=chunk.status.comment)) self.end() class FileTransferMSRPSession(MSRPSession): def _handle_incoming_FILE_OFFSET(self, chunk): self._on_incoming_cb(chunk) class FileTransferStream(MSRPStreamBase): type = 'file-transfer' priority = 10 msrp_session_class = FileTransferMSRPSession media_type = 'message' accept_types = ['*'] accept_wrapped_types = None IncomingTransferHandler = IncomingFileTransferHandler OutgoingTransferHandler = OutgoingFileTransferHandler def __init__(self, file_selector, direction, transfer_id=RandomID): if direction not in ('sendonly', 'recvonly'): raise ValueError("direction must be one of 'sendonly' or 'recvonly'") super(FileTransferStream, self).__init__(direction=direction) self.file_selector = file_selector self.transfer_id = transfer_id if transfer_id is not RandomID else str(uuid.uuid4()) if direction == 'sendonly': self.handler = self.OutgoingTransferHandler() else: self.handler = self.IncomingTransferHandler() @classmethod def new_from_sdp(cls, session, remote_sdp, stream_index): remote_stream = remote_sdp.media[stream_index] if remote_stream.media != 'message' or 'file-selector' not in remote_stream.attributes: raise UnknownStreamError expected_transport = 'TCP/TLS/MSRP' if session.account.msrp.transport == 'tls' else 'TCP/MSRP' if remote_stream.transport != expected_transport: raise InvalidStreamError("expected %s transport in file transfer stream, got %s" % (expected_transport, remote_stream.transport)) if remote_stream.formats != ['*']: raise InvalidStreamError("wrong format list specified") try: file_selector = FileSelector.parse(remote_stream.attributes.getfirst('file-selector')) except Exception as e: raise InvalidStreamError("error parsing file-selector: {}".format(e)) transfer_id = remote_stream.attributes.getfirst('file-transfer-id', None) if remote_stream.direction == 'sendonly': stream = cls(file_selector, 'recvonly', transfer_id) elif remote_stream.direction == 'recvonly': stream = cls(file_selector, 'sendonly', transfer_id) else: raise InvalidStreamError("wrong stream direction specified") stream.remote_role = remote_stream.attributes.getfirst('setup', 'active') return stream def initialize(self, session, direction): self._initialize_args = session, direction NotificationCenter().add_observer(self, sender=self.handler) self.handler.initialize(self, session) def _create_local_media(self, uri_path): local_media = super(FileTransferStream, self)._create_local_media(uri_path) local_media.attributes.append(SDPAttribute('file-selector', self.file_selector.sdp_repr)) local_media.attributes.append(SDPAttribute('x-file-offset', '')) if self.transfer_id is not None: local_media.attributes.append(SDPAttribute('file-transfer-id', self.transfer_id)) return local_media @property def file_offset_supported(self): try: return 'x-file-offset' in self.remote_media.attributes except AttributeError: return False @run_in_twisted_thread def _NH_FileTransferHandlerDidInitialize(self, notification): session, direction = self._initialize_args del self._initialize_args if not self._done: super(FileTransferStream, self).initialize(session, direction) @run_in_twisted_thread def _NH_FileTransferHandlerDidNotInitialize(self, notification): del self._initialize_args if not self._done: notification.center.post_notification('MediaStreamDidNotInitialize', sender=self, data=notification.data) @run_in_twisted_thread def _NH_FileTransferHandlerError(self, notification): self._failure_reason = notification.data.error notification.center.post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context='transferring', reason=self._failure_reason)) def _NH_MediaStreamDidNotInitialize(self, notification): notification.center.remove_observer(self, sender=self.handler) def _NH_MediaStreamWillEnd(self, notification): notification.center.remove_observer(self, sender=self.handler) def _handle_REPORT(self, chunk): # in theory, REPORT can come with Byte-Range which would limit the scope of the REPORT to the part of the message. self.handler.process_chunk(chunk) def _handle_SEND(self, chunk): notification_center = NotificationCenter() if chunk.size == 0: # keep-alive self.msrp_session.send_report(chunk, 200, 'OK') return if self.direction=='sendonly': self.msrp_session.send_report(chunk, 413, 'Unwanted Message') return if chunk.content_type.lower() == 'message/cpim': # In order to properly support the CPIM wrapper, msrplib needs to be refactored. -Luci self.msrp_session.send_report(chunk, 415, 'Invalid Content-Type') self._failure_reason = "CPIM wrapper is not supported" notification_center.post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context='reading', reason=self._failure_reason)) return try: self.msrp_session.send_report(chunk, 200, 'OK') except Exception: pass # Best effort approach: even if we couldn't send the REPORT keep writing the chunks, we might have them all -Saul self.handler.process_chunk(chunk) def _handle_FILE_OFFSET(self, chunk): if self.direction != 'recvonly': response = make_response(chunk, 413, 'Unwanted message') self.msrp_session.send_chunk(response) return self.handler.process_chunk(chunk) diff --git a/sipsimple/streams/msrp/screensharing.py b/sipsimple/streams/msrp/screensharing.py index 3cbfecaf..d991fedb 100644 --- a/sipsimple/streams/msrp/screensharing.py +++ b/sipsimple/streams/msrp/screensharing.py @@ -1,351 +1,351 @@ """ This module provides classes to parse and generate SDP related to SIP sessions that negotiate Screen Sharing. """ __all__ = ['ScreenSharingStream', 'VNCConnectionError', 'ScreenSharingHandler', 'ScreenSharingServerHandler', 'ScreenSharingViewerHandler', 'InternalVNCViewerHandler', 'InternalVNCServerHandler', 'ExternalVNCViewerHandler', 'ExternalVNCServerHandler'] from abc import ABCMeta, abstractmethod, abstractproperty from application.notification import NotificationCenter, NotificationData, IObserver from application.python.descriptor import WriteOnceAttribute from eventlib.coros import queue from eventlib.greenio import GreenSocket from eventlib.proc import spawn from eventlib.util import tcp_socket, set_reuse_addr from msrplib.protocol import FailureReportHeader, SuccessReportHeader, ContentTypeHeader from msrplib.transport import make_response, make_report from twisted.internet.error import ConnectionDone -from zope.interface import implements +from zope.interface import implementer from sipsimple.core import SDPAttribute from sipsimple.streams import InvalidStreamError, UnknownStreamError from sipsimple.streams.msrp import MSRPStreamBase from sipsimple.threading import run_in_twisted_thread class VNCConnectionError(Exception): pass +@implementer(IObserver) class ScreenSharingHandler(object, metaclass=ABCMeta): - implements(IObserver) def __init__(self): self.incoming_msrp_queue = None self.outgoing_msrp_queue = None self.msrp_reader_thread = None self.msrp_writer_thread = None def initialize(self, stream): self.incoming_msrp_queue = stream.incoming_queue self.outgoing_msrp_queue = stream.outgoing_queue NotificationCenter().add_observer(self, sender=stream) @abstractproperty def type(self): raise NotImplementedError @abstractmethod def _msrp_reader(self): raise NotImplementedError @abstractmethod def _msrp_writer(self): raise NotImplementedError def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, None) if handler is not None: handler(notification) def _NH_MediaStreamDidStart(self, notification): self.msrp_reader_thread = spawn(self._msrp_reader) self.msrp_writer_thread = spawn(self._msrp_writer) def _NH_MediaStreamWillEnd(self, notification): notification.center.remove_observer(self, sender=notification.sender) if self.msrp_reader_thread is not None: self.msrp_reader_thread.kill() self.msrp_reader_thread = None if self.msrp_writer_thread is not None: self.msrp_writer_thread.kill() self.msrp_writer_thread = None class ScreenSharingServerHandler(ScreenSharingHandler): type = property(lambda self: 'passive') class ScreenSharingViewerHandler(ScreenSharingHandler): type = property(lambda self: 'active') class InternalVNCViewerHandler(ScreenSharingViewerHandler): @run_in_twisted_thread def send(self, data): self.outgoing_msrp_queue.send(data) def _msrp_reader(self): notification_center = NotificationCenter() while True: data = self.incoming_msrp_queue.wait() notification_center.post_notification('ScreenSharingStreamGotData', sender=self, data=NotificationData(data=data)) def _msrp_writer(self): pass class InternalVNCServerHandler(ScreenSharingServerHandler): @run_in_twisted_thread def send(self, data): self.outgoing_msrp_queue.send(data) def _msrp_reader(self): notification_center = NotificationCenter() while True: data = self.incoming_msrp_queue.wait() notification_center.post_notification('ScreenSharingStreamGotData', sender=self, data=NotificationData(data=data)) def _msrp_writer(self): pass class ExternalVNCViewerHandler(ScreenSharingViewerHandler): address = ('localhost', 0) connect_timeout = 5 def __init__(self): super(ExternalVNCViewerHandler, self).__init__() self.vnc_starter_thread = None self.vnc_socket = GreenSocket(tcp_socket()) set_reuse_addr(self.vnc_socket) self.vnc_socket.settimeout(self.connect_timeout) self.vnc_socket.bind(self.address) self.vnc_socket.listen(1) self.address = self.vnc_socket.getsockname() def _msrp_reader(self): while True: try: data = self.incoming_msrp_queue.wait() self.vnc_socket.sendall(data) except Exception as e: self.msrp_reader_thread = None # avoid issues caused by the notification handler killing this greenlet during post_notification NotificationCenter().post_notification('ScreenSharingHandlerDidFail', sender=self, data=NotificationData(context='sending', reason=str(e))) break def _msrp_writer(self): while True: try: data = self.vnc_socket.recv(2048) if not data: raise VNCConnectionError("connection with the VNC viewer was closed") self.outgoing_msrp_queue.send(data) except Exception as e: self.msrp_writer_thread = None # avoid issues caused by the notification handler killing this greenlet during post_notification NotificationCenter().post_notification('ScreenSharingHandlerDidFail', sender=self, data=NotificationData(context='reading', reason=str(e))) break def _start_vnc_connection(self): try: sock, addr = self.vnc_socket.accept() self.vnc_socket.close() self.vnc_socket = sock self.vnc_socket.settimeout(None) except Exception as e: self.vnc_starter_thread = None # avoid issues caused by the notification handler killing this greenlet during post_notification NotificationCenter().post_notification('ScreenSharingHandlerDidFail', sender=self, data=NotificationData(context='connecting', reason=str(e))) else: self.msrp_reader_thread = spawn(self._msrp_reader) self.msrp_writer_thread = spawn(self._msrp_writer) finally: self.vnc_starter_thread = None def _NH_MediaStreamDidStart(self, notification): self.vnc_starter_thread = spawn(self._start_vnc_connection) def _NH_MediaStreamWillEnd(self, notification): if self.vnc_starter_thread is not None: self.vnc_starter_thread.kill() self.vnc_starter_thread = None super(ExternalVNCViewerHandler, self)._NH_MediaStreamWillEnd(notification) self.vnc_socket.close() class ExternalVNCServerHandler(ScreenSharingServerHandler): address = ('localhost', 5900) connect_timeout = 5 def __init__(self): super(ExternalVNCServerHandler, self).__init__() self.vnc_starter_thread = None self.vnc_socket = None def _msrp_reader(self): while True: try: data = self.incoming_msrp_queue.wait() self.vnc_socket.sendall(data) except Exception as e: self.msrp_reader_thread = None # avoid issues caused by the notification handler killing this greenlet during post_notification NotificationCenter().post_notification('ScreenSharingHandlerDidFail', sender=self, data=NotificationData(context='sending', reason=str(e))) break def _msrp_writer(self): while True: try: data = self.vnc_socket.recv(2048) if not data: raise VNCConnectionError("connection to the VNC server was closed") self.outgoing_msrp_queue.send(data) except Exception as e: self.msrp_writer_thread = None # avoid issues caused by the notification handler killing this greenlet during post_notification NotificationCenter().post_notification('ScreenSharingHandlerDidFail', sender=self, data=NotificationData(context='reading', reason=str(e))) break def _start_vnc_connection(self): try: self.vnc_socket = GreenSocket(tcp_socket()) self.vnc_socket.settimeout(self.connect_timeout) self.vnc_socket.connect(self.address) self.vnc_socket.settimeout(None) except Exception as e: self.vnc_starter_thread = None # avoid issues caused by the notification handler killing this greenlet during post_notification NotificationCenter().post_notification('ScreenSharingHandlerDidFail', sender=self, data=NotificationData(context='connecting', reason=str(e))) else: self.msrp_reader_thread = spawn(self._msrp_reader) self.msrp_writer_thread = spawn(self._msrp_writer) finally: self.vnc_starter_thread = None def _NH_MediaStreamDidStart(self, notification): self.vnc_starter_thread = spawn(self._start_vnc_connection) def _NH_MediaStreamWillEnd(self, notification): if self.vnc_starter_thread is not None: self.vnc_starter_thread.kill() self.vnc_starter_thread = None super(ExternalVNCServerHandler, self)._NH_MediaStreamWillEnd(notification) if self.vnc_socket is not None: self.vnc_socket.close() class ScreenSharingStream(MSRPStreamBase): type = 'screen-sharing' priority = 1 media_type = 'application' accept_types = ['application/x-rfb'] accept_wrapped_types = None ServerHandler = InternalVNCServerHandler ViewerHandler = InternalVNCViewerHandler handler = WriteOnceAttribute() def __init__(self, mode): if mode not in ('viewer', 'server'): raise ValueError("mode should be 'viewer' or 'server' not '%s'" % mode) super(ScreenSharingStream, self).__init__(direction='sendrecv') self.handler = self.ViewerHandler() if mode=='viewer' else self.ServerHandler() self.incoming_queue = queue() self.outgoing_queue = queue() self.msrp_reader_thread = None self.msrp_writer_thread = None @classmethod def new_from_sdp(cls, session, remote_sdp, stream_index): remote_stream = remote_sdp.media[stream_index] if remote_stream.media != 'application': raise UnknownStreamError accept_types = remote_stream.attributes.getfirst('accept-types', None) if accept_types is None or 'application/x-rfb' not in accept_types.split(): raise UnknownStreamError expected_transport = 'TCP/TLS/MSRP' if session.account.msrp.transport=='tls' else 'TCP/MSRP' if remote_stream.transport != expected_transport: raise InvalidStreamError("expected %s transport in chat stream, got %s" % (expected_transport, remote_stream.transport)) if remote_stream.formats != ['*']: raise InvalidStreamError("wrong format list specified") remote_rfbsetup = remote_stream.attributes.getfirst('rfbsetup', 'active') if remote_rfbsetup == 'active': stream = cls(mode='server') elif remote_rfbsetup == 'passive': stream = cls(mode='viewer') else: raise InvalidStreamError("unknown rfbsetup attribute in the remote screen sharing stream") stream.remote_role = remote_stream.attributes.getfirst('setup', 'active') return stream def _create_local_media(self, uri_path): local_media = super(ScreenSharingStream, self)._create_local_media(uri_path) local_media.attributes.append(SDPAttribute('rfbsetup', self.handler.type)) return local_media def _msrp_reader(self): while True: try: chunk = self.msrp.read_chunk() if chunk.method in (None, 'REPORT'): continue elif chunk.method == 'SEND': if chunk.content_type in self.accept_types: self.incoming_queue.send(chunk.data) response = make_response(chunk, 200, 'OK') report = make_report(chunk, 200, 'OK') else: response = make_response(chunk, 415, 'Invalid Content-Type') report = None else: response = make_response(chunk, 501, 'Unknown method') report = None if response is not None: self.msrp.write_chunk(response) if report is not None: self.msrp.write_chunk(report) except Exception as e: self.msrp_reader_thread = None # avoid issues caused by the notification handler killing this greenlet during post_notification if self.shutting_down and isinstance(e, ConnectionDone): break self._failure_reason = str(e) NotificationCenter().post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context='reading', reason=self._failure_reason)) break def _msrp_writer(self): while True: try: data = self.outgoing_queue.wait() chunk = self.msrp.make_send_request(data=data) chunk.add_header(SuccessReportHeader('no')) chunk.add_header(FailureReportHeader('partial')) chunk.add_header(ContentTypeHeader('application/x-rfb')) self.msrp.write_chunk(chunk) except Exception as e: self.msrp_writer_thread = None # avoid issues caused by the notification handler killing this greenlet during post_notification if self.shutting_down and isinstance(e, ConnectionDone): break self._failure_reason = str(e) NotificationCenter().post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context='sending', reason=self._failure_reason)) break def _NH_MediaStreamDidInitialize(self, notification): notification.center.add_observer(self, sender=self.handler) self.handler.initialize(self) def _NH_MediaStreamDidStart(self, notification): self.msrp_reader_thread = spawn(self._msrp_reader) self.msrp_writer_thread = spawn(self._msrp_writer) def _NH_MediaStreamWillEnd(self, notification): notification.center.remove_observer(self, sender=self.handler) if self.msrp_reader_thread is not None: self.msrp_reader_thread.kill() self.msrp_reader_thread = None if self.msrp_writer_thread is not None: self.msrp_writer_thread.kill() self.msrp_writer_thread = None def _NH_ScreenSharingHandlerDidFail(self, notification): self._failure_reason = notification.data.reason notification.center.post_notification('MediaStreamDidFail', sender=self, data=notification.data) diff --git a/sipsimple/streams/rtp/__init__.py b/sipsimple/streams/rtp/__init__.py index ba1d24a1..29e12440 100644 --- a/sipsimple/streams/rtp/__init__.py +++ b/sipsimple/streams/rtp/__init__.py @@ -1,617 +1,617 @@ """ Handling of RTP media streams according to RFC3550, RFC3605, RFC3581, RFC2833 and RFC3711, RFC3489 and RFC5245. """ __all__ = ['RTPStream'] import weakref from abc import ABCMeta, abstractmethod from application.notification import IObserver, NotificationCenter, NotificationData, ObserverWeakrefProxy from application.python import Null from threading import RLock -from zope.interface import implements +from zope.interface import implementer from sipsimple.account import BonjourAccount from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import RTPTransport, SIPCoreError, SIPURI from sipsimple.lookup import DNSLookup from sipsimple.streams import IMediaStream, InvalidStreamError, MediaStreamType, UnknownStreamError from sipsimple.threading import run_in_thread +@implementer(IObserver) class ZRTPStreamOptions(object): - implements(IObserver) def __init__(self, stream): self._stream = stream self.__dict__['master'] = None self.__dict__['sas'] = None self.__dict__['verified'] = False self.__dict__['peer_name'] = '' @property def sas(self): if self.master is not None: return self.master.encryption.zrtp.sas return self.__dict__['sas'] @property def verified(self): if self.master is not None: return self.master.encryption.zrtp.verified return self.__dict__['verified'] @verified.setter def verified(self, verified): if self.__dict__['verified'] == verified: return if self.sas is None: raise AttributeError('Cannot verify peer before SAS is received') if self.master is not None: self.master.encryption.zrtp.verified = verified else: rtp_transport = self._stream._rtp_transport if rtp_transport is not None: @run_in_thread('file-io') def update_verified(rtp_transport, verified): rtp_transport.set_zrtp_sas_verified(verified) notification_center = NotificationCenter() notification_center.post_notification('RTPStreamZRTPVerifiedStateChanged', sender=self._stream, data=NotificationData(verified=verified)) self.__dict__['verified'] = verified update_verified(rtp_transport, verified) @property def peer_id(self): if self.master is not None: return self.master.encryption.zrtp.peer_id rtp_transport = self._stream._rtp_transport if rtp_transport is None: return None return rtp_transport.zrtp_peer_id @property def peer_name(self): if self.master is not None: return self.master.encryption.zrtp.peer_name return self.__dict__['peer_name'] @peer_name.setter def peer_name(self, name): if self.__dict__['peer_name'] == name: return if self.master is not None: self.master.encryption.zrtp.peer_name = name else: rtp_transport = self._stream._rtp_transport if rtp_transport is not None: @run_in_thread('file-io') def update_name(rtp_transport, name): rtp_transport.zrtp_peer_name = name notification_center = NotificationCenter() notification_center.post_notification('RTPStreamZRTPPeerNameChanged', sender=self._stream, data=NotificationData(name=name)) self.__dict__['peer_name'] = name update_name(rtp_transport, name) @property def master(self): return self.__dict__['master'] @master.setter def master(self, master): old_master = self.__dict__['master'] if old_master is master: return notification_center = NotificationCenter() if old_master is not None: notification_center.remove_observer(self, sender=old_master) if master is not None: notification_center.add_observer(self, sender=master) self.__dict__['master'] = master def _enable(self, master_stream=None): rtp_transport = self._stream._rtp_transport if rtp_transport is None: return if master_stream is not None and not (master_stream.encryption.active and master_stream.encryption.type == 'ZRTP'): raise RuntimeError('Master stream must have ZRTP encryption activated') rtp_transport.set_zrtp_enabled(True, master_stream) self.master = master_stream def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_RTPStreamZRTPReceivedSAS(self, notification): # ZRTP begins on the audio stream, so this notification will only be processed # by the other streams self.__dict__['sas'] = notification.data.sas self.__dict__['verified'] = notification.data.verified self.__dict__['peer_name'] = notification.data.peer_name notification.center.post_notification(notification.name, sender=self._stream, data=notification.data) def _NH_RTPStreamZRTPVerifiedStateChanged(self, notification): self.__dict__['verified'] = notification.data.verified notification.center.post_notification(notification.name, sender=self._stream, data=notification.data) def _NH_RTPStreamZRTPPeerNameChanged(self, notification): self.__dict__['peer_name'] = notification.data.name notification.center.post_notification(notification.name, sender=self._stream, data=notification.data) def _NH_MediaStreamDidEnd(self, notification): self.master = None +@implementer(IObserver) class RTPStreamEncryption(object): - implements(IObserver) def __init__(self, stream): self._stream_ref = weakref.ref(stream) # Keep a weak reference before the stream is initialized to avoid a memory cycle that would delay releasing audio resources self._stream = None # We will store the actual reference once it's initialized and we're guaranteed to get MediaStreamDidEnd and do the cleanup self._rtp_transport = None self.__dict__['type'] = None self.__dict__['zrtp'] = None notification_center = NotificationCenter() notification_center.add_observer(ObserverWeakrefProxy(self), name='MediaStreamDidInitialize') notification_center.add_observer(ObserverWeakrefProxy(self), name='MediaStreamDidNotInitialize') @property def active(self): rtp_transport = self._rtp_transport if rtp_transport is None: return False if self.type == 'SRTP/SDES': return rtp_transport.srtp_active elif self.type == 'ZRTP': return rtp_transport.zrtp_active return False @property def cipher(self): rtp_transport = self._rtp_transport if rtp_transport is None: return None if self.type == 'SRTP/SDES': return rtp_transport.srtp_cipher elif self.type == 'ZRTP': return rtp_transport.zrtp_cipher return None @property def type(self): return self.__dict__['type'] @property def zrtp(self): zrtp = self.__dict__['zrtp'] if zrtp is None: raise RuntimeError('ZRTP options have not been initialized') return zrtp def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_MediaStreamDidInitialize(self, notification): stream = notification.sender if stream is self._stream_ref(): self._stream = stream self._rtp_transport = stream._rtp_transport notification.center.remove_observer(ObserverWeakrefProxy(self), name='MediaStreamDidInitialize') notification.center.remove_observer(ObserverWeakrefProxy(self), name='MediaStreamDidNotInitialize') notification.center.add_observer(self, sender=self._stream) notification.center.add_observer(self, sender=self._rtp_transport) encryption = stream._srtp_encryption or '' if encryption.startswith('sdes'): self.__dict__['type'] = 'SRTP/SDES' elif encryption == 'zrtp': self.__dict__['type'] = 'ZRTP' self.__dict__['zrtp'] = ZRTPStreamOptions(stream) def _NH_MediaStreamDidNotInitialize(self, notification): if notification.sender is self._stream_ref(): notification.center.remove_observer(ObserverWeakrefProxy(self), name='MediaStreamDidInitialize') notification.center.remove_observer(ObserverWeakrefProxy(self), name='MediaStreamDidNotInitialize') self._stream_ref = None self._stream = None def _NH_MediaStreamDidStart(self, notification): if self.type == 'SRTP/SDES': stream = notification.sender if self.active: notification.center.post_notification('RTPStreamDidEnableEncryption', sender=stream) else: reason = 'Not supported by remote' notification.center.post_notification('RTPStreamDidNotEnableEncryption', sender=stream, data=NotificationData(reason=reason)) def _NH_MediaStreamDidEnd(self, notification): notification.center.remove_observer(self, sender=self._stream) notification.center.remove_observer(self, sender=self._rtp_transport) self._stream = None self._stream_ref = None self._rtp_transport = None self.__dict__['type'] = None self.__dict__['zrtp'] = None def _NH_RTPTransportZRTPSecureOn(self, notification): stream = self._stream with stream._lock: if stream.state == "ENDED": return notification.center.post_notification('RTPStreamDidEnableEncryption', sender=stream) def _NH_RTPTransportZRTPSecureOff(self, notification): # We should never get here because we don't allow disabling encryption -Saul pass def _NH_RTPTransportZRTPReceivedSAS(self, notification): stream = self._stream with stream._lock: if stream.state == "ENDED": return self.zrtp.__dict__['sas'] = sas = notification.data.sas self.zrtp.__dict__['verified'] = verified = notification.data.verified self.zrtp.__dict__['peer_name'] = peer_name = notification.sender.zrtp_peer_name notification.center.post_notification('RTPStreamZRTPReceivedSAS', sender=stream, data=NotificationData(sas=sas, verified=verified, peer_name=peer_name)) def _NH_RTPTransportZRTPLog(self, notification): stream = self._stream with stream._lock: if stream.state == "ENDED": return notification.center.post_notification('RTPStreamZRTPLog', sender=stream, data=notification.data) def _NH_RTPTransportZRTPNegotiationFailed(self, notification): stream = self._stream with stream._lock: if stream.state == "ENDED": return reason = 'Negotiation failed: %s' % notification.data.reason notification.center.post_notification('RTPStreamDidNotEnableEncryption', sender=stream, data=NotificationData(reason=reason)) def _NH_RTPTransportZRTPNotSupportedByRemote(self, notification): stream = self._stream with stream._lock: if stream.state == "ENDED": return reason = 'ZRTP not supported by remote' notification.center.post_notification('RTPStreamDidNotEnableEncryption', sender=stream, data=NotificationData(reason=reason)) class RTPStreamType(ABCMeta, MediaStreamType): pass +@implementer(IMediaStream, IObserver) class RTPStream(object, metaclass=RTPStreamType): - implements(IMediaStream, IObserver) type = None priority = None hold_supported = True def __init__(self): self.notification_center = NotificationCenter() self.on_hold_by_local = False self.on_hold_by_remote = False self.direction = None self.state = "NULL" self.session = None self.encryption = RTPStreamEncryption(self) self._transport = None self._hold_request = None self._ice_state = "NULL" self._lock = RLock() self._rtp_transport = None self._try_ice = False self._srtp_encryption = None self._remote_rtp_address_sdp = None self._remote_rtp_port_sdp = None self._initialized = False self._done = False self._failure_reason = None @property def codec(self): return self._transport.codec if self._transport else None @property def sample_rate(self): return self._transport.sample_rate if self._transport else None @property def statistics(self): return self._transport.statistics if self._transport else None @property def local_rtp_address(self): return self._rtp_transport.local_rtp_address if self._rtp_transport else None @property def local_rtp_port(self): return self._rtp_transport.local_rtp_port if self._rtp_transport else None @property def local_rtp_candidate(self): return self._rtp_transport.local_rtp_candidate if self._rtp_transport else None @property def remote_rtp_address(self): if self._ice_state == "IN_USE": return self._rtp_transport.remote_rtp_address if self._rtp_transport else None return self._remote_rtp_address_sdp if self._rtp_transport else None @property def remote_rtp_port(self): if self._ice_state == "IN_USE": return self._rtp_transport.remote_rtp_port if self._rtp_transport else None return self._remote_rtp_port_sdp if self._rtp_transport else None @property def remote_rtp_candidate(self): return self._rtp_transport.remote_rtp_candidate if self._rtp_transport else None @property def ice_active(self): return self._ice_state == "IN_USE" @property def on_hold(self): return self.on_hold_by_local or self.on_hold_by_remote @abstractmethod def start(self, local_sdp, remote_sdp, stream_index): raise NotImplementedError @abstractmethod def update(self, local_sdp, remote_sdp, stream_index): raise NotImplementedError @abstractmethod def validate_update(self, remote_sdp, stream_index): raise NotImplementedError @abstractmethod def deactivate(self): raise NotImplementedError @abstractmethod def end(self): raise NotImplementedError @abstractmethod def reset(self, stream_index): raise NotImplementedError def hold(self): with self._lock: if self.on_hold_by_local or self._hold_request == 'hold': return if self.state == "ESTABLISHED" and self.direction != "inactive": self._pause() self._hold_request = 'hold' def unhold(self): with self._lock: if (not self.on_hold_by_local and self._hold_request != 'hold') or self._hold_request == 'unhold': return if self.state == "ESTABLISHED" and self._hold_request == 'hold': self._resume() self._hold_request = None if self._hold_request == 'hold' else 'unhold' @classmethod def new_from_sdp(cls, session, remote_sdp, stream_index): # TODO: actually validate the SDP settings = SIPSimpleSettings() remote_stream = remote_sdp.media[stream_index] if remote_stream.media != cls.type: raise UnknownStreamError if remote_stream.transport not in ('RTP/AVP', 'RTP/SAVP'): raise InvalidStreamError("expected RTP/AVP or RTP/SAVP transport in %s stream, got %s" % (cls.type, remote_stream.transport)) local_encryption_policy = session.account.rtp.encryption.key_negotiation if session.account.rtp.encryption.enabled else None if local_encryption_policy == "sdes_mandatory" and not "crypto" in remote_stream.attributes: raise InvalidStreamError("SRTP/SDES is locally mandatory but it's not remotely enabled") if remote_stream.transport == 'RTP/SAVP' and "crypto" in remote_stream.attributes and local_encryption_policy not in ("opportunistic", "sdes_optional", "sdes_mandatory"): raise InvalidStreamError("SRTP/SDES is remotely mandatory but it's not locally enabled") account_preferred_codecs = getattr(session.account.rtp, '%s_codec_list' % cls.type) general_codecs = getattr(settings.rtp, '%s_codec_list' % cls.type) supported_codecs = account_preferred_codecs or general_codecs if not any(codec for codec in remote_stream.codec_list if codec in supported_codecs): raise InvalidStreamError("no compatible codecs found") stream = cls() stream._incoming_remote_sdp = remote_sdp stream._incoming_stream_index = stream_index return stream def initialize(self, session, direction): with self._lock: if self.state != "NULL": raise RuntimeError("%sStream.initialize() may only be called in the NULL state" % self.type.capitalize()) self.state = "INITIALIZING" self.session = session local_encryption_policy = session.account.rtp.encryption.key_negotiation if session.account.rtp.encryption.enabled else None if hasattr(self, "_incoming_remote_sdp") and hasattr(self, '_incoming_stream_index'): # ICE attributes could come at the session level or at the media level remote_stream = self._incoming_remote_sdp.media[self._incoming_stream_index] self._try_ice = self.session.account.nat_traversal.use_ice and ((remote_stream.has_ice_attributes or self._incoming_remote_sdp.has_ice_attributes) and remote_stream.has_ice_candidates) if "zrtp-hash" in remote_stream.attributes: incoming_stream_encryption = 'zrtp' elif "crypto" in remote_stream.attributes: incoming_stream_encryption = 'sdes_mandatory' if remote_stream.transport == 'RTP/SAVP' else 'sdes_optional' else: incoming_stream_encryption = None if incoming_stream_encryption is not None and local_encryption_policy == 'opportunistic': self._srtp_encryption = incoming_stream_encryption else: self._srtp_encryption = 'zrtp' if local_encryption_policy == 'opportunistic' else local_encryption_policy else: self._try_ice = self.session.account.nat_traversal.use_ice self._srtp_encryption = 'zrtp' if local_encryption_policy == 'opportunistic' else local_encryption_policy if self._try_ice: if self.session.account.nat_traversal.stun_server_list: stun_servers = list((server.host, server.port) for server in self.session.account.nat_traversal.stun_server_list) self._init_rtp_transport(stun_servers) elif not isinstance(self.session.account, BonjourAccount): dns_lookup = DNSLookup() self.notification_center.add_observer(self, sender=dns_lookup) dns_lookup.lookup_service(SIPURI(self.session.account.id.domain), "stun") else: self._init_rtp_transport() def get_local_media(self, remote_sdp=None, index=0): with self._lock: if self.state not in ("INITIALIZED", "WAIT_ICE", "ESTABLISHED"): raise RuntimeError("%sStream.get_local_media() may only be called in the INITIALIZED, WAIT_ICE or ESTABLISHED states" % self.type.capitalize()) if remote_sdp is None: # offer old_direction = self._transport.direction if old_direction is None: new_direction = "sendrecv" elif "send" in old_direction: new_direction = ("sendonly" if (self._hold_request == 'hold' or (self._hold_request is None and self.on_hold_by_local)) else "sendrecv") else: new_direction = ("inactive" if (self._hold_request == 'hold' or (self._hold_request is None and self.on_hold_by_local)) else "recvonly") else: new_direction = None return self._transport.get_local_media(remote_sdp, index, new_direction) # Notifications def handle_notification(self, notification): handler = getattr(self, '_NH_%s' % notification.name, Null) handler(notification) def _NH_DNSLookupDidFail(self, notification): self.notification_center.remove_observer(self, sender=notification.sender) with self._lock: if self.state == "ENDED": return self._init_rtp_transport() def _NH_DNSLookupDidSucceed(self, notification): self.notification_center.remove_observer(self, sender=notification.sender) with self._lock: if self.state == "ENDED": return self._init_rtp_transport(notification.data.result) def _NH_RTPTransportDidInitialize(self, notification): rtp_transport = notification.sender with self._lock: if self.state == "ENDED": self.notification_center.remove_observer(self, sender=rtp_transport) return del self._rtp_args del self._stun_servers remote_sdp = self.__dict__.pop('_incoming_remote_sdp', None) stream_index = self.__dict__.pop('_incoming_stream_index', None) try: if remote_sdp is not None: transport = self._create_transport(rtp_transport, remote_sdp=remote_sdp, stream_index=stream_index) self._save_remote_sdp_rtp_info(remote_sdp, stream_index) else: transport = self._create_transport(rtp_transport) except SIPCoreError as e: self.state = "ENDED" self.notification_center.remove_observer(self, sender=rtp_transport) self.notification_center.post_notification('MediaStreamDidNotInitialize', sender=self, data=NotificationData(reason=e.args[0])) return self._rtp_transport = rtp_transport self._transport = transport self.notification_center.add_observer(self, sender=transport) self._initialized = True self.state = "INITIALIZED" self.notification_center.post_notification('MediaStreamDidInitialize', sender=self) def _NH_RTPTransportDidFail(self, notification): self.notification_center.remove_observer(self, sender=notification.sender) with self._lock: if self.state == "ENDED": return self._try_next_rtp_transport(notification.data.reason) def _NH_RTPTransportICENegotiationStateDidChange(self, notification): with self._lock: if self._ice_state != "NULL" or self.state not in ("INITIALIZING", "INITIALIZED", "WAIT_ICE"): return self.notification_center.post_notification('RTPStreamICENegotiationStateDidChange', sender=self, data=notification.data) def _NH_RTPTransportICENegotiationDidSucceed(self, notification): with self._lock: if self.state != "WAIT_ICE": return self._ice_state = "IN_USE" self.state = 'ESTABLISHED' self.notification_center.post_notification('RTPStreamICENegotiationDidSucceed', sender=self, data=notification.data) self.notification_center.post_notification('MediaStreamDidStart', sender=self) def _NH_RTPTransportICENegotiationDidFail(self, notification): with self._lock: if self.state != "WAIT_ICE": return self._ice_state = "FAILED" self.state = 'ESTABLISHED' self.notification_center.post_notification('RTPStreamICENegotiationDidFail', sender=self, data=notification.data) self.notification_center.post_notification('MediaStreamDidStart', sender=self) # Private methods def _init_rtp_transport(self, stun_servers=None): self._rtp_args = dict() self._rtp_args["encryption"] = self._srtp_encryption self._rtp_args["use_ice"] = self._try_ice self._stun_servers = [(None, None)] if stun_servers: self._stun_servers.extend(reversed(stun_servers)) self._try_next_rtp_transport() def _try_next_rtp_transport(self, failure_reason=None): if self._stun_servers: stun_address, stun_port = self._stun_servers.pop() try: rtp_transport = RTPTransport(ice_stun_address=stun_address, ice_stun_port=stun_port, **self._rtp_args) except SIPCoreError as e: self._try_next_rtp_transport(e.args[0]) else: self.notification_center.add_observer(self, sender=rtp_transport) try: rtp_transport.set_INIT() except SIPCoreError as e: self.notification_center.remove_observer(self, sender=rtp_transport) self._try_next_rtp_transport(e.args[0]) else: self.state = "ENDED" self.notification_center.post_notification('MediaStreamDidNotInitialize', sender=self, data=NotificationData(reason=failure_reason)) def _save_remote_sdp_rtp_info(self, remote_sdp, index): connection = remote_sdp.media[index].connection or remote_sdp.connection self._remote_rtp_address_sdp = connection.address self._remote_rtp_port_sdp = remote_sdp.media[index].port @abstractmethod def _create_transport(self, rtp_transport, remote_sdp=None, stream_index=None): raise NotImplementedError @abstractmethod def _check_hold(self, direction, is_initial): raise NotImplementedError @abstractmethod def _pause(self): raise NotImplementedError @abstractmethod def _resume(self): raise NotImplementedError from sipsimple.streams.rtp import audio, video diff --git a/sipsimple/streams/rtp/audio.py b/sipsimple/streams/rtp/audio.py index 40051c7d..aafc129c 100644 --- a/sipsimple/streams/rtp/audio.py +++ b/sipsimple/streams/rtp/audio.py @@ -1,236 +1,236 @@ __all__ = ['AudioStream'] from application.notification import NotificationCenter, NotificationData -from zope.interface import implements +from zope.interface import implementer from sipsimple.audio import AudioBridge, AudioDevice, IAudioPort, WaveRecorder from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import AudioTransport, PJSIPError, SIPCoreError from sipsimple.streams.rtp import RTPStream +@implementer(IAudioPort) class AudioStream(RTPStream): - implements(IAudioPort) type = 'audio' priority = 1 def __init__(self): super(AudioStream, self).__init__() from sipsimple.application import SIPApplication self.mixer = SIPApplication.voice_audio_mixer self.bridge = AudioBridge(self.mixer) self.device = AudioDevice(self.mixer) self._audio_rec = None self.bridge.add(self.device) @property def muted(self): return self.__dict__.get('muted', False) @muted.setter def muted(self, value): if not isinstance(value, bool): raise ValueError("illegal value for muted property: %r" % (value,)) if value == self.muted: return old_producer_slot = self.producer_slot self.__dict__['muted'] = value notification_center = NotificationCenter() data = NotificationData(consumer_slot_changed=False, producer_slot_changed=True, old_producer_slot=old_producer_slot, new_producer_slot=self.producer_slot) notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=data) @property def consumer_slot(self): return self._transport.slot if self._transport else None @property def producer_slot(self): return self._transport.slot if self._transport and not self.muted else None @property def recorder(self): return self._audio_rec def start(self, local_sdp, remote_sdp, stream_index): with self._lock: if self.state != "INITIALIZED": raise RuntimeError("AudioStream.start() may only be called in the INITIALIZED state") settings = SIPSimpleSettings() self._transport.start(local_sdp, remote_sdp, stream_index, timeout=settings.rtp.timeout) self._save_remote_sdp_rtp_info(remote_sdp, stream_index) self._check_hold(self._transport.direction, True) if self._try_ice and self._ice_state == "NULL": self.state = 'WAIT_ICE' else: self.state = 'ESTABLISHED' self.notification_center.post_notification('MediaStreamDidStart', sender=self) def validate_update(self, remote_sdp, stream_index): with self._lock: # TODO: implement return True def update(self, local_sdp, remote_sdp, stream_index): with self._lock: connection = remote_sdp.media[stream_index].connection or remote_sdp.connection if not self._rtp_transport.ice_active and (connection.address != self._remote_rtp_address_sdp or self._remote_rtp_port_sdp != remote_sdp.media[stream_index].port): settings = SIPSimpleSettings() if self._audio_rec is not None: self.bridge.remove(self._audio_rec) old_consumer_slot = self.consumer_slot old_producer_slot = self.producer_slot self.notification_center.remove_observer(self, sender=self._transport) self._transport.stop() try: self._transport = AudioTransport(self.mixer, self._rtp_transport, remote_sdp, stream_index, codecs=list(self.session.account.rtp.audio_codec_list or settings.rtp.audio_codec_list)) except SIPCoreError as e: self.state = "ENDED" self._failure_reason = e.args[0] self.notification_center.post_notification('MediaStreamDidFail', sender=self, data=NotificationData(context='update', reason=self._failure_reason)) return self.notification_center.add_observer(self, sender=self._transport) self._transport.start(local_sdp, remote_sdp, stream_index, timeout=settings.rtp.timeout) self.notification_center.post_notification('AudioPortDidChangeSlots', sender=self, data=NotificationData(consumer_slot_changed=True, producer_slot_changed=True, old_consumer_slot=old_consumer_slot, new_consumer_slot=self.consumer_slot, old_producer_slot=old_producer_slot, new_producer_slot=self.producer_slot)) if connection.address == '0.0.0.0' and remote_sdp.media[stream_index].direction == 'sendrecv': self._transport.update_direction('recvonly') self._check_hold(self._transport.direction, False) self.notification_center.post_notification('RTPStreamDidChangeRTPParameters', sender=self) else: new_direction = local_sdp.media[stream_index].direction self._transport.update_direction(new_direction) self._check_hold(new_direction, False) self._save_remote_sdp_rtp_info(remote_sdp, stream_index) self._transport.update_sdp(local_sdp, remote_sdp, stream_index) self._hold_request = None def deactivate(self): with self._lock: self.bridge.stop() def end(self): with self._lock: if self.state == "ENDED" or self._done: return self._done = True if not self._initialized: self.state = "ENDED" self.notification_center.post_notification('MediaStreamDidNotInitialize', sender=self, data=NotificationData(reason='Interrupted')) return self.notification_center.post_notification('MediaStreamWillEnd', sender=self) if self._transport is not None: if self._audio_rec is not None: self._stop_recording() self.notification_center.remove_observer(self, sender=self._transport) self.notification_center.remove_observer(self, sender=self._rtp_transport) self._transport.stop() self._transport = None self._rtp_transport = None self.state = "ENDED" self.notification_center.post_notification('MediaStreamDidEnd', sender=self, data=NotificationData(error=self._failure_reason)) self.session = None def reset(self, stream_index): with self._lock: if self.direction == "inactive" and not self.on_hold_by_local: new_direction = "sendrecv" self._transport.update_direction(new_direction) self._check_hold(new_direction, False) # TODO: do a full reset, re-creating the AudioTransport, so that a new offer # would contain all codecs and ICE would be renegotiated -Saul def send_dtmf(self, digit): with self._lock: if self.state != "ESTABLISHED": raise RuntimeError("AudioStream.send_dtmf() cannot be used in %s state" % self.state) try: self._transport.send_dtmf(digit) except PJSIPError as e: if not e.args[0].endswith("(PJ_ETOOMANY)"): raise def start_recording(self, filename): with self._lock: if self.state == "ENDED": raise RuntimeError("AudioStream.start_recording() may not be called in the ENDED state") if self._audio_rec is not None: raise RuntimeError("Already recording audio to a file") self._audio_rec = WaveRecorder(self.mixer, filename) if self.state == "ESTABLISHED": self._check_recording() def stop_recording(self): with self._lock: if self._audio_rec is None: raise RuntimeError("Not recording any audio") self._stop_recording() def _NH_RTPAudioStreamGotDTMF(self, notification): notification.center.post_notification('AudioStreamGotDTMF', sender=self, data=NotificationData(digit=notification.data.digit)) def _NH_RTPAudioTransportDidTimeout(self, notification): notification.center.post_notification('RTPStreamDidTimeout', sender=self) # Private methods # def _create_transport(self, rtp_transport, remote_sdp=None, stream_index=None): settings = SIPSimpleSettings() codecs = list(self.session.account.rtp.audio_codec_list or settings.rtp.audio_codec_list) return AudioTransport(self.mixer, rtp_transport, remote_sdp=remote_sdp, sdp_index=stream_index or 0, codecs=codecs) def _check_hold(self, direction, is_initial): was_on_hold_by_local = self.on_hold_by_local was_on_hold_by_remote = self.on_hold_by_remote was_inactive = self.direction == "inactive" self.direction = direction inactive = self.direction == "inactive" self.on_hold_by_local = was_on_hold_by_local if inactive else direction == "sendonly" self.on_hold_by_remote = "send" not in direction if (is_initial or was_on_hold_by_local or was_inactive) and not inactive and not self.on_hold_by_local and self._hold_request != 'hold': self._resume() if not was_on_hold_by_local and self.on_hold_by_local: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator="local", on_hold=True)) if was_on_hold_by_local and not self.on_hold_by_local: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator="local", on_hold=False)) if not was_on_hold_by_remote and self.on_hold_by_remote: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator="remote", on_hold=True)) if was_on_hold_by_remote and not self.on_hold_by_remote: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator="remote", on_hold=False)) if self._audio_rec is not None: self._check_recording() def _check_recording(self): if not self._audio_rec.is_active: self.notification_center.post_notification('AudioStreamWillStartRecording', sender=self, data=NotificationData(filename=self._audio_rec.filename)) try: self._audio_rec.start() except SIPCoreError as e: self._audio_rec = None self.notification_center.post_notification('AudioStreamDidStopRecording', sender=self, data=NotificationData(filename=self._audio_rec.filename, reason=e.args[0])) return self.notification_center.post_notification('AudioStreamDidStartRecording', sender=self, data=NotificationData(filename=self._audio_rec.filename)) if not self.on_hold: self.bridge.add(self._audio_rec) elif self._audio_rec in self.bridge: self.bridge.remove(self._audio_rec) def _stop_recording(self): self.notification_center.post_notification('AudioStreamWillStopRecording', sender=self, data=NotificationData(filename=self._audio_rec.filename)) try: if self._audio_rec.is_active: self._audio_rec.stop() finally: self.notification_center.post_notification('AudioStreamDidStopRecording', sender=self, data=NotificationData(filename=self._audio_rec.filename)) self._audio_rec = None def _pause(self): self.bridge.remove(self) def _resume(self): self.bridge.add(self) diff --git a/sipsimple/streams/rtp/video.py b/sipsimple/streams/rtp/video.py index a827277e..8d555039 100644 --- a/sipsimple/streams/rtp/video.py +++ b/sipsimple/streams/rtp/video.py @@ -1,190 +1,190 @@ __all__ = ['VideoStream'] from application.notification import NotificationData -from zope.interface import implements +from zope.interface import implementer from sipsimple.configuration.settings import SIPSimpleSettings from sipsimple.core import VideoTransport from sipsimple.streams import InvalidStreamError from sipsimple.streams.rtp import RTPStream from sipsimple.threading import call_in_thread, run_in_twisted_thread from sipsimple.util import ExponentialTimer from sipsimple.video import IVideoProducer +@implementer(IVideoProducer) class VideoStream(RTPStream): - implements(IVideoProducer) type = 'video' priority = 1 def __init__(self): super(VideoStream, self).__init__() from sipsimple.application import SIPApplication self.device = SIPApplication.video_device self._keyframe_timer = None @property def producer(self): return self._transport.remote_video if self._transport else None @classmethod def new_from_sdp(cls, session, remote_sdp, stream_index): stream = super(VideoStream, cls).new_from_sdp(session, remote_sdp, stream_index) if stream.device.producer is None: raise InvalidStreamError("no video support available") if not stream.validate_update(remote_sdp, stream_index): raise InvalidStreamError("no valid SDP") return stream def initialize(self, session, direction): super(VideoStream, self).initialize(session, direction) self.notification_center.add_observer(self, name='VideoDeviceDidChangeCamera') def start(self, local_sdp, remote_sdp, stream_index): with self._lock: if self.state != "INITIALIZED": raise RuntimeError("VideoStream.start() may only be called in the INITIALIZED state") settings = SIPSimpleSettings() self._transport.start(local_sdp, remote_sdp, stream_index, timeout=settings.rtp.timeout) self._transport.local_video.producer = self.device.producer self._save_remote_sdp_rtp_info(remote_sdp, stream_index) self._check_hold(self._transport.direction, True) if self._try_ice and self._ice_state == "NULL": self.state = 'WAIT_ICE' else: self._send_keyframes() self.state = 'ESTABLISHED' self.notification_center.post_notification('MediaStreamDidStart', sender=self) def validate_update(self, remote_sdp, stream_index): with self._lock: remote_media = remote_sdp.media[stream_index] if 'H264' in remote_media.codec_list: rtpmap = next(attr for attr in remote_media.attributes if attr.name=='rtpmap' and 'h264' in attr.value.lower()) payload_type = rtpmap.value.partition(' ')[0] has_profile_level_id = any('profile-level-id' in attr.value.lower() for attr in remote_media.attributes if attr.name=='fmtp' and attr.value.startswith(payload_type + ' ')) if not has_profile_level_id: return False return True def update(self, local_sdp, remote_sdp, stream_index): with self._lock: new_direction = local_sdp.media[stream_index].direction self._check_hold(new_direction, False) self._transport.update_direction(new_direction) self._save_remote_sdp_rtp_info(remote_sdp, stream_index) self._transport.update_sdp(local_sdp, remote_sdp, stream_index) self._hold_request = None def deactivate(self): with self._lock: self.notification_center.discard_observer(self, name='VideoDeviceDidChangeCamera') def end(self): with self._lock: if self.state == "ENDED" or self._done: return self._done = True if not self._initialized: self.state = "ENDED" self.notification_center.post_notification('MediaStreamDidNotInitialize', sender=self, data=NotificationData(reason='Interrupted')) return if self._keyframe_timer is not None: self._keyframe_timer.stop() self.notification_center.remove_observer(self, sender=self._keyframe_timer) self._keyframe_timer = None self.notification_center.post_notification('MediaStreamWillEnd', sender=self) if self._transport is not None: self.notification_center.remove_observer(self, sender=self._transport) self.notification_center.remove_observer(self, sender=self._rtp_transport) call_in_thread('device-io', self._transport.stop) self._transport = None self._rtp_transport = None self.state = "ENDED" self.notification_center.post_notification('MediaStreamDidEnd', sender=self, data=NotificationData(error=self._failure_reason)) self.session = None def reset(self, stream_index): pass def _NH_RTPTransportICENegotiationDidSucceed(self, notification): with self._lock: if self.state == "WAIT_ICE": self._send_keyframes() super(VideoStream, self)._NH_RTPTransportICENegotiationDidSucceed(notification) def _NH_RTPTransportICENegotiationDidFail(self, notification): with self._lock: if self.state == "WAIT_ICE": self._send_keyframes() super(VideoStream, self)._NH_RTPTransportICENegotiationDidFail(notification) def _NH_RTPVideoTransportDidTimeout(self, notification): self.notification_center.post_notification('RTPStreamDidTimeout', sender=self) def _NH_RTPVideoTransportRemoteFormatDidChange(self, notification): self.notification_center.post_notification('VideoStreamRemoteFormatDidChange', sender=self, data=notification.data) def _NH_RTPVideoTransportReceivedKeyFrame(self, notification): self.notification_center.post_notification('VideoStreamReceivedKeyFrame', sender=self, data=notification.data) def _NH_RTPVideoTransportMissedKeyFrame(self, notification): self._transport.request_keyframe() self.notification_center.post_notification('VideoStreamMissedKeyFrame', sender=self, data=notification.data) def _NH_RTPVideoTransportRequestedKeyFrame(self, notification): self._transport.send_keyframe() self.notification_center.post_notification('VideoStreamRequestedKeyFrame', sender=self, data=notification.data) def _NH_VideoDeviceDidChangeCamera(self, notification): new_camera = notification.data.new_camera if self._transport is not None and self._transport.local_video is not None: self._transport.local_video.producer = new_camera def _NH_ExponentialTimerDidTimeout(self, notification): if self._transport is not None: self._transport.send_keyframe() def _create_transport(self, rtp_transport, remote_sdp=None, stream_index=None): settings = SIPSimpleSettings() codecs = list(self.session.account.rtp.video_codec_list or settings.rtp.video_codec_list) return VideoTransport(rtp_transport, remote_sdp=remote_sdp, sdp_index=stream_index or 0, codecs=codecs) def _check_hold(self, direction, is_initial): was_on_hold_by_local = self.on_hold_by_local was_on_hold_by_remote = self.on_hold_by_remote self.direction = direction inactive = self.direction == "inactive" self.on_hold_by_local = was_on_hold_by_local if inactive else direction == "sendonly" self.on_hold_by_remote = "send" not in direction if self.on_hold_by_local or self.on_hold_by_remote: self._pause() elif not self.on_hold_by_local and not self.on_hold_by_remote and (was_on_hold_by_local or was_on_hold_by_remote): self._resume() if not was_on_hold_by_local and self.on_hold_by_local: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator="local", on_hold=True)) if was_on_hold_by_local and not self.on_hold_by_local: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator="local", on_hold=False)) if not was_on_hold_by_remote and self.on_hold_by_remote: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator="remote", on_hold=True)) if was_on_hold_by_remote and not self.on_hold_by_remote: self.notification_center.post_notification('RTPStreamDidChangeHoldState', sender=self, data=NotificationData(originator="remote", on_hold=False)) @run_in_twisted_thread def _send_keyframes(self): if self._keyframe_timer is None: self._keyframe_timer = ExponentialTimer() self.notification_center.add_observer(self, sender=self._keyframe_timer) self._keyframe_timer.start(0.5, immediate=True, iterations=5) def _pause(self): self._transport.pause() def _resume(self): self._transport.resume() self._send_keyframes() self._transport.request_keyframe() diff --git a/sipsimple/video.py b/sipsimple/video.py index 6caabdf1..c5a1034f 100644 --- a/sipsimple/video.py +++ b/sipsimple/video.py @@ -1,79 +1,79 @@ """Video support""" __all__ = ['IVideoProducer', 'VideoDevice', 'VideoError'] from .application.notification import NotificationCenter, NotificationData -from zope.interface import Attribute, Interface, implements +from zope.interface import Attribute, Interface, implementer from sipsimple.core import SIPCoreError, VideoCamera class IVideoProducer(Interface): """ Interface describing an object which can produce video data. All attributes of this interface are read-only. """ producer = Attribute("The core producer object which can be connected to a consumer") class VideoError(Exception): pass +@implementer(IVideoProducer) class VideoDevice(object): - implements(IVideoProducer) def __init__(self, device_name, resolution, framerate): self._camera = self._open_camera(device_name, resolution, framerate) self._camera.start() def _open_camera(self, device_name, resolution, framerate): try: return VideoCamera(device_name, resolution, framerate) except SIPCoreError: try: return VideoCamera('system_default', resolution, framerate) except SIPCoreError: return VideoCamera(None, resolution, framerate) def set_camera(self, device_name, resolution, framerate): old_camera = self._camera old_camera.close() new_camera = self._open_camera(device_name, resolution, framerate) if not self.muted: new_camera.start() self._camera = new_camera notification_center = NotificationCenter() notification_center.post_notification('VideoDeviceDidChangeCamera', sender=self, data=NotificationData(old_camera=old_camera, new_camera=new_camera)) @property def producer(self): return self._camera @property def name(self): return self._camera.name @property def real_name(self): return self._camera.real_name @property def muted(self): return self.__dict__.get('muted', False) @muted.setter def muted(self, value): if not isinstance(value, bool): raise ValueError('illegal value for muted property: %r' % (value,)) if value == self.muted: return if value: self._camera.stop() else: self._camera.start() self.__dict__['muted'] = value