diff options
Diffstat (limited to 'sleekxmpp')
167 files changed, 21840 insertions, 0 deletions
diff --git a/sleekxmpp/__init__.py b/sleekxmpp/__init__.py new file mode 100644 index 00000000..a1f1c0f1 --- /dev/null +++ b/sleekxmpp/__init__.py @@ -0,0 +1,18 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.basexmpp import BaseXMPP +from sleekxmpp.clientxmpp import ClientXMPP +from sleekxmpp.componentxmpp import ComponentXMPP +from sleekxmpp.stanza import Message, Presence, Iq +from sleekxmpp.xmlstream.handler import * +from sleekxmpp.xmlstream import XMLStream, RestartStream +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.stanzabase import StanzaBase, ET + +from sleekxmpp.version import __version__, __version_info__ diff --git a/sleekxmpp/basexmpp.py b/sleekxmpp/basexmpp.py new file mode 100644 index 00000000..e4fd03a1 --- /dev/null +++ b/sleekxmpp/basexmpp.py @@ -0,0 +1,792 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.basexmpp + ~~~~~~~~~~~~~~~~~~ + + This module provides the common XMPP functionality + for both clients and components. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from __future__ import with_statement, unicode_literals + +import sys +import copy +import logging + +import sleekxmpp +from sleekxmpp import plugins, roster +from sleekxmpp.exceptions import IqError, IqTimeout + +from sleekxmpp.stanza import Message, Presence, Iq, Error, StreamError +from sleekxmpp.stanza.roster import Roster +from sleekxmpp.stanza.nick import Nick +from sleekxmpp.stanza.htmlim import HTMLIM + +from sleekxmpp.xmlstream import XMLStream, JID, tostring +from sleekxmpp.xmlstream import ET, register_stanza_plugin +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * + + +log = logging.getLogger(__name__) + +# In order to make sure that Unicode is handled properly +# in Python 2.x, reset the default encoding. +if sys.version_info < (3, 0): + reload(sys) + sys.setdefaultencoding('utf8') + + +class BaseXMPP(XMLStream): + + """ + The BaseXMPP class adapts the generic XMLStream class for use + with XMPP. It also provides a plugin mechanism to easily extend + and add support for new XMPP features. + + :param default_ns: Ensure that the correct default XML namespace + is used during initialization. + """ + + def __init__(self, jid='', default_ns='jabber:client'): + XMLStream.__init__(self) + + self.default_ns = default_ns + self.stream_ns = 'http://etherx.jabber.org/streams' + self.namespace_map[self.stream_ns] = 'stream' + + #: An identifier for the stream as given by the server. + self.stream_id = None + + #: The JabberID (JID) used by this connection. + self.boundjid = JID(jid) + + #: A dictionary mapping plugin names to plugins. + self.plugin = {} + + #: Configuration options for whitelisted plugins. + #: If a plugin is registered without any configuration, + #: and there is an entry here, it will be used. + self.plugin_config = {} + + #: A list of plugins that will be loaded if + #: :meth:`register_plugins` is called. + self.plugin_whitelist = [] + + #: The main roster object. This roster supports multiple + #: owner JIDs, as in the case for components. For clients + #: which only have a single JID, see :attr:`client_roster`. + self.roster = roster.Roster(self) + self.roster.add(self.boundjid.bare) + + #: The single roster for the bound JID. This is the + #: equivalent of:: + #: + #: self.roster[self.boundjid.bare] + self.client_roster = self.roster[self.boundjid.bare] + + #: The distinction between clients and components can be + #: important, primarily for choosing how to handle the + #: ``'to'`` and ``'from'`` JIDs of stanzas. + self.is_component = False + + #: Flag indicating that the initial presence broadcast has + #: been sent. Until this happens, some servers may not + #: behave as expected when sending stanzas. + self.sentpresence = False + + #: A reference to :mod:`sleekxmpp.stanza` to make accessing + #: stanza classes easier. + self.stanza = sleekxmpp.stanza + + self.register_handler( + Callback('IM', + MatchXPath('{%s}message/{%s}body' % (self.default_ns, + self.default_ns)), + self._handle_message)) + self.register_handler( + Callback('Presence', + MatchXPath("{%s}presence" % self.default_ns), + self._handle_presence)) + self.register_handler( + Callback('Stream Error', + MatchXPath("{%s}error" % self.stream_ns), + self._handle_stream_error)) + + self.add_event_handler('disconnected', + self._handle_disconnected) + self.add_event_handler('presence_available', + self._handle_available) + self.add_event_handler('presence_dnd', + self._handle_available) + self.add_event_handler('presence_xa', + self._handle_available) + self.add_event_handler('presence_chat', + self._handle_available) + self.add_event_handler('presence_away', + self._handle_available) + self.add_event_handler('presence_unavailable', + self._handle_unavailable) + self.add_event_handler('presence_subscribe', + self._handle_subscribe) + self.add_event_handler('presence_subscribed', + self._handle_subscribed) + self.add_event_handler('presence_unsubscribe', + self._handle_unsubscribe) + self.add_event_handler('presence_unsubscribed', + self._handle_unsubscribed) + self.add_event_handler('roster_subscription_request', + self._handle_new_subscription) + + # Set up the XML stream with XMPP's root stanzas. + self.register_stanza(Message) + self.register_stanza(Iq) + self.register_stanza(Presence) + self.register_stanza(StreamError) + + # Initialize a few default stanza plugins. + register_stanza_plugin(Iq, Roster) + register_stanza_plugin(Message, Nick) + register_stanza_plugin(Message, HTMLIM) + + def start_stream_handler(self, xml): + """Save the stream ID once the streams have been established. + + :param xml: The incoming stream's root element. + """ + self.stream_id = xml.get('id', '') + + def process(self, *args, **kwargs): + """Initialize plugins and begin processing the XML stream. + + The number of threads used for processing stream events is determined + by :data:`HANDLER_THREADS`. + + :param bool block: If ``False``, then event dispatcher will run + in a separate thread, allowing for the stream to be + used in the background for another application. + Otherwise, ``process(block=True)`` blocks the current + thread. Defaults to ``False``. + :param bool threaded: **DEPRECATED** + If ``True``, then event dispatcher will run + in a separate thread, allowing for the stream to be + used in the background for another application. + Defaults to ``True``. This does **not** mean that no + threads are used at all if ``threaded=False``. + + Regardless of these threading options, these threads will + always exist: + + - The event queue processor + - The send queue processor + - The scheduler + """ + for name in self.plugin: + if not self.plugin[name].post_inited: + self.plugin[name].post_init() + return XMLStream.process(self, *args, **kwargs) + + def register_plugin(self, plugin, pconfig={}, module=None): + """Register and configure a plugin for use in this stream. + + :param plugin: The name of the plugin class. Plugin names must + be unique. + :param pconfig: A dictionary of configuration data for the plugin. + Defaults to an empty dictionary. + :param module: Optional refence to the module containing the plugin + class if using custom plugins. + """ + try: + # Import the given module that contains the plugin. + if not module: + try: + module = sleekxmpp.plugins + module = __import__( + str("%s.%s" % (module.__name__, plugin)), + globals(), locals(), [str(plugin)]) + except ImportError: + module = sleekxmpp.features + module = __import__( + str("%s.%s" % (module.__name__, plugin)), + globals(), locals(), [str(plugin)]) + if isinstance(module, str): + # We probably want to load a module from outside + # the sleekxmpp package, so leave out the globals(). + module = __import__(module, fromlist=[plugin]) + + # Use the global plugin config cache, if applicable + if not pconfig: + pconfig = self.plugin_config.get(plugin, {}) + + # Load the plugin class from the module. + self.plugin[plugin] = getattr(module, plugin)(self, pconfig) + + # Let XEP/RFC implementing plugins have some extra logging info. + spec = '(CUSTOM) %s' + if self.plugin[plugin].xep: + spec = "(XEP-%s) " % self.plugin[plugin].xep + elif self.plugin[plugin].rfc: + spec = "(RFC-%s) " % self.plugin[plugin].rfc + + desc = (spec, self.plugin[plugin].description) + log.debug("Loaded Plugin %s %s" % desc) + except: + log.exception("Unable to load plugin: %s", plugin) + + def register_plugins(self): + """Register and initialize all built-in plugins. + + Optionally, the list of plugins loaded may be limited to those + contained in :attr:`plugin_whitelist`. + + Plugin configurations stored in :attr:`plugin_config` will be used. + """ + if self.plugin_whitelist: + plugin_list = self.plugin_whitelist + else: + plugin_list = plugins.__all__ + + for plugin in plugin_list: + if plugin in plugins.__all__: + self.register_plugin(plugin, + self.plugin_config.get(plugin, {})) + else: + raise NameError("Plugin %s not in plugins.__all__." % plugin) + + # Resolve plugin inter-dependencies. + for plugin in self.plugin: + self.plugin[plugin].post_init() + + def __getitem__(self, key): + """Return a plugin given its name, if it has been registered.""" + if key in self.plugin: + return self.plugin[key] + else: + log.warning("Plugin '%s' is not loaded.", key) + return False + + def get(self, key, default): + """Return a plugin given its name, if it has been registered.""" + return self.plugin.get(key, default) + + def Message(self, *args, **kwargs): + """Create a Message stanza associated with this stream.""" + return Message(self, *args, **kwargs) + + def Iq(self, *args, **kwargs): + """Create an Iq stanza associated with this stream.""" + return Iq(self, *args, **kwargs) + + def Presence(self, *args, **kwargs): + """Create a Presence stanza associated with this stream.""" + return Presence(self, *args, **kwargs) + + def make_iq(self, id=0, ifrom=None, ito=None, itype=None, iquery=None): + """Create a new Iq stanza with a given Id and from JID. + + :param id: An ideally unique ID value for this stanza thread. + Defaults to 0. + :param ifrom: The from :class:`~sleekxmpp.xmlstream.jid.JID` + to use for this stanza. + :param ito: The destination :class:`~sleekxmpp.xmlstream.jid.JID` + for this stanza. + :param itype: The :class:`~sleekxmpp.stanza.iq.Iq`'s type, + one of: ``'get'``, ``'set'``, ``'result'``, + or ``'error'``. + :param iquery: Optional namespace for adding a query element. + """ + iq = self.Iq() + iq['id'] = str(id) + iq['to'] = ito + iq['from'] = ifrom + iq['type'] = itype + iq['query'] = iquery + return iq + + def make_iq_get(self, queryxmlns=None, ito=None, ifrom=None, iq=None): + """Create an :class:`~sleekxmpp.stanza.iq.Iq` stanza of type ``'get'``. + + Optionally, a query element may be added. + + :param queryxmlns: The namespace of the query to use. + :param ito: The destination :class:`~sleekxmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~sleekxmpp.xmlstream.jid.JID` + to use for this stanza. + :param iq: Optionally use an existing stanza instead + of generating a new one. + """ + if not iq: + iq = self.Iq() + iq['type'] = 'get' + iq['query'] = queryxmlns + if ito: + iq['to'] = ito + if ifrom: + iq['from'] = ifrom + return iq + + def make_iq_result(self, id=None, ito=None, ifrom=None, iq=None): + """ + Create an :class:`~sleekxmpp.stanza.iq.Iq` stanza of type + ``'result'`` with the given ID value. + + :param id: An ideally unique ID value. May use :meth:`new_id()`. + :param ito: The destination :class:`~sleekxmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~sleekxmpp.xmlstream.jid.JID` + to use for this stanza. + :param iq: Optionally use an existing stanza instead + of generating a new one. + """ + if not iq: + iq = self.Iq() + if id is None: + id = self.new_id() + iq['id'] = id + iq['type'] = 'result' + if ito: + iq['to'] = ito + if ifrom: + iq['from'] = ifrom + return iq + + def make_iq_set(self, sub=None, ito=None, ifrom=None, iq=None): + """ + Create an :class:`~sleekxmpp.stanza.iq.Iq` stanza of type ``'set'``. + + Optionally, a substanza may be given to use as the + stanza's payload. + + :param sub: Either an + :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` + stanza object or an + :class:`~xml.etree.ElementTree.Element` XML object + to use as the :class:`~sleekxmpp.stanza.iq.Iq`'s payload. + :param ito: The destination :class:`~sleekxmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~sleekxmpp.xmlstream.jid.JID` + to use for this stanza. + :param iq: Optionally use an existing stanza instead + of generating a new one. + """ + if not iq: + iq = self.Iq() + iq['type'] = 'set' + if sub != None: + iq.append(sub) + if ito: + iq['to'] = ito + if ifrom: + iq['from'] = ifrom + return iq + + def make_iq_error(self, id, type='cancel', + condition='feature-not-implemented', + text=None, ito=None, ifrom=None, iq=None): + """ + Create an :class:`~sleekxmpp.stanza.iq.Iq` stanza of type ``'error'``. + + :param id: An ideally unique ID value. May use :meth:`new_id()`. + :param type: The type of the error, such as ``'cancel'`` or + ``'modify'``. Defaults to ``'cancel'``. + :param condition: The error condition. Defaults to + ``'feature-not-implemented'``. + :param text: A message describing the cause of the error. + :param ito: The destination :class:`~sleekxmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~sleekxmpp.xmlstream.jid.JID` + to use for this stanza. + :param iq: Optionally use an existing stanza instead + of generating a new one. + """ + if not iq: + iq = self.Iq() + iq['id'] = id + iq['error']['type'] = type + iq['error']['condition'] = condition + iq['error']['text'] = text + if ito: + iq['to'] = ito + if ifrom: + iq['from'] = ifrom + return iq + + def make_iq_query(self, iq=None, xmlns='', ito=None, ifrom=None): + """ + Create or modify an :class:`~sleekxmpp.stanza.iq.Iq` stanza + to use the given query namespace. + + :param iq: Optionally use an existing stanza instead + of generating a new one. + :param xmlns: The query's namespace. + :param ito: The destination :class:`~sleekxmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~sleekxmpp.xmlstream.jid.JID` + to use for this stanza. + """ + if not iq: + iq = self.Iq() + iq['query'] = xmlns + if ito: + iq['to'] = ito + if ifrom: + iq['from'] = ifrom + return iq + + def make_query_roster(self, iq=None): + """Create a roster query element. + + :param iq: Optionally use an existing stanza instead + of generating a new one. + """ + if iq: + iq['query'] = 'jabber:iq:roster' + return ET.Element("{jabber:iq:roster}query") + + def make_message(self, mto, mbody=None, msubject=None, mtype=None, + mhtml=None, mfrom=None, mnick=None): + """ + Create and initialize a new + :class:`~sleekxmpp.stanza.message.Message` stanza. + + :param mto: The recipient of the message. + :param mbody: The main contents of the message. + :param msubject: Optional subject for the message. + :param mtype: The message's type, such as ``'chat'`` or + ``'groupchat'``. + :param mhtml: Optional HTML body content in the form of a string. + :param mfrom: The sender of the message. if sending from a client, + be aware that some servers require that the full JID + of the sender be used. + :param mnick: Optional nickname of the sender. + """ + message = self.Message(sto=mto, stype=mtype, sfrom=mfrom) + message['body'] = mbody + message['subject'] = msubject + if mnick is not None: + message['nick'] = mnick + if mhtml is not None: + message['html']['body'] = mhtml + return message + + def make_presence(self, pshow=None, pstatus=None, ppriority=None, + pto=None, ptype=None, pfrom=None, pnick=None): + """ + Create and initialize a new + :class:`~sleekxmpp.stanza.presence.Presence` stanza. + + :param pshow: The presence's show value. + :param pstatus: The presence's status message. + :param ppriority: This connection's priority. + :param pto: The recipient of a directed presence. + :param ptype: The type of presence, such as ``'subscribe'``. + :param pfrom: The sender of the presence. + :param pnick: Optional nickname of the presence's sender. + """ + presence = self.Presence(stype=ptype, sfrom=pfrom, sto=pto) + if pshow is not None: + presence['type'] = pshow + if pfrom is None and self.is_component: + presence['from'] = self.boundjid.full + presence['priority'] = ppriority + presence['status'] = pstatus + presence['nick'] = pnick + return presence + + def send_message(self, mto, mbody, msubject=None, mtype=None, + mhtml=None, mfrom=None, mnick=None): + """ + Create, initialize, and send a new + :class:`~sleekxmpp.stanza.message.Message` stanza. + + :param mto: The recipient of the message. + :param mbody: The main contents of the message. + :param msubject: Optional subject for the message. + :param mtype: The message's type, such as ``'chat'`` or + ``'groupchat'``. + :param mhtml: Optional HTML body content in the form of a string. + :param mfrom: The sender of the message. if sending from a client, + be aware that some servers require that the full JID + of the sender be used. + :param mnick: Optional nickname of the sender. + """ + self.make_message(mto, mbody, msubject, mtype, + mhtml, mfrom, mnick).send() + + def send_presence(self, pshow=None, pstatus=None, ppriority=None, + pto=None, pfrom=None, ptype=None, pnick=None): + """ + Create, initialize, and send a new + :class:`~sleekxmpp.stanza.presence.Presence` stanza. + + :param pshow: The presence's show value. + :param pstatus: The presence's status message. + :param ppriority: This connection's priority. + :param pto: The recipient of a directed presence. + :param ptype: The type of presence, such as ``'subscribe'``. + :param pfrom: The sender of the presence. + :param pnick: Optional nickname of the presence's sender. + """ + # Python2.6 chokes on Unicode strings for dict keys. + args = {str('pto'): pto, + str('ptype'): ptype, + str('pshow'): pshow, + str('pstatus'): pstatus, + str('ppriority'): ppriority, + str('pnick'): pnick} + + if self.is_component: + self.roster[pfrom].send_presence(**args) + else: + self.client_roster.send_presence(**args) + + def send_presence_subscription(self, pto, pfrom=None, + ptype='subscribe', pnick=None): + """ + Create, initialize, and send a new + :class:`~sleekxmpp.stanza.presence.Presence` stanza of + type ``'subscribe'``. + + :param pto: The recipient of a directed presence. + :param pfrom: The sender of the presence. + :param ptype: The type of presence, such as ``'subscribe'``. + :param pnick: Optional nickname of the presence's sender. + """ + presence = self.makePresence(ptype=ptype, + pfrom=pfrom, + pto=self.getjidbare(pto)) + if pnick: + nick = ET.Element('{http://jabber.org/protocol/nick}nick') + nick.text = pnick + presence.append(nick) + presence.send() + + @property + def jid(self): + """Attribute accessor for bare jid""" + log.warning("jid property deprecated. Use boundjid.bare") + return self.boundjid.bare + + @jid.setter + def jid(self, value): + log.warning("jid property deprecated. Use boundjid.bare") + self.boundjid.bare = value + + @property + def fulljid(self): + """Attribute accessor for full jid""" + log.warning("fulljid property deprecated. Use boundjid.full") + return self.boundjid.full + + @fulljid.setter + def fulljid(self, value): + log.warning("fulljid property deprecated. Use boundjid.full") + self.boundjid.full = value + + @property + def resource(self): + """Attribute accessor for jid resource""" + log.warning("resource property deprecated. Use boundjid.resource") + return self.boundjid.resource + + @resource.setter + def resource(self, value): + log.warning("fulljid property deprecated. Use boundjid.full") + self.boundjid.resource = value + + @property + def username(self): + """Attribute accessor for jid usernode""" + log.warning("username property deprecated. Use boundjid.user") + return self.boundjid.user + + @username.setter + def username(self, value): + log.warning("username property deprecated. Use boundjid.user") + self.boundjid.user = value + + @property + def server(self): + """Attribute accessor for jid host""" + log.warning("server property deprecated. Use boundjid.host") + return self.boundjid.server + + @server.setter + def server(self, value): + log.warning("server property deprecated. Use boundjid.host") + self.boundjid.server = value + + @property + def auto_authorize(self): + """Auto accept or deny subscription requests. + + If ``True``, auto accept subscription requests. + If ``False``, auto deny subscription requests. + If ``None``, don't automatically respond. + """ + return self.roster.auto_authorize + + @auto_authorize.setter + def auto_authorize(self, value): + self.roster.auto_authorize = value + + @property + def auto_subscribe(self): + """Auto send requests for mutual subscriptions. + + If ``True``, auto send mutual subscription requests. + """ + return self.roster.auto_subscribe + + @auto_subscribe.setter + def auto_subscribe(self, value): + self.roster.auto_subscribe = value + + def set_jid(self, jid): + """Rip a JID apart and claim it as our own.""" + log.debug("setting jid to %s", jid) + self.boundjid.full = jid + + def getjidresource(self, fulljid): + if '/' in fulljid: + return fulljid.split('/', 1)[-1] + else: + return '' + + def getjidbare(self, fulljid): + return fulljid.split('/', 1)[0] + + def _handle_disconnected(self, event): + """When disconnected, reset the roster""" + self.roster.reset() + + def _handle_stream_error(self, error): + self.event('stream_error', error) + + def _handle_message(self, msg): + """Process incoming message stanzas.""" + self.event('message', msg) + + def _handle_available(self, presence): + pto = presence['to'].bare + pfrom = presence['from'].bare + self.roster[pto][pfrom].handle_available(presence) + + def _handle_unavailable(self, presence): + pto = presence['to'].bare + pfrom = presence['from'].bare + self.roster[pto][pfrom].handle_unavailable(presence) + + def _handle_new_subscription(self, stanza): + """Attempt to automatically handle subscription requests. + + Subscriptions will be approved if the request is from + a whitelisted JID, of :attr:`auto_authorize` is True. They + will be rejected if :attr:`auto_authorize` is False. Setting + :attr:`auto_authorize` to ``None`` will disable automatic + subscription handling (except for whitelisted JIDs). + + If a subscription is accepted, a request for a mutual + subscription will be sent if :attr:`auto_subscribe` is ``True``. + """ + roster = self.roster[stanza['to'].bare] + item = self.roster[stanza['to'].bare][stanza['from'].bare] + if item['whitelisted']: + item.authorize() + elif roster.auto_authorize: + item.authorize() + if roster.auto_subscribe: + item.subscribe() + elif roster.auto_authorize == False: + item.unauthorize() + + def _handle_removed_subscription(self, presence): + pto = presence['to'].bare + pfrom = presence['from'].bare + self.roster[pto][pfrom].unauthorize() + + def _handle_subscribe(self, presence): + pto = presence['to'].bare + pfrom = presence['from'].bare + self.roster[pto][pfrom].handle_subscribe(presence) + + def _handle_subscribed(self, presence): + pto = presence['to'].bare + pfrom = presence['from'].bare + self.roster[pto][pfrom].handle_subscribed(presence) + + def _handle_unsubscribe(self, presence): + pto = presence['to'].bare + pfrom = presence['from'].bare + self.roster[pto][pfrom].handle_unsubscribe(presence) + + def _handle_unsubscribed(self, presence): + pto = presence['to'].bare + pfrom = presence['from'].bare + self.roster[pto][pfrom].handle_unsubscribed(presence) + + def _handle_presence(self, presence): + """Process incoming presence stanzas. + + Update the roster with presence information. + """ + self.event("presence_%s" % presence['type'], presence) + + # Check for changes in subscription state. + if presence['type'] in ('subscribe', 'subscribed', + 'unsubscribe', 'unsubscribed'): + self.event('changed_subscription', presence) + return + elif not presence['type'] in ('available', 'unavailable') and \ + not presence['type'] in presence.showtypes: + return + + def exception(self, exception): + """Process any uncaught exceptions, notably + :class:`~sleekxmpp.exceptions.IqError` and + :class:`~sleekxmpp.exceptions.IqTimeout` exceptions. + + :param exception: An unhandled :class:`Exception` object. + """ + if isinstance(exception, IqError): + iq = exception.iq + log.error('%s: %s', iq['error']['condition'], + iq['error']['text']) + log.warning('You should catch IqError exceptions') + elif isinstance(exception, IqTimeout): + iq = exception.iq + log.error('Request timed out: %s', iq) + log.warning('You should catch IqTimeout exceptions') + elif isinstance(exception, SyntaxError): + # Hide stream parsing errors that occur when the + # stream is disconnected (they've been handled, we + # don't need to make a mess in the logs). + pass + else: + log.exception(exception) + + +# Restore the old, lowercased name for backwards compatibility. +basexmpp = BaseXMPP + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +BaseXMPP.registerPlugin = BaseXMPP.register_plugin +BaseXMPP.makeIq = BaseXMPP.make_iq +BaseXMPP.makeIqGet = BaseXMPP.make_iq_get +BaseXMPP.makeIqResult = BaseXMPP.make_iq_result +BaseXMPP.makeIqSet = BaseXMPP.make_iq_set +BaseXMPP.makeIqError = BaseXMPP.make_iq_error +BaseXMPP.makeIqQuery = BaseXMPP.make_iq_query +BaseXMPP.makeQueryRoster = BaseXMPP.make_query_roster +BaseXMPP.makeMessage = BaseXMPP.make_message +BaseXMPP.makePresence = BaseXMPP.make_presence +BaseXMPP.sendMessage = BaseXMPP.send_message +BaseXMPP.sendPresence = BaseXMPP.send_presence +BaseXMPP.sendPresenceSubscription = BaseXMPP.send_presence_subscription diff --git a/sleekxmpp/clientxmpp.py b/sleekxmpp/clientxmpp.py new file mode 100644 index 00000000..69e7db6c --- /dev/null +++ b/sleekxmpp/clientxmpp.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.clientxmpp + ~~~~~~~~~~~~~~~~~~~~ + + This module provides XMPP functionality that + is specific to client connections. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from __future__ import absolute_import, unicode_literals + +import logging +import base64 +import sys +import hashlib +import random +import threading + +import sleekxmpp +from sleekxmpp import plugins +from sleekxmpp import stanza +from sleekxmpp import features +from sleekxmpp.basexmpp import BaseXMPP +from sleekxmpp.stanza import * +from sleekxmpp.xmlstream import XMLStream, RestartStream +from sleekxmpp.xmlstream import StanzaBase, ET, register_stanza_plugin +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * + +# Flag indicating if DNS SRV records are available for use. +try: + import dns.resolver +except ImportError: + DNSPYTHON = False +else: + DNSPYTHON = True + + +log = logging.getLogger(__name__) + + +class ClientXMPP(BaseXMPP): + + """ + SleekXMPP's client class. (Use only for good, not for evil.) + + Typical use pattern: + + .. code-block:: python + + xmpp = ClientXMPP('user@server.tld/resource', 'password') + # ... Register plugins and event handlers ... + xmpp.connect() + xmpp.process(block=False) # block=True will block the current + # thread. By default, block=False + + :param jid: The JID of the XMPP user account. + :param password: The password for the XMPP user account. + :param ssl: **Deprecated.** + :param plugin_config: A dictionary of plugin configurations. + :param plugin_whitelist: A list of approved plugins that + will be loaded when calling + :meth:`~sleekxmpp.basexmpp.BaseXMPP.register_plugins()`. + :param escape_quotes: **Deprecated.** + """ + + def __init__(self, jid, password, ssl=False, plugin_config={}, + plugin_whitelist=[], escape_quotes=True, sasl_mech=None): + BaseXMPP.__init__(self, jid, 'jabber:client') + + self.set_jid(jid) + self.password = password + self.escape_quotes = escape_quotes + self.plugin_config = plugin_config + self.plugin_whitelist = plugin_whitelist + self.default_port = 5222 + + self.stream_header = "<stream:stream to='%s' %s %s version='1.0'>" % ( + self.boundjid.host, + "xmlns:stream='%s'" % self.stream_ns, + "xmlns='%s'" % self.default_ns) + self.stream_footer = "</stream:stream>" + + self.features = set() + self._stream_feature_handlers = {} + self._stream_feature_order = [] + + #TODO: Use stream state here + self.authenticated = False + self.sessionstarted = False + self.bound = False + self.bindfail = False + + self.add_event_handler('connected', self._handle_connected) + self.add_event_handler('session_bind', self._handle_session_bind) + + self.register_stanza(StreamFeatures) + + self.register_handler( + Callback('Stream Features', + MatchXPath('{%s}features' % self.stream_ns), + self._handle_stream_features)) + self.register_handler( + Callback('Roster Update', + MatchXPath('{%s}iq/{%s}query' % ( + self.default_ns, + 'jabber:iq:roster')), + self._handle_roster)) + + # Setup default stream features + self.register_plugin('feature_starttls') + self.register_plugin('feature_bind') + self.register_plugin('feature_session') + self.register_plugin('feature_mechanisms', + pconfig={'use_mech': sasl_mech} if sasl_mech else None) + + def connect(self, address=tuple(), reattempt=True, + use_tls=True, use_ssl=False): + """Connect to the XMPP server. + + When no address is given, a SRV lookup for the server will + be attempted. If that fails, the server user in the JID + will be used. + + :param address -- A tuple containing the server's host and port. + :param reattempt: If ``True``, repeat attempting to connect if an + error occurs. Defaults to ``True``. + :param use_tls: Indicates if TLS should be used for the + connection. Defaults to ``True``. + :param use_ssl: Indicates if the older SSL connection method + should be used. Defaults to ``False``. + """ + self.session_started_event.clear() + if not address: + address = (self.boundjid.host, 5222) + + return XMLStream.connect(self, address[0], address[1], + use_tls=use_tls, use_ssl=use_ssl, + reattempt=reattempt) + + def get_dns_records(self, domain, port=None): + """Get the DNS records for a domain, including SRV records. + + :param domain: The domain in question. + :param port: If the results don't include a port, use this one. + """ + if port is None: + port = self.default_port + if DNSPYTHON: + try: + record = "_xmpp-client._tcp.%s" % domain + answers = [] + for answer in dns.resolver.query(record, dns.rdatatype.SRV): + address = (answer.target.to_text()[:-1], answer.port) + answers.append((address, answer.priority, answer.weight)) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + log.warning("No SRV records for %s", domain) + answers = super(ClientXMPP, self).get_dns_records(domain, port) + except dns.exception.Timeout: + log.warning("DNS resolution timed out " + \ + "for SRV record of %s", domain) + answers = super(ClientXMPP, self).get_dns_records(domain, port) + return answers + else: + log.warning("dnspython is not installed -- " + \ + "relying on OS A record resolution") + return [((domain, port), 0, 0)] + + def register_feature(self, name, handler, restart=False, order=5000): + """Register a stream feature handler. + + :param name: The name of the stream feature. + :param handler: The function to execute if the feature is received. + :param restart: Indicates if feature processing should halt with + this feature. Defaults to ``False``. + :param order: The relative ordering in which the feature should + be negotiated. Lower values will be attempted + earlier when available. + """ + self._stream_feature_handlers[name] = (handler, restart) + self._stream_feature_order.append((order, name)) + self._stream_feature_order.sort() + + def update_roster(self, jid, name=None, subscription=None, groups=[], + block=True, timeout=None, callback=None): + """Add or change a roster item. + + :param jid: The JID of the entry to modify. + :param name: The user's nickname for this JID. + :param subscription: The subscription status. May be one of + ``'to'``, ``'from'``, ``'both'``, or + ``'none'``. If set to ``'remove'``, + the entry will be deleted. + :param groups: The roster groups that contain this item. + :param block: Specify if the roster request will block + until a response is received, or a timeout + occurs. Defaults to ``True``. + :param timeout: The length of time (in seconds) to wait + for a response before continuing if blocking + is used. Defaults to + :attr:`~sleekxmpp.xmlstream.xmlstream.XMLStream.response_timeout`. + :param callback: Optional reference to a stream handler function. + Will be executed when the roster is received. + Implies ``block=False``. + """ + return self.client_roster.update(jid, name, subscription, groups, + block, timeout, callback) + + def del_roster_item(self, jid): + """Remove an item from the roster. + + This is done by setting its subscription status to ``'remove'``. + + :param jid: The JID of the item to remove. + """ + return self.client_roster.remove(jid) + + def get_roster(self, block=True, timeout=None, callback=None): + """Request the roster from the server. + + :param block: Specify if the roster request will block until a + response is received, or a timeout occurs. + Defaults to ``True``. + :param timeout: The length of time (in seconds) to wait for a response + before continuing if blocking is used. + Defaults to + :attr:`~sleekxmpp.xmlstream.xmlstream.XMLStream.response_timeout`. + :param callback: Optional reference to a stream handler function. Will + be executed when the roster is received. + Implies ``block=False``. + """ + iq = self.Iq() + iq['type'] = 'get' + iq.enable('roster') + + if not block and callback is None: + callback = lambda resp: self._handle_roster(resp, request=True) + + response = iq.send(block, timeout, callback) + + if block: + self._handle_roster(response, request=True) + return response + + def _handle_connected(self, event=None): + #TODO: Use stream state here + self.authenticated = False + self.sessionstarted = False + self.bound = False + self.bindfail = False + self.features = set() + + def _handle_stream_features(self, features): + """Process the received stream features. + + :param features: The features stanza. + """ + for order, name in self._stream_feature_order: + if name in features['features']: + handler, restart = self._stream_feature_handlers[name] + if handler(features) and restart: + # Don't continue if the feature requires + # restarting the XML stream. + return True + + def _handle_roster(self, iq, request=False): + """Update the roster after receiving a roster stanza. + + :param iq: The roster stanza. + :param request: Indicates if this stanza is a response + to a request for the roster, and not an + empty acknowledgement from the server. + """ + if iq['type'] == 'set' or (iq['type'] == 'result' and request): + for jid in iq['roster']['items']: + item = iq['roster']['items'][jid] + roster = self.roster[iq['to'].bare] + roster[jid]['name'] = item['name'] + roster[jid]['groups'] = item['groups'] + roster[jid]['from'] = item['subscription'] in ['from', 'both'] + roster[jid]['to'] = item['subscription'] in ['to', 'both'] + roster[jid]['pending_out'] = (item['ask'] == 'subscribe') + self.event('roster_received', iq) + + self.event("roster_update", iq) + if iq['type'] == 'set': + iq.reply() + iq.enable('roster') + iq.send() + + def _handle_session_bind(self, jid): + """Set the client roster to the JID set by the server. + + :param :class:`sleekxmpp.xmlstream.jid.JID` jid: The bound JID as + dictated by the server. The same as :attr:`boundjid`. + """ + self.client_roster = self.roster[jid] + + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +ClientXMPP.updateRoster = ClientXMPP.update_roster +ClientXMPP.delRosterItem = ClientXMPP.del_roster_item +ClientXMPP.getRoster = ClientXMPP.get_roster +ClientXMPP.registerFeature = ClientXMPP.register_feature diff --git a/sleekxmpp/componentxmpp.py b/sleekxmpp/componentxmpp.py new file mode 100644 index 00000000..5b16c5ef --- /dev/null +++ b/sleekxmpp/componentxmpp.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.clientxmpp + ~~~~~~~~~~~~~~~~~~~~ + + This module provides XMPP functionality that + is specific to external server component connections. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from __future__ import absolute_import + +import logging +import base64 +import sys +import hashlib + +from sleekxmpp import plugins +from sleekxmpp import stanza +from sleekxmpp.basexmpp import BaseXMPP +from sleekxmpp.xmlstream import XMLStream, RestartStream +from sleekxmpp.xmlstream import StanzaBase, ET +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * + + +log = logging.getLogger(__name__) + + +class ComponentXMPP(BaseXMPP): + + """ + SleekXMPP's basic XMPP server component. + + Use only for good, not for evil. + + :param jid: The JID of the component. + :param secret: The secret or password for the component. + :param host: The server accepting the component. + :param port: The port used to connect to the server. + :param plugin_config: A dictionary of plugin configurations. + :param plugin_whitelist: A list of approved plugins that + will be loaded when calling + :meth:`~sleekxmpp.basexmpp.BaseXMPP.register_plugins()`. + :param use_jc_ns: Indicates if the ``'jabber:client'`` namespace + should be used instead of the standard + ``'jabber:component:accept'`` namespace. + Defaults to ``False``. + """ + + def __init__(self, jid, secret, host=None, port=None, + plugin_config={}, plugin_whitelist=[], use_jc_ns=False): + if use_jc_ns: + default_ns = 'jabber:client' + else: + default_ns = 'jabber:component:accept' + BaseXMPP.__init__(self, jid, default_ns) + + self.auto_authorize = None + self.stream_header = "<stream:stream %s %s to='%s'>" % ( + 'xmlns="jabber:component:accept"', + 'xmlns:stream="%s"' % self.stream_ns, + jid) + self.stream_footer = "</stream:stream>" + self.server_host = host + self.server_port = port + self.secret = secret + + self.plugin_config = plugin_config + self.plugin_whitelist = plugin_whitelist + self.is_component = True + + self.register_handler( + Callback('Handshake', + MatchXPath('{jabber:component:accept}handshake'), + self._handle_handshake)) + self.add_event_handler('presence_probe', + self._handle_probe) + + def connect(self, host=None, port=None, use_ssl=False, + use_tls=True, reattempt=True): + """Connect to the server. + + Setting ``reattempt`` to ``True`` will cause connection attempts to + be made every second until a successful connection is established. + + :param host: The name of the desired server for the connection. + Defaults to :attr:`server_host`. + :param port: Port to connect to on the server. + Defauts to :attr:`server_port`. + :param use_ssl: Flag indicating if SSL should be used by connecting + directly to a port using SSL. + :param use_tls: Flag indicating if TLS should be used, allowing for + connecting to a port without using SSL immediately and + later upgrading the connection. + :param reattempt: Flag indicating if the socket should reconnect + after disconnections. + """ + if host is None: + host = self.server_host + if port is None: + port = self.server_port + log.debug("Connecting to %s:%s", host, port) + return XMLStream.connect(self, host=host, port=port, + use_ssl=use_ssl, + use_tls=use_tls, + reattempt=reattempt) + + def incoming_filter(self, xml): + """ + Pre-process incoming XML stanzas by converting any + ``'jabber:client'`` namespaced elements to the component's + default namespace. + + :param xml: The XML stanza to pre-process. + """ + if xml.tag.startswith('{jabber:client}'): + xml.tag = xml.tag.replace('jabber:client', self.default_ns) + + # The incoming_filter call is only made on top level stanza + # elements. So we manually continue filtering on sub-elements. + for sub in xml: + self.incoming_filter(sub) + + return xml + + def start_stream_handler(self, xml): + """ + Once the streams are established, attempt to handshake + with the server to be accepted as a component. + + :param xml: The incoming stream's root element. + """ + BaseXMPP.start_stream_handler(self, xml) + + # Construct a hash of the stream ID and the component secret. + sid = xml.get('id', '') + pre_hash = '%s%s' % (sid, self.secret) + if sys.version_info >= (3, 0): + # Handle Unicode byte encoding in Python 3. + pre_hash = bytes(pre_hash, 'utf-8') + + handshake = ET.Element('{jabber:component:accept}handshake') + handshake.text = hashlib.sha1(pre_hash).hexdigest().lower() + self.send_xml(handshake, now=True) + + def _handle_handshake(self, xml): + """The handshake has been accepted. + + :param xml: The reply handshake stanza. + """ + self.session_started_event.set() + self.event("session_start") + + def _handle_probe(self, presence): + pto = presence['to'].bare + pfrom = presence['from'].bare + self.roster[pto][pfrom].handle_probe(presence) diff --git a/sleekxmpp/exceptions.py b/sleekxmpp/exceptions.py new file mode 100644 index 00000000..6bac1e40 --- /dev/null +++ b/sleekxmpp/exceptions.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.exceptions + ~~~~~~~~~~~~~~~~~~~~ + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + + +class XMPPError(Exception): + + """ + A generic exception that may be raised while processing an XMPP stanza + to indicate that an error response stanza should be sent. + + The exception method for stanza objects extending + :class:`~sleekxmpp.stanza.rootstanza.RootStanza` will create an error + stanza and initialize any additional substanzas using the extension + information included in the exception. + + Meant for use in SleekXMPP plugins and applications using SleekXMPP. + + Extension information can be included to add additional XML elements + to the generated error stanza. + + :param condition: The XMPP defined error condition. + Defaults to ``'undefined-condition'``. + :param text: Human readable text describing the error. + :param etype: The XMPP error type, such as ``'cancel'`` or ``'modify'``. + Defaults to ``'cancel'``. + :param extension: Tag name of the extension's XML content. + :param extension_ns: XML namespace of the extensions' XML content. + :param extension_args: Content and attributes for the extension + element. Same as the additional arguments to + the :class:`~xml.etree.ElementTree.Element` + constructor. + :param clear: Indicates if the stanza's contents should be + removed before replying with an error. + Defaults to ``True``. + """ + + def __init__(self, condition='undefined-condition', text=None, + etype='cancel', extension=None, extension_ns=None, + extension_args=None, clear=True): + if extension_args is None: + extension_args = {} + + self.condition = condition + self.text = text + self.etype = etype + self.clear = clear + self.extension = extension + self.extension_ns = extension_ns + self.extension_args = extension_args + + +class IqTimeout(XMPPError): + + """ + An exception which indicates that an IQ request response has not been + received within the alloted time window. + """ + + def __init__(self, iq): + super(IqTimeout, self).__init__( + condition='remote-server-timeout', + etype='cancel') + + #: The :class:`~sleekxmpp.stanza.iq.Iq` stanza whose response + #: did not arrive before the timeout expired. + self.iq = iq + +class IqError(XMPPError): + + """ + An exception raised when an Iq stanza of type 'error' is received + after making a blocking send call. + """ + + def __init__(self, iq): + super(IqError, self).__init__( + condition=iq['error']['condition'], + text=iq['error']['text'], + etype=iq['error']['type']) + + #: The :class:`~sleekxmpp.stanza.iq.Iq` error result stanza. + self.iq = iq diff --git a/sleekxmpp/features/__init__.py b/sleekxmpp/features/__init__.py new file mode 100644 index 00000000..5bfe173d --- /dev/null +++ b/sleekxmpp/features/__init__.py @@ -0,0 +1,9 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +__all__ = ['feature_starttls', 'feature_mechanisms', 'feature_bind'] diff --git a/sleekxmpp/features/feature_bind/__init__.py b/sleekxmpp/features/feature_bind/__init__.py new file mode 100644 index 00000000..aa854f87 --- /dev/null +++ b/sleekxmpp/features/feature_bind/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.features.feature_bind.bind import feature_bind +from sleekxmpp.features.feature_bind.stanza import Bind diff --git a/sleekxmpp/features/feature_bind/bind.py b/sleekxmpp/features/feature_bind/bind.py new file mode 100644 index 00000000..72897131 --- /dev/null +++ b/sleekxmpp/features/feature_bind/bind.py @@ -0,0 +1,65 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +from sleekxmpp.stanza import Iq, StreamFeatures +from sleekxmpp.features.feature_bind import stanza +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * +from sleekxmpp.plugins.base import base_plugin + + +log = logging.getLogger(__name__) + + +class feature_bind(base_plugin): + + def plugin_init(self): + self.name = 'Bind Resource' + self.rfc = '6120' + self.description = 'Resource Binding Stream Feature' + self.stanza = stanza + + self.xmpp.register_feature('bind', + self._handle_bind_resource, + restart=False, + order=10000) + + register_stanza_plugin(Iq, stanza.Bind) + register_stanza_plugin(StreamFeatures, stanza.Bind) + + def _handle_bind_resource(self, features): + """ + Handle requesting a specific resource. + + Arguments: + features -- The stream features stanza. + """ + log.debug("Requesting resource: %s", self.xmpp.boundjid.resource) + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq.enable('bind') + if self.xmpp.boundjid.resource: + iq['bind']['resource'] = self.xmpp.boundjid.resource + response = iq.send(now=True) + + self.xmpp.set_jid(response['bind']['jid']) + self.xmpp.bound = True + self.xmpp.event('session_bind', self.xmpp.boundjid, direct=True) + + self.xmpp.features.add('bind') + + log.info("Node set to: %s", self.xmpp.boundjid.full) + + if 'session' not in features['features']: + log.debug("Established Session") + self.xmpp.sessionstarted = True + self.xmpp.session_started_event.set() + self.xmpp.event("session_start") diff --git a/sleekxmpp/features/feature_bind/stanza.py b/sleekxmpp/features/feature_bind/stanza.py new file mode 100644 index 00000000..2c1484e0 --- /dev/null +++ b/sleekxmpp/features/feature_bind/stanza.py @@ -0,0 +1,22 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Iq, StreamFeatures +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Bind(ElementBase): + + """ + """ + + name = 'bind' + namespace = 'urn:ietf:params:xml:ns:xmpp-bind' + interfaces = set(('resource', 'jid')) + sub_interfaces = interfaces + plugin_attrib = 'bind' diff --git a/sleekxmpp/features/feature_mechanisms/__init__.py b/sleekxmpp/features/feature_mechanisms/__init__.py new file mode 100644 index 00000000..5379ef4e --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/__init__.py @@ -0,0 +1,13 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.features.feature_mechanisms.mechanisms import feature_mechanisms +from sleekxmpp.features.feature_mechanisms.stanza import Mechanisms +from sleekxmpp.features.feature_mechanisms.stanza import Auth +from sleekxmpp.features.feature_mechanisms.stanza import Success +from sleekxmpp.features.feature_mechanisms.stanza import Failure diff --git a/sleekxmpp/features/feature_mechanisms/mechanisms.py b/sleekxmpp/features/feature_mechanisms/mechanisms.py new file mode 100644 index 00000000..deff5d30 --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/mechanisms.py @@ -0,0 +1,131 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +from sleekxmpp.thirdparty import suelta + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import RestartStream, register_stanza_plugin +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.features.feature_mechanisms import stanza + + +log = logging.getLogger(__name__) + + +class feature_mechanisms(base_plugin): + + def plugin_init(self): + self.name = 'SASL Mechanisms' + self.rfc = '6120' + self.description = "SASL Stream Feature" + self.stanza = stanza + + self.use_mech = self.config.get('use_mech', None) + + def tls_active(): + return 'starttls' in self.xmpp.features + + def basic_callback(mech, values): + if 'username' in values: + values['username'] = self.xmpp.boundjid.user + if 'password' in values: + values['password'] = self.xmpp.password + if 'access_token' in values: + values['access_token'] = self.xmpp.password + mech.fulfill(values) + + sasl_callback = self.config.get('sasl_callback', None) + if sasl_callback is None: + sasl_callback = basic_callback + + self.mech = None + self.sasl = suelta.SASL(self.xmpp.boundjid.domain, 'xmpp', + username=self.xmpp.boundjid.user, + sec_query=suelta.sec_query_allow, + request_values=sasl_callback, + tls_active=tls_active, + mech=self.use_mech) + + register_stanza_plugin(StreamFeatures, stanza.Mechanisms) + + self.xmpp.register_stanza(stanza.Success) + self.xmpp.register_stanza(stanza.Failure) + self.xmpp.register_stanza(stanza.Auth) + self.xmpp.register_stanza(stanza.Challenge) + self.xmpp.register_stanza(stanza.Response) + + self.xmpp.register_handler( + Callback('SASL Success', + MatchXPath(stanza.Success.tag_name()), + self._handle_success, + instream=True, + once=True)) + self.xmpp.register_handler( + Callback('SASL Failure', + MatchXPath(stanza.Failure.tag_name()), + self._handle_fail, + instream=True, + once=True)) + self.xmpp.register_handler( + Callback('SASL Challenge', + MatchXPath(stanza.Challenge.tag_name()), + self._handle_challenge)) + + self.xmpp.register_feature('mechanisms', + self._handle_sasl_auth, + restart=True, + order=self.config.get('order', 100)) + + def _handle_sasl_auth(self, features): + """ + Handle authenticating using SASL. + + Arguments: + features -- The stream features stanza. + """ + if 'mechanisms' in self.xmpp.features: + # SASL authentication has already succeeded, but the + # server has incorrectly offered it again. + return False + + mech_list = features['mechanisms'] + self.mech = self.sasl.choose_mechanism(mech_list) + + if self.mech is not None: + resp = stanza.Auth(self.xmpp) + resp['mechanism'] = self.mech.name + resp['value'] = self.mech.process() + resp.send(now=True) + else: + log.error("No appropriate login method.") + self.xmpp.event("no_auth", direct=True) + self.xmpp.disconnect() + return True + + def _handle_challenge(self, stanza): + """SASL challenge received. Process and send response.""" + resp = self.stanza.Response(self.xmpp) + resp['value'] = self.mech.process(stanza['value']) + resp.send(now=True) + + def _handle_success(self, stanza): + """SASL authentication succeeded. Restart the stream.""" + self.xmpp.authenticated = True + self.xmpp.features.add('mechanisms') + raise RestartStream() + + def _handle_fail(self, stanza): + """SASL authentication failed. Disconnect and shutdown.""" + log.info("Authentication failed: %s", stanza['condition']) + self.xmpp.event("failed_auth", stanza, direct=True) + self.xmpp.disconnect() + return True diff --git a/sleekxmpp/features/feature_mechanisms/stanza/__init__.py b/sleekxmpp/features/feature_mechanisms/stanza/__init__.py new file mode 100644 index 00000000..8b80f358 --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/stanza/__init__.py @@ -0,0 +1,15 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +from sleekxmpp.features.feature_mechanisms.stanza.mechanisms import Mechanisms +from sleekxmpp.features.feature_mechanisms.stanza.auth import Auth +from sleekxmpp.features.feature_mechanisms.stanza.success import Success +from sleekxmpp.features.feature_mechanisms.stanza.failure import Failure +from sleekxmpp.features.feature_mechanisms.stanza.challenge import Challenge +from sleekxmpp.features.feature_mechanisms.stanza.response import Response diff --git a/sleekxmpp/features/feature_mechanisms/stanza/auth.py b/sleekxmpp/features/feature_mechanisms/stanza/auth.py new file mode 100644 index 00000000..d2a981f9 --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/stanza/auth.py @@ -0,0 +1,49 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import base64 + +from sleekxmpp.thirdparty.suelta.util import bytes + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class Auth(StanzaBase): + + """ + """ + + name = 'auth' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set(('mechanism', 'value')) + plugin_attrib = name + + #: Some SASL mechs require sending values as is, + #: without converting base64. + plain_mechs = set(['X-MESSENGER-OAUTH2']) + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def get_value(self): + if not self['mechanism'] in self.plain_mechs: + return base64.b64decode(bytes(self.xml.text)) + else: + return self.xml.text + + def set_value(self, values): + if not self['mechanism'] in self.plain_mechs: + self.xml.text = bytes(base64.b64encode(values)).decode('utf-8') + else: + self.xml.text = bytes(values).decode('utf-8') + + def del_value(self): + self.xml.text = '' diff --git a/sleekxmpp/features/feature_mechanisms/stanza/challenge.py b/sleekxmpp/features/feature_mechanisms/stanza/challenge.py new file mode 100644 index 00000000..82af869f --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/stanza/challenge.py @@ -0,0 +1,39 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import base64 + +from sleekxmpp.thirdparty.suelta.util import bytes + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class Challenge(StanzaBase): + + """ + """ + + name = 'challenge' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set(('value',)) + plugin_attrib = name + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def get_value(self): + return base64.b64decode(bytes(self.xml.text)) + + def set_value(self, values): + self.xml.text = bytes(base64.b64encode(values)).decode('utf-8') + + def del_value(self): + self.xml.text = '' diff --git a/sleekxmpp/features/feature_mechanisms/stanza/failure.py b/sleekxmpp/features/feature_mechanisms/stanza/failure.py new file mode 100644 index 00000000..027cc5af --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/stanza/failure.py @@ -0,0 +1,78 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class Failure(StanzaBase): + + """ + """ + + name = 'failure' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set(('condition', 'text')) + plugin_attrib = name + sub_interfaces = set(('text',)) + conditions = set(('aborted', 'account-disabled', 'credentials-expired', + 'encryption-required', 'incorrect-encoding', 'invalid-authzid', + 'invalid-mechanism', 'malformed-request', 'mechansism-too-weak', + 'not-authorized', 'temporary-auth-failure')) + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides ElementBase.setup. + + Sets a default error type and condition, and changes the + parent stanza's type to 'error'. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + # StanzaBase overrides self.namespace + self.namespace = Failure.namespace + + if StanzaBase.setup(self, xml): + #If we had to generate XML then set default values. + self['condition'] = 'not-authorized' + + self.xml.tag = self.tag_name() + + def get_condition(self): + """Return the condition element's name.""" + for child in self.xml.getchildren(): + if "{%s}" % self.namespace in child.tag: + cond = child.tag.split('}', 1)[-1] + if cond in self.conditions: + return cond + return 'not-authorized' + + def set_condition(self, value): + """ + Set the tag name of the condition element. + + Arguments: + value -- The tag name of the condition element. + """ + if value in self.conditions: + del self['condition'] + self.xml.append(ET.Element("{%s}%s" % (self.namespace, value))) + return self + + def del_condition(self): + """Remove the condition element.""" + for child in self.xml.getchildren(): + if "{%s}" % self.condition_ns in child.tag: + tag = child.tag.split('}', 1)[-1] + if tag in self.conditions: + self.xml.remove(child) + return self diff --git a/sleekxmpp/features/feature_mechanisms/stanza/mechanisms.py b/sleekxmpp/features/feature_mechanisms/stanza/mechanisms.py new file mode 100644 index 00000000..c09cafbd --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/stanza/mechanisms.py @@ -0,0 +1,55 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class Mechanisms(ElementBase): + + """ + """ + + name = 'mechanisms' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set(('mechanisms', 'required')) + plugin_attrib = name + is_extension = True + + def get_required(self): + """ + """ + return True + + def get_mechanisms(self): + """ + """ + results = [] + mechs = self.findall('{%s}mechanism' % self.namespace) + if mechs: + for mech in mechs: + results.append(mech.text) + return results + + def set_mechanisms(self, values): + """ + """ + self.del_mechanisms() + for val in values: + mech = ET.Element('{%s}mechanism' % self.namespace) + mech.text = val + self.append(mech) + + def del_mechanisms(self): + """ + """ + mechs = self.findall('{%s}mechanism' % self.namespace) + if mechs: + for mech in mechs: + self.xml.remove(mech) diff --git a/sleekxmpp/features/feature_mechanisms/stanza/response.py b/sleekxmpp/features/feature_mechanisms/stanza/response.py new file mode 100644 index 00000000..45bb8207 --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/stanza/response.py @@ -0,0 +1,39 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import base64 + +from sleekxmpp.thirdparty.suelta.util import bytes + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class Response(StanzaBase): + + """ + """ + + name = 'response' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set(('value',)) + plugin_attrib = name + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def get_value(self): + return base64.b64decode(bytes(self.xml.text)) + + def set_value(self, values): + self.xml.text = bytes(base64.b64encode(values)).decode('utf-8') + + def del_value(self): + self.xml.text = '' diff --git a/sleekxmpp/features/feature_mechanisms/stanza/success.py b/sleekxmpp/features/feature_mechanisms/stanza/success.py new file mode 100644 index 00000000..028e28a3 --- /dev/null +++ b/sleekxmpp/features/feature_mechanisms/stanza/success.py @@ -0,0 +1,26 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class Success(StanzaBase): + + """ + """ + + name = 'success' + namespace = 'urn:ietf:params:xml:ns:xmpp-sasl' + interfaces = set() + plugin_attrib = name + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() diff --git a/sleekxmpp/features/feature_session/__init__.py b/sleekxmpp/features/feature_session/__init__.py new file mode 100644 index 00000000..3c84baed --- /dev/null +++ b/sleekxmpp/features/feature_session/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.features.feature_session.session import feature_session +from sleekxmpp.features.feature_session.stanza import Session diff --git a/sleekxmpp/features/feature_session/session.py b/sleekxmpp/features/feature_session/session.py new file mode 100644 index 00000000..0daec5da --- /dev/null +++ b/sleekxmpp/features/feature_session/session.py @@ -0,0 +1,56 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +from sleekxmpp.stanza import Iq, StreamFeatures +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * +from sleekxmpp.plugins.base import base_plugin + +from sleekxmpp.features.feature_session import stanza + + +log = logging.getLogger(__name__) + + +class feature_session(base_plugin): + + def plugin_init(self): + self.name = 'Start Session' + self.rfc = '3920' + self.description = 'Start Session Stream Feature' + self.stanza = stanza + + self.xmpp.register_feature('session', + self._handle_start_session, + restart=False, + order=10001) + + register_stanza_plugin(Iq, stanza.Session) + register_stanza_plugin(StreamFeatures, stanza.Session) + + def _handle_start_session(self, features): + """ + Handle the start of the session. + + Arguments: + feature -- The stream features element. + """ + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq.enable('session') + response = iq.send(now=True) + + self.xmpp.features.add('session') + + log.debug("Established Session") + self.xmpp.sessionstarted = True + self.xmpp.session_started_event.set() + self.xmpp.event("session_start") diff --git a/sleekxmpp/features/feature_session/stanza.py b/sleekxmpp/features/feature_session/stanza.py new file mode 100644 index 00000000..40ea583d --- /dev/null +++ b/sleekxmpp/features/feature_session/stanza.py @@ -0,0 +1,21 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Iq, StreamFeatures +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Session(ElementBase): + + """ + """ + + name = 'session' + namespace = 'urn:ietf:params:xml:ns:xmpp-session' + interfaces = set() + plugin_attrib = 'session' diff --git a/sleekxmpp/features/feature_starttls/__init__.py b/sleekxmpp/features/feature_starttls/__init__.py new file mode 100644 index 00000000..4ae89433 --- /dev/null +++ b/sleekxmpp/features/feature_starttls/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.features.feature_starttls.starttls import feature_starttls +from sleekxmpp.features.feature_starttls.stanza import * diff --git a/sleekxmpp/features/feature_starttls/stanza.py b/sleekxmpp/features/feature_starttls/stanza.py new file mode 100644 index 00000000..8b09ad94 --- /dev/null +++ b/sleekxmpp/features/feature_starttls/stanza.py @@ -0,0 +1,47 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import StanzaBase, ElementBase +from sleekxmpp.xmlstream import register_stanza_plugin + + +class STARTTLS(ElementBase): + + """ + """ + + name = 'starttls' + namespace = 'urn:ietf:params:xml:ns:xmpp-tls' + interfaces = set(('required',)) + plugin_attrib = name + + def get_required(self): + """ + """ + return True + + +class Proceed(StanzaBase): + + """ + """ + + name = 'proceed' + namespace = 'urn:ietf:params:xml:ns:xmpp-tls' + interfaces = set() + + +class Failure(StanzaBase): + + """ + """ + + name = 'failure' + namespace = 'urn:ietf:params:xml:ns:xmpp-tls' + interfaces = set() diff --git a/sleekxmpp/features/feature_starttls/starttls.py b/sleekxmpp/features/feature_starttls/starttls.py new file mode 100644 index 00000000..4e2b6621 --- /dev/null +++ b/sleekxmpp/features/feature_starttls/starttls.py @@ -0,0 +1,70 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +from sleekxmpp.stanza import StreamFeatures +from sleekxmpp.xmlstream import RestartStream, register_stanza_plugin +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.features.feature_starttls import stanza + + +log = logging.getLogger(__name__) + + +class feature_starttls(base_plugin): + + def plugin_init(self): + self.name = "STARTTLS" + self.rfc = '6120' + self.description = "STARTTLS Stream Feature" + self.stanza = stanza + + self.xmpp.register_handler( + Callback('STARTTLS Proceed', + MatchXPath(stanza.Proceed.tag_name()), + self._handle_starttls_proceed, + instream=True)) + self.xmpp.register_feature('starttls', + self._handle_starttls, + restart=True, + order=self.config.get('order', 0)) + + self.xmpp.register_stanza(stanza.Proceed) + self.xmpp.register_stanza(stanza.Failure) + register_stanza_plugin(StreamFeatures, stanza.STARTTLS) + + def _handle_starttls(self, features): + """ + Handle notification that the server supports TLS. + + Arguments: + features -- The stream:features element. + """ + if 'starttls' in self.xmpp.features: + # We have already negotiated TLS, but the server is + # offering it again, against spec. + return False + elif not self.xmpp.use_tls: + return False + elif self.xmpp.ssl_support: + self.xmpp.send(features['starttls'], now=True) + return True + else: + log.warning("The module tlslite is required to log in" + \ + " to some servers, and has not been found.") + return False + + def _handle_starttls_proceed(self, proceed): + """Restart the XML stream when TLS is accepted.""" + log.debug("Starting TLS") + if self.xmpp.start_tls(): + self.xmpp.features.add('starttls') + raise RestartStream() diff --git a/sleekxmpp/plugins/__init__.py b/sleekxmpp/plugins/__init__.py new file mode 100644 index 00000000..0b2fa119 --- /dev/null +++ b/sleekxmpp/plugins/__init__.py @@ -0,0 +1,36 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +__all__ = [ + # Non-standard + 'gmail_notify', # Gmail searching and notifications + + # XEPS + 'xep_0004', # Data Forms + 'xep_0009', # Jabber-RPC + 'xep_0012', # Last Activity + 'xep_0030', # Service Discovery + 'xep_0033', # Extended Stanza Addresses + 'xep_0045', # Multi-User Chat (Client) + 'xep_0050', # Ad-hoc Commands + 'xep_0059', # Result Set Management + 'xep_0060', # Pubsub (Client) + 'xep_0066', # Out-of-band Transfer +# 'xep_0078', # Non-SASL auth. Don't automatically load + 'xep_0082', # XMPP Date and Time Profiles + 'xep_0085', # Chat State Notifications + 'xep_0086', # Legacy Error Codes + 'xep_0092', # Software Version + 'xep_0115', # Entity Capabilities + 'xep_0128', # Extended Service Discovery + 'xep_0199', # Ping + 'xep_0202', # Entity Time + 'xep_0203', # Delayed Delivery + 'xep_0224', # Attention + 'xep_0249', # Direct MUC Invitations +] diff --git a/sleekxmpp/plugins/base.py b/sleekxmpp/plugins/base.py new file mode 100644 index 00000000..561421d8 --- /dev/null +++ b/sleekxmpp/plugins/base.py @@ -0,0 +1,91 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +class base_plugin(object): + + """ + The base_plugin class serves as a base for user created plugins + that provide support for existing or experimental XEPS. + + Each plugin has a dictionary for configuration options, as well + as a name and description. + + The lifecycle of a plugin is: + 1. The plugin is instantiated during registration. + 2. Once the XML stream begins processing, the method + plugin_init() is called (if the plugin is configured + as enabled with {'enable': True}). + 3. After all plugins have been initialized, the + method post_init() is called. + + Recommended event handlers: + session_start -- Plugins which require the use of the current + bound JID SHOULD wait for the session_start + event to perform any initialization (or + resetting). This is a transitive recommendation, + plugins that use other plugins which use the + bound JID should also wait for session_start + before making such calls. + session_end -- If the plugin keeps any per-session state, + such as joined MUC rooms, such state SHOULD + be cleared when the session_end event is raised. + + Attributes: + xep -- The XEP number the plugin implements, if any. + description -- A short description of the plugin, typically + the long name of the implemented XEP. + xmpp -- The main SleekXMPP instance. + config -- A dictionary of custom configuration values. + The value 'enable' is special and controls + whether or not the plugin is initialized + after registration. + post_initted -- Executed after all plugins have been initialized + to handle any cross-plugin interactions, such as + registering service discovery items. + enable -- Indicates that the plugin is enabled for use and + will be initialized after registration. + + Methods: + plugin_init -- Initialize the plugin state. + post_init -- Handle any cross-plugin concerns. + """ + + def __init__(self, xmpp, config=None): + """ + Instantiate a new plugin and store the given configuration. + + Arguments: + xmpp -- The main SleekXMPP instance. + config -- A dictionary of configuration values. + """ + if config is None: + config = {} + self.xep = None + self.rfc = None + self.description = 'Base Plugin' + self.xmpp = xmpp + self.config = config + self.post_inited = False + self.enable = config.get('enable', True) + if self.enable: + self.plugin_init() + + def plugin_init(self): + """ + Initialize plugin state, such as registering any stream or + event handlers, or new stanza types. + """ + pass + + def post_init(self): + """ + Perform any cross-plugin interactions, such as registering + service discovery identities or items. + """ + self.post_inited = True diff --git a/sleekxmpp/plugins/gmail_notify.py b/sleekxmpp/plugins/gmail_notify.py new file mode 100644 index 00000000..fc97a2ab --- /dev/null +++ b/sleekxmpp/plugins/gmail_notify.py @@ -0,0 +1,149 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +from . import base +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.iq import Iq + + +log = logging.getLogger(__name__) + + +class GmailQuery(ElementBase): + namespace = 'google:mail:notify' + name = 'query' + plugin_attrib = 'gmail' + interfaces = set(('newer-than-time', 'newer-than-tid', 'q', 'search')) + + def getSearch(self): + return self['q'] + + def setSearch(self, search): + self['q'] = search + + def delSearch(self): + del self['q'] + + +class MailBox(ElementBase): + namespace = 'google:mail:notify' + name = 'mailbox' + plugin_attrib = 'mailbox' + interfaces = set(('result-time', 'total-matched', 'total-estimate', + 'url', 'threads', 'matched', 'estimate')) + + def getThreads(self): + threads = [] + for threadXML in self.xml.findall('{%s}%s' % (MailThread.namespace, + MailThread.name)): + threads.append(MailThread(xml=threadXML, parent=None)) + return threads + + def getMatched(self): + return self['total-matched'] + + def getEstimate(self): + return self['total-estimate'] == '1' + + +class MailThread(ElementBase): + namespace = 'google:mail:notify' + name = 'mail-thread-info' + plugin_attrib = 'thread' + interfaces = set(('tid', 'participation', 'messages', 'date', + 'senders', 'url', 'labels', 'subject', 'snippet')) + sub_interfaces = set(('labels', 'subject', 'snippet')) + + def getSenders(self): + senders = [] + sendersXML = self.xml.find('{%s}senders' % self.namespace) + if sendersXML is not None: + for senderXML in sendersXML.findall('{%s}sender' % self.namespace): + senders.append(MailSender(xml=senderXML, parent=None)) + return senders + + +class MailSender(ElementBase): + namespace = 'google:mail:notify' + name = 'sender' + plugin_attrib = 'sender' + interfaces = set(('address', 'name', 'originator', 'unread')) + + def getOriginator(self): + return self.xml.attrib.get('originator', '0') == '1' + + def getUnread(self): + return self.xml.attrib.get('unread', '0') == '1' + + +class NewMail(ElementBase): + namespace = 'google:mail:notify' + name = 'new-mail' + plugin_attrib = 'new-mail' + + +class gmail_notify(base.base_plugin): + """ + Google Talk: Gmail Notifications + """ + + def plugin_init(self): + self.description = 'Google Talk: Gmail Notifications' + + self.xmpp.registerHandler( + Callback('Gmail Result', + MatchXPath('{%s}iq/{%s}%s' % (self.xmpp.default_ns, + MailBox.namespace, + MailBox.name)), + self.handle_gmail)) + + self.xmpp.registerHandler( + Callback('Gmail New Mail', + MatchXPath('{%s}iq/{%s}%s' % (self.xmpp.default_ns, + NewMail.namespace, + NewMail.name)), + self.handle_new_mail)) + + registerStanzaPlugin(Iq, GmailQuery) + registerStanzaPlugin(Iq, MailBox) + registerStanzaPlugin(Iq, NewMail) + + self.last_result_time = None + + def handle_gmail(self, iq): + mailbox = iq['mailbox'] + approx = ' approximately' if mailbox['estimated'] else '' + log.info('Gmail: Received%s %s emails', approx, mailbox['total-matched']) + self.last_result_time = mailbox['result-time'] + self.xmpp.event('gmail_messages', iq) + + def handle_new_mail(self, iq): + log.info("Gmail: New emails received!") + self.xmpp.event('gmail_notify') + self.checkEmail() + + def getEmail(self, query=None): + return self.search(query) + + def checkEmail(self): + return self.search(newer=self.last_result_time) + + def search(self, query=None, newer=None): + if query is None: + log.info("Gmail: Checking for new emails") + else: + log.info('Gmail: Searching for emails matching: "%s"', query) + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['to'] = self.xmpp.boundjid.bare + iq['gmail']['q'] = query + iq['gmail']['newer-than-time'] = newer + return iq.send() diff --git a/sleekxmpp/plugins/jobs.py b/sleekxmpp/plugins/jobs.py new file mode 100644 index 00000000..cb9deba8 --- /dev/null +++ b/sleekxmpp/plugins/jobs.py @@ -0,0 +1,49 @@ +from . import base +import logging +from xml.etree import cElementTree as ET + + +log = logging.getLogger(__name__) + + +class jobs(base.base_plugin): + def plugin_init(self): + self.xep = 'pubsubjob' + self.description = "Job distribution over Pubsub" + + def post_init(self): + pass + #TODO add event + + def createJobNode(self, host, jid, node, config=None): + pass + + def createJob(self, host, node, jobid=None, payload=None): + return self.xmpp.plugin['xep_0060'].setItem(host, node, ((jobid, payload),)) + + def claimJob(self, host, node, jobid, ifrom=None): + return self._setState(host, node, jobid, ET.Element('{http://andyet.net/protocol/pubsubjob}claimed')) + + def unclaimJob(self, host, node, jobid): + return self._setState(host, node, jobid, ET.Element('{http://andyet.net/protocol/pubsubjob}unclaimed')) + + def finishJob(self, host, node, jobid, payload=None): + finished = ET.Element('{http://andyet.net/protocol/pubsubjob}finished') + if payload is not None: + finished.append(payload) + return self._setState(host, node, jobid, finished) + + def _setState(self, host, node, jobid, state, ifrom=None): + iq = self.xmpp.Iq() + iq['to'] = host + if ifrom: iq['from'] = ifrom + iq['type'] = 'set' + iq['psstate']['node'] = node + iq['psstate']['item'] = jobid + iq['psstate']['payload'] = state + result = iq.send() + if result is None or type(result) == bool or result['type'] != 'result': + log.error("Unable to change %s:%s to %s", node, jobid, state) + return False + return True + diff --git a/sleekxmpp/plugins/old_0004.py b/sleekxmpp/plugins/old_0004.py new file mode 100644 index 00000000..7f086866 --- /dev/null +++ b/sleekxmpp/plugins/old_0004.py @@ -0,0 +1,421 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from . import base +import logging +from xml.etree import cElementTree as ET +import copy +import logging +#TODO support item groups and results + + +log = logging.getLogger(__name__) + + +class old_0004(base.base_plugin): + + def plugin_init(self): + self.xep = '0004' + self.description = '*Deprecated Data Forms' + self.xmpp.add_handler("<message><x xmlns='jabber:x:data' /></message>", self.handler_message_xform, name='Old Message Form') + + def post_init(self): + base.base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature('jabber:x:data') + log.warning("This implementation of XEP-0004 is deprecated.") + + def handler_message_xform(self, xml): + object = self.handle_form(xml) + self.xmpp.event("message_form", object) + + def handler_presence_xform(self, xml): + object = self.handle_form(xml) + self.xmpp.event("presence_form", object) + + def handle_form(self, xml): + xmlform = xml.find('{jabber:x:data}x') + object = self.buildForm(xmlform) + self.xmpp.event("message_xform", object) + return object + + def buildForm(self, xml): + form = Form(ftype=xml.attrib['type']) + form.fromXML(xml) + return form + + def makeForm(self, ftype='form', title='', instructions=''): + return Form(self.xmpp, ftype, title, instructions) + +class FieldContainer(object): + def __init__(self, stanza = 'form'): + self.fields = [] + self.field = {} + self.stanza = stanza + + def addField(self, var, ftype='text-single', label='', desc='', required=False, value=None): + self.field[var] = FormField(var, ftype, label, desc, required, value) + self.fields.append(self.field[var]) + return self.field[var] + + def buildField(self, xml): + self.field[xml.get('var', '__unnamed__')] = FormField(xml.get('var', '__unnamed__'), xml.get('type', 'text-single')) + self.fields.append(self.field[xml.get('var', '__unnamed__')]) + self.field[xml.get('var', '__unnamed__')].buildField(xml) + + def buildContainer(self, xml): + self.stanza = xml.tag + for field in xml.findall('{jabber:x:data}field'): + self.buildField(field) + + def getXML(self, ftype): + container = ET.Element(self.stanza) + for field in self.fields: + container.append(field.getXML(ftype)) + return container + +class Form(FieldContainer): + types = ('form', 'submit', 'cancel', 'result') + def __init__(self, xmpp=None, ftype='form', title='', instructions=''): + if not ftype in self.types: + raise ValueError("Invalid Form Type") + FieldContainer.__init__(self) + self.xmpp = xmpp + self.type = ftype + self.title = title + self.instructions = instructions + self.reported = [] + self.items = [] + + def merge(self, form2): + form1 = Form(ftype=self.type) + form1.fromXML(self.getXML(self.type)) + for field in form2.fields: + if not field.var in form1.field: + form1.addField(field.var, field.type, field.label, field.desc, field.required, field.value) + else: + form1.field[field.var].value = field.value + for option, label in field.options: + if (option, label) not in form1.field[field.var].options: + form1.fields[field.var].addOption(option, label) + return form1 + + def copy(self): + newform = Form(ftype=self.type) + newform.fromXML(self.getXML(self.type)) + return newform + + def update(self, form): + values = form.getValues() + for var in values: + if var in self.fields: + self.fields[var].setValue(self.fields[var]) + + def getValues(self): + result = {} + for field in self.fields: + value = field.value + if len(value) == 1: + value = value[0] + result[field.var] = value + return result + + def setValues(self, values={}): + for field in values: + if field in self.field: + if isinstance(values[field], list) or isinstance(values[field], tuple): + for value in values[field]: + self.field[field].setValue(value) + else: + self.field[field].setValue(values[field]) + + def fromXML(self, xml): + self.buildForm(xml) + + def addItem(self): + newitem = FieldContainer('item') + self.items.append(newitem) + return newitem + + def buildItem(self, xml): + newitem = self.addItem() + newitem.buildContainer(xml) + + def addReported(self): + reported = FieldContainer('reported') + self.reported.append(reported) + return reported + + def buildReported(self, xml): + reported = self.addReported() + reported.buildContainer(xml) + + def setTitle(self, title): + self.title = title + + def setInstructions(self, instructions): + self.instructions = instructions + + def setType(self, ftype): + self.type = ftype + + def getXMLMessage(self, to): + msg = self.xmpp.makeMessage(to) + msg.append(self.getXML()) + return msg + + def buildForm(self, xml): + self.type = xml.get('type', 'form') + if xml.find('{jabber:x:data}title') is not None: + self.setTitle(xml.find('{jabber:x:data}title').text) + if xml.find('{jabber:x:data}instructions') is not None: + self.setInstructions(xml.find('{jabber:x:data}instructions').text) + for field in xml.findall('{jabber:x:data}field'): + self.buildField(field) + for reported in xml.findall('{jabber:x:data}reported'): + self.buildReported(reported) + for item in xml.findall('{jabber:x:data}item'): + self.buildItem(item) + + #def getXML(self, tostring = False): + def getXML(self, ftype=None): + if ftype: + self.type = ftype + form = ET.Element('{jabber:x:data}x') + form.attrib['type'] = self.type + if self.title and self.type in ('form', 'result'): + title = ET.Element('{jabber:x:data}title') + title.text = self.title + form.append(title) + if self.instructions and self.type == 'form': + instructions = ET.Element('{jabber:x:data}instructions') + instructions.text = self.instructions + form.append(instructions) + for field in self.fields: + form.append(field.getXML(self.type)) + for reported in self.reported: + form.append(reported.getXML('{jabber:x:data}reported')) + for item in self.items: + form.append(item.getXML(self.type)) + #if tostring: + # form = self.xmpp.tostring(form) + return form + + def getXHTML(self): + form = ET.Element('{http://www.w3.org/1999/xhtml}form') + if self.title: + title = ET.Element('h2') + title.text = self.title + form.append(title) + if self.instructions: + instructions = ET.Element('p') + instructions.text = self.instructions + form.append(instructions) + for field in self.fields: + form.append(field.getXHTML()) + for field in self.reported: + form.append(field.getXHTML()) + for field in self.items: + form.append(field.getXHTML()) + return form + + + def makeSubmit(self): + self.setType('submit') + +class FormField(object): + types = ('boolean', 'fixed', 'hidden', 'jid-multi', 'jid-single', 'list-multi', 'list-single', 'text-multi', 'text-private', 'text-single') + listtypes = ('jid-multi', 'jid-single', 'list-multi', 'list-single') + lbtypes = ('fixed', 'text-multi') + def __init__(self, var, ftype='text-single', label='', desc='', required=False, value=None): + if not ftype in self.types: + raise ValueError("Invalid Field Type") + self.type = ftype + self.var = var + self.label = label + self.desc = desc + self.options = [] + self.required = False + self.value = [] + if self.type in self.listtypes: + self.islist = True + else: + self.islist = False + if self.type in self.lbtypes: + self.islinebreak = True + else: + self.islinebreak = False + if value: + self.setValue(value) + + def addOption(self, value, label): + if self.islist: + self.options.append((value, label)) + else: + raise ValueError("Cannot add options to non-list type field.") + + def setTrue(self): + if self.type == 'boolean': + self.value = [True] + + def setFalse(self): + if self.type == 'boolean': + self.value = [False] + + def require(self): + self.required = True + + def setDescription(self, desc): + self.desc = desc + + def setValue(self, value): + if self.type == 'boolean': + if value in ('1', 1, True, 'true', 'True', 'yes'): + value = True + else: + value = False + if self.islinebreak and value is not None: + self.value += value.split('\n') + else: + if len(self.value) and (not self.islist or self.type == 'list-single'): + self.value = [value] + else: + self.value.append(value) + + def delValue(self, value): + if type(self.value) == type([]): + try: + idx = self.value.index(value) + if idx != -1: + self.value.pop(idx) + except ValueError: + pass + else: + self.value = '' + + def setAnswer(self, value): + self.setValue(value) + + def buildField(self, xml): + self.type = xml.get('type', 'text-single') + self.label = xml.get('label', '') + for option in xml.findall('{jabber:x:data}option'): + self.addOption(option.find('{jabber:x:data}value').text, option.get('label', '')) + for value in xml.findall('{jabber:x:data}value'): + self.setValue(value.text) + if xml.find('{jabber:x:data}required') is not None: + self.require() + if xml.find('{jabber:x:data}desc') is not None: + self.setDescription(xml.find('{jabber:x:data}desc').text) + + def getXML(self, ftype): + field = ET.Element('{jabber:x:data}field') + if ftype != 'result': + field.attrib['type'] = self.type + if self.type != 'fixed': + if self.var: + field.attrib['var'] = self.var + if self.label: + field.attrib['label'] = self.label + if ftype == 'form': + for option in self.options: + optionxml = ET.Element('{jabber:x:data}option') + optionxml.attrib['label'] = option[1] + optionval = ET.Element('{jabber:x:data}value') + optionval.text = option[0] + optionxml.append(optionval) + field.append(optionxml) + if self.required: + required = ET.Element('{jabber:x:data}required') + field.append(required) + if self.desc: + desc = ET.Element('{jabber:x:data}desc') + desc.text = self.desc + field.append(desc) + for value in self.value: + valuexml = ET.Element('{jabber:x:data}value') + if value is True or value is False: + if value: + valuexml.text = '1' + else: + valuexml.text = '0' + else: + valuexml.text = value + field.append(valuexml) + return field + + def getXHTML(self): + field = ET.Element('div', {'class': 'xmpp-xforms-%s' % self.type}) + if self.label: + label = ET.Element('p') + label.text = "%s: " % self.label + else: + label = ET.Element('p') + label.text = "%s: " % self.var + field.append(label) + if self.type == 'boolean': + formf = ET.Element('input', {'type': 'checkbox', 'name': self.var}) + if len(self.value) and self.value[0] in (True, 'true', '1'): + formf.attrib['checked'] = 'checked' + elif self.type == 'fixed': + formf = ET.Element('p') + try: + formf.text = ', '.join(self.value) + except: + pass + field.append(formf) + formf = ET.Element('input', {'type': 'hidden', 'name': self.var}) + try: + formf.text = ', '.join(self.value) + except: + pass + elif self.type == 'hidden': + formf = ET.Element('input', {'type': 'hidden', 'name': self.var}) + try: + formf.text = ', '.join(self.value) + except: + pass + elif self.type in ('jid-multi', 'list-multi'): + formf = ET.Element('select', {'name': self.var}) + for option in self.options: + optf = ET.Element('option', {'value': option[0], 'multiple': 'multiple'}) + optf.text = option[1] + if option[1] in self.value: + optf.attrib['selected'] = 'selected' + formf.append(option) + elif self.type in ('jid-single', 'text-single'): + formf = ET.Element('input', {'type': 'text', 'name': self.var}) + try: + formf.attrib['value'] = ', '.join(self.value) + except: + pass + elif self.type == 'list-single': + formf = ET.Element('select', {'name': self.var}) + for option in self.options: + optf = ET.Element('option', {'value': option[0]}) + optf.text = option[1] + if not optf.text: + optf.text = option[0] + if option[1] in self.value: + optf.attrib['selected'] = 'selected' + formf.append(optf) + elif self.type == 'text-multi': + formf = ET.Element('textarea', {'name': self.var}) + try: + formf.text = ', '.join(self.value) + except: + pass + if not formf.text: + formf.text = ' ' + elif self.type == 'text-private': + formf = ET.Element('input', {'type': 'password', 'name': self.var}) + try: + formf.attrib['value'] = ', '.join(self.value) + except: + pass + label.append(formf) + return field + diff --git a/sleekxmpp/plugins/old_0009.py b/sleekxmpp/plugins/old_0009.py new file mode 100644 index 00000000..625b03fb --- /dev/null +++ b/sleekxmpp/plugins/old_0009.py @@ -0,0 +1,277 @@ +"""
+XEP-0009 XMPP Remote Procedure Calls
+"""
+from __future__ import with_statement
+from . import base
+import logging
+from xml.etree import cElementTree as ET
+import copy
+import time
+import base64
+
+def py2xml(*args):
+ params = ET.Element("params")
+ for x in args:
+ param = ET.Element("param")
+ param.append(_py2xml(x))
+ params.append(param) #<params><param>...
+ return params
+
+def _py2xml(*args):
+ for x in args:
+ val = ET.Element("value")
+ if type(x) is int:
+ i4 = ET.Element("i4")
+ i4.text = str(x)
+ val.append(i4)
+ if type(x) is bool:
+ boolean = ET.Element("boolean")
+ boolean.text = str(int(x))
+ val.append(boolean)
+ elif type(x) is str:
+ string = ET.Element("string")
+ string.text = x
+ val.append(string)
+ elif type(x) is float:
+ double = ET.Element("double")
+ double.text = str(x)
+ val.append(double)
+ elif type(x) is rpcbase64:
+ b64 = ET.Element("Base64")
+ b64.text = x.encoded()
+ val.append(b64)
+ elif type(x) is rpctime:
+ iso = ET.Element("dateTime.iso8601")
+ iso.text = str(x)
+ val.append(iso)
+ elif type(x) is list:
+ array = ET.Element("array")
+ data = ET.Element("data")
+ for y in x:
+ data.append(_py2xml(y))
+ array.append(data)
+ val.append(array)
+ elif type(x) is dict:
+ struct = ET.Element("struct")
+ for y in x.keys():
+ member = ET.Element("member")
+ name = ET.Element("name")
+ name.text = y
+ member.append(name)
+ member.append(_py2xml(x[y]))
+ struct.append(member)
+ val.append(struct)
+ return val
+
+def xml2py(params):
+ vals = []
+ for param in params.findall('param'):
+ vals.append(_xml2py(param.find('value')))
+ return vals
+
+def _xml2py(value):
+ if value.find('i4') is not None:
+ return int(value.find('i4').text)
+ if value.find('int') is not None:
+ return int(value.find('int').text)
+ if value.find('boolean') is not None:
+ return bool(value.find('boolean').text)
+ if value.find('string') is not None:
+ return value.find('string').text
+ if value.find('double') is not None:
+ return float(value.find('double').text)
+ if value.find('Base64') is not None:
+ return rpcbase64(value.find('Base64').text)
+ if value.find('dateTime.iso8601') is not None:
+ return rpctime(value.find('dateTime.iso8601'))
+ if value.find('struct') is not None:
+ struct = {}
+ for member in value.find('struct').findall('member'):
+ struct[member.find('name').text] = _xml2py(member.find('value'))
+ return struct
+ if value.find('array') is not None:
+ array = []
+ for val in value.find('array').find('data').findall('value'):
+ array.append(_xml2py(val))
+ return array
+ raise ValueError()
+
+class rpcbase64(object):
+ def __init__(self, data):
+ #base 64 encoded string
+ self.data = data
+
+ def decode(self):
+ return base64.decodestring(data)
+
+ def __str__(self):
+ return self.decode()
+
+ def encoded(self):
+ return self.data
+
+class rpctime(object):
+ def __init__(self,data=None):
+ #assume string data is in iso format YYYYMMDDTHH:MM:SS
+ if type(data) is str:
+ self.timestamp = time.strptime(data,"%Y%m%dT%H:%M:%S")
+ elif type(data) is time.struct_time:
+ self.timestamp = data
+ elif data is None:
+ self.timestamp = time.gmtime()
+ else:
+ raise ValueError()
+
+ def iso8601(self):
+ #return a iso8601 string
+ return time.strftime("%Y%m%dT%H:%M:%S",self.timestamp)
+
+ def __str__(self):
+ return self.iso8601()
+
+class JabberRPCEntry(object):
+ def __init__(self,call):
+ self.call = call
+ self.result = None
+ self.error = None
+ self.allow = {} #{'<jid>':['<resource1>',...],...}
+ self.deny = {}
+
+ def check_acl(self, jid, resource):
+ #Check for deny
+ if jid in self.deny.keys():
+ if self.deny[jid] == None or resource in self.deny[jid]:
+ return False
+ #Check for allow
+ if allow == None:
+ return True
+ if jid in self.allow.keys():
+ if self.allow[jid] == None or resource in self.allow[jid]:
+ return True
+ return False
+
+ def acl_allow(self, jid, resource):
+ if jid == None:
+ self.allow = None
+ elif resource == None:
+ self.allow[jid] = None
+ elif jid in self.allow.keys():
+ self.allow[jid].append(resource)
+ else:
+ self.allow[jid] = [resource]
+
+ def acl_deny(self, jid, resource):
+ if jid == None:
+ self.deny = None
+ elif resource == None:
+ self.deny[jid] = None
+ elif jid in self.deny.keys():
+ self.deny[jid].append(resource)
+ else:
+ self.deny[jid] = [resource]
+
+ def call_method(self, args):
+ ret = self.call(*args)
+
+class xep_0009(base.base_plugin):
+
+ def plugin_init(self):
+ self.xep = '0009'
+ self.description = 'Jabber-RPC'
+ self.xmpp.add_handler("<iq type='set'><query xmlns='jabber:iq:rpc' /></iq>",
+ self._callMethod, name='Jabber RPC Call')
+ self.xmpp.add_handler("<iq type='result'><query xmlns='jabber:iq:rpc' /></iq>",
+ self._callResult, name='Jabber RPC Result')
+ self.xmpp.add_handler("<iq type='error'><query xmlns='jabber:iq:rpc' /></iq>",
+ self._callError, name='Jabber RPC Error')
+ self.entries = {}
+ self.activeCalls = []
+
+ def post_init(self):
+ base.base_plugin.post_init(self)
+ self.xmpp.plugin['xep_0030'].add_feature('jabber:iq:rpc')
+ self.xmpp.plugin['xep_0030'].add_identity('automatition','rpc')
+
+ def register_call(self, method, name=None):
+ #@returns an string that can be used in acl commands.
+ with self.lock:
+ if name is None:
+ self.entries[method.__name__] = JabberRPCEntry(method)
+ return method.__name__
+ else:
+ self.entries[name] = JabberRPCEntry(method)
+ return name
+
+ def acl_allow(self, entry, jid=None, resource=None):
+ #allow the method entry to be called by the given jid and resource.
+ #if jid is None it will allow any jid/resource.
+ #if resource is None it will allow any resource belonging to the jid.
+ with self.lock:
+ if self.entries[entry]:
+ self.entries[entry].acl_allow(jid,resource)
+ else:
+ raise ValueError()
+
+ def acl_deny(self, entry, jid=None, resource=None):
+ #Note: by default all requests are denied unless allowed with acl_allow.
+ #If you deny an entry it will not be allowed regardless of acl_allow
+ with self.lock:
+ if self.entries[entry]:
+ self.entries[entry].acl_deny(jid,resource)
+ else:
+ raise ValueError()
+
+ def unregister_call(self, entry):
+ #removes the registered call
+ with self.lock:
+ if self.entries[entry]:
+ del self.entries[entry]
+ else:
+ raise ValueError()
+
+ def makeMethodCallQuery(self,pmethod,params):
+ query = self.xmpp.makeIqQuery(iq,"jabber:iq:rpc")
+ methodCall = ET.Element('methodCall')
+ methodName = ET.Element('methodName')
+ methodName.text = pmethod
+ methodCall.append(methodName)
+ methodCall.append(params)
+ query.append(methodCall)
+ return query
+
+ def makeIqMethodCall(self,pto,pmethod,params):
+ iq = self.xmpp.makeIqSet()
+ iq.set('to',pto)
+ iq.append(self.makeMethodCallQuery(pmethod,params))
+ return iq
+
+ def makeIqMethodResponse(self,pto,pid,params):
+ iq = self.xmpp.makeIqResult(pid)
+ iq.set('to',pto)
+ query = self.xmpp.makeIqQuery(iq,"jabber:iq:rpc")
+ methodResponse = ET.Element('methodResponse')
+ methodResponse.append(params)
+ query.append(methodResponse)
+ return iq
+
+ def makeIqMethodError(self,pto,id,pmethod,params,condition):
+ iq = self.xmpp.makeIqError(id)
+ iq.set('to',pto)
+ iq.append(self.makeMethodCallQuery(pmethod,params))
+ iq.append(self.xmpp['xep_0086'].makeError(condition))
+ return iq
+
+
+
+ def call_remote(self, pto, pmethod, *args):
+ #calls a remote method. Returns the id of the Iq.
+ pass
+
+ def _callMethod(self,xml):
+ pass
+
+ def _callResult(self,xml):
+ pass
+
+ def _callError(self,xml):
+ pass
diff --git a/sleekxmpp/plugins/old_0050.py b/sleekxmpp/plugins/old_0050.py new file mode 100644 index 00000000..6e969a51 --- /dev/null +++ b/sleekxmpp/plugins/old_0050.py @@ -0,0 +1,133 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from __future__ import with_statement +from . import base +import logging +from xml.etree import cElementTree as ET +import time + +class old_0050(base.base_plugin): + """ + XEP-0050 Ad-Hoc Commands + """ + + def plugin_init(self): + self.xep = '0050' + self.description = 'Ad-Hoc Commands' + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='__None__'/></iq>" % self.xmpp.default_ns, self.handler_command, name='Ad-Hoc None') + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='execute'/></iq>" % self.xmpp.default_ns, self.handler_command, name='Ad-Hoc Execute') + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='next'/></iq>" % self.xmpp.default_ns, self.handler_command_next, name='Ad-Hoc Next', threaded=True) + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='cancel'/></iq>" % self.xmpp.default_ns, self.handler_command_cancel, name='Ad-Hoc Cancel') + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='complete'/></iq>" % self.xmpp.default_ns, self.handler_command_complete, name='Ad-Hoc Complete') + self.commands = {} + self.sessions = {} + self.sd = self.xmpp.plugin['xep_0030'] + + def post_init(self): + base.base_plugin.post_init(self) + self.sd.add_feature('http://jabber.org/protocol/commands') + + def addCommand(self, node, name, form, pointer=None, multi=False): + self.sd.add_item(None, name, 'http://jabber.org/protocol/commands', node) + self.sd.add_identity('automation', 'command-node', name, node) + self.sd.add_feature('http://jabber.org/protocol/commands', node) + self.sd.add_feature('jabber:x:data', node) + self.commands[node] = (name, form, pointer, multi) + + def getNewSession(self): + return str(time.time()) + '-' + self.xmpp.getNewId() + + def handler_command(self, xml): + in_command = xml.find('{http://jabber.org/protocol/commands}command') + sessionid = in_command.get('sessionid', None) + node = in_command.get('node') + sessionid = self.getNewSession() + name, form, pointer, multi = self.commands[node] + self.sessions[sessionid] = {} + self.sessions[sessionid]['jid'] = xml.get('from') + self.sessions[sessionid]['to'] = xml.get('to') + self.sessions[sessionid]['past'] = [(form, None)] + self.sessions[sessionid]['next'] = pointer + npointer = pointer + if multi: + actions = ['next'] + status = 'executing' + else: + if pointer is None: + status = 'completed' + actions = [] + else: + status = 'executing' + actions = ['complete'] + self.xmpp.send(self.makeCommand(xml.attrib['from'], in_command.attrib['node'], form=form, id=xml.attrib['id'], sessionid=sessionid, status=status, actions=actions)) + + def handler_command_complete(self, xml): + in_command = xml.find('{http://jabber.org/protocol/commands}command') + sessionid = in_command.get('sessionid', None) + pointer = self.sessions[sessionid]['next'] + results = self.xmpp.plugin['old_0004'].makeForm('result') + results.fromXML(in_command.find('{jabber:x:data}x')) + pointer(results,sessionid) + self.xmpp.send(self.makeCommand(xml.attrib['from'], in_command.attrib['node'], form=None, id=xml.attrib['id'], sessionid=sessionid, status='completed', actions=[])) + del self.sessions[in_command.get('sessionid')] + + + def handler_command_next(self, xml): + in_command = xml.find('{http://jabber.org/protocol/commands}command') + sessionid = in_command.get('sessionid', None) + pointer = self.sessions[sessionid]['next'] + results = self.xmpp.plugin['old_0004'].makeForm('result') + results.fromXML(in_command.find('{jabber:x:data}x')) + form, npointer, next = pointer(results,sessionid) + self.sessions[sessionid]['next'] = npointer + self.sessions[sessionid]['past'].append((form, pointer)) + actions = [] + actions.append('prev') + if npointer is None: + status = 'completed' + else: + status = 'executing' + if next: + actions.append('next') + else: + actions.append('complete') + self.xmpp.send(self.makeCommand(xml.attrib['from'], in_command.attrib['node'], form=form, id=xml.attrib['id'], sessionid=sessionid, status=status, actions=actions)) + + def handler_command_cancel(self, xml): + command = xml.find('{http://jabber.org/protocol/commands}command') + try: + del self.sessions[command.get('sessionid')] + except: + pass + self.xmpp.send(self.makeCommand(xml.attrib['from'], command.attrib['node'], id=xml.attrib['id'], sessionid=command.attrib['sessionid'], status='canceled')) + + def makeCommand(self, to, node, id=None, form=None, sessionid=None, status='executing', actions=[]): + if not id: + id = self.xmpp.getNewId() + iq = self.xmpp.makeIqResult(id) + iq.attrib['from'] = self.xmpp.boundjid.full + iq.attrib['to'] = to + command = ET.Element('{http://jabber.org/protocol/commands}command') + command.attrib['node'] = node + command.attrib['status'] = status + xmlactions = ET.Element('actions') + for action in actions: + xmlactions.append(ET.Element(action)) + if xmlactions: + command.append(xmlactions) + if not sessionid: + sessionid = self.getNewSession() + else: + iq.attrib['from'] = self.sessions[sessionid]['to'] + command.attrib['sessionid'] = sessionid + if form is not None: + if hasattr(form,'getXML'): + form = form.getXML() + command.append(form) + iq.append(command) + return iq diff --git a/sleekxmpp/plugins/old_0060.py b/sleekxmpp/plugins/old_0060.py new file mode 100644 index 00000000..93124fca --- /dev/null +++ b/sleekxmpp/plugins/old_0060.py @@ -0,0 +1,313 @@ +from __future__ import with_statement +from . import base +import logging +#from xml.etree import cElementTree as ET +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET +from . import stanza_pubsub +from . xep_0004 import Form + + +log = logging.getLogger(__name__) + + +class xep_0060(base.base_plugin): + """ + XEP-0060 Publish Subscribe + """ + + def plugin_init(self): + self.xep = '0060' + self.description = 'Publish-Subscribe' + + def create_node(self, jid, node, config=None, collection=False, ntype=None): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + create = ET.Element('create') + create.set('node', node) + pubsub.append(create) + configure = ET.Element('configure') + if collection: + ntype = 'collection' + #if config is None: + # submitform = self.xmpp.plugin['xep_0004'].makeForm('submit') + #else: + if config is not None: + submitform = config + if 'FORM_TYPE' in submitform.field: + submitform.field['FORM_TYPE'].setValue('http://jabber.org/protocol/pubsub#node_config') + else: + submitform.addField('FORM_TYPE', 'hidden', value='http://jabber.org/protocol/pubsub#node_config') + if ntype: + if 'pubsub#node_type' in submitform.field: + submitform.field['pubsub#node_type'].setValue(ntype) + else: + submitform.addField('pubsub#node_type', value=ntype) + else: + if 'pubsub#node_type' in submitform.field: + submitform.field['pubsub#node_type'].setValue('leaf') + else: + submitform.addField('pubsub#node_type', value='leaf') + submitform['type'] = 'submit' + configure.append(submitform.xml) + pubsub.append(configure) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is False or result is None or result['type'] == 'error': return False + return True + + def subscribe(self, jid, node, bare=True, subscribee=None): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + subscribe = ET.Element('subscribe') + subscribe.attrib['node'] = node + if subscribee is None: + if bare: + subscribe.attrib['jid'] = self.xmpp.boundjid.bare + else: + subscribe.attrib['jid'] = self.xmpp.boundjid.full + else: + subscribe.attrib['jid'] = subscribee + pubsub.append(subscribe) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is False or result is None or result['type'] == 'error': return False + return True + + def unsubscribe(self, jid, node, bare=True, subscribee=None): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + unsubscribe = ET.Element('unsubscribe') + unsubscribe.attrib['node'] = node + if subscribee is None: + if bare: + unsubscribe.attrib['jid'] = self.xmpp.boundjid.bare + else: + unsubscribe.attrib['jid'] = self.xmpp.boundjid.full + else: + unsubscribe.attrib['jid'] = subscribee + pubsub.append(unsubscribe) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is False or result is None or result['type'] == 'error': return False + return True + + def getNodeConfig(self, jid, node=None): # if no node, then grab default + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + if node is not None: + configure = ET.Element('configure') + configure.attrib['node'] = node + else: + configure = ET.Element('default') + pubsub.append(configure) + #TODO: Add configure support. + iq = self.xmpp.makeIqGet() + iq.append(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + #self.xmpp.add_handler("<iq id='%s'/>" % id, self.handlerCreateNodeResponse) + result = iq.send() + if result is None or result == False or result['type'] == 'error': + log.warning("got error instead of config") + return False + if node is not None: + form = result.find('{http://jabber.org/protocol/pubsub#owner}pubsub/{http://jabber.org/protocol/pubsub#owner}configure/{jabber:x:data}x') + else: + form = result.find('{http://jabber.org/protocol/pubsub#owner}pubsub/{http://jabber.org/protocol/pubsub#owner}default/{jabber:x:data}x') + if not form or form is None: + log.error("No form found.") + return False + return Form(xml=form) + + def getNodeSubscriptions(self, jid, node): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + subscriptions = ET.Element('subscriptions') + subscriptions.attrib['node'] = node + pubsub.append(subscriptions) + iq = self.xmpp.makeIqGet() + iq.append(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is None or result == False or result['type'] == 'error': + log.warning("got error instead of config") + return False + else: + results = result.findall('{http://jabber.org/protocol/pubsub#owner}pubsub/{http://jabber.org/protocol/pubsub#owner}subscriptions/{http://jabber.org/protocol/pubsub#owner}subscription') + if results is None: + return False + subs = {} + for sub in results: + subs[sub.get('jid')] = sub.get('subscription') + return subs + + def getNodeAffiliations(self, jid, node): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + affiliations = ET.Element('affiliations') + affiliations.attrib['node'] = node + pubsub.append(affiliations) + iq = self.xmpp.makeIqGet() + iq.append(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is None or result == False or result['type'] == 'error': + log.warning("got error instead of config") + return False + else: + results = result.findall('{http://jabber.org/protocol/pubsub#owner}pubsub/{http://jabber.org/protocol/pubsub#owner}affiliations/{http://jabber.org/protocol/pubsub#owner}affiliation') + if results is None: + return False + subs = {} + for sub in results: + subs[sub.get('jid')] = sub.get('affiliation') + return subs + + def deleteNode(self, jid, node): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + iq = self.xmpp.makeIqSet() + delete = ET.Element('delete') + delete.attrib['node'] = node + pubsub.append(delete) + iq.append(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + result = iq.send() + if result is not None and result is not False and result['type'] != 'error': + return True + else: + return False + + + def setNodeConfig(self, jid, node, config): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + configure = ET.Element('configure') + configure.attrib['node'] = node + config = config.getXML('submit') + configure.append(config) + pubsub.append(configure) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is None or result['type'] == 'error': + return False + return True + + def setItem(self, jid, node, items=[]): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + publish = ET.Element('publish') + publish.attrib['node'] = node + for pub_item in items: + id, payload = pub_item + item = ET.Element('item') + if id is not None: + item.attrib['id'] = id + item.append(payload) + publish.append(item) + pubsub.append(publish) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is None or result is False or result['type'] == 'error': return False + return True + + def addItem(self, jid, node, items=[]): + return self.setItem(jid, node, items) + + def deleteItem(self, jid, node, item): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + retract = ET.Element('retract') + retract.attrib['node'] = node + itemn = ET.Element('item') + itemn.attrib['id'] = item + retract.append(itemn) + pubsub.append(retract) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is None or result is False or result['type'] == 'error': return False + return True + + def getNodes(self, jid): + response = self.xmpp.plugin['xep_0030'].getItems(jid) + items = response.findall('{http://jabber.org/protocol/disco#items}query/{http://jabber.org/protocol/disco#items}item') + nodes = {} + if items is not None and items is not False: + for item in items: + nodes[item.get('node')] = item.get('name') + return nodes + + def getItems(self, jid, node): + response = self.xmpp.plugin['xep_0030'].getItems(jid, node) + items = response.findall('{http://jabber.org/protocol/disco#items}query/{http://jabber.org/protocol/disco#items}item') + nodeitems = [] + if items is not None and items is not False: + for item in items: + nodeitems.append(item.get('node')) + return nodeitems + + def addNodeToCollection(self, jid, child, parent=''): + config = self.getNodeConfig(jid, child) + if not config or config is None: + self.lasterror = "Config Error" + return False + try: + config.field['pubsub#collection'].setValue(parent) + except KeyError: + log.warning("pubsub#collection doesn't exist in config, trying to add it") + config.addField('pubsub#collection', value=parent) + if not self.setNodeConfig(jid, child, config): + return False + return True + + def modifyAffiliation(self, ps_jid, node, user_jid, affiliation): + if affiliation not in ('owner', 'publisher', 'member', 'none', 'outcast'): + raise TypeError + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + affs = ET.Element('affiliations') + affs.attrib['node'] = node + aff = ET.Element('affiliation') + aff.attrib['jid'] = user_jid + aff.attrib['affiliation'] = affiliation + affs.append(aff) + pubsub.append(affs) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = ps_jid + iq.attrib['from'] = self.xmpp.boundjid.full + id = iq['id'] + result = iq.send() + if result is None or result is False or result['type'] == 'error': + return False + return True + + def addNodeToCollection(self, jid, child, parent=''): + config = self.getNodeConfig(jid, child) + if not config or config is None: + self.lasterror = "Config Error" + return False + try: + config.field['pubsub#collection'].setValue(parent) + except KeyError: + log.warning("pubsub#collection doesn't exist in config, trying to add it") + config.addField('pubsub#collection', value=parent) + if not self.setNodeConfig(jid, child, config): + return False + return True + + def removeNodeFromCollection(self, jid, child): + self.addNodeToCollection(jid, child, '') + diff --git a/sleekxmpp/plugins/xep_0004/__init__.py b/sleekxmpp/plugins/xep_0004/__init__.py new file mode 100644 index 00000000..aad4e15f --- /dev/null +++ b/sleekxmpp/plugins/xep_0004/__init__.py @@ -0,0 +1,11 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0004.stanza import Form +from sleekxmpp.plugins.xep_0004.stanza import FormField, FieldOption +from sleekxmpp.plugins.xep_0004.dataforms import xep_0004 diff --git a/sleekxmpp/plugins/xep_0004/dataforms.py b/sleekxmpp/plugins/xep_0004/dataforms.py new file mode 100644 index 00000000..5414be5c --- /dev/null +++ b/sleekxmpp/plugins/xep_0004/dataforms.py @@ -0,0 +1,60 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import copy + +from sleekxmpp.thirdparty import OrderedDict + +from sleekxmpp import Message +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0004 import stanza +from sleekxmpp.plugins.xep_0004.stanza import Form, FormField, FieldOption + + +class xep_0004(base_plugin): + """ + XEP-0004: Data Forms + """ + + def plugin_init(self): + self.xep = '0004' + self.description = 'Data Forms' + self.stanza = stanza + + self.xmpp.registerHandler( + Callback('Data Form', + StanzaPath('message/form'), + self.handle_form)) + + register_stanza_plugin(FormField, FieldOption, iterable=True) + register_stanza_plugin(Form, FormField, iterable=True) + register_stanza_plugin(Message, Form) + + def make_form(self, ftype='form', title='', instructions=''): + f = Form() + f['type'] = ftype + f['title'] = title + f['instructions'] = instructions + return f + + def post_init(self): + base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature('jabber:x:data') + + def handle_form(self, message): + self.xmpp.event("message_xform", message) + + def build_form(self, xml): + return Form(xml=xml) + + +xep_0004.makeForm = xep_0004.make_form +xep_0004.buildForm = xep_0004.build_form diff --git a/sleekxmpp/plugins/xep_0004/stanza/__init__.py b/sleekxmpp/plugins/xep_0004/stanza/__init__.py new file mode 100644 index 00000000..6ad35298 --- /dev/null +++ b/sleekxmpp/plugins/xep_0004/stanza/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0004.stanza.field import FormField, FieldOption +from sleekxmpp.plugins.xep_0004.stanza.form import Form diff --git a/sleekxmpp/plugins/xep_0004/stanza/field.py b/sleekxmpp/plugins/xep_0004/stanza/field.py new file mode 100644 index 00000000..8156997c --- /dev/null +++ b/sleekxmpp/plugins/xep_0004/stanza/field.py @@ -0,0 +1,180 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class FormField(ElementBase): + namespace = 'jabber:x:data' + name = 'field' + plugin_attrib = 'field' + interfaces = set(('answer', 'desc', 'required', 'value', + 'options', 'label', 'type', 'var')) + sub_interfaces = set(('desc',)) + plugin_tag_map = {} + plugin_attrib_map = {} + + field_types = set(('boolean', 'fixed', 'hidden', 'jid-multi', + 'jid-single', 'list-multi', 'list-single', + 'text-multi', 'text-private', 'text-single')) + + true_values = set((True, '1', 'true')) + option_types = set(('list-multi', 'list-single')) + multi_line_types = set(('hidden', 'text-multi')) + multi_value_types = set(('hidden', 'jid-multi', + 'list-multi', 'text-multi')) + + def setup(self, xml=None): + if ElementBase.setup(self, xml): + self._type = None + else: + self._type = self['type'] + + def set_type(self, value): + self._set_attr('type', value) + if value: + self._type = value + + def add_option(self, label='', value=''): + if self._type in self.option_types: + opt = FieldOption(parent=self) + opt['label'] = label + opt['value'] = value + else: + raise ValueError("Cannot add options to " + \ + "a %s field." % self['type']) + + def del_options(self): + optsXML = self.xml.findall('{%s}option' % self.namespace) + for optXML in optsXML: + self.xml.remove(optXML) + + def del_required(self): + reqXML = self.xml.find('{%s}required' % self.namespace) + if reqXML is not None: + self.xml.remove(reqXML) + + def del_value(self): + valsXML = self.xml.findall('{%s}value' % self.namespace) + for valXML in valsXML: + self.xml.remove(valXML) + + def get_answer(self): + return self['value'] + + def get_options(self): + options = [] + optsXML = self.xml.findall('{%s}option' % self.namespace) + for optXML in optsXML: + opt = FieldOption(xml=optXML) + options.append({'label': opt['label'], 'value': opt['value']}) + return options + + def get_required(self): + reqXML = self.xml.find('{%s}required' % self.namespace) + return reqXML is not None + + def get_value(self, convert=True): + valsXML = self.xml.findall('{%s}value' % self.namespace) + if len(valsXML) == 0: + return None + elif self._type == 'boolean': + if convert: + return valsXML[0].text in self.true_values + return valsXML[0].text + elif self._type in self.multi_value_types or len(valsXML) > 1: + values = [] + for valXML in valsXML: + if valXML.text is None: + valXML.text = '' + values.append(valXML.text) + if self._type == 'text-multi' and convert: + values = "\n".join(values) + return values + else: + if valsXML[0].text is None: + return '' + return valsXML[0].text + + def set_answer(self, answer): + self['value'] = answer + + def set_false(self): + self['value'] = False + + def set_options(self, options): + for value in options: + if isinstance(value, dict): + self.add_option(**value) + else: + self.add_option(value=value) + + def set_required(self, required): + exists = self['required'] + if not exists and required: + self.xml.append(ET.Element('{%s}required' % self.namespace)) + elif exists and not required: + del self['required'] + + def set_true(self): + self['value'] = True + + def set_value(self, value): + del self['value'] + valXMLName = '{%s}value' % self.namespace + + if self._type == 'boolean': + if value in self.true_values: + valXML = ET.Element(valXMLName) + valXML.text = '1' + self.xml.append(valXML) + else: + valXML = ET.Element(valXMLName) + valXML.text = '0' + self.xml.append(valXML) + elif self._type in self.multi_value_types or self._type in ('', None): + if not isinstance(value, list): + value = value.replace('\r', '') + value = value.split('\n') + for val in value: + if self._type in ('', None) and val in self.true_values: + val = '1' + valXML = ET.Element(valXMLName) + valXML.text = val + self.xml.append(valXML) + else: + if isinstance(value, list): + raise ValueError("Cannot add multiple values " + \ + "to a %s field." % self._type) + valXML = ET.Element(valXMLName) + valXML.text = value + self.xml.append(valXML) + + +class FieldOption(ElementBase): + namespace = 'jabber:x:data' + name = 'option' + plugin_attrib = 'option' + interfaces = set(('label', 'value')) + sub_interfaces = set(('value',)) + + +FormField.addOption = FormField.add_option +FormField.delOptions = FormField.del_options +FormField.delRequired = FormField.del_required +FormField.delValue = FormField.del_value +FormField.getAnswer = FormField.get_answer +FormField.getOptions = FormField.get_options +FormField.getRequired = FormField.get_required +FormField.getValue = FormField.get_value +FormField.setAnswer = FormField.set_answer +FormField.setFalse = FormField.set_false +FormField.setOptions = FormField.set_options +FormField.setRequired = FormField.set_required +FormField.setTrue = FormField.set_true +FormField.setValue = FormField.set_value diff --git a/sleekxmpp/plugins/xep_0004/stanza/form.py b/sleekxmpp/plugins/xep_0004/stanza/form.py new file mode 100644 index 00000000..bbf0ee7d --- /dev/null +++ b/sleekxmpp/plugins/xep_0004/stanza/form.py @@ -0,0 +1,254 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import copy +import logging + +from sleekxmpp.thirdparty import OrderedDict + +from sleekxmpp.xmlstream import ElementBase, ET +from sleekxmpp.plugins.xep_0004.stanza import FormField + + +log = logging.getLogger(__name__) + + +class Form(ElementBase): + namespace = 'jabber:x:data' + name = 'x' + plugin_attrib = 'form' + interfaces = set(('fields', 'instructions', 'items', + 'reported', 'title', 'type', 'values')) + sub_interfaces = set(('title',)) + form_types = set(('cancel', 'form', 'result', 'submit')) + + def __init__(self, *args, **kwargs): + title = None + if 'title' in kwargs: + title = kwargs['title'] + del kwargs['title'] + ElementBase.__init__(self, *args, **kwargs) + if title is not None: + self['title'] = title + + def setup(self, xml=None): + if ElementBase.setup(self, xml): + # If we had to generate xml + self['type'] = 'form' + + @property + def field(self): + return self['fields'] + + def set_type(self, ftype): + self._set_attr('type', ftype) + if ftype == 'submit': + fields = self['fields'] + for var in fields: + field = fields[var] + del field['type'] + del field['label'] + del field['desc'] + del field['required'] + del field['options'] + elif ftype == 'cancel': + del self['fields'] + + def add_field(self, var='', ftype=None, label='', desc='', + required=False, value=None, options=None, **kwargs): + kwtype = kwargs.get('type', None) + if kwtype is None: + kwtype = ftype + + field = FormField(parent=self) + field['var'] = var + field['type'] = kwtype + field['value'] = value + if self['type'] in ('form', 'result'): + field['label'] = label + field['desc'] = desc + field['required'] = required + if options is not None: + field['options'] = options + else: + del field['type'] + return field + + def getXML(self, type='submit'): + self['type'] = type + log.warning("Form.getXML() is deprecated API compatibility " + \ + "with plugins/old_0004.py") + return self.xml + + def fromXML(self, xml): + log.warning("Form.fromXML() is deprecated API compatibility " + \ + "with plugins/old_0004.py") + n = Form(xml=xml) + return n + + def add_item(self, values): + itemXML = ET.Element('{%s}item' % self.namespace) + self.xml.append(itemXML) + reported_vars = self['reported'].keys() + for var in reported_vars: + field = FormField() + field._type = self['reported'][var]['type'] + field['var'] = var + field['value'] = values.get(var, None) + itemXML.append(field.xml) + + def add_reported(self, var, ftype=None, label='', desc='', **kwargs): + kwtype = kwargs.get('type', None) + if kwtype is None: + kwtype = ftype + reported = self.xml.find('{%s}reported' % self.namespace) + if reported is None: + reported = ET.Element('{%s}reported' % self.namespace) + self.xml.append(reported) + fieldXML = ET.Element('{%s}field' % FormField.namespace) + reported.append(fieldXML) + field = FormField(xml=fieldXML) + field['var'] = var + field['type'] = kwtype + field['label'] = label + field['desc'] = desc + return field + + def cancel(self): + self['type'] = 'cancel' + + def del_fields(self): + fieldsXML = self.xml.findall('{%s}field' % FormField.namespace) + for fieldXML in fieldsXML: + self.xml.remove(fieldXML) + + def del_instructions(self): + instsXML = self.xml.findall('{%s}instructions') + for instXML in instsXML: + self.xml.remove(instXML) + + def del_items(self): + itemsXML = self.xml.find('{%s}item' % self.namespace) + for itemXML in itemsXML: + self.xml.remove(itemXML) + + def del_reported(self): + reportedXML = self.xml.find('{%s}reported' % self.namespace) + if reportedXML is not None: + self.xml.remove(reportedXML) + + def get_fields(self, use_dict=False): + fields = OrderedDict() + fieldsXML = self.xml.findall('{%s}field' % FormField.namespace) + for fieldXML in fieldsXML: + field = FormField(xml=fieldXML) + fields[field['var']] = field + return fields + + def get_instructions(self): + instructions = '' + instsXML = self.xml.findall('{%s}instructions' % self.namespace) + return "\n".join([instXML.text for instXML in instsXML]) + + def get_items(self): + items = [] + itemsXML = self.xml.findall('{%s}item' % self.namespace) + for itemXML in itemsXML: + item = OrderedDict() + fieldsXML = itemXML.findall('{%s}field' % FormField.namespace) + for fieldXML in fieldsXML: + field = FormField(xml=fieldXML) + item[field['var']] = field['value'] + items.append(item) + return items + + def get_reported(self): + fields = OrderedDict() + xml = self.xml.findall('{%s}reported/{%s}field' % (self.namespace, + FormField.namespace)) + for field in xml: + field = FormField(xml=field) + fields[field['var']] = field + return fields + + def get_values(self): + values = OrderedDict() + fields = self['fields'] + for var in fields: + values[var] = fields[var]['value'] + return values + + def reply(self): + if self['type'] == 'form': + self['type'] = 'submit' + elif self['type'] == 'submit': + self['type'] = 'result' + + def set_fields(self, fields): + del self['fields'] + if not isinstance(fields, list): + fields = fields.items() + for var, field in fields: + field['var'] = var + self.add_field(**field) + + def set_instructions(self, instructions): + del self['instructions'] + if instructions in [None, '']: + return + instructions = instructions.split('\n') + for instruction in instructions: + inst = ET.Element('{%s}instructions' % self.namespace) + inst.text = instruction + self.xml.append(inst) + + def set_items(self, items): + for item in items: + self.add_item(item) + + def set_reported(self, reported): + for var in reported: + field = reported[var] + field['var'] = var + self.add_reported(var, **field) + + def set_values(self, values): + fields = self['fields'] + for field in values: + fields[field]['value'] = values[field] + + def merge(self, other): + new = copy.copy(self) + if type(other) == dict: + new['values'] = other + return new + nfields = new['fields'] + ofields = other['fields'] + nfields.update(ofields) + new['fields'] = nfields + return new + + +Form.setType = Form.set_type +Form.addField = Form.add_field +Form.addItem = Form.add_item +Form.addReported = Form.add_reported +Form.delFields = Form.del_fields +Form.delInstructions = Form.del_instructions +Form.delItems = Form.del_items +Form.delReported = Form.del_reported +Form.getFields = Form.get_fields +Form.getInstructions = Form.get_instructions +Form.getItems = Form.get_items +Form.getReported = Form.get_reported +Form.getValues = Form.get_values +Form.setFields = Form.set_fields +Form.setInstructions = Form.set_instructions +Form.setItems = Form.set_items +Form.setReported = Form.set_reported +Form.setValues = Form.set_values diff --git a/sleekxmpp/plugins/xep_0009/__init__.py b/sleekxmpp/plugins/xep_0009/__init__.py new file mode 100644 index 00000000..2cd14170 --- /dev/null +++ b/sleekxmpp/plugins/xep_0009/__init__.py @@ -0,0 +1,11 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0009 import stanza +from sleekxmpp.plugins.xep_0009.rpc import xep_0009 +from sleekxmpp.plugins.xep_0009.stanza import RPCQuery, MethodCall, MethodResponse diff --git a/sleekxmpp/plugins/xep_0009/binding.py b/sleekxmpp/plugins/xep_0009/binding.py new file mode 100644 index 00000000..b4395707 --- /dev/null +++ b/sleekxmpp/plugins/xep_0009/binding.py @@ -0,0 +1,169 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from xml.etree import cElementTree as ET +import base64 +import logging +import time + +log = logging.getLogger(__name__) + +_namespace = 'jabber:iq:rpc' + +def fault2xml(fault): + value = dict() + value['faultCode'] = fault['code'] + value['faultString'] = fault['string'] + fault = ET.Element("fault", {'xmlns': _namespace}) + fault.append(_py2xml((value))) + return fault + +def xml2fault(params): + vals = [] + for value in params.findall('{%s}value' % _namespace): + vals.append(_xml2py(value)) + fault = dict() + fault['code'] = vals[0]['faultCode'] + fault['string'] = vals[0]['faultString'] + return fault + +def py2xml(*args): + params = ET.Element("{%s}params" % _namespace) + for x in args: + param = ET.Element("{%s}param" % _namespace) + param.append(_py2xml(x)) + params.append(param) #<params><param>... + return params + +def _py2xml(*args): + for x in args: + val = ET.Element("{%s}value" % _namespace) + if x is None: + nil = ET.Element("{%s}nil" % _namespace) + val.append(nil) + elif type(x) is int: + i4 = ET.Element("{%s}i4" % _namespace) + i4.text = str(x) + val.append(i4) + elif type(x) is bool: + boolean = ET.Element("{%s}boolean" % _namespace) + boolean.text = str(int(x)) + val.append(boolean) + elif type(x) is str: + string = ET.Element("{%s}string" % _namespace) + string.text = x + val.append(string) + elif type(x) is float: + double = ET.Element("{%s}double" % _namespace) + double.text = str(x) + val.append(double) + elif type(x) is rpcbase64: + b64 = ET.Element("{%s}base64" % _namespace) + b64.text = x.encoded() + val.append(b64) + elif type(x) is rpctime: + iso = ET.Element("{%s}dateTime.iso8601" % _namespace) + iso.text = str(x) + val.append(iso) + elif type(x) in (list, tuple): + array = ET.Element("{%s}array" % _namespace) + data = ET.Element("{%s}data" % _namespace) + for y in x: + data.append(_py2xml(y)) + array.append(data) + val.append(array) + elif type(x) is dict: + struct = ET.Element("{%s}struct" % _namespace) + for y in x.keys(): + member = ET.Element("{%s}member" % _namespace) + name = ET.Element("{%s}name" % _namespace) + name.text = y + member.append(name) + member.append(_py2xml(x[y])) + struct.append(member) + val.append(struct) + return val + +def xml2py(params): + namespace = 'jabber:iq:rpc' + vals = [] + for param in params.findall('{%s}param' % namespace): + vals.append(_xml2py(param.find('{%s}value' % namespace))) + return vals + +def _xml2py(value): + namespace = 'jabber:iq:rpc' + if value.find('{%s}nil' % namespace) is not None: + return None + if value.find('{%s}i4' % namespace) is not None: + return int(value.find('{%s}i4' % namespace).text) + if value.find('{%s}int' % namespace) is not None: + return int(value.find('{%s}int' % namespace).text) + if value.find('{%s}boolean' % namespace) is not None: + return bool(int(value.find('{%s}boolean' % namespace).text)) + if value.find('{%s}string' % namespace) is not None: + return value.find('{%s}string' % namespace).text + if value.find('{%s}double' % namespace) is not None: + return float(value.find('{%s}double' % namespace).text) + if value.find('{%s}base64' % namespace) is not None: + return rpcbase64(value.find('{%s}base64' % namespace).text.encode()) + if value.find('{%s}Base64' % namespace) is not None: + # Older versions of XEP-0009 used Base64 + return rpcbase64(value.find('{%s}Base64' % namespace).text.encode()) + if value.find('{%s}dateTime.iso8601' % namespace) is not None: + return rpctime(value.find('{%s}dateTime.iso8601' % namespace).text) + if value.find('{%s}struct' % namespace) is not None: + struct = {} + for member in value.find('{%s}struct' % namespace).findall('{%s}member' % namespace): + struct[member.find('{%s}name' % namespace).text] = _xml2py(member.find('{%s}value' % namespace)) + return struct + if value.find('{%s}array' % namespace) is not None: + array = [] + for val in value.find('{%s}array' % namespace).find('{%s}data' % namespace).findall('{%s}value' % namespace): + array.append(_xml2py(val)) + return array + raise ValueError() + + + +class rpcbase64(object): + + def __init__(self, data): + #base 64 encoded string + self.data = data + + def decode(self): + return base64.b64decode(self.data) + + def __str__(self): + return self.decode().decode() + + def encoded(self): + return self.data.decode() + + + +class rpctime(object): + + def __init__(self,data=None): + #assume string data is in iso format YYYYMMDDTHH:MM:SS + if type(data) is str: + self.timestamp = time.strptime(data,"%Y%m%dT%H:%M:%S") + elif type(data) is time.struct_time: + self.timestamp = data + elif data is None: + self.timestamp = time.gmtime() + else: + raise ValueError() + + def iso8601(self): + #return a iso8601 string + return time.strftime("%Y%m%dT%H:%M:%S",self.timestamp) + + def __str__(self): + return self.iso8601() diff --git a/sleekxmpp/plugins/xep_0009/remote.py b/sleekxmpp/plugins/xep_0009/remote.py new file mode 100644 index 00000000..8c08e8f3 --- /dev/null +++ b/sleekxmpp/plugins/xep_0009/remote.py @@ -0,0 +1,742 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from binding import py2xml, xml2py, xml2fault, fault2xml +from threading import RLock +import abc +import inspect +import logging +import sleekxmpp +import sys +import threading +import traceback + +log = logging.getLogger(__name__) + +def _intercept(method, name, public): + def _resolver(instance, *args, **kwargs): + log.debug("Locally calling %s.%s with arguments %s.", instance.FQN(), method.__name__, args) + try: + value = method(instance, *args, **kwargs) + if value == NotImplemented: + raise InvocationException("Local handler does not implement %s.%s!" % (instance.FQN(), method.__name__)) + return value + except InvocationException: + raise + except Exception as e: + raise InvocationException("A problem occured calling %s.%s!" % (instance.FQN(), method.__name__), e) + _resolver._rpc = public + _resolver._rpc_name = method.__name__ if name is None else name + return _resolver + +def remote(function_argument, public = True): + ''' + Decorator for methods which are remotely callable. This decorator + works in conjunction with classes which extend ABC Endpoint. + Example: + + @remote + def remote_method(arg1, arg2) + + Arguments: + function_argument -- a stand-in for either the actual method + OR a new name (string) for the method. In that case the + method is considered mapped: + Example: + + @remote("new_name") + def remote_method(arg1, arg2) + + public -- A flag which indicates if this method should be part + of the known dictionary of remote methods. Defaults to True. + Example: + + @remote(False) + def remote_method(arg1, arg2) + + Note: renaming and revising (public vs. private) can be combined. + Example: + + @remote("new_name", False) + def remote_method(arg1, arg2) + ''' + if hasattr(function_argument, '__call__'): + return _intercept(function_argument, None, public) + else: + if not isinstance(function_argument, basestring): + if not isinstance(function_argument, bool): + raise Exception('Expected an RPC method name or visibility modifier!') + else: + def _wrap_revised(function): + function = _intercept(function, None, function_argument) + return function + return _wrap_revised + def _wrap_remapped(function): + function = _intercept(function, function_argument, public) + return function + return _wrap_remapped + + +class ACL: + ''' + An Access Control List (ACL) is a list of rules, which are evaluated + in order until a match is found. The policy of the matching rule + is then applied. + + Rules are 3-tuples, consisting of a policy enumerated type, a JID + expression and a RCP resource expression. + + Examples: + [ (ACL.ALLOW, '*', '*') ] allow everyone everything, no restrictions + [ (ACL.DENY, '*', '*') ] deny everyone everything, no restrictions + [ (ACL.ALLOW, 'test@xmpp.org/unit', 'test.*'), + (ACL.DENY, '*', '*') ] deny everyone everything, except named + JID, which is allowed access to endpoint 'test' only. + + The use of wildcards is allowed in expressions, as follows: + '*' everyone, or everything (= all endpoints and methods) + 'test@xmpp.org/*' every JID regardless of JID resource + '*@xmpp.org/rpc' every JID from domain xmpp.org with JID res 'rpc' + 'frank@*' every 'frank', regardless of domain or JID res + 'system.*' all methods of endpoint 'system' + '*.reboot' all methods reboot regardless of endpoint + ''' + ALLOW = True + DENY = False + + @classmethod + def check(cls, rules, jid, resource): + if rules is None: + return cls.DENY # No rules means no access! + jid = str(jid) # Check the string representation of the JID. + if not jid: + return cls.DENY # Can't check an empty JID. + for rule in rules: + policy = cls._check(rule, jid, resource) + if policy is not None: + return policy + return cls.DENY # By default if not rule matches, deny access. + + @classmethod + def _check(cls, rule, jid, resource): + if cls._match(jid, rule[1]) and cls._match(resource, rule[2]): + return rule[0] + else: + return None + + @classmethod + def _next_token(cls, expression, index): + new_index = expression.find('*', index) + if new_index == 0: + return '' + else: + if new_index == -1: + return expression[index : ] + else: + return expression[index : new_index] + + @classmethod + def _match(cls, value, expression): + #! print "_match [VALUE] %s [EXPR] %s" % (value, expression) + index = 0 + position = 0 + while index < len(expression): + token = cls._next_token(expression, index) + #! print "[TOKEN] '%s'" % token + size = len(token) + if size > 0: + token_index = value.find(token, position) + if token_index == -1: + return False + else: + #! print "[INDEX-OF] %s" % token_index + position = token_index + len(token) + pass + if size == 0: + index += 1 + else: + index += size + #! print "index %s position %s" % (index, position) + return True + +ANY_ALL = [ (ACL.ALLOW, '*', '*') ] + + +class RemoteException(Exception): + ''' + Base exception for RPC. This exception is raised when a problem + occurs in the network layer. + ''' + + def __init__(self, message="", cause=None): + ''' + Initializes a new RemoteException. + + Arguments: + message -- The message accompanying this exception. + cause -- The underlying cause of this exception. + ''' + self._message = message + self._cause = cause + pass + + def __str__(self): + return repr(self._message) + + def get_message(self): + return self._message + + def get_cause(self): + return self._cause + + + +class InvocationException(RemoteException): + ''' + Exception raised when a problem occurs during the remote invocation + of a method. + ''' + pass + + + +class AuthorizationException(RemoteException): + ''' + Exception raised when the caller is not authorized to invoke the + remote method. + ''' + pass + + +class TimeoutException(Exception): + ''' + Exception raised when the synchronous execution of a method takes + longer than the given threshold because an underlying asynchronous + reply did not arrive in time. + ''' + pass + + +class Callback(object): + ''' + A base class for callback handlers. + ''' + __metaclass__ = abc.ABCMeta + + + @abc.abstractproperty + def set_value(self, value): + return NotImplemented + + @abc.abstractproperty + def cancel_with_error(self, exception): + return NotImplemented + + +class Future(Callback): + ''' + Represents the result of an asynchronous computation. + ''' + + def __init__(self): + ''' + Initializes a new Future. + ''' + self._value = None + self._exception = None + self._event = threading.Event() + pass + + def set_value(self, value): + ''' + Sets the value of this Future. Once the value is set, a caller + blocked on get_value will be able to continue. + ''' + self._value = value + self._event.set() + + def get_value(self, timeout=None): + ''' + Gets the value of this Future. This call will block until + the result is available, or until an optional timeout expires. + When this Future is cancelled with an error, + + Arguments: + timeout -- The maximum waiting time to obtain the value. + ''' + self._event.wait(timeout) + if self._exception: + raise self._exception + if not self._event.is_set(): + raise TimeoutException + return self._value + + def is_done(self): + ''' + Returns true if a value has been returned. + ''' + return self._event.is_set() + + def cancel_with_error(self, exception): + ''' + Cancels the Future because of an error. Once cancelled, a + caller blocked on get_value will be able to continue. + ''' + self._exception = exception + self._event.set() + + + +class Endpoint(object): + ''' + The Endpoint class is an abstract base class for all objects + participating in an RPC-enabled XMPP network. + + A user subclassing this class is required to implement the method: + FQN(self) + where FQN stands for Fully Qualified Name, an unambiguous name + which specifies which object an RPC call refers to. It is the + first part in a RPC method name '<fqn>.<method>'. + ''' + __metaclass__ = abc.ABCMeta + + + def __init__(self, session, target_jid): + ''' + Initialize a new Endpoint. This constructor should never be + invoked by a user, instead it will be called by the factories + which instantiate the RPC-enabled objects, of which only + the classes are provided by the user. + + Arguments: + session -- An RPC session instance. + target_jid -- the identity of the remote XMPP entity. + ''' + self.session = session + self.target_jid = target_jid + + @abc.abstractproperty + def FQN(self): + return NotImplemented + + def get_methods(self): + ''' + Returns a dictionary of all RPC method names provided by this + class. This method returns the actual method names as found + in the class definition which have been decorated with: + + @remote + def some_rpc_method(arg1, arg2) + + + Unless: + (1) the name has been remapped, in which case the new + name will be returned. + + @remote("new_name") + def some_rpc_method(arg1, arg2) + + (2) the method is set to hidden + + @remote(False) + def some_hidden_method(arg1, arg2) + ''' + result = dict() + for function in dir(self): + test_attr = getattr(self, function, None) + try: + if test_attr._rpc: + result[test_attr._rpc_name] = test_attr + except Exception: + pass + return result + + + +class Proxy(Endpoint): + ''' + Implementation of the Proxy pattern which is intended to wrap + around Endpoints in order to intercept calls, marshall them and + forward them to the remote object. + ''' + + def __init__(self, endpoint, callback = None): + ''' + Initializes a new Proxy. + + Arguments: + endpoint -- The endpoint which is proxified. + ''' + self._endpoint = endpoint + self._callback = callback + + def __getattribute__(self, name, *args): + if name in ('__dict__', '_endpoint', 'async', '_callback'): + return object.__getattribute__(self, name) + else: + attribute = self._endpoint.__getattribute__(name) + if hasattr(attribute, '__call__'): + try: + if attribute._rpc: + def _remote_call(*args, **kwargs): + log.debug("Remotely calling '%s.%s' with arguments %s.", self._endpoint.FQN(), attribute._rpc_name, args) + return self._endpoint.session._call_remote(self._endpoint.target_jid, "%s.%s" % (self._endpoint.FQN(), attribute._rpc_name), self._callback, *args, **kwargs) + return _remote_call + except: + pass # If the attribute doesn't exist, don't care! + return attribute + + def async(self, callback): + return Proxy(self._endpoint, callback) + + def get_endpoint(self): + ''' + Returns the proxified endpoint. + ''' + return self._endpoint + + def FQN(self): + return self._endpoint.FQN() + + +class JabberRPCEntry(object): + + + def __init__(self, endpoint_FQN, call): + self._endpoint_FQN = endpoint_FQN + self._call = call + + def call_method(self, args): + return_value = self._call(*args) + if return_value is None: + return return_value + else: + return self._return(return_value) + + def get_endpoint_FQN(self): + return self._endpoint_FQN + + def _return(self, *args): + return args + + +class RemoteSession(object): + ''' + A context object for a Jabber-RPC session. + ''' + + + def __init__(self, client, session_close_callback): + ''' + Initializes a new RPC session. + + Arguments: + client -- The SleekXMPP client associated with this session. + session_close_callback -- A callback called when the + session is closed. + ''' + self._client = client + self._session_close_callback = session_close_callback + self._event = threading.Event() + self._entries = {} + self._callbacks = {} + self._acls = {} + self._lock = RLock() + + def _wait(self): + self._event.wait() + + def _notify(self, event): + log.debug("RPC Session as %s started.", self._client.boundjid.full) + self._client.sendPresence() + self._event.set() + pass + + def _register_call(self, endpoint, method, name=None): + ''' + Registers a method from an endpoint as remotely callable. + ''' + if name is None: + name = method.__name__ + key = "%s.%s" % (endpoint, name) + log.debug("Registering call handler for %s (%s).", key, method) + with self._lock: + if key in self._entries: + raise KeyError("A handler for %s has already been regisered!" % endpoint) + self._entries[key] = JabberRPCEntry(endpoint, method) + return key + + def _register_acl(self, endpoint, acl): + log.debug("Registering ACL %s for endpoint %s.", repr(acl), endpoint) + with self._lock: + self._acls[endpoint] = acl + + def _register_callback(self, pid, callback): + with self._lock: + self._callbacks[pid] = callback + + def forget_callback(self, callback): + with self._lock: + pid = self._find_key(self._callbacks, callback) + if pid is not None: + del self._callback[pid] + else: + raise ValueError("Unknown callback!") + pass + + def _find_key(self, dict, value): + """return the key of dictionary dic given the value""" + search = [k for k, v in dict.iteritems() if v == value] + if len(search) == 0: + return None + else: + return search[0] + + def _unregister_call(self, key): + #removes the registered call + with self._lock: + if self._entries[key]: + del self._entries[key] + else: + raise ValueError() + + def new_proxy(self, target_jid, endpoint_cls): + ''' + Instantiates a new proxy object, which proxies to a remote + endpoint. This method uses a class reference without + constructor arguments to instantiate the proxy. + + Arguments: + target_jid -- the XMPP entity ID hosting the endpoint. + endpoint_cls -- The remote (duck) type. + ''' + try: + argspec = inspect.getargspec(endpoint_cls.__init__) + args = [None] * (len(argspec[0]) - 1) + result = endpoint_cls(*args) + Endpoint.__init__(result, self, target_jid) + return Proxy(result) + except: + traceback.print_exc(file=sys.stdout) + + def new_handler(self, acl, handler_cls, *args, **kwargs): + ''' + Instantiates a new handler object, which is called remotely + by others. The user can control the effect of the call by + implementing the remote method in the local endpoint class. The + returned reference can be called locally and will behave as a + regular instance. + + Arguments: + acl -- Access control list (see ACL class) + handler_clss -- The local (duck) type. + *args -- Constructor arguments for the local type. + **kwargs -- Constructor keyworded arguments for the local + type. + ''' + argspec = inspect.getargspec(handler_cls.__init__) + base_argspec = inspect.getargspec(Endpoint.__init__) + if(argspec == base_argspec): + result = handler_cls(self, self._client.boundjid.full) + else: + result = handler_cls(*args, **kwargs) + Endpoint.__init__(result, self, self._client.boundjid.full) + method_dict = result.get_methods() + for method_name, method in method_dict.iteritems(): + #!!! self._client.plugin['xep_0009'].register_call(result.FQN(), method, method_name) + self._register_call(result.FQN(), method, method_name) + self._register_acl(result.FQN(), acl) + return result + +# def is_available(self, targetCls, pto): +# return self._client.is_available(pto) + + def _call_remote(self, pto, pmethod, callback, *arguments): + iq = self._client.plugin['xep_0009'].make_iq_method_call(pto, pmethod, py2xml(*arguments)) + pid = iq['id'] + if callback is None: + future = Future() + self._register_callback(pid, future) + iq.send() + return future.get_value(30) + else: + log.debug("[RemoteSession] _call_remote %s", callback) + self._register_callback(pid, callback) + iq.send() + + def close(self): + ''' + Closes this session. + ''' + self._client.disconnect(False) + self._session_close_callback() + + def _on_jabber_rpc_method_call(self, iq): + iq.enable('rpc_query') + params = iq['rpc_query']['method_call']['params'] + args = xml2py(params) + pmethod = iq['rpc_query']['method_call']['method_name'] + try: + with self._lock: + entry = self._entries[pmethod] + rules = self._acls[entry.get_endpoint_FQN()] + if ACL.check(rules, iq['from'], pmethod): + return_value = entry.call_method(args) + else: + raise AuthorizationException("Unauthorized access to %s from %s!" % (pmethod, iq['from'])) + if return_value is None: + return_value = () + response = self._client.plugin['xep_0009'].make_iq_method_response(iq['id'], iq['from'], py2xml(*return_value)) + response.send() + except InvocationException as ie: + fault = dict() + fault['code'] = 500 + fault['string'] = ie.get_message() + self._client.plugin['xep_0009']._send_fault(iq, fault2xml(fault)) + except AuthorizationException as ae: + log.error(ae.get_message()) + error = self._client.plugin['xep_0009']._forbidden(iq) + error.send() + except Exception as e: + if isinstance(e, KeyError): + log.error("No handler available for %s!", pmethod) + error = self._client.plugin['xep_0009']._item_not_found(iq) + else: + traceback.print_exc(file=sys.stderr) + log.error("An unexpected problem occurred invoking method %s!", pmethod) + error = self._client.plugin['xep_0009']._undefined_condition(iq) + #! print "[REMOTE.PY] _handle_remote_procedure_call AN ERROR SHOULD BE SENT NOW %s " % e + error.send() + + def _on_jabber_rpc_method_response(self, iq): + iq.enable('rpc_query') + args = xml2py(iq['rpc_query']['method_response']['params']) + pid = iq['id'] + with self._lock: + callback = self._callbacks[pid] + del self._callbacks[pid] + if(len(args) > 0): + callback.set_value(args[0]) + else: + callback.set_value(None) + pass + + def _on_jabber_rpc_method_response2(self, iq): + iq.enable('rpc_query') + if iq['rpc_query']['method_response']['fault'] is not None: + self._on_jabber_rpc_method_fault(iq) + else: + args = xml2py(iq['rpc_query']['method_response']['params']) + pid = iq['id'] + with self._lock: + callback = self._callbacks[pid] + del self._callbacks[pid] + if(len(args) > 0): + callback.set_value(args[0]) + else: + callback.set_value(None) + pass + + def _on_jabber_rpc_method_fault(self, iq): + iq.enable('rpc_query') + fault = xml2fault(iq['rpc_query']['method_response']['fault']) + pid = iq['id'] + with self._lock: + callback = self._callbacks[pid] + del self._callbacks[pid] + e = { + 500: InvocationException + }[fault['code']](fault['string']) + callback.cancel_with_error(e) + + def _on_jabber_rpc_error(self, iq): + pid = iq['id'] + pmethod = self._client.plugin['xep_0009']._extract_method(iq['rpc_query']) + code = iq['error']['code'] + type = iq['error']['type'] + condition = iq['error']['condition'] + #! print("['REMOTE.PY']._BINDING_handle_remote_procedure_error -> ERROR! ERROR! ERROR! Condition is '%s'" % condition) + with self._lock: + callback = self._callbacks[pid] + del self._callbacks[pid] + e = { + 'item-not-found': RemoteException("No remote handler available for %s at %s!" % (pmethod, iq['from'])), + 'forbidden': AuthorizationException("Forbidden to invoke remote handler for %s at %s!" % (pmethod, iq['from'])), + 'undefined-condition': RemoteException("An unexpected problem occured trying to invoke %s at %s!" % (pmethod, iq['from'])), + }[condition] + if e is None: + RemoteException("An unexpected exception occurred at %s!" % iq['from']) + callback.cancel_with_error(e) + + +class Remote(object): + ''' + Bootstrap class for Jabber-RPC sessions. New sessions are openend + with an existing XMPP client, or one is instantiated on demand. + ''' + _instance = None + _sessions = dict() + _lock = threading.RLock() + + @classmethod + def new_session_with_client(cls, client, callback=None): + ''' + Opens a new session with a given client. + + Arguments: + client -- An XMPP client. + callback -- An optional callback which can be used to track + the starting state of the session. + ''' + with Remote._lock: + if(client.boundjid.bare in cls._sessions): + raise RemoteException("There already is a session associated with these credentials!") + else: + cls._sessions[client.boundjid.bare] = client; + def _session_close_callback(): + with Remote._lock: + del cls._sessions[client.boundjid.bare] + result = RemoteSession(client, _session_close_callback) + client.plugin['xep_0009'].xmpp.add_event_handler('jabber_rpc_method_call', result._on_jabber_rpc_method_call, threaded=True) + client.plugin['xep_0009'].xmpp.add_event_handler('jabber_rpc_method_response', result._on_jabber_rpc_method_response, threaded=True) + client.plugin['xep_0009'].xmpp.add_event_handler('jabber_rpc_method_fault', result._on_jabber_rpc_method_fault, threaded=True) + client.plugin['xep_0009'].xmpp.add_event_handler('jabber_rpc_error', result._on_jabber_rpc_error, threaded=True) + if callback is None: + start_event_handler = result._notify + else: + start_event_handler = callback + client.add_event_handler("session_start", start_event_handler) + if client.connect(): + client.process(threaded=True) + else: + raise RemoteException("Could not connect to XMPP server!") + pass + if callback is None: + result._wait() + return result + + @classmethod + def new_session(cls, jid, password, callback=None): + ''' + Opens a new session and instantiates a new XMPP client. + + Arguments: + jid -- The XMPP JID for logging in. + password -- The password for logging in. + callback -- An optional callback which can be used to track + the starting state of the session. + ''' + client = sleekxmpp.ClientXMPP(jid, password) + #? Register plug-ins. + client.registerPlugin('xep_0004') # Data Forms + client.registerPlugin('xep_0009') # Jabber-RPC + client.registerPlugin('xep_0030') # Service Discovery + client.registerPlugin('xep_0060') # PubSub + client.registerPlugin('xep_0199') # XMPP Ping + return cls.new_session_with_client(client, callback) + diff --git a/sleekxmpp/plugins/xep_0009/rpc.py b/sleekxmpp/plugins/xep_0009/rpc.py new file mode 100644 index 00000000..4f749f30 --- /dev/null +++ b/sleekxmpp/plugins/xep_0009/rpc.py @@ -0,0 +1,221 @@ +"""
+ SleekXMPP: The Sleek XMPP Library
+ Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON).
+ This file is part of SleekXMPP.
+
+ See the file LICENSE for copying permission.
+"""
+
+from sleekxmpp.plugins import base
+from sleekxmpp.plugins.xep_0009.stanza.RPC import RPCQuery, MethodCall, MethodResponse
+from sleekxmpp.stanza.iq import Iq
+from sleekxmpp.xmlstream.handler.callback import Callback
+from sleekxmpp.xmlstream.matcher.xpath import MatchXPath
+from sleekxmpp.xmlstream.stanzabase import register_stanza_plugin
+from xml.etree import cElementTree as ET
+import logging
+
+
+
+log = logging.getLogger(__name__)
+
+
+
+class xep_0009(base.base_plugin):
+
+ def plugin_init(self):
+ self.xep = '0009'
+ self.description = 'Jabber-RPC'
+ #self.stanza = sleekxmpp.plugins.xep_0009.stanza
+
+ register_stanza_plugin(Iq, RPCQuery)
+ register_stanza_plugin(RPCQuery, MethodCall)
+ register_stanza_plugin(RPCQuery, MethodResponse)
+
+ self.xmpp.registerHandler(
+ Callback('RPC Call', MatchXPath('{%s}iq/{%s}query/{%s}methodCall' % (self.xmpp.default_ns, RPCQuery.namespace, RPCQuery.namespace)),
+ self._handle_method_call)
+ )
+ self.xmpp.registerHandler(
+ Callback('RPC Call', MatchXPath('{%s}iq/{%s}query/{%s}methodResponse' % (self.xmpp.default_ns, RPCQuery.namespace, RPCQuery.namespace)),
+ self._handle_method_response)
+ )
+ self.xmpp.registerHandler(
+ Callback('RPC Call', MatchXPath('{%s}iq/{%s}error' % (self.xmpp.default_ns, self.xmpp.default_ns)),
+ self._handle_error)
+ )
+ self.xmpp.add_event_handler('jabber_rpc_method_call', self._on_jabber_rpc_method_call)
+ self.xmpp.add_event_handler('jabber_rpc_method_response', self._on_jabber_rpc_method_response)
+ self.xmpp.add_event_handler('jabber_rpc_method_fault', self._on_jabber_rpc_method_fault)
+ self.xmpp.add_event_handler('jabber_rpc_error', self._on_jabber_rpc_error)
+ self.xmpp.add_event_handler('error', self._handle_error)
+ #self.activeCalls = []
+
+ def post_init(self):
+ base.base_plugin.post_init(self)
+ self.xmpp.plugin['xep_0030'].add_feature('jabber:iq:rpc')
+ self.xmpp.plugin['xep_0030'].add_identity('automation','rpc')
+
+ def make_iq_method_call(self, pto, pmethod, params):
+ iq = self.xmpp.makeIqSet()
+ iq.attrib['to'] = pto
+ iq.attrib['from'] = self.xmpp.boundjid.full
+ iq.enable('rpc_query')
+ iq['rpc_query']['method_call']['method_name'] = pmethod
+ iq['rpc_query']['method_call']['params'] = params
+ return iq;
+
+ def make_iq_method_response(self, pid, pto, params):
+ iq = self.xmpp.makeIqResult(pid)
+ iq.attrib['to'] = pto
+ iq.attrib['from'] = self.xmpp.boundjid.full
+ iq.enable('rpc_query')
+ iq['rpc_query']['method_response']['params'] = params
+ return iq
+
+ def make_iq_method_response_fault(self, pid, pto, params):
+ iq = self.xmpp.makeIqResult(pid)
+ iq.attrib['to'] = pto
+ iq.attrib['from'] = self.xmpp.boundjid.full
+ iq.enable('rpc_query')
+ iq['rpc_query']['method_response']['params'] = None
+ iq['rpc_query']['method_response']['fault'] = params
+ return iq
+
+# def make_iq_method_error(self, pto, pid, pmethod, params, code, type, condition):
+# iq = self.xmpp.makeIqError(pid)
+# iq.attrib['to'] = pto
+# iq.attrib['from'] = self.xmpp.boundjid.full
+# iq['error']['code'] = code
+# iq['error']['type'] = type
+# iq['error']['condition'] = condition
+# iq['rpc_query']['method_call']['method_name'] = pmethod
+# iq['rpc_query']['method_call']['params'] = params
+# return iq
+
+ def _item_not_found(self, iq):
+ payload = iq.get_payload()
+ iq.reply().error().set_payload(payload);
+ iq['error']['code'] = '404'
+ iq['error']['type'] = 'cancel'
+ iq['error']['condition'] = 'item-not-found'
+ return iq
+
+ def _undefined_condition(self, iq):
+ payload = iq.get_payload()
+ iq.reply().error().set_payload(payload)
+ iq['error']['code'] = '500'
+ iq['error']['type'] = 'cancel'
+ iq['error']['condition'] = 'undefined-condition'
+ return iq
+
+ def _forbidden(self, iq):
+ payload = iq.get_payload()
+ iq.reply().error().set_payload(payload)
+ iq['error']['code'] = '403'
+ iq['error']['type'] = 'auth'
+ iq['error']['condition'] = 'forbidden'
+ return iq
+
+ def _recipient_unvailable(self, iq):
+ payload = iq.get_payload()
+ iq.reply().error().set_payload(payload)
+ iq['error']['code'] = '404'
+ iq['error']['type'] = 'wait'
+ iq['error']['condition'] = 'recipient-unavailable'
+ return iq
+
+ def _handle_method_call(self, iq):
+ type = iq['type']
+ if type == 'set':
+ log.debug("Incoming Jabber-RPC call from %s", iq['from'])
+ self.xmpp.event('jabber_rpc_method_call', iq)
+ else:
+ if type == 'error' and ['rpc_query'] is None:
+ self.handle_error(iq)
+ else:
+ log.debug("Incoming Jabber-RPC error from %s", iq['from'])
+ self.xmpp.event('jabber_rpc_error', iq)
+
+ def _handle_method_response(self, iq):
+ if iq['rpc_query']['method_response']['fault'] is not None:
+ log.debug("Incoming Jabber-RPC fault from %s", iq['from'])
+ #self._on_jabber_rpc_method_fault(iq)
+ self.xmpp.event('jabber_rpc_method_fault', iq)
+ else:
+ log.debug("Incoming Jabber-RPC response from %s", iq['from'])
+ self.xmpp.event('jabber_rpc_method_response', iq)
+
+ def _handle_error(self, iq):
+ print("['XEP-0009']._handle_error -> ERROR! Iq is '%s'" % iq)
+ print("#######################")
+ print("### NOT IMPLEMENTED ###")
+ print("#######################")
+
+ def _on_jabber_rpc_method_call(self, iq, forwarded=False):
+ """
+ A default handler for Jabber-RPC method call. If another
+ handler is registered, this one will defer and not run.
+
+ If this handler is called by your own custom handler with
+ forwarded set to True, then it will run as normal.
+ """
+ if not forwarded and self.xmpp.event_handled('jabber_rpc_method_call') > 1:
+ return
+ # Reply with error by default
+ error = self.client.plugin['xep_0009']._item_not_found(iq)
+ error.send()
+
+ def _on_jabber_rpc_method_response(self, iq, forwarded=False):
+ """
+ A default handler for Jabber-RPC method response. If another
+ handler is registered, this one will defer and not run.
+
+ If this handler is called by your own custom handler with
+ forwarded set to True, then it will run as normal.
+ """
+ if not forwarded and self.xmpp.event_handled('jabber_rpc_method_response') > 1:
+ return
+ error = self.client.plugin['xep_0009']._recpient_unavailable(iq)
+ error.send()
+
+ def _on_jabber_rpc_method_fault(self, iq, forwarded=False):
+ """
+ A default handler for Jabber-RPC fault response. If another
+ handler is registered, this one will defer and not run.
+
+ If this handler is called by your own custom handler with
+ forwarded set to True, then it will run as normal.
+ """
+ if not forwarded and self.xmpp.event_handled('jabber_rpc_method_fault') > 1:
+ return
+ error = self.client.plugin['xep_0009']._recpient_unavailable(iq)
+ error.send()
+
+ def _on_jabber_rpc_error(self, iq, forwarded=False):
+ """
+ A default handler for Jabber-RPC error response. If another
+ handler is registered, this one will defer and not run.
+
+ If this handler is called by your own custom handler with
+ forwarded set to True, then it will run as normal.
+ """
+ if not forwarded and self.xmpp.event_handled('jabber_rpc_error') > 1:
+ return
+ error = self.client.plugin['xep_0009']._recpient_unavailable(iq, iq.get_payload())
+ error.send()
+
+ def _send_fault(self, iq, fault_xml): #
+ fault = self.make_iq_method_response_fault(iq['id'], iq['from'], fault_xml)
+ fault.send()
+
+ def _send_error(self, iq):
+ print("['XEP-0009']._send_error -> ERROR! Iq is '%s'" % iq)
+ print("#######################")
+ print("### NOT IMPLEMENTED ###")
+ print("#######################")
+
+ def _extract_method(self, stanza):
+ xml = ET.fromstring("%s" % stanza)
+ return xml.find("./methodCall/methodName").text
+
diff --git a/sleekxmpp/plugins/xep_0009/stanza/RPC.py b/sleekxmpp/plugins/xep_0009/stanza/RPC.py new file mode 100644 index 00000000..3d1c77a2 --- /dev/null +++ b/sleekxmpp/plugins/xep_0009/stanza/RPC.py @@ -0,0 +1,64 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.stanzabase import ElementBase +from xml.etree import cElementTree as ET + + +class RPCQuery(ElementBase): + name = 'query' + namespace = 'jabber:iq:rpc' + plugin_attrib = 'rpc_query' + interfaces = set(()) + subinterfaces = set(()) + plugin_attrib_map = {} + plugin_tag_map = {} + + +class MethodCall(ElementBase): + name = 'methodCall' + namespace = 'jabber:iq:rpc' + plugin_attrib = 'method_call' + interfaces = set(('method_name', 'params')) + subinterfaces = set(()) + plugin_attrib_map = {} + plugin_tag_map = {} + + def get_method_name(self): + return self._get_sub_text('methodName') + + def set_method_name(self, value): + return self._set_sub_text('methodName', value) + + def get_params(self): + return self.xml.find('{%s}params' % self.namespace) + + def set_params(self, params): + self.append(params) + + +class MethodResponse(ElementBase): + name = 'methodResponse' + namespace = 'jabber:iq:rpc' + plugin_attrib = 'method_response' + interfaces = set(('params', 'fault')) + subinterfaces = set(()) + plugin_attrib_map = {} + plugin_tag_map = {} + + def get_params(self): + return self.xml.find('{%s}params' % self.namespace) + + def set_params(self, params): + self.append(params) + + def get_fault(self): + return self.xml.find('{%s}fault' % self.namespace) + + def set_fault(self, fault): + self.append(fault) diff --git a/sleekxmpp/plugins/xep_0009/stanza/__init__.py b/sleekxmpp/plugins/xep_0009/stanza/__init__.py new file mode 100644 index 00000000..5dcbf330 --- /dev/null +++ b/sleekxmpp/plugins/xep_0009/stanza/__init__.py @@ -0,0 +1,9 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0009.stanza.RPC import RPCQuery, MethodCall, MethodResponse diff --git a/sleekxmpp/plugins/xep_0012.py b/sleekxmpp/plugins/xep_0012.py new file mode 100644 index 00000000..c5532bd4 --- /dev/null +++ b/sleekxmpp/plugins/xep_0012.py @@ -0,0 +1,115 @@ +"""
+ SleekXMPP: The Sleek XMPP Library
+ Copyright (C) 2010 Nathanael C. Fritz
+ This file is part of SleekXMPP.
+
+ See the file LICENSE for copying permission.
+"""
+
+from datetime import datetime
+import logging
+
+from . import base
+from .. stanza.iq import Iq
+from .. xmlstream.handler.callback import Callback
+from .. xmlstream.matcher.xpath import MatchXPath
+from .. xmlstream import ElementBase, ET, JID, register_stanza_plugin
+
+
+log = logging.getLogger(__name__)
+
+
+class LastActivity(ElementBase):
+ name = 'query'
+ namespace = 'jabber:iq:last'
+ plugin_attrib = 'last_activity'
+ interfaces = set(('seconds', 'status'))
+
+ def get_seconds(self):
+ return int(self._get_attr('seconds'))
+
+ def set_seconds(self, value):
+ self._set_attr('seconds', str(value))
+
+ def get_status(self):
+ return self.xml.text
+
+ def set_status(self, value):
+ self.xml.text = str(value)
+
+ def del_status(self):
+ self.xml.text = ''
+
+class xep_0012(base.base_plugin):
+ """
+ XEP-0012 Last Activity
+ """
+ def plugin_init(self):
+ self.description = "Last Activity"
+ self.xep = "0012"
+
+ self.xmpp.registerHandler(
+ Callback('Last Activity',
+ MatchXPath('{%s}iq/{%s}query' % (self.xmpp.default_ns,
+ LastActivity.namespace)),
+ self.handle_last_activity_query))
+ register_stanza_plugin(Iq, LastActivity)
+
+ self.xmpp.add_event_handler('last_activity_request', self.handle_last_activity)
+
+
+ def post_init(self):
+ base.base_plugin.post_init(self)
+ if self.xmpp.is_component:
+ # We are a component, so we track the uptime
+ self.xmpp.add_event_handler("session_start", self._reset_uptime)
+ self._start_datetime = datetime.now()
+ self.xmpp.plugin['xep_0030'].add_feature('jabber:iq:last')
+
+ def _reset_uptime(self, event):
+ self._start_datetime = datetime.now()
+
+ def handle_last_activity_query(self, iq):
+ if iq['type'] == 'get':
+ log.debug("Last activity requested by %s", iq['from'])
+ self.xmpp.event('last_activity_request', iq)
+ elif iq['type'] == 'result':
+ log.debug("Last activity result from %s", iq['from'])
+ self.xmpp.event('last_activity', iq)
+
+ def handle_last_activity(self, iq):
+ jid = iq['from']
+
+ if self.xmpp.is_component:
+ # Send the uptime
+ result = LastActivity()
+ td = (datetime.now() - self._start_datetime)
+ result['seconds'] = td.seconds + td.days * 24 * 3600
+ reply = iq.reply().setPayload(result.xml).send()
+ else:
+ barejid = JID(jid).bare
+ if barejid in self.xmpp.roster and ( self.xmpp.roster[barejid]['subscription'] in ('from', 'both') or
+ barejid == self.xmpp.boundjid.bare ):
+ # We don't know how to calculate it
+ iq.reply().error().setPayload(iq['last_activity'].xml)
+ iq['error']['code'] = '503'
+ iq['error']['type'] = 'cancel'
+ iq['error']['condition'] = 'service-unavailable'
+ iq.send()
+ else:
+ iq.reply().error().setPayload(iq['last_activity'].xml)
+ iq['error']['code'] = '403'
+ iq['error']['type'] = 'auth'
+ iq['error']['condition'] = 'forbidden'
+ iq.send()
+
+ def get_last_activity(self, jid):
+ """Query the LastActivity of jid and return it in seconds"""
+ iq = self.xmpp.makeIqGet()
+ query = LastActivity()
+ iq.append(query.xml)
+ iq.attrib['to'] = jid
+ iq.attrib['from'] = self.xmpp.boundjid.full
+ id = iq.get('id')
+ result = iq.send()
+ return result['last_activity']['seconds']
diff --git a/sleekxmpp/plugins/xep_0030/__init__.py b/sleekxmpp/plugins/xep_0030/__init__.py new file mode 100644 index 00000000..2e183852 --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/__init__.py @@ -0,0 +1,12 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0030 import stanza +from sleekxmpp.plugins.xep_0030.stanza import DiscoInfo, DiscoItems +from sleekxmpp.plugins.xep_0030.static import StaticDisco +from sleekxmpp.plugins.xep_0030.disco import xep_0030 diff --git a/sleekxmpp/plugins/xep_0030/disco.py b/sleekxmpp/plugins/xep_0030/disco.py new file mode 100644 index 00000000..2267401e --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/disco.py @@ -0,0 +1,800 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.exceptions import XMPPError, IqError, IqTimeout +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from sleekxmpp.plugins.xep_0030 import DiscoInfo, DiscoItems, StaticDisco + + +log = logging.getLogger(__name__) + + +class xep_0030(base_plugin): + + """ + XEP-0030: Service Discovery + + Service discovery in XMPP allows entities to discover information about + other agents in the network, such as the feature sets supported by a + client, or signposts to other, related entities. + + Also see <http://www.xmpp.org/extensions/xep-0030.html>. + + The XEP-0030 plugin works using a hierarchy of dynamic + node handlers, ranging from global handlers to specific + JID+node handlers. The default set of handlers operate + in a static manner, storing disco information in memory. + However, custom handlers may use any available backend + storage mechanism desired, such as SQLite or Redis. + + Node handler hierarchy: + JID | Node | Level + --------------------- + None | None | Global + Given | None | All nodes for the JID + None | Given | Node on self.xmpp.boundjid + Given | Given | A single node + + Stream Handlers: + Disco Info -- Any Iq stanze that includes a query with the + namespace http://jabber.org/protocol/disco#info. + Disco Items -- Any Iq stanze that includes a query with the + namespace http://jabber.org/protocol/disco#items. + + Events: + disco_info -- Received a disco#info Iq query result. + disco_items -- Received a disco#items Iq query result. + disco_info_query -- Received a disco#info Iq query request. + disco_items_query -- Received a disco#items Iq query request. + + Attributes: + stanza -- A reference to the module containing the + stanza classes provided by this plugin. + static -- Object containing the default set of + static node handlers. + default_handlers -- A dictionary mapping operations to the default + global handler (by default, the static handlers). + xmpp -- The main SleekXMPP object. + + Methods: + set_node_handler -- Assign a handler to a JID/node combination. + del_node_handler -- Remove a handler from a JID/node combination. + get_info -- Retrieve disco#info data, locally or remote. + get_items -- Retrieve disco#items data, locally or remote. + set_identities -- + set_features -- + set_items -- + del_items -- + del_identity -- + del_feature -- + del_item -- + add_identity -- + add_feature -- + add_item -- + """ + + def plugin_init(self): + """ + Start the XEP-0030 plugin. + """ + self.xep = '0030' + self.description = 'Service Discovery' + self.stanza = sleekxmpp.plugins.xep_0030.stanza + + self.xmpp.register_handler( + Callback('Disco Info', + StanzaPath('iq/disco_info'), + self._handle_disco_info)) + + self.xmpp.register_handler( + Callback('Disco Items', + StanzaPath('iq/disco_items'), + self._handle_disco_items)) + + register_stanza_plugin(Iq, DiscoInfo) + register_stanza_plugin(Iq, DiscoItems) + + self.static = StaticDisco(self.xmpp, self) + + self.use_cache = self.config.get('use_cache', True) + self.wrap_results = self.config.get('wrap_results', False) + + self._disco_ops = [ + 'get_info', 'set_info', 'set_identities', 'set_features', + 'get_items', 'set_items', 'del_items', 'add_identity', + 'del_identity', 'add_feature', 'del_feature', 'add_item', + 'del_item', 'del_identities', 'del_features', 'cache_info', + 'get_cached_info', 'supports', 'has_identity'] + + self.default_handlers = {} + self._handlers = {} + for op in self._disco_ops: + self._add_disco_op(op, getattr(self.static, op)) + + def post_init(self): + """Handle cross-plugin dependencies.""" + base_plugin.post_init(self) + if 'xep_0059' in self.xmpp.plugin: + register_stanza_plugin(DiscoItems, + self.xmpp['xep_0059'].stanza.Set) + + def _add_disco_op(self, op, default_handler): + self.default_handlers[op] = default_handler + self._handlers[op] = {'global': default_handler, + 'jid': {}, + 'node': {}} + + def set_node_handler(self, htype, jid=None, node=None, handler=None): + """ + Add a node handler for the given hierarchy level and + handler type. + + Node handlers are ordered in a hierarchy where the + most specific handler is executed. Thus, a fallback, + global handler can be used for the majority of cases + with a few node specific handler that override the + global behavior. + + Node handler hierarchy: + JID | Node | Level + --------------------- + None | None | Global + Given | None | All nodes for the JID + None | Given | Node on self.xmpp.boundjid + Given | Given | A single node + + Handler types: + get_info + get_items + set_identities + set_features + set_items + del_items + del_identities + del_identity + del_feature + del_features + del_item + add_identity + add_feature + add_item + + Arguments: + htype -- The operation provided by the handler. + jid -- The JID the handler applies to. May be narrowed + further if a node is given. + node -- The particular node the handler is for. If no JID + is given, then the self.xmpp.boundjid.full is + assumed. + handler -- The handler function to use. + """ + if htype not in self._disco_ops: + return + if jid is None and node is None: + self._handlers[htype]['global'] = handler + elif node is None: + self._handlers[htype]['jid'][jid] = handler + elif jid is None: + if self.xmpp.is_component: + jid = self.xmpp.boundjid.full + else: + jid = self.xmpp.boundjid.bare + self._handlers[htype]['node'][(jid, node)] = handler + else: + self._handlers[htype]['node'][(jid, node)] = handler + + def del_node_handler(self, htype, jid, node): + """ + Remove a handler type for a JID and node combination. + + The next handler in the hierarchy will be used if one + exists. If removing the global handler, make sure that + other handlers exist to process existing nodes. + + Node handler hierarchy: + JID | Node | Level + --------------------- + None | None | Global + Given | None | All nodes for the JID + None | Given | Node on self.xmpp.boundjid + Given | Given | A single node + + Arguments: + htype -- The type of handler to remove. + jid -- The JID from which to remove the handler. + node -- The node from which to remove the handler. + """ + self.set_node_handler(htype, jid, node, None) + + def restore_defaults(self, jid=None, node=None, handlers=None): + """ + Change all or some of a node's handlers to the default + handlers. Useful for manually overriding the contents + of a node that would otherwise be handled by a JID level + or global level dynamic handler. + + The default is to use the built-in static handlers, but that + may be changed by modifying self.default_handlers. + + Arguments: + jid -- The JID owning the node to modify. + node -- The node to change to using static handlers. + handlers -- Optional list of handlers to change to the + default version. If provided, only these + handlers will be changed. Otherwise, all + handlers will use the default version. + """ + if handlers is None: + handlers = self._disco_ops + for op in handlers: + self.del_node_handler(op, jid, node) + self.set_node_handler(op, jid, node, self.default_handlers[op]) + + def supports(self, jid=None, node=None, feature=None, local=False, + cached=True, ifrom=None): + """ + Check if a JID supports a given feature. + + Return values: + True -- The feature is supported + False -- The feature is not listed as supported + None -- Nothing could be found due to a timeout + + Arguments: + jid -- Request info from this JID. + node -- The particular node to query. + feature -- The name of the feature to check. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the info. + cached -- If true, then look for the disco info data from + the local cache system. If no results are found, + send the query as usual. The self.use_cache + setting must be set to true for this option to + be useful. If set to false, then the cache will + be skipped, even if a result has already been + cached. Defaults to false. + ifrom -- Specifiy the sender's JID. + """ + data = {'feature': feature, + 'local': local, + 'cached': cached} + return self._run_node_handler('supports', jid, node, ifrom, data) + + def has_identity(self, jid=None, node=None, category=None, itype=None, + lang=None, local=False, cached=True, ifrom=None): + """ + Check if a JID provides a given identity. + + Return values: + True -- The identity is provided + False -- The identity is not listed + None -- Nothing could be found due to a timeout + + Arguments: + jid -- Request info from this JID. + node -- The particular node to query. + category -- The category of the identity to check. + itype -- The type of the identity to check. + lang -- The language of the identity to check. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the info. + cached -- If true, then look for the disco info data from + the local cache system. If no results are found, + send the query as usual. The self.use_cache + setting must be set to true for this option to + be useful. If set to false, then the cache will + be skipped, even if a result has already been + cached. Defaults to false. + ifrom -- Specifiy the sender's JID. + """ + data = {'category': category, + 'itype': itype, + 'lang': lang, + 'local': local, + 'cached': cached} + return self._run_node_handler('has_identity', jid, node, ifrom, data) + + def get_info(self, jid=None, node=None, local=False, + cached=None, **kwargs): + """ + Retrieve the disco#info results from a given JID/node combination. + + Info may be retrieved from both local resources and remote agents; + the local parameter indicates if the information should be gathered + by executing the local node handlers, or if a disco#info stanza + must be generated and sent. + + If requesting items from a local JID/node, then only a DiscoInfo + stanza will be returned. Otherwise, an Iq stanza will be returned. + + Arguments: + jid -- Request info from this JID. + node -- The particular node to query. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the info. + cached -- If true, then look for the disco info data from + the local cache system. If no results are found, + send the query as usual. The self.use_cache + setting must be set to true for this option to + be useful. If set to false, then the cache will + be skipped, even if a result has already been + cached. Defaults to false. + ifrom -- Specifiy the sender's JID. + block -- If true, block and wait for the stanzas' reply. + timeout -- The time in seconds to block while waiting for + a reply. If None, then wait indefinitely. The + timeout value is only used when block=True. + callback -- Optional callback to execute when a reply is + received instead of blocking and waiting for + the reply. + """ + if jid is not None and not isinstance(jid, JID): + jid = JID(jid) + if self.xmpp.is_component: + if jid.domain == self.xmpp.boundjid.domain: + local = True + else: + if str(jid) == str(self.xmpp.boundjid): + local = True + jid = jid.full + + if local or jid in (None, ''): + log.debug("Looking up local disco#info data " + \ + "for %s, node %s.", jid, node) + info = self._run_node_handler('get_info', + jid, node, kwargs.get('ifrom', None), kwargs) + info = self._fix_default_info(info) + return self._wrap(kwargs.get('ifrom', None), jid, info) + + if cached: + log.debug("Looking up cached disco#info data " + \ + "for %s, node %s.", jid, node) + info = self._run_node_handler('get_cached_info', + jid, node, kwargs.get('ifrom', None), kwargs) + if info is not None: + return self._wrap(kwargs.get('ifrom', None), jid, info) + + iq = self.xmpp.Iq() + # Check dfrom parameter for backwards compatibility + iq['from'] = kwargs.get('ifrom', kwargs.get('dfrom', '')) + iq['to'] = jid + iq['type'] = 'get' + iq['disco_info']['node'] = node if node else '' + return iq.send(timeout=kwargs.get('timeout', None), + block=kwargs.get('block', True), + callback=kwargs.get('callback', None)) + + def set_info(self, jid=None, node=None, info=None): + """ + Set the disco#info data for a JID/node based on an existing + disco#info stanza. + """ + if isinstance(info, Iq): + info = info['disco_info'] + self._run_node_handler('set_info', jid, node, None, info) + + def get_items(self, jid=None, node=None, local=False, **kwargs): + """ + Retrieve the disco#items results from a given JID/node combination. + + Items may be retrieved from both local resources and remote agents; + the local parameter indicates if the items should be gathered by + executing the local node handlers, or if a disco#items stanza must + be generated and sent. + + If requesting items from a local JID/node, then only a DiscoItems + stanza will be returned. Otherwise, an Iq stanza will be returned. + + Arguments: + jid -- Request info from this JID. + node -- The particular node to query. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the items. + ifrom -- Specifiy the sender's JID. + block -- If true, block and wait for the stanzas' reply. + timeout -- The time in seconds to block while waiting for + a reply. If None, then wait indefinitely. + callback -- Optional callback to execute when a reply is + received instead of blocking and waiting for + the reply. + iterator -- If True, return a result set iterator using + the XEP-0059 plugin, if the plugin is loaded. + Otherwise the parameter is ignored. + """ + if local or jid is None: + items = self._run_node_handler('get_items', + jid, node, kwargs.get('ifrom', None), kwargs) + return self._wrap(kwargs.get('ifrom', None), jid, items) + + iq = self.xmpp.Iq() + # Check dfrom parameter for backwards compatibility + iq['from'] = kwargs.get('ifrom', kwargs.get('dfrom', '')) + iq['to'] = jid + iq['type'] = 'get' + iq['disco_items']['node'] = node if node else '' + if kwargs.get('iterator', False) and self.xmpp['xep_0059']: + return self.xmpp['xep_0059'].iterate(iq, 'disco_items') + else: + return iq.send(timeout=kwargs.get('timeout', None), + block=kwargs.get('block', True), + callback=kwargs.get('callback', None)) + + def set_items(self, jid=None, node=None, **kwargs): + """ + Set or replace all items for the specified JID/node combination. + + The given items must be in a list or set where each item is a + tuple of the form: (jid, node, name). + + Arguments: + jid -- The JID to modify. + node -- Optional node to modify. + items -- A series of items in tuple format. + """ + self._run_node_handler('set_items', jid, node, None, kwargs) + + def del_items(self, jid=None, node=None, **kwargs): + """ + Remove all items from the given JID/node combination. + + Arguments: + jid -- The JID to modify. + node -- Optional node to modify. + """ + self._run_node_handler('del_items', jid, node, None, kwargs) + + def add_item(self, jid='', name='', node=None, subnode='', ijid=None): + """ + Add a new item element to the given JID/node combination. + + Each item is required to have a JID, but may also specify + a node value to reference non-addressable entities. + + Arguments: + jid -- The JID for the item. + name -- Optional name for the item. + node -- The node to modify. + subnode -- Optional node for the item. + ijid -- The JID to modify. + """ + if not jid: + jid = self.xmpp.boundjid.full + kwargs = {'ijid': jid, + 'name': name, + 'inode': subnode} + self._run_node_handler('add_item', ijid, node, None, kwargs) + + def del_item(self, jid=None, node=None, **kwargs): + """ + Remove a single item from the given JID/node combination. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + ijid -- The item's JID. + inode -- The item's node. + """ + self._run_node_handler('del_item', jid, node, None, kwargs) + + def add_identity(self, category='', itype='', name='', + node=None, jid=None, lang=None): + """ + Add a new identity to the given JID/node combination. + + Each identity must be unique in terms of all four identity + components: category, type, name, and language. + + Multiple, identical category/type pairs are allowed only + if the xml:lang values are different. Likewise, multiple + category/type/xml:lang pairs are allowed so long as the + names are different. A category and type is always required. + + Arguments: + category -- The identity's category. + itype -- The identity's type. + name -- Optional name for the identity. + lang -- Optional two-letter language code. + node -- The node to modify. + jid -- The JID to modify. + """ + kwargs = {'category': category, + 'itype': itype, + 'name': name, + 'lang': lang} + self._run_node_handler('add_identity', jid, node, None, kwargs) + + def add_feature(self, feature, node=None, jid=None): + """ + Add a feature to a JID/node combination. + + Arguments: + feature -- The namespace of the supported feature. + node -- The node to modify. + jid -- The JID to modify. + """ + kwargs = {'feature': feature} + self._run_node_handler('add_feature', jid, node, None, kwargs) + + def del_identity(self, jid=None, node=None, **kwargs): + """ + Remove an identity from the given JID/node combination. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + category -- The identity's category. + itype -- The identity's type value. + name -- Optional, human readable name for the identity. + lang -- Optional, the identity's xml:lang value. + """ + self._run_node_handler('del_identity', jid, node, None, kwargs) + + def del_feature(self, jid=None, node=None, **kwargs): + """ + Remove a feature from a given JID/node combination. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + feature -- The feature's namespace. + """ + self._run_node_handler('del_feature', jid, node, None, kwargs) + + def set_identities(self, jid=None, node=None, **kwargs): + """ + Add or replace all identities for the given JID/node combination. + + The identities must be in a set where each identity is a tuple + of the form: (category, type, lang, name) + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + identities -- A set of identities in tuple form. + lang -- Optional, xml:lang value. + """ + self._run_node_handler('set_identities', jid, node, None, kwargs) + + def del_identities(self, jid=None, node=None, **kwargs): + """ + Remove all identities for a JID/node combination. + + If a language is specified, only identities using that + language will be removed. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + lang -- Optional. If given, only remove identities + using this xml:lang value. + """ + self._run_node_handler('del_identities', jid, node, None, kwargs) + + def set_features(self, jid=None, node=None, **kwargs): + """ + Add or replace the set of supported features + for a JID/node combination. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + features -- The new set of supported features. + """ + self._run_node_handler('set_features', jid, node, None, kwargs) + + def del_features(self, jid=None, node=None, **kwargs): + """ + Remove all features from a JID/node combination. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + """ + self._run_node_handler('del_features', jid, node, None, kwargs) + + def _run_node_handler(self, htype, jid, node=None, ifrom=None, data={}): + """ + Execute the most specific node handler for the given + JID/node combination. + + Arguments: + htype -- The handler type to execute. + jid -- The JID requested. + node -- The node requested. + data -- Optional, custom data to pass to the handler. + """ + if isinstance(jid, JID): + jid = jid.full + + if jid in (None, ''): + if self.xmpp.is_component: + jid = self.xmpp.boundjid.full + else: + jid = self.xmpp.boundjid.bare + if node is None: + node = '' + + try: + args = (jid, node, ifrom, data) + if self._handlers[htype]['node'].get((jid, node), False): + return self._handlers[htype]['node'][(jid, node)](*args) + elif self._handlers[htype]['jid'].get(jid, False): + return self._handlers[htype]['jid'][jid](*args) + elif self._handlers[htype]['global']: + return self._handlers[htype]['global'](*args) + else: + return None + except TypeError: + # To preserve backward compatibility, drop the ifrom parameter + # for existing handlers that don't understand it. + args = (jid, node, data) + if self._handlers[htype]['node'].get((jid, node), False): + return self._handlers[htype]['node'][(jid, node)](*args) + elif self._handlers[htype]['jid'].get(jid, False): + return self._handlers[htype]['jid'][jid](*args) + elif self._handlers[htype]['global']: + return self._handlers[htype]['global'](*args) + else: + return None + + def _handle_disco_info(self, iq): + """ + Process an incoming disco#info stanza. If it is a get + request, find and return the appropriate identities + and features. If it is an info result, fire the + disco_info event. + + Arguments: + iq -- The incoming disco#items stanza. + """ + if iq['type'] == 'get': + log.debug("Received disco info query from " + \ + "<%s> to <%s>.", iq['from'], iq['to']) + if self.xmpp.is_component: + jid = iq['to'].full + else: + jid = iq['to'].bare + info = self._run_node_handler('get_info', + jid, + iq['disco_info']['node'], + iq['from'], + iq) + if isinstance(info, Iq): + info.send() + else: + iq.reply() + if info: + info = self._fix_default_info(info) + iq.set_payload(info.xml) + iq.send() + elif iq['type'] == 'result': + log.debug("Received disco info result from " + \ + "<%s> to <%s>.", iq['from'], iq['to']) + if self.use_cache: + log.debug("Caching disco info result from " \ + "<%s> to <%s>.", iq['from'], iq['to']) + if self.xmpp.is_component: + ito = iq['to'].full + else: + ito = None + self._run_node_handler('cache_info', + iq['from'].full, + iq['disco_info']['node'], + ito, + iq) + self.xmpp.event('disco_info', iq) + + def _handle_disco_items(self, iq): + """ + Process an incoming disco#items stanza. If it is a get + request, find and return the appropriate items. If it + is an items result, fire the disco_items event. + + Arguments: + iq -- The incoming disco#items stanza. + """ + if iq['type'] == 'get': + log.debug("Received disco items query from " + \ + "<%s> to <%s>.", iq['from'], iq['to']) + if self.xmpp.is_component: + jid = iq['to'].full + else: + jid = iq['to'].bare + items = self._run_node_handler('get_items', + jid, + iq['disco_items']['node'], + iq['from'].full, + iq) + if isinstance(items, Iq): + items.send() + else: + iq.reply() + if items: + iq.set_payload(items.xml) + iq.send() + elif iq['type'] == 'result': + log.debug("Received disco items result from " + \ + "%s to %s.", iq['from'], iq['to']) + self.xmpp.event('disco_items', iq) + + def _fix_default_info(self, info): + """ + Disco#info results for a JID are required to include at least + one identity and feature. As a default, if no other identity is + provided, SleekXMPP will use either the generic component or the + bot client identity. A the standard disco#info feature will also be + added if no features are provided. + + Arguments: + info -- The disco#info quest (not the full Iq stanza) to modify. + """ + result = info + if isinstance(info, Iq): + info = iq['disco_info'] + if not info['node']: + if not info['identities']: + if self.xmpp.is_component: + log.debug("No identity found for this entity. " + \ + "Using default component identity.") + info.add_identity('component', 'generic') + else: + log.debug("No identity found for this entity. " + \ + "Using default client identity.") + info.add_identity('client', 'bot') + if not info['features']: + log.debug("No features found for this entity. " + \ + "Using default disco#info feature.") + info.add_feature(info.namespace) + return result + + def _wrap(self, ito, ifrom, payload, force=False): + """ + Ensure that results are wrapped in an Iq stanza + if self.wrap_results has been set to True. + + Arguments: + ito -- The JID to use as the 'to' value + ifrom -- The JID to use as the 'from' value + payload -- The disco data to wrap + force -- Force wrapping, regardless of self.wrap_results + """ + if (force or self.wrap_results) and not isinstance(payload, Iq): + iq = self.xmpp.Iq() + # Since we're simulating a result, we have to treat + # the 'from' and 'to' values opposite the normal way. + iq['to'] = self.xmpp.boundjid if ito is None else ito + iq['from'] = self.xmpp.boundjid if ifrom is None else ifrom + iq['type'] = 'result' + iq.append(payload) + return iq + return payload + + +# Retain some backwards compatibility +xep_0030.getInfo = xep_0030.get_info +xep_0030.getItems = xep_0030.get_items +xep_0030.make_static = xep_0030.restore_defaults diff --git a/sleekxmpp/plugins/xep_0030/stanza/__init__.py b/sleekxmpp/plugins/xep_0030/stanza/__init__.py new file mode 100644 index 00000000..0d97cf3d --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/stanza/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0030.stanza.info import DiscoInfo +from sleekxmpp.plugins.xep_0030.stanza.items import DiscoItems diff --git a/sleekxmpp/plugins/xep_0030/stanza/info.py b/sleekxmpp/plugins/xep_0030/stanza/info.py new file mode 100644 index 00000000..25d1d07f --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/stanza/info.py @@ -0,0 +1,276 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class DiscoInfo(ElementBase): + + """ + XMPP allows for users and agents to find the identities and features + supported by other entities in the XMPP network through service discovery, + or "disco". In particular, the "disco#info" query type for <iq> stanzas is + used to request the list of identities and features offered by a JID. + + An identity is a combination of a category and type, such as the 'client' + category with a type of 'pc' to indicate the agent is a human operated + client with a GUI, or a category of 'gateway' with a type of 'aim' to + identify the agent as a gateway for the legacy AIM protocol. See + <http://xmpp.org/registrar/disco-categories.html> for a full list of + accepted category and type combinations. + + Features are simply a set of the namespaces that identify the supported + features. For example, a client that supports service discovery will + include the feature 'http://jabber.org/protocol/disco#info'. + + Since clients and components may operate in several roles at once, identity + and feature information may be grouped into "nodes". If one were to write + all of the identities and features used by a client, then node names would + be like section headings. + + Example disco#info stanzas: + <iq type="get"> + <query xmlns="http://jabber.org/protocol/disco#info" /> + </iq> + + <iq type="result"> + <query xmlns="http://jabber.org/protocol/disco#info"> + <identity category="client" type="bot" name="SleekXMPP Bot" /> + <feature var="http://jabber.org/protocol/disco#info" /> + <feature var="jabber:x:data" /> + <feature var="urn:xmpp:ping" /> + </query> + </iq> + + Stanza Interface: + node -- The name of the node to either + query or return info from. + identities -- A set of 4-tuples, where each tuple contains + the category, type, xml:lang, and name + of an identity. + features -- A set of namespaces for features. + + Methods: + add_identity -- Add a new, single identity. + del_identity -- Remove a single identity. + get_identities -- Return all identities in tuple form. + set_identities -- Use multiple identities, each given in tuple form. + del_identities -- Remove all identities. + add_feature -- Add a single feature. + del_feature -- Remove a single feature. + get_features -- Return a list of all features. + set_features -- Use a given list of features. + del_features -- Remove all features. + """ + + name = 'query' + namespace = 'http://jabber.org/protocol/disco#info' + plugin_attrib = 'disco_info' + interfaces = set(('node', 'features', 'identities')) + lang_interfaces = set(('identities',)) + + # Cache identities and features + _identities = set() + _features = set() + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides ElementBase.setup + + Caches identity and feature information. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + ElementBase.setup(self, xml) + + self._identities = set([id[0:3] for id in self['identities']]) + self._features = self['features'] + + def add_identity(self, category, itype, name=None, lang=None): + """ + Add a new identity element. Each identity must be unique + in terms of all four identity components. + + Multiple, identical category/type pairs are allowed only + if the xml:lang values are different. Likewise, multiple + category/type/xml:lang pairs are allowed so long as the names + are different. In any case, a category and type are required. + + Arguments: + category -- The general category to which the agent belongs. + itype -- A more specific designation with the category. + name -- Optional human readable name for this identity. + lang -- Optional standard xml:lang value. + """ + identity = (category, itype, lang) + if identity not in self._identities: + self._identities.add(identity) + id_xml = ET.Element('{%s}identity' % self.namespace) + id_xml.attrib['category'] = category + id_xml.attrib['type'] = itype + if lang: + id_xml.attrib['{%s}lang' % self.xml_ns] = lang + if name: + id_xml.attrib['name'] = name + self.xml.append(id_xml) + return True + return False + + def del_identity(self, category, itype, name=None, lang=None): + """ + Remove a given identity. + + Arguments: + category -- The general category to which the agent belonged. + itype -- A more specific designation with the category. + name -- Optional human readable name for this identity. + lang -- Optional, standard xml:lang value. + """ + identity = (category, itype, lang) + if identity in self._identities: + self._identities.remove(identity) + for id_xml in self.findall('{%s}identity' % self.namespace): + id = (id_xml.attrib['category'], + id_xml.attrib['type'], + id_xml.attrib.get('{%s}lang' % self.xml_ns, None)) + if id == identity: + self.xml.remove(id_xml) + return True + return False + + def get_identities(self, lang=None, dedupe=True): + """ + Return a set of all identities in tuple form as so: + (category, type, lang, name) + + If a language was specified, only return identities using + that language. + + Arguments: + lang -- Optional, standard xml:lang value. + dedupe -- If True, de-duplicate identities, otherwise + return a list of all identities. + """ + if dedupe: + identities = set() + else: + identities = [] + for id_xml in self.findall('{%s}identity' % self.namespace): + xml_lang = id_xml.attrib.get('{%s}lang' % self.xml_ns, None) + if lang is None or xml_lang == lang: + id = (id_xml.attrib['category'], + id_xml.attrib['type'], + id_xml.attrib.get('{%s}lang' % self.xml_ns, None), + id_xml.attrib.get('name', None)) + if dedupe: + identities.add(id) + else: + identities.append(id) + return identities + + def set_identities(self, identities, lang=None): + """ + Add or replace all identities. The identities must be a in set + where each identity is a tuple of the form: + (category, type, lang, name) + + If a language is specifified, any identities using that language + will be removed to be replaced with the given identities. + + NOTE: An identity's language will not be changed regardless of + the value of lang. + + Arguments: + identities -- A set of identities in tuple form. + lang -- Optional, standard xml:lang value. + """ + self.del_identities(lang) + for identity in identities: + category, itype, lang, name = identity + self.add_identity(category, itype, name, lang) + + def del_identities(self, lang=None): + """ + Remove all identities. If a language was specified, only + remove identities using that language. + + Arguments: + lang -- Optional, standard xml:lang value. + """ + for id_xml in self.findall('{%s}identity' % self.namespace): + if lang is None: + self.xml.remove(id_xml) + elif id_xml.attrib.get('{%s}lang' % self.xml_ns, None) == lang: + self._identities.remove(( + id_xml.attrib['category'], + id_xml.attrib['type'], + id_xml.attrib.get('{%s}lang' % self.xml_ns, None))) + self.xml.remove(id_xml) + + def add_feature(self, feature): + """ + Add a single, new feature. + + Arguments: + feature -- The namespace of the supported feature. + """ + if feature not in self._features: + self._features.add(feature) + feature_xml = ET.Element('{%s}feature' % self.namespace) + feature_xml.attrib['var'] = feature + self.xml.append(feature_xml) + return True + return False + + def del_feature(self, feature): + """ + Remove a single feature. + + Arguments: + feature -- The namespace of the removed feature. + """ + if feature in self._features: + self._features.remove(feature) + for feature_xml in self.findall('{%s}feature' % self.namespace): + if feature_xml.attrib['var'] == feature: + self.xml.remove(feature_xml) + return True + return False + + def get_features(self, dedupe=True): + """Return the set of all supported features.""" + if dedupe: + features = set() + else: + features = [] + for feature_xml in self.findall('{%s}feature' % self.namespace): + if dedupe: + features.add(feature_xml.attrib['var']) + else: + features.append(feature_xml.attrib['var']) + return features + + def set_features(self, features): + """ + Add or replace the set of supported features. + + Arguments: + features -- The new set of supported features. + """ + self.del_features() + for feature in features: + self.add_feature(feature) + + def del_features(self): + """Remove all features.""" + self._features = set() + for feature_xml in self.findall('{%s}feature' % self.namespace): + self.xml.remove(feature_xml) diff --git a/sleekxmpp/plugins/xep_0030/stanza/items.py b/sleekxmpp/plugins/xep_0030/stanza/items.py new file mode 100644 index 00000000..a1fb819c --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/stanza/items.py @@ -0,0 +1,136 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class DiscoItems(ElementBase): + + """ + Example disco#items stanzas: + <iq type="get"> + <query xmlns="http://jabber.org/protocol/disco#items" /> + </iq> + + <iq type="result"> + <query xmlns="http://jabber.org/protocol/disco#items"> + <item jid="chat.example.com" + node="xmppdev" + name="XMPP Dev" /> + <item jid="chat.example.com" + node="sleekdev" + name="SleekXMPP Dev" /> + </query> + </iq> + + Stanza Interface: + node -- The name of the node to either + query or return info from. + items -- A list of 3-tuples, where each tuple contains + the JID, node, and name of an item. + + Methods: + add_item -- Add a single new item. + del_item -- Remove a single item. + get_items -- Return all items. + set_items -- Set or replace all items. + del_items -- Remove all items. + """ + + name = 'query' + namespace = 'http://jabber.org/protocol/disco#items' + plugin_attrib = 'disco_items' + interfaces = set(('node', 'items')) + + # Cache items + _items = set() + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides ElementBase.setup + + Caches item information. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + ElementBase.setup(self, xml) + self._items = set([item[0:2] for item in self['items']]) + + def add_item(self, jid, node=None, name=None): + """ + Add a new item element. Each item is required to have a + JID, but may also specify a node value to reference + non-addressable entitities. + + Arguments: + jid -- The JID for the item. + node -- Optional additional information to reference + non-addressable items. + name -- Optional human readable name for the item. + """ + if (jid, node) not in self._items: + self._items.add((jid, node)) + item_xml = ET.Element('{%s}item' % self.namespace) + item_xml.attrib['jid'] = jid + if name: + item_xml.attrib['name'] = name + if node: + item_xml.attrib['node'] = node + self.xml.append(item_xml) + return True + return False + + def del_item(self, jid, node=None): + """ + Remove a single item. + + Arguments: + jid -- JID of the item to remove. + node -- Optional extra identifying information. + """ + if (jid, node) in self._items: + for item_xml in self.findall('{%s}item' % self.namespace): + item = (item_xml.attrib['jid'], + item_xml.attrib.get('node', None)) + if item == (jid, node): + self.xml.remove(item_xml) + return True + return False + + def get_items(self): + """Return all items.""" + items = set() + for item_xml in self.findall('{%s}item' % self.namespace): + item = (item_xml.attrib['jid'], + item_xml.attrib.get('node'), + item_xml.attrib.get('name')) + items.add(item) + return items + + def set_items(self, items): + """ + Set or replace all items. The given items must be in a + list or set where each item is a tuple of the form: + (jid, node, name) + + Arguments: + items -- A series of items in tuple format. + """ + self.del_items() + for item in items: + jid, node, name = item + self.add_item(jid, node, name) + + def del_items(self): + """Remove all items.""" + self._items = set() + for item_xml in self.findall('{%s}item' % self.namespace): + self.xml.remove(item_xml) diff --git a/sleekxmpp/plugins/xep_0030/static.py b/sleekxmpp/plugins/xep_0030/static.py new file mode 100644 index 00000000..e0ac29c6 --- /dev/null +++ b/sleekxmpp/plugins/xep_0030/static.py @@ -0,0 +1,441 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import threading + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.exceptions import XMPPError +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from sleekxmpp.plugins.xep_0030 import DiscoInfo, DiscoItems + + +log = logging.getLogger(__name__) + + +class StaticDisco(object): + + """ + While components will likely require fully dynamic handling + of service discovery information, most clients and simple bots + only need to manage a few disco nodes that will remain mostly + static. + + StaticDisco provides a set of node handlers that will store + static sets of disco info and items in memory. + + Attributes: + nodes -- A dictionary mapping (JID, node) tuples to a dict + containing a disco#info and a disco#items stanza. + xmpp -- The main SleekXMPP object. + """ + + def __init__(self, xmpp, disco): + """ + Create a static disco interface. Sets of disco#info and + disco#items are maintained for every given JID and node + combination. These stanzas are used to store disco + information in memory without any additional processing. + + Arguments: + xmpp -- The main SleekXMPP object. + """ + self.nodes = {} + self.xmpp = xmpp + self.disco = disco + self.lock = threading.RLock() + + def add_node(self, jid=None, node=None, ifrom=None): + """ + Create a new set of stanzas for the provided + JID and node combination. + + Arguments: + jid -- The JID that will own the new stanzas. + node -- The node that will own the new stanzas. + """ + with self.lock: + if jid is None: + jid = self.xmpp.boundjid.full + if node is None: + node = '' + if ifrom is None: + ifrom = '' + if isinstance(ifrom, JID): + ifrom = ifrom.full + if (jid, node, ifrom) not in self.nodes: + self.nodes[(jid, node, ifrom)] = {'info': DiscoInfo(), + 'items': DiscoItems()} + self.nodes[(jid, node, ifrom)]['info']['node'] = node + self.nodes[(jid, node, ifrom)]['items']['node'] = node + + def get_node(self, jid=None, node=None, ifrom=None): + with self.lock: + if jid is None: + jid = self.xmpp.boundjid.full + if node is None: + node = '' + if ifrom is None: + ifrom = '' + if isinstance(ifrom, JID): + ifrom = ifrom.full + if (jid, node, ifrom) not in self.nodes: + self.add_node(jid, node, ifrom) + return self.nodes[(jid, node, ifrom)] + + def node_exists(self, jid=None, node=None, ifrom=None): + with self.lock: + if jid is None: + jid = self.xmpp.boundjid.full + if node is None: + node = '' + if ifrom is None: + ifrom = '' + if isinstance(ifrom, JID): + ifrom = ifrom.full + if (jid, node, ifrom) not in self.nodes: + return False + return True + + # ================================================================= + # Node Handlers + # + # Each handler accepts four arguments: jid, node, ifrom, and data. + # The jid and node parameters together determine the set of info + # and items stanzas that will be retrieved or added. Additionally, + # the ifrom value allows for cached results when results vary based + # on the requester's JID. The data parameter is a dictionary with + # additional parameters that will be passed to other calls. + # + # This implementation does not allow different responses based on + # the requester's JID, except for cached results. To do that, + # register a custom node handler. + + def supports(self, jid, node, ifrom, data): + """ + Check if a JID supports a given feature. + + The data parameter may provide: + feature -- The feature to check for support. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the info. + cached -- If true, then look for the disco info data from + the local cache system. If no results are found, + send the query as usual. The self.use_cache + setting must be set to true for this option to + be useful. If set to false, then the cache will + be skipped, even if a result has already been + cached. Defaults to false. + """ + feature = data.get('feature', None) + + data = {'local': data.get('local', False), + 'cached': data.get('cached', True)} + + if not feature: + return False + + try: + info = self.disco.get_info(jid=jid, node=node, + ifrom=ifrom, **data) + info = self.disco._wrap(ifrom, jid, info, True) + features = info['disco_info']['features'] + return feature in features + except IqError: + return False + except IqTimeout: + return None + + def has_identity(self, jid, node, ifrom, data): + """ + Check if a JID has a given identity. + + The data parameter may provide: + category -- The category of the identity to check. + itype -- The type of the identity to check. + lang -- The language of the identity to check. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the info. + cached -- If true, then look for the disco info data from + the local cache system. If no results are found, + send the query as usual. The self.use_cache + setting must be set to true for this option to + be useful. If set to false, then the cache will + be skipped, even if a result has already been + cached. Defaults to false. + """ + identity = (data.get('category', None), + data.get('itype', None), + data.get('lang', None)) + + data = {'local': data.get('local', False), + 'cached': data.get('cached', True)} + + if node in (None, ''): + info = self.caps.get_caps(jid) + if info and identity in info['identities']: + return True + + try: + info = self.disco.get_info(jid=jid, node=node, + ifrom=ifrom, **data) + info = self.disco._wrap(ifrom, jid, info, True) + trunc = lambda i: (i[0], i[1], i[2]) + return identity in map(trunc, info['disco_info']['identities']) + except IqError: + return False + except IqTimeout: + return None + + + def get_info(self, jid, node, ifrom, data): + """ + Return the stored info data for the requested JID/node combination. + + The data parameter is not used. + """ + with self.lock: + if not self.node_exists(jid, node): + if not node: + return DiscoInfo() + else: + raise XMPPError(condition='item-not-found') + else: + return self.get_node(jid, node)['info'] + + def set_info(self, jid, node, ifrom, data): + """ + Set the entire info stanza for a JID/node at once. + + The data parameter is a disco#info substanza. + """ + with self.lock: + self.add_node(jid, node) + self.get_node(jid, node)['info'] = data + + def del_info(self, jid, node, ifrom, data): + """ + Reset the info stanza for a given JID/node combination. + + The data parameter is not used. + """ + with self.lock: + if self.node_exists(jid, node): + self.get_node(jid, node)['info'] = DiscoInfo() + + def get_items(self, jid, node, ifrom, data): + """ + Return the stored items data for the requested JID/node combination. + + The data parameter is not used. + """ + with self.lock: + if not self.node_exists(jid, node): + if not node: + return DiscoInfo() + else: + raise XMPPError(condition='item-not-found') + else: + return self.get_node(jid, node)['items'] + + def set_items(self, jid, node, ifrom, data): + """ + Replace the stored items data for a JID/node combination. + + The data parameter may provide: + items -- A set of items in tuple format. + """ + with self.lock: + items = data.get('items', set()) + self.add_node(jid, node) + self.get_node(jid, node)['items']['items'] = items + + def del_items(self, jid, node, ifrom, data): + """ + Reset the items stanza for a given JID/node combination. + + The data parameter is not used. + """ + with self.lock: + if self.node_exists(jid, node): + self.get_node(jid, node)['items'] = DiscoItems() + + def add_identity(self, jid, node, ifrom, data): + """ + Add a new identity to te JID/node combination. + + The data parameter may provide: + category -- The general category to which the agent belongs. + itype -- A more specific designation with the category. + name -- Optional human readable name for this identity. + lang -- Optional standard xml:lang value. + """ + with self.lock: + self.add_node(jid, node) + self.get_node(jid, node)['info'].add_identity( + data.get('category', ''), + data.get('itype', ''), + data.get('name', None), + data.get('lang', None)) + + def set_identities(self, jid, node, ifrom, data): + """ + Add or replace all identities for a JID/node combination. + + The data parameter should include: + identities -- A list of identities in tuple form: + (category, type, name, lang) + """ + with self.lock: + identities = data.get('identities', set()) + self.add_node(jid, node) + self.get_node(jid, node)['info']['identities'] = identities + + def del_identity(self, jid, node, ifrom, data): + """ + Remove an identity from a JID/node combination. + + The data parameter may provide: + category -- The general category to which the agent belonged. + itype -- A more specific designation with the category. + name -- Optional human readable name for this identity. + lang -- Optional, standard xml:lang value. + """ + with self.lock: + if self.node_exists(jid, node): + self.get_node(jid, node)['info'].del_identity( + data.get('category', ''), + data.get('itype', ''), + data.get('name', None), + data.get('lang', None)) + + def del_identities(self, jid, node, ifrom, data): + """ + Remove all identities from a JID/node combination. + + The data parameter is not used. + """ + with self.lock: + if self.node_exists(jid, node): + del self.get_node(jid, node)['info']['identities'] + + def add_feature(self, jid, node, ifrom, data): + """ + Add a feature to a JID/node combination. + + The data parameter should include: + feature -- The namespace of the supported feature. + """ + with self.lock: + self.add_node(jid, node) + self.get_node(jid, node)['info'].add_feature(data.get('feature', '')) + + def set_features(self, jid, node, ifrom, data): + """ + Add or replace all features for a JID/node combination. + + The data parameter should include: + features -- The new set of supported features. + """ + with self.lock: + features = data.get('features', set()) + self.add_node(jid, node) + self.get_node(jid, node)['info']['features'] = features + + def del_feature(self, jid, node, ifrom, data): + """ + Remove a feature from a JID/node combination. + + The data parameter should include: + feature -- The namespace of the removed feature. + """ + with self.lock: + if self.node_exists(jid, node): + self.get_node(jid, node)['info'].del_feature(data.get('feature', '')) + + def del_features(self, jid, node, ifrom, data): + """ + Remove all features from a JID/node combination. + + The data parameter is not used. + """ + with self.lock: + if not self.node_exists(jid, node): + return + del self.get_node(jid, node)['info']['features'] + + def add_item(self, jid, node, ifrom, data): + """ + Add an item to a JID/node combination. + + The data parameter may include: + ijid -- The JID for the item. + inode -- Optional additional information to reference + non-addressable items. + name -- Optional human readable name for the item. + """ + with self.lock: + self.add_node(jid, node) + self.get_node(jid, node)['items'].add_item( + data.get('ijid', ''), + node=data.get('inode', ''), + name=data.get('name', '')) + + def del_item(self, jid, node, ifrom, data): + """ + Remove an item from a JID/node combination. + + The data parameter may include: + ijid -- JID of the item to remove. + inode -- Optional extra identifying information. + """ + with self.lock: + if self.node_exists(jid, node): + self.get_node(jid, node)['items'].del_item( + data.get('ijid', ''), + node=data.get('inode', None)) + + def cache_info(self, jid, node, ifrom, data): + """ + Cache disco information for an external JID. + + The data parameter is the Iq result stanza + containing the disco info to cache, or + the disco#info substanza itself. + """ + with self.lock: + if isinstance(data, Iq): + data = data['disco_info'] + + self.add_node(jid, node, ifrom) + self.get_node(jid, node, ifrom)['info'] = data + + def get_cached_info(self, jid, node, ifrom, data): + """ + Retrieve cached disco info data. + + The data parameter is not used. + """ + with self.lock: + if isinstance(jid, JID): + jid = jid.full + + if not self.node_exists(jid, node, ifrom): + return None + else: + return self.get_node(jid, node, ifrom)['info'] diff --git a/sleekxmpp/plugins/xep_0033.py b/sleekxmpp/plugins/xep_0033.py new file mode 100644 index 00000000..c0c4d89d --- /dev/null +++ b/sleekxmpp/plugins/xep_0033.py @@ -0,0 +1,161 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +from . import base +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.message import Message + + +class Addresses(ElementBase): + namespace = 'http://jabber.org/protocol/address' + name = 'addresses' + plugin_attrib = 'addresses' + interfaces = set(('addresses', 'bcc', 'cc', 'noreply', 'replyroom', 'replyto', 'to')) + + def addAddress(self, atype='to', jid='', node='', uri='', desc='', delivered=False): + address = Address(parent=self) + address['type'] = atype + address['jid'] = jid + address['node'] = node + address['uri'] = uri + address['desc'] = desc + address['delivered'] = delivered + return address + + def getAddresses(self, atype=None): + addresses = [] + for addrXML in self.xml.findall('{%s}address' % Address.namespace): + # ElementTree 1.2.6 does not support [@attr='value'] in findall + if atype is None or addrXML.attrib.get('type') == atype: + addresses.append(Address(xml=addrXML, parent=None)) + return addresses + + def setAddresses(self, addresses, set_type=None): + self.delAddresses(set_type) + for addr in addresses: + addr = dict(addr) + # Remap 'type' to 'atype' to match the add method + if set_type is not None: + addr['type'] = set_type + curr_type = addr.get('type', None) + if curr_type is not None: + del addr['type'] + addr['atype'] = curr_type + self.addAddress(**addr) + + def delAddresses(self, atype=None): + if atype is None: + return + for addrXML in self.xml.findall('{%s}address' % Address.namespace): + # ElementTree 1.2.6 does not support [@attr='value'] in findall + if addrXML.attrib.get('type') == atype: + self.xml.remove(addrXML) + + # -------------------------------------------------------------- + + def delBcc(self): + self.delAddresses('bcc') + + def delCc(self): + self.delAddresses('cc') + + def delNoreply(self): + self.delAddresses('noreply') + + def delReplyroom(self): + self.delAddresses('replyroom') + + def delReplyto(self): + self.delAddresses('replyto') + + def delTo(self): + self.delAddresses('to') + + # -------------------------------------------------------------- + + def getBcc(self): + return self.getAddresses('bcc') + + def getCc(self): + return self.getAddresses('cc') + + def getNoreply(self): + return self.getAddresses('noreply') + + def getReplyroom(self): + return self.getAddresses('replyroom') + + def getReplyto(self): + return self.getAddresses('replyto') + + def getTo(self): + return self.getAddresses('to') + + # -------------------------------------------------------------- + + def setBcc(self, addresses): + self.setAddresses(addresses, 'bcc') + + def setCc(self, addresses): + self.setAddresses(addresses, 'cc') + + def setNoreply(self, addresses): + self.setAddresses(addresses, 'noreply') + + def setReplyroom(self, addresses): + self.setAddresses(addresses, 'replyroom') + + def setReplyto(self, addresses): + self.setAddresses(addresses, 'replyto') + + def setTo(self, addresses): + self.setAddresses(addresses, 'to') + + +class Address(ElementBase): + namespace = 'http://jabber.org/protocol/address' + name = 'address' + plugin_attrib = 'address' + interfaces = set(('delivered', 'desc', 'jid', 'node', 'type', 'uri')) + address_types = set(('bcc', 'cc', 'noreply', 'replyroom', 'replyto', 'to')) + + def getDelivered(self): + return self.xml.attrib.get('delivered', False) + + def setDelivered(self, delivered): + if delivered: + self.xml.attrib['delivered'] = "true" + else: + del self['delivered'] + + def setUri(self, uri): + if uri: + del self['jid'] + del self['node'] + self.xml.attrib['uri'] = uri + elif 'uri' in self.xml.attrib: + del self.xml.attrib['uri'] + + +class xep_0033(base.base_plugin): + """ + XEP-0033: Extended Stanza Addressing + """ + + def plugin_init(self): + self.xep = '0033' + self.description = 'Extended Stanza Addressing' + + registerStanzaPlugin(Message, Addresses) + + def post_init(self): + base.base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature(Addresses.namespace) diff --git a/sleekxmpp/plugins/xep_0045.py b/sleekxmpp/plugins/xep_0045.py new file mode 100644 index 00000000..ab3f750a --- /dev/null +++ b/sleekxmpp/plugins/xep_0045.py @@ -0,0 +1,376 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from __future__ import with_statement +from . import base +import logging +from xml.etree import cElementTree as ET +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, JID +from .. stanza.presence import Presence +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.matcher.xmlmask import MatchXMLMask +from sleekxmpp.exceptions import IqError, IqTimeout + + +log = logging.getLogger(__name__) + + +class MUCPresence(ElementBase): + name = 'x' + namespace = 'http://jabber.org/protocol/muc#user' + plugin_attrib = 'muc' + interfaces = set(('affiliation', 'role', 'jid', 'nick', 'room')) + affiliations = set(('', )) + roles = set(('', )) + + def getXMLItem(self): + item = self.xml.find('{http://jabber.org/protocol/muc#user}item') + if item is None: + item = ET.Element('{http://jabber.org/protocol/muc#user}item') + self.xml.append(item) + return item + + def getAffiliation(self): + #TODO if no affilation, set it to the default and return default + item = self.getXMLItem() + return item.get('affiliation', '') + + def setAffiliation(self, value): + item = self.getXMLItem() + #TODO check for valid affiliation + item.attrib['affiliation'] = value + return self + + def delAffiliation(self): + item = self.getXMLItem() + #TODO set default affiliation + if 'affiliation' in item.attrib: del item.attrib['affiliation'] + return self + + def getJid(self): + item = self.getXMLItem() + return JID(item.get('jid', '')) + + def setJid(self, value): + item = self.getXMLItem() + if not isinstance(value, str): + value = str(value) + item.attrib['jid'] = value + return self + + def delJid(self): + item = self.getXMLItem() + if 'jid' in item.attrib: del item.attrib['jid'] + return self + + def getRole(self): + item = self.getXMLItem() + #TODO get default role, set default role if none + return item.get('role', '') + + def setRole(self, value): + item = self.getXMLItem() + #TODO check for valid role + item.attrib['role'] = value + return self + + def delRole(self): + item = self.getXMLItem() + #TODO set default role + if 'role' in item.attrib: del item.attrib['role'] + return self + + def getNick(self): + return self.parent()['from'].resource + + def getRoom(self): + return self.parent()['from'].bare + + def setNick(self, value): + log.warning("Cannot set nick through mucpresence plugin.") + return self + + def setRoom(self, value): + log.warning("Cannot set room through mucpresence plugin.") + return self + + def delNick(self): + log.warning("Cannot delete nick through mucpresence plugin.") + return self + + def delRoom(self): + log.warning("Cannot delete room through mucpresence plugin.") + return self + +class xep_0045(base.base_plugin): + """ + Implements XEP-0045 Multi User Chat + """ + + def plugin_init(self): + self.rooms = {} + self.ourNicks = {} + self.xep = '0045' + self.description = 'Multi User Chat' + # load MUC support in presence stanzas + registerStanzaPlugin(Presence, MUCPresence) + self.xmpp.registerHandler(Callback('MUCPresence', MatchXMLMask("<presence xmlns='%s' />" % self.xmpp.default_ns), self.handle_groupchat_presence)) + self.xmpp.registerHandler(Callback('MUCMessage', MatchXMLMask("<message xmlns='%s' type='groupchat'><body/></message>" % self.xmpp.default_ns), self.handle_groupchat_message)) + self.xmpp.registerHandler(Callback('MUCSubject', MatchXMLMask("<message xmlns='%s' type='groupchat'><subject/></message>" % self.xmpp.default_ns), self.handle_groupchat_subject)) + self.xmpp.registerHandler(Callback('MUCInvite', MatchXPath("{%s}message/{http://jabber.org/protocol/muc#user}x/invite" % self.xmpp.default_ns), self.handle_groupchat_invite)) + + def handle_groupchat_invite(self, inv): + """ Handle an invite into a muc. + """ + logging.debug("MUC invite to %s from %s: %s", inv['from'], inv["from"], inv) + if inv['from'] not in self.rooms.keys(): + self.xmpp.event("groupchat_invite", inv) + + def handle_groupchat_presence(self, pr): + """ Handle a presence in a muc. + """ + got_offline = False + got_online = False + if pr['muc']['room'] not in self.rooms.keys(): + return + entry = pr['muc'].getStanzaValues() + entry['show'] = pr['show'] + entry['status'] = pr['status'] + if pr['type'] == 'unavailable': + if entry['nick'] in self.rooms[entry['room']]: + del self.rooms[entry['room']][entry['nick']] + got_offline = True + else: + if entry['nick'] not in self.rooms[entry['room']]: + got_online = True + self.rooms[entry['room']][entry['nick']] = entry + log.debug("MUC presence from %s/%s : %s", entry['room'],entry['nick'], entry) + self.xmpp.event("groupchat_presence", pr) + self.xmpp.event("muc::%s::presence" % entry['room'], pr) + if got_offline: + self.xmpp.event("muc::%s::got_offline" % entry['room'], pr) + if got_online: + self.xmpp.event("muc::%s::got_online" % entry['room'], pr) + + def handle_groupchat_message(self, msg): + """ Handle a message event in a muc. + """ + self.xmpp.event('groupchat_message', msg) + self.xmpp.event("muc::%s::message" % msg['from'].bare, msg) + + def handle_groupchat_subject(self, msg): + """ Handle a message coming from a muc indicating + a change of subject (or announcing it when joining the room) + """ + self.xmpp.event('groupchat_subject', msg) + + def jidInRoom(self, room, jid): + for nick in self.rooms[room]: + entry = self.rooms[room][nick] + if entry is not None and entry['jid'].full == jid: + return True + return False + + def getNick(self, room, jid): + for nick in self.rooms[room]: + entry = self.rooms[room][nick] + if entry is not None and entry['jid'].full == jid: + return nick + + def getRoomForm(self, room, ifrom=None): + iq = self.xmpp.makeIqGet() + iq['to'] = room + if ifrom is not None: + iq['from'] = ifrom + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + iq.append(query) + # For now, swallow errors to preserve existing API + try: + result = iq.send() + except IqError: + return False + except IqTimeout: + return False + xform = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x') + if xform is None: return False + form = self.xmpp.plugin['old_0004'].buildForm(xform) + return form + + def configureRoom(self, room, form=None, ifrom=None): + if form is None: + form = self.getRoomForm(room, ifrom=ifrom) + #form = self.xmpp.plugin['old_0004'].makeForm(ftype='submit') + #form.addField('FORM_TYPE', value='http://jabber.org/protocol/muc#roomconfig') + iq = self.xmpp.makeIqSet() + iq['to'] = room + if ifrom is not None: + iq['from'] = ifrom + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + form = form.getXML('submit') + query.append(form) + iq.append(query) + # For now, swallow errors to preserve existing API + try: + result = iq.send() + except IqError: + return False + except IqTimeout: + return False + return True + + def joinMUC(self, room, nick, maxhistory="0", password='', wait=False, pstatus=None, pshow=None, pfrom=None): + """ Join the specified room, requesting 'maxhistory' lines of history. + """ + stanza = self.xmpp.makePresence(pto="%s/%s" % (room, nick), pstatus=pstatus, pshow=pshow, pfrom=pfrom) + x = ET.Element('{http://jabber.org/protocol/muc}x') + if password: + passelement = ET.Element('password') + passelement.text = password + x.append(passelement) + if maxhistory: + history = ET.Element('history') + if maxhistory == "0": + history.attrib['maxchars'] = maxhistory + else: + history.attrib['maxstanzas'] = maxhistory + x.append(history) + stanza.append(x) + if not wait: + self.xmpp.send(stanza) + else: + #wait for our own room presence back + expect = ET.Element("{%s}presence" % self.xmpp.default_ns, {'from':"%s/%s" % (room, nick)}) + self.xmpp.send(stanza, expect) + self.rooms[room] = {} + self.ourNicks[room] = nick + + def destroy(self, room, reason='', altroom = '', ifrom=None): + iq = self.xmpp.makeIqSet() + if ifrom is not None: + iq['from'] = ifrom + iq['to'] = room + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + destroy = ET.Element('destroy') + if altroom: + destroy.attrib['jid'] = altroom + xreason = ET.Element('reason') + xreason.text = reason + destroy.append(xreason) + query.append(destroy) + iq.append(query) + # For now, swallow errors to preserve existing API + try: + r = iq.send() + except IqError: + return False + except IqTimeout: + return False + return True + + def setAffiliation(self, room, jid=None, nick=None, affiliation='member', ifrom=None): + """ Change room affiliation.""" + if affiliation not in ('outcast', 'member', 'admin', 'owner', 'none'): + raise TypeError + query = ET.Element('{http://jabber.org/protocol/muc#admin}query') + if nick is not None: + item = ET.Element('item', {'affiliation':affiliation, 'nick':nick}) + else: + item = ET.Element('item', {'affiliation':affiliation, 'jid':jid}) + query.append(item) + iq = self.xmpp.makeIqSet(query) + iq['to'] = room + iq['from'] = ifrom + # For now, swallow errors to preserve existing API + try: + result = iq.send() + except IqError: + return False + except IqTimeout: + return False + return True + + def invite(self, room, jid, reason='', mfrom=''): + """ Invite a jid to a room.""" + msg = self.xmpp.makeMessage(room) + msg['from'] = mfrom + x = ET.Element('{http://jabber.org/protocol/muc#user}x') + invite = ET.Element('{http://jabber.org/protocol/muc#user}invite', {'to': jid}) + if reason: + rxml = ET.Element('reason') + rxml.text = reason + invite.append(rxml) + x.append(invite) + msg.append(x) + self.xmpp.send(msg) + + def leaveMUC(self, room, nick, msg='', pfrom=None): + """ Leave the specified room. + """ + if msg: + self.xmpp.sendPresence(pshow='unavailable', pto="%s/%s" % (room, nick), pstatus=msg, pfrom=pfrom) + else: + self.xmpp.sendPresence(pshow='unavailable', pto="%s/%s" % (room, nick), pfrom=pfrom) + del self.rooms[room] + + def getRoomConfig(self, room, ifrom=''): + iq = self.xmpp.makeIqGet('http://jabber.org/protocol/muc#owner') + iq['to'] = room + iq['from'] = ifrom + # For now, swallow errors to preserve existing API + try: + result = iq.send() + except IqError: + raise ValueError + except IqTimeout: + raise ValueError + form = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x') + if form is None: + raise ValueError + return self.xmpp.plugin['xep_0004'].buildForm(form) + + def cancelConfig(self, room, ifrom=None): + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + x = ET.Element('{jabber:x:data}x', type='cancel') + query.append(x) + iq = self.xmpp.makeIqSet(query) + iq['to'] = room + iq['from'] = ifrom + iq.send() + + def setRoomConfig(self, room, config, ifrom=''): + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + x = config.getXML('submit') + query.append(x) + iq = self.xmpp.makeIqSet(query) + iq['to'] = room + iq['from'] = ifrom + iq.send() + + def getJoinedRooms(self): + return self.rooms.keys() + + def getOurJidInRoom(self, roomJid): + """ Return the jid we're using in a room. + """ + return "%s/%s" % (roomJid, self.ourNicks[roomJid]) + + def getJidProperty(self, room, nick, jidProperty): + """ Get the property of a nick in a room, such as its 'jid' or 'affiliation' + If not found, return None. + """ + if room in self.rooms and nick in self.rooms[room] and jidProperty in self.rooms[room][nick]: + return self.rooms[room][nick][jidProperty] + else: + return None + + def getRoster(self, room): + """ Get the list of nicks in a room. + """ + if room not in self.rooms.keys(): + return None + return self.rooms[room].keys() diff --git a/sleekxmpp/plugins/xep_0050/__init__.py b/sleekxmpp/plugins/xep_0050/__init__.py new file mode 100644 index 00000000..99f44f2a --- /dev/null +++ b/sleekxmpp/plugins/xep_0050/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0050.stanza import Command +from sleekxmpp.plugins.xep_0050.adhoc import xep_0050 diff --git a/sleekxmpp/plugins/xep_0050/adhoc.py b/sleekxmpp/plugins/xep_0050/adhoc.py new file mode 100644 index 00000000..ec7b7041 --- /dev/null +++ b/sleekxmpp/plugins/xep_0050/adhoc.py @@ -0,0 +1,614 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import time + +from sleekxmpp import Iq +from sleekxmpp.exceptions import IqError +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream import register_stanza_plugin, JID +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0050 import stanza +from sleekxmpp.plugins.xep_0050 import Command +from sleekxmpp.plugins.xep_0004 import Form + + +log = logging.getLogger(__name__) + + +class xep_0050(base_plugin): + + """ + XEP-0050: Ad-Hoc Commands + + XMPP's Adhoc Commands provides a generic workflow mechanism for + interacting with applications. The result is similar to menu selections + and multi-step dialogs in normal desktop applications. Clients do not + need to know in advance what commands are provided by any particular + application or agent. While adhoc commands provide similar functionality + to Jabber-RPC, adhoc commands are used primarily for human interaction. + + Also see <http://xmpp.org/extensions/xep-0050.html> + + Configuration Values: + threaded -- Indicates if command events should be threaded. + Defaults to True. + + Events: + command_execute -- Received a command with action="execute" + command_next -- Received a command with action="next" + command_complete -- Received a command with action="complete" + command_cancel -- Received a command with action="cancel" + + Attributes: + threaded -- Indicates if command events should be threaded. + Defaults to True. + commands -- A dictionary mapping JID/node pairs to command + names and handlers. + sessions -- A dictionary or equivalent backend mapping + session IDs to dictionaries containing data + relevant to a command's session. + + Methods: + plugin_init -- Overrides base_plugin.plugin_init + post_init -- Overrides base_plugin.post_init + new_session -- Return a new session ID. + prep_handlers -- Placeholder. May call with a list of handlers + to prepare them for use with the session storage + backend, if needed. + set_backend -- Replace the default session storage with some + external storage mechanism, such as a database. + The provided backend wrapper must be able to + act using the same syntax as a dictionary. + add_command -- Add a command for use by external entitites. + get_commands -- Retrieve a list of commands provided by a + remote agent. + send_command -- Send a command request to a remote agent. + start_command -- Command user API: initiate a command session + continue_command -- Command user API: proceed to the next step + cancel_command -- Command user API: cancel a command + complete_command -- Command user API: finish a command + terminate_command -- Command user API: delete a command's session + """ + + def plugin_init(self): + """Start the XEP-0050 plugin.""" + self.xep = '0050' + self.description = 'Ad-Hoc Commands' + self.stanza = stanza + + self.threaded = self.config.get('threaded', True) + self.commands = {} + self.sessions = self.config.get('session_db', {}) + + self.xmpp.register_handler( + Callback("Ad-Hoc Execute", + StanzaPath('iq@type=set/command'), + self._handle_command)) + + register_stanza_plugin(Iq, Command) + register_stanza_plugin(Command, Form) + + self.xmpp.add_event_handler('command_execute', + self._handle_command_start, + threaded=self.threaded) + self.xmpp.add_event_handler('command_next', + self._handle_command_next, + threaded=self.threaded) + self.xmpp.add_event_handler('command_cancel', + self._handle_command_cancel, + threaded=self.threaded) + self.xmpp.add_event_handler('command_complete', + self._handle_command_complete, + threaded=self.threaded) + + def post_init(self): + """Handle cross-plugin interactions.""" + base_plugin.post_init(self) + self.xmpp['xep_0030'].add_feature(Command.namespace) + + def set_backend(self, db): + """ + Replace the default session storage dictionary with + a generic, external data storage mechanism. + + The replacement backend must be able to interact through + the same syntax and interfaces as a normal dictionary. + + Arguments: + db -- The new session storage mechanism. + """ + self.sessions = db + + def prep_handlers(self, handlers, **kwargs): + """ + Prepare a list of functions for use by the backend service. + + Intended to be replaced by the backend service as needed. + + Arguments: + handlers -- A list of function pointers + **kwargs -- Any additional parameters required by the backend. + """ + pass + + # ================================================================= + # Server side (command provider) API + + def add_command(self, jid=None, node=None, name='', handler=None): + """ + Make a new command available to external entities. + + Access control may be implemented in the provided handler. + + Command workflow is done across a sequence of command handlers. The + first handler is given the initial Iq stanza of the request in order + to support access control. Subsequent handlers are given only the + payload items of the command. All handlers will receive the command's + session data. + + Arguments: + jid -- The JID that will expose the command. + node -- The node associated with the command. + name -- A human readable name for the command. + handler -- A function that will generate the response to the + initial command request, as well as enforcing any + access control policies. + """ + if jid is None: + jid = self.xmpp.boundjid + elif not isinstance(jid, JID): + jid = JID(jid) + item_jid = jid.full + + # Client disco uses only the bare JID + if self.xmpp.is_component: + jid = jid.full + else: + jid = jid.bare + + self.xmpp['xep_0030'].add_identity(category='automation', + itype='command-list', + name='Ad-Hoc commands', + node=Command.namespace, + jid=jid) + self.xmpp['xep_0030'].add_item(jid=item_jid, + name=name, + node=Command.namespace, + subnode=node, + ijid=jid) + self.xmpp['xep_0030'].add_identity(category='automation', + itype='command-node', + name=name, + node=node, + jid=jid) + self.xmpp['xep_0030'].add_feature(Command.namespace, None, jid) + + self.commands[(item_jid, node)] = (name, handler) + + def new_session(self): + """Return a new session ID.""" + return str(time.time()) + '-' + self.xmpp.new_id() + + def _handle_command(self, iq): + """Raise command events based on the command action.""" + self.xmpp.event('command_%s' % iq['command']['action'], iq) + + def _handle_command_start(self, iq): + """ + Process an initial request to execute a command. + + Arguments: + iq -- The command execution request. + """ + sessionid = self.new_session() + node = iq['command']['node'] + key = (iq['to'].full, node) + name, handler = self.commands.get(key, ('Not found', None)) + if not handler: + log.debug('Command not found: %s, %s', key, self.commands) + initial_session = {'id': sessionid, + 'from': iq['from'], + 'to': iq['to'], + 'node': node, + 'payload': None, + 'interfaces': '', + 'payload_classes': None, + 'notes': None, + 'has_next': False, + 'allow_complete': False, + 'allow_prev': False, + 'past': [], + 'next': None, + 'prev': None, + 'cancel': None} + + session = handler(iq, initial_session) + + self._process_command_response(iq, session) + + def _handle_command_next(self, iq): + """ + Process a request for the next step in the workflow + for a command with multiple steps. + + Arguments: + iq -- The command continuation request. + """ + sessionid = iq['command']['sessionid'] + session = self.sessions[sessionid] + + handler = session['next'] + interfaces = session['interfaces'] + results = [] + for stanza in iq['command']['substanzas']: + if stanza.plugin_attrib in interfaces: + results.append(stanza) + if len(results) == 1: + results = results[0] + + session = handler(results, session) + + self._process_command_response(iq, session) + + def _process_command_response(self, iq, session): + """ + Generate a command reply stanza based on the + provided session data. + + Arguments: + iq -- The command request stanza. + session -- A dictionary of relevant session data. + """ + sessionid = session['id'] + + payload = session['payload'] + if not isinstance(payload, list): + payload = [payload] + + session['interfaces'] = [item.plugin_attrib for item in payload] + session['payload_classes'] = [item.__class__ for item in payload] + + self.sessions[sessionid] = session + + for item in payload: + register_stanza_plugin(Command, item.__class__, iterable=True) + + iq.reply() + iq['command']['node'] = session['node'] + iq['command']['sessionid'] = session['id'] + + if session['next'] is None: + iq['command']['actions'] = [] + iq['command']['status'] = 'completed' + elif session['has_next']: + actions = ['next'] + if session['allow_complete']: + actions.append('complete') + if session['allow_prev']: + actions.append('prev') + iq['command']['actions'] = actions + iq['command']['status'] = 'executing' + else: + iq['command']['actions'] = ['complete'] + iq['command']['status'] = 'executing' + + iq['command']['notes'] = session['notes'] + + for item in payload: + iq['command'].append(item) + + iq.send() + + def _handle_command_cancel(self, iq): + """ + Process a request to cancel a command's execution. + + Arguments: + iq -- The command cancellation request. + """ + node = iq['command']['node'] + sessionid = iq['command']['sessionid'] + session = self.sessions[sessionid] + handler = session['cancel'] + + if handler: + handler(iq, session) + + try: + del self.sessions[sessionid] + except: + pass + + iq.reply() + iq['command']['node'] = node + iq['command']['sessionid'] = sessionid + iq['command']['status'] = 'canceled' + iq['command']['notes'] = session['notes'] + iq.send() + + def _handle_command_complete(self, iq): + """ + Process a request to finish the execution of command + and terminate the workflow. + + All data related to the command session will be removed. + + Arguments: + iq -- The command completion request. + """ + node = iq['command']['node'] + sessionid = iq['command']['sessionid'] + session = self.sessions[sessionid] + handler = session['next'] + interfaces = session['interfaces'] + results = [] + for stanza in iq['command']['substanzas']: + if stanza.plugin_attrib in interfaces: + results.append(stanza) + if len(results) == 1: + results = results[0] + + if handler: + handler(results, session) + + iq.reply() + iq['command']['node'] = node + iq['command']['sessionid'] = sessionid + iq['command']['actions'] = [] + iq['command']['status'] = 'completed' + iq['command']['notes'] = session['notes'] + iq.send() + + del self.sessions[sessionid] + + + # ================================================================= + # Client side (command user) API + + def get_commands(self, jid, **kwargs): + """ + Return a list of commands provided by a given JID. + + Arguments: + jid -- The JID to query for commands. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the items. + ifrom -- Specifiy the sender's JID. + block -- If true, block and wait for the stanzas' reply. + timeout -- The time in seconds to block while waiting for + a reply. If None, then wait indefinitely. + callback -- Optional callback to execute when a reply is + received instead of blocking and waiting for + the reply. + iterator -- If True, return a result set iterator using + the XEP-0059 plugin, if the plugin is loaded. + Otherwise the parameter is ignored. + """ + return self.xmpp['xep_0030'].get_items(jid=jid, + node=Command.namespace, + **kwargs) + + def send_command(self, jid, node, ifrom=None, action='execute', + payload=None, sessionid=None, flow=False, **kwargs): + """ + Create and send a command stanza, without using the provided + workflow management APIs. + + Arguments: + jid -- The JID to send the command request or result. + node -- The node for the command. + ifrom -- Specify the sender's JID. + action -- May be one of: execute, cancel, complete, + or cancel. + payload -- Either a list of payload items, or a single + payload item such as a data form. + sessionid -- The current session's ID value. + flow -- If True, process the Iq result using the + command workflow methods contained in the + session instead of returning the response + stanza itself. Defaults to False. + block -- Specify if the send call will block until a + response is received, or a timeout occurs. + Defaults to True. + timeout -- The length of time (in seconds) to wait for a + response before exiting the send call + if blocking is used. Defaults to + sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler + function. Will be executed when a reply + stanza is received if flow=False. + """ + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + iq['from'] = ifrom + iq['command']['node'] = node + iq['command']['action'] = action + if sessionid is not None: + iq['command']['sessionid'] = sessionid + if payload is not None: + if not isinstance(payload, list): + payload = [payload] + for item in payload: + iq['command'].append(item) + if not flow: + return iq.send(**kwargs) + else: + if kwargs.get('block', True): + try: + result = iq.send(**kwargs) + except IqError as err: + result = err.iq + self._handle_command_result(result) + else: + iq.send(block=False, callback=self._handle_command_result) + + def start_command(self, jid, node, session, ifrom=None, block=False): + """ + Initiate executing a command provided by a remote agent. + + The default workflow provided is non-blocking, but a blocking + version may be used with block=True. + + The provided session dictionary should contain: + next -- A handler for processing the command result. + error -- A handler for processing any error stanzas + generated by the request. + + Arguments: + jid -- The JID to send the command request. + node -- The node for the desired command. + session -- A dictionary of relevant session data. + ifrom -- Optionally specify the sender's JID. + block -- If True, block execution until a result + is received. Defaults to False. + """ + session['jid'] = jid + session['node'] = node + session['timestamp'] = time.time() + session['payload'] = None + session['block'] = block + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + iq['from'] = ifrom + session['from'] = ifrom + iq['command']['node'] = node + iq['command']['action'] = 'execute' + sessionid = 'client:pending_' + iq['id'] + session['id'] = sessionid + self.sessions[sessionid] = session + if session['block']: + try: + result = iq.send(block=True) + except IqError as err: + result = err.iq + self._handle_command_result(result) + else: + iq.send(block=False, callback=self._handle_command_result) + + def continue_command(self, session): + """ + Execute the next action of the command. + + Arguments: + session -- All stored data relevant to the current + command session. + """ + sessionid = 'client:' + session['id'] + self.sessions[sessionid] = session + + self.send_command(session['jid'], + session['node'], + ifrom=session.get('from', None), + action='next', + payload=session.get('payload', None), + sessionid=session['id'], + flow=True, + block=session['block']) + + def cancel_command(self, session): + """ + Cancel the execution of a command. + + Arguments: + session -- All stored data relevant to the current + command session. + """ + sessionid = 'client:' + session['id'] + self.sessions[sessionid] = session + + self.send_command(session['jid'], + session['node'], + ifrom=session.get('from', None), + action='cancel', + payload=session.get('payload', None), + sessionid=session['id'], + flow=True, + block=session['block']) + + def complete_command(self, session): + """ + Finish the execution of a command workflow. + + Arguments: + session -- All stored data relevant to the current + command session. + """ + sessionid = 'client:' + session['id'] + self.sessions[sessionid] = session + + self.send_command(session['jid'], + session['node'], + ifrom=session.get('from', None), + action='complete', + payload=session.get('payload', None), + sessionid=session['id'], + flow=True, + block=session['block']) + + def terminate_command(self, session): + """ + Delete a command's session after a command has completed + or an error has occured. + + Arguments: + session -- All stored data relevant to the current + command session. + """ + try: + del self.sessions[session['id']] + except: + pass + + def _handle_command_result(self, iq): + """ + Process the results of a command request. + + Will execute the 'next' handler stored in the session + data, or the 'error' handler depending on the Iq's type. + + Arguments: + iq -- The command response. + """ + sessionid = 'client:' + iq['command']['sessionid'] + pending = False + + if sessionid not in self.sessions: + pending = True + pendingid = 'client:pending_' + iq['id'] + if pendingid not in self.sessions: + return + sessionid = pendingid + + session = self.sessions[sessionid] + sessionid = 'client:' + iq['command']['sessionid'] + session['id'] = iq['command']['sessionid'] + + self.sessions[sessionid] = session + + if pending: + del self.sessions[pendingid] + + handler_type = 'next' + if iq['type'] == 'error': + handler_type = 'error' + handler = session.get(handler_type, None) + if handler: + handler(iq, session) + elif iq['type'] == 'error': + self.terminate_command(session) + + if iq['command']['status'] == 'completed': + self.terminate_command(session) diff --git a/sleekxmpp/plugins/xep_0050/stanza.py b/sleekxmpp/plugins/xep_0050/stanza.py new file mode 100644 index 00000000..31a4a5d5 --- /dev/null +++ b/sleekxmpp/plugins/xep_0050/stanza.py @@ -0,0 +1,185 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class Command(ElementBase): + + """ + XMPP's Adhoc Commands provides a generic workflow mechanism for + interacting with applications. The result is similar to menu selections + and multi-step dialogs in normal desktop applications. Clients do not + need to know in advance what commands are provided by any particular + application or agent. While adhoc commands provide similar functionality + to Jabber-RPC, adhoc commands are used primarily for human interaction. + + Also see <http://xmpp.org/extensions/xep-0050.html> + + Example command stanzas: + <iq type="set"> + <command xmlns="http://jabber.org/protocol/commands" + node="run_foo" + action="execute" /> + </iq> + + <iq type="result"> + <command xmlns="http://jabber.org/protocol/commands" + node="run_foo" + sessionid="12345" + status="executing"> + <actions> + <complete /> + </actions> + <note type="info">Information!</note> + <x xmlns="jabber:x:data"> + <field var="greeting" + type="text-single" + label="Greeting" /> + </x> + </command> + </iq> + + Stanza Interface: + action -- The action to perform. + actions -- The set of allowable next actions. + node -- The node associated with the command. + notes -- A list of tuples for informative notes. + sessionid -- A unique identifier for a command session. + status -- May be one of: canceled, completed, or executing. + + Attributes: + actions -- A set of allowed action values. + statuses -- A set of allowed status values. + next_actions -- A set of allowed next action names. + + Methods: + get_action -- Return the requested action. + get_actions -- Return the allowable next actions. + set_actions -- Set the allowable next actions. + del_actions -- Remove the current set of next actions. + get_notes -- Return a list of informative note data. + set_notes -- Set informative notes. + del_notes -- Remove any note data. + add_note -- Add a single note. + """ + + name = 'command' + namespace = 'http://jabber.org/protocol/commands' + plugin_attrib = 'command' + interfaces = set(('action', 'sessionid', 'node', + 'status', 'actions', 'notes')) + actions = set(('cancel', 'complete', 'execute', 'next', 'prev')) + statuses = set(('canceled', 'completed', 'executing')) + next_actions = set(('prev', 'next', 'complete')) + + def get_action(self): + """ + Return the value of the action attribute. + + If the Iq stanza's type is "set" then use a default + value of "execute". + """ + if self.parent()['type'] == 'set': + return self._get_attr('action', default='execute') + return self._get_attr('action') + + def set_actions(self, values): + """ + Assign the set of allowable next actions. + + Arguments: + values -- A list containing any combination of: + 'prev', 'next', and 'complete' + """ + self.del_actions() + if values: + self._set_sub_text('{%s}actions' % self.namespace, '', True) + actions = self.find('{%s}actions' % self.namespace) + for val in values: + if val in self.next_actions: + action = ET.Element('{%s}%s' % (self.namespace, val)) + actions.append(action) + + def get_actions(self): + """ + Return the set of allowable next actions. + """ + actions = [] + actions_xml = self.find('{%s}actions' % self.namespace) + if actions_xml is not None: + for action in self.next_actions: + action_xml = actions_xml.find('{%s}%s' % (self.namespace, + action)) + if action_xml is not None: + actions.append(action) + return actions + + def del_actions(self): + """ + Remove all allowable next actions. + """ + self._del_sub('{%s}actions' % self.namespace) + + def get_notes(self): + """ + Return a list of note information. + + Example: + [('info', 'Some informative data'), + ('warning', 'Use caution'), + ('error', 'The command ran, but had errors')] + """ + notes = [] + notes_xml = self.findall('{%s}note' % self.namespace) + for note in notes_xml: + notes.append((note.attrib.get('type', 'info'), + note.text)) + return notes + + def set_notes(self, notes): + """ + Add multiple notes to the command result. + + Each note is a tuple, with the first item being one of: + 'info', 'warning', or 'error', and the second item being + any human readable message. + + Example: + [('info', 'Some informative data'), + ('warning', 'Use caution'), + ('error', 'The command ran, but had errors')] + + + Arguments: + notes -- A list of tuples of note information. + """ + self.del_notes() + for note in notes: + self.add_note(note[1], note[0]) + + def del_notes(self): + """ + Remove all notes associated with the command result. + """ + notes_xml = self.findall('{%s}note' % self.namespace) + for note in notes_xml: + self.xml.remove(note) + + def add_note(self, msg='', ntype='info'): + """ + Add a single note annotation to the command. + + Arguments: + msg -- A human readable message. + ntype -- One of: 'info', 'warning', 'error' + """ + xml = ET.Element('{%s}note' % self.namespace) + xml.attrib['type'] = ntype + xml.text = msg + self.xml.append(xml) diff --git a/sleekxmpp/plugins/xep_0059/__init__.py b/sleekxmpp/plugins/xep_0059/__init__.py new file mode 100644 index 00000000..3a9b8edf --- /dev/null +++ b/sleekxmpp/plugins/xep_0059/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Erik Reuterborg Larsson + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0059.stanza import Set +from sleekxmpp.plugins.xep_0059.rsm import ResultIterator, xep_0059 diff --git a/sleekxmpp/plugins/xep_0059/rsm.py b/sleekxmpp/plugins/xep_0059/rsm.py new file mode 100644 index 00000000..35908473 --- /dev/null +++ b/sleekxmpp/plugins/xep_0059/rsm.py @@ -0,0 +1,119 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Erik Reuterborg Larsson + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.plugins.xep_0059 import Set + + +log = logging.getLogger(__name__) + + +class ResultIterator(): + + """ + An iterator for Result Set Managment + """ + + def __init__(self, query, interface, amount=10, start=None, reverse=False): + """ + Arguments: + query -- The template query + interface -- The substanza of the query, for example disco_items + amount -- The max amounts of items to request per iteration + start -- From which item id to start + reverse -- If True, page backwards through the results + + Example: + q = Iq() + q['to'] = 'pubsub.example.com' + q['disco_items']['node'] = 'blog' + for i in ResultIterator(q, 'disco_items', '10'): + print i['disco_items']['items'] + + """ + self.query = query + self.amount = amount + self.start = start + self.interface = interface + self.reverse = reverse + + def __iter__(self): + return self + + def __next__(self): + return self.next() + + def next(self): + """ + Return the next page of results from a query. + + Note: If using backwards paging, then the next page of + results will be the items before the current page + of items. + """ + self.query[self.interface]['rsm']['before'] = self.reverse + self.query['id'] = self.query.stream.new_id() + self.query[self.interface]['rsm']['max'] = str(self.amount) + + if self.start and self.reverse: + self.query[self.interface]['rsm']['before'] = self.start + elif self.start: + self.query[self.interface]['rsm']['after'] = self.start + + r = self.query.send(block=True) + + if not r or not r[self.interface]['rsm']['first'] and \ + not r[self.interface]['rsm']['last']: + raise StopIteration + + if self.reverse: + self.start = r[self.interface]['rsm']['first'] + else: + self.start = r[self.interface]['rsm']['last'] + + return r + + +class xep_0059(base_plugin): + + """ + XEP-0050: Result Set Management + """ + + def plugin_init(self): + """ + Start the XEP-0059 plugin. + """ + self.xep = '0059' + self.description = 'Result Set Management' + self.stanza = sleekxmpp.plugins.xep_0059.stanza + + def post_init(self): + """Handle inter-plugin dependencies.""" + base_plugin.post_init(self) + self.xmpp['xep_0030'].add_feature(Set.namespace) + + def iterate(self, stanza, interface): + """ + Create a new result set iterator for a given stanza query. + + Arguments: + stanza -- A stanza object to serve as a template for + queries made each iteration. For example, a + basic disco#items query. + interface -- The name of the substanza to which the + result set management stanza should be + appended. For example, for disco#items queries + the interface 'disco_items' should be used. + """ + return ResultIterator(stanza, interface) diff --git a/sleekxmpp/plugins/xep_0059/stanza.py b/sleekxmpp/plugins/xep_0059/stanza.py new file mode 100644 index 00000000..7c637d0b --- /dev/null +++ b/sleekxmpp/plugins/xep_0059/stanza.py @@ -0,0 +1,108 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Erik Reuterborg Larsson + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET +from sleekxmpp.plugins.xep_0030.stanza.items import DiscoItems + + +class Set(ElementBase): + + """ + XEP-0059 (Result Set Managment) can be used to manage the + results of queries. For example, limiting the number of items + per response or starting at certain positions. + + Example set stanzas: + <iq type="get"> + <query xmlns="http://jabber.org/protocol/disco#items"> + <set xmlns="http://jabber.org/protocol/rsm"> + <max>2</max> + </set> + </query> + </iq> + + <iq type="result"> + <query xmlns="http://jabber.org/protocol/disco#items"> + <item jid="conference.example.com" /> + <item jid="pubsub.example.com" /> + <set xmlns="http://jabber.org/protocol/rsm"> + <first>conference.example.com</first> + <last>pubsub.example.com</last> + </set> + </query> + </iq> + + Stanza Interface: + first_index -- The index attribute of <first> + after -- The id defining from which item to start + before -- The id defining from which item to + start when browsing backwards + max -- Max amount per response + first -- Id for the first item in the response + last -- Id for the last item in the response + index -- Used to set an index to start from + count -- The number of remote items available + + Methods: + set_first_index -- Sets the index attribute for <first> and + creates the element if it doesn't exist + get_first_index -- Returns the value of the index + attribute for <first> + del_first_index -- Removes the index attribute for <first> + but keeps the element + set_before -- Sets the value of <before>, if the value is True + then the element will be created without a value + get_before -- Returns the value of <before>, if it is + empty it will return True + + """ + namespace = 'http://jabber.org/protocol/rsm' + name = 'set' + plugin_attrib = 'rsm' + sub_interfaces = set(('first', 'after', 'before', 'count', + 'index', 'last', 'max')) + interfaces = set(('first_index', 'first', 'after', 'before', + 'count', 'index', 'last', 'max')) + + def set_first_index(self, val): + fi = self.find("{%s}first" % (self.namespace)) + if fi is not None: + if val: + fi.attrib['index'] = val + else: + del fi.attrib['index'] + elif val: + fi = ET.Element("{%s}first" % (self.namespace)) + fi.attrib['index'] = val + self.xml.append(fi) + + def get_first_index(self): + fi = self.find("{%s}first" % (self.namespace)) + if fi is not None: + return fi.attrib.get('index', '') + + def del_first_index(self): + fi = self.xml.find("{%s}first" % (self.namespace)) + if fi is not None: + del fi.attrib['index'] + + def set_before(self, val): + b = self.xml.find("{%s}before" % (self.namespace)) + if b is None and val == True: + self._set_sub_text('{%s}before' % self.namespace, '', True) + else: + self._set_sub_text('{%s}before' % self.namespace, val) + + def get_before(self): + b = self.xml.find("{%s}before" % (self.namespace)) + if b is not None and not b.text: + return True + elif b is not None: + return b.text + else: + return None diff --git a/sleekxmpp/plugins/xep_0060/__init__.py b/sleekxmpp/plugins/xep_0060/__init__.py new file mode 100644 index 00000000..026f7c2b --- /dev/null +++ b/sleekxmpp/plugins/xep_0060/__init__.py @@ -0,0 +1,2 @@ +from sleekxmpp.plugins.xep_0060.pubsub import xep_0060 +from sleekxmpp.plugins.xep_0060 import stanza diff --git a/sleekxmpp/plugins/xep_0060/pubsub.py b/sleekxmpp/plugins/xep_0060/pubsub.py new file mode 100644 index 00000000..9e394ef2 --- /dev/null +++ b/sleekxmpp/plugins/xep_0060/pubsub.py @@ -0,0 +1,450 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +from sleekxmpp.xmlstream import JID +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0060 import stanza + + +log = logging.getLogger(__name__) + + +class xep_0060(base_plugin): + + """ + XEP-0060 Publish Subscribe + """ + + def plugin_init(self): + self.xep = '0060' + self.description = 'Publish-Subscribe' + self.stanza = stanza + + def create_node(self, jid, node, config=None, ntype=None, ifrom=None, + block=True, callback=None, timeout=None): + """ + Create and configure a new pubsub node. + + A server MAY use a different name for the node than the one provided, + so be sure to check the result stanza for a server assigned name. + + If no configuration form is provided, the node will be created using + the server's default configuration. To get the default configuration + use get_node_config(). + + Arguments: + jid -- The JID of the pubsub service. + node -- Optional name of the node to create. If no name is + provided, the server MAY generate a node ID for you. + The server can also assign a different name than the + one you provide; check the result stanza to see if + the server assigned a name. + config -- Optional XEP-0004 data form of configuration settings. + ntype -- The type of node to create. Servers typically default + to using 'leaf' if no type is provided. + ifrom -- Specify the sender's JID. + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub']['create']['node'] = node + + if config is not None: + form_type = 'http://jabber.org/protocol/pubsub#node_config' + if 'FORM_TYPE' in config['fields']: + config.field['FORM_TYPE']['value'] = form_type + else: + config.add_field(var='FORM_TYPE', + ftype='hidden', + value=form_type) + if ntype: + if 'pubsub#node_type' in config['fields']: + config.field['pubsub#node_type']['value'] = ntype + else: + config.add_field(var='pubsub#node_type', value=ntype) + iq['pubsub']['configure'].append(config) + + return iq.send(block=block, callback=callback, timeout=timeout) + + def subscribe(self, jid, node, bare=True, subscribee=None, options=None, + ifrom=None, block=True, callback=None, timeout=None): + """ + Subscribe to updates from a pubsub node. + + The rules for determining the JID that is subscribing to the node are: + 1. If subscribee is given, use that as provided. + 2. If ifrom was given, use the bare or full version based on bare. + 3. Otherwise, use self.xmpp.boundjid based on bare. + + Arguments: + jid -- The pubsub service JID. + node -- The node to subscribe to. + bare -- Indicates if the subscribee is a bare or full JID. + Defaults to True for a bare JID. + subscribee -- The JID that is subscribing to the node. + options -- + ifrom -- Specify the sender's JID. + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub']['subscribe']['node'] = node + + if subscribee is None: + if ifrom: + if bare: + subscribee = JID(ifrom).bare + else: + subscribee = ifrom + else: + if bare: + subscribee = self.xmpp.boundjid.bare + else: + subscribee = self.xmpp.boundjid + + iq['pubsub']['subscribe']['jid'] = subscribee + if options is not None: + iq['pubsub']['options'].append(options) + return iq.send(block=block, callback=callback, timeout=timeout) + + def unsubscribe(self, jid, node, subid=None, bare=True, subscribee=None, + ifrom=None, block=True, callback=None, timeout=None): + """ + Unubscribe from updates from a pubsub node. + + The rules for determining the JID that is unsubscribing + from the node are: + 1. If subscribee is given, use that as provided. + 2. If ifrom was given, use the bare or full version based on bare. + 3. Otherwise, use self.xmpp.boundjid based on bare. + + Arguments: + jid -- The pubsub service JID. + node -- The node to subscribe to. + subid -- The specific subscription, if multiple subscriptions + exist for this JID/node combination. + bare -- Indicates if the subscribee is a bare or full JID. + Defaults to True for a bare JID. + subscribee -- The JID that is subscribing to the node. + ifrom -- Specify the sender's JID. + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub']['unsubscribe']['node'] = node + + if subscribee is None: + if ifrom: + if bare: + subscribee = JID(ifrom).bare + else: + subscribee = ifrom + else: + if bare: + subscribee = self.xmpp.boundjid.bare + else: + subscribee = self.xmpp.boundjid + + iq['pubsub']['unsubscribe']['jid'] = subscribee + iq['pubsub']['unsubscribe']['subid'] = subid + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_subscriptions(self, jid, node=None, ifrom=None, block=True, + callback=None, timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + iq['pubsub']['subscriptions']['node'] = node + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_affiliations(self, jid, node=None, ifrom=None, block=True, + callback=None, timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + iq['pubsub']['affiliations']['node'] = node + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_subscription_options(self, jid, node=None, user_jid=None, ifrom=None, + block=True, callback=None, timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + if user_jid is None: + iq['pubsub']['default']['node'] = node + else: + iq['pubsub']['options']['node'] = node + iq['pubsub']['options']['jid'] = user_jid + return iq.send(block=block, callback=callback, timeout=timeout) + + def set_subscription_options(self, jid, node, user_jid, options, + ifrom=None, block=True, callback=None, + timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + iq['pubsub']['options']['node'] = node + iq['pubsub']['options']['jid'] = user_jid + iq['pubsub']['options'].append(options) + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_node_config(self, jid, node=None, ifrom=None, block=True, + callback=None, timeout=None): + """ + Retrieve the configuration for a node, or the pubsub service's + default configuration for new nodes. + + Arguments: + jid -- The JID of the pubsub service. + node -- The node to retrieve the configuration for. If None, + the default configuration for new nodes will be + requested. Defaults to None. + ifrom -- Specify the sender's JID. + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + if node is None: + iq['pubsub_owner']['default'] + else: + iq['pubsub_owner']['configure']['node'] = node + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_node_subscriptions(self, jid, node, ifrom=None, block=True, + callback=None, timeout=None): + """ + Retrieve the subscriptions associated with a given node. + + Arguments: + jid -- The JID of the pubsub service. + node -- The node to retrieve subscriptions from. + ifrom -- Specify the sender's JID. + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + iq['pubsub_owner']['subscriptions']['node'] = node + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_node_affiliations(self, jid, node, ifrom=None, block=True, + callback=None, timeout=None): + """ + Retrieve the affiliations associated with a given node. + + Arguments: + jid -- The JID of the pubsub service. + node -- The node to retrieve affiliations from. + ifrom -- Specify the sender's JID. + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + iq['pubsub_owner']['affiliations']['node'] = node + return iq.send(block=block, callback=callback, timeout=timeout) + + def delete_node(self, jid, node, ifrom=None, block=True, + callback=None, timeout=None): + """ + Delete a a pubsub node. + + Arguments: + jid -- The JID of the pubsub service. + node -- The node to delete. + ifrom -- Specify the sender's JID. + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub_owner']['delete']['node'] = node + return iq.send(block=block, callback=callback, timeout=timeout) + + def set_node_config(self, jid, node, config, ifrom=None, block=True, + callback=None, timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub_owner']['configure']['node'] = node + iq['pubsub_owner']['configure']['form'].values = config.values + return iq.send(block=block, callback=callback, timeout=timeout) + + def publish(self, jid, node, id=None, payload=None, options=None, + ifrom=None, block=True, callback=None, timeout=None): + """ + Add a new item to a node, or edit an existing item. + + For services that support it, you can use the publish command + as an event signal by not including an ID or payload. + + When including a payload and you do not provide an ID then + the service will generally create an ID for you. + + Publish options may be specified, and how those options + are processed is left to the service, such as treating + the options as preconditions that the node's settings + must match. + + Arguments: + jid -- The JID of the pubsub service. + node -- The node to publish the item to. + id -- Optionally specify the ID of the item. + payload -- The item content to publish. + options -- A form of publish options. + ifrom -- Specify the sender's JID. + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub']['publish']['node'] = node + if id is not None: + iq['pubsub']['publish']['item']['id'] = id + if payload is not None: + iq['pubsub']['publish']['item']['payload'] = payload + iq['pubsub']['publish_options'] = options + return iq.send(block=block, callback=callback, timeout=timeout) + + def retract(self, jid, node, id, notify=None, ifrom=None, block=True, + callback=None, timeout=None): + """ + Delete a single item from a node. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + + iq['pubsub']['retract']['node'] = node + iq['pubsub']['retract']['notify'] = notify + iq['pubsub']['retract']['item']['id'] = id + return iq.send(block=block, callback=callback, timeout=timeout) + + def purge(self, jid, node, ifrom=None, block=True, callback=None, + timeout=None): + """ + Remove all items from a node. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub_owner']['purge']['node'] = node + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_nodes(self, *args, **kwargs): + """ + Discover the nodes provided by a Pubsub service, using disco. + """ + return self.xmpp.plugin['xep_0030'].get_items(*args, **kwargs) + + def get_item(self, jid, node, item_id, ifrom=None, block=True, + callback=None, timeout=None): + """ + Retrieve the content of an individual item. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + item = self.stanza.Item() + item['id'] = item_id + iq['pubsub']['items']['node'] = node + iq['pubsub']['items'].append(item) + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_items(self, jid, node, item_ids=None, max_items=None, + iterator=False, ifrom=None, block=False, + callback=None, timeout=None): + """ + Request the contents of a node's items. + + The desired items can be specified, or a query for the last + few published items can be used. + + Pubsub services may use result set management for nodes with + many items, so an iterator can be returned if needed. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + iq['pubsub']['items']['node'] = node + iq['pubsub']['items']['max_items'] = max_items + + if item_ids is not None: + for item_id in item_ids: + item = self.stanza.Item() + item['id'] = item_id + iq['pubsub']['items'].append(item) + + if iterator: + return self.xmpp['xep_0059'].iterate(iq, 'pubsub') + else: + return iq.send(block=block, callback=callback, timeout=timeout) + + def get_item_ids(self, jid, node, ifrom=None, block=True, + callback=None, timeout=None, iterator=False): + """ + Retrieve the ItemIDs hosted by a given node, using disco. + """ + return self.xmpp.plugin['xep_0030'].get_items(jid, node, + ifrom=ifrom, + block=block, + callback=callback, + timeout=timeout, + iterator=iterator) + + def modify_affiliations(self, jid, node, affiliations=None, ifrom=None, + block=True, callback=None, timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub_owner']['affiliations']['node'] = node + + if affiliations is None: + affiliations = [] + + for jid, affiliation in affiliations: + aff = self.stanza.OwnerAffiliation() + aff['jid'] = jid + aff['affiliation'] = affiliation + iq['pubsub_owner']['affiliations'].append(aff) + + return iq.send(block=block, callback=callback, timeout=timeout) + + def modify_subscriptions(self, jid, node, subscriptions=None, ifrom=None, + block=True, callback=None, timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub_owner']['subscriptions']['node'] = node + + if subscriptions is None: + subscriptions = [] + + for jid, subscription in subscriptions: + sub = self.stanza.OwnerSubscription() + sub['jid'] = jid + sub['subscription'] = subscription + iq['pubsub_owner']['subscriptions'].append(sub) + + return iq.send(block=block, callback=callback, timeout=timeout) diff --git a/sleekxmpp/plugins/xep_0060/stanza/__init__.py b/sleekxmpp/plugins/xep_0060/stanza/__init__.py new file mode 100644 index 00000000..37f52f0e --- /dev/null +++ b/sleekxmpp/plugins/xep_0060/stanza/__init__.py @@ -0,0 +1,12 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0060.stanza.pubsub import * +from sleekxmpp.plugins.xep_0060.stanza.pubsub_owner import * +from sleekxmpp.plugins.xep_0060.stanza.pubsub_event import * +from sleekxmpp.plugins.xep_0060.stanza.pubsub_errors import * diff --git a/sleekxmpp/plugins/xep_0060/stanza/base.py b/sleekxmpp/plugins/xep_0060/stanza/base.py new file mode 100644 index 00000000..d0b7851e --- /dev/null +++ b/sleekxmpp/plugins/xep_0060/stanza/base.py @@ -0,0 +1,29 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ET + + +class OptionalSetting(object): + + interfaces = set(('required',)) + + def set_required(self, value): + if value in (True, 'true', 'True', '1'): + self.xml.append(ET.Element("{%s}required" % self.namespace)) + elif self['required']: + self.del_required() + + def get_required(self): + required = self.xml.find("{%s}required" % self.namespace) + return required is not None + + def del_required(self): + required = self.xml.find("{%s}required" % self.namespace) + if required is not None: + self.xml.remove(required) diff --git a/sleekxmpp/plugins/xep_0060/stanza/pubsub.py b/sleekxmpp/plugins/xep_0060/stanza/pubsub.py new file mode 100644 index 00000000..004f0a02 --- /dev/null +++ b/sleekxmpp/plugins/xep_0060/stanza/pubsub.py @@ -0,0 +1,300 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp import Iq, Message +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from sleekxmpp.plugins import xep_0004 +from sleekxmpp.plugins.xep_0060.stanza.base import OptionalSetting + + +class Pubsub(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'pubsub' + plugin_attrib = name + interfaces = set(tuple()) + + +class Affiliations(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'affiliations' + plugin_attrib = name + interfaces = set(('node',)) + + +class Affiliation(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'affiliation' + plugin_attrib = name + interfaces = set(('node', 'affiliation', 'jid')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +class Subscription(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'subscription' + plugin_attrib = name + interfaces = set(('jid', 'node', 'subscription', 'subid')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +class Subscriptions(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'subscriptions' + plugin_attrib = name + interfaces = set(('node',)) + + +class SubscribeOptions(ElementBase, OptionalSetting): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'subscribe-options' + plugin_attrib = 'suboptions' + interfaces = set(('required',)) + + +class Item(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'item' + plugin_attrib = name + interfaces = set(('id', 'payload')) + + def set_payload(self, value): + del self['payload'] + self.append(value) + + def get_payload(self): + childs = self.xml.getchildren() + if len(childs) > 0: + return childs[0] + + def del_payload(self): + for child in self.xml.getchildren(): + self.xml.remove(child) + + +class Items(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'items' + plugin_attrib = name + interfaces = set(('node', 'max_items')) + + def set_max_items(self, value): + self._set_attr('max_items', str(value)) + + +class Create(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'create' + plugin_attrib = name + interfaces = set(('node',)) + + +class Default(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'default' + plugin_attrib = name + interfaces = set(('node', 'type')) + + def get_type(self): + t = self._get_attr('type') + if not t: + return 'leaf' + return t + + +class Publish(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'publish' + plugin_attrib = name + interfaces = set(('node',)) + + +class Retract(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'retract' + plugin_attrib = name + interfaces = set(('node', 'notify')) + + def get_notify(self): + notify = self._get_attr('notify') + if notify in ('0', 'false'): + return False + elif notify in ('1', 'true'): + return True + return None + + def set_notify(self, value): + del self['notify'] + if value is None: + return + elif value in (True, '1', 'true', 'True'): + self._set_attr('notify', 'true') + else: + self._set_attr('notify', 'false') + + +class Unsubscribe(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'unsubscribe' + plugin_attrib = name + interfaces = set(('node', 'jid', 'subid')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +class Subscribe(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'subscribe' + plugin_attrib = name + interfaces = set(('node', 'jid')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +class Configure(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'configure' + plugin_attrib = name + interfaces = set(('node', 'type')) + + def getType(self): + t = self._get_attr('type') + if not t: + t == 'leaf' + return t + + +class Options(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'options' + plugin_attrib = name + interfaces = set(('jid', 'node', 'options')) + + def __init__(self, *args, **kwargs): + ElementBase.__init__(self, *args, **kwargs) + + def get_options(self): + config = self.xml.find('{jabber:x:data}x') + form = xep_0004.Form(xml=config) + return form + + def set_options(self, value): + self.xml.append(value.getXML()) + return self + + def del_options(self): + config = self.xml.find('{jabber:x:data}x') + self.xml.remove(config) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +class PublishOptions(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'publish-options' + plugin_attrib = 'publish_options' + interfaces = set(('publish_options',)) + is_extension = True + + def get_publish_options(self): + config = self.xml.find('{jabber:x:data}x') + if config is None: + return None + form = xep_0004.Form(xml=config) + return form + + def set_publish_options(self, value): + if value is None: + self.del_publish_options() + else: + self.xml.append(value.getXML()) + return self + + def del_publish_options(self): + config = self.xml.find('{jabber:x:data}x') + if config is not None: + self.xml.remove(config) + self.parent().xml.remove(self.xml) + + +class PubsubState(ElementBase): + """This is an experimental pubsub extension.""" + namespace = 'http://jabber.org/protocol/psstate' + name = 'state' + plugin_attrib = 'psstate' + interfaces = set(('node', 'item', 'payload')) + + def set_payload(self, value): + self.xml.append(value) + + def get_payload(self): + childs = self.xml.getchildren() + if len(childs) > 0: + return childs[0] + + def del_payload(self): + for child in self.xml.getchildren(): + self.xml.remove(child) + + +class PubsubStateEvent(ElementBase): + """This is an experimental pubsub extension.""" + namespace = 'http://jabber.org/protocol/psstate#event' + name = 'event' + plugin_attrib = 'psstate_event' + intefaces = set(tuple()) + + +register_stanza_plugin(Iq, PubsubState) +register_stanza_plugin(Message, PubsubStateEvent) +register_stanza_plugin(PubsubStateEvent, PubsubState) + + +register_stanza_plugin(Iq, Pubsub) +register_stanza_plugin(Pubsub, Affiliations) +register_stanza_plugin(Pubsub, Configure) +register_stanza_plugin(Pubsub, Create) +register_stanza_plugin(Pubsub, Default) +register_stanza_plugin(Pubsub, Items) +register_stanza_plugin(Pubsub, Options) +register_stanza_plugin(Pubsub, Publish) +register_stanza_plugin(Pubsub, PublishOptions) +register_stanza_plugin(Pubsub, Retract) +register_stanza_plugin(Pubsub, Subscribe) +register_stanza_plugin(Pubsub, Subscription) +register_stanza_plugin(Pubsub, Subscriptions) +register_stanza_plugin(Pubsub, Unsubscribe) +register_stanza_plugin(Affiliations, Affiliation, iterable=True) +register_stanza_plugin(Configure, xep_0004.Form) +register_stanza_plugin(Items, Item, iterable=True) +register_stanza_plugin(Publish, Item, iterable=True) +register_stanza_plugin(Retract, Item) +register_stanza_plugin(Subscribe, Options) +register_stanza_plugin(Subscription, SubscribeOptions) +register_stanza_plugin(Subscriptions, Subscription, iterable=True) diff --git a/sleekxmpp/plugins/xep_0060/stanza/pubsub_errors.py b/sleekxmpp/plugins/xep_0060/stanza/pubsub_errors.py new file mode 100644 index 00000000..aeaeefe0 --- /dev/null +++ b/sleekxmpp/plugins/xep_0060/stanza/pubsub_errors.py @@ -0,0 +1,86 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Error +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class PubsubErrorCondition(ElementBase): + + plugin_attrib = 'pubsub' + interfaces = set(('condition', 'unsupported')) + plugin_attrib_map = {} + plugin_tag_map = {} + conditions = set(('closed-node', 'configuration-required', 'invalid-jid', + 'invalid-options', 'invalid-payload', 'invalid-subid', + 'item-forbidden', 'item-required', 'jid-required', + 'max-items-exceeded', 'max-nodes-exceeded', + 'nodeid-required', 'not-in-roster-group', + 'not-subscribed', 'payload-too-big', + 'payload-required', 'pending-subscription', + 'presence-subscription-required', 'subid-required', + 'too-many-subscriptions', 'unsupported')) + condition_ns = 'http://jabber.org/protocol/pubsub#errors' + + def setup(self, xml): + """Don't create XML for the plugin.""" + self.xml = ET.Element('') + + def get_condition(self): + """Return the condition element's name.""" + for child in self.parent().xml.getchildren(): + if "{%s}" % self.condition_ns in child.tag: + cond = child.tag.split('}', 1)[-1] + if cond in self.conditions: + return cond + return '' + + def set_condition(self, value): + """ + Set the tag name of the condition element. + + Arguments: + value -- The tag name of the condition element. + """ + if value in self.conditions: + del self['condition'] + cond = ET.Element("{%s}%s" % (self.condition_ns, value)) + self.parent().xml.append(cond) + return self + + def del_condition(self): + """Remove the condition element.""" + for child in self.parent().xml.getchildren(): + if "{%s}" % self.condition_ns in child.tag: + tag = child.tag.split('}', 1)[-1] + if tag in self.conditions: + self.parent().xml.remove(child) + return self + + def get_unsupported(self): + """Return the name of an unsupported feature""" + xml = self.parent().xml.find('{%s}unsupported' % self.condition_ns) + if xml is not None: + return xml.attrib.get('feature', '') + return '' + + def set_unsupported(self, value): + """Mark a feature as unsupported""" + self.del_unsupported() + xml = ET.Element('{%s}unsupported' % self.condition_ns) + xml.attrib['feature'] = value + self.parent().xml.append(xml) + + def del_unsupported(self): + """Delete an unsupported feature condition.""" + xml = self.parent().xml.find('{%s}unsupported' % self.condition_ns) + if xml is not None: + self.parent().xml.remove(xml) + + +register_stanza_plugin(Error, PubsubErrorCondition) diff --git a/sleekxmpp/plugins/xep_0060/stanza/pubsub_event.py b/sleekxmpp/plugins/xep_0060/stanza/pubsub_event.py new file mode 100644 index 00000000..c7263577 --- /dev/null +++ b/sleekxmpp/plugins/xep_0060/stanza/pubsub_event.py @@ -0,0 +1,112 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp import Message +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from sleekxmpp.plugins.xep_0004 import Form + + +class Event(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'event' + plugin_attrib = 'pubsub_event' + interfaces = set(('node',)) + + +class EventItem(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'item' + plugin_attrib = name + interfaces = set(('id', 'payload')) + + def set_payload(self, value): + self.xml.append(value) + + def get_payload(self): + childs = self.xml.getchildren() + if len(childs) > 0: + return childs[0] + + def del_payload(self): + for child in self.xml.getchildren(): + self.xml.remove(child) + + +class EventRetract(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'retract' + plugin_attrib = name + interfaces = set(('id',)) + + +class EventItems(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'items' + plugin_attrib = name + interfaces = set(('node',)) + + +class EventCollection(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'collection' + plugin_attrib = name + interfaces = set(('node',)) + + +class EventAssociate(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'associate' + plugin_attrib = name + interfaces = set(('node',)) + + +class EventDisassociate(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'disassociate' + plugin_attrib = name + interfaces = set(('node',)) + + +class EventConfiguration(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'configuration' + plugin_attrib = name + interfaces = set(('node', 'config')) + + +class EventPurge(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'purge' + plugin_attrib = name + interfaces = set(('node',)) + + +class EventSubscription(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'subscription' + plugin_attrib = name + interfaces = set(('node', 'expiry', 'jid', 'subid', 'subscription')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +register_stanza_plugin(Message, Event) +register_stanza_plugin(Event, EventCollection) +register_stanza_plugin(Event, EventConfiguration) +register_stanza_plugin(Event, EventItems) +register_stanza_plugin(Event, EventPurge) +register_stanza_plugin(Event, EventSubscription) +register_stanza_plugin(EventCollection, EventAssociate) +register_stanza_plugin(EventCollection, EventDisassociate) +register_stanza_plugin(EventConfiguration, Form) +register_stanza_plugin(EventItems, EventItem, iterable=True) +register_stanza_plugin(EventItems, EventRetract, iterable=True) diff --git a/sleekxmpp/plugins/xep_0060/stanza/pubsub_owner.py b/sleekxmpp/plugins/xep_0060/stanza/pubsub_owner.py new file mode 100644 index 00000000..4a35db9d --- /dev/null +++ b/sleekxmpp/plugins/xep_0060/stanza/pubsub_owner.py @@ -0,0 +1,131 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp import Iq +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from sleekxmpp.plugins.xep_0004 import Form +from sleekxmpp.plugins.xep_0060.stanza.base import OptionalSetting +from sleekxmpp.plugins.xep_0060.stanza.pubsub import Affiliations, Affiliation +from sleekxmpp.plugins.xep_0060.stanza.pubsub import Configure, Subscriptions + + +class PubsubOwner(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'pubsub' + plugin_attrib = 'pubsub_owner' + interfaces = set(tuple()) + + +class DefaultConfig(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'default' + plugin_attrib = name + interfaces = set(('node', 'config')) + + def __init__(self, *args, **kwargs): + ElementBase.__init__(self, *args, **kwargs) + + def get_config(self): + return self['form'] + + def set_config(self, value): + self['form'].values = value.values + return self + + +class OwnerAffiliations(Affiliations): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('node',)) + + def append(self, affiliation): + if not isinstance(affiliation, OwnerAffiliation): + raise TypeError + self.xml.append(affiliation.xml) + + +class OwnerAffiliation(Affiliation): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('affiliation', 'jid')) + + +class OwnerConfigure(Configure): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'configure' + plugin_attrib = name + interfaces = set(('node',)) + + +class OwnerDefault(OwnerConfigure): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('node',)) + + +class OwnerDelete(ElementBase, OptionalSetting): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'delete' + plugin_attrib = name + interfaces = set(('node',)) + + +class OwnerPurge(ElementBase, OptionalSetting): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'purge' + plugin_attrib = name + interfaces = set(('node',)) + + +class OwnerRedirect(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'redirect' + plugin_attrib = name + interfaces = set(('node', 'jid')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +class OwnerSubscriptions(Subscriptions): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('node',)) + + def append(self, subscription): + if not isinstance(subscription, OwnerSubscription): + raise TypeError + self.xml.append(subscription.xml) + + +class OwnerSubscription(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'subscription' + plugin_attrib = name + interfaces = set(('jid', 'subscription')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +register_stanza_plugin(Iq, PubsubOwner) +register_stanza_plugin(PubsubOwner, DefaultConfig) +register_stanza_plugin(PubsubOwner, OwnerAffiliations) +register_stanza_plugin(PubsubOwner, OwnerConfigure) +register_stanza_plugin(PubsubOwner, OwnerDefault) +register_stanza_plugin(PubsubOwner, OwnerDelete) +register_stanza_plugin(PubsubOwner, OwnerPurge) +register_stanza_plugin(PubsubOwner, OwnerSubscriptions) +register_stanza_plugin(DefaultConfig, Form) +register_stanza_plugin(OwnerAffiliations, OwnerAffiliation, iterable=True) +register_stanza_plugin(OwnerConfigure, Form) +register_stanza_plugin(OwnerDefault, Form) +register_stanza_plugin(OwnerDelete, OwnerRedirect) +register_stanza_plugin(OwnerSubscriptions, OwnerSubscription, iterable=True) diff --git a/sleekxmpp/plugins/xep_0066/__init__.py b/sleekxmpp/plugins/xep_0066/__init__.py new file mode 100644 index 00000000..ebfbd0c2 --- /dev/null +++ b/sleekxmpp/plugins/xep_0066/__init__.py @@ -0,0 +1,11 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0066 import stanza +from sleekxmpp.plugins.xep_0066.stanza import OOB, OOBTransfer +from sleekxmpp.plugins.xep_0066.oob import xep_0066 diff --git a/sleekxmpp/plugins/xep_0066/oob.py b/sleekxmpp/plugins/xep_0066/oob.py new file mode 100644 index 00000000..d1f4b3ff --- /dev/null +++ b/sleekxmpp/plugins/xep_0066/oob.py @@ -0,0 +1,153 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +from sleekxmpp.stanza import Message, Presence, Iq +from sleekxmpp.exceptions import XMPPError +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0066 import stanza + + +log = logging.getLogger(__name__) + + +class xep_0066(base_plugin): + + """ + XEP-0066: Out-of-Band Data + + Out-of-Band Data is a basic method for transferring files between + XMPP agents. The URL of the resource in question is sent to the receiving + entity, which then downloads the resource before responding to the OOB + request. OOB is also used as a generic means to transmit URLs in other + stanzas to indicate where to find additional information. + + Also see <http://www.xmpp.org/extensions/xep-0066.html>. + + Events: + oob_transfer -- Raised when a request to download a resource + has been received. + + Methods: + send_oob -- Send a request to another entity to download a file + or other addressable resource. + """ + + def plugin_init(self): + """Start the XEP-0066 plugin.""" + self.xep = '0066' + self.description = 'Out-of-Band Transfer' + self.stanza = stanza + + self.url_handlers = {'global': self._default_handler, + 'jid': {}} + + register_stanza_plugin(Iq, stanza.OOBTransfer) + register_stanza_plugin(Message, stanza.OOB) + register_stanza_plugin(Presence, stanza.OOB) + + self.xmpp.register_handler( + Callback('OOB Transfer', + StanzaPath('iq@type=set/oob_transfer'), + self._handle_transfer)) + + def post_init(self): + """Handle cross-plugin dependencies.""" + base_plugin.post_init(self) + self.xmpp['xep_0030'].add_feature(stanza.OOBTransfer.namespace) + self.xmpp['xep_0030'].add_feature(stanza.OOB.namespace) + + def register_url_handler(self, jid=None, handler=None): + """ + Register a handler to process download requests, either for all + JIDs or a single JID. + + Arguments: + jid -- If None, then set the handler as a global default. + handler -- If None, then remove the existing handler for the + given JID, or reset the global handler if the JID + is None. + """ + if jid is None: + if handler is not None: + self.url_handlers['global'] = handler + else: + self.url_handlers['global'] = self._default_handler + else: + if handler is not None: + self.url_handlers['jid'][jid] = handler + else: + del self.url_handlers['jid'][jid] + + def send_oob(self, to, url, desc=None, ifrom=None, **iqargs): + """ + Initiate a basic file transfer by sending the URL of + a file or other resource. + + Arguments: + url -- The URL of the resource to transfer. + desc -- An optional human readable description of the item + that is to be transferred. + ifrom -- Specifiy the sender's JID. + block -- If true, block and wait for the stanzas' reply. + timeout -- The time in seconds to block while waiting for + a reply. If None, then wait indefinitely. + callback -- Optional callback to execute when a reply is + received instead of blocking and waiting for + the reply. + """ + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = to + iq['from'] = ifrom + iq['oob_transfer']['url'] = url + iq['oob_transfer']['desc'] = desc + return iq.send(**iqargs) + + def _run_url_handler(self, iq): + """ + Execute the appropriate handler for a transfer request. + + Arguments: + iq -- The Iq stanza containing the OOB transfer request. + """ + if iq['to'] in self.url_handlers['jid']: + return self.url_handlers['jid'][jid](iq) + else: + if self.url_handlers['global']: + self.url_handlers['global'](iq) + else: + raise XMPPError('service-unavailable') + + def _default_handler(self, iq): + """ + As a safe default, don't actually download files. + + Register a new handler using self.register_url_handler to + screen requests and download files. + + Arguments: + iq -- The Iq stanza containing the OOB transfer request. + """ + raise XMPPError('service-unavailable') + + def _handle_transfer(self, iq): + """ + Handle receiving an out-of-band transfer request. + + Arguments: + iq -- An Iq stanza containing an OOB transfer request. + """ + log.debug('Received out-of-band data request for %s from %s:' % ( + iq['oob_transfer']['url'], iq['from'])) + self._run_url_handler(iq) + iq.reply().send() diff --git a/sleekxmpp/plugins/xep_0066/stanza.py b/sleekxmpp/plugins/xep_0066/stanza.py new file mode 100644 index 00000000..21387485 --- /dev/null +++ b/sleekxmpp/plugins/xep_0066/stanza.py @@ -0,0 +1,33 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase + + +class OOBTransfer(ElementBase): + + """ + """ + + name = 'query' + namespace = 'jabber:iq:oob' + plugin_attrib = 'oob_transfer' + interfaces = set(('url', 'desc', 'sid')) + sub_interfaces = set(('url', 'desc')) + + +class OOB(ElementBase): + + """ + """ + + name = 'x' + namespace = 'jabber:x:oob' + plugin_attrib = 'oob' + interfaces = set(('url', 'desc')) + sub_interfaces = interfaces diff --git a/sleekxmpp/plugins/xep_0078/__init__.py b/sleekxmpp/plugins/xep_0078/__init__.py new file mode 100644 index 00000000..5a2bda77 --- /dev/null +++ b/sleekxmpp/plugins/xep_0078/__init__.py @@ -0,0 +1,12 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0078 import stanza +from sleekxmpp.plugins.xep_0078.stanza import IqAuth, AuthFeature +from sleekxmpp.plugins.xep_0078.legacyauth import xep_0078 + diff --git a/sleekxmpp/plugins/xep_0078/legacyauth.py b/sleekxmpp/plugins/xep_0078/legacyauth.py new file mode 100644 index 00000000..dec775a3 --- /dev/null +++ b/sleekxmpp/plugins/xep_0078/legacyauth.py @@ -0,0 +1,119 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import hashlib +import random + +from sleekxmpp.stanza import Iq, StreamFeatures +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0078 import stanza + + +log = logging.getLogger(__name__) + + +class xep_0078(base_plugin): + + """ + XEP-0078 NON-SASL Authentication + + This XEP is OBSOLETE in favor of using SASL, so DO NOT use this plugin + unless you are forced to use an old XMPP server implementation. + """ + + def plugin_init(self): + self.xep = "0078" + self.description = "Non-SASL Authentication" + self.stanza = stanza + + self.xmpp.register_feature('auth', + self._handle_auth, + restart=False, + order=self.config.get('order', 15)) + + register_stanza_plugin(Iq, stanza.IqAuth) + register_stanza_plugin(StreamFeatures, stanza.AuthFeature) + + + def _handle_auth(self, features): + # If we can or have already authenticated with SASL, do nothing. + if 'mechanisms' in features['features']: + return False + if self.xmpp.authenticated: + return False + + log.debug("Starting jabber:iq:auth Authentication") + + # Step 1: Request the auth form + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['to'] = self.xmpp.boundjid.host + iq['auth']['username'] = self.xmpp.boundjid.user + + try: + resp = iq.send(now=True) + except IqError: + log.info("Authentication failed: %s", resp['error']['condition']) + self.xmpp.event('failed_auth', direct=True) + self.xmpp.disconnect() + return True + except IqTimeout: + log.info("Authentication failed: %s", 'timeout') + self.xmpp.event('failed_auth', direct=True) + self.xmpp.disconnect() + return True + + # Step 2: Fill out auth form for either password or digest auth + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['auth']['username'] = self.xmpp.boundjid.user + + # A resource is required, so create a random one if necessary + if self.xmpp.boundjid.resource: + iq['auth']['resource'] = self.xmpp.boundjid.resource + else: + iq['auth']['resource'] = '%s' % random.random() + + if 'digest' in resp['auth']['fields']: + log.debug('Authenticating via jabber:iq:auth Digest') + if sys.version_info < (3, 0): + stream_id = bytes(self.xmpp.stream_id) + password = bytes(self.xmpp.password) + else: + stream_id = bytes(self.xmpp.stream_id, encoding='utf-8') + password = bytes(self.xmpp.password, encoding='utf-8') + + digest = hashlib.sha1(b'%s%s' % (stream_id, password)).hexdigest() + iq['auth']['digest'] = digest + else: + log.warning('Authenticating via jabber:iq:auth Plain.') + iq['auth']['password'] = self.xmpp.password + + # Step 3: Send credentials + try: + result = iq.send(now=True) + except IqError as err: + log.info("Authentication failed") + self.xmpp.disconnect() + self.xmpp.event("failed_auth", direct=True) + except IqTimeout: + log.info("Authentication failed") + self.xmpp.disconnect() + self.xmpp.event("failed_auth", direct=True) + + self.xmpp.features.add('auth') + + self.xmpp.authenticated = True + log.debug("Established Session") + self.xmpp.sessionstarted = True + self.xmpp.session_started_event.set() + self.xmpp.event('session_start') + + return True diff --git a/sleekxmpp/plugins/xep_0078/stanza.py b/sleekxmpp/plugins/xep_0078/stanza.py new file mode 100644 index 00000000..86ba09ad --- /dev/null +++ b/sleekxmpp/plugins/xep_0078/stanza.py @@ -0,0 +1,43 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class IqAuth(ElementBase): + namespace = 'jabber:iq:auth' + name = 'query' + plugin_attrib = 'auth' + interfaces = set(('fields', 'username', 'password', 'resource', 'digest')) + sub_interfaces = set(('username', 'password', 'resource', 'digest')) + plugin_tag_map = {} + plugin_attrib_map = {} + + def get_fields(self): + fields = set() + for field in self.sub_interfaces: + if self.xml.find('{%s}%s' % (self.namespace, field)) is not None: + fields.add(field) + return fields + + def set_resource(self, value): + self._set_sub_text('resource', value, keep=True) + + def set_password(self, value): + self._set_sub_text('password', value, keep=True) + + +class AuthFeature(ElementBase): + namespace = 'http://jabber.org/features/iq-auth' + name = 'auth' + plugin_attrib = 'auth' + interfaces = set() + plugin_tag_map = {} + plugin_attrib_map = {} + + diff --git a/sleekxmpp/plugins/xep_0082.py b/sleekxmpp/plugins/xep_0082.py new file mode 100644 index 00000000..25c80fd0 --- /dev/null +++ b/sleekxmpp/plugins/xep_0082.py @@ -0,0 +1,219 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import datetime as dt + +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.thirdparty import tzutc, tzoffset, parse_iso + + +# ===================================================================== +# To make it easier for stanzas without direct access to plugin objects +# to use the XEP-0082 utility methods, we will define them as top-level +# functions and then just reference them in the plugin itself. + +def parse(time_str): + """ + Convert a string timestamp into a datetime object. + + Arguments: + time_str -- A formatted timestamp string. + """ + return parse_iso(time_str) + + +def format_date(time_obj): + """ + Return a formatted string version of a date object. + + Format: + YYYY-MM-DD + + Arguments: + time_obj -- A date or datetime object. + """ + if isinstance(time_obj, dt.datetime): + time_obj = time_obj.date() + return time_obj.isoformat() + +def format_time(time_obj): + """ + Return a formatted string version of a time object. + + format: + hh:mm:ss[.sss][TZD] + + arguments: + time_obj -- A time or datetime object. + """ + if isinstance(time_obj, dt.datetime): + time_obj = time_obj.timetz() + timestamp = time_obj.isoformat() + if time_obj.tzinfo == tzutc(): + timestamp = timestamp[:-6] + return '%sZ' % timestamp + return timestamp + +def format_datetime(time_obj): + """ + Return a formatted string version of a datetime object. + + Format: + YYYY-MM-DDThh:mm:ss[.sss]TZD + + arguments: + time_obj -- A datetime object. + """ + timestamp = time_obj.isoformat('T') + if time_obj.tzinfo == tzutc(): + timestamp = timestamp[:-6] + return '%sZ' % timestamp + return timestamp + +def date(year=None, month=None, day=None, obj=False): + """ + Create a date only timestamp for the given instant. + + Unspecified components default to their current counterparts. + + Arguments: + year -- Integer value of the year (4 digits) + month -- Integer value of the month + day -- Integer value of the day of the month. + obj -- If True, return the date object instead + of a formatted string. Defaults to False. + """ + today = dt.datetime.utcnow() + if year is None: + year = today.year + if month is None: + month = today.month + if day is None: + day = today.day + value = dt.date(year, month, day) + if obj: + return value + return format_date(value) + +def time(hour=None, min=None, sec=None, micro=None, offset=None, obj=False): + """ + Create a time only timestamp for the given instant. + + Unspecified components default to their current counterparts. + + Arguments: + hour -- Integer value of the hour. + min -- Integer value of the number of minutes. + sec -- Integer value of the number of seconds. + micro -- Integer value of the number of microseconds. + offset -- Either a positive or negative number of seconds + to offset from UTC to match a desired timezone, + or a tzinfo object. + obj -- If True, return the time object instead + of a formatted string. Defaults to False. + """ + now = dt.datetime.utcnow() + if hour is None: + hour = now.hour + if min is None: + min = now.minute + if sec is None: + sec = now.second + if micro is None: + micro = now.microsecond + if offset is None: + offset = tzutc() + elif not isinstance(offset, dt.tzinfo): + offset = tzoffset(None, offset) + value = dt.time(hour, min, sec, micro, offset) + if obj: + return value + return format_time(value) + +def datetime(year=None, month=None, day=None, hour=None, + min=None, sec=None, micro=None, offset=None, + separators=True, obj=False): + """ + Create a datetime timestamp for the given instant. + + Unspecified components default to their current counterparts. + + Arguments: + year -- Integer value of the year (4 digits) + month -- Integer value of the month + day -- Integer value of the day of the month. + hour -- Integer value of the hour. + min -- Integer value of the number of minutes. + sec -- Integer value of the number of seconds. + micro -- Integer value of the number of microseconds. + offset -- Either a positive or negative number of seconds + to offset from UTC to match a desired timezone, + or a tzinfo object. + obj -- If True, return the datetime object instead + of a formatted string. Defaults to False. + """ + now = dt.datetime.utcnow() + if year is None: + year = now.year + if month is None: + month = now.month + if day is None: + day = now.day + if hour is None: + hour = now.hour + if min is None: + min = now.minute + if sec is None: + sec = now.second + if micro is None: + micro = now.microsecond + if offset is None: + offset = tzutc() + elif not isinstance(offset, dt.tzinfo): + offset = tzoffset(None, offset) + + value = dt.datetime(year, month, day, hour, + min, sec, micro, offset) + if obj: + return value + return format_datetime(value) + +class xep_0082(base_plugin): + + """ + XEP-0082: XMPP Date and Time Profiles + + XMPP uses a subset of the formats allowed by ISO 8601 as a matter of + pragmatism based on the relatively few formats historically used by + the XMPP. + + Also see <http://www.xmpp.org/extensions/xep-0082.html>. + + Methods: + date -- Create a time stamp using the Date profile. + datetime -- Create a time stamp using the DateTime profile. + time -- Create a time stamp using the Time profile. + format_date -- Format an existing date object. + format_datetime -- Format an existing datetime object. + format_time -- Format an existing time object. + parse -- Convert a time string into a Python datetime object. + """ + + def plugin_init(self): + """Start the XEP-0082 plugin.""" + self.xep = '0082' + self.description = 'XMPP Date and Time Profiles' + + self.date = date + self.datetime = datetime + self.time = time + self.format_date = format_date + self.format_datetime = format_datetime + self.format_time = format_time + self.parse = parse diff --git a/sleekxmpp/plugins/xep_0085/__init__.py b/sleekxmpp/plugins/xep_0085/__init__.py new file mode 100644 index 00000000..ff882f05 --- /dev/null +++ b/sleekxmpp/plugins/xep_0085/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permissio +""" + +from sleekxmpp.plugins.xep_0085.stanza import ChatState +from sleekxmpp.plugins.xep_0085.chat_states import xep_0085 diff --git a/sleekxmpp/plugins/xep_0085/chat_states.py b/sleekxmpp/plugins/xep_0085/chat_states.py new file mode 100644 index 00000000..e95434d2 --- /dev/null +++ b/sleekxmpp/plugins/xep_0085/chat_states.py @@ -0,0 +1,49 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permissio +""" + +import logging + +import sleekxmpp +from sleekxmpp.stanza import Message +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream import register_stanza_plugin, ElementBase, ET +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0085 import stanza, ChatState + + +log = logging.getLogger(__name__) + + +class xep_0085(base_plugin): + + """ + XEP-0085 Chat State Notifications + """ + + def plugin_init(self): + self.xep = '0085' + self.description = 'Chat State Notifications' + self.stanza = stanza + + for state in ChatState.states: + self.xmpp.register_handler( + Callback('Chat State: %s' % state, + StanzaPath('message@chat_state=%s' % state), + self._handle_chat_state)) + + register_stanza_plugin(Message, ChatState) + + def post_init(self): + base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature(ChatState.namespace) + + def _handle_chat_state(self, msg): + state = msg['chat_state'] + log.debug("Chat State: %s, %s", state, msg['from'].jid) + self.xmpp.event('chatstate_%s' % state, msg) diff --git a/sleekxmpp/plugins/xep_0085/stanza.py b/sleekxmpp/plugins/xep_0085/stanza.py new file mode 100644 index 00000000..8c46758c --- /dev/null +++ b/sleekxmpp/plugins/xep_0085/stanza.py @@ -0,0 +1,73 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permissio +""" + +import sleekxmpp +from sleekxmpp.xmlstream import ElementBase, ET + + +class ChatState(ElementBase): + + """ + Example chat state stanzas: + <message> + <active xmlns="http://jabber.org/protocol/chatstates" /> + </message> + + <message> + <paused xmlns="http://jabber.org/protocol/chatstates" /> + </message> + + Stanza Interfaces: + chat_state + + Attributes: + states + + Methods: + get_chat_state + set_chat_state + del_chat_state + """ + + name = '' + namespace = 'http://jabber.org/protocol/chatstates' + plugin_attrib = 'chat_state' + interfaces = set(('chat_state',)) + is_extension = True + + states = set(('active', 'composing', 'gone', 'inactive', 'paused')) + + def setup(self, xml=None): + self.xml = ET.Element('') + return True + + def get_chat_state(self): + parent = self.parent() + for state in self.states: + state_xml = parent.find('{%s}%s' % (self.namespace, state)) + if state_xml is not None: + self.xml = state_xml + return state + return '' + + def set_chat_state(self, state): + self.del_chat_state() + parent = self.parent() + if state in self.states: + self.xml = ET.Element('{%s}%s' % (self.namespace, state)) + parent.append(self.xml) + elif state not in [None, '']: + raise ValueError('Invalid chat state') + + def del_chat_state(self): + parent = self.parent() + for state in self.states: + state_xml = parent.find('{%s}%s' % (self.namespace, state)) + if state_xml is not None: + self.xml = ET.Element('') + parent.xml.remove(state_xml) diff --git a/sleekxmpp/plugins/xep_0086/__init__.py b/sleekxmpp/plugins/xep_0086/__init__.py new file mode 100644 index 00000000..b021e2b5 --- /dev/null +++ b/sleekxmpp/plugins/xep_0086/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0086.stanza import LegacyError +from sleekxmpp.plugins.xep_0086.legacy_error import xep_0086 diff --git a/sleekxmpp/plugins/xep_0086/legacy_error.py b/sleekxmpp/plugins/xep_0086/legacy_error.py new file mode 100644 index 00000000..25b98c5a --- /dev/null +++ b/sleekxmpp/plugins/xep_0086/legacy_error.py @@ -0,0 +1,42 @@ +"""
+ SleekXMPP: The Sleek XMPP Library
+ Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
+ This file is part of SleekXMPP.
+
+ See the file LICENSE for copying permission.
+"""
+
+from sleekxmpp.stanza import Error
+from sleekxmpp.xmlstream import register_stanza_plugin
+from sleekxmpp.plugins.base import base_plugin
+from sleekxmpp.plugins.xep_0086 import stanza, LegacyError
+
+
+class xep_0086(base_plugin):
+
+ """
+ XEP-0086: Error Condition Mappings
+
+ Older XMPP implementations used code based error messages, similar
+ to HTTP response codes. Since then, error condition elements have
+ been introduced. XEP-0086 provides a mapping between the new
+ condition elements and a combination of error types and the older
+ response codes.
+
+ Also see <http://xmpp.org/extensions/xep-0086.html>.
+
+ Configuration Values:
+ override -- Indicates if applying legacy error codes should
+ be done automatically. Defaults to True.
+ If False, then inserting legacy error codes can
+ be done using:
+ iq['error']['legacy']['condition'] = ...
+ """
+
+ def plugin_init(self):
+ self.xep = '0086'
+ self.description = 'Error Condition Mappings'
+ self.stanza = stanza
+
+ register_stanza_plugin(Error, LegacyError,
+ overrides=self.config.get('override', True))
diff --git a/sleekxmpp/plugins/xep_0086/stanza.py b/sleekxmpp/plugins/xep_0086/stanza.py new file mode 100644 index 00000000..6554d249 --- /dev/null +++ b/sleekxmpp/plugins/xep_0086/stanza.py @@ -0,0 +1,91 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Error +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class LegacyError(ElementBase): + + """ + Older XMPP implementations used code based error messages, similar + to HTTP response codes. Since then, error condition elements have + been introduced. XEP-0086 provides a mapping between the new + condition elements and a combination of error types and the older + response codes. + + Also see <http://xmpp.org/extensions/xep-0086.html>. + + Example legacy error stanzas: + <error xmlns="jabber:client" code="501" type="cancel"> + <feature-not-implemented + xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" /> + </error> + + <error code="402" type="auth"> + <payment-required + xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" /> + </error> + + Attributes: + error_map -- A map of error conditions to error types and + code values. + Methods: + setup -- Overrides ElementBase.setup + set_condition -- Remap the type and code interfaces when a + condition is set. + """ + + name = 'legacy' + namespace = Error.namespace + plugin_attrib = name + interfaces = set(('condition',)) + overrides = ['set_condition'] + + error_map = {'bad-request': ('modify','400'), + 'conflict': ('cancel','409'), + 'feature-not-implemented': ('cancel','501'), + 'forbidden': ('auth','403'), + 'gone': ('modify','302'), + 'internal-server-error': ('wait','500'), + 'item-not-found': ('cancel','404'), + 'jid-malformed': ('modify','400'), + 'not-acceptable': ('modify','406'), + 'not-allowed': ('cancel','405'), + 'not-authorized': ('auth','401'), + 'payment-required': ('auth','402'), + 'recipient-unavailable': ('wait','404'), + 'redirect': ('modify','302'), + 'registration-required': ('auth','407'), + 'remote-server-not-found': ('cancel','404'), + 'remote-server-timeout': ('wait','504'), + 'resource-constraint': ('wait','500'), + 'service-unavailable': ('cancel','503'), + 'subscription-required': ('auth','407'), + 'undefined-condition': (None,'500'), + 'unexpected-request': ('wait','400')} + + def setup(self, xml): + """Don't create XML for the plugin.""" + self.xml = ET.Element('') + + def set_condition(self, value): + """ + Set the error type and code based on the given error + condition value. + + Arguments: + value -- The new error condition. + """ + self.parent().set_condition(value) + + error_data = self.error_map.get(value, None) + if error_data is not None: + if error_data[0] is not None: + self.parent()['type'] = error_data[0] + self.parent()['code'] = error_data[1] diff --git a/sleekxmpp/plugins/xep_0092/__init__.py b/sleekxmpp/plugins/xep_0092/__init__.py new file mode 100644 index 00000000..7c5bdb76 --- /dev/null +++ b/sleekxmpp/plugins/xep_0092/__init__.py @@ -0,0 +1,11 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0092 import stanza +from sleekxmpp.plugins.xep_0092.stanza import Version +from sleekxmpp.plugins.xep_0092.version import xep_0092 diff --git a/sleekxmpp/plugins/xep_0092/stanza.py b/sleekxmpp/plugins/xep_0092/stanza.py new file mode 100644 index 00000000..77654e37 --- /dev/null +++ b/sleekxmpp/plugins/xep_0092/stanza.py @@ -0,0 +1,42 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class Version(ElementBase): + + """ + XMPP allows for an agent to advertise the name and version of the + underlying software libraries, as well as the operating system + that the agent is running on. + + Example version stanzas: + <iq type="get"> + <query xmlns="jabber:iq:version" /> + </iq> + + <iq type="result"> + <query xmlns="jabber:iq:version"> + <name>SleekXMPP</name> + <version>1.0</version> + <os>Linux</os> + </query> + </iq> + + Stanza Interface: + name -- The human readable name of the software. + version -- The specific version of the software. + os -- The name of the operating system running the program. + """ + + name = 'query' + namespace = 'jabber:iq:version' + plugin_attrib = 'software_version' + interfaces = set(('name', 'version', 'os')) + sub_interfaces = interfaces diff --git a/sleekxmpp/plugins/xep_0092/version.py b/sleekxmpp/plugins/xep_0092/version.py new file mode 100644 index 00000000..ba72a9c3 --- /dev/null +++ b/sleekxmpp/plugins/xep_0092/version.py @@ -0,0 +1,87 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0092 import Version + + +log = logging.getLogger(__name__) + + +class xep_0092(base_plugin): + + """ + XEP-0092: Software Version + """ + + def plugin_init(self): + """ + Start the XEP-0092 plugin. + """ + self.xep = "0092" + self.description = "Software Version" + self.stanza = sleekxmpp.plugins.xep_0092.stanza + + self.name = self.config.get('name', 'SleekXMPP') + self.version = self.config.get('version', sleekxmpp.__version__) + self.os = self.config.get('os', '') + + self.getVersion = self.get_version + + self.xmpp.register_handler( + Callback('Software Version', + StanzaPath('iq@type=get/software_version'), + self._handle_version)) + + register_stanza_plugin(Iq, Version) + + def post_init(self): + """ + Handle cross-plugin dependencies. + """ + base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature('jabber:iq:version') + + def _handle_version(self, iq): + """ + Respond to a software version query. + + Arguments: + iq -- The Iq stanza containing the software version query. + """ + iq.reply() + iq['software_version']['name'] = self.name + iq['software_version']['version'] = self.version + iq['software_version']['os'] = self.os + iq.send() + + def get_version(self, jid, ifrom=None): + """ + Retrieve the software version of a remote agent. + + Arguments: + jid -- The JID of the entity to query. + """ + iq = self.xmpp.Iq() + iq['to'] = jid + iq['from'] = ifrom + iq['type'] = 'get' + iq['query'] = Version.namespace + + result = iq.send() + + if result and result['type'] != 'error': + return result['software_version'].values + return False diff --git a/sleekxmpp/plugins/xep_0115/__init__.py b/sleekxmpp/plugins/xep_0115/__init__.py new file mode 100644 index 00000000..f4892f84 --- /dev/null +++ b/sleekxmpp/plugins/xep_0115/__init__.py @@ -0,0 +1,11 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0115.stanza import Capabilities +from sleekxmpp.plugins.xep_0115.static import StaticCaps +from sleekxmpp.plugins.xep_0115.caps import xep_0115 diff --git a/sleekxmpp/plugins/xep_0115/caps.py b/sleekxmpp/plugins/xep_0115/caps.py new file mode 100644 index 00000000..289bb8d1 --- /dev/null +++ b/sleekxmpp/plugins/xep_0115/caps.py @@ -0,0 +1,306 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import hashlib +import base64 + +import sleekxmpp +from sleekxmpp.stanza import StreamFeatures, Presence, Iq +from sleekxmpp.xmlstream import register_stanza_plugin, JID +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.exceptions import XMPPError, IqError, IqTimeout +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0115 import stanza, StaticCaps + + +log = logging.getLogger(__name__) + + +class xep_0115(base_plugin): + + """ + XEP-0115: Entity Capabalities + """ + + def plugin_init(self): + self.xep = '0115' + self.description = 'Entity Capabilities' + self.stanza = stanza + + self.hashes = {'sha-1': hashlib.sha1, + 'md5': hashlib.md5} + + self.hash = self.config.get('hash', 'sha-1') + self.caps_node = self.config.get('caps_node', None) + self.broadcast = self.config.get('broadcast', True) + + if self.caps_node is None: + ver = sleekxmpp.__version__ + self.caps_node = 'http://sleekxmpp.com/ver/%s' % ver + + register_stanza_plugin(Presence, stanza.Capabilities) + register_stanza_plugin(StreamFeatures, stanza.Capabilities) + + self._disco_ops = ['cache_caps', + 'get_caps', + 'assign_verstring', + 'get_verstring', + 'supports', + 'has_identity'] + + self.xmpp.register_handler( + Callback('Entity Capabilites', + StanzaPath('presence/caps'), + self._handle_caps)) + + self.xmpp.add_filter('out', self._filter_add_caps) + + self.xmpp.add_event_handler('entity_caps', self._process_caps, + threaded=True) + + if not self.xmpp.is_component: + self.xmpp.register_feature('caps', + self._handle_caps_feature, + restart=False, + order=10010) + + def post_init(self): + base_plugin.post_init(self) + self.xmpp['xep_0030'].add_feature(stanza.Capabilities.namespace) + + disco = self.xmpp['xep_0030'] + self.static = StaticCaps(self.xmpp, disco.static) + + for op in self._disco_ops: + disco._add_disco_op(op, getattr(self.static, op)) + + self._run_node_handler = disco._run_node_handler + + disco.cache_caps = self.cache_caps + disco.update_caps = self.update_caps + disco.assign_verstring = self.assign_verstring + disco.get_verstring = self.get_verstring + + def _filter_add_caps(self, stanza): + if isinstance(stanza, Presence) and self.broadcast: + ver = self.get_verstring(stanza['from']) + if ver: + stanza['caps']['node'] = self.caps_node + stanza['caps']['hash'] = self.hash + stanza['caps']['ver'] = ver + return stanza + + def _handle_caps(self, presence): + if not self.xmpp.is_component: + if presence['from'] == self.xmpp.boundjid: + return + self.xmpp.event('entity_caps', presence) + + def _handle_caps_feature(self, features): + # We already have a method to process presence with + # caps, so wrap things up and use that. + p = Presence() + p['from'] = self.xmpp.boundjid.domain + p.append(features['caps']) + self.xmpp.features.add('caps') + + self.xmpp.event('entity_caps', p) + + def _process_caps(self, pres): + if not pres['caps']['hash']: + log.debug("Received unsupported legacy caps.") + self.xmpp.event('entity_caps_legacy', pres) + return + + existing_verstring = self.get_verstring(pres['from'].full) + if str(existing_verstring) == str(pres['caps']['ver']): + return + + if pres['caps']['hash'] not in self.hashes: + try: + log.debug("Unknown caps hash: %s", pres['caps']['hash']) + self.xmpp['xep_003'].get_info(jid=pres['from'].full) + return + except XMPPError: + return + + log.debug("New caps verification string: %s", pres['caps']['ver']) + try: + caps = self.xmpp['xep_0030'].get_info( + jid=pres['from'].full, + node='%s#%s' % (pres['caps']['node'], + pres['caps']['ver'])) + + if self._validate_caps(caps['disco_info'], + pres['caps']['hash'], + pres['caps']['ver']): + self.assign_verstring(pres['from'], pres['caps']['ver']) + except XMPPError: + log.debug("Could not retrieve disco#info results for caps") + + def _validate_caps(self, caps, hash, check_verstring): + # Check Identities + full_ids = caps.get_identities(dedupe=False) + deduped_ids = caps.get_identities() + if len(full_ids) != len(deduped_ids): + log.debug("Duplicate disco identities found, invalid for caps") + return False + + # Check Features + + full_features = caps.get_features(dedupe=False) + deduped_features = caps.get_features() + if len(full_features) != len(deduped_features): + log.debug("Duplicate disco features found, invalid for caps") + return False + + # Check Forms + form_types = [] + deduped_form_types = set() + for stanza in caps['substanzas']: + if isinstance(stanza, self.xmpp['xep_0004'].stanza.Form): + if 'FORM_TYPE' in stanza['fields']: + f_type = tuple(stanza['fields']['FORM_TYPE']['value']) + form_types.append(f_type) + deduped_form_types.add(f_type) + if len(form_types) != len(deduped_form_types): + log.debug("Duplicated FORM_TYPE values, invalid for caps") + return False + + if len(f_type) > 1: + deduped_type = set(f_type) + if len(f_type) != len(deduped_type): + log.debug("Extra FORM_TYPE data, invalid for caps") + return False + + if stanza['fields']['FORM_TYPE']['type'] != 'hidden': + log.debug("Field FORM_TYPE type not 'hidden', ignoring form for caps") + caps.xml.remove(stanza.xml) + else: + log.debug("No FORM_TYPE found, ignoring form for caps") + caps.xml.remove(stanza.xml) + + verstring = self.generate_verstring(caps, hash) + if verstring != check_verstring: + log.debug("Verification strings do not match: %s, %s" % ( + verstring, check_verstring)) + return False + + self.cache_caps(verstring, caps) + return True + + def generate_verstring(self, info, hash): + hash = self.hashes.get(hash, None) + if hash is None: + return None + + S = '' + + # Convert None to '' in the identities + def clean_identity(id): + return map(lambda i: i or '', id) + identities = map(clean_identity, info['identities']) + + identities = sorted(('/'.join(i) for i in identities)) + features = sorted(info['features']) + + S += '<'.join(identities) + '<' + S += '<'.join(features) + '<' + + form_types = {} + + for stanza in info['substanzas']: + if isinstance(stanza, self.xmpp['xep_0004'].stanza.Form): + if 'FORM_TYPE' in stanza['fields']: + f_type = stanza['values']['FORM_TYPE'] + if len(f_type): + f_type = f_type[0] + if f_type not in form_types: + form_types[f_type] = [] + form_types[f_type].append(stanza) + + sorted_forms = sorted(form_types.keys()) + for f_type in sorted_forms: + for form in form_types[f_type]: + S += '%s<' % f_type + fields = sorted(form['fields'].keys()) + fields.remove('FORM_TYPE') + for field in fields: + S += '%s<' % field + vals = form['fields'][field].get_value(convert=False) + if vals is None: + S += '<' + else: + if not isinstance(vals, list): + vals = [vals] + S += '<'.join(sorted(vals)) + '<' + + binary = hash(S.encode('utf8')).digest() + return base64.b64encode(binary).decode('utf-8') + + def update_caps(self, jid=None, node=None): + try: + info = self.xmpp['xep_0030'].get_info(jid, node, local=True) + if isinstance(info, Iq): + info = info['disco_info'] + ver = self.generate_verstring(info, self.hash) + self.xmpp['xep_0030'].set_info( + jid=jid, + node='%s#%s' % (self.caps_node, ver), + info=info) + self.cache_caps(ver, info) + self.assign_verstring(jid, ver) + + if self.broadcast: + # Check if we've sent directed presence. If we haven't, we + # can just send a normal presence stanza. If we have, then + # we will send presence to each contact individually so + # that we don't clobber existing statuses. + directed = False + for contact in self.xmpp.roster[jid]: + if self.xmpp.roster[jid][contact].last_status is not None: + directed = True + if not directed: + self.xmpp.roster[jid].send_last_presence() + else: + for contact in self.xmpp.roster[jid]: + self.xmpp.roster[jid][contact].send_last_presence() + except XMPPError: + return + + def get_verstring(self, jid=None): + if jid in ('', None): + jid = self.xmpp.boundjid.full + if isinstance(jid, JID): + jid = jid.full + return self._run_node_handler('get_verstring', jid) + + def assign_verstring(self, jid=None, verstring=None): + if jid in (None, ''): + jid = self.xmpp.boundjid.full + if isinstance(jid, JID): + jid = jid.full + return self._run_node_handler('assign_verstring', jid, + data={'verstring': verstring}) + + def cache_caps(self, verstring=None, info=None): + data = {'verstring': verstring, 'info': info} + return self._run_node_handler('cache_caps', None, None, data=data) + + def get_caps(self, jid=None, verstring=None): + if verstring is None: + if jid is not None: + verstring = self.get_verstring(jid) + else: + return None + if isinstance(jid, JID): + jid = jid.full + data = {'verstring': verstring} + return self._run_node_handler('get_caps', jid, None, None, data) diff --git a/sleekxmpp/plugins/xep_0115/stanza.py b/sleekxmpp/plugins/xep_0115/stanza.py new file mode 100644 index 00000000..af02949b --- /dev/null +++ b/sleekxmpp/plugins/xep_0115/stanza.py @@ -0,0 +1,19 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from __future__ import unicode_literals + +from sleekxmpp.xmlstream import ElementBase, ET + + +class Capabilities(ElementBase): + + namespace = 'http://jabber.org/protocol/caps' + name = 'c' + plugin_attrib = 'caps' + interfaces = set(('hash', 'node', 'ver', 'ext')) diff --git a/sleekxmpp/plugins/xep_0115/static.py b/sleekxmpp/plugins/xep_0115/static.py new file mode 100644 index 00000000..204181d5 --- /dev/null +++ b/sleekxmpp/plugins/xep_0115/static.py @@ -0,0 +1,147 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp.xmlstream import JID +from sleekxmpp.plugins.xep_0030 import StaticDisco + + +log = logging.getLogger(__name__) + + +class StaticCaps(object): + + """ + Extend the default StaticDisco implementation to provide + support for extended identity information. + """ + + def __init__(self, xmpp, static): + """ + Augment the default XEP-0030 static handler object. + + Arguments: + static -- The default static XEP-0030 handler object. + """ + self.xmpp = xmpp + self.disco = self.xmpp['xep_0030'] + self.caps = self.xmpp['xep_0115'] + self.static = static + self.ver_cache = {} + self.jid_vers = {} + + def supports(self, jid, node, ifrom, data): + """ + Check if a JID supports a given feature. + + The data parameter may provide: + feature -- The feature to check for support. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the info. + cached -- If true, then look for the disco info data from + the local cache system. If no results are found, + send the query as usual. The self.use_cache + setting must be set to true for this option to + be useful. If set to false, then the cache will + be skipped, even if a result has already been + cached. Defaults to false. + """ + feature = data.get('feature', None) + + data = {'local': data.get('local', False), + 'cached': data.get('cached', True)} + + if not feature: + return False + + if node in (None, ''): + info = self.caps.get_caps(jid) + if info and feature in info['features']: + return True + + try: + info = self.disco.get_info(jid=jid, node=node, + ifrom=ifrom, **data) + info = self.disco._wrap(ifrom, jid, info, True) + return feature in info['disco_info']['features'] + except IqError: + return False + except IqTimeout: + return None + + def has_identity(self, jid, node, ifrom, data): + """ + Check if a JID has a given identity. + + The data parameter may provide: + category -- The category of the identity to check. + itype -- The type of the identity to check. + lang -- The language of the identity to check. + local -- If true, then the query is for a JID/node + combination handled by this Sleek instance and + no stanzas need to be sent. + Otherwise, a disco stanza must be sent to the + remove JID to retrieve the info. + cached -- If true, then look for the disco info data from + the local cache system. If no results are found, + send the query as usual. The self.use_cache + setting must be set to true for this option to + be useful. If set to false, then the cache will + be skipped, even if a result has already been + cached. Defaults to false. + """ + identity = (data.get('category', None), + data.get('itype', None), + data.get('lang', None)) + + data = {'local': data.get('local', False), + 'cached': data.get('cached', True)} + + trunc = lambda i: (i[0], i[1], i[2]) + + if node in (None, ''): + info = self.caps.get_caps(jid) + if info and identity in map(trunc, info['identities']): + return True + + try: + info = self.disco.get_info(jid=jid, node=node, + ifrom=ifrom, **data) + info = self.disco._wrap(ifrom, jid, info, True) + return identity in map(trunc, info['disco_info']['identities']) + except IqError: + return False + except IqTimeout: + return None + + def cache_caps(self, jid, node, ifrom, data): + with self.static.lock: + verstring = data.get('verstring', None) + info = data.get('info', None) + if not verstring or not info: + return + self.ver_cache[verstring] = info + + def assign_verstring(self, jid, node, ifrom, data): + with self.static.lock: + if isinstance(jid, JID): + jid = jid.full + self.jid_vers[jid] = data.get('verstring', None) + + def get_verstring(self, jid, node, ifrom, data): + with self.static.lock: + return self.jid_vers.get(jid, None) + + def get_caps(self, jid, node, ifrom, data): + with self.static.lock: + return self.ver_cache.get(data.get('verstring', None), None) diff --git a/sleekxmpp/plugins/xep_0128/__init__.py b/sleekxmpp/plugins/xep_0128/__init__.py new file mode 100644 index 00000000..3c6379a3 --- /dev/null +++ b/sleekxmpp/plugins/xep_0128/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0128.static import StaticExtendedDisco +from sleekxmpp.plugins.xep_0128.extended_disco import xep_0128 diff --git a/sleekxmpp/plugins/xep_0128/extended_disco.py b/sleekxmpp/plugins/xep_0128/extended_disco.py new file mode 100644 index 00000000..5bb78320 --- /dev/null +++ b/sleekxmpp/plugins/xep_0128/extended_disco.py @@ -0,0 +1,101 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0004 import Form +from sleekxmpp.plugins.xep_0030 import DiscoInfo +from sleekxmpp.plugins.xep_0128 import StaticExtendedDisco + + +class xep_0128(base_plugin): + + """ + XEP-0128: Service Discovery Extensions + + Allow the use of data forms to add additional identity + information to disco#info results. + + Also see <http://www.xmpp.org/extensions/xep-0128.html>. + + Attributes: + disco -- A reference to the XEP-0030 plugin. + static -- Object containing the default set of static + node handlers. + xmpp -- The main SleekXMPP object. + + Methods: + set_extended_info -- Set extensions to a disco#info result. + add_extended_info -- Add an extension to a disco#info result. + del_extended_info -- Remove all extensions from a disco#info result. + """ + + def plugin_init(self): + """Start the XEP-0128 plugin.""" + self.xep = '0128' + self.description = 'Service Discovery Extensions' + + self._disco_ops = ['set_extended_info', + 'add_extended_info', + 'del_extended_info'] + + register_stanza_plugin(DiscoInfo, Form, iterable=True) + + def post_init(self): + """Handle cross-plugin dependencies.""" + base_plugin.post_init(self) + self.disco = self.xmpp['xep_0030'] + self.static = StaticExtendedDisco(self.disco.static) + + self.disco.set_extended_info = self.set_extended_info + self.disco.add_extended_info = self.add_extended_info + self.disco.del_extended_info = self.del_extended_info + + for op in self._disco_ops: + self.disco._add_disco_op(op, getattr(self.static, op)) + + def set_extended_info(self, jid=None, node=None, **kwargs): + """ + Set additional, extended identity information to a node. + + Replaces any existing extended information. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + data -- Either a form, or a list of forms to use + as extended information, replacing any + existing extensions. + """ + self.disco._run_node_handler('set_extended_info', jid, node, None, kwargs) + + def add_extended_info(self, jid=None, node=None, **kwargs): + """ + Add additional, extended identity information to a node. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + data -- Either a form, or a list of forms to add + as extended information. + """ + self.disco._run_node_handler('add_extended_info', jid, node, None, kwargs) + + def del_extended_info(self, jid=None, node=None, **kwargs): + """ + Remove all extended identity information to a node. + + Arguments: + jid -- The JID to modify. + node -- The node to modify. + """ + self.disco._run_node_handler('del_extended_info', jid, node, None, kwargs) diff --git a/sleekxmpp/plugins/xep_0128/static.py b/sleekxmpp/plugins/xep_0128/static.py new file mode 100644 index 00000000..427011c0 --- /dev/null +++ b/sleekxmpp/plugins/xep_0128/static.py @@ -0,0 +1,73 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp.plugins.xep_0030 import StaticDisco + + +log = logging.getLogger(__name__) + + +class StaticExtendedDisco(object): + + """ + Extend the default StaticDisco implementation to provide + support for extended identity information. + """ + + def __init__(self, static): + """ + Augment the default XEP-0030 static handler object. + + Arguments: + static -- The default static XEP-0030 handler object. + """ + self.static = static + + def set_extended_info(self, jid, node, ifrom, data): + """ + Replace the extended identity data for a JID/node combination. + + The data parameter may provide: + data -- Either a single data form, or a list of data forms. + """ + with self.static.lock: + self.del_extended_info(jid, node, ifrom, data) + self.add_extended_info(jid, node, ifrom, data) + + def add_extended_info(self, jid, node, ifrom, data): + """ + Add additional extended identity data for a JID/node combination. + + The data parameter may provide: + data -- Either a single data form, or a list of data forms. + """ + with self.static.lock: + self.static.add_node(jid, node) + + forms = data.get('data', []) + if not isinstance(forms, list): + forms = [forms] + + info = self.static.get_node(jid, node)['info'] + for form in forms: + info.append(form) + + def del_extended_info(self, jid, node, ifrom, data): + """ + Replace the extended identity data for a JID/node combination. + + The data parameter is not used. + """ + with self.static.lock: + if self.static.node_exists(jid, node): + info = self.static.get_node(jid, node)['info'] + for form in info['substanza']: + info.xml.remove(form.xml) diff --git a/sleekxmpp/plugins/xep_0199/__init__.py b/sleekxmpp/plugins/xep_0199/__init__.py new file mode 100644 index 00000000..3444fe94 --- /dev/null +++ b/sleekxmpp/plugins/xep_0199/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0199.stanza import Ping +from sleekxmpp.plugins.xep_0199.ping import xep_0199 diff --git a/sleekxmpp/plugins/xep_0199/ping.py b/sleekxmpp/plugins/xep_0199/ping.py new file mode 100644 index 00000000..a0f60532 --- /dev/null +++ b/sleekxmpp/plugins/xep_0199/ping.py @@ -0,0 +1,175 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import time +import logging + +import sleekxmpp +from sleekxmpp import Iq +from sleekxmpp.exceptions import IqError, IqTimeout +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0199 import stanza, Ping + + +log = logging.getLogger(__name__) + + +class xep_0199(base_plugin): + + """ + XEP-0199: XMPP Ping + + Given that XMPP is based on TCP connections, it is possible for the + underlying connection to be terminated without the application's + awareness. Ping stanzas provide an alternative to whitespace based + keepalive methods for detecting lost connections. + + Also see <http://www.xmpp.org/extensions/xep-0199.html>. + + Attributes: + keepalive -- If True, periodically send ping requests + to the server. If a ping is not answered, + the connection will be reset. + frequency -- Time in seconds between keepalive pings. + Defaults to 300 seconds. + timeout -- Time in seconds to wait for a ping response. + Defaults to 30 seconds. + Methods: + send_ping -- Send a ping to a given JID, returning the + round trip time. + """ + + def plugin_init(self): + """ + Start the XEP-0199 plugin. + """ + self.description = 'XMPP Ping' + self.xep = '0199' + self.stanza = stanza + + self.keepalive = self.config.get('keepalive', False) + self.frequency = float(self.config.get('frequency', 300)) + self.timeout = self.config.get('timeout', 30) + + register_stanza_plugin(Iq, Ping) + + self.xmpp.register_handler( + Callback('Ping', + StanzaPath('iq@type=get/ping'), + self._handle_ping)) + + if self.keepalive: + self.xmpp.add_event_handler('session_start', + self._handle_keepalive, + threaded=True) + self.xmpp.add_event_handler('session_end', + self._handle_session_end) + + def post_init(self): + """Handle cross-plugin dependencies.""" + base_plugin.post_init(self) + self.xmpp['xep_0030'].add_feature(Ping.namespace) + + def _handle_keepalive(self, event): + """ + Begin periodic pinging of the server. If a ping is not + answered, the connection will be restarted. + + The pinging interval can be adjused using self.frequency + before beginning processing. + + Arguments: + event -- The session_start event. + """ + def scheduled_ping(): + """Send ping request to the server.""" + log.debug("Pinging...") + try: + self.send_ping(self.xmpp.boundjid.host, self.timeout) + except IqError: + log.debug("Ping response was an error." + \ + "Requesting Reconnect.") + self.xmpp.reconnect() + except IqTimeout: + log.debug("Did not recieve ping back in time." + \ + "Requesting Reconnect.") + self.xmpp.reconnect() + + self.xmpp.schedule('Ping Keep Alive', + self.frequency, + scheduled_ping, + repeat=True) + + def _handle_session_end(self, event): + self.xmpp.scheduler.remove('Ping Keep Alive') + + def _handle_ping(self, iq): + """ + Automatically reply to ping requests. + + Arguments: + iq -- The ping request. + """ + log.debug("Pinged by %s", iq['from']) + iq.reply().send() + + def send_ping(self, jid, timeout=None, errorfalse=False, + ifrom=None, block=True, callback=None): + """ + Send a ping request and calculate the response time. + + Arguments: + jid -- The JID that will receive the ping. + timeout -- Time in seconds to wait for a response. + Defaults to self.timeout. + errorfalse -- Indicates if False should be returned + if an error stanza is received. Defaults + to False. + ifrom -- Specifiy the sender JID. + block -- Indicate if execution should block until + a pong response is received. Defaults + to True. + callback -- Optional handler to execute when a pong + is received. Useful in conjunction with + the option block=False. + """ + log.debug("Pinging %s", jid) + if timeout is None: + timeout = self.timeout + + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['to'] = jid + iq['from'] = ifrom + iq.enable('ping') + + start_time = time.clock() + + try: + resp = iq.send(block=block, + timeout=timeout, + callback=callback) + except IqError as err: + resp = err.iq + + end_time = time.clock() + + delay = end_time - start_time + + if not block: + return None + + log.debug("Pong: %s %f", jid, delay) + return delay + + +# Backwards compatibility for names +xep_0199.sendPing = xep_0199.send_ping diff --git a/sleekxmpp/plugins/xep_0199/stanza.py b/sleekxmpp/plugins/xep_0199/stanza.py new file mode 100644 index 00000000..6586a763 --- /dev/null +++ b/sleekxmpp/plugins/xep_0199/stanza.py @@ -0,0 +1,36 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import sleekxmpp +from sleekxmpp.xmlstream import ElementBase + + +class Ping(ElementBase): + + """ + Given that XMPP is based on TCP connections, it is possible for the + underlying connection to be terminated without the application's + awareness. Ping stanzas provide an alternative to whitespace based + keepalive methods for detecting lost connections. + + Example ping stanza: + <iq type="get"> + <ping xmlns="urn:xmpp:ping" /> + </iq> + + Stanza Interface: + None + + Methods: + None + """ + + name = 'ping' + namespace = 'urn:xmpp:ping' + plugin_attrib = 'ping' + interfaces = set() diff --git a/sleekxmpp/plugins/xep_0202/__init__.py b/sleekxmpp/plugins/xep_0202/__init__.py new file mode 100644 index 00000000..a34b2376 --- /dev/null +++ b/sleekxmpp/plugins/xep_0202/__init__.py @@ -0,0 +1,12 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +from sleekxmpp.plugins.xep_0202 import stanza +from sleekxmpp.plugins.xep_0202.stanza import EntityTime +from sleekxmpp.plugins.xep_0202.time import xep_0202 diff --git a/sleekxmpp/plugins/xep_0202/stanza.py b/sleekxmpp/plugins/xep_0202/stanza.py new file mode 100644 index 00000000..b6ccc960 --- /dev/null +++ b/sleekxmpp/plugins/xep_0202/stanza.py @@ -0,0 +1,127 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import datetime as dt + +from sleekxmpp.xmlstream import ElementBase +from sleekxmpp.plugins import xep_0082 +from sleekxmpp.thirdparty import tzutc, tzoffset + + +class EntityTime(ElementBase): + + """ + The <time> element represents the local time for an XMPP agent. + The time is expressed in UTC to make synchronization easier + between entities, but the offset for the local timezone is also + included. + + Example <time> stanzas: + <iq type="result"> + <time xmlns="urn:xmpp:time"> + <utc>2011-07-03T11:37:12.234569</utc> + <tzo>-07:00</tzo> + </time> + </iq> + + Stanza Interface: + time -- The local time for the entity (updates utc and tzo). + utc -- The UTC equivalent to local time. + tzo -- The local timezone offset from UTC. + + Methods: + get_time -- Return local time datetime object. + set_time -- Set UTC and TZO fields. + del_time -- Remove both UTC and TZO fields. + get_utc -- Return datetime object of UTC time. + set_utc -- Set the UTC time. + get_tzo -- Return tzinfo object. + set_tzo -- Set the local timezone offset. + """ + + name = 'time' + namespace = 'urn:xmpp:time' + plugin_attrib = 'entity_time' + interfaces = set(('tzo', 'utc', 'time')) + sub_interfaces = interfaces + + def set_time(self, value): + """ + Set both the UTC and TZO fields given a time object. + + Arguments: + value -- A datetime object or properly formatted + string equivalent. + """ + date = value + if not isinstance(value, dt.datetime): + date = xep_0082.parse(value) + self['utc'] = date + self['tzo'] = date.tzinfo + + def get_time(self): + """ + Return the entity's local time based on the UTC and TZO data. + """ + date = self['utc'] + tz = self['tzo'] + return date.astimezone(tz) + + def del_time(self): + """Remove both the UTC and TZO fields.""" + del self['utc'] + del self['tzo'] + + def get_tzo(self): + """ + Return the timezone offset from UTC as a tzinfo object. + """ + tzo = self._get_sub_text('tzo') + if tzo == '': + tzo = 'Z' + time = xep_0082.parse('00:00:00%s' % tzo) + return time.tzinfo + + def set_tzo(self, value): + """ + Set the timezone offset from UTC. + + Arguments: + value -- Either a tzinfo object or the number of + seconds (positive or negative) to offset. + """ + time = xep_0082.time(offset=value) + if xep_0082.parse(time).tzinfo == tzutc(): + self._set_sub_text('tzo', 'Z') + else: + self._set_sub_text('tzo', time[-6:]) + + def get_utc(self): + """ + Return the time in UTC as a datetime object. + """ + value = self._get_sub_text('utc') + if value == '': + return xep_0082.parse(xep_0082.datetime()) + return xep_0082.parse('%sZ' % value) + + def set_utc(self, value): + """ + Set the time in UTC. + + Arguments: + value -- A datetime object or properly formatted + string equivalent. + """ + date = value + if not isinstance(value, dt.datetime): + date = xep_0082.parse(value) + date = date.astimezone(tzutc()) + value = xep_0082.format_datetime(date)[:-1] + self._set_sub_text('utc', value) diff --git a/sleekxmpp/plugins/xep_0202/time.py b/sleekxmpp/plugins/xep_0202/time.py new file mode 100644 index 00000000..2c6faa4b --- /dev/null +++ b/sleekxmpp/plugins/xep_0202/time.py @@ -0,0 +1,91 @@ +"""
+ SleekXMPP: The Sleek XMPP Library
+ Copyright (C) 2010 Nathanael C. Fritz
+ This file is part of SleekXMPP.
+
+ See the file LICENSE for copying permission.
+"""
+
+import logging
+
+from sleekxmpp.stanza.iq import Iq
+from sleekxmpp.xmlstream import register_stanza_plugin
+from sleekxmpp.xmlstream.handler import Callback
+from sleekxmpp.xmlstream.matcher import StanzaPath
+from sleekxmpp.plugins.base import base_plugin
+from sleekxmpp.plugins import xep_0082
+from sleekxmpp.plugins.xep_0202 import stanza
+
+
+log = logging.getLogger(__name__)
+
+
+class xep_0202(base_plugin):
+
+ """
+ XEP-0202: Entity Time
+ """
+
+ def plugin_init(self):
+ """Start the XEP-0203 plugin."""
+ self.xep = '0202'
+ self.description = 'Entity Time'
+ self.stanza = stanza
+
+ self.tz_offset = self.config.get('tz_offset', 0)
+
+ # As a default, respond to time requests with the
+ # local time returned by XEP-0082. However, a
+ # custom function can be supplied which accepts
+ # the JID of the entity to query for the time.
+ self.local_time = self.config.get('local_time', None)
+ if not self.local_time:
+ self.local_time = lambda x: xep_0082.datetime(offset=self.tz_offset)
+
+ self.xmpp.registerHandler(
+ Callback('Entity Time',
+ StanzaPath('iq/entity_time'),
+ self._handle_time_request))
+ register_stanza_plugin(Iq, stanza.EntityTime)
+
+ def post_init(self):
+ """Handle cross-plugin interactions."""
+ base_plugin.post_init(self)
+ self.xmpp['xep_0030'].add_feature('urn:xmpp:time')
+
+
+ def _handle_time_request(self, iq):
+ """
+ Respond to a request for the local time.
+
+ The time is taken from self.local_time(), which may be replaced
+ during plugin configuration with a function that maps JIDs to
+ times.
+
+ Arguments:
+ iq -- The Iq time request stanza.
+ """
+ iq.reply()
+ iq['entity_time']['time'] = self.local_time(iq['to'])
+ iq.send()
+
+ def get_entity_time(self, to, ifrom=None, **iqargs):
+ """
+ Request the time from another entity.
+
+ Arguments:
+ to -- JID of the entity to query.
+ ifrom -- Specifiy the sender's JID.
+ block -- If true, block and wait for the stanzas' reply.
+ timeout -- The time in seconds to block while waiting for
+ a reply. If None, then wait indefinitely.
+ callback -- Optional callback to execute when a reply is
+ received instead of blocking and waiting for
+ the reply.
+ """
+ iq = self.xmpp.Iq()
+ iq['type'] = 'get'
+ iq['to'] = to
+ iq['from'] = ifrom
+ iq.enable('entity_time')
+ return iq.send(**iqargs)
diff --git a/sleekxmpp/plugins/xep_0203/__init__.py b/sleekxmpp/plugins/xep_0203/__init__.py new file mode 100644 index 00000000..445ccf37 --- /dev/null +++ b/sleekxmpp/plugins/xep_0203/__init__.py @@ -0,0 +1,12 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0203 import stanza +from sleekxmpp.plugins.xep_0203.stanza import Delay +from sleekxmpp.plugins.xep_0203.delay import xep_0203 + diff --git a/sleekxmpp/plugins/xep_0203/delay.py b/sleekxmpp/plugins/xep_0203/delay.py new file mode 100644 index 00000000..8ff14d18 --- /dev/null +++ b/sleekxmpp/plugins/xep_0203/delay.py @@ -0,0 +1,36 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +from sleekxmpp.stanza import Message, Presence +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0203 import stanza + + +class xep_0203(base_plugin): + + """ + XEP-0203: Delayed Delivery + + XMPP stanzas are sometimes withheld for delivery due to the recipient + being offline, or are resent in order to establish recent history as + is the case with MUCS. In any case, it is important to know when the + stanza was originally sent, not just when it was last received. + + Also see <http://www.xmpp.org/extensions/xep-0203.html>. + """ + + def plugin_init(self): + """Start the XEP-0203 plugin.""" + self.xep = '0203' + self.description = 'Delayed Delivery' + self.stanza = stanza + + register_stanza_plugin(Message, stanza.Delay) + register_stanza_plugin(Presence, stanza.Delay) diff --git a/sleekxmpp/plugins/xep_0203/stanza.py b/sleekxmpp/plugins/xep_0203/stanza.py new file mode 100644 index 00000000..baae4cd3 --- /dev/null +++ b/sleekxmpp/plugins/xep_0203/stanza.py @@ -0,0 +1,41 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import datetime as dt + +from sleekxmpp.xmlstream import ElementBase +from sleekxmpp.plugins import xep_0082 + + +class Delay(ElementBase): + + """ + """ + + name = 'delay' + namespace = 'urn:xmpp:delay' + plugin_attrib = 'delay' + interfaces = set(('from', 'stamp', 'text')) + + def get_stamp(self): + timestamp = self._get_attr('stamp') + return xep_0082.parse(timestamp) + + def set_stamp(self, value): + if isinstance(value, dt.datetime): + value = xep_0082.format_datetime(value) + self._set_attr('stamp', value) + + def get_text(self): + return self.xml.text + + def set_text(self, value): + self.xml.text = value + + def del_text(self): + self.xml.text = '' diff --git a/sleekxmpp/plugins/xep_0224/__init__.py b/sleekxmpp/plugins/xep_0224/__init__.py new file mode 100644 index 00000000..62f5bf82 --- /dev/null +++ b/sleekxmpp/plugins/xep_0224/__init__.py @@ -0,0 +1,11 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0224 import stanza +from sleekxmpp.plugins.xep_0224.stanza import Attention +from sleekxmpp.plugins.xep_0224.attention import xep_0224 diff --git a/sleekxmpp/plugins/xep_0224/attention.py b/sleekxmpp/plugins/xep_0224/attention.py new file mode 100644 index 00000000..4a3ff368 --- /dev/null +++ b/sleekxmpp/plugins/xep_0224/attention.py @@ -0,0 +1,72 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +from sleekxmpp.stanza import Message +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.plugins.xep_0224 import stanza + + +log = logging.getLogger(__name__) + + +class xep_0224(base_plugin): + + """ + XEP-0224: Attention + """ + + def plugin_init(self): + """Start the XEP-0224 plugin.""" + self.xep = '0224' + self.description = 'Attention' + self.stanza = stanza + + register_stanza_plugin(Message, stanza.Attention) + + self.xmpp.register_handler( + Callback('Attention', + StanzaPath('message/attention'), + self._handle_attention)) + + def post_init(self): + """Handle cross-plugin dependencies.""" + base_plugin.post_init(self) + self.xmpp['xep_0030'].add_feature(stanza.Attention.namespace) + + def request_attention(self, to, mfrom=None, mbody=''): + """ + Send an attention message with an optional body. + + Arguments: + to -- The attention request recipient's JID. + mfrom -- Optionally specify the sender of the attention request. + mbody -- An optional message body to include in the request. + """ + m = self.xmpp.Message() + m['to'] = to + m['type'] = 'headline' + m['attention'] = True + if mfrom: + m['from'] = mfrom + m['body'] = mbody + m.send() + + def _handle_attention(self, msg): + """ + Raise an event after receiving a message with an attention request. + + Arguments: + msg -- A message stanza with an attention element. + """ + log.debug("Received attention request from: %s", msg['from']) + self.xmpp.event('attention', msg) diff --git a/sleekxmpp/plugins/xep_0224/stanza.py b/sleekxmpp/plugins/xep_0224/stanza.py new file mode 100644 index 00000000..f15172d9 --- /dev/null +++ b/sleekxmpp/plugins/xep_0224/stanza.py @@ -0,0 +1,40 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET + + +class Attention(ElementBase): + + """ + """ + + name = 'attention' + namespace = 'urn:xmpp:attention:0' + plugin_attrib = 'attention' + interfaces = set(('attention',)) + is_extension = True + + def setup(self, xml): + return True + + def set_attention(self, value): + if value: + xml = ET.Element(self.tag_name()) + self.parent().xml.append(xml) + else: + self.del_attention() + + def get_attention(self): + xml = self.parent().xml.find(self.tag_name()) + return xml is not None + + def del_attention(self): + xml = self.parent().xml.find(self.tag_name()) + if xml is not None: + self.parent().xml.remove(xml) diff --git a/sleekxmpp/plugins/xep_0249/__init__.py b/sleekxmpp/plugins/xep_0249/__init__.py new file mode 100644 index 00000000..e88d87ac --- /dev/null +++ b/sleekxmpp/plugins/xep_0249/__init__.py @@ -0,0 +1,10 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dalek + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.plugins.xep_0249.stanza import Invite +from sleekxmpp.plugins.xep_0249.invite import xep_0249 diff --git a/sleekxmpp/plugins/xep_0249/invite.py b/sleekxmpp/plugins/xep_0249/invite.py new file mode 100644 index 00000000..95fcb37c --- /dev/null +++ b/sleekxmpp/plugins/xep_0249/invite.py @@ -0,0 +1,79 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dalek + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +import sleekxmpp +from sleekxmpp import Message +from sleekxmpp.plugins.base import base_plugin +from sleekxmpp.xmlstream import register_stanza_plugin +from sleekxmpp.xmlstream.handler import Callback +from sleekxmpp.xmlstream.matcher import StanzaPath +from sleekxmpp.plugins.xep_0249 import Invite + + +log = logging.getLogger(__name__) + + +class xep_0249(base_plugin): + + """ + XEP-0249: Direct MUC Invitations + """ + + def plugin_init(self): + self.xep = "0249" + self.description = "Direct MUC Invitations" + self.stanza = sleekxmpp.plugins.xep_0249.stanza + + self.xmpp.register_handler( + Callback('Direct MUC Invitations', + StanzaPath('message/groupchat_invite'), + self._handle_invite)) + + register_stanza_plugin(Message, Invite) + + def post_init(self): + base_plugin.post_init(self) + self.xmpp['xep_0030'].add_feature(Invite.namespace) + + def _handle_invite(self, msg): + """ + Raise an event for all invitations received. + """ + log.debug("Received direct muc invitation from %s to room %s", + msg['from'], msg['groupchat_invite']['jid']) + + self.xmpp.event('groupchat_direct_invite', msg) + + def send_invitation(self, jid, roomjid, password=None, + reason=None, ifrom=None): + """ + Send a direct MUC invitation to an XMPP entity. + + Arguments: + jid -- The JID of the entity that will receive + the invitation + roomjid -- the address of the groupchat room to be joined + password -- a password needed for entry into a + password-protected room (OPTIONAL). + reason -- a human-readable purpose for the invitation + (OPTIONAL). + """ + + msg = self.xmpp.Message() + msg['to'] = jid + if ifrom is not None: + msg['from'] = ifrom + msg['groupchat_invite']['jid'] = roomjid + if password is not None: + msg['groupchat_invite']['password'] = password + if reason is not None: + msg['groupchat_invite']['reason'] = reason + + return msg.send() diff --git a/sleekxmpp/plugins/xep_0249/stanza.py b/sleekxmpp/plugins/xep_0249/stanza.py new file mode 100644 index 00000000..ba4060d7 --- /dev/null +++ b/sleekxmpp/plugins/xep_0249/stanza.py @@ -0,0 +1,39 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dalek + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase + + +class Invite(ElementBase): + + """ + XMPP allows for an agent in an MUC room to directly invite another + user to join the chat room (as opposed to a mediated invitation + done through the server). + + Example invite stanza: + <message from='crone1@shakespeare.lit/desktop' + to='hecate@shakespeare.lit'> + <x xmlns='jabber:x:conference' + jid='darkcave@macbeth.shakespeare.lit' + password='cauldronburn' + reason='Hey Hecate, this is the place for all good witches!'/> + </message> + + Stanza Interface: + jid -- The JID of the groupchat room + password -- The password used to gain entry in the room + (optional) + reason -- The reason for the invitation (optional) + + """ + + name = "x" + namespace = "jabber:x:conference" + plugin_attrib = "groupchat_invite" + interfaces = ("jid", "password", "reason") diff --git a/sleekxmpp/roster/__init__.py b/sleekxmpp/roster/__init__.py new file mode 100644 index 00000000..4335d367 --- /dev/null +++ b/sleekxmpp/roster/__init__.py @@ -0,0 +1,12 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import JID +from sleekxmpp.roster.item import RosterItem +from sleekxmpp.roster.single import RosterNode +from sleekxmpp.roster.multi import Roster diff --git a/sleekxmpp/roster/item.py b/sleekxmpp/roster/item.py new file mode 100644 index 00000000..bd7bbbde --- /dev/null +++ b/sleekxmpp/roster/item.py @@ -0,0 +1,487 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +class RosterItem(object): + + """ + A RosterItem is a single entry in a roster node, and tracks + the subscription state and user annotations of a single JID. + + Roster items may use an external datastore to persist roster data + across sessions. Client applications will not need to use this + functionality, but is intended for components that do not have their + roster persisted automatically by the XMPP server. + + Roster items provide many methods for handling incoming presence + stanzas that ensure that response stanzas are sent according to + RFC 3921. + + The external datastore is accessed through a provided interface + object which is stored in self.db. The interface object MUST + provide two methods: load and save, both of which are responsible + for working with a single roster item. A private dictionary, + self._db_state, is used to store any metadata needed by the + interface, such as the row ID of a roster item, etc. + + Interface for self.db.load: + load(owner_jid, jid, db_state): + owner_jid -- The JID that owns the roster. + jid -- The JID of the roster item. + db_state -- A dictionary containing any data saved + by the interface object after a save() + call. Will typically have the equivalent + of a 'row_id' value. + + Interface for self.db.save: + save(owner_jid, jid, item_state, db_state): + owner_jid -- The JID that owns the roster. + jid -- The JID of the roster item. + item_state -- A dictionary containing the fields: + 'from', 'to', 'pending_in', 'pending_out', + 'whitelisted', 'subscription', 'name', + and 'groups'. + db_state -- A dictionary provided for persisting + datastore specific information. Typically, + a value equivalent to 'row_id' will be + stored here. + + State Fields: + from -- Indicates if a subscription of type 'from' + has been authorized. + to -- Indicates if a subscription of type 'to' has + been authorized. + pending_in -- Indicates if a subscription request has been + received from this JID and it has not been + authorized yet. + pending_out -- Indicates if a subscription request has been sent + to this JID and it has not been accepted yet. + subscription -- Returns one of: 'to', 'from', 'both', or 'none' + based on the states of from, to, pending_in, + and pending_out. Assignment to this value does + not affect the states of the other values. + whitelisted -- Indicates if a subscription request from this + JID should be automatically accepted. + name -- A user supplied alias for the JID. + groups -- A list of group names for the JID. + + Attributes: + xmpp -- The main SleekXMPP instance. + owner -- The JID that owns the roster. + jid -- The JID for the roster item. + db -- Optional datastore interface object. + last_status -- The last presence sent to this JID. + resources -- A dictionary of online resources for this JID. + Will contain the fields 'show', 'status', + and 'priority'. + + Methods: + load -- Retrieve the roster item from an + external datastore, if one was provided. + save -- Save the roster item to an external + datastore, if one was provided. + remove -- Remove a subscription to the JID and revoke + its whitelisted status. + subscribe -- Subscribe to the JID. + authorize -- Accept a subscription from the JID. + unauthorize -- Deny a subscription from the JID. + unsubscribe -- Unsubscribe from the JID. + send_presence -- Send a directed presence to the JID. + send_last_presence -- Resend the last sent presence. + handle_available -- Update the JID's resource information. + handle_unavailable -- Update the JID's resource information. + handle_subscribe -- Handle a subscription request. + handle_subscribed -- Handle a notice that a subscription request + was authorized by the JID. + handle_unsubscribe -- Handle an unsubscribe request. + handle_unsubscribed -- Handle a notice that a subscription was + removed by the JID. + handle_probe -- Handle a presence probe query. + """ + + def __init__(self, xmpp, jid, owner=None, + state=None, db=None, roster=None): + """ + Create a new roster item. + + Arguments: + xmpp -- The main SleekXMPP instance. + jid -- The item's JID. + owner -- The roster owner's JID. Defaults + so self.xmpp.boundjid.bare. + state -- A dictionary of initial state values. + db -- An optional interface to an external datastore. + roster -- The roster object containing this entry. + """ + self.xmpp = xmpp + self.jid = jid + self.owner = owner or self.xmpp.boundjid.bare + self.last_status = None + self.resources = {} + self.roster = roster + self.db = db + self._state = state or { + 'from': False, + 'to': False, + 'pending_in': False, + 'pending_out': False, + 'whitelisted': False, + 'subscription': 'none', + 'name': '', + 'groups': []} + self._db_state = {} + self.load() + + def set_backend(self, db=None): + """ + Set the datastore interface object for the roster item. + + Arguments: + db -- The new datastore interface. + """ + self.db = db + self.load() + + def load(self): + """ + Load the item's state information from an external datastore, + if one has been provided. + """ + if self.db: + item = self.db.load(self.owner, self.jid, + self._db_state) + if item: + self['name'] = item['name'] + self['groups'] = item['groups'] + self['from'] = item['from'] + self['to'] = item['to'] + self['whitelisted'] = item['whitelisted'] + self['pending_out'] = item['pending_out'] + self['pending_in'] = item['pending_in'] + self['subscription'] = self._subscription() + return self._state + return None + + def save(self): + """ + Save the item's state information to an external datastore, + if one has been provided. + """ + self['subscription'] = self._subscription() + if self.db: + self.db.save(self.owner, self.jid, + self._state, self._db_state) + + def __getitem__(self, key): + """Return a state field's value.""" + if key in self._state: + if key == 'subscription': + return self._subscription() + return self._state[key] + else: + raise KeyError + + def __setitem__(self, key, value): + """ + Set the value of a state field. + + For boolean states, the values True, 'true', '1', 'on', + and 'yes' are accepted as True; all others are False. + + Arguments: + key -- The state field to modify. + value -- The new value of the state field. + """ + if key in self._state: + if key in ['name', 'subscription', 'groups']: + self._state[key] = value + else: + value = str(value).lower() + self._state[key] = value in ('true', '1', 'on', 'yes') + else: + raise KeyError + + def _subscription(self): + """Return the proper subscription type based on current state.""" + if self['to'] and self['from']: + return 'both' + elif self['from']: + return 'from' + elif self['to']: + return 'to' + else: + return 'none' + + def remove(self): + """ + Remove a JID's whitelisted status and unsubscribe if a + subscription exists. + """ + if self['to']: + p = self.xmpp.Presence() + p['to'] = self.jid + p['type'] = 'unsubscribe' + if self.xmpp.is_component: + p['from'] = self.owner + p.send() + self['to'] = False + self['whitelisted'] = False + self.save() + + def subscribe(self): + """Send a subscription request to the JID.""" + p = self.xmpp.Presence() + p['to'] = self.jid + p['type'] = 'subscribe' + if self.xmpp.is_component: + p['from'] = self.owner + self['pending_out'] = True + self.save() + p.send() + + def authorize(self): + """Authorize a received subscription request from the JID.""" + self['from'] = True + self['pending_in'] = False + self.save() + self._subscribed() + self.send_last_presence() + + def unauthorize(self): + """Deny a received subscription request from the JID.""" + self['from'] = False + self['pending_in'] = False + self.save() + self._unsubscribed() + p = self.xmpp.Presence() + p['to'] = self.jid + p['type'] = 'unavailable' + if self.xmpp.is_component: + p['from'] = self.owner + p.send() + + def _subscribed(self): + """Handle acknowledging a subscription.""" + p = self.xmpp.Presence() + p['to'] = self.jid + p['type'] = 'subscribed' + if self.xmpp.is_component: + p['from'] = self.owner + p.send() + + def unsubscribe(self): + """Unsubscribe from the JID.""" + p = self.xmpp.Presence() + p['to'] = self.jid + p['type'] = 'unsubscribe' + if self.xmpp.is_component: + p['from'] = self.owner + self.save() + p.send() + + def _unsubscribed(self): + """Handle acknowledging an unsubscribe request.""" + p = self.xmpp.Presence() + p['to'] = self.jid + p['type'] = 'unsubscribed' + if self.xmpp.is_component: + p['from'] = self.owner + p.send() + + def send_presence(self, ptype=None, pshow=None, pstatus=None, + ppriority=None, pnick=None): + """ + Create, initialize, and send a Presence stanza. + + Arguments: + pshow -- The presence's show value. + pstatus -- The presence's status message. + ppriority -- This connections' priority. + ptype -- The type of presence, such as 'subscribe'. + pnick -- Optional nickname of the presence's sender. + """ + p = self.xmpp.make_presence(pshow=pshow, + pstatus=pstatus, + ppriority=ppriority, + ptype=ptype, + pnick=pnick, + pto=self.jid) + if self.xmpp.is_component: + p['from'] = self.owner + if p['type'] in p.showtypes or \ + p['type'] in ['available', 'unavailable']: + self.last_status = p + p.send() + + if not self.xmpp.sentpresence: + self.xmpp.event('sent_presence') + self.xmpp.sentpresence = True + + def send_last_presence(self): + if self.last_status is None: + pres = self.roster.last_status + if pres is None: + self.send_presence() + else: + pres['to'] = self.jid + if self.xmpp.is_component: + pres['from'] = self.owner + else: + del pres['from'] + pres.send() + else: + self.last_status.send() + + def handle_available(self, presence): + resource = presence['from'].resource + data = {'status': presence['status'], + 'show': presence['show'], + 'priority': presence['priority']} + if not self.resources: + self.xmpp.event('got_online', presence) + if resource not in self.resources: + self.resources[resource] = {} + old_status = self.resources[resource].get('status', '') + old_show = self.resources[resource].get('show', None) + self.resources[resource].update(data) + if old_show != presence['show'] or old_status != presence['status']: + self.xmpp.event('changed_status', presence) + + def handle_unavailable(self, presence): + resource = presence['from'].resource + if not self.resources: + return + if resource in self.resources: + del self.resources[resource] + self.xmpp.event('changed_status', presence) + if not self.resources: + self.xmpp.event('got_offline', presence) + + def handle_subscribe(self, presence): + """ + +------------------------------------------------------------------+ + | EXISTING STATE | DELIVER? | NEW STATE | + +------------------------------------------------------------------+ + | "None" | yes | "None + Pending In" | + | "None + Pending Out" | yes | "None + Pending Out/In" | + | "None + Pending In" | no | no state change | + | "None + Pending Out/In" | no | no state change | + | "To" | yes | "To + Pending In" | + | "To + Pending In" | no | no state change | + | "From" | no * | no state change | + | "From + Pending Out" | no * | no state change | + | "Both" | no * | no state change | + +------------------------------------------------------------------+ + """ + if self.xmpp.is_component: + if not self['from'] and not self['pending_in']: + self['pending_in'] = True + self.xmpp.event('roster_subscription_request', presence) + elif self['from']: + self._subscribed() + self.save() + else: + #server shouldn't send an invalid subscription request + self.xmpp.event('roster_subscription_request', presence) + + def handle_subscribed(self, presence): + """ + +------------------------------------------------------------------+ + | EXISTING STATE | DELIVER? | NEW STATE | + +------------------------------------------------------------------+ + | "None" | no | no state change | + | "None + Pending Out" | yes | "To" | + | "None + Pending In" | no | no state change | + | "None + Pending Out/In" | yes | "To + Pending In" | + | "To" | no | no state change | + | "To + Pending In" | no | no state change | + | "From" | no | no state change | + | "From + Pending Out" | yes | "Both" | + | "Both" | no | no state change | + +------------------------------------------------------------------+ + """ + if self.xmpp.is_component: + if not self['to'] and self['pending_out']: + self['pending_out'] = False + self['to'] = True + self.xmpp.event('roster_subscription_authorized', presence) + self.save() + else: + self.xmpp.event('roster_subscription_authorized', presence) + + def handle_unsubscribe(self, presence): + """ + +------------------------------------------------------------------+ + | EXISTING STATE | DELIVER? | NEW STATE | + +------------------------------------------------------------------+ + | "None" | no | no state change | + | "None + Pending Out" | no | no state change | + | "None + Pending In" | yes * | "None" | + | "None + Pending Out/In" | yes * | "None + Pending Out" | + | "To" | no | no state change | + | "To + Pending In" | yes * | "To" | + | "From" | yes * | "None" | + | "From + Pending Out" | yes * | "None + Pending Out | + | "Both" | yes * | "To" | + +------------------------------------------------------------------+ + """ + if self.xmpp.is_component: + if not self['from'] and self['pending_in']: + self['pending_in'] = False + self._unsubscribed() + elif self['from']: + self['from'] = False + self._unsubscribed() + self.xmpp.event('roster_subscription_remove', presence) + self.save() + else: + self.xmpp.event('roster_subscription_remove', presence) + + def handle_unsubscribed(self, presence): + """ + +------------------------------------------------------------------+ + | EXISTING STATE | DELIVER? | NEW STATE | + +------------------------------------------------------------------+ + | "None" | no | no state change | + | "None + Pending Out" | yes | "None" | + | "None + Pending In" | no | no state change | + | "None + Pending Out/In" | yes | "None + Pending In" | + | "To" | yes | "None" | + | "To + Pending In" | yes | "None + Pending In" | + | "From" | no | no state change | + | "From + Pending Out" | yes | "From" | + | "Both" | yes | "From" | + +------------------------------------------------------------------ + """ + if self.xmpp.is_component: + if not self['to'] and self['pending_out']: + self['pending_out'] = False + elif self['to'] and not self['pending_out']: + self['to'] = False + self.xmpp.event('roster_subscription_removed', presence) + self.save() + else: + self.xmpp.event('roster_subscription_removed', presence) + + def handle_probe(self, presence): + if self['to']: + self.send_last_presence() + if self['pending_out']: + self.subscribe() + if not self['to']: + self._unsubscribed() + + def reset(self): + """ + Forgot current resource presence information as part of + a roster reset request. + """ + self.resources = {} + + def __repr__(self): + return repr(self._state) diff --git a/sleekxmpp/roster/multi.py b/sleekxmpp/roster/multi.py new file mode 100644 index 00000000..28876814 --- /dev/null +++ b/sleekxmpp/roster/multi.py @@ -0,0 +1,189 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import JID +from sleekxmpp.roster import RosterNode + + +class Roster(object): + + """ + SleekXMPP's roster manager. + + The roster is divided into "nodes", where each node is responsible + for a single JID. While the distinction is not strictly necessary + for client connections, it is a necessity for components that use + multiple JIDs. + + Rosters may be stored and persisted in an external datastore. An + interface object to the datastore that loads and saves roster items may + be provided. See the documentation for the RosterItem class for the + methods that the datastore interface object must provide. + + Attributes: + xmpp -- The main SleekXMPP instance. + db -- Optional interface object to an external datastore. + auto_authorize -- Default auto_authorize value for new roster nodes. + Defaults to True. + auto_subscribe -- Default auto_subscribe value for new roster nodes. + Defaults to True. + + Methods: + add -- Create a new roster node for a JID. + send_presence -- Shortcut for sending a presence stanza. + """ + + def __init__(self, xmpp, db=None): + """ + Create a new roster. + + Arguments: + xmpp -- The main SleekXMPP instance. + db -- Optional interface object to a datastore. + """ + self.xmpp = xmpp + self.db = db + self._auto_authorize = True + self._auto_subscribe = True + self._rosters = {} + + if self.db: + for node in self.db.entries(None, {}): + self.add(node) + + def __getitem__(self, key): + """ + Return the roster node for a JID. + + A new roster node will be created if one + does not already exist. + + Arguments: + key -- Return the roster for this JID. + """ + if isinstance(key, JID): + key = key.bare + if key is None: + key = self.xmpp.boundjid.bare + if key not in self._rosters: + self.add(key) + self._rosters[key].auto_authorize = self.auto_authorize + self._rosters[key].auto_subscribe = self.auto_subscribe + return self._rosters[key] + + def keys(self): + """Return the JIDs managed by the roster.""" + return self._rosters.keys() + + def __iter__(self): + """Iterate over the roster nodes.""" + return self._rosters.__iter__() + + def add(self, node): + """ + Add a new roster node for the given JID. + + Arguments: + node -- The JID for the new roster node. + """ + if isinstance(node, JID): + node = node.bare + if node not in self._rosters: + self._rosters[node] = RosterNode(self.xmpp, node, self.db) + + def set_backend(self, db=None): + """ + Set the datastore interface object for the roster. + + Arguments: + db -- The new datastore interface. + """ + self.db = db + for node in self.db.entries(None, {}): + self.add(node) + for node in self._rosters: + self._rosters[node].set_backend(db) + + def reset(self): + """ + Reset the state of the roster to forget any current + presence information. Useful after a disconnection occurs. + """ + for node in self: + self[node].reset() + + def send_presence(self, pshow=None, pstatus=None, ppriority=None, + pto=None, pfrom=None, ptype=None, pnick=None): + """ + Create, initialize, and send a Presence stanza. + + Forwards the send request to the appropriate roster to + perform the actual sending. + + Arguments: + pshow -- The presence's show value. + pstatus -- The presence's status message. + ppriority -- This connections' priority. + pto -- The recipient of a directed presence. + ptype -- The type of presence, such as 'subscribe'. + pfrom -- The sender of the presence. + pnick -- Optional nickname of the presence's sender. + """ + self[pfrom].send_presence(ptype=ptype, + pshow=pshow, + pstatus=pstatus, + ppriority=ppriority, + pnick=pnick, + pto=pto) + + @property + def auto_authorize(self): + """ + Auto accept or deny subscription requests. + + If True, auto accept subscription requests. + If False, auto deny subscription requests. + If None, don't automatically respond. + """ + return self._auto_authorize + + @auto_authorize.setter + def auto_authorize(self, value): + """ + Auto accept or deny subscription requests. + + If True, auto accept subscription requests. + If False, auto deny subscription requests. + If None, don't automatically respond. + """ + self._auto_authorize = value + for node in self._rosters: + self._rosters[node].auto_authorize = value + + @property + def auto_subscribe(self): + """ + Auto send requests for mutual subscriptions. + + If True, auto send mutual subscription requests. + """ + return self._auto_subscribe + + @auto_subscribe.setter + def auto_subscribe(self, value): + """ + Auto send requests for mutual subscriptions. + + If True, auto send mutual subscription requests. + """ + self._auto_subscribe = value + for node in self._rosters: + self._rosters[node].auto_subscribe = value + + def __repr__(self): + return repr(self._rosters) diff --git a/sleekxmpp/roster/single.py b/sleekxmpp/roster/single.py new file mode 100644 index 00000000..633f23f6 --- /dev/null +++ b/sleekxmpp/roster/single.py @@ -0,0 +1,304 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import JID +from sleekxmpp.roster import RosterItem + + +class RosterNode(object): + + """ + A roster node is a roster for a single JID. + + Attributes: + xmpp -- The main SleekXMPP instance. + jid -- The JID that owns the roster node. + db -- Optional interface to an external datastore. + auto_authorize -- Determines how authorizations are handled: + True -- Accept all subscriptions. + False -- Reject all subscriptions. + None -- Subscriptions must be + manually authorized. + Defaults to True. + auto_subscribe -- Determines if bi-directional subscriptions + are created after automatically authrorizing + a subscription request. + Defaults to True + last_status -- The last sent presence status that was broadcast + to all contact JIDs. + + Methods: + add -- Add a JID to the roster. + update -- Update a JID's subscription information. + subscribe -- Subscribe to a JID. + unsubscribe -- Unsubscribe from a JID. + remove -- Remove a JID from the roster. + presence -- Return presence information for a JID's resources. + send_presence -- Shortcut for sending a presence stanza. + """ + + def __init__(self, xmpp, jid, db=None): + """ + Create a roster node for a JID. + + Arguments: + xmpp -- The main SleekXMPP instance. + jid -- The JID that owns the roster. + db -- Optional interface to an external datastore. + """ + self.xmpp = xmpp + self.jid = jid + self.db = db + self.auto_authorize = True + self.auto_subscribe = True + self.last_status = None + self._jids = {} + + if self.db: + for jid in self.db.entries(self.jid): + self.add(jid) + + def __getitem__(self, key): + """ + Return the roster item for a subscribed JID. + + A new item entry will be created if one does not already exist. + """ + if isinstance(key, JID): + key = key.bare + if key not in self._jids: + self.add(key, save=True) + return self._jids[key] + + def __len__(self): + """Return the number of JIDs referenced by the roster.""" + return len(self._jids) + + def keys(self): + """Return a list of all subscribed JIDs.""" + return self._jids.keys() + + def has_jid(self, jid): + """Returns whether the roster has a JID.""" + return jid in self._jids + + def groups(self): + """Return a dictionary mapping group names to JIDs.""" + result = {} + for jid in self._jids: + for group in self._jids[jid]['groups']: + if group not in result: + result[group] = [] + result[group].append(jid) + return result + + def __iter__(self): + """Iterate over the roster items.""" + return self._jids.__iter__() + + def set_backend(self, db=None): + """ + Set the datastore interface object for the roster node. + + Arguments: + db -- The new datastore interface. + """ + self.db = db + for jid in self.db.entries(self.jid): + self.add(jid) + for jid in self._jids: + self._jids[jid].set_backend(db) + + def add(self, jid, name='', groups=None, afrom=False, ato=False, + pending_in=False, pending_out=False, whitelisted=False, + save=False): + """ + Add a new roster item entry. + + Arguments: + jid -- The JID for the roster item. + name -- An alias for the JID. + groups -- A list of group names. + afrom -- Indicates if the JID has a subscription state + of 'from'. Defaults to False. + ato -- Indicates if the JID has a subscription state + of 'to'. Defaults to False. + pending_in -- Indicates if the JID has sent a subscription + request to this connection's JID. + Defaults to False. + pending_out -- Indicates if a subscription request has been sent + to this JID. + Defaults to False. + whitelisted -- Indicates if a subscription request from this JID + should be automatically authorized. + Defaults to False. + save -- Indicates if the item should be persisted + immediately to an external datastore, + if one is used. + Defaults to False. + """ + if isinstance(jid, JID): + key = jid.bare + else: + key = jid + + state = {'name': name, + 'groups': groups or [], + 'from': afrom, + 'to': ato, + 'pending_in': pending_in, + 'pending_out': pending_out, + 'whitelisted': whitelisted, + 'subscription': 'none'} + self._jids[key] = RosterItem(self.xmpp, jid, self.jid, + state=state, db=self.db, + roster=self) + if save: + self._jids[key].save() + + def subscribe(self, jid): + """ + Subscribe to the given JID. + + Arguments: + jid -- The JID to subscribe to. + """ + self[jid].subscribe() + + def unsubscribe(self, jid): + """ + Unsubscribe from the given JID. + + Arguments: + jid -- The JID to unsubscribe from. + """ + self[jid].unsubscribe() + + def remove(self, jid): + """ + Remove a JID from the roster. + + Arguments: + jid -- The JID to remove. + """ + self[jid].remove() + if not self.xmpp.is_component: + return self.update(jid, subscription='remove') + + def update(self, jid, name=None, subscription=None, groups=[], + block=True, timeout=None, callback=None): + """ + Update a JID's subscription information. + + Arguments: + jid -- The JID to update. + name -- Optional alias for the JID. + subscription -- The subscription state. May be one of: 'to', + 'from', 'both', 'none', or 'remove'. + groups -- A list of group names. + block -- Specify if the roster request will block + until a response is received, or a timeout + occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait + for a response before continuing if blocking + is used. Defaults to self.response_timeout. + callback -- Optional reference to a stream handler function. + Will be executed when the roster is received. + Implies block=False. + """ + self[jid]['name'] = name + self[jid]['groups'] = groups + self[jid].save() + + if not self.xmpp.is_component: + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['roster']['items'] = {jid: {'name': name, + 'subscription': subscription, + 'groups': groups}} + + return iq.send(block, timeout, callback) + + def presence(self, jid, resource=None): + """ + Retrieve the presence information of a JID. + + May return either all online resources' status, or + a single resource's status. + + Arguments: + jid -- The JID to lookup. + resource -- Optional resource for returning + only the status of a single connection. + """ + if resource is None: + return self[jid].resources + + default_presence = {'status': '', + 'priority': 0, + 'show': ''} + return self[jid].resources.get(resource, + default_presence) + + def reset(self): + """ + Reset the state of the roster to forget any current + presence information. Useful after a disconnection occurs. + """ + for jid in self: + self[jid].reset() + + def send_presence(self, ptype=None, pshow=None, pstatus=None, + ppriority=None, pnick=None, pto=None): + """ + Create, initialize, and send a Presence stanza. + + If no recipient is specified, send the presence immediately. + Otherwise, forward the send request to the recipient's roster + entry for processing. + + Arguments: + pshow -- The presence's show value. + pstatus -- The presence's status message. + ppriority -- This connections' priority. + pto -- The recipient of a directed presence. + ptype -- The type of presence, such as 'subscribe'. + """ + if pto: + self[pto].send_presence(ptype, pshow, pstatus, + ppriority, pnick) + else: + p = self.xmpp.make_presence(pshow=pshow, + pstatus=pstatus, + ppriority=ppriority, + ptype=ptype, + pnick=pnick) + if self.xmpp.is_component: + p['from'] = self.jid + if p['type'] in p.showtypes or \ + p['type'] in ['available', 'unavailable']: + self.last_status = p + p.send() + + if not self.xmpp.sentpresence: + self.xmpp.event('sent_presence') + self.xmpp.sentpresence = True + + def send_last_presence(self): + if self.last_status is None: + self.send_presence() + else: + pres = self.last_status + if self.xmpp.is_component: + pres['from'] = self.jid + else: + del pres['from'] + pres.send() + + def __repr__(self): + return repr(self._jids) diff --git a/sleekxmpp/stanza/__init__.py b/sleekxmpp/stanza/__init__.py new file mode 100644 index 00000000..4bd37dc5 --- /dev/null +++ b/sleekxmpp/stanza/__init__.py @@ -0,0 +1,15 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +from sleekxmpp.stanza.error import Error +from sleekxmpp.stanza.iq import Iq +from sleekxmpp.stanza.message import Message +from sleekxmpp.stanza.presence import Presence +from sleekxmpp.stanza.stream_features import StreamFeatures +from sleekxmpp.stanza.stream_error import StreamError diff --git a/sleekxmpp/stanza/atom.py b/sleekxmpp/stanza/atom.py new file mode 100644 index 00000000..244ef315 --- /dev/null +++ b/sleekxmpp/stanza/atom.py @@ -0,0 +1,26 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase + + +class AtomEntry(ElementBase): + + """ + A simple Atom feed entry. + + Stanza Interface: + title -- The title of the Atom feed entry. + summary -- The summary of the Atom feed entry. + """ + + namespace = 'http://www.w3.org/2005/Atom' + name = 'entry' + plugin_attrib = 'entry' + interfaces = set(('title', 'summary')) + sub_interfaces = set(('title', 'summary')) diff --git a/sleekxmpp/stanza/error.py b/sleekxmpp/stanza/error.py new file mode 100644 index 00000000..d985f729 --- /dev/null +++ b/sleekxmpp/stanza/error.py @@ -0,0 +1,146 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Error(ElementBase): + + """ + XMPP stanzas of type 'error' should include an <error> stanza that + describes the nature of the error and how it should be handled. + + Use the 'XEP-0086: Error Condition Mappings' plugin to include error + codes used in older XMPP versions. + + Example error stanza: + <error type="cancel" code="404"> + <item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" /> + <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"> + The item was not found. + </text> + </error> + + Stanza Interface: + code -- The error code used in older XMPP versions. + condition -- The name of the condition element. + text -- Human readable description of the error. + type -- Error type indicating how the error should be handled. + + Attributes: + conditions -- The set of allowable error condition elements. + condition_ns -- The namespace for the condition element. + types -- A set of values indicating how the error + should be treated. + + Methods: + setup -- Overrides ElementBase.setup. + get_condition -- Retrieve the name of the condition element. + set_condition -- Add a condition element. + del_condition -- Remove the condition element. + get_text -- Retrieve the contents of the <text> element. + set_text -- Set the contents of the <text> element. + del_text -- Remove the <text> element. + """ + + namespace = 'jabber:client' + name = 'error' + plugin_attrib = 'error' + interfaces = set(('code', 'condition', 'text', 'type')) + sub_interfaces = set(('text',)) + plugin_attrib_map = {} + plugin_tag_map = {} + conditions = set(('bad-request', 'conflict', 'feature-not-implemented', + 'forbidden', 'gone', 'internal-server-error', + 'item-not-found', 'jid-malformed', 'not-acceptable', + 'not-allowed', 'not-authorized', 'payment-required', + 'recipient-unavailable', 'redirect', + 'registration-required', 'remote-server-not-found', + 'remote-server-timeout', 'resource-constraint', + 'service-unavailable', 'subscription-required', + 'undefined-condition', 'unexpected-request')) + condition_ns = 'urn:ietf:params:xml:ns:xmpp-stanzas' + types = set(('cancel', 'continue', 'modify', 'auth', 'wait')) + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides ElementBase.setup. + + Sets a default error type and condition, and changes the + parent stanza's type to 'error'. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + if ElementBase.setup(self, xml): + #If we had to generate XML then set default values. + self['type'] = 'cancel' + self['condition'] = 'feature-not-implemented' + if self.parent is not None: + self.parent()['type'] = 'error' + + def get_condition(self): + """Return the condition element's name.""" + for child in self.xml.getchildren(): + if "{%s}" % self.condition_ns in child.tag: + cond = child.tag.split('}', 1)[-1] + if cond in self.conditions: + return cond + return '' + + def set_condition(self, value): + """ + Set the tag name of the condition element. + + Arguments: + value -- The tag name of the condition element. + """ + if value in self.conditions: + del self['condition'] + self.xml.append(ET.Element("{%s}%s" % (self.condition_ns, value))) + return self + + def del_condition(self): + """Remove the condition element.""" + for child in self.xml.getchildren(): + if "{%s}" % self.condition_ns in child.tag: + tag = child.tag.split('}', 1)[-1] + if tag in self.conditions: + self.xml.remove(child) + return self + + def get_text(self): + """Retrieve the contents of the <text> element.""" + return self._get_sub_text('{%s}text' % self.condition_ns) + + def set_text(self, value): + """ + Set the contents of the <text> element. + + Arguments: + value -- The new contents for the <text> element. + """ + self._set_sub_text('{%s}text' % self.condition_ns, text=value) + return self + + def del_text(self): + """Remove the <text> element.""" + self._del_sub('{%s}text' % self.condition_ns) + return self + + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +Error.getCondition = Error.get_condition +Error.setCondition = Error.set_condition +Error.delCondition = Error.del_condition +Error.getText = Error.get_text +Error.setText = Error.set_text +Error.delText = Error.del_text diff --git a/sleekxmpp/stanza/htmlim.py b/sleekxmpp/stanza/htmlim.py new file mode 100644 index 00000000..d21a74e1 --- /dev/null +++ b/sleekxmpp/stanza/htmlim.py @@ -0,0 +1,86 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Message +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class HTMLIM(ElementBase): + + """ + XEP-0071: XHTML-IM defines a method for embedding XHTML content + within a <message> stanza so that lightweight markup can be used + to format the message contents and to create links. + + Only a subset of XHTML is recommended for use with XHTML-IM. + See the full spec at 'http://xmpp.org/extensions/xep-0071.html' + for more information. + + Example stanza: + <message to="user@example.com"> + <body>Non-html message content.</body> + <html xmlns="http://jabber.org/protocol/xhtml-im"> + <body xmlns="http://www.w3.org/1999/xhtml"> + <p><b>HTML!</b></p> + </body> + </html> + </message> + + Stanza Interface: + body -- The contents of the HTML body tag. + + Methods: + setup -- Overrides ElementBase.setup. + get_body -- Return the HTML body contents. + set_body -- Set the HTML body contents. + del_body -- Remove the HTML body contents. + """ + + namespace = 'http://jabber.org/protocol/xhtml-im' + name = 'html' + interfaces = set(('body',)) + plugin_attrib = name + + def set_body(self, html): + """ + Set the contents of the HTML body. + + Arguments: + html -- Either a string or XML object. If the top level + element is not <body> with a namespace of + 'http://www.w3.org/1999/xhtml', it will be wrapped. + """ + if isinstance(html, str): + html = ET.XML(html) + if html.tag != '{http://www.w3.org/1999/xhtml}body': + body = ET.Element('{http://www.w3.org/1999/xhtml}body') + body.append(html) + self.xml.append(body) + else: + self.xml.append(html) + + def get_body(self): + """Return the contents of the HTML body.""" + html = self.xml.find('{http://www.w3.org/1999/xhtml}body') + if html is None: + return '' + return html + + def del_body(self): + """Remove the HTML body contents.""" + if self.parent is not None: + self.parent().xml.remove(self.xml) + + +register_stanza_plugin(Message, HTMLIM) + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +HTMLIM.setBody = HTMLIM.set_body +HTMLIM.getBody = HTMLIM.get_body +HTMLIM.delBody = HTMLIM.del_body diff --git a/sleekxmpp/stanza/iq.py b/sleekxmpp/stanza/iq.py new file mode 100644 index 00000000..f05dad17 --- /dev/null +++ b/sleekxmpp/stanza/iq.py @@ -0,0 +1,241 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Error +from sleekxmpp.stanza.rootstanza import RootStanza +from sleekxmpp.xmlstream import StanzaBase, ET +from sleekxmpp.xmlstream.handler import Waiter, Callback +from sleekxmpp.xmlstream.matcher import MatcherId +from sleekxmpp.exceptions import IqTimeout, IqError + + +class Iq(RootStanza): + + """ + XMPP <iq> stanzas, or info/query stanzas, are XMPP's method of + requesting and modifying information, similar to HTTP's GET and + POST methods. + + Each <iq> stanza must have an 'id' value which associates the + stanza with the response stanza. XMPP entities must always + be given a response <iq> stanza with a type of 'result' after + sending a stanza of type 'get' or 'set'. + + Most uses cases for <iq> stanzas will involve adding a <query> + element whose namespace indicates the type of information + desired. However, some custom XMPP applications use <iq> stanzas + as a carrier stanza for an application-specific protocol instead. + + Example <iq> Stanzas: + <iq to="user@example.com" type="get" id="314"> + <query xmlns="http://jabber.org/protocol/disco#items" /> + </iq> + + <iq to="user@localhost" type="result" id="17"> + <query xmlns='jabber:iq:roster'> + <item jid='otheruser@example.net' + name='John Doe' + subscription='both'> + <group>Friends</group> + </item> + </query> + </iq> + + Stanza Interface: + query -- The namespace of the <query> element if one exists. + + Attributes: + types -- May be one of: get, set, result, or error. + + Methods: + __init__ -- Overrides StanzaBase.__init__. + unhandled -- Send error if there are no handlers. + set_payload -- Overrides StanzaBase.set_payload. + set_query -- Add or modify a <query> element. + get_query -- Return the namespace of the <query> element. + del_query -- Remove the <query> element. + reply -- Overrides StanzaBase.reply + send -- Overrides StanzaBase.send + """ + + namespace = 'jabber:client' + name = 'iq' + interfaces = set(('type', 'to', 'from', 'id', 'query')) + types = set(('get', 'result', 'set', 'error')) + plugin_attrib = name + + def __init__(self, *args, **kwargs): + """ + Initialize a new <iq> stanza with an 'id' value. + + Overrides StanzaBase.__init__. + """ + StanzaBase.__init__(self, *args, **kwargs) + if self['id'] == '': + if self.stream is not None: + self['id'] = self.stream.new_id() + else: + self['id'] = '0' + + def unhandled(self): + """ + Send a feature-not-implemented error if the stanza is not handled. + + Overrides StanzaBase.unhandled. + """ + if self['type'] in ('get', 'set'): + self.reply() + self['error']['condition'] = 'feature-not-implemented' + self['error']['text'] = 'No handlers registered for this request.' + self.send() + + def set_payload(self, value): + """ + Set the XML contents of the <iq> stanza. + + Arguments: + value -- An XML object to use as the <iq> stanza's contents + """ + self.clear() + StanzaBase.set_payload(self, value) + return self + + def set_query(self, value): + """ + Add or modify a <query> element. + + Query elements are differentiated by their namespace. + + Arguments: + value -- The namespace of the <query> element. + """ + query = self.xml.find("{%s}query" % value) + if query is None and value: + self.clear() + query = ET.Element("{%s}query" % value) + self.xml.append(query) + return self + + def get_query(self): + """Return the namespace of the <query> element.""" + for child in self.xml.getchildren(): + if child.tag.endswith('query'): + ns = child.tag.split('}')[0] + if '{' in ns: + ns = ns[1:] + return ns + return '' + + def del_query(self): + """Remove the <query> element.""" + for child in self.xml.getchildren(): + if child.tag.endswith('query'): + self.xml.remove(child) + return self + + def reply(self, clear=True): + """ + Send a reply <iq> stanza. + + Overrides StanzaBase.reply + + Sets the 'type' to 'result' in addition to the default + StanzaBase.reply behavior. + + Arguments: + clear -- Indicates if existing content should be + removed before replying. Defaults to True. + """ + self['type'] = 'result' + StanzaBase.reply(self, clear) + return self + + def send(self, block=True, timeout=None, callback=None, now=False): + """ + Send an <iq> stanza over the XML stream. + + The send call can optionally block until a response is received or + a timeout occurs. Be aware that using blocking in non-threaded event + handlers can drastically impact performance. Otherwise, a callback + handler can be provided that will be executed when the Iq stanza's + result reply is received. Be aware though that that the callback + handler will not be executed in its own thread. + + Using both block and callback is not recommended, and only the + callback argument will be used in that case. + + Overrides StanzaBase.send + + Arguments: + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + now -- Indicates if the send queue should be skipped and send + the stanza immediately. Used during stream + initialization. Defaults to False. + """ + if timeout is None: + timeout = self.stream.response_timeout + if callback is not None and self['type'] in ('get', 'set'): + handler_name = 'IqCallback_%s' % self['id'] + handler = Callback(handler_name, + MatcherId(self['id']), + callback, + once=True) + self.stream.register_handler(handler) + StanzaBase.send(self, now=now) + return handler_name + elif block and self['type'] in ('get', 'set'): + waitfor = Waiter('IqWait_%s' % self['id'], MatcherId(self['id'])) + self.stream.register_handler(waitfor) + StanzaBase.send(self, now=now) + result = waitfor.wait(timeout) + if not result: + raise IqTimeout(self) + if result['type'] == 'error': + raise IqError(result) + return result + else: + return StanzaBase.send(self, now=now) + + def _set_stanza_values(self, values): + """ + Set multiple stanza interface values using a dictionary. + + Stanza plugin values may be set usind nested dictionaries. + + If the interface 'query' is given, then it will be set + last to avoid duplication of the <query /> element. + + Overrides ElementBase._set_stanza_values. + + Arguments: + values -- A dictionary mapping stanza interface with values. + Plugin interfaces may accept a nested dictionary that + will be used recursively. + """ + query = values.get('query', '') + if query: + del values['query'] + StanzaBase._set_stanza_values(self, values) + self['query'] = query + else: + StanzaBase._set_stanza_values(self, values) + return self + + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +Iq.setPayload = Iq.set_payload +Iq.getQuery = Iq.get_query +Iq.setQuery = Iq.set_query +Iq.delQuery = Iq.del_query diff --git a/sleekxmpp/stanza/message.py b/sleekxmpp/stanza/message.py new file mode 100644 index 00000000..19d4d9e2 --- /dev/null +++ b/sleekxmpp/stanza/message.py @@ -0,0 +1,157 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Error +from sleekxmpp.stanza.rootstanza import RootStanza +from sleekxmpp.xmlstream import StanzaBase, ET + + +class Message(RootStanza): + + """ + XMPP's <message> stanzas are a "push" mechanism to send information + to other XMPP entities without requiring a response. + + Chat clients will typically use <message> stanzas that have a type + of either "chat" or "groupchat". + + When handling a message event, be sure to check if the message is + an error response. + + Example <message> stanzas: + <message to="user1@example.com" from="user2@example.com"> + <body>Hi!</body> + </message> + + <message type="groupchat" to="room@conference.example.com"> + <body>Hi everyone!</body> + </message> + + Stanza Interface: + body -- The main contents of the message. + subject -- An optional description of the message's contents. + mucroom -- (Read-only) The name of the MUC room that sent the message. + mucnick -- (Read-only) The MUC nickname of message's sender. + + Attributes: + types -- May be one of: normal, chat, headline, groupchat, or error. + + Methods: + setup -- Overrides StanzaBase.setup. + chat -- Set the message type to 'chat'. + normal -- Set the message type to 'normal'. + reply -- Overrides StanzaBase.reply + get_type -- Overrides StanzaBase interface + get_mucroom -- Return the name of the MUC room of the message. + set_mucroom -- Dummy method to prevent assignment. + del_mucroom -- Dummy method to prevent deletion. + get_mucnick -- Return the MUC nickname of the message's sender. + set_mucnick -- Dummy method to prevent assignment. + del_mucnick -- Dummy method to prevent deletion. + """ + + namespace = 'jabber:client' + name = 'message' + interfaces = set(('type', 'to', 'from', 'id', 'body', 'subject', + 'mucroom', 'mucnick')) + sub_interfaces = set(('body', 'subject')) + plugin_attrib = name + types = set((None, 'normal', 'chat', 'headline', 'error', 'groupchat')) + + def get_type(self): + """ + Return the message type. + + Overrides default stanza interface behavior. + + Returns 'normal' if no type attribute is present. + """ + return self._get_attr('type', 'normal') + + def chat(self): + """Set the message type to 'chat'.""" + self['type'] = 'chat' + return self + + def normal(self): + """Set the message type to 'normal'.""" + self['type'] = 'normal' + return self + + def reply(self, body=None, clear=True): + """ + Create a message reply. + + Overrides StanzaBase.reply. + + Sets proper 'to' attribute if the message is from a MUC, and + adds a message body if one is given. + + Arguments: + body -- Optional text content for the message. + clear -- Indicates if existing content should be removed + before replying. Defaults to True. + """ + StanzaBase.reply(self, clear) + if self['type'] == 'groupchat': + self['to'] = self['to'].bare + + del self['id'] + + if body is not None: + self['body'] = body + return self + + def get_mucroom(self): + """ + Return the name of the MUC room where the message originated. + + Read-only stanza interface. + """ + if self['type'] == 'groupchat': + return self['from'].bare + else: + return '' + + def get_mucnick(self): + """ + Return the nickname of the MUC user that sent the message. + + Read-only stanza interface. + """ + if self['type'] == 'groupchat': + return self['from'].resource + else: + return '' + + def set_mucroom(self, value): + """Dummy method to prevent modification.""" + pass + + def del_mucroom(self): + """Dummy method to prevent deletion.""" + pass + + def set_mucnick(self, value): + """Dummy method to prevent modification.""" + pass + + def del_mucnick(self): + """Dummy method to prevent deletion.""" + pass + + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +Message.getType = Message.get_type +Message.getMucroom = Message.get_mucroom +Message.setMucroom = Message.set_mucroom +Message.delMucroom = Message.del_mucroom +Message.getMucnick = Message.get_mucnick +Message.setMucnick = Message.set_mucnick +Message.delMucnick = Message.del_mucnick diff --git a/sleekxmpp/stanza/nick.py b/sleekxmpp/stanza/nick.py new file mode 100644 index 00000000..1e23d34f --- /dev/null +++ b/sleekxmpp/stanza/nick.py @@ -0,0 +1,78 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Message, Presence +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Nick(ElementBase): + + """ + XEP-0172: User Nickname allows the addition of a <nick> element + in several stanza types, including <message> and <presence> stanzas. + + The nickname contained in a <nick> should be the global, friendly or + informal name chosen by the owner of a bare JID. The <nick> element + may be included when establishing communications with new entities, + such as normal XMPP users or MUC services. + + The nickname contained in a <nick> element will not necessarily be + the same as the nickname used in a MUC. + + Example stanzas: + <message to="user@example.com"> + <nick xmlns="http://jabber.org/nick/nick">The User</nick> + <body>...</body> + </message> + + <presence to="otheruser@example.com" type="subscribe"> + <nick xmlns="http://jabber.org/nick/nick">The User</nick> + </presence> + + Stanza Interface: + nick -- A global, friendly or informal name chosen by a user. + + Methods: + setup -- Overrides ElementBase.setup. + get_nick -- Return the nickname in the <nick> element. + set_nick -- Add a <nick> element with the given nickname. + del_nick -- Remove the <nick> element. + """ + + namespace = 'http://jabber.org/protocol/nick' + name = 'nick' + plugin_attrib = name + interfaces = set(('nick',)) + + def set_nick(self, nick): + """ + Add a <nick> element with the given nickname. + + Arguments: + nick -- A human readable, informal name. + """ + self.xml.text = nick + + def get_nick(self): + """Return the nickname in the <nick> element.""" + return self.xml.text + + def del_nick(self): + """Remove the <nick> element.""" + if self.parent is not None: + self.parent().xml.remove(self.xml) + + +register_stanza_plugin(Message, Nick) +register_stanza_plugin(Presence, Nick) + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +Nick.setNick = Nick.set_nick +Nick.getNick = Nick.get_nick +Nick.delNick = Nick.del_nick diff --git a/sleekxmpp/stanza/presence.py b/sleekxmpp/stanza/presence.py new file mode 100644 index 00000000..c8706233 --- /dev/null +++ b/sleekxmpp/stanza/presence.py @@ -0,0 +1,180 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Error +from sleekxmpp.stanza.rootstanza import RootStanza +from sleekxmpp.xmlstream import StanzaBase, ET + + +class Presence(RootStanza): + + """ + XMPP's <presence> stanza allows entities to know the status of other + clients and components. Since it is currently the only multi-cast + stanza in XMPP, many extensions add more information to <presence> + stanzas to broadcast to every entry in the roster, such as + capabilities, music choices, or locations (XEP-0115: Entity Capabilities + and XEP-0163: Personal Eventing Protocol). + + Since <presence> stanzas are broadcast when an XMPP entity changes + its status, the bulk of the traffic in an XMPP network will be from + <presence> stanzas. Therefore, do not include more information than + necessary in a status message or within a <presence> stanza in order + to help keep the network running smoothly. + + Example <presence> stanzas: + <presence /> + + <presence from="user@example.com"> + <show>away</show> + <status>Getting lunch.</status> + <priority>5</priority> + </presence> + + <presence type="unavailable" /> + + <presence to="user@otherhost.com" type="subscribe" /> + + Stanza Interface: + priority -- A value used by servers to determine message routing. + show -- The type of status, such as away or available for chat. + status -- Custom, human readable status message. + + Attributes: + types -- One of: available, unavailable, error, probe, + subscribe, subscribed, unsubscribe, + and unsubscribed. + showtypes -- One of: away, chat, dnd, and xa. + + Methods: + setup -- Overrides StanzaBase.setup + reply -- Overrides StanzaBase.reply + set_show -- Set the value of the <show> element. + get_type -- Get the value of the type attribute or <show> element. + set_type -- Set the value of the type attribute or <show> element. + get_priority -- Get the value of the <priority> element. + set_priority -- Set the value of the <priority> element. + """ + + namespace = 'jabber:client' + name = 'presence' + interfaces = set(('type', 'to', 'from', 'id', 'show', + 'status', 'priority')) + sub_interfaces = set(('show', 'status', 'priority')) + plugin_attrib = name + + types = set(('available', 'unavailable', 'error', 'probe', 'subscribe', + 'subscribed', 'unsubscribe', 'unsubscribed')) + showtypes = set(('dnd', 'chat', 'xa', 'away')) + + def exception(self, e): + """ + Override exception passback for presence. + """ + pass + + def set_show(self, show): + """ + Set the value of the <show> element. + + Arguments: + show -- Must be one of: away, chat, dnd, or xa. + """ + if show is None: + self._del_sub('show') + elif show in self.showtypes: + self._set_sub_text('show', text=show) + return self + + def get_type(self): + """ + Return the value of the <presence> stanza's type attribute, or + the value of the <show> element. + """ + out = self._get_attr('type') + if not out: + out = self['show'] + if not out or out is None: + out = 'available' + return out + + def set_type(self, value): + """ + Set the type attribute's value, and the <show> element + if applicable. + + Arguments: + value -- Must be in either self.types or self.showtypes. + """ + if value in self.types: + self['show'] = None + if value == 'available': + value = '' + self._set_attr('type', value) + elif value in self.showtypes: + self['show'] = value + return self + + def del_type(self): + """ + Remove both the type attribute and the <show> element. + """ + self._del_attr('type') + self._del_sub('show') + + def set_priority(self, value): + """ + Set the entity's priority value. Some server use priority to + determine message routing behavior. + + Bot clients should typically use a priority of 0 if the same + JID is used elsewhere by a human-interacting client. + + Arguments: + value -- An integer value greater than or equal to 0. + """ + self._set_sub_text('priority', text=str(value)) + + def get_priority(self): + """ + Return the value of the <presence> element as an integer. + """ + p = self._get_sub_text('priority') + if not p: + p = 0 + try: + return int(p) + except ValueError: + # The priority is not a number: we consider it 0 as a default + return 0 + + def reply(self, clear=True): + """ + Set the appropriate presence reply type. + + Overrides StanzaBase.reply. + + Arguments: + clear -- Indicates if the stanza contents should be removed + before replying. Defaults to True. + """ + if self['type'] == 'unsubscribe': + self['type'] = 'unsubscribed' + elif self['type'] == 'subscribe': + self['type'] = 'subscribed' + return StanzaBase.reply(self, clear) + + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +Presence.setShow = Presence.set_show +Presence.getType = Presence.get_type +Presence.setType = Presence.set_type +Presence.delType = Presence.get_type +Presence.getPriority = Presence.get_priority +Presence.setPriority = Presence.set_priority diff --git a/sleekxmpp/stanza/rootstanza.py b/sleekxmpp/stanza/rootstanza.py new file mode 100644 index 00000000..2ac47d8b --- /dev/null +++ b/sleekxmpp/stanza/rootstanza.py @@ -0,0 +1,87 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import traceback +import sys + +from sleekxmpp.exceptions import XMPPError, IqError, IqTimeout +from sleekxmpp.stanza import Error +from sleekxmpp.xmlstream import ET, StanzaBase, register_stanza_plugin + + +log = logging.getLogger(__name__) + + +class RootStanza(StanzaBase): + + """ + A top-level XMPP stanza in an XMLStream. + + The RootStanza class provides a more XMPP specific exception + handler than provided by the generic StanzaBase class. + + Methods: + exception -- Overrides StanzaBase.exception + """ + + def exception(self, e): + """ + Create and send an error reply. + + Typically called when an event handler raises an exception. + The error's type and text content are based on the exception + object's type and content. + + Overrides StanzaBase.exception. + + Arguments: + e -- Exception object + """ + if isinstance(e, IqError): + # We received an Iq error reply, but it wasn't caught + # locally. Using the condition/text from that error + # response could leak too much information, so we'll + # only use a generic error here. + self.reply() + self['error']['condition'] = 'undefined-condition' + self['error']['text'] = 'External error' + self['error']['type'] = 'cancel' + log.warning('You should catch IqError exceptions') + self.send() + elif isinstance(e, IqTimeout): + self.reply() + self['error']['condition'] = 'remote-server-timeout' + self['error']['type'] = 'wait' + log.warning('You should catch IqTimeout exceptions') + self.send() + elif isinstance(e, XMPPError): + # We raised this deliberately + self.reply(clear=e.clear) + self['error']['condition'] = e.condition + self['error']['text'] = e.text + self['error']['type'] = e.etype + if e.extension is not None: + # Extended error tag + extxml = ET.Element("{%s}%s" % (e.extension_ns, e.extension), + e.extension_args) + self['error'].append(extxml) + self.send() + else: + # We probably didn't raise this on purpose, so send an error stanza + self.reply() + self['error']['condition'] = 'undefined-condition' + self['error']['text'] = "SleekXMPP got into trouble." + self['error']['type'] = 'cancel' + self.send() + # log the error + log.exception('Error handling {%s}%s stanza' , self.namespace, self.name) + # Finally raise the exception to a global exception handler + self.stream.exception(e) + +register_stanza_plugin(RootStanza, Error) diff --git a/sleekxmpp/stanza/roster.py b/sleekxmpp/stanza/roster.py new file mode 100644 index 00000000..c7ea4147 --- /dev/null +++ b/sleekxmpp/stanza/roster.py @@ -0,0 +1,127 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Iq +from sleekxmpp.xmlstream import JID +from sleekxmpp.xmlstream import ET, ElementBase, register_stanza_plugin + + +class Roster(ElementBase): + + """ + Example roster stanzas: + <iq type="set"> + <query xmlns="jabber:iq:roster"> + <item jid="user@example.com" subscription="both" name="User"> + <group>Friends</group> + </item> + </query> + </iq> + + Stanza Inteface: + items -- A dictionary of roster entries contained + in the stanza. + + Methods: + get_items -- Return a dictionary of roster entries. + set_items -- Add <item> elements. + del_items -- Remove all <item> elements. + """ + + namespace = 'jabber:iq:roster' + name = 'query' + plugin_attrib = 'roster' + interfaces = set(('items',)) + + def set_items(self, items): + """ + Set the roster entries in the <roster> stanza. + + Uses a dictionary using JIDs as keys, where each entry is itself + a dictionary that contains: + name -- An alias or nickname for the JID. + subscription -- The subscription type. Can be one of 'to', + 'from', 'both', 'none', or 'remove'. + groups -- A list of group names to which the JID + has been assigned. + + Arguments: + items -- A dictionary of roster entries. + """ + self.del_items() + for jid in items: + item = RosterItem() + item.values = items[jid] + item['jid'] = jid + self.append(item) + return self + + def get_items(self): + """ + Return a dictionary of roster entries. + + Each item is keyed using its JID, and contains: + name -- An assigned alias or nickname for the JID. + subscription -- The subscription type. Can be one of 'to', + 'from', 'both', 'none', or 'remove'. + groups -- A list of group names to which the JID has + been assigned. + """ + items = {} + for item in self['substanzas']: + if isinstance(item, RosterItem): + items[item['jid']] = item.values + # Remove extra JID reference to keep everything + # backward compatible + del items[item['jid']]['jid'] + return items + + def del_items(self): + """ + Remove all <item> elements from the roster stanza. + """ + for item in self['substanzas']: + if isinstance(item, RosterItem): + self.xml.remove(item.xml) + + +class RosterItem(ElementBase): + namespace = 'jabber:iq:roster' + name = 'item' + plugin_attrib = 'item' + interfaces = set(('jid', 'name', 'subscription', 'ask', + 'approved', 'groups')) + + def get_groups(self): + groups = [] + for group in self.xml.findall('{%s}group' % self.namespace): + groups.append(group.text) + return groups + + def set_groups(self, values): + self.del_groups() + for group in values: + group_xml = ET.Element('{%s}group' % self.namespace) + group_xml.text = group + self.xml.append(group_xml) + + def del_groups(self): + for group in self.xml.findall('{%s}group' % self.namespace): + self.xmp.remove(group) + + + + +register_stanza_plugin(Iq, Roster) +register_stanza_plugin(Roster, RosterItem, iterable=True) + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +Roster.setItems = Roster.set_items +Roster.getItems = Roster.get_items +Roster.delItems = Roster.del_items diff --git a/sleekxmpp/stanza/stream_error.py b/sleekxmpp/stanza/stream_error.py new file mode 100644 index 00000000..cf59a7fa --- /dev/null +++ b/sleekxmpp/stanza/stream_error.py @@ -0,0 +1,69 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza.error import Error +from sleekxmpp.xmlstream import StanzaBase, ElementBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class StreamError(Error, StanzaBase): + + """ + XMPP stanzas of type 'error' should include an <error> stanza that + describes the nature of the error and how it should be handled. + + Use the 'XEP-0086: Error Condition Mappings' plugin to include error + codes used in older XMPP versions. + + The stream:error stanza is used to provide more information for + error that occur with the underlying XML stream itself, and not + a particular stanza. + + Note: The StreamError stanza is mostly the same as the normal + Error stanza, but with different namespaces and + condition names. + + Example error stanza: + <stream:error> + <not-well-formed xmlns="urn:ietf:params:xml:ns:xmpp-streams" /> + <text xmlns="urn:ietf:params:xml:ns:xmpp-streams"> + XML was not well-formed. + </text> + </stream:error> + + Stanza Interface: + condition -- The name of the condition element. + text -- Human readable description of the error. + + Attributes: + conditions -- The set of allowable error condition elements. + condition_ns -- The namespace for the condition element. + + Methods: + setup -- Overrides ElementBase.setup. + get_condition -- Retrieve the name of the condition element. + set_condition -- Add a condition element. + del_condition -- Remove the condition element. + get_text -- Retrieve the contents of the <text> element. + set_text -- Set the contents of the <text> element. + del_text -- Remove the <text> element. + """ + + namespace = 'http://etherx.jabber.org/streams' + interfaces = set(('condition', 'text')) + conditions = set(( + 'bad-format', 'bad-namespace-prefix', 'conflict', + 'connection-timeout', 'host-gone', 'host-unknown', + 'improper-addressing', 'internal-server-error', 'invalid-from', + 'invalid-namespace', 'invalid-xml', 'not-authorized', + 'not-well-formed', 'policy-violation', 'remote-connection-failed', + 'reset', 'resource-constraint', 'restricted-xml', 'see-other-host', + 'system-shutdown', 'undefined-condition', 'unsupported-encoding', + 'unsupported-feature', 'unsupported-stanza-type', + 'unsupported-version')) + condition_ns = 'urn:ietf:params:xml:ns:xmpp-streams' diff --git a/sleekxmpp/stanza/stream_features.py b/sleekxmpp/stanza/stream_features.py new file mode 100644 index 00000000..b800011f --- /dev/null +++ b/sleekxmpp/stanza/stream_features.py @@ -0,0 +1,54 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, StanzaBase, ET +from sleekxmpp.xmlstream import register_stanza_plugin + + +class StreamFeatures(StanzaBase): + + """ + """ + + name = 'features' + namespace = 'http://etherx.jabber.org/streams' + interfaces = set(('features', 'required', 'optional')) + sub_interfaces = interfaces + plugin_tag_map = {} + plugin_attrib_map = {} + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.values = self.values + + def get_features(self): + """ + """ + return self.plugins + + def set_features(self, value): + """ + """ + pass + + def del_features(self): + """ + """ + pass + + def get_required(self): + """ + """ + features = self['features'] + return [f for n, f in features.items() if f['required']] + + def get_optional(self): + """ + """ + features = self['features'] + return [f for n, f in features.items() if not f['required']] diff --git a/sleekxmpp/test/__init__.py b/sleekxmpp/test/__init__.py new file mode 100644 index 00000000..54d4dc57 --- /dev/null +++ b/sleekxmpp/test/__init__.py @@ -0,0 +1,11 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.test.mocksocket import TestSocket +from sleekxmpp.test.livesocket import TestLiveSocket +from sleekxmpp.test.sleektest import * diff --git a/sleekxmpp/test/livesocket.py b/sleekxmpp/test/livesocket.py new file mode 100644 index 00000000..80d63307 --- /dev/null +++ b/sleekxmpp/test/livesocket.py @@ -0,0 +1,174 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import socket +import threading +try: + import queue +except ImportError: + import Queue as queue + + +class TestLiveSocket(object): + + """ + A live test socket that reads and writes to queues in + addition to an actual networking socket. + + Methods: + next_sent -- Return the next sent stanza. + next_recv -- Return the next received stanza. + recv_data -- Dummy method to have same interface as TestSocket. + recv -- Read the next stanza from the socket. + send -- Write a stanza to the socket. + makefile -- Dummy call, returns self. + read -- Read the next stanza from the socket. + """ + + def __init__(self, *args, **kwargs): + """ + Create a new, live test socket. + + Arguments: + Same as arguments for socket.socket + """ + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.recv_buffer = [] + self.recv_queue = queue.Queue() + self.send_queue = queue.Queue() + self.send_queue_lock = threading.Lock() + self.recv_queue_lock = threading.Lock() + self.is_live = True + + def __getattr__(self, name): + """ + Return attribute values of internal, live socket. + + Arguments: + name -- Name of the attribute requested. + """ + + return getattr(self.socket, name) + + # ------------------------------------------------------------------ + # Testing Interface + + def disconnect_errror(self): + """ + Used to simulate a socket disconnection error. + + Not used by live sockets. + """ + try: + self.socket.shutdown() + self.socket.close() + except: + pass + + def next_sent(self, timeout=None): + """ + Get the next stanza that has been sent. + + Arguments: + timeout -- Optional timeout for waiting for a new value. + """ + args = {'block': False} + if timeout is not None: + args = {'block': True, 'timeout': timeout} + try: + return self.send_queue.get(**args) + except: + return None + + def next_recv(self, timeout=None): + """ + Get the next stanza that has been received. + + Arguments: + timeout -- Optional timeout for waiting for a new value. + """ + args = {'block': False} + if timeout is not None: + args = {'block': True, 'timeout': timeout} + try: + if self.recv_buffer: + return self.recv_buffer.pop(0) + else: + return self.recv_queue.get(**args) + except: + return None + + def recv_data(self, data): + """ + Add data to a receive buffer for cases when more than a single stanza + was received. + """ + self.recv_buffer.append(data) + + # ------------------------------------------------------------------ + # Socket Interface + + def recv(self, *args, **kwargs): + """ + Read data from the socket. + + Store a copy in the receive queue. + + Arguments: + Placeholders. Same as for socket.recv. + """ + data = self.socket.recv(*args, **kwargs) + with self.recv_queue_lock: + self.recv_queue.put(data) + return data + + def send(self, data): + """ + Send data on the socket. + + Store a copy in the send queue. + + Arguments: + data -- String value to write. + """ + with self.send_queue_lock: + self.send_queue.put(data) + return self.socket.send(data) + + # ------------------------------------------------------------------ + # File Socket + + def makefile(self, *args, **kwargs): + """ + File socket version to use with ElementTree. + + Arguments: + Placeholders, same as socket.makefile() + """ + return self + + def read(self, *args, **kwargs): + """ + Implement the file socket read interface. + + Arguments: + Placeholders, same as socket.recv() + """ + return self.recv(*args, **kwargs) + + def clear(self): + """ + Empty the send queue, typically done once the session has started to + remove the feature negotiation and log in stanzas. + """ + with self.send_queue_lock: + for i in range(0, self.send_queue.qsize()): + self.send_queue.get(block=False) + with self.recv_queue_lock: + for i in range(0, self.recv_queue.qsize()): + self.recv_queue.get(block=False) diff --git a/sleekxmpp/test/mocksocket.py b/sleekxmpp/test/mocksocket.py new file mode 100644 index 00000000..0920b7ea --- /dev/null +++ b/sleekxmpp/test/mocksocket.py @@ -0,0 +1,155 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import socket +try: + import queue +except ImportError: + import Queue as queue + + +class TestSocket(object): + + """ + A dummy socket that reads and writes to queues instead + of an actual networking socket. + + Methods: + next_sent -- Return the next sent stanza. + recv_data -- Make a stanza available to read next. + recv -- Read the next stanza from the socket. + send -- Write a stanza to the socket. + makefile -- Dummy call, returns self. + read -- Read the next stanza from the socket. + """ + + def __init__(self, *args, **kwargs): + """ + Create a new test socket. + + Arguments: + Same as arguments for socket.socket + """ + self.socket = socket.socket(*args, **kwargs) + self.recv_queue = queue.Queue() + self.send_queue = queue.Queue() + self.is_live = False + self.disconnected = False + + def __getattr__(self, name): + """ + Return attribute values of internal, dummy socket. + + Some attributes and methods are disabled to prevent the + socket from connecting to the network. + + Arguments: + name -- Name of the attribute requested. + """ + + def dummy(*args): + """Method to do nothing and prevent actual socket connections.""" + return None + + overrides = {'connect': dummy, + 'close': dummy, + 'shutdown': dummy} + + return overrides.get(name, getattr(self.socket, name)) + + # ------------------------------------------------------------------ + # Testing Interface + + def next_sent(self, timeout=None): + """ + Get the next stanza that has been 'sent'. + + Arguments: + timeout -- Optional timeout for waiting for a new value. + """ + args = {'block': False} + if timeout is not None: + args = {'block': True, 'timeout': timeout} + try: + return self.send_queue.get(**args) + except: + return None + + def recv_data(self, data): + """ + Add data to the receiving queue. + + Arguments: + data -- String data to 'write' to the socket to be received + by the XMPP client. + """ + self.recv_queue.put(data) + + def disconnect_error(self): + """ + Simulate a disconnect error by raising a socket.error exception + for any current or further socket operations. + """ + self.disconnected = True + + # ------------------------------------------------------------------ + # Socket Interface + + def recv(self, *args, **kwargs): + """ + Read a value from the received queue. + + Arguments: + Placeholders. Same as for socket.Socket.recv. + """ + if self.disconnected: + raise socket.error + return self.read(block=True) + + def send(self, data): + """ + Send data by placing it in the send queue. + + Arguments: + data -- String value to write. + """ + if self.disconnected: + raise socket.error + self.send_queue.put(data) + return len(data) + + # ------------------------------------------------------------------ + # File Socket + + def makefile(self, *args, **kwargs): + """ + File socket version to use with ElementTree. + + Arguments: + Placeholders, same as socket.Socket.makefile() + """ + return self + + def read(self, block=True, timeout=None, **kwargs): + """ + Implement the file socket interface. + + Arguments: + block -- Indicate if the read should block until a + value is ready. + timeout -- Time in seconds a block should last before + returning None. + """ + if self.disconnected: + raise socket.error + if timeout is not None: + block = True + try: + return self.recv_queue.get(block, timeout) + except: + return None diff --git a/sleekxmpp/test/sleektest.py b/sleekxmpp/test/sleektest.py new file mode 100644 index 00000000..dd3df29a --- /dev/null +++ b/sleekxmpp/test/sleektest.py @@ -0,0 +1,757 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import unittest +try: + import Queue as queue +except: + import queue + +import sleekxmpp +from sleekxmpp import ClientXMPP, ComponentXMPP +from sleekxmpp.stanza import Message, Iq, Presence +from sleekxmpp.test import TestSocket, TestLiveSocket +from sleekxmpp.exceptions import XMPPError, IqTimeout, IqError +from sleekxmpp.xmlstream import ET, register_stanza_plugin +from sleekxmpp.xmlstream import ElementBase, StanzaBase +from sleekxmpp.xmlstream.tostring import tostring +from sleekxmpp.xmlstream.matcher import StanzaPath, MatcherId +from sleekxmpp.xmlstream.matcher import MatchXMLMask, MatchXPath + + +class SleekTest(unittest.TestCase): + + """ + A SleekXMPP specific TestCase class that provides + methods for comparing message, iq, and presence stanzas. + + Methods: + Message -- Create a Message stanza object. + Iq -- Create an Iq stanza object. + Presence -- Create a Presence stanza object. + check_jid -- Check a JID and its component parts. + check -- Compare a stanza against an XML string. + stream_start -- Initialize a dummy XMPP client. + stream_close -- Disconnect the XMPP client. + make_header -- Create a stream header. + send_header -- Check that the given header has been sent. + send_feature -- Send a raw XML element. + send -- Check that the XMPP client sent the given + generic stanza. + recv -- Queue data for XMPP client to receive, or + verify the data that was received from a + live connection. + recv_header -- Check that a given stream header + was received. + recv_feature -- Check that a given, raw XML element + was recveived. + fix_namespaces -- Add top-level namespace to an XML object. + compare -- Compare XML objects against each other. + """ + + def __init__(self, *args, **kwargs): + unittest.TestCase.__init__(self, *args, **kwargs) + self.xmpp = None + + def parse_xml(self, xml_string): + try: + xml = ET.fromstring(xml_string) + return xml + except SyntaxError as e: + if 'unbound' in e.msg: + known_prefixes = { + 'stream': 'http://etherx.jabber.org/streams'} + + prefix = xml_string.split('<')[1].split(':')[0] + if prefix in known_prefixes: + xml_string = '<fixns xmlns:%s="%s">%s</fixns>' % ( + prefix, + known_prefixes[prefix], + xml_string) + xml = self.parse_xml(xml_string) + xml = xml.getchildren()[0] + return xml + else: + self.fail("XML data was mal-formed:\n%s" % xml_string) + + # ------------------------------------------------------------------ + # Shortcut methods for creating stanza objects + + def Message(self, *args, **kwargs): + """ + Create a Message stanza. + + Uses same arguments as StanzaBase.__init__ + + Arguments: + xml -- An XML object to use for the Message's values. + """ + return Message(self.xmpp, *args, **kwargs) + + def Iq(self, *args, **kwargs): + """ + Create an Iq stanza. + + Uses same arguments as StanzaBase.__init__ + + Arguments: + xml -- An XML object to use for the Iq's values. + """ + return Iq(self.xmpp, *args, **kwargs) + + def Presence(self, *args, **kwargs): + """ + Create a Presence stanza. + + Uses same arguments as StanzaBase.__init__ + + Arguments: + xml -- An XML object to use for the Iq's values. + """ + return Presence(self.xmpp, *args, **kwargs) + + def check_jid(self, jid, user=None, domain=None, resource=None, + bare=None, full=None, string=None): + """ + Verify the components of a JID. + + Arguments: + jid -- The JID object to test. + user -- Optional. The user name portion of the JID. + domain -- Optional. The domain name portion of the JID. + resource -- Optional. The resource portion of the JID. + bare -- Optional. The bare JID. + full -- Optional. The full JID. + string -- Optional. The string version of the JID. + """ + if user is not None: + self.assertEqual(jid.user, user, + "User does not match: %s" % jid.user) + if domain is not None: + self.assertEqual(jid.domain, domain, + "Domain does not match: %s" % jid.domain) + if resource is not None: + self.assertEqual(jid.resource, resource, + "Resource does not match: %s" % jid.resource) + if bare is not None: + self.assertEqual(jid.bare, bare, + "Bare JID does not match: %s" % jid.bare) + if full is not None: + self.assertEqual(jid.full, full, + "Full JID does not match: %s" % jid.full) + if string is not None: + self.assertEqual(str(jid), string, + "String does not match: %s" % str(jid)) + + def check_roster(self, owner, jid, name=None, subscription=None, + afrom=None, ato=None, pending_out=None, pending_in=None, + groups=None): + roster = self.xmpp.roster[owner][jid] + if name is not None: + self.assertEqual(roster['name'], name, + "Incorrect name value: %s" % roster['name']) + if subscription is not None: + self.assertEqual(roster['subscription'], subscription, + "Incorrect subscription: %s" % roster['subscription']) + if afrom is not None: + self.assertEqual(roster['from'], afrom, + "Incorrect from state: %s" % roster['from']) + if ato is not None: + self.assertEqual(roster['to'], ato, + "Incorrect to state: %s" % roster['to']) + if pending_out is not None: + self.assertEqual(roster['pending_out'], pending_out, + "Incorrect pending_out state: %s" % roster['pending_out']) + if pending_in is not None: + self.assertEqual(roster['pending_in'], pending_out, + "Incorrect pending_in state: %s" % roster['pending_in']) + if groups is not None: + self.assertEqual(roster['groups'], groups, + "Incorrect groups: %s" % roster['groups']) + + # ------------------------------------------------------------------ + # Methods for comparing stanza objects to XML strings + + def check(self, stanza, criteria, method='exact', + defaults=None, use_values=True): + """ + Create and compare several stanza objects to a correct XML string. + + If use_values is False, tests using stanza.values will not be used. + + Some stanzas provide default values for some interfaces, but + these defaults can be problematic for testing since they can easily + be forgotten when supplying the XML string. A list of interfaces that + use defaults may be provided and the generated stanzas will use the + default values for those interfaces if needed. + + However, correcting the supplied XML is not possible for interfaces + that add or remove XML elements. Only interfaces that map to XML + attributes may be set using the defaults parameter. The supplied XML + must take into account any extra elements that are included by default. + + Arguments: + stanza -- The stanza object to test. + criteria -- An expression the stanza must match against. + method -- The type of matching to use; one of: + 'exact', 'mask', 'id', 'xpath', and 'stanzapath'. + Defaults to the value of self.match_method. + defaults -- A list of stanza interfaces that have default + values. These interfaces will be set to their + defaults for the given and generated stanzas to + prevent unexpected test failures. + use_values -- Indicates if testing using stanza.values should + be used. Defaults to True. + """ + if method is None and hasattr(self, 'match_method'): + method = getattr(self, 'match_method') + + if method != 'exact': + matchers = {'stanzapath': StanzaPath, + 'xpath': MatchXPath, + 'mask': MatchXMLMask, + 'id': MatcherId} + Matcher = matchers.get(method, None) + if Matcher is None: + raise ValueError("Unknown matching method.") + test = Matcher(criteria) + self.failUnless(test.match(stanza), + "Stanza did not match using %s method:\n" % method + \ + "Criteria:\n%s\n" % str(criteria) + \ + "Stanza:\n%s" % str(stanza)) + else: + stanza_class = stanza.__class__ + if not isinstance(criteria, ElementBase): + xml = self.parse_xml(criteria) + else: + xml = criteria.xml + + # Ensure that top level namespaces are used, even if they + # were not provided. + self.fix_namespaces(stanza.xml, 'jabber:client') + self.fix_namespaces(xml, 'jabber:client') + + stanza2 = stanza_class(xml=xml) + + if use_values: + # Using stanza.values will add XML for any interface that + # has a default value. We need to set those defaults on + # the existing stanzas and XML so that they will compare + # correctly. + default_stanza = stanza_class() + if defaults is None: + known_defaults = { + Message: ['type'], + Presence: ['priority'] + } + defaults = known_defaults.get(stanza_class, []) + for interface in defaults: + stanza[interface] = stanza[interface] + stanza2[interface] = stanza2[interface] + # Can really only automatically add defaults for top + # level attribute values. Anything else must be accounted + # for in the provided XML string. + if interface not in xml.attrib: + if interface in default_stanza.xml.attrib: + value = default_stanza.xml.attrib[interface] + xml.attrib[interface] = value + + values = stanza2.values + stanza3 = stanza_class() + stanza3.values = values + + debug = "Three methods for creating stanzas do not match.\n" + debug += "Given XML:\n%s\n" % tostring(xml) + debug += "Given stanza:\n%s\n" % tostring(stanza.xml) + debug += "Generated stanza:\n%s\n" % tostring(stanza2.xml) + debug += "Second generated stanza:\n%s\n" % tostring(stanza3.xml) + result = self.compare(xml, stanza.xml, stanza2.xml, stanza3.xml) + else: + debug = "Two methods for creating stanzas do not match.\n" + debug += "Given XML:\n%s\n" % tostring(xml) + debug += "Given stanza:\n%s\n" % tostring(stanza.xml) + debug += "Generated stanza:\n%s\n" % tostring(stanza2.xml) + result = self.compare(xml, stanza.xml, stanza2.xml) + + self.failUnless(result, debug) + + # ------------------------------------------------------------------ + # Methods for simulating stanza streams. + + def stream_disconnect(self): + """ + Simulate a stream disconnection. + """ + if self.xmpp: + self.xmpp.socket.disconnect_error() + + def stream_start(self, mode='client', skip=True, header=None, + socket='mock', jid='tester@localhost', + password='test', server='localhost', + port=5222, sasl_mech=None, + plugins=None, plugin_config={}): + """ + Initialize an XMPP client or component using a dummy XML stream. + + Arguments: + mode -- Either 'client' or 'component'. Defaults to 'client'. + skip -- Indicates if the first item in the sent queue (the + stream header) should be removed. Tests that wish + to test initializing the stream should set this to + False. Otherwise, the default of True should be used. + socket -- Either 'mock' or 'live' to indicate if the socket + should be a dummy, mock socket or a live, functioning + socket. Defaults to 'mock'. + jid -- The JID to use for the connection. + Defaults to 'tester@localhost'. + password -- The password to use for the connection. + Defaults to 'test'. + server -- The name of the XMPP server. Defaults to 'localhost'. + port -- The port to use when connecting to the server. + Defaults to 5222. + plugins -- List of plugins to register. By default, all plugins + are loaded. + """ + if mode == 'client': + self.xmpp = ClientXMPP(jid, password, + sasl_mech=sasl_mech, + plugin_config=plugin_config) + elif mode == 'component': + self.xmpp = ComponentXMPP(jid, password, + server, port, + plugin_config=plugin_config) + else: + raise ValueError("Unknown XMPP connection mode.") + + # We will use this to wait for the session_start event + # for live connections. + skip_queue = queue.Queue() + + if socket == 'mock': + self.xmpp.set_socket(TestSocket()) + + # Simulate connecting for mock sockets. + self.xmpp.auto_reconnect = False + self.xmpp.state._set_state('connected') + + # Must have the stream header ready for xmpp.process() to work. + if not header: + header = self.xmpp.stream_header + self.xmpp.socket.recv_data(header) + elif socket == 'live': + self.xmpp.socket_class = TestLiveSocket + + def wait_for_session(x): + self.xmpp.socket.clear() + skip_queue.put('started') + + self.xmpp.add_event_handler('session_start', wait_for_session) + if server is not None: + self.xmpp.connect((server, port)) + else: + self.xmpp.connect() + else: + raise ValueError("Unknown socket type.") + + if plugins is None: + self.xmpp.register_plugins() + else: + for plugin in plugins: + self.xmpp.register_plugin(plugin) + self.xmpp.process(threaded=True) + if skip: + if socket != 'live': + # Mark send queue as usable + self.xmpp.session_started_event.set() + # Clear startup stanzas + self.xmpp.socket.next_sent(timeout=1) + if mode == 'component': + self.xmpp.socket.next_sent(timeout=1) + else: + skip_queue.get(block=True, timeout=10) + + def make_header(self, sto='', + sfrom='', + sid='', + stream_ns="http://etherx.jabber.org/streams", + default_ns="jabber:client", + version="1.0", + xml_header=True): + """ + Create a stream header to be received by the test XMPP agent. + + The header must be saved and passed to stream_start. + + Arguments: + sto -- The recipient of the stream header. + sfrom -- The agent sending the stream header. + sid -- The stream's id. + stream_ns -- The namespace of the stream's root element. + default_ns -- The default stanza namespace. + version -- The stream version. + xml_header -- Indicates if the XML version header should be + appended before the stream header. + """ + header = '<stream:stream %s>' + parts = [] + if xml_header: + header = '<?xml version="1.0"?>' + header + if sto: + parts.append('to="%s"' % sto) + if sfrom: + parts.append('from="%s"' % sfrom) + if sid: + parts.append('id="%s"' % sid) + parts.append('version="%s"' % version) + parts.append('xmlns:stream="%s"' % stream_ns) + parts.append('xmlns="%s"' % default_ns) + return header % ' '.join(parts) + + def recv(self, data, defaults=[], method='exact', + use_values=True, timeout=1): + """ + Pass data to the dummy XMPP client as if it came from an XMPP server. + + If using a live connection, verify what the server has sent. + + Arguments: + data -- If a dummy socket is being used, the XML that is to + be received next. Otherwise it is the criteria used + to match against live data that is received. + defaults -- A list of stanza interfaces with default values that + may interfere with comparisons. + method -- Select the type of comparison to use for + verifying the received stanza. Options are 'exact', + 'id', 'stanzapath', 'xpath', and 'mask'. + Defaults to the value of self.match_method. + use_values -- Indicates if stanza comparisons should test using + stanza.values. Defaults to True. + timeout -- Time to wait in seconds for data to be received by + a live connection. + """ + if self.xmpp.socket.is_live: + # we are working with a live connection, so we should + # verify what has been received instead of simulating + # receiving data. + recv_data = self.xmpp.socket.next_recv(timeout) + if recv_data is None: + self.fail("No stanza was received.") + xml = self.parse_xml(recv_data) + self.fix_namespaces(xml, 'jabber:client') + stanza = self.xmpp._build_stanza(xml, 'jabber:client') + self.check(stanza, data, + method=method, + defaults=defaults, + use_values=use_values) + else: + # place the data in the dummy socket receiving queue. + data = str(data) + self.xmpp.socket.recv_data(data) + + def recv_header(self, sto='', + sfrom='', + sid='', + stream_ns="http://etherx.jabber.org/streams", + default_ns="jabber:client", + version="1.0", + xml_header=False, + timeout=1): + """ + Check that a given stream header was received. + + Arguments: + sto -- The recipient of the stream header. + sfrom -- The agent sending the stream header. + sid -- The stream's id. Set to None to ignore. + stream_ns -- The namespace of the stream's root element. + default_ns -- The default stanza namespace. + version -- The stream version. + xml_header -- Indicates if the XML version header should be + appended before the stream header. + timeout -- Length of time to wait in seconds for a + response. + """ + header = self.make_header(sto, sfrom, sid, + stream_ns=stream_ns, + default_ns=default_ns, + version=version, + xml_header=xml_header) + recv_header = self.xmpp.socket.next_recv(timeout) + if recv_header is None: + raise ValueError("Socket did not return data.") + + # Apply closing elements so that we can construct + # XML objects for comparison. + header2 = header + '</stream:stream>' + recv_header2 = recv_header + '</stream:stream>' + + xml = self.parse_xml(header2) + recv_xml = self.parse_xml(recv_header2) + + if sid is None: + # Ignore the id sent by the server since + # we can't know in advance what it will be. + if 'id' in recv_xml.attrib: + del recv_xml.attrib['id'] + + # Ignore the xml:lang attribute for now. + if 'xml:lang' in recv_xml.attrib: + del recv_xml.attrib['xml:lang'] + xml_ns = 'http://www.w3.org/XML/1998/namespace' + if '{%s}lang' % xml_ns in recv_xml.attrib: + del recv_xml.attrib['{%s}lang' % xml_ns] + + if recv_xml.getchildren: + # We received more than just the header + for xml in recv_xml.getchildren(): + self.xmpp.socket.recv_data(tostring(xml)) + + attrib = recv_xml.attrib + recv_xml.clear() + recv_xml.attrib = attrib + + self.failUnless( + self.compare(xml, recv_xml), + "Stream headers do not match:\nDesired:\n%s\nReceived:\n%s" % ( + '%s %s' % (xml.tag, xml.attrib), + '%s %s' % (recv_xml.tag, recv_xml.attrib))) + + def recv_feature(self, data, method='mask', use_values=True, timeout=1): + """ + """ + if method is None and hasattr(self, 'match_method'): + method = getattr(self, 'match_method') + + if self.xmpp.socket.is_live: + # we are working with a live connection, so we should + # verify what has been received instead of simulating + # receiving data. + recv_data = self.xmpp.socket.next_recv(timeout) + xml = self.parse_xml(data) + recv_xml = self.parse_xml(recv_data) + if recv_data is None: + self.fail("No stanza was received.") + if method == 'exact': + self.failUnless(self.compare(xml, recv_xml), + "Features do not match.\nDesired:\n%s\nReceived:\n%s" % ( + tostring(xml), tostring(recv_xml))) + elif method == 'mask': + matcher = MatchXMLMask(xml) + self.failUnless(matcher.match(recv_xml), + "Stanza did not match using %s method:\n" % method + \ + "Criteria:\n%s\n" % tostring(xml) + \ + "Stanza:\n%s" % tostring(recv_xml)) + else: + raise ValueError("Uknown matching method: %s" % method) + else: + # place the data in the dummy socket receiving queue. + data = str(data) + self.xmpp.socket.recv_data(data) + + def send_header(self, sto='', + sfrom='', + sid='', + stream_ns="http://etherx.jabber.org/streams", + default_ns="jabber:client", + version="1.0", + xml_header=False, + timeout=1): + """ + Check that a given stream header was sent. + + Arguments: + sto -- The recipient of the stream header. + sfrom -- The agent sending the stream header. + sid -- The stream's id. + stream_ns -- The namespace of the stream's root element. + default_ns -- The default stanza namespace. + version -- The stream version. + xml_header -- Indicates if the XML version header should be + appended before the stream header. + timeout -- Length of time to wait in seconds for a + response. + """ + header = self.make_header(sto, sfrom, sid, + stream_ns=stream_ns, + default_ns=default_ns, + version=version, + xml_header=xml_header) + sent_header = self.xmpp.socket.next_sent(timeout) + if sent_header is None: + raise ValueError("Socket did not return data.") + + # Apply closing elements so that we can construct + # XML objects for comparison. + header2 = header + '</stream:stream>' + sent_header2 = sent_header + b'</stream:stream>' + + xml = self.parse_xml(header2) + sent_xml = self.parse_xml(sent_header2) + + self.failUnless( + self.compare(xml, sent_xml), + "Stream headers do not match:\nDesired:\n%s\nSent:\n%s" % ( + header, sent_header)) + + def send_feature(self, data, method='mask', use_values=True, timeout=1): + """ + """ + sent_data = self.xmpp.socket.next_sent(timeout) + xml = self.parse_xml(data) + sent_xml = self.parse_xml(sent_data) + if sent_data is None: + self.fail("No stanza was sent.") + if method == 'exact': + self.failUnless(self.compare(xml, sent_xml), + "Features do not match.\nDesired:\n%s\nReceived:\n%s" % ( + tostring(xml), tostring(sent_xml))) + elif method == 'mask': + matcher = MatchXMLMask(xml) + self.failUnless(matcher.match(sent_xml), + "Stanza did not match using %s method:\n" % method + \ + "Criteria:\n%s\n" % tostring(xml) + \ + "Stanza:\n%s" % tostring(sent_xml)) + else: + raise ValueError("Uknown matching method: %s" % method) + + def send(self, data, defaults=None, use_values=True, + timeout=.5, method='exact'): + """ + Check that the XMPP client sent the given stanza XML. + + Extracts the next sent stanza and compares it with the given + XML using check. + + Arguments: + stanza_class -- The class of the sent stanza object. + data -- The XML string of the expected Message stanza, + or an equivalent stanza object. + use_values -- Modifies the type of tests used by check_message. + defaults -- A list of stanza interfaces that have defaults + values which may interfere with comparisons. + timeout -- Time in seconds to wait for a stanza before + failing the check. + method -- Select the type of comparison to use for + verifying the sent stanza. Options are 'exact', + 'id', 'stanzapath', 'xpath', and 'mask'. + Defaults to the value of self.match_method. + """ + sent = self.xmpp.socket.next_sent(timeout) + if data is None and sent is None: + return + if data is None and sent is not None: + self.fail("Stanza data was sent: %s" % sent) + if sent is None: + self.fail("No stanza was sent.") + + xml = self.parse_xml(sent) + self.fix_namespaces(xml, 'jabber:client') + sent = self.xmpp._build_stanza(xml, 'jabber:client') + self.check(sent, data, + method=method, + defaults=defaults, + use_values=use_values) + + def stream_close(self): + """ + Disconnect the dummy XMPP client. + + Can be safely called even if stream_start has not been called. + + Must be placed in the tearDown method of a test class to ensure + that the XMPP client is disconnected after an error. + """ + if hasattr(self, 'xmpp') and self.xmpp is not None: + self.xmpp.socket.recv_data(self.xmpp.stream_footer) + self.xmpp.disconnect() + + # ------------------------------------------------------------------ + # XML Comparison and Cleanup + + def fix_namespaces(self, xml, ns): + """ + Assign a namespace to an element and any children that + don't have a namespace. + + Arguments: + xml -- The XML object to fix. + ns -- The namespace to add to the XML object. + """ + if xml.tag.startswith('{'): + return + xml.tag = '{%s}%s' % (ns, xml.tag) + for child in xml.getchildren(): + self.fix_namespaces(child, ns) + + def compare(self, xml, *other): + """ + Compare XML objects. + + Arguments: + xml -- The XML object to compare against. + *other -- The list of XML objects to compare. + """ + if not other: + return False + + # Compare multiple objects + if len(other) > 1: + for xml2 in other: + if not self.compare(xml, xml2): + return False + return True + + other = other[0] + + # Step 1: Check tags + if xml.tag != other.tag: + return False + + # Step 2: Check attributes + if xml.attrib != other.attrib: + return False + + # Step 3: Check text + if xml.text is None: + xml.text = "" + if other.text is None: + other.text = "" + xml.text = xml.text.strip() + other.text = other.text.strip() + + if xml.text != other.text: + return False + + # Step 4: Check children count + if len(xml.getchildren()) != len(other.getchildren()): + return False + + # Step 5: Recursively check children + for child in xml: + child2s = other.findall("%s" % child.tag) + if child2s is None: + return False + for child2 in child2s: + if self.compare(child, child2): + break + else: + return False + + # Step 6: Recursively check children the other way. + for child in other: + child2s = xml.findall("%s" % child.tag) + if child2s is None: + return False + for child2 in child2s: + if self.compare(child, child2): + break + else: + return False + + # Everything matches + return True diff --git a/sleekxmpp/thirdparty/__init__.py b/sleekxmpp/thirdparty/__init__.py new file mode 100644 index 00000000..1c7bf651 --- /dev/null +++ b/sleekxmpp/thirdparty/__init__.py @@ -0,0 +1,7 @@ +try: + from collections import OrderedDict +except: + from sleekxmpp.thirdparty.ordereddict import OrderedDict + +from sleekxmpp.thirdparty import suelta +from sleekxmpp.thirdparty.mini_dateutil import tzutc, tzoffset, parse_iso diff --git a/sleekxmpp/thirdparty/mini_dateutil.py b/sleekxmpp/thirdparty/mini_dateutil.py new file mode 100644 index 00000000..6af5ffde --- /dev/null +++ b/sleekxmpp/thirdparty/mini_dateutil.py @@ -0,0 +1,267 @@ +# This module is a very stripped down version of the dateutil +# package for when dateutil has not been installed. As a replacement +# for dateutil.parser.parse, the parsing methods from +# http://blog.mfabrik.com/2008/06/30/relativity-of-time-shortcomings-in-python-datetime-and-workaround/ + +#As such, the following copyrights and licenses applies: + + +# dateutil - Extensions to the standard python 2.3+ datetime module. +# +# Copyright (c) 2003-2011 - Gustavo Niemeyer <gustavo@niemeyer.net> +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +# fixed_dateime +# +# Copyright (c) 2008, Red Innovation Ltd., Finland +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of Red Innovation nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY RED INNOVATION ``AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL RED INNOVATION BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + +import re +import datetime + + +ZERO = datetime.timedelta(0) + + +try: + from dateutil.parser import parse as parse_iso + from dateutil.tz import tzoffset, tzutc +except: + # As a stopgap, define the two timezones here based + # on the dateutil code. + + class tzutc(datetime.tzinfo): + + def utcoffset(self, dt): + return ZERO + + def dst(self, dt): + return ZERO + + def tzname(self, dt): + return "UTC" + + def __eq__(self, other): + return (isinstance(other, tzutc) or + (isinstance(other, tzoffset) and other._offset == ZERO)) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s()" % self.__class__.__name__ + + __reduce__ = object.__reduce__ + + class tzoffset(datetime.tzinfo): + + def __init__(self, name, offset): + self._name = name + self._offset = datetime.timedelta(seconds=offset) + + def utcoffset(self, dt): + return self._offset + + def dst(self, dt): + return ZERO + + def tzname(self, dt): + return self._name + + def __eq__(self, other): + return (isinstance(other, tzoffset) and + self._offset == other._offset) + + def __ne__(self, other): + return not self.__eq__(other) + + def __repr__(self): + return "%s(%s, %s)" % (self.__class__.__name__, + repr(self._name), + self._offset.days*86400+self._offset.seconds) + + __reduce__ = object.__reduce__ + + + _fixed_offset_tzs = { } + UTC = tzutc() + + def _get_fixed_offset_tz(offsetmins): + """For internal use only: Returns a tzinfo with + the given fixed offset. This creates only one instance + for each offset; the zones are kept in a dictionary""" + + if offsetmins == 0: + return UTC + + if not offsetmins in _fixed_offset_tzs: + if offsetmins < 0: + sign = '-' + absoff = -offsetmins + else: + sign = '+' + absoff = offsetmins + + name = "UTC%s%02d:%02d" % (sign, int(absoff / 60), absoff % 60) + inst = tzoffset(offsetmins, name) + _fixed_offset_tzs[offsetmins] = inst + + return _fixed_offset_tzs[offsetmins] + + + _iso8601_parser = re.compile(""" + ^ + (?P<year> [0-9]{4})?(?P<ymdsep>-?)? + (?P<month>[0-9]{2})?(?P=ymdsep)? + (?P<day> [0-9]{2})? + + (?: # time part... optional... at least hour must be specified + (?:T|\s+)? + (?P<hour>[0-9]{2}) + (?: + # minutes, separated with :, or none, from hours + (?P<hmssep>[:]?) + (?P<minute>[0-9]{2}) + (?: + # same for seconds, separated with :, or none, from hours + (?P=hmssep) + (?P<second>[0-9]{2}) + )? + )? + + # fractions + (?: [,.] (?P<frac>[0-9]{1,10}))? + + # timezone, Z, +-hh or +-hh:?mm. MUST BE, but complain if not there. + ( + (?P<tzempty>Z) + | + (?P<tzh>[+-][0-9]{2}) + (?: :? # optional separator + (?P<tzm>[0-9]{2}) + )? + )? + )? + $ + """, re.X) # """ + + def parse_iso(timestamp): + """Internal function for parsing a timestamp in + ISO 8601 format""" + + timestamp = timestamp.strip() + + m = _iso8601_parser.match(timestamp) + if not m: + raise ValueError("Not a proper ISO 8601 timestamp!: %s" % timestamp) + + vals = m.groupdict() + def_vals = {'year': 1970, 'month': 1, 'day': 1} + for key in vals: + if vals[key] is None: + vals[key] = def_vals.get(key, 0) + elif key not in ['ymdsep', 'hmssep', 'tzempty']: + vals[key] = int(vals[key]) + + year = vals['year'] + month = vals['month'] + day = vals['day'] + + h, min, s, us = None, None, None, 0 + frac = 0 + if m.group('tzempty') == None and m.group('tzh') == None: + raise ValueError("Not a proper ISO 8601 timestamp: " + + "missing timezone (Z or +hh[:mm])!") + + if m.group('frac'): + frac = m.group('frac') + power = len(frac) + frac = int(frac) / 10.0 ** power + + if m.group('hour'): + h = vals['hour'] + + if m.group('minute'): + min = vals['minute'] + + if m.group('second'): + s = vals['second'] + + if frac != None: + # ok, fractions of hour? + if min == None: + frac, min = _math.modf(frac * 60.0) + min = int(min) + + # fractions of second? + if s == None: + frac, s = _math.modf(frac * 60.0) + s = int(s) + + # and extract microseconds... + us = int(frac * 1000000) + + if m.group('tzempty') == 'Z': + offsetmins = 0 + else: + # timezone: hour diff with sign + offsetmins = vals['tzh'] * 60 + tzm = m.group('tzm') + + # add optional minutes + if tzm != None: + tzm = int(tzm) + offsetmins += tzm if offsetmins > 0 else -tzm + + tz = _get_fixed_offset_tz(offsetmins) + return datetime.datetime(year, month, day, h, min, s, us, tz) diff --git a/sleekxmpp/thirdparty/ordereddict.py b/sleekxmpp/thirdparty/ordereddict.py new file mode 100644 index 00000000..5b0303f5 --- /dev/null +++ b/sleekxmpp/thirdparty/ordereddict.py @@ -0,0 +1,127 @@ +# Copyright (c) 2009 Raymond Hettinger
+#
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation files
+# (the "Software"), to deal in the Software without restriction,
+# including without limitation the rights to use, copy, modify, merge,
+# publish, distribute, sublicense, and/or sell copies of the Software,
+# and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+
+from UserDict import DictMixin
+
+class OrderedDict(dict, DictMixin):
+
+ def __init__(self, *args, **kwds):
+ if len(args) > 1:
+ raise TypeError('expected at most 1 arguments, got %d' % len(args))
+ try:
+ self.__end
+ except AttributeError:
+ self.clear()
+ self.update(*args, **kwds)
+
+ def clear(self):
+ self.__end = end = []
+ end += [None, end, end] # sentinel node for doubly linked list
+ self.__map = {} # key --> [key, prev, next]
+ dict.clear(self)
+
+ def __setitem__(self, key, value):
+ if key not in self:
+ end = self.__end
+ curr = end[1]
+ curr[2] = end[1] = self.__map[key] = [key, curr, end]
+ dict.__setitem__(self, key, value)
+
+ def __delitem__(self, key):
+ dict.__delitem__(self, key)
+ key, prev, next = self.__map.pop(key)
+ prev[2] = next
+ next[1] = prev
+
+ def __iter__(self):
+ end = self.__end
+ curr = end[2]
+ while curr is not end:
+ yield curr[0]
+ curr = curr[2]
+
+ def __reversed__(self):
+ end = self.__end
+ curr = end[1]
+ while curr is not end:
+ yield curr[0]
+ curr = curr[1]
+
+ def popitem(self, last=True):
+ if not self:
+ raise KeyError('dictionary is empty')
+ if last:
+ key = reversed(self).next()
+ else:
+ key = iter(self).next()
+ value = self.pop(key)
+ return key, value
+
+ def __reduce__(self):
+ items = [[k, self[k]] for k in self]
+ tmp = self.__map, self.__end
+ del self.__map, self.__end
+ inst_dict = vars(self).copy()
+ self.__map, self.__end = tmp
+ if inst_dict:
+ return (self.__class__, (items,), inst_dict)
+ return self.__class__, (items,)
+
+ def keys(self):
+ return list(self)
+
+ setdefault = DictMixin.setdefault
+ update = DictMixin.update
+ pop = DictMixin.pop
+ values = DictMixin.values
+ items = DictMixin.items
+ iterkeys = DictMixin.iterkeys
+ itervalues = DictMixin.itervalues
+ iteritems = DictMixin.iteritems
+
+ def __repr__(self):
+ if not self:
+ return '%s()' % (self.__class__.__name__,)
+ return '%s(%r)' % (self.__class__.__name__, self.items())
+
+ def copy(self):
+ return self.__class__(self)
+
+ @classmethod
+ def fromkeys(cls, iterable, value=None):
+ d = cls()
+ for key in iterable:
+ d[key] = value
+ return d
+
+ def __eq__(self, other):
+ if isinstance(other, OrderedDict):
+ if len(self) != len(other):
+ return False
+ for p, q in zip(self.items(), other.items()):
+ if p != q:
+ return False
+ return True
+ return dict.__eq__(self, other)
+
+ def __ne__(self, other):
+ return not self == other
diff --git a/sleekxmpp/thirdparty/statemachine.py b/sleekxmpp/thirdparty/statemachine.py new file mode 100644 index 00000000..8a7324b5 --- /dev/null +++ b/sleekxmpp/thirdparty/statemachine.py @@ -0,0 +1,287 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +import threading +import time +import logging + +log = logging.getLogger(__name__) + + +class StateMachine(object): + + def __init__(self, states=[]): + self.lock = threading.Lock() + self.notifier = threading.Event() + self.__states = [] + self.addStates(states) + self.__default_state = self.__states[0] + self.__current_state = self.__default_state + + def addStates(self, states): + self.lock.acquire() + try: + for state in states: + if state in self.__states: + raise IndexError("The state '%s' is already in the StateMachine." % state) + self.__states.append(state) + finally: self.lock.release() + + + def transition(self, from_state, to_state, wait=0.0, func=None, args=[], kwargs={}): + ''' + Transition from the given `from_state` to the given `to_state`. + This method will return `True` if the state machine is now in `to_state`. It + will return `False` if a timeout occurred the transition did not occur. + If `wait` is 0 (the default,) this method returns immediately if the state machine + is not in `from_state`. + + If you want the thread to block and transition once the state machine to enters + `from_state`, set `wait` to a non-negative value. Note there is no 'block + indefinitely' flag since this leads to deadlock. If you want to wait indefinitely, + choose a reasonable value for `wait` (e.g. 20 seconds) and do so in a while loop like so: + + :: + + while not thread_should_exit and not state_machine.transition('disconnected', 'connecting', wait=20 ): + pass # timeout will occur every 20s unless transition occurs + if thread_should_exit: return + # perform actions here after successful transition + + This allows the thread to be responsive by setting `thread_should_exit=True`. + + The optional `func` argument allows the user to pass a callable operation which occurs + within the context of the state transition (e.g. while the state machine is locked.) + If `func` returns a True value, the transition will occur. If `func` returns a non- + True value or if an exception is thrown, the transition will not occur. Any thrown + exception is not caught by the state machine and is the caller's responsibility to handle. + If `func` completes normally, this method will return the value returned by `func.` If + values for `args` and `kwargs` are provided, they are expanded and passed like so: + `func( *args, **kwargs )`. + ''' + + return self.transition_any((from_state,), to_state, wait=wait, + func=func, args=args, kwargs=kwargs) + + + def transition_any(self, from_states, to_state, wait=0.0, func=None, args=[], kwargs={}): + ''' + Transition from any of the given `from_states` to the given `to_state`. + ''' + + if not (isinstance(from_states,tuple) or isinstance(from_states,list)): + raise ValueError("from_states should be a list or tuple") + + for state in from_states: + if not state in self.__states: + raise ValueError("StateMachine does not contain from_state %s." % state) + if not to_state in self.__states: + raise ValueError("StateMachine does not contain to_state %s." % to_state) + + start = time.time() + while not self.lock.acquire(False): + time.sleep(.001) + if (start + wait - time.time()) <= 0.0: + log.debug("Could not acquire lock") + return False + + while not self.__current_state in from_states: + # detect timeout: + remainder = start + wait - time.time() + if remainder > 0: + self.notifier.wait(remainder) + else: + log.debug("State was not ready") + self.lock.release() + return False + + try: # lock is acquired; all other threads will return false or wait until notify/timeout + if self.__current_state in from_states: # should always be True due to lock + + # Note that func might throw an exception, but that's OK, it aborts the transition + return_val = func(*args,**kwargs) if func is not None else True + + # some 'false' value returned from func, + # indicating that transition should not occur: + if not return_val: return return_val + + log.debug(' ==== TRANSITION %s -> %s', self.__current_state, to_state) + self._set_state(to_state) + return return_val # some 'true' value returned by func or True if func was None + else: + log.error("StateMachine bug!! The lock should ensure this doesn't happen!") + return False + finally: + self.notifier.set() # notify any waiting threads that the state has changed. + self.notifier.clear() + self.lock.release() + + + def transition_ctx(self, from_state, to_state, wait=0.0): + ''' + Use the state machine as a context manager. The transition occurs on /exit/ from + the `with` context, so long as no exception is thrown. For example: + + :: + + with state_machine.transition_ctx('one','two', wait=5) as locked: + if locked: + # the state machine is currently locked in state 'one', and will + # transition to 'two' when the 'with' statement ends, so long as + # no exception is thrown. + print 'Currently locked in state one: %s' % state_machine['one'] + + else: + # The 'wait' timed out, and no lock has been acquired + print 'Timed out before entering state "one"' + + print 'Since no exception was thrown, we are now in state "two": %s' % state_machine['two'] + + + The other main difference between this method and `transition()` is that the + state machine is locked for the duration of the `with` statement. Normally, + after a `transition()` occurs, the state machine is immediately unlocked and + available to another thread to call `transition()` again. + ''' + + if not from_state in self.__states: + raise ValueError("StateMachine does not contain from_state %s." % from_state) + if not to_state in self.__states: + raise ValueError("StateMachine does not contain to_state %s." % to_state) + + return _StateCtx(self, from_state, to_state, wait) + + + def ensure(self, state, wait=0.0, block_on_transition=False): + ''' + Ensure the state machine is currently in `state`, or wait until it enters `state`. + ''' + return self.ensure_any((state,), wait=wait, block_on_transition=block_on_transition) + + + def ensure_any(self, states, wait=0.0, block_on_transition=False): + ''' + Ensure we are currently in one of the given `states` or wait until + we enter one of those states. + + Note that due to the nature of the function, you cannot guarantee that + the entirety of some operation completes while you remain in a given + state. That would require acquiring and holding a lock, which + would mean no other threads could do the same. (You'd essentially + be serializing all of the threads that are 'ensuring' their tasks + occurred in some state. + ''' + if not (isinstance(states,tuple) or isinstance(states,list)): + raise ValueError('states arg should be a tuple or list') + + for state in states: + if not state in self.__states: + raise ValueError("StateMachine does not contain state '%s'" % state) + + # if we're in the middle of a transition, determine whether we should + # 'fall back' to the 'current' state, or wait for the new state, in order to + # avoid an operation occurring in the wrong state. + # TODO another option would be an ensure_ctx that uses a semaphore to allow + # threads to indicate they want to remain in a particular state. + + # will return immediately if no transition is in process. + if block_on_transition: + # we're not in the middle of a transition; don't hold the lock + if self.lock.acquire(False): self.lock.release() + # wait for the transition to complete + else: self.notifier.wait() + + start = time.time() + while not self.__current_state in states: + # detect timeout: + remainder = start + wait - time.time() + if remainder > 0: self.notifier.wait(remainder) + else: return False + return True + + + def reset(self): + # TODO need to lock before calling this? + self.transition(self.__current_state, self.__default_state) + + + def _set_state(self, state): #unsynchronized, only call internally after lock is acquired + self.__current_state = state + return state + + + def current_state(self): + ''' + Return the current state name. + ''' + return self.__current_state + + + def __getitem__(self, state): + ''' + Non-blocking, non-synchronized test to determine if we are in the given state. + Use `StateMachine.ensure(state)` to wait until the machine enters a certain state. + ''' + return self.__current_state == state + + def __str__(self): + return "".join(("StateMachine(", ','.join(self.__states), "): ", self.__current_state)) + + + +class _StateCtx: + + def __init__(self, state_machine, from_state, to_state, wait): + self.state_machine = state_machine + self.from_state = from_state + self.to_state = to_state + self.wait = wait + self._locked = False + + def __enter__(self): + start = time.time() + while not self.state_machine[self.from_state] or not self.state_machine.lock.acquire(False): + # detect timeout: + remainder = start + self.wait - time.time() + if remainder > 0: self.state_machine.notifier.wait(remainder) + else: + log.debug('StateMachine timeout while waiting for state: %s', self.from_state) + return False + + self._locked = True # lock has been acquired at this point + self.state_machine.notifier.clear() + log.debug('StateMachine entered context in state: %s', + self.state_machine.current_state()) + return True + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val is not None: + log.exception("StateMachine exception in context, remaining in state: %s\n%s:%s", + self.state_machine.current_state(), exc_type.__name__, exc_val) + + if self._locked: + if exc_val is None: + log.debug(' ==== TRANSITION %s -> %s', + self.state_machine.current_state(), self.to_state) + self.state_machine._set_state(self.to_state) + + self.state_machine.notifier.set() + self.state_machine.lock.release() + + return False # re-raise any exception + +if __name__ == '__main__': + + def callback(s, s2): + print((1, s.transition('on', 'off', wait=0.0, func=callback, args=[s,s2]))) + print((2, s2.transition('off', 'on', func=callback, args=[s,s2]))) + return True + + s = StateMachine(('off', 'on')) + s2 = StateMachine(('off', 'on')) + print((3, s.transition('off', 'on', wait=0.0, func=callback, args=[s,s2]),)) + print((s.current_state(), s2.current_state())) diff --git a/sleekxmpp/thirdparty/suelta/LICENSE b/sleekxmpp/thirdparty/suelta/LICENSE new file mode 100644 index 00000000..6eee4f33 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/LICENSE @@ -0,0 +1,21 @@ +This software is subject to "The MIT License" + +Copyright 2007-2010 David Alan Cridland + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/sleekxmpp/thirdparty/suelta/PLAYING-NICELY b/sleekxmpp/thirdparty/suelta/PLAYING-NICELY new file mode 100644 index 00000000..393b8078 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/PLAYING-NICELY @@ -0,0 +1,27 @@ +Hi. + +This is a short note explaining the license in non-legally-binding terms, and +describing how I hope to see people work with the licensing. + +First off, the license is permissive, and more or less allows you to do +anything, as long as you leave my credit and copyright intact. + +You can, and are very much welcome to, include this in commercial works, and +in code that has tightly controlled distribution, as well as open-source. + +If it doesn't work - and I have no doubt that there are bugs - then this is +largely your problem. + +If you do find a bug, though, do let me know - although you don't have to. + +And if you fix it, I'd greatly appreciate a patch, too. Please give me a +licensing statement, and a copyright statement, along with your patch. + +Similarly, any enhancements are welcome, and also will need copyright and +licensing. Please stick to a license which is compatible with the MIT license, +and consider assignment (as required) to me to simplify licensing. (Public +domain does not exist in the UK, sorry). + +Thanks, + +Dave. diff --git a/sleekxmpp/thirdparty/suelta/README b/sleekxmpp/thirdparty/suelta/README new file mode 100644 index 00000000..c32463a4 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/README @@ -0,0 +1,8 @@ +Suelta - A pure-Python SASL client library + +Suelta is a SASL library, providing you with authentication and in some cases +security layers. + +It supports a wide range of typical SASL mechanisms, including the MTI for +all known protocols. + diff --git a/sleekxmpp/thirdparty/suelta/__init__.py b/sleekxmpp/thirdparty/suelta/__init__.py new file mode 100644 index 00000000..04f0cbad --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/__init__.py @@ -0,0 +1,26 @@ +# Copyright 2007-2010 David Alan Cridland +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +from sleekxmpp.thirdparty.suelta.saslprep import saslprep +from sleekxmpp.thirdparty.suelta.sasl import * +from sleekxmpp.thirdparty.suelta.mechanisms import * + +__version__ = '2.0' +__version_info__ = (2, 0, 0) diff --git a/sleekxmpp/thirdparty/suelta/exceptions.py b/sleekxmpp/thirdparty/suelta/exceptions.py new file mode 100644 index 00000000..625cca0e --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/exceptions.py @@ -0,0 +1,31 @@ +class SASLError(Exception): + + def __init__(self, sasl, text, mech=None): + """ + :param sasl: The main `suelta.SASL` object. + :param text: Descpription of the error. + :param mech: Optional reference to the mechanism object. + + :type sasl: `suelta.SASL` + """ + self.sasl = sasl + self.text = text + self.mech = mech + + def __str__(self): + if self.mech is None: + return 'SASL Error: %s' % self.text + else: + return 'SASL Error (%s): %s' % (self.mech, self.text) + + +class SASLCancelled(SASLError): + + def __init__(self, sasl, mech=None): + """ + :param sasl: The main `suelta.SASL` object. + :param mech: Optional reference to the mechanism object. + + :type sasl: `suelta.SASL` + """ + super(SASLCancelled, self).__init__(sasl, "User cancelled", mech) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/__init__.py b/sleekxmpp/thirdparty/suelta/mechanisms/__init__.py new file mode 100644 index 00000000..e115e5d5 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/__init__.py @@ -0,0 +1,6 @@ +from sleekxmpp.thirdparty.suelta.mechanisms.anonymous import ANONYMOUS +from sleekxmpp.thirdparty.suelta.mechanisms.plain import PLAIN +from sleekxmpp.thirdparty.suelta.mechanisms.cram_md5 import CRAM_MD5 +from sleekxmpp.thirdparty.suelta.mechanisms.digest_md5 import DIGEST_MD5 +from sleekxmpp.thirdparty.suelta.mechanisms.scram_hmac import SCRAM_HMAC +from sleekxmpp.thirdparty.suelta.mechanisms.messenger_oauth2 import X_MESSENGER_OAUTH2 diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/anonymous.py b/sleekxmpp/thirdparty/suelta/mechanisms/anonymous.py new file mode 100644 index 00000000..e44e91a2 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/anonymous.py @@ -0,0 +1,36 @@ +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + +class ANONYMOUS(Mechanism): + + """ + """ + + def __init__(self, sasl, name): + """ + """ + super(ANONYMOUS, self).__init__(sasl, name, 0) + + def get_values(self): + """ + """ + return {} + + def process(self, challenge=None): + """ + """ + return b'Anonymous, Suelta' + + def okay(self): + """ + """ + return True + + def get_user(self): + """ + """ + return 'anonymous' + + +register_mechanism('ANONYMOUS', 0, ANONYMOUS, use_hashes=False) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/cram_md5.py b/sleekxmpp/thirdparty/suelta/mechanisms/cram_md5.py new file mode 100644 index 00000000..ba44befe --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/cram_md5.py @@ -0,0 +1,63 @@ +import sys +import hmac + +from sleekxmpp.thirdparty.suelta.util import hash, bytes +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + +class CRAM_MD5(Mechanism): + + """ + """ + + def __init__(self, sasl, name): + """ + """ + super(CRAM_MD5, self).__init__(sasl, name, 2) + + self.hash = hash(name[5:]) + if self.hash is None: + raise SASLCancelled(self.sasl, self) + if not self.sasl.tls_active(): + if not self.sasl.sec_query(self, 'CRAM-MD5'): + raise SASLCancelled(self.sasl, self) + + def prep(self): + """ + """ + if 'savepass' not in self.values: + if self.sasl.sec_query(self, 'CLEAR-PASSWORD'): + self.values['savepass'] = True + + if 'savepass' not in self.values: + del self.values['password'] + + def process(self, challenge): + """ + """ + if challenge is None: + return None + + self.check_values(['username', 'password']) + username = bytes(self.values['username']) + password = bytes(self.values['password']) + + mac = hmac.HMAC(key=password, digestmod=self.hash) + + mac.update(challenge) + + return username + b' ' + bytes(mac.hexdigest()) + + def okay(self): + """ + """ + return True + + def get_user(self): + """ + """ + return self.values['username'] + + +register_mechanism('CRAM-', 20, CRAM_MD5) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/digest_md5.py b/sleekxmpp/thirdparty/suelta/mechanisms/digest_md5.py new file mode 100644 index 00000000..5492c553 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/digest_md5.py @@ -0,0 +1,273 @@ +import sys + +import random + +from sleekxmpp.thirdparty.suelta.util import hash, bytes, quote +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + + +def parse_challenge(stuff): + """ + """ + ret = {} + var = b'' + val = b'' + in_var = True + in_quotes = False + new = False + escaped = False + for c in stuff: + if sys.version_info >= (3, 0): + c = bytes([c]) + if in_var: + if c.isspace(): + continue + if c == b'=': + in_var = False + new = True + else: + var += c + else: + if new: + if c == b'"': + in_quotes = True + else: + val += c + new = False + elif in_quotes: + if escaped: + escaped = False + val += c + else: + if c == b'\\': + escaped = True + elif c == b'"': + in_quotes = False + else: + val += c + else: + if c == b',': + if var: + ret[var] = val + var = b'' + val = b'' + in_var = True + else: + val += c + if var: + ret[var] = val + return ret + + +class DIGEST_MD5(Mechanism): + + """ + """ + + enc_magic = 'Digest session key to client-to-server signing key magic' + dec_magic = 'Digest session key to server-to-client signing key magic' + + def __init__(self, sasl, name): + """ + """ + super(DIGEST_MD5, self).__init__(sasl, name, 3) + + self.hash = hash(name[7:]) + if self.hash is None: + raise SASLCancelled(self.sasl, self) + + if not self.sasl.tls_active(): + if not self.sasl.sec_query(self, '-ENCRYPTION, DIGEST-MD5'): + raise SASLCancelled(self.sasl, self) + + self._rspauth_okay = False + self._digest_uri = None + self._a1 = None + self._enc_buf = b'' + self._enc_key = None + self._enc_seq = 0 + self._max_buffer = 65536 + self._dec_buf = b'' + self._dec_key = None + self._dec_seq = 0 + self._qops = [b'auth'] + self._qop = b'auth' + + def MAC(self, seq, msg, key): + """ + """ + mac = hmac.HMAC(key=key, digestmod=self.hash) + seqnum = num_to_bytes(seq) + mac.update(seqnum) + mac.update(msg) + return mac.digest()[:10] + b'\x00\x01' + seqnum + + + def encode(self, text): + """ + """ + self._enc_buf += text + + def flush(self): + """ + """ + result = b'' + # Leave buffer space for the MAC + mbuf = self._max_buffer - 10 - 2 - 4 + + while self._enc_buf: + msg = self._encbuf[:mbuf] + mac = self.MAC(self._enc_seq, msg, self._enc_key, self.hash) + self._enc_seq += 1 + msg += mac + result += num_to_bytes(len(msg)) + msg + self._enc_buf = self._enc_buf[mbuf:] + + return result + + def decode(self, text): + """ + """ + self._dec_buf += text + result = b'' + + while len(self._dec_buf) > 4: + num = bytes_to_num(self._dec_buf) + if len(self._dec_buf) < (num + 4): + return result + + mac = self._dec_buf[4:4 + num] + self._dec_buf = self._dec_buf[4 + num:] + msg = mac[:-16] + + mac_conf = self.MAC(self._dec_mac, msg, self._dec_key) + if mac[-16:] != mac_conf: + self._desc_sec = None + return result + + self._dec_seq += 1 + result += msg + + return result + + def response(self): + """ + """ + vitals = ['username'] + if not self.has_values(['key_hash']): + vitals.append('password') + self.check_values(vitals) + + resp = {} + if 'auth-int' in self._qops: + self._qop = b'auth-int' + resp['qop'] = self._qop + if 'realm' in self.values: + resp['realm'] = quote(self.values['realm']) + + resp['username'] = quote(bytes(self.values['username'])) + resp['nonce'] = quote(self.values['nonce']) + if self.values['nc']: + self._cnonce = self.values['cnonce'] + else: + self._cnonce = bytes('%s' % random.random())[2:] + resp['cnonce'] = quote(self._cnonce) + self.values['nc'] += 1 + resp['nc'] = bytes('%08x' % self.values['nc']) + + service = bytes(self.sasl.service) + host = bytes(self.sasl.host) + self._digest_uri = service + b'/' + host + resp['digest-uri'] = quote(self._digest_uri) + + a2 = b'AUTHENTICATE:' + self._digest_uri + if self._qop != b'auth': + a2 += b':00000000000000000000000000000000' + resp['maxbuf'] = b'16777215' # 2**24-1 + resp['response'] = self.gen_hash(a2) + return b','.join([bytes(k) + b'=' + bytes(v) for k, v in resp.items()]) + + def gen_hash(self, a2): + """ + """ + if not self.has_values(['key_hash']): + key_hash = self.hash() + user = bytes(self.values['username']) + password = bytes(self.values['password']) + realm = bytes(self.values['realm']) + kh = user + b':' + realm + b':' + password + key_hash.update(kh) + self.values['key_hash'] = key_hash.digest() + + a1 = self.hash(self.values['key_hash']) + a1h = b':' + self.values['nonce'] + b':' + self._cnonce + a1.update(a1h) + response = self.hash() + self._a1 = a1.digest() + rv = bytes(a1.hexdigest().lower()) + rv += b':' + self.values['nonce'] + rv += b':' + bytes('%08x' % self.values['nc']) + rv += b':' + self._cnonce + rv += b':' + self._qop + rv += b':' + bytes(self.hash(a2).hexdigest().lower()) + response.update(rv) + return bytes(response.hexdigest().lower()) + + def mutual_auth(self, cmp_hash): + """ + """ + a2 = b':' + self._digest_uri + if self._qop != b'auth': + a2 += b':00000000000000000000000000000000' + if self.gen_hash(a2) == cmp_hash: + self._rspauth_okay = True + + def prep(self): + """ + """ + if 'password' in self.values: + del self.values['password'] + self.values['cnonce'] = self._cnonce + + def process(self, challenge=None): + """ + """ + if challenge is None: + if self.has_values(['username', 'realm', 'nonce', 'key_hash', + 'nc', 'cnonce', 'qops']): + self._qops = self.values['qops'] + return self.response() + else: + return None + + d = parse_challenge(challenge) + if b'rspauth' in d: + self.mutual_auth(d[b'rspauth']) + else: + if b'realm' not in d: + d[b'realm'] = self.sasl.def_realm + for key in ['nonce', 'realm']: + if bytes(key) in d: + self.values[key] = d[bytes(key)] + self.values['nc'] = 0 + self._qops = [b'auth'] + if b'qop' in d: + self._qops = [x.strip() for x in d[b'qop'].split(b',')] + self.values['qops'] = self._qops + if b'maxbuf' in d: + self._max_buffer = int(d[b'maxbuf']) + return self.response() + + def okay(self): + """ + """ + if self._rspauth_okay and self._qop == b'auth-int': + self._enc_key = self.hash(self._a1 + self.enc_magic).digest() + self._dec_key = self.hash(self._a1 + self.dec_magic).digest() + self.encoding = True + return self._rspauth_okay + + +register_mechanism('DIGEST-', 30, DIGEST_MD5) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/messenger_oauth2.py b/sleekxmpp/thirdparty/suelta/mechanisms/messenger_oauth2.py new file mode 100644 index 00000000..f5b0ddec --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/messenger_oauth2.py @@ -0,0 +1,17 @@ +from sleekxmpp.thirdparty.suelta.util import bytes +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism + + +class X_MESSENGER_OAUTH2(Mechanism): + + def __init__(self, sasl, name): + super(X_MESSENGER_OAUTH2, self).__init__(sasl, name) + self.check_values(['access_token']) + + def process(self, challenge=None): + return bytes(self.values['access_token']) + + def okay(self): + return True + +register_mechanism('X-MESSENGER-OAUTH2', 10, X_MESSENGER_OAUTH2, use_hashes=False) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/plain.py b/sleekxmpp/thirdparty/suelta/mechanisms/plain.py new file mode 100644 index 00000000..ab17095e --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/plain.py @@ -0,0 +1,61 @@ +import sys + +from sleekxmpp.thirdparty.suelta.util import bytes +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + +class PLAIN(Mechanism): + + """ + """ + + def __init__(self, sasl, name): + """ + """ + super(PLAIN, self).__init__(sasl, name) + + if not self.sasl.tls_active(): + if not self.sasl.sec_query(self, '-ENCRYPTION, PLAIN'): + raise SASLCancelled(self.sasl, self) + else: + if not self.sasl.sec_query(self, '+ENCRYPTION, PLAIN'): + raise SASLCancelled(self.sasl, self) + + self.check_values(['username', 'password']) + + def prep(self): + """ + Prepare for processing by deleting the password if + the user has not approved storing it in the clear. + """ + if 'savepass' not in self.values: + if self.sasl.sec_query(self, 'CLEAR-PASSWORD'): + self.values['savepass'] = True + + if 'savepass' not in self.values: + del self.values['password'] + + return True + + def process(self, challenge=None): + """ + Process a challenge request and return the response. + + :param challenge: A challenge issued by the server that + must be answered for authentication. + """ + user = bytes(self.values['username']) + password = bytes(self.values['password']) + return b'\x00' + user + b'\x00' + password + + def okay(self): + """ + Mutual authentication is not supported by PLAIN. + + :returns: ``True`` + """ + return True + + +register_mechanism('PLAIN', 1, PLAIN, use_hashes=False) diff --git a/sleekxmpp/thirdparty/suelta/mechanisms/scram_hmac.py b/sleekxmpp/thirdparty/suelta/mechanisms/scram_hmac.py new file mode 100644 index 00000000..b70ac9a4 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/mechanisms/scram_hmac.py @@ -0,0 +1,176 @@ +import sys +import hmac +import random +from base64 import b64encode, b64decode + +from sleekxmpp.thirdparty.suelta.util import hash, bytes, num_to_bytes, bytes_to_num, XOR +from sleekxmpp.thirdparty.suelta.sasl import Mechanism, register_mechanism +from sleekxmpp.thirdparty.suelta.exceptions import SASLError, SASLCancelled + + +def parse_challenge(challenge): + """ + """ + items = {} + for key, value in [item.split(b'=', 1) for item in challenge.split(b',')]: + items[key] = value + return items + + +class SCRAM_HMAC(Mechanism): + + """ + """ + + def __init__(self, sasl, name): + """ + """ + super(SCRAM_HMAC, self).__init__(sasl, name, 0) + + self._cb = False + if name[-5:] == '-PLUS': + name = name[:-5] + self._cb = True + + self.hash = hash(name[6:]) + if self.hash is None: + raise SASLCancelled(self.sasl, self) + if not self.sasl.tls_active(): + if not self.sasl.sec_query(self, '-ENCRYPTION, SCRAM'): + raise SASLCancelled(self.sasl, self) + + self._step = 0 + self._rspauth = False + + def HMAC(self, key, msg): + """ + """ + return hmac.HMAC(key=key, msg=msg, digestmod=self.hash).digest() + + def Hi(self, text, salt, iterations): + """ + """ + text = bytes(text) + ui_1 = self.HMAC(text, salt + b'\0\0\0\01') + ui = ui_1 + for i in range(iterations - 1): + ui_1 = self.HMAC(text, ui_1) + ui = XOR(ui, ui_1) + return ui + + def H(self, text): + """ + """ + return self.hash(text).digest() + + def prep(self): + if 'password' in self.values: + del self.values['password'] + + def process(self, challenge=None): + """ + """ + steps = { + 0: self.process_one, + 1: self.process_two, + 2: self.process_three + } + return steps[self._step](challenge) + + def process_one(self, challenge): + """ + """ + vitals = ['username'] + if 'SaltedPassword' not in self.values: + vitals.append('password') + if 'Iterations' not in self.values: + vitals.append('password') + + self.check_values(vitals) + + username = bytes(self.values['username']) + + self._step = 1 + self._cnonce = bytes(('%s' % random.random())[2:]) + self._soup = b'n=' + username + b',r=' + self._cnonce + self._gs2header = b'' + + if not self.sasl.tls_active(): + if self._cb: + self._gs2header = b'p=tls-unique,,' + else: + self._gs2header = b'y,,' + else: + self._gs2header = b'n,,' + + return self._gs2header + self._soup + + def process_two(self, challenge): + """ + """ + data = parse_challenge(challenge) + + self._step = 2 + self._soup += b',' + challenge + b',' + self._nonce = data[b'r'] + self._salt = b64decode(data[b's']) + self._iter = int(data[b'i']) + + if self._nonce[:len(self._cnonce)] != self._cnonce: + raise SASLCancelled(self.sasl, self) + + cbdata = self.sasl.tls_active() + c = self._gs2header + if not cbdata and self._cb: + c += None + + r = b'c=' + b64encode(c).replace(b'\n', b'') + r += b',r=' + self._nonce + self._soup += r + + if 'Iterations' in self.values: + if self.values['Iterations'] != self._iter: + if 'SaltedPassword' in self.values: + del self.values['SaltedPassword'] + if 'Salt' in self.values: + if self.values['Salt'] != self._salt: + if 'SaltedPassword' in self.values: + del self.values['SaltedPassword'] + + self.values['Iterations'] = self._iter + self.values['Salt'] = self._salt + + if 'SaltedPassword' not in self.values: + self.check_values(['password']) + password = bytes(self.values['password']) + salted_pass = self.Hi(password, self._salt, self._iter) + self.values['SaltedPassword'] = salted_pass + + salted_pass = self.values['SaltedPassword'] + client_key = self.HMAC(salted_pass, b'Client Key') + stored_key = self.H(client_key) + client_sig = self.HMAC(stored_key, self._soup) + client_proof = XOR(client_key, client_sig) + r += b',p=' + b64encode(client_proof).replace(b'\n', b'') + server_key = self.HMAC(self.values['SaltedPassword'], b'Server Key') + self.server_sig = self.HMAC(server_key, self._soup) + return r + + def process_three(self, challenge=None): + """ + """ + data = parse_challenge(challenge) + if b64decode(data[b'v']) == self.server_sig: + self._rspauth = True + + def okay(self): + """ + """ + return self._rspauth + + def get_user(self): + return self.values['username'] + + +register_mechanism('SCRAM-', 60, SCRAM_HMAC) +register_mechanism('SCRAM-', 70, SCRAM_HMAC, extra='-PLUS') diff --git a/sleekxmpp/thirdparty/suelta/sasl.py b/sleekxmpp/thirdparty/suelta/sasl.py new file mode 100644 index 00000000..2ae9ae61 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/sasl.py @@ -0,0 +1,402 @@ +from sleekxmpp.thirdparty.suelta.util import hashes +from sleekxmpp.thirdparty.suelta.saslprep import saslprep + +#: Global session storage for user answers to requested mechanism values +#: and security questions. This allows the user's preferences to be +#: persisted across multiple SASL authentication attempts made by the +#: same process. +SESSION = {'answers': {}, + 'passwords': {}, + 'sec_queries': {}, + 'stash': {}, + 'stash_file': ''} + +#: Global registry mapping mechanism names to implementation classes. +MECHANISMS = {} + +#: Global registry mapping mechanism names to security scores. +MECH_SEC_SCORES = {} + + +def register_mechanism(basename, basescore, impl, extra=None, use_hashes=True): + """ + Add a SASL mechanism to the registry of available mechanisms. + + :param basename: The base name of the mechanism type, such as ``CRAM-``. + :param basescore: The base security score for this type of mechanism. + :param impl: The class implementing the mechanism. + :param extra: Any additional qualifiers to the mechanism name, + such as ``-PLUS``. + :param use_hashes: If ``True``, then register the mechanism for use with + all available hashes. + """ + n = 0 + if use_hashes: + for hashing_alg in hashes(): + n += 1 + name = basename + hashing_alg + if extra is not None: + name += extra + MECHANISMS[name] = impl + MECH_SEC_SCORES[name] = basescore + n + else: + MECHANISMS[basename] = impl + MECH_SEC_SCORES[basename] = basescore + + +def set_stash_file(filename): + """ + Enable or disable storing the stash to disk. + + If the filename is ``None``, then disable using a stash file. + + :param filename: The path to the file to store the stash data. + """ + SESSION['stash_file'] = filename + try: + import marshal + stash_file = file(filename) + SESSION['stash'] = marshal.load(stash_file) + except: + SESSION['stash'] = {} + + +def sec_query_allow(mech, query): + """ + Quick default to allow all feature combinations which could + negatively affect security. + + :param mech: The chosen SASL mechanism + :param query: An encoding of the combination of enabled and + disabled features which may affect security. + + :returns: ``True`` + """ + return True + + +class SASL(object): + + """ + """ + + def __init__(self, host, service, mech=None, username=None, + min_sec=0, request_values=None, sec_query=None, + tls_active=None, def_realm=None): + """ + :param string host: The host of the service requiring authentication. + :param string service: The name of the underlying protocol in use. + :param string mech: Optional name of the SASL mechanism to use. + If given, only this mechanism may be used for + authentication. + :param string username: The username to use when authenticating. + :param request_values: Reference to a function for supplying + values requested by mechanisms, such + as passwords. (See above) + :param sec_query: Reference to a function for approving or + denying feature combinations which could + negatively impact security. (See above) + :param tls_active: Function for indicating if TLS has been + negotiated. (See above) + :param integer min_sec: The minimum security level accepted. This + only allows for SASL mechanisms whose + security rating is greater than `min_sec`. + :param string def_realm: The default realm, if different than `host`. + + :type request_values: :func:`request_values` + :type sec_query: :func:`sec_query` + :type tls_active: :func:`tls_active` + """ + self.host = host + self.def_realm = def_realm or host + self.service = service + self.user = username + self.mech = mech + self.min_sec = min_sec - 1 + + self.request_values = request_values + self._sec_query = sec_query + if tls_active is not None: + self.tls_active = tls_active + else: + self.tls_active = lambda: False + + self.try_username = self.user + self.try_password = None + + self.stash_id = None + self.testkey = None + + def reset_stash_id(self, username): + """ + Reset the ID for the stash for persisting user data. + + :param username: The username to base the new ID on. + """ + username = saslprep(username) + self.user = username + self.try_username = self.user + self.testkey = [self.user, self.host, self.service] + self.stash_id = '\0'.join(self.testkey) + + def sec_query(self, mech, query): + """ + Request authorization from the user to use a combination + of features which could negatively affect security. + + The ``sec_query`` callback when creating the SASL object will + be called if the query has not been answered before. Otherwise, + the query response will be pulled from ``SESSION['sec_queries']``. + + If no ``sec_query`` callback was provided, then all queries + will be denied. + + :param mech: The chosen SASL mechanism + :param query: An encoding of the combination of enabled and + disabled features which may affect security. + :rtype: bool + """ + if self._sec_query is None: + return False + if query in SESSION['sec_queries']: + return SESSION['sec_queries'][query] + resp = self._sec_query(mech, query) + if resp: + SESSION['sec_queries'][query] = resp + + return resp + + def find_password(self, mech): + """ + Find and return the user's password, if it has been entered before + during this session. + + :param mech: The chosen SASL mechanism. + """ + if self.try_password is not None: + return self.try_password + if self.testkey is None: + return + + testkey = self.testkey[:] + lockout = 1 + + def find_username(self): + """Find and return user's username if known.""" + return self.try_username + + def success(self, mech): + mech.preprep() + if 'password' in mech.values: + testkey = self.testkey[:] + while len(testkey): + tk = '\0'.join(testkey) + if tk in SESSION['passwords']: + break + SESSION['passwords'][tk] = mech.values['password'] + testkey = testkey[:-1] + mech.prep() + mech.save_values() + + def failure(self, mech): + mech.clear() + self.testkey = self.testkey[:-1] + + def choose_mechanism(self, mechs, force_plain=False): + """ + Choose the most secure mechanism from a list of mechanisms. + + If ``force_plain`` is given, return the ``PLAIN`` mechanism. + + :param mechs: A list of mechanism names. + :param force_plain: If ``True``, force the selection of the + ``PLAIN`` mechanism. + :returns: A SASL mechanism object, or ``None`` if no mechanism + could be selected. + """ + # Handle selection of PLAIN and ANONYMOUS + if force_plain: + return MECHANISMS['PLAIN'](self, 'PLAIN') + + if self.user is not None: + requested_mech = '*' if self.mech is None else self.mech + else: + if self.mech is None: + requested_mech = 'ANONYMOUS' + else: + requested_mech = self.mech + if requested_mech == '*' and self.user in ['', 'anonymous', None]: + requested_mech = 'ANONYMOUS' + + # If a specific mechanism was requested, try it + if requested_mech != '*': + if requested_mech in MECHANISMS and \ + requested_mech in MECH_SEC_SCORES: + return MECHANISMS[requested_mech](self, requested_mech) + return None + + # Pick the best mechanism based on its security score + best_score = self.min_sec + best_mech = None + for name in mechs: + if name in MECH_SEC_SCORES: + if MECH_SEC_SCORES[name] > best_score: + best_score = MECH_SEC_SCORES[name] + best_mech = name + if best_mech is not None: + best_mech = MECHANISMS[best_mech](self, best_mech) + + return best_mech + + +class Mechanism(object): + + """ + """ + + def __init__(self, sasl, name, version=0, use_stash=True): + self.name = name + self.sasl = sasl + self.use_stash = use_stash + + self.encoding = False + self.values = {} + + if use_stash: + self.load_values() + + def load_values(self): + """Retrieve user data from the stash.""" + self.values = {} + if not self.use_stash: + return False + if self.sasl.stash_id is not None: + if self.sasl.stash_id in SESSION['stash']: + if SESSION['stash'][self.sasl.stash_id]['mech'] == self.name: + values = SESSION['stash'][self.sasl.stash_id]['values'] + self.values.update(values) + if self.sasl.user is not None: + if not self.has_values(['username']): + self.values['username'] = self.sasl.user + return None + + def save_values(self): + """ + Save user data to the session stash. + + If a stash file name has been set using ``SESSION['stash_file']``, + the saved values will be persisted to disk. + """ + if not self.use_stash: + return False + if self.sasl.stash_id is not None: + if self.sasl.stash_id not in SESSION['stash']: + SESSION['stash'][self.sasl.stash_id] = {} + SESSION['stash'][self.sasl.stash_id]['values'] = self.values + SESSION['stash'][self.sasl.stash_id]['mech'] = self.name + if SESSION['stash_file'] not in ['', None]: + import marshal + stash_file = file(SESSION['stash_file'], 'wb') + marshal.dump(SESSION['stash'], stash_file) + + def clear(self): + """Reset all user data, except the username.""" + username = None + if 'username' in self.values: + username = self.values['username'] + self.values = {} + if username is not None: + self.values['username'] = username + self.save_values() + self.values = {} + self.load_values() + + def okay(self): + """ + Indicate if mutual authentication has completed successfully. + + :rtype: bool + """ + return False + + def preprep(self): + """Ensure that the stash ID has been set before processing.""" + if self.sasl.stash_id is None: + if 'username' in self.values: + self.sasl.reset_stash_id(self.values['username']) + + def prep(self): + """ + Prepare stored values for processing. + + For example, by removing extra copies of passwords from memory. + """ + pass + + def process(self, challenge=None): + """ + Process a challenge request and return the response. + + :param challenge: A challenge issued by the server that + must be answered for authentication. + """ + raise NotImplemented + + def fulfill(self, values): + """ + Provide requested values to the mechanism. + + :param values: A dictionary of requested values. + """ + if 'password' in values: + values['password'] = saslprep(values['password']) + self.values.update(values) + + def missing_values(self, keys): + """ + Return a dictionary of value names that have not been given values + by the user, or retrieved from the stash. + + :param keys: A list of value names to check. + :rtype: dict + """ + vals = {} + for name in keys: + if name not in self.values or self.values[name] is None: + if self.use_stash: + if name == 'username': + value = self.sasl.find_username() + if value is not None: + self.sasl.reset_stash_id(value) + self.values[name] = value + break + if name == 'password': + value = self.sasl.find_password(self) + if value is not None: + self.values[name] = value + break + vals[name] = None + return vals + + def has_values(self, keys): + """ + Check that the given values have been retrieved from the user, + or from the stash. + + :param keys: A list of value names to check. + """ + return len(self.missing_values(keys)) == 0 + + def check_values(self, keys): + """ + Request missing values from the user. + + :param keys: A list of value names to request, if missing. + """ + vals = self.missing_values(keys) + if vals: + self.sasl.request_values(self, vals) + + def get_user(self): + """Return the username usd for this mechanism.""" + return self.values['username'] diff --git a/sleekxmpp/thirdparty/suelta/saslprep.py b/sleekxmpp/thirdparty/suelta/saslprep.py new file mode 100644 index 00000000..fe58d58b --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/saslprep.py @@ -0,0 +1,78 @@ +from __future__ import unicode_literals + +import sys +import stringprep +import unicodedata + + +def saslprep(text, strict=True): + """ + Return a processed version of the given string, using the SASLPrep + profile of stringprep. + + :param text: The string to process, in UTF-8. + :param strict: If ``True``, prevent the use of unassigned code points. + """ + + if sys.version_info < (3, 0): + if type(text) == str: + text = text.decode('us-ascii') + + # Mapping: + # + # - non-ASCII space characters [StringPrep, C.1.2] that can be + # mapped to SPACE (U+0020), and + # + # - the 'commonly mapped to nothing' characters [StringPrep, B.1] + # that can be mapped to nothing. + buffer = '' + for char in text: + if stringprep.in_table_c12(char): + buffer += ' ' + elif not stringprep.in_table_b1(char): + buffer += char + + # Normalization using form KC + text = unicodedata.normalize('NFKC', buffer) + + # Check for bidirectional string + buffer = '' + first_is_randal = False + if text: + first_is_randal = stringprep.in_table_d1(text[0]) + if first_is_randal and not stringprep.in_table_d1(text[-1]): + raise UnicodeError('Section 6.3 [end]') + + # Check for prohibited characters + for x in range(len(text)): + if strict and stringprep.in_table_a1(text[x]): + raise UnicodeError('Unassigned Codepoint') + if stringprep.in_table_c12(text[x]): + raise UnicodeError('In table C.1.2') + if stringprep.in_table_c21(text[x]): + raise UnicodeError('In table C.2.1') + if stringprep.in_table_c22(text[x]): + raise UnicodeError('In table C.2.2') + if stringprep.in_table_c3(text[x]): + raise UnicodeError('In table C.3') + if stringprep.in_table_c4(text[x]): + raise UnicodeError('In table C.4') + if stringprep.in_table_c5(text[x]): + raise UnicodeError('In table C.5') + if stringprep.in_table_c6(text[x]): + raise UnicodeError('In table C.6') + if stringprep.in_table_c7(text[x]): + raise UnicodeError('In table C.7') + if stringprep.in_table_c8(text[x]): + raise UnicodeError('In table C.8') + if stringprep.in_table_c9(text[x]): + raise UnicodeError('In table C.9') + if x: + if first_is_randal and stringprep.in_table_d2(text[x]): + raise UnicodeError('Section 6.2') + if not first_is_randal and \ + x != len(text) - 1 and \ + stringprep.in_table_d1(text[x]): + raise UnicodeError('Section 6.3') + + return text diff --git a/sleekxmpp/thirdparty/suelta/util.py b/sleekxmpp/thirdparty/suelta/util.py new file mode 100644 index 00000000..7d822a81 --- /dev/null +++ b/sleekxmpp/thirdparty/suelta/util.py @@ -0,0 +1,118 @@ +""" +""" + +import sys +import hashlib + + +def bytes(text): + """ + Convert Unicode text to UTF-8 encoded bytes. + + Since Python 2.6+ and Python 3+ have similar but incompatible + signatures, this function unifies the two to keep code sane. + + :param text: Unicode text to convert to bytes + :rtype: bytes (Python3), str (Python2.6+) + """ + if sys.version_info < (3, 0): + import __builtin__ + return __builtin__.bytes(text) + else: + import builtins + if isinstance(text, builtins.bytes): + # We already have bytes, so do nothing + return text + if isinstance(text, list): + # Convert a list of integers to bytes + return builtins.bytes(text) + else: + # Convert UTF-8 text to bytes + return builtins.bytes(text, encoding='utf-8') + + +def quote(text): + """ + Enclose in quotes and escape internal slashes and double quotes. + + :param text: A Unicode or byte string. + """ + text = bytes(text) + return b'"' + text.replace(b'\\', b'\\\\').replace(b'"', b'\\"') + b'"' + + +def num_to_bytes(num): + """ + Convert an integer into a four byte sequence. + + :param integer num: An integer to convert to its byte representation. + """ + bval = b'' + bval += bytes(chr(0xFF & (num >> 24))) + bval += bytes(chr(0xFF & (num >> 16))) + bval += bytes(chr(0xFF & (num >> 8))) + bval += bytes(chr(0xFF & (num >> 0))) + return bval + + +def bytes_to_num(bval): + """ + Convert a four byte sequence to an integer. + + :param bytes bval: A four byte sequence to turn into an integer. + """ + num = 0 + num += ord(bval[0] << 24) + num += ord(bval[1] << 16) + num += ord(bval[2] << 8) + num += ord(bval[3]) + return num + + +def XOR(x, y): + """ + Return the results of an XOR operation on two equal length byte strings. + + :param bytes x: A byte string + :param bytes y: A byte string + :rtype: bytes + """ + result = b'' + for a, b in zip(x, y): + if sys.version_info < (3, 0): + result += chr((ord(a) ^ ord(b))) + else: + result += bytes([a ^ b]) + return result + + +def hash(name): + """ + Return a hash function implementing the given algorithm. + + :param name: The name of the hashing algorithm to use. + :type name: string + + :rtype: function + """ + name = name.lower() + if name.startswith('sha-'): + name = 'sha' + name[4:] + if name in dir(hashlib): + return getattr(hashlib, name) + return None + + +def hashes(): + """ + Return a list of available hashing algorithms. + + :rtype: list of strings + """ + t = [] + if 'md5' in dir(hashlib): + t = ['MD5'] + if 'md2' in dir(hashlib): + t += ['MD2'] + hashes = ['SHA-' + h[3:] for h in dir(hashlib) if h.startswith('sha')] + return t + hashes diff --git a/sleekxmpp/version.py b/sleekxmpp/version.py new file mode 100644 index 00000000..037c6463 --- /dev/null +++ b/sleekxmpp/version.py @@ -0,0 +1,13 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +# We don't want to have to import the entire library +# just to get the version info for setup.py + +__version__ = '1.0' +__version_info__ = (1, 0, 0, '', 0) diff --git a/sleekxmpp/xmlstream/__init__.py b/sleekxmpp/xmlstream/__init__.py new file mode 100644 index 00000000..67b20c56 --- /dev/null +++ b/sleekxmpp/xmlstream/__init__.py @@ -0,0 +1,19 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.jid import JID +from sleekxmpp.xmlstream.scheduler import Scheduler +from sleekxmpp.xmlstream.stanzabase import StanzaBase, ElementBase, ET +from sleekxmpp.xmlstream.stanzabase import register_stanza_plugin +from sleekxmpp.xmlstream.tostring import tostring +from sleekxmpp.xmlstream.xmlstream import XMLStream, RESPONSE_TIMEOUT +from sleekxmpp.xmlstream.xmlstream import RestartStream + +__all__ = ['JID', 'Scheduler', 'StanzaBase', 'ElementBase', + 'ET', 'StateMachine', 'tostring', 'XMLStream', + 'RESPONSE_TIMEOUT', 'RestartStream'] diff --git a/sleekxmpp/xmlstream/filesocket.py b/sleekxmpp/xmlstream/filesocket.py new file mode 100644 index 00000000..56554c73 --- /dev/null +++ b/sleekxmpp/xmlstream/filesocket.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.filesocket + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module is a shim for correcting deficiencies in the file + socket implementation of Python2.6. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from socket import _fileobject +import socket + + +class FileSocket(_fileobject): + + """Create a file object wrapper for a socket to work around + issues present in Python 2.6 when using sockets as file objects. + + The parser for :class:`~xml.etree.cElementTree` requires a file, but + we will be reading from the XMPP connection socket instead. + """ + + def read(self, size=4096): + """Read data from the socket as if it were a file.""" + if self._sock is None: + return None + data = self._sock.recv(size) + if data is not None: + return data + + +class Socket26(socket._socketobject): + + """A custom socket implementation that uses our own FileSocket class + to work around issues in Python 2.6 when using sockets as files. + """ + + def makefile(self, mode='r', bufsize=-1): + """makefile([mode[, bufsize]]) -> file object + Return a regular file object corresponding to the socket. The mode + and bufsize arguments are as for the built-in open() function.""" + return FileSocket(self._sock, mode, bufsize) diff --git a/sleekxmpp/xmlstream/handler/__init__.py b/sleekxmpp/xmlstream/handler/__init__.py new file mode 100644 index 00000000..7bcf0b71 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/__init__.py @@ -0,0 +1,14 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.handler.callback import Callback +from sleekxmpp.xmlstream.handler.waiter import Waiter +from sleekxmpp.xmlstream.handler.xmlcallback import XMLCallback +from sleekxmpp.xmlstream.handler.xmlwaiter import XMLWaiter + +__all__ = ['Callback', 'Waiter', 'XMLCallback', 'XMLWaiter'] diff --git a/sleekxmpp/xmlstream/handler/base.py b/sleekxmpp/xmlstream/handler/base.py new file mode 100644 index 00000000..59dcb306 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/base.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.handler.base + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import weakref + + +class BaseHandler(object): + + """ + Base class for stream handlers. Stream handlers are matched with + incoming stanzas so that the stanza may be processed in some way. + Stanzas may be matched with multiple handlers. + + Handler execution may take place in two phases: during the incoming + stream processing, and in the main event loop. The :meth:`prerun()` + method is executed in the first case, and :meth:`run()` is called + during the second. + + :param string name: The name of the handler. + :param matcher: A :class:`~sleekxmpp.xmlstream.matcher.base.MatcherBase` + derived object that will be used to determine if a + stanza should be accepted by this handler. + :param stream: The :class:`~sleekxmpp.xmlstream.xmlstream.XMLStream` + instance that the handle will respond to. + """ + + def __init__(self, name, matcher, stream=None): + #: The name of the handler + self.name = name + + #: The XML stream this handler is assigned to + self.stream = None + if stream is not None: + self.stream = weakref.ref(stream) + stream.register_handler(self) + + self._destroy = False + self._payload = None + self._matcher = matcher + + def match(self, xml): + """Compare a stanza or XML object with the handler's matcher. + + :param xml: An XML or + :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` object + """ + return self._matcher.match(xml) + + def prerun(self, payload): + """Prepare the handler for execution while the XML + stream is being processed. + + :param payload: A :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` + object. + """ + self._payload = payload + + def run(self, payload): + """Execute the handler after XML stream processing and during the + main event loop. + + :param payload: A :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` + object. + """ + self._payload = payload + + def check_delete(self): + """Check if the handler should be removed from the list + of stream handlers. + """ + return self._destroy + + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +BaseHandler.checkDelete = BaseHandler.check_delete diff --git a/sleekxmpp/xmlstream/handler/callback.py b/sleekxmpp/xmlstream/handler/callback.py new file mode 100644 index 00000000..37f53335 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/callback.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.handler.callback + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from sleekxmpp.xmlstream.handler.base import BaseHandler + + +class Callback(BaseHandler): + + """ + The Callback handler will execute a callback function with + matched stanzas. + + The handler may execute the callback either during stream + processing or during the main event loop. + + Callback functions are all executed in the same thread, so be aware if + you are executing functions that will block for extended periods of + time. Typically, you should signal your own events using the SleekXMPP + object's :meth:`~sleekxmpp.xmlstream.xmlstream.XMLStream.event()` + method to pass the stanza off to a threaded event handler for further + processing. + + + :param string name: The name of the handler. + :param matcher: A :class:`~sleekxmpp.xmlstream.matcher.base.MatcherBase` + derived object for matching stanza objects. + :param pointer: The function to execute during callback. + :param bool thread: **DEPRECATED.** Remains only for + backwards compatibility. + :param bool once: Indicates if the handler should be used only + once. Defaults to False. + :param bool instream: Indicates if the callback should be executed + during stream processing instead of in the + main event loop. + :param stream: The :class:`~sleekxmpp.xmlstream.xmlstream.XMLStream` + instance this handler should monitor. + """ + + def __init__(self, name, matcher, pointer, thread=False, + once=False, instream=False, stream=None): + BaseHandler.__init__(self, name, matcher, stream) + self._pointer = pointer + self._once = once + self._instream = instream + + def prerun(self, payload): + """Execute the callback during stream processing, if + the callback was created with ``instream=True``. + + :param payload: The matched + :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` object. + """ + if self._once: + self._destroy = True + if self._instream: + self.run(payload, True) + + def run(self, payload, instream=False): + """Execute the callback function with the matched stanza payload. + + :param payload: The matched + :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` object. + :param bool instream: Force the handler to execute during stream + processing. This should only be used by + :meth:`prerun()`. Defaults to ``False``. + """ + if not self._instream or instream: + self._pointer(payload) + if self._once: + self._destroy = True + del self._pointer diff --git a/sleekxmpp/xmlstream/handler/waiter.py b/sleekxmpp/xmlstream/handler/waiter.py new file mode 100644 index 00000000..01ff5d67 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/waiter.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.handler.waiter + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import logging +try: + import queue +except ImportError: + import Queue as queue + +from sleekxmpp.xmlstream import StanzaBase +from sleekxmpp.xmlstream.handler.base import BaseHandler + + +log = logging.getLogger(__name__) + + +class Waiter(BaseHandler): + + """ + The Waiter handler allows an event handler to block until a + particular stanza has been received. The handler will either be + given the matched stanza, or ``False`` if the waiter has timed out. + + :param string name: The name of the handler. + :param matcher: A :class:`~sleekxmpp.xmlstream.matcher.base.MatcherBase` + derived object for matching stanza objects. + :param stream: The :class:`~sleekxmpp.xmlstream.xmlstream.XMLStream` + instance this handler should monitor. + """ + + def __init__(self, name, matcher, stream=None): + BaseHandler.__init__(self, name, matcher, stream=stream) + self._payload = queue.Queue() + + def prerun(self, payload): + """Store the matched stanza when received during processing. + + :param payload: The matched + :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` object. + """ + self._payload.put(payload) + + def run(self, payload): + """Do not process this handler during the main event loop.""" + pass + + def wait(self, timeout=None): + """Block an event handler while waiting for a stanza to arrive. + + Be aware that this will impact performance if called from a + non-threaded event handler. + + Will return either the received stanza, or ``False`` if the + waiter timed out. + + :param int timeout: The number of seconds to wait for the stanza + to arrive. Defaults to the the stream's + :class:`~sleekxmpp.xmlstream.xmlstream.XMLStream.response_timeout` + value. + """ + if timeout is None: + timeout = self.stream().response_timeout + + elapsed_time = 0 + stanza = False + while elapsed_time < timeout and not self.stream().stop.is_set(): + try: + stanza = self._payload.get(True, 1) + break + except queue.Empty: + elapsed_time += 1 + if elapsed_time >= timeout: + log.warning("Timed out waiting for %s", self.name) + self.stream().remove_handler(self.name) + return stanza + + def check_delete(self): + """Always remove waiters after use.""" + return True diff --git a/sleekxmpp/xmlstream/handler/xmlcallback.py b/sleekxmpp/xmlstream/handler/xmlcallback.py new file mode 100644 index 00000000..11607ffb --- /dev/null +++ b/sleekxmpp/xmlstream/handler/xmlcallback.py @@ -0,0 +1,36 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.handler import Callback + + +class XMLCallback(Callback): + + """ + The XMLCallback class is identical to the normal Callback class, + except that XML contents of matched stanzas will be processed instead + of the stanza objects themselves. + + Methods: + run -- Overrides Callback.run + """ + + def run(self, payload, instream=False): + """ + Execute the callback function with the matched stanza's + XML contents, instead of the stanza itself. + + Overrides BaseHandler.run + + Arguments: + payload -- The matched stanza object. + instream -- Force the handler to execute during + stream processing. Used only by prerun. + Defaults to False. + """ + Callback.run(self, payload.xml, instream) diff --git a/sleekxmpp/xmlstream/handler/xmlwaiter.py b/sleekxmpp/xmlstream/handler/xmlwaiter.py new file mode 100644 index 00000000..5201caf3 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/xmlwaiter.py @@ -0,0 +1,33 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.handler import Waiter + + +class XMLWaiter(Waiter): + + """ + The XMLWaiter class is identical to the normal Waiter class + except that it returns the XML contents of the stanza instead + of the full stanza object itself. + + Methods: + prerun -- Overrides Waiter.prerun + """ + + def prerun(self, payload): + """ + Store the XML contents of the stanza to return to the + waiting event handler. + + Overrides Waiter.prerun + + Arguments: + payload -- The matched stanza object. + """ + Waiter.prerun(self, payload.xml) diff --git a/sleekxmpp/xmlstream/jid.py b/sleekxmpp/xmlstream/jid.py new file mode 100644 index 00000000..281bf4ee --- /dev/null +++ b/sleekxmpp/xmlstream/jid.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.jid + ~~~~~~~~~~~~~~~~~~~~~~~ + + This module allows for working with Jabber IDs (JIDs) by + providing accessors for the various components of a JID. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from __future__ import unicode_literals + + +class JID(object): + + """ + A representation of a Jabber ID, or JID. + + Each JID may have three components: a user, a domain, and an optional + resource. For example: user@domain/resource + + When a resource is not used, the JID is called a bare JID. + The JID is a full JID otherwise. + + **JID Properties:** + :jid: Alias for ``full``. + :full: The value of the full JID. + :bare: The value of the bare JID. + :user: The username portion of the JID. + :domain: The domain name portion of the JID. + :server: Alias for ``domain``. + :resource: The resource portion of the JID. + + :param string jid: A string of the form ``'[user@]domain[/resource]'``. + """ + + def __init__(self, jid): + """Initialize a new JID""" + self.reset(jid) + + def reset(self, jid): + """Start fresh from a new JID string. + + :param string jid: A string of the form ``'[user@]domain[/resource]'``. + """ + if isinstance(jid, JID): + jid = jid.full + self._full = self._jid = jid + self._domain = None + self._resource = None + self._user = None + self._bare = None + + def __getattr__(self, name): + """Handle getting the JID values, using cache if available. + + :param name: One of: user, server, domain, resource, + full, or bare. + """ + if name == 'resource': + if self._resource is None and '/' in self._jid: + self._resource = self._jid.split('/', 1)[-1] + return self._resource or "" + elif name == 'user': + if self._user is None: + if '@' in self._jid: + self._user = self._jid.split('@', 1)[0] + else: + self._user = self._user + return self._user or "" + elif name in ('server', 'domain', 'host'): + if self._domain is None: + self._domain = self._jid.split('@', 1)[-1].split('/', 1)[0] + return self._domain or "" + elif name in ('full', 'jid'): + return self._jid or "" + elif name == 'bare': + if self._bare is None: + self._bare = self._jid.split('/', 1)[0] + return self._bare or "" + + def __setattr__(self, name, value): + """Edit a JID by updating it's individual values, resetting the + generated JID in the end. + + Arguments: + name -- The name of the JID part. One of: user, domain, + server, resource, full, jid, or bare. + value -- The new value for the JID part. + """ + if name in ('resource', 'user', 'domain'): + object.__setattr__(self, "_%s" % name, value) + self.regenerate() + elif name in ('server', 'domain', 'host'): + self.domain = value + elif name in ('full', 'jid'): + self.reset(value) + self.regenerate() + elif name == 'bare': + if '@' in value: + u, d = value.split('@', 1) + object.__setattr__(self, "_user", u) + object.__setattr__(self, "_domain", d) + else: + object.__setattr__(self, "_user", '') + object.__setattr__(self, "_domain", value) + self.regenerate() + else: + object.__setattr__(self, name, value) + + def regenerate(self): + """Generate a new JID based on current values, useful after editing.""" + jid = "" + if self.user: + jid = "%s@" % self.user + jid += self.domain + if self.resource: + jid += "/%s" % self.resource + self.reset(jid) + + def __str__(self): + """Use the full JID as the string value.""" + return self.full + + def __repr__(self): + return self.full + + def __eq__(self, other): + """ + Two JIDs are considered equal if they have the same full JID value. + """ + other = JID(other) + return self.full == other.full + + def __ne__(self, other): + """Two JIDs are considered unequal if they are not equal.""" + return not self == other + + def __hash__(self): + """Hash a JID based on the string version of its full JID.""" + return hash(self.full) diff --git a/sleekxmpp/xmlstream/matcher/__init__.py b/sleekxmpp/xmlstream/matcher/__init__.py new file mode 100644 index 00000000..1038d1bd --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/__init__.py @@ -0,0 +1,16 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.matcher.id import MatcherId +from sleekxmpp.xmlstream.matcher.many import MatchMany +from sleekxmpp.xmlstream.matcher.stanzapath import StanzaPath +from sleekxmpp.xmlstream.matcher.xmlmask import MatchXMLMask +from sleekxmpp.xmlstream.matcher.xpath import MatchXPath + +__all__ = ['MatcherId', 'MatchMany', 'StanzaPath', + 'MatchXMLMask', 'MatchXPath'] diff --git a/sleekxmpp/xmlstream/matcher/base.py b/sleekxmpp/xmlstream/matcher/base.py new file mode 100644 index 00000000..83c26688 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/base.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.matcher.base + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + + +class MatcherBase(object): + + """ + Base class for stanza matchers. Stanza matchers are used to pick + stanzas out of the XML stream and pass them to the appropriate + stream handlers. + + :param criteria: Object to compare some aspect of a stanza against. + """ + + def __init__(self, criteria): + self._criteria = criteria + + def match(self, xml): + """Check if a stanza matches the stored criteria. + + Meant to be overridden. + """ + return False diff --git a/sleekxmpp/xmlstream/matcher/id.py b/sleekxmpp/xmlstream/matcher/id.py new file mode 100644 index 00000000..11ab70bb --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/id.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.matcher.id + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +class MatcherId(MatcherBase): + + """ + The ID matcher selects stanzas that have the same stanza 'id' + interface value as the desired ID. + """ + + def match(self, xml): + """Compare the given stanza's ``'id'`` attribute to the stored + ``id`` value. + + :param xml: The :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` + stanza to compare against. + """ + return xml['id'] == self._criteria diff --git a/sleekxmpp/xmlstream/matcher/many.py b/sleekxmpp/xmlstream/matcher/many.py new file mode 100644 index 00000000..f470ec9c --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/many.py @@ -0,0 +1,40 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +class MatchMany(MatcherBase): + + """ + The MatchMany matcher may compare a stanza against multiple + criteria. It is essentially an OR relation combining multiple + matchers. + + Each of the criteria must implement a match() method. + + Methods: + match -- Overrides MatcherBase.match. + """ + + def match(self, xml): + """ + Match a stanza against multiple criteria. The match is successful + if one of the criteria matches. + + Each of the criteria must implement a match() method. + + Overrides MatcherBase.match. + + Arguments: + xml -- The stanza object to compare against. + """ + for m in self._criteria: + if m.match(xml): + return True + return False diff --git a/sleekxmpp/xmlstream/matcher/stanzapath.py b/sleekxmpp/xmlstream/matcher/stanzapath.py new file mode 100644 index 00000000..a4c0fda0 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/stanzapath.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.matcher.stanzapath + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from sleekxmpp.xmlstream.matcher.base import MatcherBase +from sleekxmpp.xmlstream.stanzabase import fix_ns + + +class StanzaPath(MatcherBase): + + """ + The StanzaPath matcher selects stanzas that match a given "stanza path", + which is similar to a normal XPath except that it uses the interfaces and + plugins of the stanza instead of the actual, underlying XML. + + :param criteria: Object to compare some aspect of a stanza against. + """ + + def __init__(self, criteria): + self._criteria = fix_ns(criteria, split=True, + propagate_ns=False, + default_ns='jabber:client') + self._raw_criteria = criteria + + def match(self, stanza): + """ + Compare a stanza against a "stanza path". A stanza path is similar to + an XPath expression, but uses the stanza's interfaces and plugins + instead of the underlying XML. See the documentation for the stanza + :meth:`~sleekxmpp.xmlstream.stanzabase.ElementBase.match()` method + for more information. + + :param stanza: The :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` + stanza to compare against. + """ + return stanza.match(self._criteria) or stanza.match(self._raw_criteria) diff --git a/sleekxmpp/xmlstream/matcher/xmlmask.py b/sleekxmpp/xmlstream/matcher/xmlmask.py new file mode 100644 index 00000000..7977e767 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/xmlmask.py @@ -0,0 +1,158 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging + +from xml.parsers.expat import ExpatError + +from sleekxmpp.xmlstream.stanzabase import ET +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +# Flag indicating if the builtin XPath matcher should be used, which +# uses namespaces, or a custom matcher that ignores namespaces. +# Changing this will affect ALL XMLMask matchers. +IGNORE_NS = False + + +log = logging.getLogger(__name__) + + +class MatchXMLMask(MatcherBase): + + """ + The XMLMask matcher selects stanzas whose XML matches a given + XML pattern, or mask. For example, message stanzas with body elements + could be matched using the mask: + + .. code-block:: xml + + <message xmlns="jabber:client"><body /></message> + + Use of XMLMask is discouraged, and + :class:`~sleekxmpp.xmlstream.matcher.xpath.MatchXPath` or + :class:`~sleekxmpp.xmlstream.matcher.stanzapath.StanzaPath` + should be used instead. + + The use of namespaces in the mask comparison is controlled by + ``IGNORE_NS``. Setting ``IGNORE_NS`` to ``True`` will disable namespace + based matching for ALL XMLMask matchers. + + :param criteria: Either an :class:`~xml.etree.ElementTree.Element` XML + object or XML string to use as a mask. + """ + + def __init__(self, criteria): + MatcherBase.__init__(self, criteria) + if isinstance(criteria, str): + self._criteria = ET.fromstring(self._criteria) + self.default_ns = 'jabber:client' + + def setDefaultNS(self, ns): + """Set the default namespace to use during comparisons. + + :param ns: The new namespace to use as the default. + """ + self.default_ns = ns + + def match(self, xml): + """Compare a stanza object or XML object against the stored XML mask. + + Overrides MatcherBase.match. + + :param xml: The stanza object or XML object to compare against. + """ + if hasattr(xml, 'xml'): + xml = xml.xml + return self._mask_cmp(xml, self._criteria, True) + + def _mask_cmp(self, source, mask, use_ns=False, default_ns='__no_ns__'): + """Compare an XML object against an XML mask. + + :param source: The :class:`~xml.etree.ElementTree.Element` XML object + to compare against the mask. + :param mask: The :class:`~xml.etree.ElementTree.Element` XML object + serving as the mask. + :param use_ns: Indicates if namespaces should be respected during + the comparison. + :default_ns: The default namespace to apply to elements that + do not have a specified namespace. + Defaults to ``"__no_ns__"``. + """ + use_ns = not IGNORE_NS + + if source is None: + # If the element was not found. May happend during recursive calls. + return False + + # Convert the mask to an XML object if it is a string. + if not hasattr(mask, 'attrib'): + try: + mask = ET.fromstring(mask) + except ExpatError: + log.warning("Expat error: %s\nIn parsing: %s", '', mask) + if not use_ns: + # Compare the element without using namespaces. + source_tag = source.tag.split('}', 1)[-1] + mask_tag = mask.tag.split('}', 1)[-1] + if source_tag != mask_tag: + return False + else: + # Compare the element using namespaces + mask_ns_tag = "{%s}%s" % (self.default_ns, mask.tag) + if source.tag not in [mask.tag, mask_ns_tag]: + return False + + # If the mask includes text, compare it. + if mask.text and source.text and \ + source.text.strip() != mask.text.strip(): + return False + + # Compare attributes. The stanza must include the attributes + # defined by the mask, but may include others. + for name, value in mask.attrib.items(): + if source.attrib.get(name, "__None__") != value: + return False + + # Recursively check subelements. + matched_elements = {} + for subelement in mask: + if use_ns: + matched = False + for other in source.findall(subelement.tag): + matched_elements[other] = False + if self._mask_cmp(other, subelement, use_ns): + if not matched_elements.get(other, False): + matched_elements[other] = True + matched = True + if not matched: + return False + else: + if not self._mask_cmp(self._get_child(source, subelement.tag), + subelement, use_ns): + return False + + # Everything matches. + return True + + def _get_child(self, xml, tag): + """Return a child element given its tag, ignoring namespace values. + + Returns ``None`` if the child was not found. + + :param xml: The :class:`~xml.etree.ElementTree.Element` XML object + to search for the given child tag. + :param tag: The name of the subelement to find. + """ + tag = tag.split('}')[-1] + try: + children = [c.tag.split('}')[-1] for c in xml.getchildren()] + index = children.index(tag) + except ValueError: + return None + return xml.getchildren()[index] diff --git a/sleekxmpp/xmlstream/matcher/xpath.py b/sleekxmpp/xmlstream/matcher/xpath.py new file mode 100644 index 00000000..b6af0609 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/xpath.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.matcher.xpath + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from sleekxmpp.xmlstream.stanzabase import ET +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +# Flag indicating if the builtin XPath matcher should be used, which +# uses namespaces, or a custom matcher that ignores namespaces. +# Changing this will affect ALL XPath matchers. +IGNORE_NS = False + + +class MatchXPath(MatcherBase): + + """ + The XPath matcher selects stanzas whose XML contents matches a given + XPath expression. + + .. warning:: + + Using this matcher may not produce expected behavior when using + attribute selectors. For Python 2.6 and 3.1, the ElementTree + :meth:`~xml.etree.ElementTree.Element.find()` method does + not support the use of attribute selectors. If you need to + support Python 2.6 or 3.1, it might be more useful to use a + :class:`~sleekxmpp.xmlstream.matcher.stanzapath.StanzaPath` matcher. + + If the value of :data:`IGNORE_NS` is set to ``True``, then XPath + expressions will be matched without using namespaces. + """ + + def match(self, xml): + """ + Compare a stanza's XML contents to an XPath expression. + + If the value of :data:`IGNORE_NS` is set to ``True``, then XPath + expressions will be matched without using namespaces. + + .. warning:: + + In Python 2.6 and 3.1 the ElementTree + :meth:`~xml.etree.ElementTree.Element.find()` method does not + support attribute selectors in the XPath expression. + + :param xml: The :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` + stanza to compare against. + """ + if hasattr(xml, 'xml'): + xml = xml.xml + x = ET.Element('x') + x.append(xml) + + if not IGNORE_NS: + # Use builtin, namespace respecting, XPath matcher. + if x.find(self._criteria) is not None: + return True + return False + else: + # Remove namespaces from the XPath expression. + criteria = [] + for ns_block in self._criteria.split('{'): + criteria.extend(ns_block.split('}')[-1].split('/')) + + # Walk the XPath expression. + xml = x + for tag in criteria: + if not tag: + # Skip empty tag name artifacts from the cleanup phase. + continue + + children = [c.tag.split('}')[-1] for c in xml.getchildren()] + try: + index = children.index(tag) + except ValueError: + return False + xml = xml.getchildren()[index] + return True diff --git a/sleekxmpp/xmlstream/scheduler.py b/sleekxmpp/xmlstream/scheduler.py new file mode 100644 index 00000000..4a6f073f --- /dev/null +++ b/sleekxmpp/xmlstream/scheduler.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.scheduler + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module provides a task scheduler that works better + with SleekXMPP's threading usage than the stock version. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import time +import threading +import logging +try: + import queue +except ImportError: + import Queue as queue + + +log = logging.getLogger(__name__) + + +class Task(object): + + """ + A scheduled task that will be executed by the scheduler + after a given time interval has passed. + + :param string name: The name of the task. + :param int seconds: The number of seconds to wait before executing. + :param callback: The function to execute. + :param tuple args: The arguments to pass to the callback. + :param dict kwargs: The keyword arguments to pass to the callback. + :param bool repeat: Indicates if the task should repeat. + Defaults to ``False``. + :param pointer: A pointer to an event queue for queuing callback + execution instead of executing immediately. + """ + + def __init__(self, name, seconds, callback, args=None, + kwargs=None, repeat=False, qpointer=None): + #: The name of the task. + self.name = name + + #: The number of seconds to wait before executing. + self.seconds = seconds + + #: The function to execute once enough time has passed. + self.callback = callback + + #: The arguments to pass to :attr:`callback`. + self.args = args or tuple() + + #: The keyword arguments to pass to :attr:`callback`. + self.kwargs = kwargs or {} + + #: Indicates if the task should repeat after executing, + #: using the same :attr:`seconds` delay. + self.repeat = repeat + + #: The time when the task should execute next. + self.next = time.time() + self.seconds + + #: The main event queue, which allows for callbacks to + #: be queued for execution instead of executing immediately. + self.qpointer = qpointer + + def run(self): + """Execute the task's callback. + + If an event queue was supplied, place the callback in the queue; + otherwise, execute the callback immediately. + """ + if self.qpointer is not None: + self.qpointer.put(('schedule', self.callback, + self.args, self.name)) + else: + self.callback(*self.args, **self.kwargs) + self.reset() + return self.repeat + + def reset(self): + """Reset the task's timer so that it will repeat.""" + self.next = time.time() + self.seconds + + +class Scheduler(object): + + """ + A threaded scheduler that allows for updates mid-execution unlike the + scheduler in the standard library. + + Based on: http://docs.python.org/library/sched.html#module-sched + + :param parentstop: An :class:`~threading.Event` to signal stopping + the scheduler. + """ + + def __init__(self, parentstop=None): + #: A queue for storing tasks + self.addq = queue.Queue() + + #: A list of tasks in order of execution time. + self.schedule = [] + + #: If running in threaded mode, this will be the thread processing + #: the schedule. + self.thread = None + + #: A flag indicating that the scheduler is running. + self.run = False + + #: An :class:`~threading.Event` instance for signalling to stop + #: the scheduler. + self.stop = parentstop + + #: Lock for accessing the task queue. + self.schedule_lock = threading.RLock() + + def process(self, threaded=True): + """Begin accepting and processing scheduled tasks. + + :param bool threaded: Indicates if the scheduler should execute + in its own thread. Defaults to ``True``. + """ + if threaded: + self.thread = threading.Thread(name='scheduler_process', + target=self._process) + self.thread.start() + else: + self._process() + + def _process(self): + """Process scheduled tasks.""" + self.run = True + try: + while self.run and not self.stop.isSet(): + wait = 1 + updated = False + if self.schedule: + wait = self.schedule[0].next - time.time() + try: + if wait <= 0.0: + newtask = self.addq.get(False) + else: + if wait >= 3.0: + wait = 3.0 + newtask = self.addq.get(True, wait) + except queue.Empty: + cleanup = [] + self.schedule_lock.acquire() + for task in self.schedule: + if time.time() >= task.next: + updated = True + if not task.run(): + cleanup.append(task) + else: + break + for task in cleanup: + x = self.schedule.pop(self.schedule.index(task)) + else: + updated = True + self.schedule_lock.acquire() + self.schedule.append(newtask) + finally: + if updated: + self.schedule = sorted(self.schedule, + key=lambda task: task.next) + self.schedule_lock.release() + except KeyboardInterrupt: + self.run = False + except SystemExit: + self.run = False + log.debug("Quitting Scheduler thread") + + def add(self, name, seconds, callback, args=None, + kwargs=None, repeat=False, qpointer=None): + """Schedule a new task. + + :param string name: The name of the task. + :param int seconds: The number of seconds to wait before executing. + :param callback: The function to execute. + :param tuple args: The arguments to pass to the callback. + :param dict kwargs: The keyword arguments to pass to the callback. + :param bool repeat: Indicates if the task should repeat. + Defaults to ``False``. + :param pointer: A pointer to an event queue for queuing callback + execution instead of executing immediately. + """ + try: + self.schedule_lock.acquire() + for task in self.schedule: + if task.name == name: + raise ValueError("Key %s already exists" % name) + + self.addq.put(Task(name, seconds, callback, args, + kwargs, repeat, qpointer)) + except: + raise + finally: + self.schedule_lock.release() + + def remove(self, name): + """Remove a scheduled task ahead of schedule, and without + executing it. + + :param string name: The name of the task to remove. + """ + try: + self.schedule_lock.acquire() + the_task = None + for task in self.schedule: + if task.name == name: + the_task = task + if the_task is not None: + self.schedule.remove(the_task) + except: + raise + finally: + self.schedule_lock.release() + + def quit(self): + """Shutdown the scheduler.""" + self.run = False diff --git a/sleekxmpp/xmlstream/stanzabase.py b/sleekxmpp/xmlstream/stanzabase.py new file mode 100644 index 00000000..dff8c997 --- /dev/null +++ b/sleekxmpp/xmlstream/stanzabase.py @@ -0,0 +1,1323 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.stanzabase + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module implements a wrapper layer for XML objects + that allows them to be treated like dictionaries. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import copy +import logging +import sys +import weakref +from xml.etree import cElementTree as ET + +from sleekxmpp.xmlstream import JID +from sleekxmpp.xmlstream.tostring import tostring +from sleekxmpp.thirdparty import OrderedDict + + +log = logging.getLogger(__name__) + + +# Used to check if an argument is an XML object. +XML_TYPE = type(ET.Element('xml')) + + +def register_stanza_plugin(stanza, plugin, iterable=False, overrides=False): + """ + Associate a stanza object as a plugin for another stanza. + + >>> from sleekxmpp.xmlstream import register_stanza_plugin + >>> register_stanza_plugin(Iq, CustomStanza) + + :param class stanza: The class of the parent stanza. + :param class plugin: The class of the plugin stanza. + :param bool iterable: Indicates if the plugin stanza should be + included in the parent stanza's iterable + ``'substanzas'`` interface results. + :param bool overrides: Indicates if the plugin should be allowed + to override the interface handlers for + the parent stanza, based on the plugin's + ``overrides`` field. + + .. versionadded:: 1.0-Beta1 + Made ``register_stanza_plugin`` the default name. The prior + ``registerStanzaPlugin`` function name remains as an alias. + """ + tag = "{%s}%s" % (plugin.namespace, plugin.name) + + # Prevent weird memory reference gotchas by ensuring + # that the parent stanza class has its own set of + # plugin info maps and is not using the mappings from + # an ancestor class (like ElementBase). + plugin_info = ('plugin_attrib_map', 'plugin_tag_map', + 'plugin_iterables', 'plugin_overrides') + for attr in plugin_info: + info = getattr(stanza, attr) + setattr(stanza, attr, info.copy()) + + stanza.plugin_attrib_map[plugin.plugin_attrib] = plugin + stanza.plugin_tag_map[tag] = plugin + + if iterable: + stanza.plugin_iterables.add(plugin) + if overrides: + for interface in plugin.overrides: + stanza.plugin_overrides[interface] = plugin.plugin_attrib + + +# To maintain backwards compatibility for now, preserve the camel case name. +registerStanzaPlugin = register_stanza_plugin + + +def fix_ns(xpath, split=False, propagate_ns=True, default_ns=''): + """Apply the stanza's namespace to elements in an XPath expression. + + :param string xpath: The XPath expression to fix with namespaces. + :param bool split: Indicates if the fixed XPath should be left as a + list of element names with namespaces. Defaults to + False, which returns a flat string path. + :param bool propagate_ns: Overrides propagating parent element + namespaces to child elements. Useful if + you wish to simply split an XPath that has + non-specified namespaces, and child and + parent namespaces are known not to always + match. Defaults to True. + """ + fixed = [] + # Split the XPath into a series of blocks, where a block + # is started by an element with a namespace. + ns_blocks = xpath.split('{') + for ns_block in ns_blocks: + if '}' in ns_block: + # Apply the found namespace to following elements + # that do not have namespaces. + namespace = ns_block.split('}')[0] + elements = ns_block.split('}')[1].split('/') + else: + # Apply the stanza's namespace to the following + # elements since no namespace was provided. + namespace = default_ns + elements = ns_block.split('/') + + for element in elements: + if element: + # Skip empty entry artifacts from splitting. + if propagate_ns: + tag = '{%s}%s' % (namespace, element) + else: + tag = element + fixed.append(tag) + if split: + return fixed + return '/'.join(fixed) + + +class ElementBase(object): + + """ + The core of SleekXMPP's stanza XML manipulation and handling is provided + by ElementBase. ElementBase wraps XML cElementTree objects and enables + access to the XML contents through dictionary syntax, similar in style + to the Ruby XMPP library Blather's stanza implementation. + + Stanzas are defined by their name, namespace, and interfaces. For + example, a simplistic Message stanza could be defined as:: + + >>> class Message(ElementBase): + ... name = "message" + ... namespace = "jabber:client" + ... interfaces = set(('to', 'from', 'type', 'body')) + ... sub_interfaces = set(('body',)) + + The resulting Message stanza's contents may be accessed as so:: + + >>> message['to'] = "user@example.com" + >>> message['body'] = "Hi!" + >>> message['body'] + "Hi!" + >>> del message['body'] + >>> message['body'] + "" + + The interface values map to either custom access methods, stanza + XML attributes, or (if the interface is also in sub_interfaces) the + text contents of a stanza's subelement. + + Custom access methods may be created by adding methods of the + form "getInterface", "setInterface", or "delInterface", where + "Interface" is the titlecase version of the interface name. + + Stanzas may be extended through the use of plugins. A plugin + is simply a stanza that has a plugin_attrib value. For example:: + + >>> class MessagePlugin(ElementBase): + ... name = "custom_plugin" + ... namespace = "custom" + ... interfaces = set(('useful_thing', 'custom')) + ... plugin_attrib = "custom" + + The plugin stanza class must be associated with its intended + container stanza by using register_stanza_plugin as so:: + + >>> register_stanza_plugin(Message, MessagePlugin) + + The plugin may then be accessed as if it were built-in to the parent + stanza:: + + >>> message['custom']['useful_thing'] = 'foo' + + If a plugin provides an interface that is the same as the plugin's + plugin_attrib value, then the plugin's interface may be assigned + directly from the parent stanza, as shown below, but retrieving + information will require all interfaces to be used, as so:: + + >>> message['custom'] = 'bar' # Same as using message['custom']['custom'] + >>> message['custom']['custom'] # Must use all interfaces + 'bar' + + If the plugin sets :attr:`is_extension` to ``True``, then both setting + and getting an interface value that is the same as the plugin's + plugin_attrib value will work, as so:: + + >>> message['custom'] = 'bar' # Using is_extension=True + >>> message['custom'] + 'bar' + + + :param xml: Initialize the stanza object with an existing XML object. + :param parent: Optionally specify a parent stanza object will will + contain this substanza. + """ + + #: The XML tag name of the element, not including any namespace + #: prefixes. For example, an :class:`ElementBase` object for ``<message />`` + #: would use ``name = 'message'``. + name = 'stanza' + + #: The XML namespace for the element. Given ``<foo xmlns="bar" />``, + #: then ``namespace = "bar"`` should be used. The default namespace + #: is ``jabber:client`` since this is being used in an XMPP library. + namespace = 'jabber:client' + + #: For :class:`ElementBase` subclasses which are intended to be used + #: as plugins, the ``plugin_attrib`` value defines the plugin name. + #: Plugins may be accessed by using the ``plugin_attrib`` value as + #: the interface. An example using ``plugin_attrib = 'foo'``:: + #: + #: register_stanza_plugin(Message, FooPlugin) + #: msg = Message() + #: msg['foo']['an_interface_from_the_foo_plugin'] + plugin_attrib = 'plugin' + + #: The set of keys that the stanza provides for accessing and + #: manipulating the underlying XML object. This set may be augmented + #: with the :attr:`plugin_attrib` value of any registered + #: stanza plugins. + interfaces = set(('type', 'to', 'from', 'id', 'payload')) + + #: A subset of :attr:`interfaces` which maps interfaces to direct + #: subelements of the underlying XML object. Using this set, the text + #: of these subelements may be set, retrieved, or removed without + #: needing to define custom methods. + sub_interfaces = tuple() + + #: In some cases you may wish to override the behaviour of one of the + #: parent stanza's interfaces. The ``overrides`` list specifies the + #: interface name and access method to be overridden. For example, + #: to override setting the parent's ``'condition'`` interface you + #: would use:: + #: + #: overrides = ['set_condition'] + #: + #: Getting and deleting the ``'condition'`` interface would not + #: be affected. + #: + #: .. versionadded:: 1.0-Beta5 + overrides = [] + + #: If you need to add a new interface to an existing stanza, you + #: can create a plugin and set ``is_extension = True``. Be sure + #: to set the :attr:`plugin_attrib` value to the desired interface + #: name, and that it is the only interface listed in + #: :attr:`interfaces`. Requests for the new interface from the + #: parent stanza will be passed to the plugin directly. + #: + #: .. versionadded:: 1.0-Beta5 + is_extension = False + + #: A map of interface operations to the overriding functions. + #: For example, after overriding the ``set`` operation for + #: the interface ``body``, :attr:`plugin_overrides` would be:: + #: + #: {'set_body': <some function>} + #: + #: .. versionadded: 1.0-Beta5 + plugin_overrides = {} + + #: A mapping of the :attr:`plugin_attrib` values of registered + #: plugins to their respective classes. + plugin_attrib_map = {} + + #: A mapping of root element tag names (in ``'{namespace}elementname'`` + #: format) to the plugin classes responsible for them. + plugin_tag_map = {} + + #: The set of stanza classes that can be iterated over using + #: the 'substanzas' interface. Classes are added to this set + #: when registering a plugin with ``iterable=True``:: + #: + #: register_stanza_plugin(DiscoInfo, DiscoItem, iterable=True) + #: + #: .. versionadded:: 1.0-Beta5 + plugin_iterables = set() + + #: A deprecated version of :attr:`plugin_iterables` that remains + #: for backward compatibility. It required a parent stanza to + #: know beforehand what stanza classes would be iterable:: + #: + #: class DiscoItem(ElementBase): + #: ... + #: + #: class DiscoInfo(ElementBase): + #: subitem = (DiscoItem, ) + #: ... + #: + #: .. deprecated:: 1.0-Beta5 + subitem = set() + + #: The default XML namespace: ``http://www.w3.org/XML/1998/namespace``. + xml_ns = 'http://www.w3.org/XML/1998/namespace' + + def __init__(self, xml=None, parent=None): + self._index = 0 + + #: The underlying XML object for the stanza. It is a standard + #: :class:`xml.etree.cElementTree` object. + self.xml = xml + + #: An ordered dictionary of plugin stanzas, mapped by their + #: :attr:`plugin_attrib` value. + self.plugins = OrderedDict() + + #: A list of child stanzas whose class is included in + #: :attr:`plugin_iterables`. + self.iterables = [] + + #: The name of the tag for the stanza's root element. It is the + #: same as calling :meth:`tag_name()` and is formatted as + #: ``'{namespace}elementname'``. + self.tag = self.tag_name() + + #: A :class:`weakref.weakref` to the parent stanza, if there is one. + #: If not, then :attr:`parent` is ``None``. + self.parent = None + if parent is not None: + self.parent = weakref.ref(parent) + + if self.subitem is not None: + for sub in self.subitem: + self.plugin_iterables.add(sub) + + if self.setup(xml): + # If we generated our own XML, then everything is ready. + return + + # Initialize values using provided XML + for child in self.xml.getchildren(): + if child.tag in self.plugin_tag_map: + plugin_class = self.plugin_tag_map[child.tag] + plugin = plugin_class(child, self) + self.plugins[plugin.plugin_attrib] = plugin + if plugin_class in self.plugin_iterables: + self.iterables.append(plugin) + + def setup(self, xml=None): + """Initialize the stanza's XML contents. + + Will return ``True`` if XML was generated according to the stanza's + definition instead of building a stanza object from an existing + XML object. + + :param xml: An existing XML object to use for the stanza's content + instead of generating new XML. + """ + if self.xml is None: + self.xml = xml + + if self.xml is None: + # Generate XML from the stanza definition + for ename in self.name.split('/'): + new = ET.Element("{%s}%s" % (self.namespace, ename)) + if self.xml is None: + self.xml = new + else: + last_xml.append(new) + last_xml = new + if self.parent is not None: + self.parent().xml.append(self.xml) + + # We had to generate XML + return True + else: + # We did not generate XML + return False + + def enable(self, attrib): + """Enable and initialize a stanza plugin. + + Alias for :meth:`init_plugin`. + + :param string attrib: The :attr:`plugin_attrib` value of the + plugin to enable. + """ + return self.init_plugin(attrib) + + def init_plugin(self, attrib): + """Enable and initialize a stanza plugin. + + :param string attrib: The :attr:`plugin_attrib` value of the + plugin to enable. + """ + if attrib not in self.plugins: + plugin_class = self.plugin_attrib_map[attrib] + existing_xml = self.xml.find(plugin_class.tag_name()) + plugin = plugin_class(parent=self, xml=existing_xml) + self.plugins[attrib] = plugin + if plugin_class in self.plugin_iterables: + self.iterables.append(plugin) + return self + + def _get_stanza_values(self): + """Return A JSON/dictionary version of the XML content + exposed through the stanza's interfaces:: + + >>> msg = Message() + >>> msg.values + {'body': '', 'from': , 'mucnick': '', 'mucroom': '', + 'to': , 'type': 'normal', 'id': '', 'subject': ''} + + Likewise, assigning to :attr:`values` will change the XML + content:: + + >>> msg = Message() + >>> msg.values = {'body': 'Hi!', 'to': 'user@example.com'} + >>> msg + '<message to="user@example.com"><body>Hi!</body></message>' + + .. versionadded:: 1.0-Beta1 + """ + values = {} + for interface in self.interfaces: + values[interface] = self[interface] + for plugin, stanza in self.plugins.items(): + values[plugin] = stanza.values + if self.iterables: + iterables = [] + for stanza in self.iterables: + iterables.append(stanza.values) + iterables[-1]['__childtag__'] = stanza.tag + values['substanzas'] = iterables + return values + + def _set_stanza_values(self, values): + """Set multiple stanza interface values using a dictionary. + + Stanza plugin values may be set using nested dictionaries. + + :param values: A dictionary mapping stanza interface with values. + Plugin interfaces may accept a nested dictionary that + will be used recursively. + + .. versionadded:: 1.0-Beta1 + """ + iterable_interfaces = [p.plugin_attrib for \ + p in self.plugin_iterables] + + for interface, value in values.items(): + if interface == 'substanzas': + # Remove existing substanzas + for stanza in self.iterables: + self.xml.remove(stanza.xml) + self.iterables = [] + + # Add new substanzas + for subdict in value: + if '__childtag__' in subdict: + for subclass in self.plugin_iterables: + child_tag = "{%s}%s" % (subclass.namespace, + subclass.name) + if subdict['__childtag__'] == child_tag: + sub = subclass(parent=self) + sub.values = subdict + self.iterables.append(sub) + break + elif interface in self.interfaces: + self[interface] = value + elif interface in self.plugin_attrib_map: + if interface not in iterable_interfaces: + if interface not in self.plugins: + self.init_plugin(interface) + self.plugins[interface].values = value + return self + + def __getitem__(self, attrib): + """Return the value of a stanza interface using dict-like syntax. + + Example:: + + >>> msg['body'] + 'Message contents' + + Stanza interfaces are typically mapped directly to the underlying XML + object, but can be overridden by the presence of a ``get_attrib`` + method (or ``get_foo`` where the interface is named ``'foo'``, etc). + + The search order for interface value retrieval for an interface + named ``'foo'`` is: + + 1. The list of substanzas (``'substanzas'``) + 2. The result of calling the ``get_foo`` override handler. + 3. The result of calling ``get_foo``. + 4. The result of calling ``getFoo``. + 5. The contents of the ``foo`` subelement, if ``foo`` is listed + in :attr:`sub_interfaces`. + 6. The value of the ``foo`` attribute of the XML object. + 7. The plugin named ``'foo'`` + 8. An empty string. + + :param string attrib: The name of the requested stanza interface. + """ + if attrib == 'substanzas': + return self.iterables + elif attrib in self.interfaces: + get_method = "get_%s" % attrib.lower() + get_method2 = "get%s" % attrib.title() + + if self.plugin_overrides: + plugin = self.plugin_overrides.get(get_method, None) + if plugin: + if plugin not in self.plugins: + self.init_plugin(plugin) + handler = getattr(self.plugins[plugin], get_method, None) + if handler: + return handler() + + if hasattr(self, get_method): + return getattr(self, get_method)() + elif hasattr(self, get_method2): + return getattr(self, get_method2)() + else: + if attrib in self.sub_interfaces: + return self._get_sub_text(attrib) + else: + return self._get_attr(attrib) + elif attrib in self.plugin_attrib_map: + if attrib not in self.plugins: + self.init_plugin(attrib) + if self.plugins[attrib].is_extension: + return self.plugins[attrib][attrib] + return self.plugins[attrib] + else: + return '' + + def __setitem__(self, attrib, value): + """Set the value of a stanza interface using dictionary-like syntax. + + Example:: + + >>> msg['body'] = "Hi!" + >>> msg['body'] + 'Hi!' + + Stanza interfaces are typically mapped directly to the underlying XML + object, but can be overridden by the presence of a ``set_attrib`` + method (or ``set_foo`` where the interface is named ``'foo'``, etc). + + The effect of interface value assignment for an interface + named ``'foo'`` will be one of: + + 1. Delete the interface's contents if the value is None. + 2. Call the ``set_foo`` override handler, if it exists. + 3. Call ``set_foo``, if it exists. + 4. Call ``setFoo``, if it exists. + 5. Set the text of a ``foo`` element, if ``'foo'`` is + in :attr:`sub_interfaces`. + 6. Set the value of a top level XML attribute named ``foo``. + 7. Attempt to pass the value to a plugin named ``'foo'`` using + the plugin's ``'foo'`` interface. + 8. Do nothing. + + :param string attrib: The name of the stanza interface to modify. + :param value: The new value of the stanza interface. + """ + if attrib in self.interfaces: + if value is not None: + set_method = "set_%s" % attrib.lower() + set_method2 = "set%s" % attrib.title() + + if self.plugin_overrides: + plugin = self.plugin_overrides.get(set_method, None) + if plugin: + if plugin not in self.plugins: + self.init_plugin(plugin) + handler = getattr(self.plugins[plugin], + set_method, None) + if handler: + return handler(value) + + if hasattr(self, set_method): + getattr(self, set_method)(value,) + elif hasattr(self, set_method2): + getattr(self, set_method2)(value,) + else: + if attrib in self.sub_interfaces: + return self._set_sub_text(attrib, text=value) + else: + self._set_attr(attrib, value) + else: + self.__delitem__(attrib) + elif attrib in self.plugin_attrib_map: + if attrib not in self.plugins: + self.init_plugin(attrib) + self.plugins[attrib][attrib] = value + return self + + def __delitem__(self, attrib): + """Delete the value of a stanza interface using dict-like syntax. + + Example:: + + >>> msg['body'] = "Hi!" + >>> msg['body'] + 'Hi!' + >>> del msg['body'] + >>> msg['body'] + '' + + Stanza interfaces are typically mapped directly to the underlyig XML + object, but can be overridden by the presence of a ``del_attrib`` + method (or ``del_foo`` where the interface is named ``'foo'``, etc). + + The effect of deleting a stanza interface value named ``foo`` will be + one of: + + 1. Call ``del_foo`` override handler, if it exists. + 2. Call ``del_foo``, if it exists. + 3. Call ``delFoo``, if it exists. + 4. Delete ``foo`` element, if ``'foo'`` is in + :attr:`sub_interfaces`. + 5. Delete top level XML attribute named ``foo``. + 6. Remove the ``foo`` plugin, if it was loaded. + 7. Do nothing. + + :param attrib: The name of the affected stanza interface. + """ + if attrib in self.interfaces: + del_method = "del_%s" % attrib.lower() + del_method2 = "del%s" % attrib.title() + + if self.plugin_overrides: + plugin = self.plugin_overrides.get(del_method, None) + if plugin: + if plugin not in self.plugins: + self.init_plugin(plugin) + handler = getattr(self.plugins[plugin], del_method, None) + if handler: + return handler() + + if hasattr(self, del_method): + getattr(self, del_method)() + elif hasattr(self, del_method2): + getattr(self, del_method2)() + else: + if attrib in self.sub_interfaces: + return self._del_sub(attrib) + else: + self._del_attr(attrib) + elif attrib in self.plugin_attrib_map: + if attrib in self.plugins: + xml = self.plugins[attrib].xml + if self.plugins[attrib].is_extension: + del self.plugins[attrib][attrib] + del self.plugins[attrib] + try: + self.xml.remove(xml) + except: + pass + return self + + def _set_attr(self, name, value): + """Set the value of a top level attribute of the XML object. + + If the new value is None or an empty string, then the attribute will + be removed. + + :param name: The name of the attribute. + :param value: The new value of the attribute, or None or '' to + remove it. + """ + if value is None or value == '': + self.__delitem__(name) + else: + self.xml.attrib[name] = value + + def _del_attr(self, name): + """Remove a top level attribute of the XML object. + + :param name: The name of the attribute. + """ + if name in self.xml.attrib: + del self.xml.attrib[name] + + def _get_attr(self, name, default=''): + """Return the value of a top level attribute of the XML object. + + In case the attribute has not been set, a default value can be + returned instead. An empty string is returned if no other default + is supplied. + + :param name: The name of the attribute. + :param default: Optional value to return if the attribute has not + been set. An empty string is returned otherwise. + """ + return self.xml.attrib.get(name, default) + + def _get_sub_text(self, name, default=''): + """Return the text contents of a sub element. + + In case the element does not exist, or it has no textual content, + a default value can be returned instead. An empty string is returned + if no other default is supplied. + + :param name: The name or XPath expression of the element. + :param default: Optional default to return if the element does + not exists. An empty string is returned otherwise. + """ + name = self._fix_ns(name) + stanza = self.xml.find(name) + if stanza is None or stanza.text is None: + return default + else: + return stanza.text + + def _set_sub_text(self, name, text=None, keep=False): + """Set the text contents of a sub element. + + In case the element does not exist, a element will be created, + and its text contents will be set. + + If the text is set to an empty string, or None, then the + element will be removed, unless keep is set to True. + + :param name: The name or XPath expression of the element. + :param text: The new textual content of the element. If the text + is an empty string or None, the element will be removed + unless the parameter keep is True. + :param keep: Indicates if the element should be kept if its text is + removed. Defaults to False. + """ + path = self._fix_ns(name, split=True) + element = self.xml.find(name) + + if not text and not keep: + return self._del_sub(name) + + if element is None: + # We need to add the element. If the provided name was + # an XPath expression, some of the intermediate elements + # may already exist. If so, we want to use those instead + # of generating new elements. + last_xml = self.xml + walked = [] + for ename in path: + walked.append(ename) + element = self.xml.find("/".join(walked)) + if element is None: + element = ET.Element(ename) + last_xml.append(element) + last_xml = element + element = last_xml + + element.text = text + return element + + def _del_sub(self, name, all=False): + """Remove sub elements that match the given name or XPath. + + If the element is in a path, then any parent elements that become + empty after deleting the element may also be deleted if requested + by setting all=True. + + :param name: The name or XPath expression for the element(s) to remove. + :param bool all: If True, remove all empty elements in the path to the + deleted element. Defaults to False. + """ + path = self._fix_ns(name, split=True) + original_target = path[-1] + + for level, _ in enumerate(path): + # Generate the paths to the target elements and their parent. + element_path = "/".join(path[:len(path) - level]) + parent_path = "/".join(path[:len(path) - level - 1]) + + elements = self.xml.findall(element_path) + parent = self.xml.find(parent_path) + + if elements: + if parent is None: + parent = self.xml + for element in elements: + if element.tag == original_target or \ + not element.getchildren(): + # Only delete the originally requested elements, and + # any parent elements that have become empty. + parent.remove(element) + if not all: + # If we don't want to delete elements up the tree, stop + # after deleting the first level of elements. + return + + def match(self, xpath): + """Compare a stanza object with an XPath-like expression. + + If the XPath matches the contents of the stanza object, the match + is successful. + + The XPath expression may include checks for stanza attributes. + For example:: + + 'presence@show=xa@priority=2/status' + + Would match a presence stanza whose show value is set to ``'xa'``, + has a priority value of ``'2'``, and has a status element. + + :param string xpath: The XPath expression to check against. It + may be either a string or a list of element + names with attribute checks. + """ + if not isinstance(xpath, list): + xpath = self._fix_ns(xpath, split=True, propagate_ns=False) + + # Extract the tag name and attribute checks for the first XPath node. + components = xpath[0].split('@') + tag = components[0] + attributes = components[1:] + + if tag not in (self.name, "{%s}%s" % (self.namespace, self.name)) and \ + tag not in self.plugins and tag not in self.plugin_attrib: + # The requested tag is not in this stanza, so no match. + return False + + # Check the rest of the XPath against any substanzas. + matched_substanzas = False + for substanza in self.iterables: + if xpath[1:] == []: + break + matched_substanzas = substanza.match(xpath[1:]) + if matched_substanzas: + break + + # Check attribute values. + for attribute in attributes: + name, value = attribute.split('=') + if self[name] != value: + return False + + # Check sub interfaces. + if len(xpath) > 1: + next_tag = xpath[1] + if next_tag in self.sub_interfaces and self[next_tag]: + return True + + # Attempt to continue matching the XPath using the stanza's plugins. + if not matched_substanzas and len(xpath) > 1: + # Convert {namespace}tag@attribs to just tag + next_tag = xpath[1].split('@')[0].split('}')[-1] + if next_tag in self.plugins: + return self.plugins[next_tag].match(xpath[1:]) + else: + return False + + # Everything matched. + return True + + def find(self, xpath): + """Find an XML object in this stanza given an XPath expression. + + Exposes ElementTree interface for backwards compatibility. + + .. note:: + + Matching on attribute values is not supported in Python 2.6 + or Python 3.1 + + :param string xpath: An XPath expression matching a single + desired element. + """ + return self.xml.find(xpath) + + def findall(self, xpath): + """Find multiple XML objects in this stanza given an XPath expression. + + Exposes ElementTree interface for backwards compatibility. + + .. note:: + + Matching on attribute values is not supported in Python 2.6 + or Python 3.1. + + :param string xpath: An XPath expression matching multiple + desired elements. + """ + return self.xml.findall(xpath) + + def get(self, key, default=None): + """Return the value of a stanza interface. + + If the found value is None or an empty string, return the supplied + default value. + + Allows stanza objects to be used like dictionaries. + + :param string key: The name of the stanza interface to check. + :param default: Value to return if the stanza interface has a value + of ``None`` or ``""``. Will default to returning None. + """ + value = self[key] + if value is None or value == '': + return default + return value + + def keys(self): + """Return the names of all stanza interfaces provided by the + stanza object. + + Allows stanza objects to be used like dictionaries. + """ + out = [] + out += [x for x in self.interfaces] + out += [x for x in self.plugins] + if self.iterables: + out.append('substanzas') + return out + + def append(self, item): + """Append either an XML object or a substanza to this stanza object. + + If a substanza object is appended, it will be added to the list + of iterable stanzas. + + Allows stanza objects to be used like lists. + + :param item: Either an XML object or a stanza object to add to + this stanza's contents. + """ + if not isinstance(item, ElementBase): + if type(item) == XML_TYPE: + return self.appendxml(item) + else: + raise TypeError + self.xml.append(item.xml) + self.iterables.append(item) + return self + + def appendxml(self, xml): + """Append an XML object to the stanza's XML. + + The added XML will not be included in the list of + iterable substanzas. + + :param XML xml: The XML object to add to the stanza. + """ + self.xml.append(xml) + return self + + def pop(self, index=0): + """Remove and return the last substanza in the list of + iterable substanzas. + + Allows stanza objects to be used like lists. + + :param int index: The index of the substanza to remove. + """ + substanza = self.iterables.pop(index) + self.xml.remove(substanza.xml) + return substanza + + def next(self): + """Return the next iterable substanza.""" + return self.__next__() + + def clear(self): + """Remove all XML element contents and plugins. + + Any attribute values will be preserved. + """ + for child in self.xml.getchildren(): + self.xml.remove(child) + for plugin in list(self.plugins.keys()): + del self.plugins[plugin] + return self + + @classmethod + def tag_name(cls): + """Return the namespaced name of the stanza's root element. + + The format for the tag name is:: + + '{namespace}elementname' + + For example, for the stanza ``<foo xmlns="bar" />``, + ``stanza.tag_name()`` would return ``"{bar}foo"``. + """ + return "{%s}%s" % (cls.namespace, cls.name) + + @property + def attrib(self): + """Return the stanza object itself. + + Older implementations of stanza objects used XML objects directly, + requiring the use of ``.attrib`` to access attribute values. + + Use of the dictionary syntax with the stanza object itself for + accessing stanza interfaces is preferred. + + .. deprecated:: 1.0 + """ + return self + + def _fix_ns(self, xpath, split=False, propagate_ns=True): + return fix_ns(xpath, split=split, + propagate_ns=propagate_ns, + default_ns=self.namespace) + + def __eq__(self, other): + """Compare the stanza object with another to test for equality. + + Stanzas are equal if their interfaces return the same values, + and if they are both instances of ElementBase. + + :param ElementBase other: The stanza object to compare against. + """ + if not isinstance(other, ElementBase): + return False + + # Check that this stanza is a superset of the other stanza. + values = self.values + for key in other.keys(): + if key not in values or values[key] != other[key]: + return False + + # Check that the other stanza is a superset of this stanza. + values = other.values + for key in self.keys(): + if key not in values or values[key] != self[key]: + return False + + # Both stanzas are supersets of each other, therefore they + # must be equal. + return True + + def __ne__(self, other): + """Compare the stanza object with another to test for inequality. + + Stanzas are not equal if their interfaces return different values, + or if they are not both instances of ElementBase. + + :param ElementBase other: The stanza object to compare against. + """ + return not self.__eq__(other) + + def __bool__(self): + """Stanza objects should be treated as True in boolean contexts. + + Python 3.x version. + """ + return True + + def __nonzero__(self): + """Stanza objects should be treated as True in boolean contexts. + + Python 2.x version. + """ + return True + + def __len__(self): + """Return the number of iterable substanzas in this stanza.""" + return len(self.iterables) + + def __iter__(self): + """Return an iterator object for the stanza's substanzas. + + The iterator is the stanza object itself. Attempting to use two + iterators on the same stanza at the same time is discouraged. + """ + self._index = 0 + return self + + def __next__(self): + """Return the next iterable substanza.""" + self._index += 1 + if self._index > len(self.iterables): + self._index = 0 + raise StopIteration + return self.iterables[self._index - 1] + + def __copy__(self): + """Return a copy of the stanza object that does not share the same + underlying XML object. + """ + return self.__class__(xml=copy.deepcopy(self.xml), parent=self.parent) + + def __str__(self, top_level_ns=True): + """Return a string serialization of the underlying XML object. + + .. seealso:: :ref:`tostring` + + :param bool top_level_ns: Display the top-most namespace. + Defaults to True. + """ + stanza_ns = '' if top_level_ns else self.namespace + return tostring(self.xml, xmlns='', + stanza_ns=stanza_ns, + top_level=not top_level_ns) + + def __repr__(self): + """Use the stanza's serialized XML as its representation.""" + return self.__str__() + + +class StanzaBase(ElementBase): + + """ + StanzaBase provides the foundation for all other stanza objects used + by SleekXMPP, and defines a basic set of interfaces common to nearly + all stanzas. These interfaces are the ``'id'``, ``'type'``, ``'to'``, + and ``'from'`` attributes. An additional interface, ``'payload'``, is + available to access the XML contents of the stanza. Most stanza objects + will provided more specific interfaces, however. + + **Stanza Interfaces:** + + :id: An optional id value that can be used to associate stanzas + :to: A JID object representing the recipient's JID. + :from: A JID object representing the sender's JID. + with their replies. + :type: The type of stanza, typically will be ``'normal'``, + ``'error'``, ``'get'``, or ``'set'``, etc. + :payload: The XML contents of the stanza. + + :param XMLStream stream: Optional :class:`sleekxmpp.xmlstream.XMLStream` + object responsible for sending this stanza. + :param XML xml: Optional XML contents to initialize stanza values. + :param string stype: Optional stanza type value. + :param sto: Optional string or :class:`sleekxmpp.xmlstream.JID` + object of the recipient's JID. + :param sfrom: Optional string or :class:`sleekxmpp.xmlstream.JID` + object of the sender's JID. + :param string sid: Optional ID value for the stanza. + """ + + #: The default XMPP client namespace + namespace = 'jabber:client' + + #: There is a small set of attributes which apply to all XMPP stanzas: + #: the stanza type, the to and from JIDs, the stanza ID, and, especially + #: in the case of an Iq stanza, a payload. + interfaces = set(('type', 'to', 'from', 'id', 'payload')) + + #: A basic set of allowed values for the ``'type'`` interface. + types = set(('get', 'set', 'error', None, 'unavailable', 'normal', 'chat')) + + def __init__(self, stream=None, xml=None, stype=None, + sto=None, sfrom=None, sid=None): + self.stream = stream + if stream is not None: + self.namespace = stream.default_ns + ElementBase.__init__(self, xml) + if stype is not None: + self['type'] = stype + if sto is not None: + self['to'] = sto + if sfrom is not None: + self['from'] = sfrom + self.tag = "{%s}%s" % (self.namespace, self.name) + + def set_type(self, value): + """Set the stanza's ``'type'`` attribute. + + Only type values contained in :attr:`types` are accepted. + + :param string value: One of the values contained in :attr:`types` + """ + if value in self.types: + self.xml.attrib['type'] = value + return self + + def get_to(self): + """Return the value of the stanza's ``'to'`` attribute.""" + return JID(self._get_attr('to')) + + def set_to(self, value): + """Set the ``'to'`` attribute of the stanza. + + :param value: A string or :class:`sleekxmpp.xmlstream.JID` object + representing the recipient's JID. + """ + return self._set_attr('to', str(value)) + + def get_from(self): + """Return the value of the stanza's ``'from'`` attribute.""" + return JID(self._get_attr('from')) + + def set_from(self, value): + """Set the 'from' attribute of the stanza. + + Arguments: + from -- A string or JID object representing the sender's JID. + """ + return self._set_attr('from', str(value)) + + def get_payload(self): + """Return a list of XML objects contained in the stanza.""" + return self.xml.getchildren() + + def set_payload(self, value): + """Add XML content to the stanza. + + :param value: Either an XML or a stanza object, or a list + of XML or stanza objects. + """ + if not isinstance(value, list): + value = [value] + for val in value: + self.append(val) + return self + + def del_payload(self): + """Remove the XML contents of the stanza.""" + self.clear() + return self + + def reply(self, clear=True): + """Prepare the stanza for sending a reply. + + Swaps the ``'from'`` and ``'to'`` attributes. + + If ``clear=True``, then also remove the stanza's + contents to make room for the reply content. + + For client streams, the ``'from'`` attribute is removed. + + :param bool clear: Indicates if the stanza's contents should be + removed. Defaults to ``True``. + """ + # if it's a component, use from + if self.stream and hasattr(self.stream, "is_component") and \ + self.stream.is_component: + self['from'], self['to'] = self['to'], self['from'] + else: + self['to'] = self['from'] + del self['from'] + if clear: + self.clear() + return self + + def error(self): + """Set the stanza's type to ``'error'``.""" + self['type'] = 'error' + return self + + def unhandled(self): + """Called if no handlers have been registered to process this stanza. + + Meant to be overridden. + """ + pass + + def exception(self, e): + """Handle exceptions raised during stanza processing. + + Meant to be overridden. + """ + log.exception('Error handling {%s}%s stanza', self.namespace, + self.name) + + def send(self, now=False): + """Queue the stanza to be sent on the XML stream. + + :param bool now: Indicates if the queue should be skipped and the + stanza sent immediately. Useful for stream + initialization. Defaults to ``False``. + """ + self.stream.send(self, now=now) + + def __copy__(self): + """Return a copy of the stanza object that does not share the + same underlying XML object, but does share the same XML stream. + """ + return self.__class__(xml=copy.deepcopy(self.xml), + stream=self.stream) + + def __str__(self, top_level_ns=False): + """Serialize the stanza's XML to a string. + + :param bool top_level_ns: Display the top-most namespace. + Defaults to ``False``. + """ + stanza_ns = '' if top_level_ns else self.namespace + return tostring(self.xml, xmlns='', + stanza_ns=stanza_ns, + stream=self.stream, + top_level=not top_level_ns) + + +#: A JSON/dictionary version of the XML content exposed through +#: the stanza interfaces:: +#: +#: >>> msg = Message() +#: >>> msg.values +#: {'body': '', 'from': , 'mucnick': '', 'mucroom': '', +#: 'to': , 'type': 'normal', 'id': '', 'subject': ''} +#: +#: Likewise, assigning to the :attr:`values` will change the XML +#: content:: +#: +#: >>> msg = Message() +#: >>> msg.values = {'body': 'Hi!', 'to': 'user@example.com'} +#: >>> msg +#: '<message to="user@example.com"><body>Hi!</body></message>' +#: +#: Child stanzas are exposed as nested dictionaries. +ElementBase.values = property(ElementBase._get_stanza_values, + ElementBase._set_stanza_values) + + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +ElementBase.initPlugin = ElementBase.init_plugin +ElementBase._getAttr = ElementBase._get_attr +ElementBase._setAttr = ElementBase._set_attr +ElementBase._delAttr = ElementBase._del_attr +ElementBase._getSubText = ElementBase._get_sub_text +ElementBase._setSubText = ElementBase._set_sub_text +ElementBase._delSub = ElementBase._del_sub +ElementBase.getStanzaValues = ElementBase._get_stanza_values +ElementBase.setStanzaValues = ElementBase._set_stanza_values + +StanzaBase.setType = StanzaBase.set_type +StanzaBase.getTo = StanzaBase.get_to +StanzaBase.setTo = StanzaBase.set_to +StanzaBase.getFrom = StanzaBase.get_from +StanzaBase.setFrom = StanzaBase.set_from +StanzaBase.getPayload = StanzaBase.get_payload +StanzaBase.setPayload = StanzaBase.set_payload +StanzaBase.delPayload = StanzaBase.del_payload diff --git a/sleekxmpp/xmlstream/test.py b/sleekxmpp/xmlstream/test.py new file mode 100644 index 00000000..a45fb8b4 --- /dev/null +++ b/sleekxmpp/xmlstream/test.py @@ -0,0 +1,23 @@ +import xmlstream +import time +import socket +from handler.callback import Callback +from matcher.xpath import MatchXPath + +def server(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(('localhost', 5228)) + s.listen(1) + servers = [] + while True: + conn, addr = s.accept() + server = xmlstream.XMLStream(conn, 'localhost', 5228) + server.registerHandler(Callback('test', MatchXPath('test'), testHandler)) + server.process() + servers.append(server) + +def testHandler(xml): + print("weeeeeeeee!") + +server() diff --git a/sleekxmpp/xmlstream/test.xml b/sleekxmpp/xmlstream/test.xml new file mode 100644 index 00000000..d20dd82c --- /dev/null +++ b/sleekxmpp/xmlstream/test.xml @@ -0,0 +1,2 @@ +<stream> +</stream> diff --git a/sleekxmpp/xmlstream/testclient.py b/sleekxmpp/xmlstream/testclient.py new file mode 100644 index 00000000..50eb6c50 --- /dev/null +++ b/sleekxmpp/xmlstream/testclient.py @@ -0,0 +1,13 @@ +import socket +import time + +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.connect(('localhost', 5228)) +s.send("<stream>") +#s.flush() +s.send("<test/>") +s.send("<test/>") +s.send("<test/>") +s.send("</stream>") +#s.flush() +s.close() diff --git a/sleekxmpp/xmlstream/tostring.py b/sleekxmpp/xmlstream/tostring.py new file mode 100644 index 00000000..8e729f79 --- /dev/null +++ b/sleekxmpp/xmlstream/tostring.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.tostring + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module converts XML objects into Unicode strings and + intelligently includes namespaces only when necessary to + keep the output readable. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import sys + +if sys.version_info < (3, 0): + import types + + +def tostring(xml=None, xmlns='', stanza_ns='', stream=None, + outbuffer='', top_level=False): + """Serialize an XML object to a Unicode string. + + If namespaces are provided using ``xmlns`` or ``stanza_ns``, then + elements that use those namespaces will not include the xmlns attribute + in the output. + + :param XML xml: The XML object to serialize. + :param string xmlns: Optional namespace of an element wrapping the XML + object. + :param string stanza_ns: The namespace of the stanza object that contains + the XML object. + :param stream: The XML stream that generated the XML object. + :param string outbuffer: Optional buffer for storing serializations + during recursive calls. + :param bool top_level: Indicates that the element is the outermost + element. + + + :type xml: :py:class:`~xml.etree.ElementTree.Element` + :type stream: :class:`~sleekxmpp.xmlstream.xmlstream.XMLStream` + + :rtype: Unicode string + """ + # Add previous results to the start of the output. + output = [outbuffer] + + # Extract the element's tag name. + tag_name = xml.tag.split('}', 1)[-1] + + # Extract the element's namespace if it is defined. + if '}' in xml.tag: + tag_xmlns = xml.tag.split('}', 1)[0][1:] + else: + tag_xmlns = '' + + default_ns = '' + stream_ns = '' + if stream: + default_ns = stream.default_ns + stream_ns = stream.stream_ns + + # Output the tag name and derived namespace of the element. + namespace = '' + if top_level and tag_xmlns not in ['', default_ns, stream_ns] or \ + tag_xmlns not in ['', xmlns, stanza_ns, stream_ns]: + namespace = ' xmlns="%s"' % tag_xmlns + if stream and tag_xmlns in stream.namespace_map: + mapped_namespace = stream.namespace_map[tag_xmlns] + if mapped_namespace: + tag_name = "%s:%s" % (mapped_namespace, tag_name) + output.append("<%s" % tag_name) + output.append(namespace) + + # Output escaped attribute values. + for attrib, value in xml.attrib.items(): + value = xml_escape(value) + if '}' not in attrib: + output.append(' %s="%s"' % (attrib, value)) + else: + attrib_ns = attrib.split('}')[0][1:] + attrib = attrib.split('}')[1] + if stream and attrib_ns in stream.namespace_map: + mapped_ns = stream.namespace_map[attrib_ns] + if mapped_ns: + output.append(' %s:%s="%s"' % (mapped_ns, + attrib, + value)) + + if len(xml) or xml.text: + # If there are additional child elements to serialize. + output.append(">") + if xml.text: + output.append(xml_escape(xml.text)) + if len(xml): + for child in xml.getchildren(): + output.append(tostring(child, tag_xmlns, stanza_ns, stream)) + output.append("</%s>" % tag_name) + elif xml.text: + # If we only have text content. + output.append(">%s</%s>" % (xml_escape(xml.text), tag_name)) + else: + # Empty element. + output.append(" />") + if xml.tail: + # If there is additional text after the element. + output.append(xml_escape(xml.tail)) + return ''.join(output) + + +def xml_escape(text): + """Convert special characters in XML to escape sequences. + + :param string text: The XML text to convert. + :rtype: Unicode string + """ + if sys.version_info < (3, 0): + if type(text) != types.UnicodeType: + text = unicode(text, 'utf-8', 'ignore') + + text = list(text) + escapes = {'&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"'} + for i, c in enumerate(text): + text[i] = escapes.get(c, c) + return ''.join(text) diff --git a/sleekxmpp/xmlstream/xmlstream.py b/sleekxmpp/xmlstream/xmlstream.py new file mode 100644 index 00000000..4c8696b3 --- /dev/null +++ b/sleekxmpp/xmlstream/xmlstream.py @@ -0,0 +1,1479 @@ +# -*- coding: utf-8 -*- +""" + sleekxmpp.xmlstream.xmlstream + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module provides the module for creating and + interacting with generic XML streams, along with + the necessary eventing infrastructure. + + Part of SleekXMPP: The Sleek XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from __future__ import with_statement, unicode_literals + +import base64 +import copy +import logging +import signal +import socket as Socket +import ssl +import sys +import threading +import time +import types +import random +import weakref +try: + import queue +except ImportError: + import Queue as queue + +import sleekxmpp +from sleekxmpp.thirdparty.statemachine import StateMachine +from sleekxmpp.xmlstream import Scheduler, tostring +from sleekxmpp.xmlstream.stanzabase import StanzaBase, ET, ElementBase +from sleekxmpp.xmlstream.handler import Waiter, XMLCallback +from sleekxmpp.xmlstream.matcher import MatchXMLMask + +# In Python 2.x, file socket objects are broken. A patched socket +# wrapper is provided for this case in filesocket.py. +if sys.version_info < (3, 0): + from sleekxmpp.xmlstream.filesocket import FileSocket, Socket26 + +try: + import dns.resolver +except ImportError: + DNSPYTHON = False +else: + DNSPYTHON = True + + +#: The time in seconds to wait before timing out waiting for response stanzas. +RESPONSE_TIMEOUT = 30 + +#: The time in seconds to wait for events from the event queue, and also the +#: time between checks for the process stop signal. +WAIT_TIMEOUT = 1 + +#: The number of threads to use to handle XML stream events. This is not the +#: same as the number of custom event handling threads. +#: :data:`HANDLER_THREADS` must be at least 1. For Python implementations +#: with a GIL, this should be left at 1, but for implemetnations without +#: a GIL increasing this value can provide better performance. +HANDLER_THREADS = 1 + +#: Flag indicating if the SSL library is available for use. +SSL_SUPPORT = True + +#: The time in seconds to delay between attempts to resend data +#: after an SSL error. +SSL_RETRY_DELAY = 0.5 + +#: The maximum number of times to attempt resending data due to +#: an SSL error. +SSL_RETRY_MAX = 10 + +#: Maximum time to delay between connection attempts is one hour. +RECONNECT_MAX_DELAY = 600 + + +log = logging.getLogger(__name__) + + +class RestartStream(Exception): + """ + Exception to restart stream processing, including + resending the stream header. + """ + + +class XMLStream(object): + """ + An XML stream connection manager and event dispatcher. + + The XMLStream class abstracts away the issues of establishing a + connection with a server and sending and receiving XML "stanzas". + A stanza is a complete XML element that is a direct child of a root + document element. Two streams are used, one for each communication + direction, over the same socket. Once the connection is closed, both + streams should be complete and valid XML documents. + + Three types of events are provided to manage the stream: + :Stream: Triggered based on received stanzas, similar in concept + to events in a SAX XML parser. + :Custom: Triggered manually. + :Scheduled: Triggered based on time delays. + + Typically, stanzas are first processed by a stream event handler which + will then trigger custom events to continue further processing, + especially since custom event handlers may run in individual threads. + + :param socket: Use an existing socket for the stream. Defaults to + ``None`` to generate a new socket. + :param string host: The name of the target server. + :param int port: The port to use for the connection. Defaults to 0. + """ + + def __init__(self, socket=None, host='', port=0): + #: Flag indicating if the SSL library is available for use. + self.ssl_support = SSL_SUPPORT + + #: Most XMPP servers support TLSv1, but OpenFire in particular + #: does not work well with it. For OpenFire, set + #: :attr:`ssl_version` to use ``SSLv23``:: + #: + #: import ssl + #: xmpp.ssl_version = ssl.PROTOCOL_SSLv23 + self.ssl_version = ssl.PROTOCOL_TLSv1 + + #: Path to a file containing certificates for verifying the + #: server SSL certificate. A non-``None`` value will trigger + #: certificate checking. + #: + #: .. note:: + #: + #: On Mac OS X, certificates in the system keyring will + #: be consulted, even if they are not in the provided file. + self.ca_certs = None + + #: The time in seconds to wait for events from the event queue, + #: and also the time between checks for the process stop signal. + self.wait_timeout = WAIT_TIMEOUT + + #: The time in seconds to wait before timing out waiting + #: for response stanzas. + self.response_timeout = RESPONSE_TIMEOUT + + #: The current amount to time to delay attempting to reconnect. + #: This value doubles (with some jitter) with each failed + #: connection attempt up to :attr:`reconnect_max_delay` seconds. + self.reconnect_delay = None + + #: Maximum time to delay between connection attempts is one hour. + self.reconnect_max_delay = RECONNECT_MAX_DELAY + + #: The time in seconds to delay between attempts to resend data + #: after an SSL error. + self.ssl_retry_max = SSL_RETRY_MAX + + #: The maximum number of times to attempt resending data due to + #: an SSL error. + self.ssl_retry_delay = SSL_RETRY_DELAY + + #: The connection state machine tracks if the stream is + #: ``'connected'`` or ``'disconnected'``. + self.state = StateMachine(('disconnected', 'connected')) + self.state._set_state('disconnected') + + #: The default port to return when querying DNS records. + self.default_port = int(port) + + #: The domain to try when querying DNS records. + self.default_domain = '' + + #: The desired, or actual, address of the connected server. + self.address = (host, int(port)) + + #: A file-like wrapper for the socket for use with the + #: :mod:`~xml.etree.ElementTree` module. + self.filesocket = None + self.set_socket(socket) + + if sys.version_info < (3, 0): + self.socket_class = Socket26 + else: + self.socket_class = Socket.socket + + #: Enable connecting to the server directly over SSL, in + #: particular when the service provides two ports: one for + #: non-SSL traffic and another for SSL traffic. + self.use_ssl = False + + #: Enable connecting to the service without using SSL + #: immediately, but allow upgrading the connection later + #: to use SSL. + self.use_tls = False + + #: If set to ``True``, attempt to connect through an HTTP + #: proxy based on the settings in :attr:`proxy_config`. + self.use_proxy = False + + #: An optional dictionary of proxy settings. It may provide: + #: :host: The host offering proxy services. + #: :port: The port for the proxy service. + #: :username: Optional username for accessing the proxy. + #: :password: Optional password for accessing the proxy. + self.proxy_config = {} + + #: The default namespace of the stream content, not of the + #: stream wrapper itself. + self.default_ns = '' + + #: The namespace of the enveloping stream element. + self.stream_ns = '' + + #: The default opening tag for the stream element. + self.stream_header = "<stream>" + + #: The default closing tag for the stream element. + self.stream_footer = "</stream>" + + #: If ``True``, periodically send a whitespace character over the + #: wire to keep the connection alive. Mainly useful for connections + #: traversing NAT. + self.whitespace_keepalive = True + + #: The default interval between keepalive signals when + #: :attr:`whitespace_keepalive` is enabled. + self.whitespace_keepalive_interval = 300 + + #: An :class:`~threading.Event` to signal that the application + #: is stopping, and that all threads should shutdown. + self.stop = threading.Event() + + #: An :class:`~threading.Event` to signal receiving a closing + #: stream tag from the server. + self.stream_end_event = threading.Event() + self.stream_end_event.set() + + #: An :class:`~threading.Event` to signal the start of a stream + #: session. Until this event fires, the send queue is not used + #: and data is sent immediately over the wire. + self.session_started_event = threading.Event() + + #: The default time in seconds to wait for a session to start + #: after connecting before reconnecting and trying again. + self.session_timeout = 45 + + #: A queue of stream, custom, and scheduled events to be processed. + self.event_queue = queue.Queue() + + #: A queue of string data to be sent over the stream. + self.send_queue = queue.Queue() + + #: A :class:`~sleekxmpp.xmlstream.scheduler.Scheduler` instance for + #: executing callbacks in the future based on time delays. + self.scheduler = Scheduler(self.stop) + self.__failed_send_stanza = None + + #: A mapping of XML namespaces to well-known prefixes. + self.namespace_map = {StanzaBase.xml_ns: 'xml'} + + self.__thread = {} + self.__root_stanza = [] + self.__handlers = [] + self.__event_handlers = {} + self.__event_handlers_lock = threading.Lock() + self.__filters = {'in': [], 'out': []} + + self._id = 0 + self._id_lock = threading.Lock() + + #: The :attr:`auto_reconnnect` setting controls whether or not + #: the stream will be restarted in the event of an error. + self.auto_reconnect = True + + #: The :attr:`disconnect_wait` setting is the default value + #: for controlling if the system waits for the send queue to + #: empty before ending the stream. This may be overridden by + #: passing ``wait=True`` or ``wait=False`` to :meth:`disconnect`. + #: The default :attr:`disconnect_wait` value is ``False``. + self.disconnect_wait = False + + #: A list of DNS results that have not yet been tried. + self.dns_answers = [] + + self.add_event_handler('connected', self._handle_connected) + self.add_event_handler('session_start', self._start_keepalive) + self.add_event_handler('session_end', self._end_keepalive) + + def use_signals(self, signals=None): + """Register signal handlers for ``SIGHUP`` and ``SIGTERM``. + + By using signals, a ``'killed'`` event will be raised when the + application is terminated. + + If a signal handler already existed, it will be executed first, + before the ``'killed'`` event is raised. + + :param list signals: A list of signal names to be monitored. + Defaults to ``['SIGHUP', 'SIGTERM']``. + """ + if signals is None: + signals = ['SIGHUP', 'SIGTERM'] + + existing_handlers = {} + for sig_name in signals: + if hasattr(signal, sig_name): + sig = getattr(signal, sig_name) + handler = signal.getsignal(sig) + if handler: + existing_handlers[sig] = handler + + def handle_kill(signum, frame): + """ + Capture kill event and disconnect cleanly after first + spawning the ``'killed'`` event. + """ + + if signum in existing_handlers and \ + existing_handlers[signum] != handle_kill: + existing_handlers[signum](signum, frame) + + self.event("killed", direct=True) + self.disconnect() + + try: + for sig_name in signals: + if hasattr(signal, sig_name): + sig = getattr(signal, sig_name) + signal.signal(sig, handle_kill) + self.__signals_installed = True + except: + log.debug("Can not set interrupt signal handlers. " + \ + "SleekXMPP is not running from a main thread.") + + def new_id(self): + """Generate and return a new stream ID in hexadecimal form. + + Many stanzas, handlers, or matchers may require unique + ID values. Using this method ensures that all new ID values + are unique in this stream. + """ + with self._id_lock: + self._id += 1 + return self.get_id() + + def get_id(self): + """Return the current unique stream ID in hexadecimal form.""" + return "%X" % self._id + + def connect(self, host='', port=0, use_ssl=False, + use_tls=True, reattempt=True): + """Create a new socket and connect to the server. + + Setting ``reattempt`` to ``True`` will cause connection attempts to + be made every second until a successful connection is established. + + :param host: The name of the desired server for the connection. + :param port: Port to connect to on the server. + :param use_ssl: Flag indicating if SSL should be used by connecting + directly to a port using SSL. + :param use_tls: Flag indicating if TLS should be used, allowing for + connecting to a port without using SSL immediately and + later upgrading the connection. + :param reattempt: Flag indicating if the socket should reconnect + after disconnections. + """ + if host and port: + self.address = (host, int(port)) + try: + Socket.inet_aton(self.address[0]) + except Socket.error: + self.default_domain = self.address[0] + + # Respect previous SSL and TLS usage directives. + if use_ssl is not None: + self.use_ssl = use_ssl + if use_tls is not None: + self.use_tls = use_tls + + # Repeatedly attempt to connect until a successful connection + # is established. + connected = self.state.transition('disconnected', 'connected', + func=self._connect) + while reattempt and not connected and not self.stop.is_set(): + connected = self.state.transition('disconnected', 'connected', + func=self._connect) + return connected + + def _connect(self): + self.scheduler.remove('Session timeout check') + self.stop.clear() + if self.default_domain: + self.address = self.pick_dns_answer(self.default_domain, + self.address[1]) + self.socket = self.socket_class(Socket.AF_INET, Socket.SOCK_STREAM) + self.configure_socket() + + if self.reconnect_delay is None: + delay = 1.0 + else: + delay = min(self.reconnect_delay * 2, self.reconnect_max_delay) + delay = random.normalvariate(delay, delay * 0.1) + log.debug('Waiting %s seconds before connecting.', delay) + elapsed = 0 + try: + while elapsed < delay and not self.stop.is_set(): + time.sleep(0.1) + elapsed += 0.1 + except KeyboardInterrupt: + self.stop.set() + return False + except SystemExit: + self.stop.set() + return False + + if self.use_proxy: + connected = self._connect_proxy() + if not connected: + self.reconnect_delay = delay + return False + + if self.use_ssl and self.ssl_support: + log.debug("Socket Wrapped for SSL") + if self.ca_certs is None: + cert_policy = ssl.CERT_NONE + else: + cert_policy = ssl.CERT_REQUIRED + + ssl_socket = ssl.wrap_socket(self.socket, + ca_certs=self.ca_certs, + cert_reqs=cert_policy) + + if hasattr(self.socket, 'socket'): + # We are using a testing socket, so preserve the top + # layer of wrapping. + self.socket.socket = ssl_socket + else: + self.socket = ssl_socket + + try: + if not self.use_proxy: + log.debug("Connecting to %s:%s", *self.address) + self.socket.connect(self.address) + + self.set_socket(self.socket, ignore=True) + #this event is where you should set your application state + self.event("connected", direct=True) + self.reconnect_delay = 1.0 + return True + except Socket.error as serr: + error_msg = "Could not connect to %s:%s. Socket Error #%s: %s" + self.event('socket_error', serr) + log.error(error_msg, self.address[0], self.address[1], + serr.errno, serr.strerror) + self.reconnect_delay = delay + return False + + def _connect_proxy(self): + """Attempt to connect using an HTTP Proxy.""" + + # Extract the proxy address, and optional credentials + address = (self.proxy_config['host'], int(self.proxy_config['port'])) + cred = None + if self.proxy_config['username']: + username = self.proxy_config['username'] + password = self.proxy_config['password'] + + cred = '%s:%s' % (username, password) + if sys.version_info < (3, 0): + cred = bytes(cred) + else: + cred = bytes(cred, 'utf-8') + cred = base64.b64encode(cred).decode('utf-8') + + # Build the HTTP headers for connecting to the XMPP server + headers = ['CONNECT %s:%s HTTP/1.0' % self.address, + 'Host: %s:%s' % self.address, + 'Proxy-Connection: Keep-Alive', + 'Pragma: no-cache', + 'User-Agent: SleekXMPP/%s' % sleekxmpp.__version__] + if cred: + headers.append('Proxy-Authorization: Basic %s' % cred) + headers = '\r\n'.join(headers) + '\r\n\r\n' + + try: + log.debug("Connecting to proxy: %s:%s", address) + self.socket.connect(address) + self.send_raw(headers, now=True) + resp = '' + while '\r\n\r\n' not in resp and not self.stop.is_set(): + resp += self.socket.recv(1024).decode('utf-8') + log.debug('RECV: %s', resp) + + lines = resp.split('\r\n') + if '200' not in lines[0]: + self.event('proxy_error', resp) + log.error('Proxy Error: %s', lines[0]) + return False + + # Proxy connection established, continue connecting + # with the XMPP server. + return True + except Socket.error as serr: + error_msg = "Could not connect to %s:%s. Socket Error #%s: %s" + self.event('socket_error', serr) + log.error(error_msg, self.address[0], self.address[1], + serr.errno, serr.strerror) + return False + + def _handle_connected(self, event=None): + """ + Add check to ensure that a session is established within + a reasonable amount of time. + """ + + def _handle_session_timeout(): + if not self.session_started_event.is_set(): + log.debug("Session start has taken more " + \ + "than %d seconds", self.session_timeout) + self.disconnect(reconnect=self.auto_reconnect) + + self.schedule("Session timeout check", + self.session_timeout, + _handle_session_timeout) + + def disconnect(self, reconnect=False, wait=None): + """Terminate processing and close the XML streams. + + Optionally, the connection may be reconnected and + resume processing afterwards. + + If the disconnect should take place after all items + in the send queue have been sent, use ``wait=True``. + + .. warning:: + + If you are constantly adding items to the queue + such that it is never empty, then the disconnect will + not occur and the call will continue to block. + + :param reconnect: Flag indicating if the connection + and processing should be restarted. + Defaults to ``False``. + :param wait: Flag indicating if the send queue should + be emptied before disconnecting, overriding + :attr:`disconnect_wait`. + """ + self.state.transition('connected', 'disconnected', + func=self._disconnect, args=(reconnect, wait)) + + def _disconnect(self, reconnect=False, wait=None): + self.event('session_end', direct=True) + + # Wait for the send queue to empty. + if wait is not None: + if wait: + self.send_queue.join() + elif self.disconnect_wait: + self.send_queue.join() + + # Send the end of stream marker. + self.send_raw(self.stream_footer, now=True) + self.session_started_event.clear() + # Wait for confirmation that the stream was + # closed in the other direction. + self.auto_reconnect = reconnect + log.debug('Waiting for %s from server', self.stream_footer) + self.stream_end_event.wait(4) + if not self.auto_reconnect: + self.stop.set() + try: + self.socket.shutdown(Socket.SHUT_RDWR) + self.socket.close() + self.filesocket.close() + except Socket.error as serr: + self.event('socket_error', serr) + finally: + #clear your application state + self.event("disconnected", direct=True) + return True + + def reconnect(self, reattempt=True): + """Reset the stream's state and reconnect to the server.""" + log.debug("reconnecting...") + if self.state.ensure('connected'): + self.state.transition('connected', 'disconnected', wait=2.0, + func=self._disconnect, args=(True,)) + + log.debug("connecting...") + connected = self.state.transition('disconnected', 'connected', + wait=2.0, func=self._connect) + while reattempt and not connected and not self.stop.is_set(): + connected = self.state.transition('disconnected', 'connected', + wait=2.0, func=self._connect) + connected = connected or self.state.ensure('connected') + return connected + + def set_socket(self, socket, ignore=False): + """Set the socket to use for the stream. + + The filesocket will be recreated as well. + + :param socket: The new socket object to use. + :param bool ignore: If ``True``, don't set the connection + state to ``'connected'``. + """ + self.socket = socket + if socket is not None: + # ElementTree.iterparse requires a file. + # 0 buffer files have to be binary. + + # Use the correct fileobject type based on the Python + # version to work around a broken implementation in + # Python 2.x. + if sys.version_info < (3, 0): + self.filesocket = FileSocket(self.socket) + else: + self.filesocket = self.socket.makefile('rb', 0) + if not ignore: + self.state._set_state('connected') + + def configure_socket(self): + """Set timeout and other options for self.socket. + + Meant to be overridden. + """ + self.socket.settimeout(None) + + def configure_dns(self, resolver, domain=None, port=None): + """ + Configure and set options for a :class:`~dns.resolver.Resolver` + instance, and other DNS related tasks. For example, you + can also check :meth:`~socket.socket.getaddrinfo` to see + if you need to call out to ``libresolv.so.2`` to + run ``res_init()``. + + Meant to be overridden. + + :param resolver: A :class:`~dns.resolver.Resolver` instance + or ``None`` if ``dnspython`` is not installed. + :param domain: The initial domain under consideration. + :param port: The initial port under consideration. + """ + pass + + def start_tls(self): + """Perform handshakes for TLS. + + If the handshake is successful, the XML stream will need + to be restarted. + """ + if self.ssl_support: + log.info("Negotiating TLS") + log.info("Using SSL version: %s", str(self.ssl_version)) + if self.ca_certs is None: + cert_policy = ssl.CERT_NONE + else: + cert_policy = ssl.CERT_REQUIRED + + ssl_socket = ssl.wrap_socket(self.socket, + ssl_version=self.ssl_version, + do_handshake_on_connect=False, + ca_certs=self.ca_certs, + cert_reqs=cert_policy) + + if hasattr(self.socket, 'socket'): + # We are using a testing socket, so preserve the top + # layer of wrapping. + self.socket.socket = ssl_socket + else: + self.socket = ssl_socket + self.socket.do_handshake() + self.set_socket(self.socket) + return True + else: + log.warning("Tried to enable TLS, but ssl module not found.") + return False + + def _start_keepalive(self, event): + """Begin sending whitespace periodically to keep the connection alive. + + May be disabled by setting:: + + self.whitespace_keepalive = False + + The keepalive interval can be set using:: + + self.whitespace_keepalive_interval = 300 + """ + + def send_keepalive(): + if self.send_queue.empty(): + self.send_raw(' ') + + self.schedule('Whitespace Keepalive', + self.whitespace_keepalive_interval, + send_keepalive, + repeat=True) + + def _end_keepalive(self, event): + """Stop sending whitespace keepalives""" + self.scheduler.remove('Whitespace Keepalive') + + def start_stream_handler(self, xml): + """Perform any initialization actions, such as handshakes, + once the stream header has been sent. + + Meant to be overridden. + """ + pass + + def register_stanza(self, stanza_class): + """Add a stanza object class as a known root stanza. + + A root stanza is one that appears as a direct child of the stream's + root element. + + Stanzas that appear as substanzas of a root stanza do not need to + be registered here. That is done using register_stanza_plugin() from + sleekxmpp.xmlstream.stanzabase. + + Stanzas that are not registered will not be converted into + stanza objects, but may still be processed using handlers and + matchers. + + :param stanza_class: The top-level stanza object's class. + """ + self.__root_stanza.append(stanza_class) + + def remove_stanza(self, stanza_class): + """Remove a stanza from being a known root stanza. + + A root stanza is one that appears as a direct child of the stream's + root element. + + Stanzas that are not registered will not be converted into + stanza objects, but may still be processed using handlers and + matchers. + """ + del self.__root_stanza[stanza_class] + + def add_filter(self, mode, handler, order=None): + """Add a filter for incoming or outgoing stanzas. + + These filters are applied before incoming stanzas are + passed to any handlers, and before outgoing stanzas + are put in the send queue. + + Each filter must accept a single stanza, and return + either a stanza or ``None``. If the filter returns + ``None``, then the stanza will be dropped from being + processed for events or from being sent. + + :param mode: One of ``'in'`` or ``'out'``. + :param handler: The filter function. + :param int order: The position to insert the filter in + the list of active filters. + """ + if order: + self.__filters[mode].insert(order, handler) + else: + self.__filters[mode].append(handler) + + def add_handler(self, mask, pointer, name=None, disposable=False, + threaded=False, filter=False, instream=False): + """A shortcut method for registering a handler using XML masks. + + The use of :meth:`register_handler()` is preferred. + + :param mask: An XML snippet matching the structure of the + stanzas that will be passed to this handler. + :param pointer: The handler function itself. + :parm name: A unique name for the handler. A name will + be generated if one is not provided. + :param disposable: Indicates if the handler should be discarded + after one use. + :param threaded: **DEPRECATED**. + Remains for backwards compatibility. + :param filter: **DEPRECATED**. + Remains for backwards compatibility. + :param instream: Indicates if the handler should execute during + stream processing and not during normal event + processing. + """ + # To prevent circular dependencies, we must load the matcher + # and handler classes here. + + if name is None: + name = 'add_handler_%s' % self.getNewId() + self.registerHandler(XMLCallback(name, MatchXMLMask(mask), pointer, + once=disposable, instream=instream)) + + def register_handler(self, handler, before=None, after=None): + """Add a stream event handler that will be executed when a matching + stanza is received. + + :param handler: The :class:`~sleekxmpp.xmlstream.handler.base.BaseHandler` + derived object to execute. + """ + if handler.stream is None: + self.__handlers.append(handler) + handler.stream = weakref.ref(self) + + def remove_handler(self, name): + """Remove any stream event handlers with the given name. + + :param name: The name of the handler. + """ + idx = 0 + for handler in self.__handlers: + if handler.name == name: + self.__handlers.pop(idx) + return True + idx += 1 + return False + + def get_dns_records(self, domain, port=None): + """Get the DNS records for a domain. + + :param domain: The domain in question. + :param port: If the results don't include a port, use this one. + """ + if port is None: + port = self.default_port + if DNSPYTHON: + resolver = dns.resolver.get_default_resolver() + self.configure_dns(resolver, domain=domain, port=port) + + try: + answers = resolver.query(domain, dns.rdatatype.A) + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + log.warning("No A records for %s", domain) + return [((domain, port), 0, 0)] + except dns.exception.Timeout: + log.warning("DNS resolution timed out " + \ + "for A record of %s", domain) + return [((domain, port), 0, 0)] + else: + return [((ans.address, port), 0, 0) for ans in answers] + else: + log.warning("dnspython is not installed -- " + \ + "relying on OS A record resolution") + self.configure_dns(None, domain=domain, port=port) + return [((domain, port), 0, 0)] + + def pick_dns_answer(self, domain, port=None): + """Pick a server and port from DNS answers. + + Gets DNS answers if none available. + Removes used answer from available answers. + + :param domain: The domain in question. + :param port: If the results don't include a port, use this one. + """ + if not self.dns_answers: + self.dns_answers = self.get_dns_records(domain, port) + addresses = {} + intmax = 0 + topprio = 65535 + for answer in self.dns_answers: + topprio = min(topprio, answer[1]) + for answer in self.dns_answers: + if answer[1] == topprio: + intmax += answer[2] + addresses[intmax] = answer[0] + + #python3 returns a generator for dictionary keys + items = [x for x in addresses.keys()] + items.sort() + + picked = random.randint(0, intmax) + for item in items: + if picked <= item: + address = addresses[item] + break + for idx, answer in enumerate(self.dns_answers): + if self.dns_answers[0] == address: + break + self.dns_answers.pop(idx) + log.debug("Trying to connect to %s:%s", *address) + return address + + def add_event_handler(self, name, pointer, + threaded=False, disposable=False): + """Add a custom event handler that will be executed whenever + its event is manually triggered. + + :param name: The name of the event that will trigger + this handler. + :param pointer: The function to execute. + :param threaded: If set to ``True``, the handler will execute + in its own thread. Defaults to ``False``. + :param disposable: If set to ``True``, the handler will be + discarded after one use. Defaults to ``False``. + """ + if not name in self.__event_handlers: + self.__event_handlers[name] = [] + self.__event_handlers[name].append((pointer, threaded, disposable)) + + def del_event_handler(self, name, pointer): + """Remove a function as a handler for an event. + + :param name: The name of the event. + :param pointer: The function to remove as a handler. + """ + if not name in self.__event_handlers: + return + + # Need to keep handlers that do not use + # the given function pointer + def filter_pointers(handler): + return handler[0] != pointer + + self.__event_handlers[name] = list(filter( + filter_pointers, + self.__event_handlers[name])) + + def event_handled(self, name): + """Returns the number of registered handlers for an event. + + :param name: The name of the event to check. + """ + return len(self.__event_handlers.get(name, [])) + + def event(self, name, data={}, direct=False): + """Manually trigger a custom event. + + :param name: The name of the event to trigger. + :param data: Data that will be passed to each event handler. + Defaults to an empty dictionary, but is usually + a stanza object. + :param direct: Runs the event directly if True, skipping the + event queue. All event handlers will run in the + same thread. + """ + handlers = self.__event_handlers.get(name, []) + for handler in handlers: + #TODO: Data should not be copied, but should be read only, + # but this might break current code so it's left for future. + + out_data = copy.copy(data) if len(handlers) > 1 else data + old_exception = getattr(data, 'exception', None) + if direct: + try: + handler[0](out_data) + except Exception as e: + error_msg = 'Error processing event handler: %s' + log.exception(error_msg, str(handler[0])) + if old_exception: + old_exception(e) + else: + self.exception(e) + else: + self.event_queue.put(('event', handler, out_data)) + if handler[2]: + # If the handler is disposable, we will go ahead and + # remove it now instead of waiting for it to be + # processed in the queue. + with self.__event_handlers_lock: + try: + h_index = self.__event_handlers[name].index(handler) + self.__event_handlers[name].pop(h_index) + except: + pass + + def schedule(self, name, seconds, callback, args=None, + kwargs=None, repeat=False): + """Schedule a callback function to execute after a given delay. + + :param name: A unique name for the scheduled callback. + :param seconds: The time in seconds to wait before executing. + :param callback: A pointer to the function to execute. + :param args: A tuple of arguments to pass to the function. + :param kwargs: A dictionary of keyword arguments to pass to + the function. + :param repeat: Flag indicating if the scheduled event should + be reset and repeat after executing. + """ + self.scheduler.add(name, seconds, callback, args, kwargs, + repeat, qpointer=self.event_queue) + + def incoming_filter(self, xml): + """Filter incoming XML objects before they are processed. + + Possible uses include remapping namespaces, or correcting elements + from sources with incorrect behavior. + + Meant to be overridden. + """ + return xml + + def send(self, data, mask=None, timeout=None, now=False): + """A wrapper for :meth:`send_raw()` for sending stanza objects. + + May optionally block until an expected response is received. + + :param data: The :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` + stanza to send on the stream. + :param mask: **DEPRECATED** + An XML string snippet matching the structure + of the expected response. Execution will block + in this thread until the response is received + or a timeout occurs. + :param int timeout: Time in seconds to wait for a response before + continuing. Defaults to :attr:`response_timeout`. + :param bool now: Indicates if the send queue should be skipped, + sending the stanza immediately. Useful mainly + for stream initialization stanzas. + Defaults to ``False``. + """ + if timeout is None: + timeout = self.response_timeout + if hasattr(mask, 'xml'): + mask = mask.xml + + if isinstance(data, ElementBase): + for filter in self.__filters['out']: + if data is not None: + data = filter(data) + if data is None: + return + + data = str(data) + if mask is not None: + log.warning("Use of send mask waiters is deprecated.") + wait_for = Waiter("SendWait_%s" % self.new_id(), + MatchXMLMask(mask)) + self.register_handler(wait_for) + self.send_raw(data, now) + if mask is not None: + return wait_for.wait(timeout) + + def send_xml(self, data, mask=None, timeout=None, now=False): + """Send an XML object on the stream, and optionally wait + for a response. + + :param data: The :class:`~xml.etree.ElementTree.Element` XML object + to send on the stream. + :param mask: **DEPRECATED** + An XML string snippet matching the structure + of the expected response. Execution will block + in this thread until the response is received + or a timeout occurs. + :param int timeout: Time in seconds to wait for a response before + continuing. Defaults to :attr:`response_timeout`. + :param bool now: Indicates if the send queue should be skipped, + sending the stanza immediately. Useful mainly + for stream initialization stanzas. + Defaults to ``False``. + """ + if timeout is None: + timeout = self.response_timeout + return self.send(tostring(data), mask, timeout, now) + + def send_raw(self, data, now=False, reconnect=None): + """Send raw data across the stream. + + :param string data: Any string value. + :param bool reconnect: Indicates if the stream should be + restarted if there is an error sending + the stanza. Used mainly for testing. + Defaults to :attr:`auto_reconnect`. + """ + if now: + log.debug("SEND (IMMED): %s", data) + try: + data = data.encode('utf-8') + total = len(data) + sent = 0 + count = 0 + tries = 0 + while sent < total and not self.stop.is_set(): + try: + sent += self.socket.send(data[sent:]) + count += 1 + except ssl.SSLError as serr: + if tries >= self.ssl_retry_max: + log.debug('SSL error - max retries reached') + self.exception(serr) + log.warning("Failed to send %s", data) + if reconnect is None: + reconnect = self.auto_reconnect + self.disconnect(reconnect) + log.warning('SSL write error - reattempting') + time.sleep(self.ssl_retry_delay) + tries += 1 + if count > 1: + log.debug('SENT: %d chunks', count) + except Socket.error as serr: + self.event('socket_error', serr) + log.warning("Failed to send %s", data) + if reconnect is None: + reconnect = self.auto_reconnect + self.disconnect(reconnect) + else: + self.send_queue.put(data) + return True + + def process(self, **kwargs): + """Initialize the XML streams and begin processing events. + + The number of threads used for processing stream events is determined + by :data:`HANDLER_THREADS`. + + :param bool block: If ``False``, then event dispatcher will run + in a separate thread, allowing for the stream to be + used in the background for another application. + Otherwise, ``process(block=True)`` blocks the current + thread. Defaults to ``False``. + :param bool threaded: **DEPRECATED** + If ``True``, then event dispatcher will run + in a separate thread, allowing for the stream to be + used in the background for another application. + Defaults to ``True``. This does **not** mean that no + threads are used at all if ``threaded=False``. + + Regardless of these threading options, these threads will + always exist: + + - The event queue processor + - The send queue processor + - The scheduler + """ + if 'threaded' in kwargs and 'block' in kwargs: + raise ValueError("process() called with both " + \ + "block and threaded arguments") + elif 'block' in kwargs: + threaded = not(kwargs.get('block', False)) + else: + threaded = kwargs.get('threaded', True) + + self.scheduler.process(threaded=True) + + def start_thread(name, target): + self.__thread[name] = threading.Thread(name=name, target=target) + self.__thread[name].start() + + for t in range(0, HANDLER_THREADS): + log.debug("Starting HANDLER THREAD") + start_thread('stream_event_handler_%s' % t, self._event_runner) + + start_thread('send_thread', self._send_thread) + + if threaded: + # Run the XML stream in the background for another application. + start_thread('process', self._process) + else: + self._process() + + def _process(self): + """Start processing the XML streams. + + Processing will continue after any recoverable errors + if reconnections are allowed. + """ + + # The body of this loop will only execute once per connection. + # Additional passes will be made only if an error occurs and + # reconnecting is permitted. + while True: + shutdown = False + try: + # The call to self.__read_xml will block and prevent + # the body of the loop from running until a disconnect + # occurs. After any reconnection, the stream header will + # be resent and processing will resume. + while not self.stop.is_set(): + # Only process the stream while connected to the server + if not self.state.ensure('connected', wait=0.1, + block_on_transition=True): + continue + # Ensure the stream header is sent for any + # new connections. + if not self.session_started_event.is_set(): + self.send_raw(self.stream_header, now=True) + if not self.__read_xml(): + # If the server terminated the stream, end processing + break + except KeyboardInterrupt: + log.debug("Keyboard Escape Detected in _process") + self.event('killed', direct=True) + shutdown = True + except SystemExit: + log.debug("SystemExit in _process") + shutdown = True + except SyntaxError as e: + log.error("Error reading from XML stream.") + shutdown = True + self.exception(e) + except Socket.error as serr: + self.event('socket_error', serr) + log.exception('Socket Error') + except Exception as e: + if not self.stop.is_set(): + log.exception('Connection error.') + self.exception(e) + + if not shutdown and not self.stop.is_set() \ + and self.auto_reconnect: + self.reconnect() + else: + self.disconnect() + break + + def __read_xml(self): + """Parse the incoming XML stream + + Stream events are raised for each received stanza. + """ + depth = 0 + root = None + for event, xml in ET.iterparse(self.filesocket, (b'end', b'start')): + if event == b'start': + if depth == 0: + # We have received the start of the root element. + root = xml + # Perform any stream initialization actions, such + # as handshakes. + self.stream_end_event.clear() + self.start_stream_handler(root) + depth += 1 + if event == b'end': + depth -= 1 + if depth == 0: + # The stream's root element has closed, + # terminating the stream. + log.debug("End of stream recieved") + self.stream_end_event.set() + return False + elif depth == 1: + # We only raise events for stanzas that are direct + # children of the root element. + try: + self.__spawn_event(xml) + except RestartStream: + return True + if root is not None: + # Keep the root element empty of children to + # save on memory use. + root.clear() + log.debug("Ending read XML loop") + + def _build_stanza(self, xml, default_ns=None): + """Create a stanza object from a given XML object. + + If a specialized stanza type is not found for the XML, then + a generic :class:`~sleekxmpp.xmlstream.stanzabase.StanzaBase` + stanza will be returned. + + :param xml: The :class:`~xml.etree.ElementTree.Element` XML object + to convert into a stanza object. + :param default_ns: Optional default namespace to use instead of the + stream's current default namespace. + """ + if default_ns is None: + default_ns = self.default_ns + stanza_type = StanzaBase + for stanza_class in self.__root_stanza: + if xml.tag == "{%s}%s" % (default_ns, stanza_class.name) or \ + xml.tag == stanza_class.tag_name(): + stanza_type = stanza_class + break + stanza = stanza_type(self, xml) + return stanza + + def __spawn_event(self, xml): + """ + Analyze incoming XML stanzas and convert them into stanza + objects if applicable and queue stream events to be processed + by matching handlers. + + :param xml: The :class:`~sleekxmpp.xmlstream.stanzabase.ElementBase` + stanza to analyze. + """ + # Apply any preprocessing filters. + xml = self.incoming_filter(xml) + + # Convert the raw XML object into a stanza object. If no registered + # stanza type applies, a generic StanzaBase stanza will be used. + stanza = self._build_stanza(xml) + + for filter in self.__filters['in']: + if stanza is not None: + stanza = filter(stanza) + if stanza is None: + return + + log.debug("RECV: %s", stanza) + + # Match the stanza against registered handlers. Handlers marked + # to run "in stream" will be executed immediately; the rest will + # be queued. + unhandled = True + matched_handlers = [h for h in self.__handlers if h.match(stanza)] + for handler in matched_handlers: + if len(matched_handlers) > 1: + stanza_copy = copy.copy(stanza) + else: + stanza_copy = stanza + handler.prerun(stanza_copy) + self.event_queue.put(('stanza', handler, stanza_copy)) + try: + if handler.check_delete(): + self.__handlers.remove(handler) + except: + pass # not thread safe + unhandled = False + + # Some stanzas require responses, such as Iq queries. A default + # handler will be executed immediately for this case. + if unhandled: + stanza.unhandled() + + def _threaded_event_wrapper(self, func, args): + """Capture exceptions for event handlers that run + in individual threads. + + :param func: The event handler to execute. + :param args: Arguments to the event handler. + """ + # this is always already copied before this is invoked + orig = args[0] + try: + func(*args) + except Exception as e: + error_msg = 'Error processing event handler: %s' + log.exception(error_msg, str(func)) + if hasattr(orig, 'exception'): + orig.exception(e) + else: + self.exception(e) + + def _event_runner(self): + """Process the event queue and execute handlers. + + The number of event runner threads is controlled by HANDLER_THREADS. + + Stream event handlers will all execute in this thread. Custom event + handlers may be spawned in individual threads. + """ + log.debug("Loading event runner") + try: + while not self.stop.is_set(): + try: + wait = self.wait_timeout + event = self.event_queue.get(True, timeout=wait) + except queue.Empty: + event = None + if event is None: + continue + + etype, handler = event[0:2] + args = event[2:] + orig = copy.copy(args[0]) + + if etype == 'stanza': + try: + handler.run(args[0]) + except Exception as e: + error_msg = 'Error processing stream handler: %s' + log.exception(error_msg, handler.name) + orig.exception(e) + elif etype == 'schedule': + name = args[1] + try: + log.debug('Scheduled event: %s: %s', name, args[0]) + handler(*args[0]) + except Exception as e: + log.exception('Error processing scheduled task') + self.exception(e) + elif etype == 'event': + func, threaded, disposable = handler + try: + if threaded: + x = threading.Thread( + name="Event_%s" % str(func), + target=self._threaded_event_wrapper, + args=(func, args)) + x.start() + else: + func(*args) + except Exception as e: + error_msg = 'Error processing event handler: %s' + log.exception(error_msg, str(func)) + if hasattr(orig, 'exception'): + orig.exception(e) + else: + self.exception(e) + elif etype == 'quit': + log.debug("Quitting event runner thread") + return False + except KeyboardInterrupt: + log.debug("Keyboard Escape Detected in _event_runner") + self.event('killed', direct=True) + self.disconnect() + return + except SystemExit: + self.disconnect() + self.event_queue.put(('quit', None, None)) + return + + def _send_thread(self): + """Extract stanzas from the send queue and send them on the stream.""" + try: + while not self.stop.is_set(): + while not self.stop.is_set and \ + not self.session_started_event.is_set(): + self.session_started_event.wait(timeout=1) + if self.__failed_send_stanza is not None: + data = self.__failed_send_stanza + self.__failed_send_stanza = None + else: + try: + data = self.send_queue.get(True, 1) + except queue.Empty: + continue + log.debug("SEND: %s", data) + enc_data = data.encode('utf-8') + total = len(enc_data) + sent = 0 + count = 0 + tries = 0 + try: + while sent < total and not self.stop.is_set(): + try: + sent += self.socket.send(enc_data[sent:]) + count += 1 + except ssl.SSLError as serr: + if tries >= self.ssl_retry_max: + log.debug('SSL error - max retries reached') + self.exception(serr) + log.warning("Failed to send %s", data) + if reconnect is None: + reconnect = self.auto_reconnect + self.disconnect(reconnect) + log.warning('SSL write error - reattempting') + time.sleep(self.ssl_retry_delay) + tries += 1 + if count > 1: + log.debug('SENT: %d chunks', count) + self.send_queue.task_done() + except Socket.error as serr: + self.event('socket_error', serr) + log.warning("Failed to send %s", data) + self.__failed_send_stanza = data + self.disconnect(self.auto_reconnect) + except Exception as ex: + log.exception('Unexpected error in send thread: %s', ex) + self.exception(ex) + if not self.stop.is_set(): + self.disconnect(self.auto_reconnect) + + def exception(self, exception): + """Process an unknown exception. + + Meant to be overridden. + + :param exception: An unhandled exception object. + """ + pass + + +# To comply with PEP8, method names now use underscores. +# Deprecated method names are re-mapped for backwards compatibility. +XMLStream.startTLS = XMLStream.start_tls +XMLStream.registerStanza = XMLStream.register_stanza +XMLStream.removeStanza = XMLStream.remove_stanza +XMLStream.registerHandler = XMLStream.register_handler +XMLStream.removeHandler = XMLStream.remove_handler +XMLStream.setSocket = XMLStream.set_socket +XMLStream.sendRaw = XMLStream.send_raw +XMLStream.getId = XMLStream.get_id +XMLStream.getNewId = XMLStream.new_id +XMLStream.sendXML = XMLStream.send_xml |