diff options
Diffstat (limited to 'slixmpp')
326 files changed, 36029 insertions, 0 deletions
diff --git a/slixmpp/__init__.py b/slixmpp/__init__.py new file mode 100644 index 00000000..c09446df --- /dev/null +++ b/slixmpp/__init__.py @@ -0,0 +1,26 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import asyncio +asyncio.sslproto._is_sslproto_available=lambda: False +import logging +logging.getLogger(__name__).addHandler(logging.NullHandler()) + + +from slixmpp.stanza import Message, Presence, Iq +from slixmpp.jid import JID, InvalidJID +from slixmpp.xmlstream.stanzabase import ET, ElementBase, register_stanza_plugin +from slixmpp.xmlstream.handler import * +from slixmpp.xmlstream import XMLStream +from slixmpp.xmlstream.matcher import * +from slixmpp.xmlstream.asyncio import asyncio, future_wrapper +from slixmpp.basexmpp import BaseXMPP +from slixmpp.clientxmpp import ClientXMPP +from slixmpp.componentxmpp import ComponentXMPP + +from slixmpp.version import __version__, __version_info__ diff --git a/slixmpp/api.py b/slixmpp/api.py new file mode 100644 index 00000000..f09e0365 --- /dev/null +++ b/slixmpp/api.py @@ -0,0 +1,200 @@ +from slixmpp.xmlstream import JID + + +class APIWrapper(object): + + def __init__(self, api, name): + self.api = api + self.name = name + if name not in self.api.settings: + self.api.settings[name] = {} + + def __getattr__(self, attr): + """Curry API management commands with the API name.""" + if attr == 'name': + return self.name + elif attr == 'settings': + return self.api.settings[self.name] + elif attr == 'register': + def partial(handler, op, jid=None, node=None, default=False): + register = getattr(self.api, attr) + return register(handler, self.name, op, jid, node, default) + return partial + elif attr == 'register_default': + def partial(handler, op, jid=None, node=None): + return getattr(self.api, attr)(handler, self.name, op) + return partial + elif attr in ('run', 'restore_default', 'unregister'): + def partial(*args, **kwargs): + return getattr(self.api, attr)(self.name, *args, **kwargs) + return partial + return None + + def __getitem__(self, attr): + def partial(jid=None, node=None, ifrom=None, args=None): + return self.api.run(self.name, attr, jid, node, ifrom, args) + return partial + + +class APIRegistry(object): + + def __init__(self, xmpp): + self._handlers = {} + self._handler_defaults = {} + self.xmpp = xmpp + self.settings = {} + + def _setup(self, ctype, op): + """Initialize the API callback dictionaries. + + :param string ctype: The name of the API to initialize. + :param string op: The API operation to initialize. + """ + if ctype not in self.settings: + self.settings[ctype] = {} + if ctype not in self._handler_defaults: + self._handler_defaults[ctype] = {} + if ctype not in self._handlers: + self._handlers[ctype] = {} + if op not in self._handlers[ctype]: + self._handlers[ctype][op] = {'global': None, + 'jid': {}, + 'node': {}} + + def wrap(self, ctype): + """Return a wrapper object that targets a specific API.""" + return APIWrapper(self, ctype) + + def purge(self, ctype): + """Remove all information for a given API.""" + del self.settings[ctype] + del self._handler_defaults[ctype] + del self._handlers[ctype] + + def run(self, ctype, op, jid=None, node=None, ifrom=None, args=None): + """Execute an API callback, based on specificity. + + The API callback that is executed is chosen based on the combination + of the provided JID and node: + + JID | node | Handler + ============================== + Given | Given | Node handler + Given | None | JID handler + None | None | Global handler + + A node handler is responsible for servicing a single node at a single + JID, while a JID handler may respond for any node at a given JID, and + the global handler will answer to any JID+node combination. + + Handlers should check that the JID ``ifrom`` is authorized to perform + the desired action. + + :param string ctype: The name of the API to use. + :param string op: The API operation to perform. + :param JID jid: Optionally provide specific JID. + :param string node: Optionally provide specific node. + :param JID ifrom: Optionally provide the requesting JID. + :param tuple args: Optional positional arguments to the handler. + """ + self._setup(ctype, op) + + if not jid: + jid = self.xmpp.boundjid + elif jid and not isinstance(jid, JID): + jid = JID(jid) + elif jid == JID(''): + jid = self.xmpp.boundjid + + if node is None: + node = '' + + if self.xmpp.is_component: + if self.settings[ctype].get('component_bare', False): + jid = jid.bare + else: + jid = jid.full + else: + if self.settings[ctype].get('client_bare', False): + jid = jid.bare + else: + jid = jid.full + + jid = JID(jid) + + handler = self._handlers[ctype][op]['node'].get((jid, node), None) + if handler is None: + handler = self._handlers[ctype][op]['jid'].get(jid, None) + if handler is None: + handler = self._handlers[ctype][op].get('global', None) + + if handler: + try: + return handler(jid, node, ifrom, args) + except TypeError: + # To preserve backward compatibility, drop the ifrom + # parameter for existing handlers that don't understand it. + return handler(jid, node, args) + + def register(self, handler, ctype, op, jid=None, node=None, default=False): + """Register an API callback, with JID+node specificity. + + The API callback can later be executed based on the + specificity of the provided JID+node combination. + + See :meth:`~ApiRegistry.run` for more details. + + :param string ctype: The name of the API to use. + :param string op: The API operation to perform. + :param JID jid: Optionally provide specific JID. + :param string node: Optionally provide specific node. + """ + self._setup(ctype, op) + if jid is None and node is None: + if handler is None: + handler = self._handler_defaults[op] + self._handlers[ctype][op]['global'] = handler + elif jid is not None and node is None: + self._handlers[ctype][op]['jid'][jid] = handler + else: + self._handlers[ctype][op]['node'][(jid, node)] = handler + + if default: + self.register_default(handler, ctype, op) + + def register_default(self, handler, ctype, op): + """Register a default, global handler for an operation. + + :param func handler: The default, global handler for the operation. + :param string ctype: The name of the API to modify. + :param string op: The API operation to use. + """ + self._setup(ctype, op) + self._handler_defaults[ctype][op] = handler + + def unregister(self, ctype, op, jid=None, node=None): + """Remove an API callback. + + The API callback chosen for removal is based on the + specificity of the provided JID+node combination. + + See :meth:`~ApiRegistry.run` for more details. + + :param string ctype: The name of the API to use. + :param string op: The API operation to perform. + :param JID jid: Optionally provide specific JID. + :param string node: Optionally provide specific node. + """ + self._setup(ctype, op) + self.register(None, ctype, op, jid, node) + + def restore_default(self, ctype, op, jid=None, node=None): + """Reset an API callback to use a default handler. + + :param string ctype: The name of the API to use. + :param string op: The API operation to perform. + :param JID jid: Optionally provide specific JID. + :param string node: Optionally provide specific node. + """ + self.unregister(ctype, op, jid, node) + self.register(self._handler_defaults[ctype][op], ctype, op, jid, node) diff --git a/slixmpp/basexmpp.py b/slixmpp/basexmpp.py new file mode 100644 index 00000000..83741bd7 --- /dev/null +++ b/slixmpp/basexmpp.py @@ -0,0 +1,793 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.basexmpp + ~~~~~~~~~~~~~~~~~~ + + This module provides the common XMPP functionality + for both clients and components. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import logging +import threading + +from slixmpp import plugins, roster, stanza +from slixmpp.api import APIRegistry +from slixmpp.exceptions import IqError, IqTimeout + +from slixmpp.stanza import Message, Presence, Iq, StreamError +from slixmpp.stanza.roster import Roster +from slixmpp.stanza.nick import Nick + +from slixmpp.xmlstream import XMLStream, JID +from slixmpp.xmlstream import ET, register_stanza_plugin +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.stanzabase import XML_NS + +from slixmpp.plugins import PluginManager, load_plugin + + +log = logging.getLogger(__name__) + +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', **kwargs): + XMLStream.__init__(self, **kwargs) + + 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) requested for this connection. + self.requested_jid = JID(jid) + + #: The JabberID (JID) used by this connection, + #: as set after session binding. This may even be a + #: different bare JID than what was requested. + self.boundjid = JID(jid) + + self._expected_server_name = self.boundjid.host + self._redirect_attempts = 0 + + #: The maximum number of consecutive see-other-host + #: redirections that will be followed before quitting. + self.max_redirects = 5 + + self.session_bind_event = threading.Event() + + #: A dictionary mapping plugin names to plugins. + self.plugin = PluginManager(self) + + #: 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) + + #: The single roster for the bound JID. This is the + #: equivalent of:: + #: + #: self.roster[self.boundjid.bare] + self.client_roster = self.roster[self.boundjid] + + #: 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 + + #: Messages may optionally be tagged with ID values. Setting + #: :attr:`use_message_ids` to `True` will assign all outgoing + #: messages an ID. Some plugin features require enabling + #: this option. + self.use_message_ids = False + + #: Presence updates may optionally be tagged with ID values. + #: Setting :attr:`use_message_ids` to `True` will assign all + #: outgoing messages an ID. + self.use_presence_ids = False + + #: The API registry is a way to process callbacks based on + #: JID+node combinations. Each callback in the registry is + #: marked with: + #: + #: - An API name, e.g. xep_0030 + #: - The name of an action, e.g. get_info + #: - The JID that will be affected + #: - The node that will be affected + #: + #: API handlers with no JID or node will act as global handlers, + #: while those with a JID and no node will service all nodes + #: for a JID, and handlers with both a JID and node will be + #: used only for that specific combination. The handler that + #: provides the most specificity will be used. + self.api = APIRegistry(self) + + #: 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:`slixmpp.stanza` to make accessing + #: stanza classes easier. + self.stanza = stanza + + self.register_handler( + Callback('IM', + MatchXPath('{%s}message/{%s}body' % (self.default_ns, + self.default_ns)), + self._handle_message)) + + self.register_handler( + Callback('IMError', + MatchXPath('{%s}message/{%s}error' % (self.default_ns, + self.default_ns)), + self._handle_message_error)) + + 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('session_start', + self._handle_session_start) + 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) + + 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', '') + self.stream_version = xml.get('version', '') + self.peer_default_lang = xml.get('{%s}lang' % XML_NS, None) + + if not self.is_component and not self.stream_version: + log.warning('Legacy XMPP 0.9 protocol detected.') + self.event('legacy_protocol') + + def process(self, *, forever=True, timeout=None): + self.init_plugins() + XMLStream.process(self, forever=forever, timeout=timeout) + + def init_plugins(self): + for name in self.plugin: + if not hasattr(self.plugin[name], 'post_inited'): + if hasattr(self.plugin[name], 'post_init'): + self.plugin[name].post_init() + self.plugin[name].post_inited = True + + def register_plugin(self, plugin, pconfig=None, 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. + """ + + # Use the global plugin config cache, if applicable + if not pconfig: + pconfig = self.plugin_config.get(plugin, {}) + + if not self.plugin.registered(plugin): + load_plugin(plugin, module) + self.plugin.enable(plugin, pconfig) + + 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) + else: + raise NameError("Plugin %s not in plugins.__all__." % plugin) + + 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.""" + msg = Message(self, *args, **kwargs) + msg['lang'] = self.default_lang + return msg + + 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.""" + pres = Presence(self, *args, **kwargs) + pres['lang'] = self.default_lang + return pres + + 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:`~slixmpp.xmlstream.jid.JID` + to use for this stanza. + :param ito: The destination :class:`~slixmpp.xmlstream.jid.JID` + for this stanza. + :param itype: The :class:`~slixmpp.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:`~slixmpp.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:`~slixmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~slixmpp.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:`~slixmpp.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:`~slixmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~slixmpp.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:`~slixmpp.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:`~slixmpp.xmlstream.stanzabase.ElementBase` + stanza object or an + :class:`~xml.etree.ElementTree.Element` XML object + to use as the :class:`~slixmpp.stanza.iq.Iq`'s payload. + :param ito: The destination :class:`~slixmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~slixmpp.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:`~slixmpp.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:`~slixmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~slixmpp.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:`~slixmpp.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:`~slixmpp.xmlstream.jid.JID` + for this stanza. + :param ifrom: The ``'from'`` :class:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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. + """ + self.make_presence(pshow, pstatus, ppriority, pto, + ptype, pfrom, pnick).send() + + def send_presence_subscription(self, pto, pfrom=None, + ptype='subscribe', pnick=None): + """ + Create, initialize, and send a new + :class:`~slixmpp.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. + """ + self.make_presence(ptype=ptype, + pfrom=pfrom, + pto=JID(pto).bare, + pnick=pnick).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.resource") + 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 = JID(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_session_start(self, event): + """Reset redirection attempt count.""" + self._redirect_attempts = 0 + + def _handle_disconnected(self, event): + """When disconnected, reset the roster""" + self.roster.reset() + self.session_bind_event.clear() + + def _handle_stream_error(self, error): + self.event('stream_error', error) + + if error['condition'] == 'see-other-host': + other_host = error['see_other_host'] + if not other_host: + log.warning("No other host specified.") + return + + if self._redirect_attempts > self.max_redirects: + log.error("Exceeded maximum number of redirection attempts.") + return + + self._redirect_attempts += 1 + + host = other_host + port = 5222 + + if '[' in other_host and ']' in other_host: + host = other_host.split(']')[0][1:] + elif ':' in other_host: + host = other_host.split(':')[0] + + port_sec = other_host.split(']')[-1] + if ':' in port_sec: + port = int(port_sec.split(':')[1]) + + self.address = (host, port) + self.default_domain = host + self.dns_records = None + self.reconnect_delay = None + self.reconnect() + + def _handle_message(self, msg): + """Process incoming message stanzas.""" + if not self.is_component and not msg['to'].bare: + msg['to'] = self.boundjid + self.event('message', msg) + + def _handle_message_error(self, msg): + """Process incoming message error stanzas.""" + if not self.is_component and not msg['to'].bare: + msg['to'] = self.boundjid + self.event('message_error', msg) + + def _handle_available(self, pres): + self.roster[pres['to']][pres['from']].handle_available(pres) + + def _handle_unavailable(self, pres): + self.roster[pres['to']][pres['from']].handle_unavailable(pres) + + def _handle_new_subscription(self, pres): + """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[pres['to']] + item = self.roster[pres['to']][pres['from']] + if item['whitelisted']: + item.authorize() + if roster.auto_subscribe: + item.subscribe() + elif roster.auto_authorize: + item.authorize() + if roster.auto_subscribe: + item.subscribe() + elif roster.auto_authorize == False: + item.unauthorize() + + def _handle_removed_subscription(self, pres): + self.roster[pres['to']][pres['from']].handle_unauthorize(pres) + + def _handle_subscribe(self, pres): + self.roster[pres['to']][pres['from']].handle_subscribe(pres) + + def _handle_subscribed(self, pres): + self.roster[pres['to']][pres['from']].handle_subscribed(pres) + + def _handle_unsubscribe(self, pres): + self.roster[pres['to']][pres['from']].handle_unsubscribe(pres) + + def _handle_unsubscribed(self, pres): + self.roster[pres['to']][pres['from']].handle_unsubscribed(pres) + + def _handle_presence(self, presence): + """Process incoming presence stanzas. + + Update the roster with presence information. + """ + if not self.is_component and not presence['to'].bare: + presence['to'] = self.boundjid + + self.event('presence', presence) + 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:`~slixmpp.exceptions.IqError` and + :class:`~slixmpp.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) diff --git a/slixmpp/clientxmpp.py b/slixmpp/clientxmpp.py new file mode 100644 index 00000000..40d20333 --- /dev/null +++ b/slixmpp/clientxmpp.py @@ -0,0 +1,305 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.clientxmpp + ~~~~~~~~~~~~~~~~~~~~ + + This module provides XMPP functionality that + is specific to client connections. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import logging + +from slixmpp.stanza import StreamFeatures +from slixmpp.basexmpp import BaseXMPP +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream import XMLStream +from slixmpp.xmlstream.matcher import StanzaPath, MatchXPath +from slixmpp.xmlstream.handler import Callback + +# 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): + + """ + Slixmpp'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 plugin_config: A dictionary of plugin configurations. + :param plugin_whitelist: A list of approved plugins that + will be loaded when calling + :meth:`~slixmpp.basexmpp.BaseXMPP.register_plugins()`. + :param escape_quotes: **Deprecated.** + """ + + def __init__(self, jid, password, plugin_config=None, + plugin_whitelist=None, escape_quotes=True, sasl_mech=None, + lang='en', **kwargs): + if not plugin_whitelist: + plugin_whitelist = [] + if not plugin_config: + plugin_config = {} + + BaseXMPP.__init__(self, jid, 'jabber:client', **kwargs) + + self.escape_quotes = escape_quotes + self.plugin_config = plugin_config + self.plugin_whitelist = plugin_whitelist + self.default_port = 5222 + self.default_lang = lang + + self.credentials = {} + + self.password = password + + self.stream_header = "<stream:stream to='%s' %s %s %s %s>" % ( + self.boundjid.host, + "xmlns:stream='%s'" % self.stream_ns, + "xmlns='%s'" % self.default_ns, + "xml:lang='%s'" % self.default_lang, + "version='1.0'") + self.stream_footer = "</stream:stream>" + + self.features = set() + self._stream_feature_handlers = {} + self._stream_feature_order = [] + + self.dns_service = 'xmpp-client' + + #TODO: Use stream state here + self.authenticated = False + self.sessionstarted = False + self.bound = False + self.bindfail = False + + self.add_event_handler('connected', self._reset_connection_state) + self.add_event_handler('session_bind', self._handle_session_bind) + self.add_event_handler('roster_update', self._handle_roster) + + 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', + StanzaPath('iq@type=set/roster'), + lambda iq: self.event('roster_update', iq))) + + # Setup default stream features + self.register_plugin('feature_starttls') + self.register_plugin('feature_bind') + self.register_plugin('feature_session') + self.register_plugin('feature_rosterver') + self.register_plugin('feature_preapproval') + self.register_plugin('feature_mechanisms') + + if sasl_mech: + self['feature_mechanisms'].use_mech = sasl_mech + + @property + def password(self): + return self.credentials.get('password', '') + + @password.setter + def password(self, value): + self.credentials['password'] = value + + def connect(self, address=tuple(), use_ssl=False, + force_starttls=True, disable_starttls=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 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``. + """ + + # If an address was provided, disable using DNS SRV lookup; + # otherwise, use the domain from the client JID with the standard + # XMPP client port and allow SRV lookup. + if address: + self.dns_service = None + else: + address = (self.boundjid.host, 5222) + self.dns_service = 'xmpp-client' + + return XMLStream.connect(self, address[0], address[1], use_ssl=use_ssl, + force_starttls=force_starttls, disable_starttls=disable_starttls) + + 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 unregister_feature(self, name, order): + if name in self._stream_feature_handlers: + del self._stream_feature_handlers[name] + self._stream_feature_order.remove((order, name)) + self._stream_feature_order.sort() + + def update_roster(self, jid, **kwargs): + """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 timeout: The length of time (in seconds) to wait + for a response before continuing if blocking + is used. Defaults to + :attr:`~slixmpp.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``. + """ + current = self.client_roster[jid] + + name = kwargs.get('name', current['name']) + subscription = kwargs.get('subscription', current['subscription']) + groups = kwargs.get('groups', current['groups']) + + timeout = kwargs.get('timeout', None) + callback = kwargs.get('callback', None) + + return self.client_roster.update(jid, name, subscription, groups, + 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, callback=None, timeout=None, timeout_callback=None): + """Request the roster from the server. + + :param callback: Reference to a stream handler function. Will + be executed when the roster is received. + """ + iq = self.Iq() + iq['type'] = 'get' + iq.enable('roster') + if 'rosterver' in self.features: + iq['roster']['ver'] = self.client_roster.version + + if callback is None: + callback = lambda resp: self.event('roster_update', resp) + else: + orig_cb = callback + def wrapped(resp): + self.event('roster_update', resp) + orig_cb(resp) + callback = wrapped + + iq.send(callback, timeout, timeout_callback) + + def _reset_connection_state(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 + log.debug('Finished processing stream features.') + self.event('stream_negotiated') + + def _handle_roster(self, iq): + """Update the roster after receiving a roster stanza. + + :param iq: The roster stanza. + """ + if iq['type'] == 'set': + if iq['from'].bare and iq['from'].bare != self.boundjid.bare: + raise XMPPError(condition='service-unavailable') + + roster = self.client_roster + if iq['roster']['ver']: + roster.version = iq['roster']['ver'] + items = iq['roster']['items'] + + valid_subscriptions = ('to', 'from', 'both', 'none', 'remove') + for jid, item in items.items(): + if item['subscription'] in valid_subscriptions: + 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') + + roster[jid].save(remove=(item['subscription'] == 'remove')) + + if iq['type'] == 'set': + resp = self.Iq(stype='result', + sto=iq['from'], + sid=iq['id']) + resp.enable('roster') + resp.send() + + def _handle_session_bind(self, jid): + """Set the client roster to the JID set by the server. + + :param :class:`slixmpp.xmlstream.jid.JID` jid: The bound JID as + dictated by the server. The same as :attr:`boundjid`. + """ + self.client_roster = self.roster[jid] diff --git a/slixmpp/componentxmpp.py b/slixmpp/componentxmpp.py new file mode 100644 index 00000000..868798d1 --- /dev/null +++ b/slixmpp/componentxmpp.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.clientxmpp + ~~~~~~~~~~~~~~~~~~~~ + + This module provides XMPP functionality that + is specific to external server component connections. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import logging +import hashlib + +from slixmpp.basexmpp import BaseXMPP +from slixmpp.xmlstream import XMLStream +from slixmpp.xmlstream import ET +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.xmlstream.handler import Callback + + +log = logging.getLogger(__name__) + + +class ComponentXMPP(BaseXMPP): + + """ + Slixmpp'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:`~slixmpp.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=None, plugin_whitelist=None, use_jc_ns=False): + + if not plugin_whitelist: + plugin_whitelist = [] + if not plugin_config: + plugin_config = {} + + 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.sessionstarted = False + + 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): + """Connect to the server. + + + :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. + """ + if host is None: + host = self.server_host + if port is None: + port = self.server_port + + self.server_name = self.boundjid.host + + log.debug("Connecting to %s:%s", host, port) + return XMLStream.connect(self, host=host, port=port, + use_ssl=use_ssl) + + 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) + 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 = bytes('%s%s' % (sid, self.secret), 'utf-8') + + handshake = ET.Element('{jabber:component:accept}handshake') + handshake.text = hashlib.sha1(pre_hash).hexdigest().lower() + self.send_xml(handshake) + + def _handle_handshake(self, xml): + """The handshake has been accepted. + + :param xml: The reply handshake stanza. + """ + self.session_bind_event.set() + self.sessionstarted = True + self.event('session_bind', self.boundjid) + self.event('session_start') + + def _handle_probe(self, pres): + self.roster[pres['to']][pres['from']].handle_probe(pres) diff --git a/slixmpp/exceptions.py b/slixmpp/exceptions.py new file mode 100644 index 00000000..a6c09a0b --- /dev/null +++ b/slixmpp/exceptions.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.exceptions + ~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick 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:`~slixmpp.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 Slixmpp plugins and applications using Slixmpp. + + 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='', + 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 + + def format(self): + """ + Format the error in a simple user-readable string. + """ + text = [self.etype, self.condition] + if self.text: + text.append(self.text) + if self.extension: + text.append(self.extension) + # TODO: handle self.extension_args + return ': '.join(text) + + +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:`~slixmpp.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:`~slixmpp.stanza.iq.Iq` error result stanza. + self.iq = iq diff --git a/slixmpp/features/__init__.py b/slixmpp/features/__init__.py new file mode 100644 index 00000000..5b728ee8 --- /dev/null +++ b/slixmpp/features/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +__all__ = [ + 'feature_starttls', + 'feature_mechanisms', + 'feature_bind', + 'feature_session', + 'feature_rosterver', + 'feature_preapproval' +] diff --git a/slixmpp/features/feature_bind/__init__.py b/slixmpp/features/feature_bind/__init__.py new file mode 100644 index 00000000..65f5b626 --- /dev/null +++ b/slixmpp/features/feature_bind/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.features.feature_bind.bind import FeatureBind +from slixmpp.features.feature_bind.stanza import Bind + + +register_plugin(FeatureBind) + + +# Retain some backwards compatibility +feature_bind = FeatureBind diff --git a/slixmpp/features/feature_bind/bind.py b/slixmpp/features/feature_bind/bind.py new file mode 100644 index 00000000..c031ab72 --- /dev/null +++ b/slixmpp/features/feature_bind/bind.py @@ -0,0 +1,67 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.jid import JID +from slixmpp.stanza import Iq, StreamFeatures +from slixmpp.features.feature_bind import stanza +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin + + +log = logging.getLogger(__name__) + + +class FeatureBind(BasePlugin): + + name = 'feature_bind' + description = 'RFC 6120: Stream Feature: Resource Binding' + dependencies = set() + stanza = stanza + + def plugin_init(self): + 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.requested_jid.resource) + self.features = features + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq.enable('bind') + if self.xmpp.requested_jid.resource: + iq['bind']['resource'] = self.xmpp.requested_jid.resource + + iq.send(callback=self._on_bind_response) + + def _on_bind_response(self, response): + self.xmpp.boundjid = JID(response['bind']['jid']) + self.xmpp.bound = True + self.xmpp.event('session_bind', self.xmpp.boundjid) + self.xmpp.session_bind_event.set() + + self.xmpp.features.add('bind') + + log.info("JID set to: %s", self.xmpp.boundjid.full) + + if 'session' not in self.features['features']: + log.debug("Established Session") + self.xmpp.sessionstarted = True + self.xmpp.event('session_start') diff --git a/slixmpp/features/feature_bind/stanza.py b/slixmpp/features/feature_bind/stanza.py new file mode 100644 index 00000000..b9ecd97c --- /dev/null +++ b/slixmpp/features/feature_bind/stanza.py @@ -0,0 +1,21 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +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/slixmpp/features/feature_mechanisms/__init__.py b/slixmpp/features/feature_mechanisms/__init__.py new file mode 100644 index 00000000..7532eaa2 --- /dev/null +++ b/slixmpp/features/feature_mechanisms/__init__.py @@ -0,0 +1,22 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.features.feature_mechanisms.mechanisms import FeatureMechanisms +from slixmpp.features.feature_mechanisms.stanza import Mechanisms +from slixmpp.features.feature_mechanisms.stanza import Auth +from slixmpp.features.feature_mechanisms.stanza import Success +from slixmpp.features.feature_mechanisms.stanza import Failure + + +register_plugin(FeatureMechanisms) + + +# Retain some backwards compatibility +feature_mechanisms = FeatureMechanisms diff --git a/slixmpp/features/feature_mechanisms/mechanisms.py b/slixmpp/features/feature_mechanisms/mechanisms.py new file mode 100644 index 00000000..8e507afc --- /dev/null +++ b/slixmpp/features/feature_mechanisms/mechanisms.py @@ -0,0 +1,249 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import ssl +import logging + +from slixmpp.util import sasl +from slixmpp.util.stringprep_profiles import StringPrepError +from slixmpp.stanza import StreamFeatures +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.xmlstream.handler import Callback +from slixmpp.features.feature_mechanisms import stanza + + +log = logging.getLogger(__name__) + + +class FeatureMechanisms(BasePlugin): + + name = 'feature_mechanisms' + description = 'RFC 6120: Stream Feature: SASL' + dependencies = set() + stanza = stanza + default_config = { + 'use_mech': None, + 'use_mechs': None, + 'min_mech': None, + 'sasl_callback': None, + 'security_callback': None, + 'encrypted_plain': True, + 'unencrypted_plain': False, + 'unencrypted_digest': False, + 'unencrypted_cram': False, + 'unencrypted_scram': True, + 'order': 100 + } + + def plugin_init(self): + if self.sasl_callback is None: + self.sasl_callback = self._default_credentials + + if self.security_callback is None: + self.security_callback = self._default_security + + creds = self.sasl_callback(set(['username']), set()) + if not self.use_mech and not creds['username']: + self.use_mech = 'ANONYMOUS' + + self.mech = None + self.mech_list = set() + self.attempted_mechs = set() + + 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_stanza(stanza.Abort) + + self.xmpp.register_handler( + Callback('SASL Success', + MatchXPath(stanza.Success.tag_name()), + self._handle_success, + instream=True)) + self.xmpp.register_handler( + Callback('SASL Failure', + MatchXPath(stanza.Failure.tag_name()), + self._handle_fail, + instream=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.order) + + def _default_credentials(self, required_values, optional_values): + creds = self.xmpp.credentials + result = {} + values = required_values.union(optional_values) + for value in values: + if value == 'username': + result[value] = creds.get('username', self.xmpp.requested_jid.user) + elif value == 'email': + jid = self.xmpp.requested_jid.bare + result[value] = creds.get('email', jid) + elif value == 'channel_binding': + if hasattr(self.xmpp.socket, 'get_channel_binding'): + result[value] = self.xmpp.socket.get_channel_binding() + else: + log.debug("Channel binding not supported.") + log.debug("Use Python 3.3+ for channel binding and " + \ + "SCRAM-SHA-1-PLUS support") + result[value] = None + elif value == 'host': + result[value] = creds.get('host', self.xmpp.requested_jid.domain) + elif value == 'realm': + result[value] = creds.get('realm', self.xmpp.requested_jid.domain) + elif value == 'service-name': + result[value] = creds.get('service-name', self.xmpp._service_name) + elif value == 'service': + result[value] = creds.get('service', 'xmpp') + elif value in creds: + result[value] = creds[value] + return result + + def _default_security(self, values): + result = {} + for value in values: + if value == 'encrypted': + if 'starttls' in self.xmpp.features: + result[value] = True + elif isinstance(self.xmpp.socket, ssl.SSLSocket): + result[value] = True + else: + result[value] = False + else: + result[value] = self.config.get(value, False) + return result + + 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 + + enforce_limit = False + limited_mechs = self.use_mechs + + if limited_mechs is None: + limited_mechs = set() + elif limited_mechs and not isinstance(limited_mechs, set): + limited_mechs = set(limited_mechs) + enforce_limit = True + + if self.use_mech: + limited_mechs.add(self.use_mech) + enforce_limit = True + + if enforce_limit: + self.use_mechs = limited_mechs + + self.mech_list = set(features['mechanisms']) + + return self._send_auth() + + def _send_auth(self): + mech_list = self.mech_list - self.attempted_mechs + try: + self.mech = sasl.choose(mech_list, + self.sasl_callback, + self.security_callback, + limit=self.use_mechs, + min_mech=self.min_mech) + except sasl.SASLNoAppropriateMechanism: + log.error("No appropriate login method.") + self.xmpp.event("failed_all_auth") + if not self.attempted_mechs: + # Only trigger this event if we didn't try at least one + # method + self.xmpp.event("no_auth") + self.attempted_mechs = set() + return self.xmpp.disconnect() + except StringPrepError: + log.exception("A credential value did not pass SASLprep.") + self.xmpp.disconnect() + + resp = stanza.Auth(self.xmpp) + resp['mechanism'] = self.mech.name + try: + resp['value'] = self.mech.process() + except sasl.SASLCancelled: + self.attempted_mechs.add(self.mech.name) + self._send_auth() + except sasl.SASLMutualAuthFailed: + log.error("Mutual authentication failed! " + \ + "A security breach is possible.") + self.attempted_mechs.add(self.mech.name) + self.xmpp.disconnect() + except sasl.SASLFailed: + self.attempted_mechs.add(self.mech.name) + self._send_auth() + else: + resp.send() + + return True + + def _handle_challenge(self, stanza): + """SASL challenge received. Process and send response.""" + resp = self.stanza.Response(self.xmpp) + try: + resp['value'] = self.mech.process(stanza['value']) + except sasl.SASLCancelled: + self.stanza.Abort(self.xmpp).send() + except sasl.SASLMutualAuthFailed: + log.error("Mutual authentication failed! " + \ + "A security breach is possible.") + self.attempted_mechs.add(self.mech.name) + self.xmpp.disconnect() + except sasl.SASLFailed: + self.stanza.Abort(self.xmpp).send() + else: + if resp.get_value() == '': + resp.del_value() + resp.send() + + def _handle_success(self, stanza): + """SASL authentication succeeded. Restart the stream.""" + try: + final = self.mech.process(stanza['value']) + except sasl.SASLMutualAuthFailed: + log.error("Mutual authentication failed! " + \ + "A security breach is possible.") + self.attempted_mechs.add(self.mech.name) + self.xmpp.disconnect() + else: + self.attempted_mechs = set() + self.xmpp.authenticated = True + self.xmpp.features.add('mechanisms') + self.xmpp.event('auth_success', stanza) + # Restart the stream + self.xmpp.init_parser() + self.xmpp.send_raw(self.xmpp.stream_header) + + def _handle_fail(self, stanza): + """SASL authentication failed. Disconnect and shutdown.""" + self.attempted_mechs.add(self.mech.name) + log.info("Authentication failed: %s", stanza['condition']) + self.xmpp.event("failed_auth", stanza) + self._send_auth() + return True diff --git a/slixmpp/features/feature_mechanisms/stanza/__init__.py b/slixmpp/features/feature_mechanisms/stanza/__init__.py new file mode 100644 index 00000000..4d515bf2 --- /dev/null +++ b/slixmpp/features/feature_mechanisms/stanza/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +from slixmpp.features.feature_mechanisms.stanza.mechanisms import Mechanisms +from slixmpp.features.feature_mechanisms.stanza.auth import Auth +from slixmpp.features.feature_mechanisms.stanza.success import Success +from slixmpp.features.feature_mechanisms.stanza.failure import Failure +from slixmpp.features.feature_mechanisms.stanza.challenge import Challenge +from slixmpp.features.feature_mechanisms.stanza.response import Response +from slixmpp.features.feature_mechanisms.stanza.abort import Abort diff --git a/slixmpp/features/feature_mechanisms/stanza/abort.py b/slixmpp/features/feature_mechanisms/stanza/abort.py new file mode 100644 index 00000000..fca29aee --- /dev/null +++ b/slixmpp/features/feature_mechanisms/stanza/abort.py @@ -0,0 +1,24 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import StanzaBase + + +class Abort(StanzaBase): + + """ + """ + + name = 'abort' + 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/slixmpp/features/feature_mechanisms/stanza/auth.py b/slixmpp/features/feature_mechanisms/stanza/auth.py new file mode 100644 index 00000000..c32069ec --- /dev/null +++ b/slixmpp/features/feature_mechanisms/stanza/auth.py @@ -0,0 +1,49 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import base64 + +from slixmpp.util import bytes +from slixmpp.xmlstream import StanzaBase + + +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: + if values: + self.xml.text = bytes(base64.b64encode(values)).decode('utf-8') + elif values == b'': + self.xml.text = '=' + else: + self.xml.text = bytes(values).decode('utf-8') + + def del_value(self): + self.xml.text = '' diff --git a/slixmpp/features/feature_mechanisms/stanza/challenge.py b/slixmpp/features/feature_mechanisms/stanza/challenge.py new file mode 100644 index 00000000..21a061ee --- /dev/null +++ b/slixmpp/features/feature_mechanisms/stanza/challenge.py @@ -0,0 +1,39 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import base64 + +from slixmpp.util import bytes +from slixmpp.xmlstream import StanzaBase + + +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): + if values: + self.xml.text = bytes(base64.b64encode(values)).decode('utf-8') + else: + self.xml.text = '=' + + def del_value(self): + self.xml.text = '' diff --git a/slixmpp/features/feature_mechanisms/stanza/failure.py b/slixmpp/features/feature_mechanisms/stanza/failure.py new file mode 100644 index 00000000..cc0ac877 --- /dev/null +++ b/slixmpp/features/feature_mechanisms/stanza/failure.py @@ -0,0 +1,76 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import StanzaBase, ET + + +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: + 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: + 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/slixmpp/features/feature_mechanisms/stanza/mechanisms.py b/slixmpp/features/feature_mechanisms/stanza/mechanisms.py new file mode 100644 index 00000000..4437e155 --- /dev/null +++ b/slixmpp/features/feature_mechanisms/stanza/mechanisms.py @@ -0,0 +1,53 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +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/slixmpp/features/feature_mechanisms/stanza/response.py b/slixmpp/features/feature_mechanisms/stanza/response.py new file mode 100644 index 00000000..8da236ba --- /dev/null +++ b/slixmpp/features/feature_mechanisms/stanza/response.py @@ -0,0 +1,39 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import base64 + +from slixmpp.util import bytes +from slixmpp.xmlstream import StanzaBase + + +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): + if values: + self.xml.text = bytes(base64.b64encode(values)).decode('utf-8') + else: + self.xml.text = '=' + + def del_value(self): + self.xml.text = '' diff --git a/slixmpp/features/feature_mechanisms/stanza/success.py b/slixmpp/features/feature_mechanisms/stanza/success.py new file mode 100644 index 00000000..f7cde0f8 --- /dev/null +++ b/slixmpp/features/feature_mechanisms/stanza/success.py @@ -0,0 +1,38 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import base64 + +from slixmpp.util import bytes +from slixmpp.xmlstream import StanzaBase + +class Success(StanzaBase): + + """ + """ + + name = 'success' + 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): + if values: + self.xml.text = bytes(base64.b64encode(values)).decode('utf-8') + else: + self.xml.text = '=' + + def del_value(self): + self.xml.text = '' diff --git a/slixmpp/features/feature_preapproval/__init__.py b/slixmpp/features/feature_preapproval/__init__.py new file mode 100644 index 00000000..f22be050 --- /dev/null +++ b/slixmpp/features/feature_preapproval/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.features.feature_preapproval.preapproval import FeaturePreApproval +from slixmpp.features.feature_preapproval.stanza import PreApproval + + +register_plugin(FeaturePreApproval) diff --git a/slixmpp/features/feature_preapproval/preapproval.py b/slixmpp/features/feature_preapproval/preapproval.py new file mode 100644 index 00000000..1d60d7e7 --- /dev/null +++ b/slixmpp/features/feature_preapproval/preapproval.py @@ -0,0 +1,42 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza import StreamFeatures +from slixmpp.features.feature_preapproval import stanza +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.base import BasePlugin + + +log = logging.getLogger(__name__) + + +class FeaturePreApproval(BasePlugin): + + name = 'feature_preapproval' + description = 'RFC 6121: Stream Feature: Subscription Pre-Approval' + dependences = set() + stanza = stanza + + def plugin_init(self): + self.xmpp.register_feature('preapproval', + self._handle_preapproval, + restart=False, + order=9001) + + register_stanza_plugin(StreamFeatures, stanza.PreApproval) + + def _handle_preapproval(self, features): + """Save notice that the server support subscription pre-approvals. + + Arguments: + features -- The stream features stanza. + """ + log.debug("Server supports subscription pre-approvals.") + self.xmpp.features.add('preapproval') diff --git a/slixmpp/features/feature_preapproval/stanza.py b/slixmpp/features/feature_preapproval/stanza.py new file mode 100644 index 00000000..03d721ef --- /dev/null +++ b/slixmpp/features/feature_preapproval/stanza.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class PreApproval(ElementBase): + + name = 'sub' + namespace = 'urn:xmpp:features:pre-approval' + interfaces = set() + plugin_attrib = 'preapproval' diff --git a/slixmpp/features/feature_rosterver/__init__.py b/slixmpp/features/feature_rosterver/__init__.py new file mode 100644 index 00000000..d338b584 --- /dev/null +++ b/slixmpp/features/feature_rosterver/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.features.feature_rosterver.rosterver import FeatureRosterVer +from slixmpp.features.feature_rosterver.stanza import RosterVer + + +register_plugin(FeatureRosterVer) + + +# Retain some backwards compatibility +feature_rosterver = FeatureRosterVer diff --git a/slixmpp/features/feature_rosterver/rosterver.py b/slixmpp/features/feature_rosterver/rosterver.py new file mode 100644 index 00000000..2c2c8c84 --- /dev/null +++ b/slixmpp/features/feature_rosterver/rosterver.py @@ -0,0 +1,42 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza import StreamFeatures +from slixmpp.features.feature_rosterver import stanza +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.base import BasePlugin + + +log = logging.getLogger(__name__) + + +class FeatureRosterVer(BasePlugin): + + name = 'feature_rosterver' + description = 'RFC 6121: Stream Feature: Roster Versioning' + dependences = set() + stanza = stanza + + def plugin_init(self): + self.xmpp.register_feature('rosterver', + self._handle_rosterver, + restart=False, + order=9000) + + register_stanza_plugin(StreamFeatures, stanza.RosterVer) + + def _handle_rosterver(self, features): + """Enable using roster versioning. + + Arguments: + features -- The stream features stanza. + """ + log.debug("Enabling roster versioning.") + self.xmpp.features.add('rosterver') diff --git a/slixmpp/features/feature_rosterver/stanza.py b/slixmpp/features/feature_rosterver/stanza.py new file mode 100644 index 00000000..c9a4a2da --- /dev/null +++ b/slixmpp/features/feature_rosterver/stanza.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class RosterVer(ElementBase): + + name = 'ver' + namespace = 'urn:xmpp:features:rosterver' + interfaces = set() + plugin_attrib = 'rosterver' diff --git a/slixmpp/features/feature_session/__init__.py b/slixmpp/features/feature_session/__init__.py new file mode 100644 index 00000000..0ac950c6 --- /dev/null +++ b/slixmpp/features/feature_session/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.features.feature_session.session import FeatureSession +from slixmpp.features.feature_session.stanza import Session + + +register_plugin(FeatureSession) + + +# Retain some backwards compatibility +feature_session = FeatureSession diff --git a/slixmpp/features/feature_session/session.py b/slixmpp/features/feature_session/session.py new file mode 100644 index 00000000..0635455a --- /dev/null +++ b/slixmpp/features/feature_session/session.py @@ -0,0 +1,54 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza import Iq, StreamFeatures +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin + +from slixmpp.features.feature_session import stanza + + +log = logging.getLogger(__name__) + + +class FeatureSession(BasePlugin): + + name = 'feature_session' + description = 'RFC 3920: Stream Feature: Start Session' + dependencies = set() + stanza = stanza + + def plugin_init(self): + 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') + iq.send(callback=self._on_start_session_response) + + def _on_start_session_response(self, response): + self.xmpp.features.add('session') + + log.debug("Established Session") + self.xmpp.sessionstarted = True + self.xmpp.event('session_start') diff --git a/slixmpp/features/feature_session/stanza.py b/slixmpp/features/feature_session/stanza.py new file mode 100644 index 00000000..f68483d6 --- /dev/null +++ b/slixmpp/features/feature_session/stanza.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class Session(ElementBase): + + """ + """ + + name = 'session' + namespace = 'urn:ietf:params:xml:ns:xmpp-session' + interfaces = set() + plugin_attrib = 'session' diff --git a/slixmpp/features/feature_starttls/__init__.py b/slixmpp/features/feature_starttls/__init__.py new file mode 100644 index 00000000..81a88650 --- /dev/null +++ b/slixmpp/features/feature_starttls/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.features.feature_starttls.starttls import FeatureSTARTTLS +from slixmpp.features.feature_starttls.stanza import * + + +register_plugin(FeatureSTARTTLS) + + +# Retain some backwards compatibility +feature_starttls = FeatureSTARTTLS diff --git a/slixmpp/features/feature_starttls/stanza.py b/slixmpp/features/feature_starttls/stanza.py new file mode 100644 index 00000000..df50897e --- /dev/null +++ b/slixmpp/features/feature_starttls/stanza.py @@ -0,0 +1,45 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import StanzaBase, ElementBase + + +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/slixmpp/features/feature_starttls/starttls.py b/slixmpp/features/feature_starttls/starttls.py new file mode 100644 index 00000000..d472dad7 --- /dev/null +++ b/slixmpp/features/feature_starttls/starttls.py @@ -0,0 +1,65 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza import StreamFeatures +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.xmlstream.handler import Callback +from slixmpp.features.feature_starttls import stanza + + +log = logging.getLogger(__name__) + + +class FeatureSTARTTLS(BasePlugin): + + name = 'feature_starttls' + description = 'RFC 6120: Stream Feature: STARTTLS' + dependencies = set() + stanza = stanza + + def plugin_init(self): + 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 self.xmpp.disable_starttls: + return False + else: + self.xmpp.send(features['starttls']) + return True + + 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') diff --git a/slixmpp/jid.py b/slixmpp/jid.py new file mode 100644 index 00000000..2e23e242 --- /dev/null +++ b/slixmpp/jid.py @@ -0,0 +1,449 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.jid + ~~~~~~~~~~~~~~~~~~~~~~~ + + This module allows for working with Jabber IDs (JIDs). + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import re +import socket + +from copy import deepcopy +from functools import lru_cache + +from slixmpp.stringprep import nodeprep, resourceprep, idna, StringprepError + +HAVE_INET_PTON = hasattr(socket, 'inet_pton') + +#: The basic regex pattern that a JID must match in order to determine +#: the local, domain, and resource parts. This regex does NOT do any +#: validation, which requires application of nodeprep, resourceprep, etc. +JID_PATTERN = re.compile( + "^(?:([^\"&'/:<>@]{1,1023})@)?([^/@]{1,1023})(?:/(.{1,1023}))?$" +) + +#: The set of escape sequences for the characters not allowed by nodeprep. +JID_ESCAPE_SEQUENCES = {'\\20', '\\22', '\\26', '\\27', '\\2f', + '\\3a', '\\3c', '\\3e', '\\40', '\\5c'} + +#: The reverse mapping of escape sequences to their original forms. +JID_UNESCAPE_TRANSFORMATIONS = {'\\20': ' ', + '\\22': '"', + '\\26': '&', + '\\27': "'", + '\\2f': '/', + '\\3a': ':', + '\\3c': '<', + '\\3e': '>', + '\\40': '@', + '\\5c': '\\'} + + +# TODO: Find the best cache size for a standard usage. +@lru_cache(maxsize=1024) +def _parse_jid(data): + """ + Parse string data into the node, domain, and resource + components of a JID, if possible. + + :param string data: A string that is potentially a JID. + + :raises InvalidJID: + + :returns: tuple of the validated local, domain, and resource strings + """ + match = JID_PATTERN.match(data) + if not match: + raise InvalidJID('JID could not be parsed') + + (node, domain, resource) = match.groups() + + node = _validate_node(node) + domain = _validate_domain(domain) + resource = _validate_resource(resource) + + return node, domain, resource + + +def _validate_node(node): + """Validate the local, or username, portion of a JID. + + :raises InvalidJID: + + :returns: The local portion of a JID, as validated by nodeprep. + """ + if node is None: + return None + + try: + node = nodeprep(node) + except StringprepError: + raise InvalidJID('Nodeprep failed') + + if not node: + raise InvalidJID('Localpart must not be 0 bytes') + if len(node) > 1023: + raise InvalidJID('Localpart must be less than 1024 bytes') + return node + + +def _validate_domain(domain): + """Validate the domain portion of a JID. + + IP literal addresses are left as-is, if valid. Domain names + are stripped of any trailing label separators (`.`), and are + checked with the nameprep profile of stringprep. If the given + domain is actually a punyencoded version of a domain name, it + is converted back into its original Unicode form. Domains must + also not start or end with a dash (`-`). + + :raises InvalidJID: + + :returns: The validated domain name + """ + ip_addr = False + + # First, check if this is an IPv4 address + try: + socket.inet_aton(domain) + ip_addr = True + except socket.error: + pass + + # Check if this is an IPv6 address + if not ip_addr and HAVE_INET_PTON and domain[0] == '[' and domain[-1] == ']': + try: + ip = domain[1:-1] + socket.inet_pton(socket.AF_INET6, ip) + ip_addr = True + except (socket.error, ValueError): + pass + + if not ip_addr: + # This is a domain name, which must be checked further + + if domain and domain[-1] == '.': + domain = domain[:-1] + + try: + domain = idna(domain) + except StringprepError: + raise InvalidJID('idna validation failed') + + if ':' in domain: + raise InvalidJID('Domain containing a port') + for label in domain.split('.'): + if not label: + raise InvalidJID('Domain containing too many dots') + if '-' in (label[0], label[-1]): + raise InvalidJID('Domain started or ended with -') + + if not domain: + raise InvalidJID('Domain must not be 0 bytes') + if len(domain) > 1023: + raise InvalidJID('Domain must be less than 1024 bytes') + + return domain + + +def _validate_resource(resource): + """Validate the resource portion of a JID. + + :raises InvalidJID: + + :returns: The local portion of a JID, as validated by resourceprep. + """ + if resource is None: + return None + + try: + resource = resourceprep(resource) + except StringprepError: + raise InvalidJID('Resourceprep failed') + + if not resource: + raise InvalidJID('Resource must not be 0 bytes') + if len(resource) > 1023: + raise InvalidJID('Resource must be less than 1024 bytes') + return resource + + +def _unescape_node(node): + """Unescape a local portion of a JID. + + .. note:: + The unescaped local portion is meant ONLY for presentation, + and should not be used for other purposes. + """ + unescaped = [] + seq = '' + for i, char in enumerate(node): + if char == '\\': + seq = node[i:i+3] + if seq not in JID_ESCAPE_SEQUENCES: + seq = '' + if seq: + if len(seq) == 3: + unescaped.append(JID_UNESCAPE_TRANSFORMATIONS.get(seq, char)) + + # Pop character off the escape sequence, and ignore it + seq = seq[1:] + else: + unescaped.append(char) + return ''.join(unescaped) + + +def _format_jid(local=None, domain=None, resource=None): + """Format the given JID components into a full or bare JID. + + :param string local: Optional. The local portion of the JID. + :param string domain: Required. The domain name portion of the JID. + :param strin resource: Optional. The resource portion of the JID. + + :return: A full or bare JID string. + """ + result = [] + if local is not None: + result.append(local) + result.append('@') + if domain is not None: + result.append(domain) + if resource is not None: + result.append('/') + result.append(resource) + return ''.join(result) + + +class InvalidJID(ValueError): + """ + Raised when attempting to create a JID that does not pass validation. + + It can also be raised if modifying an existing JID in such a way as + to make it invalid, such trying to remove the domain from an existing + full JID while the local and resource portions still exist. + """ + +# pylint: disable=R0903 +class UnescapedJID: + + """ + .. versionadded:: 1.1.10 + """ + + __slots__ = ('_node', '_domain', '_resource') + + def __init__(self, node, domain, resource): + self._node = node + self._domain = domain + self._resource = resource + + def __getattribute__(self, name): + """Retrieve the given JID component. + + :param name: one of: user, server, domain, resource, + full, or bare. + """ + if name == 'resource': + return self._resource or '' + if name in ('user', 'username', 'local', 'node'): + return self._node or '' + if name in ('server', 'domain', 'host'): + return self._domain or '' + if name in ('full', 'jid'): + return _format_jid(self._node, self._domain, self._resource) + if name == 'bare': + return _format_jid(self._node, self._domain) + return object.__getattribute__(self, name) + + def __str__(self): + """Use the full JID as the string value.""" + return _format_jid(self._node, self._domain, self._resource) + + def __repr__(self): + """Use the full JID as the representation.""" + return _format_jid(self._node, self._domain, self._resource) + + +class JID: + + """ + 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:** + :full: The string value of the full JID. + :jid: Alias for ``full``. + :bare: The string value of the bare JID. + :node: The node portion of the JID. + :user: Alias for ``node``. + :local: Alias for ``node``. + :username: Alias for ``node``. + :domain: The domain name portion of the JID. + :server: Alias for ``domain``. + :host: Alias for ``domain``. + :resource: The resource portion of the JID. + + :param string jid: + A string of the form ``'[user@]domain[/resource]'``. + + :raises InvalidJID: + """ + + __slots__ = ('_node', '_domain', '_resource') + + def __init__(self, jid=None): + if not jid: + self._node = None + self._domain = None + self._resource = None + elif not isinstance(jid, JID): + self._node, self._domain, self._resource = _parse_jid(jid) + else: + self._node = jid._node + self._domain = jid._domain + self._resource = jid._resource + + def unescape(self): + """Return an unescaped JID object. + + Using an unescaped JID is preferred for displaying JIDs + to humans, and they should NOT be used for any other + purposes than for presentation. + + :return: :class:`UnescapedJID` + + .. versionadded:: 1.1.10 + """ + return UnescapedJID(_unescape_node(self._node), + self._domain, + self._resource) + + @property + def node(self): + return self._node or '' + + @property + def user(self): + return self._node or '' + + @property + def local(self): + return self._node or '' + + @property + def username(self): + return self._node or '' + + @property + def domain(self): + return self._domain or '' + + @property + def server(self): + return self._domain or '' + + @property + def host(self): + return self._domain or '' + + @property + def resource(self): + return self._resource or '' + + @property + def bare(self): + return _format_jid(self._node, self._domain) + + @property + def full(self): + return _format_jid(self._node, self._domain, self._resource) + + @property + def jid(self): + return _format_jid(self._node, self._domain, self._resource) + + @node.setter + def node(self, value): + self._node = _validate_node(value) + + @user.setter + def user(self, value): + self._node = _validate_node(value) + + @local.setter + def local(self, value): + self._node = _validate_node(value) + + @username.setter + def username(self, value): + self._node = _validate_node(value) + + @domain.setter + def domain(self, value): + self._domain = _validate_domain(value) + + @server.setter + def server(self, value): + self._domain = _validate_domain(value) + + @host.setter + def host(self, value): + self._domain = _validate_domain(value) + + @bare.setter + def bare(self, value): + node, domain, resource = _parse_jid(value) + assert not resource + self._node = node + self._domain = domain + + @resource.setter + def resource(self, value): + self._resource = _validate_resource(value) + + @full.setter + def full(self, value): + self._node, self._domain, self._resource = _parse_jid(value) + + @jid.setter + def jid(self, value): + self._node, self._domain, self._resource = _parse_jid(value) + + def __str__(self): + """Use the full JID as the string value.""" + return _format_jid(self._node, self._domain, self._resource) + + def __repr__(self): + """Use the full JID as the representation.""" + return _format_jid(self._node, self._domain, self._resource) + + # pylint: disable=W0212 + def __eq__(self, other): + """Two JIDs are equal if they have the same full JID value.""" + if isinstance(other, UnescapedJID): + return False + if not isinstance(other, JID): + other = JID(other) + + return (self._node == other._node and + self._domain == other._domain and + self._resource == other._resource) + + 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(_format_jid(self._node, self._domain, self._resource)) diff --git a/slixmpp/plugins/__init__.py b/slixmpp/plugins/__init__.py new file mode 100644 index 00000000..d28cf281 --- /dev/null +++ b/slixmpp/plugins/__init__.py @@ -0,0 +1,88 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import PluginManager, PluginNotFound, BasePlugin +from slixmpp.plugins.base import register_plugin, load_plugin + + +__all__ = [ + # XEPS + 'xep_0004', # Data Forms + 'xep_0009', # Jabber-RPC + 'xep_0012', # Last Activity + 'xep_0013', # Flexible Offline Message Retrieval + 'xep_0016', # Privacy Lists + 'xep_0020', # Feature Negotiation + 'xep_0027', # Current Jabber OpenPGP Usage + 'xep_0030', # Service Discovery + 'xep_0033', # Extended Stanza Addresses + 'xep_0045', # Multi-User Chat (Client) + 'xep_0047', # In-Band Bytestreams + 'xep_0048', # Bookmarks + 'xep_0049', # Private XML Storage + 'xep_0050', # Ad-hoc Commands + 'xep_0054', # vcard-temp + 'xep_0059', # Result Set Management + 'xep_0060', # Pubsub (Client) + 'xep_0065', # SOCKS5 Bytestreams + 'xep_0066', # Out of Band Data + 'xep_0071', # XHTML-IM + 'xep_0077', # In-Band Registration +# 'xep_0078', # Non-SASL auth. Don't automatically load + 'xep_0079', # Advanced Message Processing + 'xep_0080', # User Location + 'xep_0082', # XMPP Date and Time Profiles + 'xep_0084', # User Avatar + 'xep_0085', # Chat State Notifications + 'xep_0086', # Legacy Error Codes + 'xep_0091', # Legacy Delayed Delivery + 'xep_0092', # Software Version + 'xep_0106', # JID Escaping + 'xep_0107', # User Mood + 'xep_0108', # User Activity + 'xep_0115', # Entity Capabilities + 'xep_0118', # User Tune + 'xep_0122', # Data Forms Validation + 'xep_0128', # Extended Service Discovery + 'xep_0131', # Standard Headers and Internet Metadata + 'xep_0133', # Service Administration + 'xep_0152', # Reachability Addresses + 'xep_0153', # vCard-Based Avatars + 'xep_0163', # Personal Eventing Protocol + 'xep_0172', # User Nickname + 'xep_0184', # Message Receipts + 'xep_0186', # Invisible Command + 'xep_0191', # Blocking Command + 'xep_0196', # User Gaming + 'xep_0198', # Stream Management + 'xep_0199', # Ping + 'xep_0202', # Entity Time + 'xep_0203', # Delayed Delivery + 'xep_0221', # Data Forms Media Element + 'xep_0222', # Persistent Storage of Public Data via Pubsub + 'xep_0223', # Persistent Storage of Private Data via Pubsub + 'xep_0224', # Attention + 'xep_0231', # Bits of Binary + 'xep_0235', # OAuth Over XMPP + 'xep_0242', # XMPP Client Compliance 2009 + 'xep_0249', # Direct MUC Invitations + 'xep_0256', # Last Activity in Presence + 'xep_0257', # Client Certificate Management for SASL EXTERNAL + 'xep_0258', # Security Labels in XMPP + 'xep_0270', # XMPP Compliance Suites 2010 + 'xep_0279', # Server IP Check + 'xep_0280', # Message Carbons + 'xep_0297', # Stanza Forwarding + 'xep_0302', # XMPP Compliance Suites 2012 + 'xep_0308', # Last Message Correction + 'xep_0313', # Message Archive Management + 'xep_0319', # Last User Interaction in Presence + 'xep_0323', # IoT Systems Sensor Data + 'xep_0325', # IoT Systems Control + 'xep_0332', # HTTP Over XMPP Transport +] diff --git a/slixmpp/plugins/base.py b/slixmpp/plugins/base.py new file mode 100644 index 00000000..0fe083bc --- /dev/null +++ b/slixmpp/plugins/base.py @@ -0,0 +1,352 @@ +# -*- encoding: utf-8 -*- + +""" + slixmpp.plugins.base + ~~~~~~~~~~~~~~~~~~~~~~ + + This module provides XMPP functionality that + is specific to client connections. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2012 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import sys +import copy +import logging +import threading + + +log = logging.getLogger(__name__) + + +#: Associate short string names of plugins with implementations. The +#: plugin names are based on the spec used by the plugin, such as +#: `'xep_0030'` for a plugin that implements XEP-0030. +PLUGIN_REGISTRY = {} + +#: In order to do cascading plugin disabling, reverse dependencies +#: must be tracked. +PLUGIN_DEPENDENTS = {} + +#: Only allow one thread to manipulate the plugin registry at a time. +REGISTRY_LOCK = threading.RLock() + + +class PluginNotFound(Exception): + """Raised if an unknown plugin is accessed.""" + + +def register_plugin(impl, name=None): + """Add a new plugin implementation to the registry. + + :param class impl: The plugin class. + + The implementation class must provide a :attr:`~BasePlugin.name` + value that will be used as a short name for enabling and disabling + the plugin. The name should be based on the specification used by + the plugin. For example, a plugin implementing XEP-0030 would be + named `'xep_0030'`. + """ + if name is None: + name = impl.name + with REGISTRY_LOCK: + PLUGIN_REGISTRY[name] = impl + if name not in PLUGIN_DEPENDENTS: + PLUGIN_DEPENDENTS[name] = set() + for dep in impl.dependencies: + if dep not in PLUGIN_DEPENDENTS: + PLUGIN_DEPENDENTS[dep] = set() + PLUGIN_DEPENDENTS[dep].add(name) + + +def load_plugin(name, module=None): + """Find and import a plugin module so that it can be registered. + + This function is called to import plugins that have selected for + enabling, but no matching registered plugin has been found. + + :param str name: The name of the plugin. It is expected that + plugins are in packages matching their name, + even though the plugin class name does not + have to match. + :param str module: The name of the base module to search + for the plugin. + """ + try: + if not module: + try: + module = 'slixmpp.plugins.%s' % name + __import__(module) + mod = sys.modules[module] + except ImportError: + module = 'slixmpp.features.%s' % name + __import__(module) + mod = sys.modules[module] + elif isinstance(module, str): + __import__(module) + mod = sys.modules[module] + else: + mod = module + + # Add older style plugins to the registry. + if hasattr(mod, name): + plugin = getattr(mod, name) + if hasattr(plugin, 'xep') or hasattr(plugin, 'rfc'): + plugin.name = name + # Mark the plugin as an older style plugin so + # we can work around dependency issues. + plugin.old_style = True + register_plugin(plugin, name) + except ImportError: + log.exception("Unable to load plugin: %s", name) + + +class PluginManager(object): + def __init__(self, xmpp, config=None): + #: We will track all enabled plugins in a set so that we + #: can enable plugins in batches and pull in dependencies + #: without problems. + self._enabled = set() + + #: Maintain references to active plugins. + self._plugins = {} + + self._plugin_lock = threading.RLock() + + #: Globally set default plugin configuration. This will + #: be used for plugins that are auto-enabled through + #: dependency loading. + self.config = config if config else {} + + self.xmpp = xmpp + + def register(self, plugin, enable=True): + """Register a new plugin, and optionally enable it. + + :param class plugin: The implementation class of the plugin + to register. + :param bool enable: If ``True``, immediately enable the + plugin after registration. + """ + register_plugin(plugin) + if enable: + self.enable(plugin.name) + + def enable(self, name, config=None, enabled=None): + """Enable a plugin, including any dependencies. + + :param string name: The short name of the plugin. + :param dict config: Optional settings dictionary for + configuring plugin behaviour. + """ + if enabled is None: + enabled = set() + + with self._plugin_lock: + if name not in self._enabled: + enabled.add(name) + self._enabled.add(name) + if not self.registered(name): + load_plugin(name) + + plugin_class = PLUGIN_REGISTRY.get(name, None) + if not plugin_class: + raise PluginNotFound(name) + + if config is None: + config = self.config.get(name, None) + + plugin = plugin_class(self.xmpp, config) + self._plugins[name] = plugin + for dep in plugin.dependencies: + self.enable(dep, enabled=enabled) + plugin._init() + + for name in enabled: + if hasattr(self._plugins[name], 'old_style'): + # Older style plugins require post_init() + # to run just before stream processing begins, + # so we don't call it here. + pass + else: + self._plugins[name].post_init() + + def enable_all(self, names=None, config=None): + """Enable all registered plugins. + + :param list names: A list of plugin names to enable. If + none are provided, all registered plugins + will be enabled. + :param dict config: A dictionary mapping plugin names to + configuration dictionaries, as used by + :meth:`~PluginManager.enable`. + """ + names = names if names else PLUGIN_REGISTRY.keys() + if config is None: + config = {} + for name in names: + self.enable(name, config.get(name, {})) + + def enabled(self, name): + """Check if a plugin has been enabled. + + :param string name: The name of the plugin to check. + :return: boolean + """ + return name in self._enabled + + def registered(self, name): + """Check if a plugin has been registered. + + :param string name: The name of the plugin to check. + :return: boolean + """ + return name in PLUGIN_REGISTRY + + def disable(self, name, _disabled=None): + """Disable a plugin, including any dependent upon it. + + :param string name: The name of the plugin to disable. + :param set _disabled: Private set used to track the + disabled status of plugins during + the cascading process. + """ + if _disabled is None: + _disabled = set() + with self._plugin_lock: + if name not in _disabled and name in self._enabled: + _disabled.add(name) + plugin = self._plugins.get(name, None) + if plugin is None: + raise PluginNotFound(name) + for dep in PLUGIN_DEPENDENTS[name]: + self.disable(dep, _disabled) + plugin._end() + if name in self._enabled: + self._enabled.remove(name) + del self._plugins[name] + + def __keys__(self): + """Return the set of enabled plugins.""" + return self._plugins.keys() + + def __getitem__(self, name): + """ + Allow plugins to be accessed through the manager as if + it were a dictionary. + """ + plugin = self._plugins.get(name, None) + if plugin is None: + raise PluginNotFound(name) + return plugin + + def __iter__(self): + """Return an iterator over the set of enabled plugins.""" + return self._plugins.__iter__() + + def __len__(self): + """Return the number of enabled plugins.""" + return len(self._plugins) + + +class BasePlugin(object): + + #: A short name for the plugin based on the implemented specification. + #: For example, a plugin for XEP-0030 would use `'xep_0030'`. + name = '' + + #: A longer name for the plugin, describing its purpose. For example, + #: a plugin for XEP-0030 would use `'Service Discovery'` as its + #: description value. + description = '' + + #: Some plugins may depend on others in order to function properly. + #: Any plugin names included in :attr:`~BasePlugin.dependencies` will + #: be initialized as needed if this plugin is enabled. + dependencies = set() + + #: The basic, standard configuration for the plugin, which may + #: be overridden when initializing the plugin. The configuration + #: fields included here may be accessed directly as attributes of + #: the plugin. For example, including the configuration field 'foo' + #: would mean accessing `plugin.foo` returns the current value of + #: `plugin.config['foo']`. + default_config = {} + + def __init__(self, xmpp, config=None): + self.xmpp = xmpp + if self.xmpp: + self.api = self.xmpp.api.wrap(self.name) + + #: A plugin's behaviour may be configurable, in which case those + #: configuration settings will be provided as a dictionary. + self.config = copy.copy(self.default_config) + if config: + self.config.update(config) + + def __getattr__(self, key): + """Provide direct access to configuration fields. + + If the standard configuration includes the option `'foo'`, then + accessing `self.foo` should be the same as `self.config['foo']`. + """ + if key in self.default_config: + return self.config.get(key, None) + else: + return object.__getattribute__(self, key) + + def __setattr__(self, key, value): + """Provide direct assignment to configuration fields. + + If the standard configuration includes the option `'foo'`, then + assigning to `self.foo` should be the same as assigning to + `self.config['foo']`. + """ + if key in self.default_config: + self.config[key] = value + else: + super(BasePlugin, self).__setattr__(key, value) + + def _init(self): + """Initialize plugin state, such as registering event handlers. + + Also sets up required event handlers. + """ + if self.xmpp is not None: + self.xmpp.add_event_handler('session_bind', self.session_bind) + if self.xmpp.session_bind_event.is_set(): + self.session_bind(self.xmpp.boundjid.full) + self.plugin_init() + log.debug('Loaded Plugin: %s', self.description) + + def _end(self): + """Cleanup plugin state, and prepare for plugin removal. + + Also removes required event handlers. + """ + if self.xmpp is not None: + self.xmpp.del_event_handler('session_bind', self.session_bind) + self.plugin_end() + log.debug('Disabled Plugin: %s' % self.description) + + def plugin_init(self): + """Initialize plugin state, such as registering event handlers.""" + pass + + def plugin_end(self): + """Cleanup plugin state, and prepare for plugin removal.""" + pass + + def session_bind(self, jid): + """Initialize plugin state based on the bound JID.""" + pass + + def post_init(self): + """Initialize any cross-plugin state. + + Only needed if the plugin has circular dependencies. + """ + pass diff --git a/slixmpp/plugins/gmail_notify.py b/slixmpp/plugins/gmail_notify.py new file mode 100644 index 00000000..8071984c --- /dev/null +++ b/slixmpp/plugins/gmail_notify.py @@ -0,0 +1,149 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +from slixmpp.plugins import BasePlugin +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import register_stanza_plugin, 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(BasePlugin): + """ + 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)) + + register_stanza_plugin(Iq, GmailQuery) + register_stanza_plugin(Iq, MailBox) + register_stanza_plugin(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/slixmpp/plugins/google/auth/stanza.py b/slixmpp/plugins/google/auth/stanza.py new file mode 100644 index 00000000..c5c693ee --- /dev/null +++ b/slixmpp/plugins/google/auth/stanza.py @@ -0,0 +1,47 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +class GoogleAuth(ElementBase): + name = 'auth' + namespace = 'http://www.google.com/talk/protocol/auth' + plugin_attrib = 'google' + interfaces = set(['client_uses_full_bind_result', 'service']) + + discovery_attr= '{%s}client-uses-full-bind-result' % namespace + service_attr= '{%s}service' % namespace + + def setup(self, xml): + """Don't create XML for the plugin.""" + self.xml = ET.Element('') + + def get_client_uses_full_bind_result(self): + return self.parent()._get_attr(self.discovery_attr) == 'true' + + def set_client_uses_full_bind_result(self, value): + if value in (True, 'true'): + self.parent()._set_attr(self.discovery_attr, 'true') + else: + self.parent()._del_attr(self.discovery_attr) + + def del_client_uses_full_bind_result(self): + self.parent()._del_attr(self.discovery_attr) + + def get_service(self): + return self.parent()._get_attr(self.service_attr, '') + + def set_service(self, value): + if value: + self.parent()._set_attr(self.service_attr, value) + else: + self.parent()._del_attr(self.service_attr) + + def del_service(self): + self.parent()._del_attr(self.service_attr) diff --git a/slixmpp/plugins/google/gmail/notifications.py b/slixmpp/plugins/google/gmail/notifications.py new file mode 100644 index 00000000..e6785ccb --- /dev/null +++ b/slixmpp/plugins/google/gmail/notifications.py @@ -0,0 +1,90 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza import Iq +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.google.gmail import stanza + + +log = logging.getLogger(__name__) + + +class Gmail(BasePlugin): + + """ + Google: Gmail Notifications + + Also see <https://developers.google.com/talk/jep_extensions/gmail>. + """ + + name = 'gmail' + description = 'Google: Gmail Notifications' + dependencies = set() + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, stanza.GmailQuery) + register_stanza_plugin(Iq, stanza.MailBox) + register_stanza_plugin(Iq, stanza.NewMail) + + self.xmpp.register_handler( + Callback('Gmail New Mail', + MatchXPath('{%s}iq/{%s}%s' % ( + self.xmpp.default_ns, + stanza.NewMail.namespace, + stanza.NewMail.name)), + self._handle_new_mail)) + + self._last_result_time = None + self._last_result_tid = None + + def plugin_end(self): + self.xmpp.remove_handler('Gmail New Mail') + + def _handle_new_mail(self, iq): + log.info('Gmail: New email!') + iq.reply().send() + self.xmpp.event('gmail_notification') + + def check(self, timeout=None, callback=None): + last_time = self._last_result_time + last_tid = self._last_result_tid + + callback = lambda iq: self._update_last_results(iq, callback) + + return self.search(newer_time=last_time, + newer_tid=last_tid, + timeout=timeout, + callback=callback) + + def _update_last_results(self, iq, callback=None): + self._last_result_time = iq['gmail_messages']['result_time'] + threads = iq['gmail_messages']['threads'] + if threads: + self._last_result_tid = threads[0]['tid'] + if callback: + callback(iq) + + def search(self, query=None, newer_time=None, newer_tid=None, + timeout=None, callback=None): + if not query: + log.info('Gmail: Checking for new email') + 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']['search'] = query + iq['gmail']['newer_than_time'] = newer_time + iq['gmail']['newer_than_tid'] = newer_tid + return iq.send(timeout=timeout, callback=callback) diff --git a/slixmpp/plugins/google/nosave/stanza.py b/slixmpp/plugins/google/nosave/stanza.py new file mode 100644 index 00000000..b060a486 --- /dev/null +++ b/slixmpp/plugins/google/nosave/stanza.py @@ -0,0 +1,59 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.jid import JID +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + + +class NoSave(ElementBase): + name = 'x' + namespace = 'google:nosave' + plugin_attrib = 'google_nosave' + interfaces = set(['value']) + + def get_value(self): + return self._get_attr('value', '') == 'enabled' + + def set_value(self, value): + self._set_attr('value', 'enabled' if value else 'disabled') + + +class NoSaveQuery(ElementBase): + name = 'query' + namespace = 'google:nosave' + plugin_attrib = 'google_nosave' + interfaces = set() + + +class Item(ElementBase): + name = 'item' + namespace = 'google:nosave' + plugin_attrib = 'item' + plugin_multi_attrib = 'items' + interfaces = set(['jid', 'source', 'value']) + + def get_value(self): + return self._get_attr('value', '') == 'enabled' + + def set_value(self, value): + self._set_attr('value', 'enabled' if value else 'disabled') + + def get_jid(self): + return JID(self._get_attr('jid', '')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_source(self): + return JID(self._get_attr('source', '')) + + def set_source(self, value): + self._set_attr('source', str(value)) + + +register_stanza_plugin(NoSaveQuery, Item) diff --git a/slixmpp/plugins/google/settings/settings.py b/slixmpp/plugins/google/settings/settings.py new file mode 100644 index 00000000..84a8dfa9 --- /dev/null +++ b/slixmpp/plugins/google/settings/settings.py @@ -0,0 +1,63 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Iq +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.google.settings import stanza + + +class GoogleSettings(BasePlugin): + + """ + Google: Gmail Notifications + + Also see <https://developers.google.com/talk/jep_extensions/usersettings>. + """ + + name = 'google_settings' + description = 'Google: User Settings' + dependencies = set() + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, stanza.UserSettings) + + self.xmpp.register_handler( + Callback('Google Settings', + StanzaPath('iq@type=set/google_settings'), + self._handle_settings_change)) + + def plugin_end(self): + self.xmpp.remove_handler('Google Settings') + + def get(self, timeout=None, callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq.enable('google_settings') + return iq.send(timeout=timeout, callback=callback) + + def update(self, settings, timeout=None, callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq.enable('google_settings') + + for setting, value in settings.items(): + iq['google_settings'][setting] = value + + return iq.send(timeout=timeout, callback=callback) + + def _handle_settings_change(self, iq): + reply = self.xmpp.Iq() + reply['type'] = 'result' + reply['id'] = iq['id'] + reply['to'] = iq['from'] + reply.send() + self.xmpp.event('google_settings_change', iq) diff --git a/slixmpp/plugins/xep_0004/__init__.py b/slixmpp/plugins/xep_0004/__init__.py new file mode 100644 index 00000000..3eb2b7a5 --- /dev/null +++ b/slixmpp/plugins/xep_0004/__init__.py @@ -0,0 +1,22 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0004.stanza import Form +from slixmpp.plugins.xep_0004.stanza import FormField, FieldOption +from slixmpp.plugins.xep_0004.dataforms import XEP_0004 + + +register_plugin(XEP_0004) + + +# Retain some backwards compatibility +xep_0004 = XEP_0004 +xep_0004.makeForm = xep_0004.make_form +xep_0004.buildForm = xep_0004.build_form diff --git a/slixmpp/plugins/xep_0004/dataforms.py b/slixmpp/plugins/xep_0004/dataforms.py new file mode 100644 index 00000000..90a87774 --- /dev/null +++ b/slixmpp/plugins/xep_0004/dataforms.py @@ -0,0 +1,57 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp import Message +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0004 import stanza +from slixmpp.plugins.xep_0004.stanza import Form, FormField, FieldOption + + +class XEP_0004(BasePlugin): + + """ + XEP-0004: Data Forms + """ + + name = 'xep_0004' + description = 'XEP-0004: Data Forms' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + self.xmpp.register_handler( + 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 plugin_end(self): + self.xmpp.remove_handler('Data Form') + self.xmpp['xep_0030'].del_feature(feature='jabber:x:data') + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('jabber:x:data') + + def make_form(self, ftype='form', title='', instructions=''): + f = Form() + f['type'] = ftype + f['title'] = title + f['instructions'] = instructions + return f + + def handle_form(self, message): + self.xmpp.event("message_xform", message) + + def build_form(self, xml): + return Form(xml=xml) diff --git a/slixmpp/plugins/xep_0004/stanza/__init__.py b/slixmpp/plugins/xep_0004/stanza/__init__.py new file mode 100644 index 00000000..9daaab75 --- /dev/null +++ b/slixmpp/plugins/xep_0004/stanza/__init__.py @@ -0,0 +1,10 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.xep_0004.stanza.field import FormField, FieldOption +from slixmpp.plugins.xep_0004.stanza.form import Form diff --git a/slixmpp/plugins/xep_0004/stanza/field.py b/slixmpp/plugins/xep_0004/stanza/field.py new file mode 100644 index 00000000..42f1210b --- /dev/null +++ b/slixmpp/plugins/xep_0004/stanza/field.py @@ -0,0 +1,185 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +class FormField(ElementBase): + namespace = 'jabber:x:data' + name = 'field' + plugin_attrib = 'field' + plugin_multi_attrib = 'fields' + interfaces = set(('answer', 'desc', 'required', 'value', + '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 is None or self._type in self.option_types: + opt = FieldOption() + opt['label'] = label + opt['value'] = value + self.append(opt) + 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 isinstance(value, bool): + value = [value] + 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',)) + plugin_multi_attrib = 'options' + + +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/slixmpp/plugins/xep_0004/stanza/form.py b/slixmpp/plugins/xep_0004/stanza/form.py new file mode 100644 index 00000000..151e2ef1 --- /dev/null +++ b/slixmpp/plugins/xep_0004/stanza/form.py @@ -0,0 +1,275 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import copy +import logging + +from collections import OrderedDict +from slixmpp.thirdparty import OrderedSet + +from slixmpp.xmlstream import ElementBase, ET +from slixmpp.plugins.xep_0004.stanza import FormField + + +log = logging.getLogger(__name__) + + +class Form(ElementBase): + namespace = 'jabber:x:data' + name = 'x' + plugin_attrib = 'form' + interfaces = OrderedSet(('instructions', 'reported', 'title', 'type', 'items', )) + 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.get_fields() + + def set_type(self, ftype): + self._set_attr('type', ftype) + if ftype == 'submit': + fields = self.get_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() + 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: + for option in options: + field.add_option(**option) + else: + del field['type'] + self.append(field) + 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() + for stanza in self['substanzas']: + if isinstance(stanza, FormField): + fields[stanza['var']] = stanza + return fields + + def get_instructions(self): + 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.get_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( + var=field.get('var'), + label=field.get('label'), + desc=field.get('desc'), + required=field.get('required'), + value=field.get('value'), + options=field.get('options'), + type=field.get('type')) + + def set_instructions(self, instructions): + del self['instructions'] + if instructions in [None, '']: + return + if not isinstance(instructions, list): + 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): + """ + This either needs a dictionary of dictionaries or a dictionary of form fields. + :param reported: + :return: + """ + for var in reported: + field = reported[var] + + if isinstance(field, dict): + self.add_reported(**field) + else: + 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) + new_field = FormField(xml=fieldXML) + new_field.values = field.values + + def set_values(self, values): + fields = self.get_fields() + for field in values: + if field not in self.get_fields(): + fields[field] = self.add_field(var=field) + self.get_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.addField = Form.add_field +Form.addReported = Form.add_reported +Form.delFields = Form.del_fields +Form.delInstructions = Form.del_instructions +Form.delReported = Form.del_reported +Form.getFields = Form.get_fields +Form.getInstructions = Form.get_instructions +Form.getReported = Form.get_reported +Form.getValues = Form.get_values +Form.setFields = Form.set_fields +Form.setInstructions = Form.set_instructions +Form.setReported = Form.set_reported +Form.setValues = Form.set_values diff --git a/slixmpp/plugins/xep_0009/__init__.py b/slixmpp/plugins/xep_0009/__init__.py new file mode 100644 index 00000000..a06306b2 --- /dev/null +++ b/slixmpp/plugins/xep_0009/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0009 import stanza +from slixmpp.plugins.xep_0009.rpc import XEP_0009 +from slixmpp.plugins.xep_0009.stanza import RPCQuery, MethodCall, MethodResponse + + +register_plugin(XEP_0009) + + +# Retain some backwards compatibility +xep_0009 = XEP_0009 diff --git a/slixmpp/plugins/xep_0009/binding.py b/slixmpp/plugins/xep_0009/binding.py new file mode 100644 index 00000000..d922dfc7 --- /dev/null +++ b/slixmpp/plugins/xep_0009/binding.py @@ -0,0 +1,169 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import 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/slixmpp/plugins/xep_0009/remote.py b/slixmpp/plugins/xep_0009/remote.py new file mode 100644 index 00000000..9675c88d --- /dev/null +++ b/slixmpp/plugins/xep_0009/remote.py @@ -0,0 +1,772 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.xep_0009.binding import py2xml, xml2py, xml2fault, fault2xml +from threading import RLock +import abc +import inspect +import logging +import slixmpp +import sys +import threading +import traceback + +log = logging.getLogger(__name__) + +def _isstr(obj): + return isinstance(obj, str) + + +# Class decorator to declare a metaclass to a class in a way compatible with Python 2 and 3. +# This decorator is copied from 'six' (https://bitbucket.org/gutworth/six): +# +# Copyright (c) 2010-2015 Benjamin Peterson +# +# 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. +def _add_metaclass(metaclass): + def wrapper(cls): + orig_vars = cls.__dict__.copy() + slots = orig_vars.get('__slots__') + if slots is not None: + if isinstance(slots, str): + slots = [slots] + for slots_var in slots: + orig_vars.pop(slots_var) + orig_vars.pop('__dict__', None) + orig_vars.pop('__weakref__', None) + return metaclass(cls.__name__, cls.__bases__, orig_vars) + return wrapper + +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 _isstr(function_argument): + 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 + + +@_add_metaclass(abc.ABCMeta) +class Callback(object): + ''' + A base class for callback handlers. + ''' + + @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() + + +@_add_metaclass(abc.ABCMeta) +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>'. + ''' + + 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 Slixmpp 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.send_presence() + 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.items() 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.items(): + #!!! 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, wait=False): + ''' + Closes this session. + ''' + self._client.disconnect(wait=wait) + 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 = slixmpp.ClientXMPP(jid, password) + #? Register plug-ins. + client.register_plugin('xep_0004') # Data Forms + client.register_plugin('xep_0009') # Jabber-RPC + client.register_plugin('xep_0030') # Service Discovery + client.register_plugin('xep_0060') # PubSub + client.register_plugin('xep_0199') # XMPP Ping + return cls.new_session_with_client(client, callback) + diff --git a/slixmpp/plugins/xep_0009/rpc.py b/slixmpp/plugins/xep_0009/rpc.py new file mode 100644 index 00000000..3ce156cf --- /dev/null +++ b/slixmpp/plugins/xep_0009/rpc.py @@ -0,0 +1,223 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq +from slixmpp.xmlstream import ET, register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0009 import stanza +from slixmpp.plugins.xep_0009.stanza.RPC import RPCQuery, MethodCall, MethodResponse + + +log = logging.getLogger(__name__) + + +class XEP_0009(BasePlugin): + + name = 'xep_0009' + description = 'XEP-0009: Jabber-RPC' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, RPCQuery) + register_stanza_plugin(RPCQuery, MethodCall) + register_stanza_plugin(RPCQuery, MethodResponse) + + self.xmpp.register_handler( + Callback('RPC Call', MatchXPath('{%s}iq/{%s}query/{%s}methodCall' % (self.xmpp.default_ns, RPCQuery.namespace, RPCQuery.namespace)), + self._handle_method_call) + ) + self.xmpp.register_handler( + Callback('RPC Call', MatchXPath('{%s}iq/{%s}query/{%s}methodResponse' % (self.xmpp.default_ns, RPCQuery.namespace, RPCQuery.namespace)), + self._handle_method_response) + ) + self.xmpp.register_handler( + 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 = [] + + self.xmpp['xep_0030'].add_feature('jabber:iq:rpc') + self.xmpp['xep_0030'].add_identity('automation','rpc') + + def make_iq_method_call(self, pto, pmethod, params): + iq = self.xmpp.make_iq_set() + 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.make_iq_result(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.make_iq_result(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.make_iq_error(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 = iq.reply() + iq.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 = iq.reply() + iq.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 = iq.reply() + iq.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 = 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/slixmpp/plugins/xep_0009/stanza/RPC.py b/slixmpp/plugins/xep_0009/stanza/RPC.py new file mode 100644 index 00000000..3abab8fc --- /dev/null +++ b/slixmpp/plugins/xep_0009/stanza/RPC.py @@ -0,0 +1,64 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/plugins/xep_0009/stanza/__init__.py b/slixmpp/plugins/xep_0009/stanza/__init__.py new file mode 100644 index 00000000..79a8a3d7 --- /dev/null +++ b/slixmpp/plugins/xep_0009/stanza/__init__.py @@ -0,0 +1,9 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dann Martens (TOMOTON). + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.xep_0009.stanza.RPC import RPCQuery, MethodCall, MethodResponse diff --git a/slixmpp/plugins/xep_0012/__init__.py b/slixmpp/plugins/xep_0012/__init__.py new file mode 100644 index 00000000..3dd39987 --- /dev/null +++ b/slixmpp/plugins/xep_0012/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0012.stanza import LastActivity +from slixmpp.plugins.xep_0012.last_activity import XEP_0012 + + +register_plugin(XEP_0012) + + +# Retain some backwards compatibility +xep_0004 = XEP_0012 diff --git a/slixmpp/plugins/xep_0012/last_activity.py b/slixmpp/plugins/xep_0012/last_activity.py new file mode 100644 index 00000000..6a7773c1 --- /dev/null +++ b/slixmpp/plugins/xep_0012/last_activity.py @@ -0,0 +1,156 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +from datetime import datetime, timedelta + +from slixmpp.plugins import BasePlugin, register_plugin +from slixmpp import future_wrapper, Iq +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream import JID, register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.xep_0012 import stanza, LastActivity + + +log = logging.getLogger(__name__) + + +class XEP_0012(BasePlugin): + + """ + XEP-0012 Last Activity + """ + + name = 'xep_0012' + description = 'XEP-0012: Last Activity' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, LastActivity) + + self._last_activities = {} + + self.xmpp.register_handler( + Callback('Last Activity', + StanzaPath('iq@type=get/last_activity'), + self._handle_get_last_activity)) + + self.api.register(self._default_get_last_activity, + 'get_last_activity', + default=True) + self.api.register(self._default_set_last_activity, + 'set_last_activity', + default=True) + self.api.register(self._default_del_last_activity, + 'del_last_activity', + default=True) + + def plugin_end(self): + self.xmpp.remove_handler('Last Activity') + self.xmpp['xep_0030'].del_feature(feature='jabber:iq:last') + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('jabber:iq:last') + + def begin_idle(self, jid=None, status=None): + self.set_last_activity(jid, 0, status) + + def end_idle(self, jid=None): + self.del_last_activity(jid) + + def start_uptime(self, status=None): + self.set_last_activity(jid, 0, status) + + def set_last_activity(self, jid=None, seconds=None, status=None): + self.api['set_last_activity'](jid, args={ + 'seconds': seconds, + 'status': status}) + + def del_last_activity(self, jid): + self.api['del_last_activity'](jid) + + @future_wrapper + def get_last_activity(self, jid, local=False, ifrom=None, timeout=None, + callback=None, timeout_callback=None): + 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 last activity data for %s", jid) + return self.api['get_last_activity'](jid, None, ifrom, None) + + iq = self.xmpp.Iq() + iq['from'] = ifrom + iq['to'] = jid + iq['type'] = 'get' + iq.enable('last_activity') + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def _handle_get_last_activity(self, iq): + log.debug("Received last activity query from " + \ + "<%s> to <%s>.", iq['from'], iq['to']) + reply = self.api['get_last_activity'](iq['to'], None, iq['from'], iq) + reply.send() + + # ================================================================= + # Default in-memory implementations for storing last activity data. + # ================================================================= + + def _default_set_last_activity(self, jid, node, ifrom, data): + seconds = data.get('seconds', None) + if seconds is None: + seconds = 0 + + status = data.get('status', None) + if status is None: + status = '' + + self._last_activities[jid] = { + 'seconds': datetime.now() - timedelta(seconds=seconds), + 'status': status} + + def _default_del_last_activity(self, jid, node, ifrom, data): + if jid in self._last_activities: + del self._last_activities[jid] + + def _default_get_last_activity(self, jid, node, ifrom, iq): + if not isinstance(iq, Iq): + reply = self.xmpp.Iq() + else: + reply = iq.reply() + + if jid not in self._last_activities: + raise XMPPError('service-unavailable') + + bare = JID(jid).bare + + if bare != self.xmpp.boundjid.bare: + if bare in self.xmpp.roster[jid]: + sub = self.xmpp.roster[jid][bare]['subscription'] + if sub not in ('from', 'both'): + raise XMPPError('forbidden') + + td = datetime.now() - self._last_activities[jid]['seconds'] + seconds = td.seconds + td.days * 24 * 3600 + status = self._last_activities[jid]['status'] + + reply['last_activity']['seconds'] = seconds + reply['last_activity']['status'] = status + + return reply diff --git a/slixmpp/plugins/xep_0012/stanza.py b/slixmpp/plugins/xep_0012/stanza.py new file mode 100644 index 00000000..bd539a2d --- /dev/null +++ b/slixmpp/plugins/xep_0012/stanza.py @@ -0,0 +1,32 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +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 = '' diff --git a/slixmpp/plugins/xep_0013/__init__.py b/slixmpp/plugins/xep_0013/__init__.py new file mode 100644 index 00000000..c02a6c5a --- /dev/null +++ b/slixmpp/plugins/xep_0013/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0013.stanza import Offline +from slixmpp.plugins.xep_0013.offline import XEP_0013 + + +register_plugin(XEP_0013) diff --git a/slixmpp/plugins/xep_0013/offline.py b/slixmpp/plugins/xep_0013/offline.py new file mode 100644 index 00000000..51840e7b --- /dev/null +++ b/slixmpp/plugins/xep_0013/offline.py @@ -0,0 +1,124 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import logging + +import slixmpp +from slixmpp.stanza import Message, Iq +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream.handler import Collector +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0013 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0013(BasePlugin): + + """ + XEP-0013 Flexible Offline Message Retrieval + """ + + name = 'xep_0013' + description = 'XEP-0013: Flexible Offline Message Retrieval' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, stanza.Offline) + register_stanza_plugin(Message, stanza.Offline) + + def get_count(self, **kwargs): + return self.xmpp['xep_0030'].get_info( + node='http://jabber.org/protocol/offline', + local=False, + **kwargs) + + def get_headers(self, **kwargs): + return self.xmpp['xep_0030'].get_items( + node='http://jabber.org/protocol/offline', + local=False, + **kwargs) + + def view(self, nodes, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + if not isinstance(nodes, (list, set)): + nodes = [nodes] + + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['from'] = ifrom + offline = iq['offline'] + for node in nodes: + item = stanza.Item() + item['node'] = node + item['action'] = 'view' + offline.append(item) + + collector = Collector( + 'Offline_Results_%s' % iq['id'], + StanzaPath('message/offline')) + self.xmpp.register_handler(collector) + + def wrapped_cb(iq): + results = collector.stop() + if iq['type'] == 'result': + iq['offline']['results'] = results + callback(iq) + iq.send(timeout=timeout, callback=wrapped_cb, + timeout_callback=timeout_callback) + + def remove(self, nodes, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + if not isinstance(nodes, (list, set)): + nodes = [nodes] + + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + offline = iq['offline'] + for node in nodes: + item = stanza.Item() + item['node'] = node + item['action'] = 'remove' + offline.append(item) + + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def fetch(self, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq['offline']['fetch'] = True + + collector = Collector( + 'Offline_Results_%s' % iq['id'], + StanzaPath('message/offline')) + self.xmpp.register_handler(collector) + + def wrapped_cb(iq): + results = collector.stop() + if iq['type'] == 'result': + iq['offline']['results'] = results + callback(iq) + iq.send(timeout=timeout, callback=wrapped_cb, + timeout_callback=timeout_callback) + + def purge(self, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq['offline']['purge'] = True + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0013/stanza.py b/slixmpp/plugins/xep_0013/stanza.py new file mode 100644 index 00000000..cf9c385b --- /dev/null +++ b/slixmpp/plugins/xep_0013/stanza.py @@ -0,0 +1,53 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.jid import JID +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + + +class Offline(ElementBase): + name = 'offline' + namespace = 'http://jabber.org/protocol/offline' + plugin_attrib = 'offline' + interfaces = set(['fetch', 'purge', 'results']) + bool_interfaces = interfaces + + def setup(self, xml=None): + ElementBase.setup(self, xml) + self._results = [] + + # The results interface is meant only as an easy + # way to access the set of collected message responses + # from the query. + + def get_results(self): + return self._results + + def set_results(self, values): + self._results = values + + def del_results(self): + self._results = [] + + +class Item(ElementBase): + name = 'item' + namespace = 'http://jabber.org/protocol/offline' + plugin_attrib = 'item' + interfaces = set(['action', 'node', 'jid']) + + actions = set(['view', 'remove']) + + def get_jid(self): + return JID(self._get_attr('jid')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + +register_stanza_plugin(Offline, Item, iterable=True) diff --git a/slixmpp/plugins/xep_0016/__init__.py b/slixmpp/plugins/xep_0016/__init__.py new file mode 100644 index 00000000..aa95c78d --- /dev/null +++ b/slixmpp/plugins/xep_0016/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0016 import stanza +from slixmpp.plugins.xep_0016.stanza import Privacy +from slixmpp.plugins.xep_0016.privacy import XEP_0016 + + +register_plugin(XEP_0016) diff --git a/slixmpp/plugins/xep_0016/privacy.py b/slixmpp/plugins/xep_0016/privacy.py new file mode 100644 index 00000000..38444b2b --- /dev/null +++ b/slixmpp/plugins/xep_0016/privacy.py @@ -0,0 +1,127 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp import Iq +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0016 import stanza +from slixmpp.plugins.xep_0016.stanza import Privacy, Item + + +class XEP_0016(BasePlugin): + + name = 'xep_0016' + description = 'XEP-0016: Privacy Lists' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, Privacy) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=Privacy.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(Privacy.namespace) + + def get_privacy_lists(self, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq.enable('privacy') + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def get_list(self, name, timeout=None, callback=None, timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['privacy']['list']['name'] = name + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def get_active(self, timeout=None, callback=None, timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['privacy'].enable('active') + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def get_default(self, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['privacy'].enable('default') + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def activate(self, name, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['privacy']['active']['name'] = name + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def deactivate(self, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['privacy'].enable('active') + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def make_default(self, name, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['privacy']['default']['name'] = name + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def remove_default(self, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['privacy'].enable('default') + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def edit_list(self, name, rules, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['privacy']['list']['name'] = name + priv_list = iq['privacy']['list'] + + if not rules: + rules = [] + + for rule in rules: + if isinstance(rule, Item): + priv_list.append(rule) + continue + + priv_list.add_item( + rule['value'], + rule['action'], + rule['order'], + itype=rule.get('type', None), + iq=rule.get('iq', None), + message=rule.get('message', None), + presence_in=rule.get('presence_in', + rule.get('presence-in', None)), + presence_out=rule.get('presence_out', + rule.get('presence-out', None))) + + def remove_list(self, name, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['privacy']['list']['name'] = name + iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0016/stanza.py b/slixmpp/plugins/xep_0016/stanza.py new file mode 100644 index 00000000..58c9fdb3 --- /dev/null +++ b/slixmpp/plugins/xep_0016/stanza.py @@ -0,0 +1,103 @@ +from slixmpp.xmlstream import ET, ElementBase, register_stanza_plugin + + +class Privacy(ElementBase): + name = 'query' + namespace = 'jabber:iq:privacy' + plugin_attrib = 'privacy' + interfaces = set() + + def add_list(self, name): + priv_list = List() + priv_list['name'] = name + self.append(priv_list) + return priv_list + + +class Active(ElementBase): + name = 'active' + namespace = 'jabber:iq:privacy' + plugin_attrib = name + interfaces = set(['name']) + + +class Default(ElementBase): + name = 'default' + namespace = 'jabber:iq:privacy' + plugin_attrib = name + interfaces = set(['name']) + + +class List(ElementBase): + name = 'list' + namespace = 'jabber:iq:privacy' + plugin_attrib = name + plugin_multi_attrib = 'lists' + interfaces = set(['name']) + + def add_item(self, value, action, order, itype=None, iq=False, + message=False, presence_in=False, presence_out=False): + item = Item() + item.values = {'type': itype, + 'value': value, + 'action': action, + 'order': order, + 'message': message, + 'iq': iq, + 'presence_in': presence_in, + 'presence_out': presence_out} + self.append(item) + return item + + +class Item(ElementBase): + name = 'item' + namespace = 'jabber:iq:privacy' + plugin_attrib = name + plugin_multi_attrib = 'items' + interfaces = set(['type', 'value', 'action', 'order', 'iq', + 'message', 'presence_in', 'presence_out']) + bool_interfaces = set(['message', 'iq', 'presence_in', 'presence_out']) + + type_values = ('', 'jid', 'group', 'subscription') + action_values = ('allow', 'deny') + + def set_type(self, value): + if value and value not in self.type_values: + raise ValueError('Unknown type value: %s' % value) + else: + self._set_attr('type', value) + + def set_action(self, value): + if value not in self.action_values: + raise ValueError('Unknown action value: %s' % value) + else: + self._set_attr('action', value) + + def set_presence_in(self, value): + keep = True if value else False + self._set_sub_text('presence-in', '', keep=keep) + + def get_presence_in(self): + pres = self.xml.find('{%s}presence-in' % self.namespace) + return pres is not None + + def del_presence_in(self): + self._del_sub('{%s}presence-in' % self.namespace) + + def set_presence_out(self, value): + keep = True if value else False + self._set_sub_text('presence-in', '', keep=keep) + + def get_presence_out(self): + pres = self.xml.find('{%s}presence-in' % self.namespace) + return pres is not None + + def del_presence_out(self): + self._del_sub('{%s}presence-in' % self.namespace) + + +register_stanza_plugin(Privacy, Active) +register_stanza_plugin(Privacy, Default) +register_stanza_plugin(Privacy, List, iterable=True) +register_stanza_plugin(List, Item, iterable=True) diff --git a/slixmpp/plugins/xep_0020/__init__.py b/slixmpp/plugins/xep_0020/__init__.py new file mode 100644 index 00000000..53d96fe4 --- /dev/null +++ b/slixmpp/plugins/xep_0020/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0020 import stanza +from slixmpp.plugins.xep_0020.stanza import FeatureNegotiation +from slixmpp.plugins.xep_0020.feature_negotiation import XEP_0020 + + +register_plugin(XEP_0020) diff --git a/slixmpp/plugins/xep_0020/feature_negotiation.py b/slixmpp/plugins/xep_0020/feature_negotiation.py new file mode 100644 index 00000000..2f4e8c15 --- /dev/null +++ b/slixmpp/plugins/xep_0020/feature_negotiation.py @@ -0,0 +1,36 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq, Message +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.plugins.xep_0020 import stanza, FeatureNegotiation +from slixmpp.plugins.xep_0004 import Form + + +log = logging.getLogger(__name__) + + +class XEP_0020(BasePlugin): + + name = 'xep_0020' + description = 'XEP-0020: Feature Negotiation' + dependencies = set(['xep_0004', 'xep_0030']) + stanza = stanza + + def plugin_init(self): + self.xmpp['xep_0030'].add_feature(FeatureNegotiation.namespace) + + register_stanza_plugin(FeatureNegotiation, Form) + + register_stanza_plugin(Iq, FeatureNegotiation) + register_stanza_plugin(Message, FeatureNegotiation) diff --git a/slixmpp/plugins/xep_0020/stanza.py b/slixmpp/plugins/xep_0020/stanza.py new file mode 100644 index 00000000..d9cea8f4 --- /dev/null +++ b/slixmpp/plugins/xep_0020/stanza.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class FeatureNegotiation(ElementBase): + + name = 'feature' + namespace = 'http://jabber.org/protocol/feature-neg' + plugin_attrib = 'feature_neg' + interfaces = set() diff --git a/slixmpp/plugins/xep_0027/__init__.py b/slixmpp/plugins/xep_0027/__init__.py new file mode 100644 index 00000000..1f510cb2 --- /dev/null +++ b/slixmpp/plugins/xep_0027/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0027.stanza import Signed, Encrypted +from slixmpp.plugins.xep_0027.gpg import XEP_0027 + + +register_plugin(XEP_0027) diff --git a/slixmpp/plugins/xep_0027/gpg.py b/slixmpp/plugins/xep_0027/gpg.py new file mode 100644 index 00000000..a0b1df48 --- /dev/null +++ b/slixmpp/plugins/xep_0027/gpg.py @@ -0,0 +1,169 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.thirdparty import GPG + +from slixmpp.stanza import Presence, Message +from slixmpp.plugins.base import BasePlugin, register_plugin +from slixmpp.xmlstream import ElementBase, register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.xep_0027 import stanza, Signed, Encrypted + + +def _extract_data(data, kind): + stripped = [] + begin_headers = False + begin_data = False + for line in data.split('\n'): + if not begin_headers and 'BEGIN PGP %s' % kind in line: + begin_headers = True + continue + if begin_headers and line.strip() == '': + begin_data = True + continue + if 'END PGP %s' % kind in line: + return '\n'.join(stripped) + if begin_data: + stripped.append(line) + return '' + + +class XEP_0027(BasePlugin): + + name = 'xep_0027' + description = 'XEP-0027: Current Jabber OpenPGP Usage' + dependencies = set() + stanza = stanza + default_config = { + 'gpg_binary': 'gpg', + 'gpg_home': '', + 'use_agent': True, + 'keyring': None, + 'key_server': 'pgp.mit.edu' + } + + def plugin_init(self): + self.gpg = GPG(gnupghome=self.gpg_home, + gpgbinary=self.gpg_binary, + use_agent=self.use_agent, + keyring=self.keyring) + + self.xmpp.add_filter('out', self._sign_presence) + + self._keyids = {} + + self.api.register(self._set_keyid, 'set_keyid', default=True) + self.api.register(self._get_keyid, 'get_keyid', default=True) + self.api.register(self._del_keyid, 'del_keyid', default=True) + self.api.register(self._get_keyids, 'get_keyids', default=True) + + register_stanza_plugin(Presence, Signed) + register_stanza_plugin(Message, Encrypted) + + self.xmpp.add_event_handler('unverified_signed_presence', + self._handle_unverified_signed_presence) + + self.xmpp.register_handler( + Callback('Signed Presence', + StanzaPath('presence/signed'), + self._handle_signed_presence)) + + self.xmpp.register_handler( + Callback('Encrypted Message', + StanzaPath('message/encrypted'), + self._handle_encrypted_message)) + + def plugin_end(self): + self.xmpp.remove_handler('Encrypted Message') + self.xmpp.remove_handler('Signed Presence') + self.xmpp.del_filter('out', self._sign_presence) + self.xmpp.del_event_handler('unverified_signed_presence', + self._handle_unverified_signed_presence) + + def _sign_presence(self, stanza): + if isinstance(stanza, Presence): + if stanza['type'] == 'available' or \ + stanza['type'] in Presence.showtypes: + stanza['signed'] = stanza['status'] + return stanza + + def sign(self, data, jid=None): + keyid = self.get_keyid(jid) + if keyid: + signed = self.gpg.sign(data, keyid=keyid) + return _extract_data(signed.data, 'SIGNATURE') + + def encrypt(self, data, jid=None): + keyid = self.get_keyid(jid) + if keyid: + enc = self.gpg.encrypt(data, keyid) + return _extract_data(enc.data, 'MESSAGE') + + def decrypt(self, data, jid=None): + template = '-----BEGIN PGP MESSAGE-----\n' + \ + '\n' + \ + '%s\n' + \ + '-----END PGP MESSAGE-----\n' + dec = self.gpg.decrypt(template % data) + return dec.data + + def verify(self, data, sig, jid=None): + template = '-----BEGIN PGP SIGNED MESSAGE-----\n' + \ + 'Hash: SHA1\n' + \ + '\n' + \ + '%s\n' + \ + '-----BEGIN PGP SIGNATURE-----\n' + \ + '\n' + \ + '%s\n' + \ + '-----END PGP SIGNATURE-----\n' + v = self.gpg.verify(template % (data, sig)) + return v + + def set_keyid(self, jid=None, keyid=None): + self.api['set_keyid'](jid, args=keyid) + + def get_keyid(self, jid=None): + return self.api['get_keyid'](jid) + + def del_keyid(self, jid=None): + self.api['del_keyid'](jid) + + def get_keyids(self): + return self.api['get_keyids']() + + def _handle_signed_presence(self, pres): + self.xmpp.event('unverified_signed_presence', pres) + + def _handle_unverified_signed_presence(self, pres): + verified = self.verify(pres['status'], pres['signed']) + if verified.key_id: + if not self.get_keyid(pres['from']): + known_keyids = [e['keyid'] for e in self.gpg.list_keys()] + if verified.key_id not in known_keyids: + self.gpg.recv_keys(self.key_server, verified.key_id) + self.set_keyid(jid=pres['from'], keyid=verified.key_id) + self.xmpp.event('signed_presence', pres) + + def _handle_encrypted_message(self, msg): + self.xmpp.event('encrypted_message', msg) + + # ================================================================= + + def _set_keyid(self, jid, node, ifrom, keyid): + self._keyids[jid] = keyid + + def _get_keyid(self, jid, node, ifrom, keyid): + return self._keyids.get(jid, None) + + def _del_keyid(self, jid, node, ifrom, keyid): + if jid in self._keyids: + del self._keyids[jid] + + def _get_keyids(self, jid, node, ifrom, data): + return self._keyids diff --git a/slixmpp/plugins/xep_0027/stanza.py b/slixmpp/plugins/xep_0027/stanza.py new file mode 100644 index 00000000..fd41cace --- /dev/null +++ b/slixmpp/plugins/xep_0027/stanza.py @@ -0,0 +1,53 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class Signed(ElementBase): + name = 'x' + namespace = 'jabber:x:signed' + plugin_attrib = 'signed' + interfaces = set(['signed']) + is_extension = True + + def set_signed(self, value): + parent = self.parent() + xmpp = parent.stream + data = xmpp['xep_0027'].sign(value, parent['from']) + if data: + self.xml.text = data + else: + del parent['signed'] + + def get_signed(self): + return self.xml.text + + +class Encrypted(ElementBase): + name = 'x' + namespace = 'jabber:x:encrypted' + plugin_attrib = 'encrypted' + interfaces = set(['encrypted']) + is_extension = True + + def set_encrypted(self, value): + parent = self.parent() + xmpp = parent.stream + data = xmpp['xep_0027'].encrypt(value, parent['to']) + if data: + self.xml.text = data + else: + del parent['encrypted'] + + def get_encrypted(self): + parent = self.parent() + xmpp = parent.stream + if self.xml.text: + return xmpp['xep_0027'].decrypt(self.xml.text, parent['to']) + return None diff --git a/slixmpp/plugins/xep_0030/__init__.py b/slixmpp/plugins/xep_0030/__init__.py new file mode 100644 index 00000000..1fc71069 --- /dev/null +++ b/slixmpp/plugins/xep_0030/__init__.py @@ -0,0 +1,22 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0030 import stanza +from slixmpp.plugins.xep_0030.stanza import DiscoInfo, DiscoItems +from slixmpp.plugins.xep_0030.static import StaticDisco +from slixmpp.plugins.xep_0030.disco import XEP_0030 + + +register_plugin(XEP_0030) + +# Retain some backwards compatibility +xep_0030 = XEP_0030 +XEP_0030.getInfo = XEP_0030.get_info +XEP_0030.make_static = XEP_0030.restore_defaults diff --git a/slixmpp/plugins/xep_0030/disco.py b/slixmpp/plugins/xep_0030/disco.py new file mode 100644 index 00000000..e6286b92 --- /dev/null +++ b/slixmpp/plugins/xep_0030/disco.py @@ -0,0 +1,748 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq +from slixmpp import future_wrapper +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.plugins.xep_0030 import stanza, DiscoInfo, DiscoItems +from slixmpp.plugins.xep_0030 import StaticDisco + + +log = logging.getLogger(__name__) + + +class XEP_0030(BasePlugin): + + """ + 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 Slixmpp 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 -- + """ + + name = 'xep_0030' + description = 'XEP-0030: Service Discovery' + dependencies = set() + stanza = stanza + default_config = { + 'use_cache': True, + 'wrap_results': False + } + + def plugin_init(self): + """ + Start the XEP-0030 plugin. + """ + 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._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'] + + for op in self._disco_ops: + self.api.register(getattr(self.static, op), op, default=True) + + def session_bind(self, jid): + self.add_feature('http://jabber.org/protocol/disco#info') + + def plugin_end(self): + self.del_feature('http://jabber.org/protocol/disco#info') + + def _add_disco_op(self, op, default_handler): + self.api.register(default_handler, op) + self.api.register_default(default_handler, op) + + 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. + """ + self.api.register(handler, htype, jid, node) + + 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.api.unregister(htype, jid, node) + + 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.api.restore_default(op, jid, node) + + 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 Slixmpp 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.api['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 Slixmpp 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.api['has_identity'](jid, node, ifrom, data) + + @future_wrapper + def get_info(self, jid=None, node=None, local=None, + 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 Slixmpp 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. + timeout -- The time in seconds to wait for reply, before + calling timeout_callback + callback -- Optional callback to execute when a reply is + received instead of blocking and waiting for + the reply. + timeout_callback -- Optional callback to execute when no result + has been received in timeout seconds. + """ + if local is None: + 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 + elif jid in (None, ''): + local = True + + if local: + log.debug("Looking up local disco#info data " + \ + "for %s, node %s.", jid, node) + info = self.api['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.api['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), + callback=kwargs.get('callback', None), + timeout_callback=kwargs.get('timeout_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.api['set_info'](jid, node, None, info) + + @future_wrapper + 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 Slixmpp 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. + 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. + timeout_callback -- Optional callback to execute when no result + has been received in timeout seconds. + """ + if local or local is None and jid is None: + items = self.api['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']: + raise NotImplementedError("XEP 0059 has not yet been fixed") + return self.xmpp['xep_0059'].iterate(iq, 'disco_items') + else: + return iq.send(timeout=kwargs.get('timeout', None), + callback=kwargs.get('callback', None), + timeout_callback=kwargs.get('timeout_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.api['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.api['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.api['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.api['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.api['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.api['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.api['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.api['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.api['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.api['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.api['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.api['del_features'](jid, node, None, kwargs) + + def _run_node_handler(self, htype, jid, node=None, ifrom=None, data=None): + """ + 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 not data: + data = {} + + return self.api[htype](jid, node, ifrom, data) + + 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']) + info = self.api['get_info'](iq['to'], + iq['disco_info']['node'], + iq['from'], + iq) + if isinstance(info, Iq): + info['id'] = iq['id'] + info.send() + else: + iq = 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.api['cache_info'](iq['from'], + 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']) + items = self.api['get_items'](iq['to'], + iq['disco_items']['node'], + iq['from'], + iq) + if isinstance(items, Iq): + items.send() + else: + iq = 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, Slixmpp 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 = info['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 diff --git a/slixmpp/plugins/xep_0030/stanza/__init__.py b/slixmpp/plugins/xep_0030/stanza/__init__.py new file mode 100644 index 00000000..bdac9bf2 --- /dev/null +++ b/slixmpp/plugins/xep_0030/stanza/__init__.py @@ -0,0 +1,10 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.xep_0030.stanza.info import DiscoInfo +from slixmpp.plugins.xep_0030.stanza.items import DiscoItems diff --git a/slixmpp/plugins/xep_0030/stanza/info.py b/slixmpp/plugins/xep_0030/stanza/info.py new file mode 100644 index 00000000..39ee83d5 --- /dev/null +++ b/slixmpp/plugins/xep_0030/stanza/info.py @@ -0,0 +1,276 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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="Slixmpp 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.insert(0, 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/slixmpp/plugins/xep_0030/stanza/items.py b/slixmpp/plugins/xep_0030/stanza/items.py new file mode 100644 index 00000000..0e238492 --- /dev/null +++ b/slixmpp/plugins/xep_0030/stanza/items.py @@ -0,0 +1,152 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + + +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="slixdev" + name="Slixmpp 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 = DiscoItem(parent=self) + item['jid'] = jid + item['node'] = node + item['name'] = name + self.iterables.append(item) + 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 in self['substanzas']: + if isinstance(item, DiscoItem): + items.add((item['jid'], item['node'], item['name'])) + 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() + items = [i for i in self.iterables if isinstance(i, DiscoItem)] + for item in items: + self.xml.remove(item.xml) + self.iterables.remove(item) + + +class DiscoItem(ElementBase): + name = 'item' + namespace = 'http://jabber.org/protocol/disco#items' + plugin_attrib = name + interfaces = set(('jid', 'node', 'name')) + + def get_node(self): + """Return the item's node name or ``None``.""" + return self._get_attr('node', None) + + def get_name(self): + """Return the item's human readable name, or ``None``.""" + return self._get_attr('name', None) + + +register_stanza_plugin(DiscoItems, DiscoItem, iterable=True) diff --git a/slixmpp/plugins/xep_0030/static.py b/slixmpp/plugins/xep_0030/static.py new file mode 100644 index 00000000..d9a7c80a --- /dev/null +++ b/slixmpp/plugins/xep_0030/static.py @@ -0,0 +1,430 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import threading + +from slixmpp import Iq +from slixmpp.exceptions import XMPPError, IqError, IqTimeout +from slixmpp.xmlstream import JID +from slixmpp.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 Slixmpp 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 Slixmpp 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 Slixmpp 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 Slixmpp 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)} + + 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 DiscoItems() + 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 not self.node_exists(jid, node, ifrom): + return None + else: + return self.get_node(jid, node, ifrom)['info'] diff --git a/slixmpp/plugins/xep_0033/__init__.py b/slixmpp/plugins/xep_0033/__init__.py new file mode 100644 index 00000000..e6953ce7 --- /dev/null +++ b/slixmpp/plugins/xep_0033/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0033 import stanza +from slixmpp.plugins.xep_0033.stanza import Addresses, Address +from slixmpp.plugins.xep_0033.addresses import XEP_0033 + + +register_plugin(XEP_0033) + +# Retain some backwards compatibility +xep_0033 = XEP_0033 +Addresses.addAddress = Addresses.add_address diff --git a/slixmpp/plugins/xep_0033/addresses.py b/slixmpp/plugins/xep_0033/addresses.py new file mode 100644 index 00000000..7b3c2d17 --- /dev/null +++ b/slixmpp/plugins/xep_0033/addresses.py @@ -0,0 +1,37 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Message, Presence +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0033 import stanza, Addresses + + +class XEP_0033(BasePlugin): + + """ + XEP-0033: Extended Stanza Addressing + """ + + name = 'xep_0033' + description = 'XEP-0033: Extended Stanza Addressing' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, Addresses) + register_stanza_plugin(Presence, Addresses) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=Addresses.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(Addresses.namespace) + diff --git a/slixmpp/plugins/xep_0033/stanza.py b/slixmpp/plugins/xep_0033/stanza.py new file mode 100644 index 00000000..3f89f373 --- /dev/null +++ b/slixmpp/plugins/xep_0033/stanza.py @@ -0,0 +1,131 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import JID, ElementBase, ET, register_stanza_plugin + + +class Addresses(ElementBase): + + name = 'addresses' + namespace = 'http://jabber.org/protocol/address' + plugin_attrib = 'addresses' + interfaces = set() + + def add_address(self, atype='to', jid='', node='', uri='', + desc='', delivered=False): + addr = Address(parent=self) + addr['type'] = atype + addr['jid'] = jid + addr['node'] = node + addr['uri'] = uri + addr['desc'] = desc + addr['delivered'] = delivered + + return addr + + # Additional methods for manipulating sets of addresses + # based on type are generated below. + + +class Address(ElementBase): + + name = 'address' + namespace = 'http://jabber.org/protocol/address' + plugin_attrib = 'address' + interfaces = set(['type', 'jid', 'node', 'uri', 'desc', 'delivered']) + + address_types = set(('bcc', 'cc', 'noreply', 'replyroom', 'replyto', 'to')) + + def get_jid(self): + return JID(self._get_attr('jid')) + + def set_jid(self, value): + self._set_attr('jid', str(value)) + + def get_delivered(self): + value = self._get_attr('delivered', False) + return value and value.lower() in ('true', '1') + + def set_delivered(self, delivered): + if delivered: + self._set_attr('delivered', 'true') + else: + del self['delivered'] + + def set_uri(self, uri): + if uri: + del self['jid'] + del self['node'] + self._set_attr('uri', uri) + else: + self._del_attr('uri') + + +# ===================================================================== +# Auto-generate address type filters for the Addresses class. + +def _addr_filter(atype): + def _type_filter(addr): + if isinstance(addr, Address): + if atype == 'all' or addr['type'] == atype: + return True + return False + return _type_filter + + +def _build_methods(atype): + + def get_multi(self): + return list(filter(_addr_filter(atype), self)) + + def set_multi(self, value): + del self[atype] + for addr in value: + + # Support assigning dictionary versions of addresses + # instead of full Address objects. + if not isinstance(addr, Address): + if atype != 'all': + addr['type'] = atype + elif 'atype' in addr and 'type' not in addr: + addr['type'] = addr['atype'] + addrObj = Address() + addrObj.values = addr + addr = addrObj + + self.append(addr) + + def del_multi(self): + res = list(filter(_addr_filter(atype), self)) + for addr in res: + self.iterables.remove(addr) + self.xml.remove(addr.xml) + + return get_multi, set_multi, del_multi + + +for atype in ('all', 'bcc', 'cc', 'noreply', 'replyroom', 'replyto', 'to'): + get_multi, set_multi, del_multi = _build_methods(atype) + + Addresses.interfaces.add(atype) + setattr(Addresses, "get_%s" % atype, get_multi) + setattr(Addresses, "set_%s" % atype, set_multi) + setattr(Addresses, "del_%s" % atype, del_multi) + + # To retain backwards compatibility: + setattr(Addresses, "get%s" % atype.title(), get_multi) + setattr(Addresses, "set%s" % atype.title(), set_multi) + setattr(Addresses, "del%s" % atype.title(), del_multi) + if atype == 'all': + Addresses.interfaces.add('addresses') + setattr(Addresses, "getAddresses", get_multi) + setattr(Addresses, "setAddresses", set_multi) + setattr(Addresses, "delAddresses", del_multi) + + +register_stanza_plugin(Addresses, Address, iterable=True) diff --git a/slixmpp/plugins/xep_0045.py b/slixmpp/plugins/xep_0045.py new file mode 100644 index 00000000..f6f48891 --- /dev/null +++ b/slixmpp/plugins/xep_0045.py @@ -0,0 +1,418 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" +from __future__ import with_statement + +import logging + +from slixmpp import Presence +from slixmpp.plugins import BasePlugin, register_plugin +from slixmpp.xmlstream import register_stanza_plugin, ElementBase, JID, ET +from slixmpp.xmlstream.handler.callback import Callback +from slixmpp.xmlstream.matcher.xpath import MatchXPath +from slixmpp.xmlstream.matcher.xmlmask import MatchXMLMask +from slixmpp.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(BasePlugin): + + """ + Implements XEP-0045 Multi-User Chat + """ + + name = 'xep_0045' + description = 'XEP-0045: Multi-User Chat' + dependencies = set(['xep_0030', 'xep_0004']) + + def plugin_init(self): + self.rooms = {} + self.ourNicks = {} + self.xep = '0045' + # load MUC support in presence stanzas + register_stanza_plugin(Presence, MUCPresence) + self.xmpp.register_handler(Callback('MUCPresence', MatchXMLMask("<presence xmlns='%s' />" % self.xmpp.default_ns), self.handle_groupchat_presence)) + self.xmpp.register_handler(Callback('MUCError', MatchXMLMask("<message xmlns='%s' type='error'><error/></message>" % self.xmpp.default_ns), self.handle_groupchat_error_message)) + self.xmpp.register_handler(Callback('MUCMessage', MatchXMLMask("<message xmlns='%s' type='groupchat'><body/></message>" % self.xmpp.default_ns), self.handle_groupchat_message)) + self.xmpp.register_handler(Callback('MUCSubject', MatchXMLMask("<message xmlns='%s' type='groupchat'><subject/></message>" % self.xmpp.default_ns), self.handle_groupchat_subject)) + self.xmpp.register_handler(Callback('MUCConfig', MatchXMLMask("<message xmlns='%s' type='groupchat'><x xmlns='http://jabber.org/protocol/muc#user'><status/></x></message>" % self.xmpp.default_ns), self.handle_config_change)) + self.xmpp.register_handler(Callback('MUCInvite', MatchXPath("{%s}message/{%s}x/{%s}invite" % ( + self.xmpp.default_ns, + 'http://jabber.org/protocol/muc#user', + 'http://jabber.org/protocol/muc#user')), self.handle_groupchat_invite)) + + def plugin_end(self): + self.xmpp.plugin['xep_0030'].del_feature(feature='http://jabber.org/protocol/muc') + + def session_bind(self, jid): + self.xmpp.plugin['xep_0030'].add_feature('http://jabber.org/protocol/muc') + + def handle_groupchat_invite(self, inv): + """ Handle an invite into a muc. + """ + logging.debug("MUC invite to %s from %s: %s", inv['to'], inv["from"], inv) + if inv['from'] not in self.rooms.keys(): + self.xmpp.event("groupchat_invite", inv) + + def handle_config_change(self, msg): + """Handle a MUC configuration change (with status code).""" + self.xmpp.event('groupchat_config_status', msg) + self.xmpp.event('muc::%s::config_status' % msg['from'].bare , msg) + + 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'].get_stanza_values() + entry['show'] = pr['show'] + entry['status'] = pr['status'] + entry['alt_nick'] = pr['nick'] + 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_error_message(self, msg): + """ Handle a message error event in a muc. + """ + self.xmpp.event('groupchat_message_error', msg) + self.xmpp.event("muc::%s::message_error" % 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 configureRoom(self, room, form=None, ifrom=None): + if form is None: + form = self.getRoomConfig(room, ifrom=ifrom) + iq = self.xmpp.make_iq_set() + 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.make_presence(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('{http://jabber.org/protocol/muc}password') + passelement.text = password + x.append(passelement) + if maxhistory: + history = ET.Element('{http://jabber.org/protocol/muc}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.make_iq_set() + if ifrom is not None: + iq['from'] = ifrom + iq['to'] = room + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + destroy = ET.Element('{http://jabber.org/protocol/muc#owner}destroy') + if altroom: + destroy.attrib['jid'] = altroom + xreason = ET.Element('{http://jabber.org/protocol/muc#owner}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('{http://jabber.org/protocol/muc#admin}item', {'affiliation':affiliation, 'nick':nick}) + else: + item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation':affiliation, 'jid':jid}) + query.append(item) + iq = self.xmpp.make_iq_set(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 setRole(self, room, nick, role): + """ Change role property of a nick in a room. + Typically, roles are temporary (they last only as long as you are in the + room), whereas affiliations are permanent (they last across groupchat + sessions). + """ + if role not in ('moderator', 'participant', 'visitor', 'none'): + raise TypeError + query = ET.Element('{http://jabber.org/protocol/muc#admin}query') + item = ET.Element('item', {'role':role, 'nick':nick}) + query.append(item) + iq = self.xmpp.make_iq_set(query) + iq['to'] = room + result = iq.send() + if result is False or result['type'] != 'result': + raise ValueError + return True + + def invite(self, room, jid, reason='', mfrom=''): + """ Invite a jid to a room.""" + msg = self.xmpp.make_message(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('{http://jabber.org/protocol/muc#user}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.send_presence(pshow='unavailable', pto="%s/%s" % (room, nick), pstatus=msg, pfrom=pfrom) + else: + self.xmpp.send_presence(pshow='unavailable', pto="%s/%s" % (room, nick), pfrom=pfrom) + del self.rooms[room] + + def getRoomConfig(self, room, ifrom=''): + iq = self.xmpp.make_iq_get('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.make_iq_set(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.make_iq_set(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() + + def getUsersByAffiliation(cls, room, affiliation='member', ifrom=None): + if affiliation not in ('outcast', 'member', 'admin', 'owner', 'none'): + raise TypeError + query = ET.Element('{http://jabber.org/protocol/muc#admin}query') + item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {'affiliation': affiliation}) + query.append(item) + iq = cls.xmpp.Iq(sto=room, sfrom=ifrom, stype='get') + iq.append(query) + return iq.send() + + +xep_0045 = XEP_0045 +register_plugin(XEP_0045) diff --git a/slixmpp/plugins/xep_0047/__init__.py b/slixmpp/plugins/xep_0047/__init__.py new file mode 100644 index 00000000..5bb9e7cc --- /dev/null +++ b/slixmpp/plugins/xep_0047/__init__.py @@ -0,0 +1,21 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0047 import stanza +from slixmpp.plugins.xep_0047.stanza import Open, Close, Data +from slixmpp.plugins.xep_0047.stream import IBBytestream +from slixmpp.plugins.xep_0047.ibb import XEP_0047 + + +register_plugin(XEP_0047) + + +# Retain some backwards compatibility +xep_0047 = XEP_0047 diff --git a/slixmpp/plugins/xep_0047/ibb.py b/slixmpp/plugins/xep_0047/ibb.py new file mode 100644 index 00000000..52d7fbe5 --- /dev/null +++ b/slixmpp/plugins/xep_0047/ibb.py @@ -0,0 +1,183 @@ +import asyncio +import uuid +import logging + +from slixmpp import Message, Iq +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0047 import stanza, Open, Close, Data, IBBytestream + + +log = logging.getLogger(__name__) + + +class XEP_0047(BasePlugin): + + name = 'xep_0047' + description = 'XEP-0047: In-band Bytestreams' + dependencies = set(['xep_0030']) + stanza = stanza + default_config = { + 'block_size': 4096, + 'max_block_size': 8192, + 'auto_accept': False, + } + + def plugin_init(self): + self._streams = {} + self._preauthed_sids = {} + + register_stanza_plugin(Iq, Open) + register_stanza_plugin(Iq, Close) + register_stanza_plugin(Iq, Data) + register_stanza_plugin(Message, Data) + + self.xmpp.register_handler(Callback( + 'IBB Open', + StanzaPath('iq@type=set/ibb_open'), + self._handle_open_request)) + + self.xmpp.register_handler(Callback( + 'IBB Close', + StanzaPath('iq@type=set/ibb_close'), + self._handle_close)) + + self.xmpp.register_handler(Callback( + 'IBB Data', + StanzaPath('iq@type=set/ibb_data'), + self._handle_data)) + + self.xmpp.register_handler(Callback( + 'IBB Message Data', + StanzaPath('message/ibb_data'), + self._handle_data)) + + self.api.register(self._authorized, 'authorized', default=True) + self.api.register(self._authorized_sid, 'authorized_sid', default=True) + self.api.register(self._preauthorize_sid, 'preauthorize_sid', default=True) + self.api.register(self._get_stream, 'get_stream', default=True) + self.api.register(self._set_stream, 'set_stream', default=True) + self.api.register(self._del_stream, 'del_stream', default=True) + + def plugin_end(self): + self.xmpp.remove_handler('IBB Open') + self.xmpp.remove_handler('IBB Close') + self.xmpp.remove_handler('IBB Data') + self.xmpp.remove_handler('IBB Message Data') + self.xmpp['xep_0030'].del_feature(feature='http://jabber.org/protocol/ibb') + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('http://jabber.org/protocol/ibb') + + def _get_stream(self, jid, sid, peer_jid, data): + return self._streams.get((jid, sid, peer_jid), None) + + def _set_stream(self, jid, sid, peer_jid, stream): + self._streams[(jid, sid, peer_jid)] = stream + + def _del_stream(self, jid, sid, peer_jid, data): + if (jid, sid, peer_jid) in self._streams: + del self._streams[(jid, sid, peer_jid)] + + def _accept_stream(self, iq): + receiver = iq['to'] + sender = iq['from'] + sid = iq['ibb_open']['sid'] + + if self.api['authorized_sid'](receiver, sid, sender, iq): + return True + return self.api['authorized'](receiver, sid, sender, iq) + + def _authorized(self, jid, sid, ifrom, iq): + if self.auto_accept: + return True + return False + + def _authorized_sid(self, jid, sid, ifrom, iq): + if (jid, sid, ifrom) in self._preauthed_sids: + del self._preauthed_sids[(jid, sid, ifrom)] + return True + return False + + def _preauthorize_sid(self, jid, sid, ifrom, data): + self._preauthed_sids[(jid, sid, ifrom)] = True + + def open_stream(self, jid, block_size=None, sid=None, use_messages=False, + ifrom=None, timeout=None, callback=None): + if sid is None: + sid = str(uuid.uuid4()) + if block_size is None: + block_size = self.block_size + + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + iq['from'] = ifrom + iq['ibb_open']['block_size'] = block_size + iq['ibb_open']['sid'] = sid + iq['ibb_open']['stanza'] = 'message' if use_messages else 'iq' + + stream = IBBytestream(self.xmpp, sid, block_size, + iq['from'], iq['to'], use_messages) + + stream_future = asyncio.Future() + + def _handle_opened_stream(iq): + log.debug('IBB stream (%s) accepted by %s', stream.sid, iq['from']) + stream.self_jid = iq['to'] + stream.peer_jid = iq['from'] + stream.stream_started = True + self.api['set_stream'](stream.self_jid, stream.sid, stream.peer_jid, stream) + stream_future.set_result(stream) + if callback is not None: + callback(stream) + self.xmpp.event('ibb_stream_start', stream) + self.xmpp.event('stream:%s:%s' % (stream.sid, stream.peer_jid), stream) + + iq.send(timeout=timeout, callback=_handle_opened_stream) + + return stream_future + + def _handle_open_request(self, iq): + sid = iq['ibb_open']['sid'] + size = iq['ibb_open']['block_size'] or self.block_size + + log.debug('Received IBB stream request from %s', iq['from']) + + if not sid: + raise XMPPError(etype='modify', condition='bad-request') + + if not self._accept_stream(iq): + raise XMPPError(etype='cancel', condition='not-acceptable') + + if size > self.max_block_size: + raise XMPPError('resource-constraint') + + stream = IBBytestream(self.xmpp, sid, size, + iq['to'], iq['from']) + stream.stream_started = True + self.api['set_stream'](stream.self_jid, stream.sid, stream.peer_jid, stream) + iq.reply().send() + + self.xmpp.event('ibb_stream_start', stream) + self.xmpp.event('stream:%s:%s' % (sid, stream.peer_jid), stream) + + def _handle_data(self, stanza): + sid = stanza['ibb_data']['sid'] + stream = self.api['get_stream'](stanza['to'], sid, stanza['from']) + if stream is not None and stanza['from'] == stream.peer_jid: + stream._recv_data(stanza) + else: + raise XMPPError('item-not-found') + + def _handle_close(self, iq): + sid = iq['ibb_close']['sid'] + stream = self.api['get_stream'](iq['to'], sid, iq['from']) + if stream is not None and iq['from'] == stream.peer_jid: + stream._closed(iq) + self.api['del_stream'](stream.self_jid, stream.sid, stream.peer_jid) + else: + raise XMPPError('item-not-found') diff --git a/slixmpp/plugins/xep_0047/stanza.py b/slixmpp/plugins/xep_0047/stanza.py new file mode 100644 index 00000000..7f8ff0ba --- /dev/null +++ b/slixmpp/plugins/xep_0047/stanza.py @@ -0,0 +1,70 @@ +import re +import base64 + +from slixmpp.util import bytes +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream import ElementBase + + +VALID_B64 = re.compile(r'[A-Za-z0-9\+\/]*=*') + + +def to_b64(data): + return bytes(base64.b64encode(bytes(data))).decode('utf-8') + + +def from_b64(data): + return bytes(base64.b64decode(bytes(data))) + + +class Open(ElementBase): + name = 'open' + namespace = 'http://jabber.org/protocol/ibb' + plugin_attrib = 'ibb_open' + interfaces = set(('block_size', 'sid', 'stanza')) + + def get_block_size(self): + return int(self._get_attr('block-size', '0')) + + def set_block_size(self, value): + self._set_attr('block-size', str(value)) + + def del_block_size(self): + self._del_attr('block-size') + + +class Data(ElementBase): + name = 'data' + namespace = 'http://jabber.org/protocol/ibb' + plugin_attrib = 'ibb_data' + interfaces = set(('seq', 'sid', 'data')) + sub_interfaces = set(['data']) + + def get_seq(self): + return int(self._get_attr('seq', '0')) + + def set_seq(self, value): + self._set_attr('seq', str(value)) + + def get_data(self): + text = self.xml.text + if not text: + raise XMPPError('not-acceptable', 'IBB data element is empty.') + b64_data = text.strip() + if VALID_B64.match(b64_data).group() == b64_data: + return from_b64(b64_data) + else: + raise XMPPError('not-acceptable') + + def set_data(self, value): + self.xml.text = to_b64(value) + + def del_data(self): + self.xml.text = '' + + +class Close(ElementBase): + name = 'close' + namespace = 'http://jabber.org/protocol/ibb' + plugin_attrib = 'ibb_close' + interfaces = set(['sid']) diff --git a/slixmpp/plugins/xep_0047/stream.py b/slixmpp/plugins/xep_0047/stream.py new file mode 100644 index 00000000..3be894eb --- /dev/null +++ b/slixmpp/plugins/xep_0047/stream.py @@ -0,0 +1,128 @@ +import asyncio +import socket +import logging + +from slixmpp.stanza import Iq +from slixmpp.exceptions import XMPPError + + +log = logging.getLogger(__name__) + + +class IBBytestream(object): + + def __init__(self, xmpp, sid, block_size, jid, peer, use_messages=False): + self.xmpp = xmpp + self.sid = sid + self.block_size = block_size + self.use_messages = use_messages + + if jid is None: + jid = xmpp.boundjid + self.self_jid = jid + self.peer_jid = peer + + self.send_seq = -1 + self.recv_seq = -1 + + self.stream_started = False + self.stream_in_closed = False + self.stream_out_closed = False + + self.recv_queue = asyncio.Queue() + + @asyncio.coroutine + def send(self, data, timeout=None): + if not self.stream_started or self.stream_out_closed: + raise socket.error + if len(data) > self.block_size: + data = data[:self.block_size] + self.send_seq = (self.send_seq + 1) % 65535 + seq = self.send_seq + if self.use_messages: + msg = self.xmpp.Message() + msg['to'] = self.peer_jid + msg['from'] = self.self_jid + msg['id'] = self.xmpp.new_id() + msg['ibb_data']['sid'] = self.sid + msg['ibb_data']['seq'] = seq + msg['ibb_data']['data'] = data + msg.send() + else: + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = self.peer_jid + iq['from'] = self.self_jid + iq['ibb_data']['sid'] = self.sid + iq['ibb_data']['seq'] = seq + iq['ibb_data']['data'] = data + yield from iq.send(timeout=timeout) + return len(data) + + @asyncio.coroutine + def sendall(self, data, timeout=None): + sent_len = 0 + while sent_len < len(data): + sent_len += yield from self.send(data[sent_len:self.block_size], timeout=timeout) + + @asyncio.coroutine + def sendfile(self, file, timeout=None): + while True: + data = file.read(self.block_size) + if not data: + break + yield from self.send(data, timeout=timeout) + + def _recv_data(self, stanza): + new_seq = stanza['ibb_data']['seq'] + if new_seq != (self.recv_seq + 1) % 65535: + self.close() + raise XMPPError('unexpected-request') + self.recv_seq = new_seq + + data = stanza['ibb_data']['data'] + if len(data) > self.block_size: + self.close() + raise XMPPError('not-acceptable') + + self.recv_queue.put_nowait(data) + self.xmpp.event('ibb_stream_data', self) + + if isinstance(stanza, Iq): + stanza.reply().send() + + def recv(self, *args, **kwargs): + return self.read() + + def read(self): + if not self.stream_started or self.stream_in_closed: + raise socket.error + return self.recv_queue.get_nowait() + + def close(self, timeout=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = self.peer_jid + iq['from'] = self.self_jid + iq['ibb_close']['sid'] = self.sid + self.stream_out_closed = True + def _close_stream(_): + self.stream_in_closed = True + future = iq.send(timeout=timeout, callback=_close_stream) + self.xmpp.event('ibb_stream_end', self) + return future + + def _closed(self, iq): + self.stream_in_closed = True + self.stream_out_closed = True + iq.reply().send() + self.xmpp.event('ibb_stream_end', self) + + def makefile(self, *args, **kwargs): + return self + + def connect(*args, **kwargs): + return None + + def shutdown(self, *args, **kwargs): + return None diff --git a/slixmpp/plugins/xep_0048/__init__.py b/slixmpp/plugins/xep_0048/__init__.py new file mode 100644 index 00000000..d19f12a3 --- /dev/null +++ b/slixmpp/plugins/xep_0048/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0048.stanza import Bookmarks, Conference, URL +from slixmpp.plugins.xep_0048.bookmarks import XEP_0048 + + +register_plugin(XEP_0048) diff --git a/slixmpp/plugins/xep_0048/bookmarks.py b/slixmpp/plugins/xep_0048/bookmarks.py new file mode 100644 index 00000000..fa76df39 --- /dev/null +++ b/slixmpp/plugins/xep_0048/bookmarks.py @@ -0,0 +1,76 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq +from slixmpp.plugins import BasePlugin +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0048 import stanza, Bookmarks, Conference, URL + + +log = logging.getLogger(__name__) + + +class XEP_0048(BasePlugin): + + name = 'xep_0048' + description = 'XEP-0048: Bookmarks' + dependencies = set(['xep_0045', 'xep_0049', 'xep_0060', 'xep_0163', 'xep_0223']) + stanza = stanza + default_config = { + 'auto_join': False, + 'storage_method': 'xep_0049' + } + + def plugin_init(self): + register_stanza_plugin(self.xmpp['xep_0060'].stanza.Item, Bookmarks) + + self.xmpp['xep_0049'].register(Bookmarks) + self.xmpp['xep_0163'].register_pep('bookmarks', Bookmarks) + + self.xmpp.add_event_handler('session_start', self._autojoin) + + def plugin_end(self): + self.xmpp.del_event_handler('session_start', self._autojoin) + + def _autojoin(self, __): + if not self.auto_join: + return + + try: + result = self.get_bookmarks(method=self.storage_method) + except XMPPError: + return + + if self.storage_method == 'xep_0223': + bookmarks = result['pubsub']['items']['item']['bookmarks'] + else: + bookmarks = result['private']['bookmarks'] + + for conf in bookmarks['conferences']: + if conf['autojoin']: + log.debug('Auto joining %s as %s', conf['jid'], conf['nick']) + self.xmpp['xep_0045'].joinMUC(conf['jid'], conf['nick'], + password=conf['password']) + + def set_bookmarks(self, bookmarks, method=None, **iqargs): + if not method: + method = self.storage_method + return self.xmpp[method].store(bookmarks, **iqargs) + + def get_bookmarks(self, method=None, **iqargs): + if not method: + method = self.storage_method + + loc = 'storage:bookmarks' if method == 'xep_0223' else 'bookmarks' + + return self.xmpp[method].retrieve(loc, **iqargs) diff --git a/slixmpp/plugins/xep_0048/stanza.py b/slixmpp/plugins/xep_0048/stanza.py new file mode 100644 index 00000000..c158535a --- /dev/null +++ b/slixmpp/plugins/xep_0048/stanza.py @@ -0,0 +1,65 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ET, ElementBase, register_stanza_plugin + + +class Bookmarks(ElementBase): + name = 'storage' + namespace = 'storage:bookmarks' + plugin_attrib = 'bookmarks' + interfaces = set() + + def add_conference(self, jid, nick, name=None, autojoin=None, password=None): + conf = Conference() + conf['jid'] = jid + conf['nick'] = nick + if name is None: + name = jid + conf['name'] = name + conf['autojoin'] = autojoin + conf['password'] = password + self.append(conf) + + def add_url(self, url, name=None): + saved_url = URL() + saved_url['url'] = url + if name is None: + name = url + saved_url['name'] = name + self.append(saved_url) + + +class Conference(ElementBase): + name = 'conference' + namespace = 'storage:bookmarks' + plugin_attrib = 'conference' + plugin_multi_attrib = 'conferences' + interfaces = set(['nick', 'password', 'autojoin', 'jid', 'name']) + sub_interfaces = set(['nick', 'password']) + + def get_autojoin(self): + value = self._get_attr('autojoin') + return value in ('1', 'true') + + def set_autojoin(self, value): + del self['autojoin'] + if value in ('1', 'true', True): + self._set_attr('autojoin', 'true') + + +class URL(ElementBase): + name = 'url' + namespace = 'storage:bookmarks' + plugin_attrib = 'url' + plugin_multi_attrib = 'urls' + interfaces = set(['url', 'name']) + + +register_stanza_plugin(Bookmarks, Conference, iterable=True) +register_stanza_plugin(Bookmarks, URL, iterable=True) diff --git a/slixmpp/plugins/xep_0049/__init__.py b/slixmpp/plugins/xep_0049/__init__.py new file mode 100644 index 00000000..396be75d --- /dev/null +++ b/slixmpp/plugins/xep_0049/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0049.stanza import PrivateXML +from slixmpp.plugins.xep_0049.private_storage import XEP_0049 + + +register_plugin(XEP_0049) diff --git a/slixmpp/plugins/xep_0049/private_storage.py b/slixmpp/plugins/xep_0049/private_storage.py new file mode 100644 index 00000000..a66c05d1 --- /dev/null +++ b/slixmpp/plugins/xep_0049/private_storage.py @@ -0,0 +1,57 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0049 import stanza, PrivateXML + + +log = logging.getLogger(__name__) + + +class XEP_0049(BasePlugin): + + name = 'xep_0049' + description = 'XEP-0049: Private XML Storage' + dependencies = set([]) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, PrivateXML) + + def register(self, stanza): + register_stanza_plugin(PrivateXML, stanza, iterable=True) + + def store(self, data, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + + if not isinstance(data, list): + data = [data] + + for elem in data: + iq['private'].append(elem) + + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def retrieve(self, name, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['from'] = ifrom + iq['private'].enable(name) + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0049/stanza.py b/slixmpp/plugins/xep_0049/stanza.py new file mode 100644 index 00000000..a8d425ba --- /dev/null +++ b/slixmpp/plugins/xep_0049/stanza.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ET, ElementBase + + +class PrivateXML(ElementBase): + + name = 'query' + namespace = 'jabber:iq:private' + plugin_attrib = 'private' + interfaces = set() diff --git a/slixmpp/plugins/xep_0050/__init__.py b/slixmpp/plugins/xep_0050/__init__.py new file mode 100644 index 00000000..5a2d4805 --- /dev/null +++ b/slixmpp/plugins/xep_0050/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0050.stanza import Command +from slixmpp.plugins.xep_0050.adhoc import XEP_0050 + + +register_plugin(XEP_0050) + + +# Retain some backwards compatibility +xep_0050 = XEP_0050 diff --git a/slixmpp/plugins/xep_0050/adhoc.py b/slixmpp/plugins/xep_0050/adhoc.py new file mode 100644 index 00000000..fa6017d5 --- /dev/null +++ b/slixmpp/plugins/xep_0050/adhoc.py @@ -0,0 +1,665 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import time + +from slixmpp import Iq +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0050 import stanza +from slixmpp.plugins.xep_0050 import Command +from slixmpp.plugins.xep_0004 import Form + + +log = logging.getLogger(__name__) + + +class XEP_0050(BasePlugin): + + """ + 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> + + 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: + 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 BasePlugin.plugin_init + post_init -- Overrides BasePlugin.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 + """ + + name = 'xep_0050' + description = 'XEP-0050: Ad-Hoc Commands' + dependencies = set(['xep_0030', 'xep_0004']) + stanza = stanza + default_config = { + 'session_db': None + } + + def plugin_init(self): + """Start the XEP-0050 plugin.""" + self.sessions = self.session_db + if self.sessions is None: + self.sessions = {} + + self.commands = {} + + 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, iterable=True) + + self.xmpp.add_event_handler('command_execute', + self._handle_command_start) + self.xmpp.add_event_handler('command_next', + self._handle_command_next) + self.xmpp.add_event_handler('command_cancel', + self._handle_command_cancel) + self.xmpp.add_event_handler('command_complete', + self._handle_command_complete) + + def plugin_end(self): + self.xmpp.del_event_handler('command_execute', + self._handle_command_start) + self.xmpp.del_event_handler('command_next', + self._handle_command_next) + self.xmpp.del_event_handler('command_cancel', + self._handle_command_cancel) + self.xmpp.del_event_handler('command_complete', + self._handle_command_complete) + self.xmpp.remove_handler('Ad-Hoc Execute') + self.xmpp['xep_0030'].del_feature(feature=Command.namespace) + self.xmpp['xep_0030'].set_items(node=Command.namespace, items=tuple()) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(Command.namespace) + self.xmpp['xep_0030'].set_items(node=Command.namespace, items=tuple()) + + 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 + + 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) + raise XMPPError('item-not-found') + + payload = [] + for stanza in iq['command']['substanzas']: + payload.append(stanza) + + if len(payload) == 1: + payload = payload[0] + + interfaces = set([item.plugin_attrib for item in payload]) + payload_classes = set([item.__class__ for item in payload]) + + initial_session = {'id': sessionid, + 'from': iq['from'], + 'to': iq['to'], + 'node': node, + 'payload': payload, + 'interfaces': interfaces, + 'payload_classes': payload_classes, + '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.get(sessionid) + + if session: + 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) + else: + raise XMPPError('item-not-found') + + def _handle_command_prev(self, iq): + """ + Process a request for the prev step in the workflow + for a command with multiple steps. + + Arguments: + iq -- The command continuation request. + """ + sessionid = iq['command']['sessionid'] + session = self.sessions.get(sessionid) + + if session: + handler = session['prev'] + 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) + else: + raise XMPPError('item-not-found') + + 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 payload is None: + payload = [] + if not isinstance(payload, list): + payload = [payload] + + interfaces = session.get('interfaces', set()) + payload_classes = session.get('payload_classes', set()) + + interfaces.update(set([item.plugin_attrib for item in payload])) + payload_classes.update(set([item.__class__ for item in payload])) + + session['interfaces'] = interfaces + session['payload_classes'] = payload_classes + + self.sessions[sessionid] = session + + for item in payload: + register_stanza_plugin(Command, item.__class__, iterable=True) + + iq = 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.get(sessionid) + + if session: + handler = session['cancel'] + if handler: + handler(iq, session) + del self.sessions[sessionid] + iq = iq.reply() + iq['command']['node'] = node + iq['command']['sessionid'] = sessionid + iq['command']['status'] = 'canceled' + iq['command']['notes'] = session['notes'] + iq.send() + else: + raise XMPPError('item-not-found') + + + 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.get(sessionid) + + if session: + 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) + + del self.sessions[sessionid] + + payload = session['payload'] + if payload is None: + payload = [] + if not isinstance(payload, list): + payload = [payload] + + for item in payload: + register_stanza_plugin(Command, item.__class__, iterable=True) + + iq = iq.reply() + + iq['command']['node'] = node + iq['command']['sessionid'] = sessionid + iq['command']['actions'] = [] + iq['command']['status'] = 'completed' + iq['command']['notes'] = session['notes'] + + for item in payload: + iq['command'].append(item) + + iq.send() + else: + raise XMPPError('item-not-found') + + # ================================================================= + # 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 Slixmpp 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. + 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. + timeout -- The length of time (in seconds) to wait for a + response before exiting the send call + if blocking is used. Defaults to + slixmpp.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: + iq.send(callback=self._handle_command_result) + + def start_command(self, jid, node, session, ifrom=None): + """ + Initiate executing a command provided by a remote agent. + + 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. + """ + session['jid'] = jid + session['node'] = node + session['timestamp'] = time.time() + if 'payload' not in session: + session['payload'] = None + + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + iq['from'] = ifrom + session['from'] = ifrom + iq['command']['node'] = node + iq['command']['action'] = 'execute' + if session['payload'] is not None: + payload = session['payload'] + if not isinstance(payload, list): + payload = list(payload) + for stanza in payload: + iq['command'].append(stanza) + sessionid = 'client:pending_' + iq['id'] + session['id'] = sessionid + self.sessions[sessionid] = session + iq.send(callback=self._handle_command_result) + + def continue_command(self, session, direction='next'): + """ + 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=direction, + payload=session.get('payload', None), + sessionid=session['id'], + flow=True) + + 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) + + 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) + + 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. + """ + sessionid = 'client:' + session['id'] + try: + del self.sessions[sessionid] + except Exception as e: + log.error("Error deleting adhoc command session: %s" % e.message) + + 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/slixmpp/plugins/xep_0050/stanza.py b/slixmpp/plugins/xep_0050/stanza.py new file mode 100644 index 00000000..9bae7d15 --- /dev/null +++ b/slixmpp/plugins/xep_0050/stanza.py @@ -0,0 +1,185 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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 = set() + 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.add(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/slixmpp/plugins/xep_0054/__init__.py b/slixmpp/plugins/xep_0054/__init__.py new file mode 100644 index 00000000..2029b41f --- /dev/null +++ b/slixmpp/plugins/xep_0054/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0054.stanza import VCardTemp +from slixmpp.plugins.xep_0054.vcard_temp import XEP_0054 + + +register_plugin(XEP_0054) diff --git a/slixmpp/plugins/xep_0054/stanza.py b/slixmpp/plugins/xep_0054/stanza.py new file mode 100644 index 00000000..48a41432 --- /dev/null +++ b/slixmpp/plugins/xep_0054/stanza.py @@ -0,0 +1,571 @@ +import base64 +import datetime as dt + +from slixmpp.util import bytes +from slixmpp.xmlstream import ElementBase, ET, register_stanza_plugin, JID +from slixmpp.plugins import xep_0082 + + +class VCardTemp(ElementBase): + name = 'vCard' + namespace = 'vcard-temp' + plugin_attrib = 'vcard_temp' + interfaces = set(['FN', 'VERSION']) + sub_interfaces = set(['FN', 'VERSION']) + + +class Name(ElementBase): + name = 'N' + namespace = 'vcard-temp' + plugin_attrib = name + interfaces = set(['FAMILY', 'GIVEN', 'MIDDLE', 'PREFIX', 'SUFFIX']) + sub_interfaces = interfaces + + def _set_component(self, name, value): + if isinstance(value, list): + value = ','.join(value) + if value is not None: + self._set_sub_text(name, value, keep=True) + else: + self._del_sub(name) + + def _get_component(self, name): + value = self._get_sub_text(name, '') + if ',' in value: + value = [v.strip() for v in value.split(',')] + return value + + def set_family(self, value): + self._set_component('FAMILY', value) + + def get_family(self): + return self._get_component('FAMILY') + + def set_given(self, value): + self._set_component('GIVEN', value) + + def get_given(self): + return self._get_component('GIVEN') + + def set_middle(self, value): + print(value) + self._set_component('MIDDLE', value) + + def get_middle(self): + return self._get_component('MIDDLE') + + def set_prefix(self, value): + self._set_component('PREFIX', value) + + def get_prefix(self): + return self._get_component('PREFIX') + + def set_suffix(self, value): + self._set_component('SUFFIX', value) + + def get_suffix(self): + return self._get_component('SUFFIX') + + +class Nickname(ElementBase): + name = 'NICKNAME' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'nicknames' + interfaces = set([name]) + is_extension = True + + def set_nickname(self, value): + if not value: + self.xml.text = '' + return + + if not isinstance(value, list): + value = [value] + + self.xml.text = ','.join(value) + + def get_nickname(self): + if self.xml.text: + return self.xml.text.split(',') + + +class Email(ElementBase): + name = 'EMAIL' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'emails' + interfaces = set(['HOME', 'WORK', 'INTERNET', 'PREF', 'X400', 'USERID']) + sub_interfaces = set(['USERID']) + bool_interfaces = set(['HOME', 'WORK', 'INTERNET', 'PREF', 'X400']) + + +class Address(ElementBase): + name = 'ADR' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'addresses' + interfaces = set(['HOME', 'WORK', 'POSTAL', 'PARCEL', 'DOM', 'INTL', + 'PREF', 'POBOX', 'EXTADD', 'STREET', 'LOCALITY', + 'REGION', 'PCODE', 'CTRY']) + sub_interfaces = set(['POBOX', 'EXTADD', 'STREET', 'LOCALITY', + 'REGION', 'PCODE', 'CTRY']) + bool_interfaces = set(['HOME', 'WORK', 'DOM', 'INTL', 'PREF']) + + +class Telephone(ElementBase): + name = 'TEL' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'telephone_numbers' + interfaces = set(['HOME', 'WORK', 'VOICE', 'FAX', 'PAGER', 'MSG', + 'CELL', 'VIDEO', 'BBS', 'MODEM', 'ISDN', 'PCS', + 'PREF', 'NUMBER']) + sub_interfaces = set(['NUMBER']) + bool_interfaces = set(['HOME', 'WORK', 'VOICE', 'FAX', 'PAGER', + 'MSG', 'CELL', 'VIDEO', 'BBS', 'MODEM', + 'ISDN', 'PCS', 'PREF']) + + def setup(self, xml=None): + super(Telephone, self).setup(xml=xml) + ## this blanks out numbers received from server + ##self._set_sub_text('NUMBER', '', keep=True) + + def set_number(self, value): + self._set_sub_text('NUMBER', value, keep=True) + + def del_number(self): + self._set_sub_text('NUMBER', '', keep=True) + + +class Label(ElementBase): + name = 'LABEL' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'labels' + interfaces = set(['HOME', 'WORK', 'POSTAL', 'PARCEL', 'DOM', 'INT', + 'PREF', 'lines']) + bool_interfaces = set(['HOME', 'WORK', 'POSTAL', 'PARCEL', 'DOM', + 'INT', 'PREF']) + + def add_line(self, value): + line = ET.Element('{%s}LINE' % self.namespace) + line.text = value + self.xml.append(line) + + def get_lines(self): + lines = self.xml.find('{%s}LINE' % self.namespace) + if lines is None: + return [] + return [line.text for line in lines] + + def set_lines(self, values): + self.del_lines() + for line in values: + self.add_line(line) + + def del_lines(self): + lines = self.xml.find('{%s}LINE' % self.namespace) + if lines is None: + return + for line in lines: + self.xml.remove(line) + + +class Geo(ElementBase): + name = 'GEO' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'geolocations' + interfaces = set(['LAT', 'LON']) + sub_interfaces = interfaces + + +class Org(ElementBase): + name = 'ORG' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'organizations' + interfaces = set(['ORGNAME', 'ORGUNIT', 'orgunits']) + sub_interfaces = set(['ORGNAME', 'ORGUNIT']) + + def add_orgunit(self, value): + orgunit = ET.Element('{%s}ORGUNIT' % self.namespace) + orgunit.text = value + self.xml.append(orgunit) + + def get_orgunits(self): + orgunits = self.xml.find('{%s}ORGUNIT' % self.namespace) + if orgunits is None: + return [] + return [orgunit.text for orgunit in orgunits] + + def set_orgunits(self, values): + self.del_orgunits() + for orgunit in values: + self.add_orgunit(orgunit) + + def del_orgunits(self): + orgunits = self.xml.find('{%s}ORGUNIT' % self.namespace) + if orgunits is None: + return + for orgunit in orgunits: + self.xml.remove(orgunit) + + +class Photo(ElementBase): + name = 'PHOTO' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'photos' + interfaces = set(['TYPE', 'EXTVAL']) + sub_interfaces = interfaces + + +class Logo(ElementBase): + name = 'LOGO' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'logos' + interfaces = set(['TYPE', 'EXTVAL']) + sub_interfaces = interfaces + + +class Sound(ElementBase): + name = 'SOUND' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'sounds' + interfaces = set(['PHONETC', 'EXTVAL']) + sub_interfaces = interfaces + + +class BinVal(ElementBase): + name = 'BINVAL' + namespace = 'vcard-temp' + plugin_attrib = name + interfaces = set(['BINVAL']) + is_extension = True + + def setup(self, xml=None): + self.xml = ET.Element('') + return True + + def set_binval(self, value): + self.del_binval() + parent = self.parent() + if value: + xml = ET.Element('{%s}BINVAL' % self.namespace) + xml.text = bytes(base64.b64encode(value)).decode('utf-8') + parent.append(xml) + + def get_binval(self): + parent = self.parent() + xml = parent.find('{%s}BINVAL' % self.namespace) + if xml is not None: + return base64.b64decode(bytes(xml.text)) + return b'' + + def del_binval(self): + self.parent()._del_sub('{%s}BINVAL' % self.namespace) + + +class Classification(ElementBase): + name = 'CLASS' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'classifications' + interfaces = set(['PUBLIC', 'PRIVATE', 'CONFIDENTIAL']) + bool_interfaces = interfaces + + +class Categories(ElementBase): + name = 'CATEGORIES' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'categories' + interfaces = set([name]) + is_extension = True + + def set_categories(self, values): + self.del_categories() + for keyword in values: + item = ET.Element('{%s}KEYWORD' % self.namespace) + item.text = keyword + self.xml.append(item) + + def get_categories(self): + items = self.xml.findall('{%s}KEYWORD' % self.namespace) + if items is None: + return [] + keywords = [] + for item in items: + keywords.append(item.text) + return keywords + + def del_categories(self): + items = self.xml.findall('{%s}KEYWORD' % self.namespace) + for item in items: + self.xml.remove(item) + + +class Birthday(ElementBase): + name = 'BDAY' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'birthdays' + interfaces = set([name]) + is_extension = True + + def set_bday(self, value): + if isinstance(value, dt.datetime): + value = xep_0082.format_datetime(value) + self.xml.text = value + + def get_bday(self): + if not self.xml.text: + return None + try: + return xep_0082.parse(self.xml.text) + except ValueError: + return self.xml.text + + +class Rev(ElementBase): + name = 'REV' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'revision_dates' + interfaces = set([name]) + is_extension = True + + def set_rev(self, value): + if isinstance(value, dt.datetime): + value = xep_0082.format_datetime(value) + self.xml.text = value + + def get_rev(self): + if not self.xml.text: + return None + try: + return xep_0082.parse(self.xml.text) + except ValueError: + return self.xml.text + + +class Title(ElementBase): + name = 'TITLE' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'titles' + interfaces = set([name]) + is_extension = True + + def set_title(self, value): + self.xml.text = value + + def get_title(self): + return self.xml.text + + +class Role(ElementBase): + name = 'ROLE' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'roles' + interfaces = set([name]) + is_extension = True + + def set_role(self, value): + self.xml.text = value + + def get_role(self): + return self.xml.text + + +class Note(ElementBase): + name = 'NOTE' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'notes' + interfaces = set([name]) + is_extension = True + + def set_note(self, value): + self.xml.text = value + + def get_note(self): + return self.xml.text + + +class Desc(ElementBase): + name = 'DESC' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'descriptions' + interfaces = set([name]) + is_extension = True + + def set_desc(self, value): + self.xml.text = value + + def get_desc(self): + return self.xml.text + + +class URL(ElementBase): + name = 'URL' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'urls' + interfaces = set([name]) + is_extension = True + + def set_url(self, value): + self.xml.text = value + + def get_url(self): + return self.xml.text + + +class UID(ElementBase): + name = 'UID' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'uids' + interfaces = set([name]) + is_extension = True + + def set_uid(self, value): + self.xml.text = value + + def get_uid(self): + return self.xml.text + + +class ProdID(ElementBase): + name = 'PRODID' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'product_ids' + interfaces = set([name]) + is_extension = True + + def set_prodid(self, value): + self.xml.text = value + + def get_prodid(self): + return self.xml.text + + +class Mailer(ElementBase): + name = 'MAILER' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'mailers' + interfaces = set([name]) + is_extension = True + + def set_mailer(self, value): + self.xml.text = value + + def get_mailer(self): + return self.xml.text + + +class SortString(ElementBase): + name = 'SORT-STRING' + namespace = 'vcard-temp' + plugin_attrib = 'SORT_STRING' + plugin_multi_attrib = 'sort_strings' + interfaces = set([name]) + is_extension = True + + def set_sort_string(self, value): + self.xml.text = value + + def get_sort_string(self): + return self.xml.text + + +class Agent(ElementBase): + name = 'AGENT' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'agents' + interfaces = set(['EXTVAL']) + sub_interfaces = interfaces + + +class JabberID(ElementBase): + name = 'JABBERID' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'jids' + interfaces = set([name]) + is_extension = True + + def set_jabberid(self, value): + self.xml.text = JID(value).bare + + def get_jabberid(self): + return JID(self.xml.text) + + +class TimeZone(ElementBase): + name = 'TZ' + namespace = 'vcard-temp' + plugin_attrib = name + plugin_multi_attrib = 'timezones' + interfaces = set([name]) + is_extension = True + + def set_tz(self, value): + time = xep_0082.time(offset=value) + if time[-1] == 'Z': + self.xml.text = 'Z' + else: + self.xml.text = time[-6:] + + def get_tz(self): + if not self.xml.text: + return xep_0082.tzutc() + try: + time = xep_0082.parse('00:00:00%s' % self.xml.text) + return time.tzinfo + except ValueError: + return self.xml.text + + +register_stanza_plugin(VCardTemp, Name) +register_stanza_plugin(VCardTemp, Address, iterable=True) +register_stanza_plugin(VCardTemp, Agent, iterable=True) +register_stanza_plugin(VCardTemp, Birthday, iterable=True) +register_stanza_plugin(VCardTemp, Categories, iterable=True) +register_stanza_plugin(VCardTemp, Desc, iterable=True) +register_stanza_plugin(VCardTemp, Email, iterable=True) +register_stanza_plugin(VCardTemp, Geo, iterable=True) +register_stanza_plugin(VCardTemp, JabberID, iterable=True) +register_stanza_plugin(VCardTemp, Label, iterable=True) +register_stanza_plugin(VCardTemp, Logo, iterable=True) +register_stanza_plugin(VCardTemp, Mailer, iterable=True) +register_stanza_plugin(VCardTemp, Note, iterable=True) +register_stanza_plugin(VCardTemp, Nickname, iterable=True) +register_stanza_plugin(VCardTemp, Org, iterable=True) +register_stanza_plugin(VCardTemp, Photo, iterable=True) +register_stanza_plugin(VCardTemp, ProdID, iterable=True) +register_stanza_plugin(VCardTemp, Rev, iterable=True) +register_stanza_plugin(VCardTemp, Role, iterable=True) +register_stanza_plugin(VCardTemp, SortString, iterable=True) +register_stanza_plugin(VCardTemp, Sound, iterable=True) +register_stanza_plugin(VCardTemp, Telephone, iterable=True) +register_stanza_plugin(VCardTemp, Title, iterable=True) +register_stanza_plugin(VCardTemp, TimeZone, iterable=True) +register_stanza_plugin(VCardTemp, UID, iterable=True) +register_stanza_plugin(VCardTemp, URL, iterable=True) + +register_stanza_plugin(Photo, BinVal) +register_stanza_plugin(Logo, BinVal) +register_stanza_plugin(Sound, BinVal) + +register_stanza_plugin(Agent, VCardTemp) diff --git a/slixmpp/plugins/xep_0054/vcard_temp.py b/slixmpp/plugins/xep_0054/vcard_temp.py new file mode 100644 index 00000000..f0173386 --- /dev/null +++ b/slixmpp/plugins/xep_0054/vcard_temp.py @@ -0,0 +1,147 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import JID, Iq +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0054 import VCardTemp, stanza +from slixmpp import future_wrapper + + +log = logging.getLogger(__name__) + + +class XEP_0054(BasePlugin): + + """ + XEP-0054: vcard-temp + """ + + name = 'xep_0054' + description = 'XEP-0054: vcard-temp' + dependencies = set(['xep_0030', 'xep_0082']) + stanza = stanza + + def plugin_init(self): + """ + Start the XEP-0054 plugin. + """ + register_stanza_plugin(Iq, VCardTemp) + + + self.api.register(self._set_vcard, 'set_vcard', default=True) + self.api.register(self._get_vcard, 'get_vcard', default=True) + self.api.register(self._del_vcard, 'del_vcard', default=True) + + self._vcard_cache = {} + + self.xmpp.register_handler( + Callback('VCardTemp', + StanzaPath('iq/vcard_temp'), + self._handle_get_vcard)) + + def plugin_end(self): + self.xmpp.remove_handler('VCardTemp') + self.xmpp['xep_0030'].del_feature(feature='vcard-temp') + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('vcard-temp') + + def make_vcard(self): + return VCardTemp() + + @future_wrapper + def get_vcard(self, jid=None, ifrom=None, local=None, cached=False, + callback=None, timeout=None, timeout_callback=None): + if local is None: + 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 + elif jid in (None, ''): + local = True + + if local: + vcard = self.api['get_vcard'](jid, None, ifrom) + if not isinstance(vcard, Iq): + iq = self.xmpp.Iq() + if vcard is None: + vcard = VCardTemp() + iq.append(vcard) + return iq + return vcard + + if cached: + vcard = self.api['get_vcard'](jid, None, ifrom) + if vcard is not None: + if not isinstance(vcard, Iq): + iq = self.xmpp.Iq() + iq.append(vcard) + return iq + return vcard + + iq = self.xmpp.Iq() + iq['to'] = jid + iq['from'] = ifrom + iq['type'] = 'get' + iq.enable('vcard_temp') + + return iq.send(callback=callback, timeout=timeout, + timeout_callback=timeout_callback) + + @future_wrapper + def publish_vcard(self, vcard=None, jid=None, ifrom=None, + callback=None, timeout=None, timeout_callback=None): + self.api['set_vcard'](jid, None, ifrom, vcard) + if self.xmpp.is_component: + return + + iq = self.xmpp.Iq() + iq['to'] = jid + iq['from'] = ifrom + iq['type'] = 'set' + iq.append(vcard) + return iq.send(callback=callback, timeout=timeout, + timeout_callback=timeout_callback) + + def _handle_get_vcard(self, iq): + if iq['type'] == 'result': + self.api['set_vcard'](jid=iq['from'], args=iq['vcard_temp']) + return + elif iq['type'] == 'get': + vcard = self.api['get_vcard'](iq['from'].bare) + if isinstance(vcard, Iq): + vcard.send() + else: + iq = iq.reply() + iq.append(vcard) + iq.send() + elif iq['type'] == 'set': + raise XMPPError('service-unavailable') + + # ================================================================= + + def _set_vcard(self, jid, node, ifrom, vcard): + self._vcard_cache[jid.bare] = vcard + + def _get_vcard(self, jid, node, ifrom, vcard): + return self._vcard_cache.get(jid.bare, None) + + def _del_vcard(self, jid, node, ifrom, vcard): + if jid.bare in self._vcard_cache: + del self._vcard_cache[jid.bare] diff --git a/slixmpp/plugins/xep_0059/__init__.py b/slixmpp/plugins/xep_0059/__init__.py new file mode 100644 index 00000000..21a0e5c9 --- /dev/null +++ b/slixmpp/plugins/xep_0059/__init__.py @@ -0,0 +1,18 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Erik Reuterborg Larsson + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0059.stanza import Set +from slixmpp.plugins.xep_0059.rsm import ResultIterator, XEP_0059 + + +register_plugin(XEP_0059) + +# Retain some backwards compatibility +xep_0059 = XEP_0059 diff --git a/slixmpp/plugins/xep_0059/rsm.py b/slixmpp/plugins/xep_0059/rsm.py new file mode 100644 index 00000000..5876a9aa --- /dev/null +++ b/slixmpp/plugins/xep_0059/rsm.py @@ -0,0 +1,145 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Erik Reuterborg Larsson + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +import slixmpp +from slixmpp import Iq +from slixmpp.plugins import BasePlugin, register_plugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0059 import stanza, Set +from slixmpp.exceptions import XMPPError + + +log = logging.getLogger(__name__) + + +class ResultIterator(): + + """ + An iterator for Result Set Managment + """ + + def __init__(self, query, interface, results='substanzas', amount=10, + start=None, reverse=False): + """ + Arguments: + query -- The template query + interface -- The substanza of the query, for example disco_items + results -- The query stanza's interface which provides a + countable list of query results. + 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.results = results + self.reverse = reverse + self._stop = False + + 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. + """ + if self._stop: + raise StopIteration + 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 + + try: + r = self.query.send(block=True) + + if not r[self.interface]['rsm']['first'] and \ + not r[self.interface]['rsm']['last']: + raise StopIteration + + if r[self.interface]['rsm']['count'] and \ + r[self.interface]['rsm']['first_index']: + count = int(r[self.interface]['rsm']['count']) + first = int(r[self.interface]['rsm']['first_index']) + num_items = len(r[self.interface][self.results]) + if first + num_items == count: + self._stop = True + + if self.reverse: + self.start = r[self.interface]['rsm']['first'] + else: + self.start = r[self.interface]['rsm']['last'] + + return r + except XMPPError: + raise StopIteration + + +class XEP_0059(BasePlugin): + + """ + XEP-0050: Result Set Management + """ + + name = 'xep_0059' + description = 'XEP-0059: Result Set Management' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + """ + Start the XEP-0059 plugin. + """ + register_stanza_plugin(self.xmpp['xep_0030'].stanza.DiscoItems, + self.stanza.Set) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=Set.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(Set.namespace) + + def iterate(self, stanza, interface, results='substanzas'): + """ + 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. + results -- The name of the interface containing the + query results (typically just 'substanzas'). + """ + return ResultIterator(stanza, interface, results) diff --git a/slixmpp/plugins/xep_0059/stanza.py b/slixmpp/plugins/xep_0059/stanza.py new file mode 100644 index 00000000..e2701af4 --- /dev/null +++ b/slixmpp/plugins/xep_0059/stanza.py @@ -0,0 +1,108 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Erik Reuterborg Larsson + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET +from slixmpp.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 + elif 'index' in fi.attrib: + 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 is 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/slixmpp/plugins/xep_0060/__init__.py b/slixmpp/plugins/xep_0060/__init__.py new file mode 100644 index 00000000..6c4d8428 --- /dev/null +++ b/slixmpp/plugins/xep_0060/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0060.pubsub import XEP_0060 +from slixmpp.plugins.xep_0060 import stanza + + +register_plugin(XEP_0060) + + +# Retain some backwards compatibility +xep_0060 = XEP_0060 diff --git a/slixmpp/plugins/xep_0060/pubsub.py b/slixmpp/plugins/xep_0060/pubsub.py new file mode 100644 index 00000000..8e12ae92 --- /dev/null +++ b/slixmpp/plugins/xep_0060/pubsub.py @@ -0,0 +1,566 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.xmlstream import JID +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0060 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0060(BasePlugin): + + """ + XEP-0060 Publish Subscribe + """ + + name = 'xep_0060' + description = 'XEP-0060: Publish-Subscribe' + dependencies = set(['xep_0030', 'xep_0004', 'xep_0082', 'xep_0131']) + stanza = stanza + + def plugin_init(self): + self.node_event_map = {} + + self.xmpp.register_handler( + Callback('Pubsub Event: Items', + StanzaPath('message/pubsub_event/items'), + self._handle_event_items)) + self.xmpp.register_handler( + Callback('Pubsub Event: Purge', + StanzaPath('message/pubsub_event/purge'), + self._handle_event_purge)) + self.xmpp.register_handler( + Callback('Pubsub Event: Delete', + StanzaPath('message/pubsub_event/delete'), + self._handle_event_delete)) + self.xmpp.register_handler( + Callback('Pubsub Event: Configuration', + StanzaPath('message/pubsub_event/configuration'), + self._handle_event_configuration)) + self.xmpp.register_handler( + Callback('Pubsub Event: Subscription', + StanzaPath('message/pubsub_event/subscription'), + self._handle_event_subscription)) + + self.xmpp['xep_0131'].supported_headers.add('SubID') + + def plugin_end(self): + self.xmpp.remove_handler('Pubsub Event: Items') + self.xmpp.remove_handler('Pubsub Event: Purge') + self.xmpp.remove_handler('Pubsub Event: Delete') + self.xmpp.remove_handler('Pubsub Event: Configuration') + self.xmpp.remove_handler('Pubsub Event: Subscription') + + def _handle_event_items(self, msg): + """Raise events for publish and retraction notifications.""" + node = msg['pubsub_event']['items']['node'] + + multi = len(msg['pubsub_event']['items']) > 1 + values = {} + if multi: + values = msg.values + del values['pubsub_event'] + + for item in msg['pubsub_event']['items']: + event_name = self.node_event_map.get(node, None) + event_type = 'publish' + if item.name == 'retract': + event_type = 'retract' + + if multi: + condensed = self.xmpp.Message() + condensed.values = values + condensed['pubsub_event']['items']['node'] = node + condensed['pubsub_event']['items'].append(item) + self.xmpp.event('pubsub_%s' % event_type, msg) + if event_name: + self.xmpp.event('%s_%s' % (event_name, event_type), + condensed) + else: + self.xmpp.event('pubsub_%s' % event_type, msg) + if event_name: + self.xmpp.event('%s_%s' % (event_name, event_type), msg) + + def _handle_event_purge(self, msg): + """Raise events for node purge notifications.""" + node = msg['pubsub_event']['purge']['node'] + event_name = self.node_event_map.get(node, None) + + self.xmpp.event('pubsub_purge', msg) + if event_name: + self.xmpp.event('%s_purge' % event_name, msg) + + def _handle_event_delete(self, msg): + """Raise events for node deletion notifications.""" + node = msg['pubsub_event']['delete']['node'] + event_name = self.node_event_map.get(node, None) + + self.xmpp.event('pubsub_delete', msg) + if event_name: + self.xmpp.event('%s_delete' % event_name, msg) + + def _handle_event_configuration(self, msg): + """Raise events for node configuration notifications.""" + node = msg['pubsub_event']['configuration']['node'] + event_name = self.node_event_map.get(node, None) + + self.xmpp.event('pubsub_config', msg) + if event_name: + self.xmpp.event('%s_config' % event_name, msg) + + def _handle_event_subscription(self, msg): + """Raise events for node subscription notifications.""" + node = msg['pubsub_event']['subscription']['node'] + event_name = self.node_event_map.get(node, None) + + self.xmpp.event('pubsub_subscription', msg) + if event_name: + self.xmpp.event('%s_subscription' % event_name, msg) + + def map_node_event(self, node, event_name): + """ + Map node names to events. + + When a pubsub event is received for the given node, + raise the provided event. + + For example:: + + map_node_event('http://jabber.org/protocol/tune', + 'user_tune') + + will produce the events 'user_tune_publish' and 'user_tune_retract' + when the respective notifications are received from the node + 'http://jabber.org/protocol/tune', among other events. + + Arguments: + node -- The node name to map to an event. + event_name -- The name of the event to raise when a + notification from the given node is received. + """ + self.node_event_map[node] = event_name + + def create_node(self, jid, node, config=None, ntype=None, ifrom=None, + timeout_callback=None, 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. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def subscribe(self, jid, node, bare=True, subscribee=None, options=None, + ifrom=None, timeout_callback=None, 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. + timeout -- The length of time (in seconds) to wait for a + response before exiting the send call if blocking + is used. + Defaults to slixmpp.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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def unsubscribe(self, jid, node, subid=None, bare=True, subscribee=None, + ifrom=None, timeout_callback=None, 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 unsubscribe from. + 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 unsubscribing from the node. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a + response before exiting the send call if blocking + is used. + Defaults to slixmpp.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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_subscriptions(self, jid, node=None, ifrom=None, + timeout_callback=None, callback=None, + timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + iq['pubsub']['subscriptions']['node'] = node + return iq.send(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_affiliations(self, jid, node=None, ifrom=None, + timeout_callback=None, callback=None, timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + iq['pubsub']['affiliations']['node'] = node + return iq.send(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_subscription_options(self, jid, node=None, user_jid=None, + ifrom=None, timeout_callback=None, + 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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def set_subscription_options(self, jid, node, user_jid, options, + ifrom=None, timeout_callback=None, + 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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_node_config(self, jid, node=None, ifrom=None, + timeout_callback=None, 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. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_node_subscriptions(self, jid, node, ifrom=None, + timeout_callback=None, 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. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_node_affiliations(self, jid, node, ifrom=None, timeout_callback=None, + 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. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def delete_node(self, jid, node, ifrom=None, timeout_callback=None, 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. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def set_node_config(self, jid, node, config, ifrom=None, + timeout_callback=None, callback=None, timeout=None): + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='set') + iq['pubsub_owner']['configure']['node'] = node + iq['pubsub_owner']['configure'].append(config) + return iq.send(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def publish(self, jid, node, id=None, payload=None, options=None, + ifrom=None, timeout_callback=None, 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. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def retract(self, jid, node, id, notify=None, ifrom=None, + timeout_callback=None, 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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def purge(self, jid, node, ifrom=None, timeout_callback=None, 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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_nodes(self, *args, **kwargs): + """ + Discover the nodes provided by a Pubsub service, using disco. + """ + return self.xmpp['xep_0030'].get_items(*args, **kwargs) + + def get_item(self, jid, node, item_id, ifrom=None, + timeout_callback=None, callback=None, timeout=None): + """ + Retrieve the content of an individual item. + """ + iq = self.xmpp.Iq(sto=jid, sfrom=ifrom, stype='get') + item = stanza.Item() + item['id'] = item_id + iq['pubsub']['items']['node'] = node + iq['pubsub']['items'].append(item) + return iq.send(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_items(self, jid, node, item_ids=None, max_items=None, + iterator=False, ifrom=None, timeout_callback=None, + 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 = 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(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def get_item_ids(self, jid, node, ifrom=None, timeout_callback=None, callback=None, + timeout=None, iterator=False): + """ + Retrieve the ItemIDs hosted by a given node, using disco. + """ + self.xmpp['xep_0030'].get_items(jid, node, ifrom=ifrom, + callback=callback, timeout=timeout, + iterator=iterator, + timeout_callback=timeout_callback) + + def modify_affiliations(self, jid, node, affiliations=None, ifrom=None, + timeout_callback=None, 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 = stanza.OwnerAffiliation() + aff['jid'] = jid + aff['affiliation'] = affiliation + iq['pubsub_owner']['affiliations'].append(aff) + + return iq.send(callback=callback, timeout=timeout, timeout_callback=timeout_callback) + + def modify_subscriptions(self, jid, node, subscriptions=None, + ifrom=None, timeout_callback=None, + 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 = stanza.OwnerSubscription() + sub['jid'] = jid + sub['subscription'] = subscription + iq['pubsub_owner']['subscriptions'].append(sub) + + return iq.send(callback=callback, timeout=timeout, timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0060/stanza/__init__.py b/slixmpp/plugins/xep_0060/stanza/__init__.py new file mode 100644 index 00000000..31c4ac68 --- /dev/null +++ b/slixmpp/plugins/xep_0060/stanza/__init__.py @@ -0,0 +1,12 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.xep_0060.stanza.pubsub import * +from slixmpp.plugins.xep_0060.stanza.pubsub_owner import * +from slixmpp.plugins.xep_0060.stanza.pubsub_event import * +from slixmpp.plugins.xep_0060.stanza.pubsub_errors import * diff --git a/slixmpp/plugins/xep_0060/stanza/base.py b/slixmpp/plugins/xep_0060/stanza/base.py new file mode 100644 index 00000000..b8f3d6cc --- /dev/null +++ b/slixmpp/plugins/xep_0060/stanza/base.py @@ -0,0 +1,29 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/plugins/xep_0060/stanza/pubsub.py b/slixmpp/plugins/xep_0060/stanza/pubsub.py new file mode 100644 index 00000000..b4293918 --- /dev/null +++ b/slixmpp/plugins/xep_0060/stanza/pubsub.py @@ -0,0 +1,272 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp import Iq, Message +from slixmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from slixmpp.plugins import xep_0004 +from slixmpp.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'] + if isinstance(value, ElementBase): + if value.tag_name() in self.plugin_tag_map: + self.init_plugin(value.plugin_attrib, existing_xml=value.xml) + self.xml.append(value.xml) + else: + self.xml.append(value) + + def get_payload(self): + childs = list(self.xml) + if len(childs) > 0: + return childs[0] + + def del_payload(self): + for child in self.xml: + 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) + + +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/slixmpp/plugins/xep_0060/stanza/pubsub_errors.py b/slixmpp/plugins/xep_0060/stanza/pubsub_errors.py new file mode 100644 index 00000000..3e728009 --- /dev/null +++ b/slixmpp/plugins/xep_0060/stanza/pubsub_errors.py @@ -0,0 +1,86 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Error +from slixmpp.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: + 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: + 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/slixmpp/plugins/xep_0060/stanza/pubsub_event.py b/slixmpp/plugins/xep_0060/stanza/pubsub_event.py new file mode 100644 index 00000000..1ba97c75 --- /dev/null +++ b/slixmpp/plugins/xep_0060/stanza/pubsub_event.py @@ -0,0 +1,151 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import datetime as dt + +from slixmpp import Message +from slixmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from slixmpp.plugins.xep_0004 import Form +from slixmpp.plugins import xep_0082 + + +class Event(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'event' + plugin_attrib = 'pubsub_event' + interfaces = set() + + +class EventItem(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'item' + plugin_attrib = name + interfaces = set(('id', 'payload', 'node', 'publisher')) + + def set_payload(self, value): + self.xml.append(value) + + def get_payload(self): + childs = list(self.xml) + if len(childs) > 0: + return childs[0] + + def del_payload(self): + for child in self.xml: + 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',)) + + +class EventPurge(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'purge' + plugin_attrib = name + interfaces = set(('node',)) + + +class EventDelete(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'delete' + plugin_attrib = name + interfaces = set(('node', 'redirect')) + + def set_redirect(self, uri): + del self['redirect'] + redirect = ET.Element('{%s}redirect' % self.namespace) + redirect.attrib['uri'] = uri + self.xml.append(redirect) + + def get_redirect(self): + redirect = self.xml.find('{%s}redirect' % self.namespace) + if redirect is not None: + return redirect.attrib.get('uri', '') + return '' + + def del_redirect(self): + redirect = self.xml.find('{%s}redirect' % self.namespace) + if redirect is not None: + self.xml.remove(redirect) + + +class EventSubscription(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'subscription' + plugin_attrib = name + interfaces = set(('node', 'expiry', 'jid', 'subid', 'subscription')) + + def get_expiry(self): + expiry = self._get_attr('expiry') + if expiry.lower() == 'presence': + return expiry + return xep_0082.parse(expiry) + + def set_expiry(self, value): + if isinstance(value, dt.datetime): + value = xep_0082.format_datetime(value) + self._set_attr('expiry', value) + + 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, EventPurge) +register_stanza_plugin(Event, EventDelete) +register_stanza_plugin(Event, EventItems) +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/slixmpp/plugins/xep_0060/stanza/pubsub_owner.py b/slixmpp/plugins/xep_0060/stanza/pubsub_owner.py new file mode 100644 index 00000000..402e5e30 --- /dev/null +++ b/slixmpp/plugins/xep_0060/stanza/pubsub_owner.py @@ -0,0 +1,134 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp import Iq +from slixmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from slixmpp.plugins.xep_0004 import Form +from slixmpp.plugins.xep_0060.stanza.base import OptionalSetting +from slixmpp.plugins.xep_0060.stanza.pubsub import Affiliations, Affiliation +from slixmpp.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): + del self['from'] + self.append(value) + 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): + name = 'subscriptions' + namespace = 'http://jabber.org/protocol/pubsub#owner' + plugin_attrib = name + 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/slixmpp/plugins/xep_0065/__init__.py b/slixmpp/plugins/xep_0065/__init__.py new file mode 100644 index 00000000..c392bd23 --- /dev/null +++ b/slixmpp/plugins/xep_0065/__init__.py @@ -0,0 +1,8 @@ +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0065.socks5 import Socks5Protocol +from slixmpp.plugins.xep_0065.stanza import Socks5 +from slixmpp.plugins.xep_0065.proxy import XEP_0065 + + +register_plugin(XEP_0065) diff --git a/slixmpp/plugins/xep_0065/proxy.py b/slixmpp/plugins/xep_0065/proxy.py new file mode 100644 index 00000000..c5d358dd --- /dev/null +++ b/slixmpp/plugins/xep_0065/proxy.py @@ -0,0 +1,279 @@ +import asyncio +import logging +import socket + +from hashlib import sha1 +from uuid import uuid4 + +from slixmpp.stanza import Iq +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.base import BasePlugin + +from slixmpp.plugins.xep_0065 import stanza, Socks5, Socks5Protocol + + +log = logging.getLogger(__name__) + + +class XEP_0065(BasePlugin): + + name = 'xep_0065' + description = "XEP-0065: SOCKS5 Bytestreams" + dependencies = set(['xep_0030']) + default_config = { + 'auto_accept': False + } + + def plugin_init(self): + register_stanza_plugin(Iq, Socks5) + + self._proxies = {} + self._sessions = {} + self._preauthed_sids = {} + + self.xmpp.register_handler( + Callback('Socks5 Bytestreams', + StanzaPath('iq@type=set/socks/streamhost'), + self._handle_streamhost)) + + self.api.register(self._authorized, 'authorized', default=True) + self.api.register(self._authorized_sid, 'authorized_sid', default=True) + self.api.register(self._preauthorize_sid, 'preauthorize_sid', default=True) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(Socks5.namespace) + + def plugin_end(self): + self.xmpp.remove_handler('Socks5 Bytestreams') + self.xmpp.remove_handler('Socks5 Streamhost Used') + self.xmpp['xep_0030'].del_feature(feature=Socks5.namespace) + + def get_socket(self, sid): + """Returns the socket associated to the SID.""" + return self._sessions.get(sid, None) + + def handshake(self, to, ifrom=None, sid=None, timeout=None): + """ Starts the handshake to establish the socks5 bytestreams + connection. + """ + if not self._proxies: + self._proxies = yield from self.discover_proxies() + + if sid is None: + sid = uuid4().hex + + used = yield from self.request_stream(to, sid=sid, ifrom=ifrom, timeout=timeout) + proxy = used['socks']['streamhost_used']['jid'] + + if proxy not in self._proxies: + log.warning('Received unknown SOCKS5 proxy: %s', proxy) + return + + try: + self._sessions[sid] = (yield from self._connect_proxy( + self._get_dest_sha1(sid, self.xmpp.boundjid, to), + self._proxies[proxy][0], + self._proxies[proxy][1]))[1] + except socket.error: + return None + addr, port = yield from self._sessions[sid].connected + + # Request that the proxy activate the session with the target. + yield from self.activate(proxy, sid, to, timeout=timeout) + sock = self.get_socket(sid) + self.xmpp.event('stream:%s:%s' % (sid, to), sock) + return sock + + def request_stream(self, to, sid=None, ifrom=None, timeout=None, callback=None): + if sid is None: + sid = uuid4().hex + + # Requester initiates S5B negotiation with Target by sending + # IQ-set that includes the JabberID and network address of + # StreamHost as well as the StreamID (SID) of the proposed + # bytestream. + iq = self.xmpp.Iq() + iq['to'] = to + iq['from'] = ifrom + iq['type'] = 'set' + iq['socks']['sid'] = sid + for proxy, (host, port) in self._proxies.items(): + iq['socks'].add_streamhost(proxy, host, port) + return iq.send(timeout=timeout, callback=callback) + + def discover_proxies(self, jid=None, ifrom=None, timeout=None): + """Auto-discover the JIDs of SOCKS5 proxies on an XMPP server.""" + if jid is None: + if self.xmpp.is_component: + jid = self.xmpp.server + else: + jid = self.xmpp.boundjid.server + + discovered = set() + + disco_items = yield from self.xmpp['xep_0030'].get_items(jid, timeout=timeout) + disco_items = {item[0] for item in disco_items['disco_items']['items']} + + disco_info_futures = {} + for item in disco_items: + disco_info_futures[item] = self.xmpp['xep_0030'].get_info(item, timeout=timeout) + + for item in disco_items: + try: + disco_info = yield from disco_info_futures[item] + except XMPPError: + continue + else: + # Verify that the identity is a bytestream proxy. + identities = disco_info['disco_info']['identities'] + for identity in identities: + if identity[0] == 'proxy' and identity[1] == 'bytestreams': + discovered.add(disco_info['from']) + + for jid in discovered: + try: + addr = yield from self.get_network_address(jid, ifrom=ifrom, timeout=timeout) + self._proxies[jid] = (addr['socks']['streamhost']['host'], + addr['socks']['streamhost']['port']) + except XMPPError: + continue + + return self._proxies + + def get_network_address(self, proxy, ifrom=None, timeout=None, callback=None): + """Get the network information of a proxy.""" + iq = self.xmpp.Iq(sto=proxy, stype='get', sfrom=ifrom) + iq.enable('socks') + return iq.send(timeout=timeout, callback=callback) + + def _get_dest_sha1(self, sid, requester, target): + # The hostname MUST be SHA1(SID + Requester JID + Target JID) + # where the output is hexadecimal-encoded (not binary). + digest = sha1() + digest.update(sid.encode('utf8')) + digest.update(str(requester).encode('utf8')) + digest.update(str(target).encode('utf8')) + return digest.hexdigest() + + def _handle_streamhost(self, iq): + """Handle incoming SOCKS5 session request.""" + sid = iq['socks']['sid'] + if not sid: + raise XMPPError(etype='modify', condition='bad-request') + + if not self._accept_stream(iq): + raise XMPPError(etype='modify', condition='not-acceptable') + + streamhosts = iq['socks']['streamhosts'] + requester = iq['from'] + target = iq['to'] + + dest = self._get_dest_sha1(sid, requester, target) + + proxy_futures = [] + for streamhost in streamhosts: + proxy_futures.append(self._connect_proxy( + dest, + streamhost['host'], + streamhost['port'])) + + @asyncio.coroutine + def gather(futures, iq, streamhosts): + proxies = yield from asyncio.gather(*futures, return_exceptions=True) + for streamhost, proxy in zip(streamhosts, proxies): + if isinstance(proxy, ValueError): + continue + elif isinstance(proxy, socket.error): + log.error('Socket error while connecting to the proxy.') + continue + proxy = proxy[1] + # TODO: what if the future never happens? + try: + addr, port = yield from proxy.connected + except socket.error: + log.exception('Socket error while connecting to the proxy.') + continue + # TODO: make a better choice than just the first working one. + used_streamhost = streamhost['jid'] + conn = proxy + break + else: + raise XMPPError(etype='cancel', condition='item-not-found') + + # TODO: close properly the connection to the other proxies. + + iq = iq.reply() + self._sessions[sid] = conn + iq['socks']['sid'] = sid + iq['socks']['streamhost_used']['jid'] = used_streamhost + iq.send() + self.xmpp.event('socks5_stream', conn) + self.xmpp.event('stream:%s:%s' % (sid, requester), conn) + + asyncio.async(gather(proxy_futures, iq, streamhosts)) + + def activate(self, proxy, sid, target, ifrom=None, timeout=None, callback=None): + """Activate the socks5 session that has been negotiated.""" + iq = self.xmpp.Iq(sto=proxy, stype='set', sfrom=ifrom) + iq['socks']['sid'] = sid + iq['socks']['activate'] = target + return iq.send(timeout=timeout, callback=callback) + + def deactivate(self, sid): + """Closes the proxy socket associated with this SID.""" + sock = self._sessions.get(sid) + if sock: + try: + # sock.close() will also delete sid from self._sessions (see _connect_proxy) + sock.close() + except socket.error: + pass + # Though this should not be neccessary remove the closed session anyway + if sid in self._sessions: + log.warn(('SOCKS5 session with sid = "%s" was not ' + + 'removed from _sessions by sock.close()') % sid) + del self._sessions[sid] + + def close(self): + """Closes all proxy sockets.""" + for sid, sock in self._sessions.items(): + sock.close() + self._sessions = {} + + def _connect_proxy(self, dest, proxy, proxy_port): + """ Returns a future to a connection between the client and the server-side + Socks5 proxy. + + dest : The SHA-1 of (SID + Requester JID + Target JID), in hex. <str> + host : The hostname or the IP of the proxy. <str> + port : The port of the proxy. <str> or <int> + """ + factory = lambda: Socks5Protocol(dest, 0, self.xmpp.event) + return self.xmpp.loop.create_connection(factory, proxy, proxy_port) + + def _accept_stream(self, iq): + receiver = iq['to'] + sender = iq['from'] + sid = iq['socks']['sid'] + + if self.api['authorized_sid'](receiver, sid, sender, iq): + return True + return self.api['authorized'](receiver, sid, sender, iq) + + def _authorized(self, jid, sid, ifrom, iq): + return self.auto_accept + + def _authorized_sid(self, jid, sid, ifrom, iq): + log.debug('>>> authed sids: %s', self._preauthed_sids) + log.debug('>>> lookup: %s %s %s', jid, sid, ifrom) + if (jid, sid, ifrom) in self._preauthed_sids: + del self._preauthed_sids[(jid, sid, ifrom)] + return True + return False + + def _preauthorize_sid(self, jid, sid, ifrom, data): + log.debug('>>>> %s %s %s %s', jid, sid, ifrom, data) + self._preauthed_sids[(jid, sid, ifrom)] = True diff --git a/slixmpp/plugins/xep_0065/socks5.py b/slixmpp/plugins/xep_0065/socks5.py new file mode 100644 index 00000000..54267b32 --- /dev/null +++ b/slixmpp/plugins/xep_0065/socks5.py @@ -0,0 +1,265 @@ +'''Pure asyncio implementation of RFC 1928 - SOCKS Protocol Version 5.''' + +import asyncio +import enum +import logging +import socket +import struct + +from slixmpp.stringprep import punycode, StringprepError + + +log = logging.getLogger(__name__) + + +class ProtocolMismatch(Exception): + '''We only implement SOCKS5, no other version or protocol.''' + + +class ProtocolError(Exception): + '''Some protocol error.''' + + +class MethodMismatch(Exception): + '''The server answered with a method we didn’t ask for.''' + + +class MethodUnacceptable(Exception): + '''None of our methods is supported by the server.''' + + +class AddressTypeUnacceptable(Exception): + '''The address type (ATYP) field isn’t one of IPv4, IPv6 or domain name.''' + + +class ReplyError(Exception): + '''The server answered with an error.''' + + possible_values = ( + "succeeded", + "general SOCKS server failure", + "connection not allowed by ruleset", + "Network unreachable", + "Host unreachable", + "Connection refused", + "TTL expired", + "Command not supported", + "Address type not supported", + "Unknown error") + + def __init__(self, result): + if result < 9: + Exception.__init__(self, self.possible_values[result]) + else: + Exception.__init__(self, self.possible_values[9]) + + +class Method(enum.IntEnum): + '''Known methods for a SOCKS5 session.''' + none = 0 + gssapi = 1 + password = 2 + # Methods 3 to 127 are reserved by IANA. + # Methods 128 to 254 are reserved for private use. + unacceptable = 255 + not_yet_selected = -1 + + +class Command(enum.IntEnum): + '''Existing commands for requests.''' + connect = 1 + bind = 2 + udp_associate = 3 + + +class AddressType(enum.IntEnum): + '''Existing address types.''' + ipv4 = 1 + domain = 3 + ipv6 = 4 + + +class Socks5Protocol(asyncio.Protocol): + '''This implements SOCKS5 as an asyncio protocol.''' + + def __init__(self, dest_addr, dest_port, event): + self.methods = {Method.none} + self.selected_method = Method.not_yet_selected + self.transport = None + self.dest = (dest_addr, dest_port) + self.connected = asyncio.Future() + self.event = event + self.paused = asyncio.Future() + self.paused.set_result(None) + + def register_method(self, method): + '''Register a SOCKS5 method.''' + self.methods.add(method) + + def unregister_method(self, method): + '''Unregister a SOCKS5 method.''' + self.methods.remove(method) + + def connection_made(self, transport): + '''Called when the connection to the SOCKS5 server is established.''' + + log.debug('SOCKS5 connection established.') + + self.transport = transport + self._send_methods() + + def data_received(self, data): + '''Called when we received some data from the SOCKS5 server.''' + + log.debug('SOCKS5 message received.') + + # If we are already connected, this is a data packet. + if self.connected.done(): + return self.event('socks5_data', data) + + # Every SOCKS5 message starts with the protocol version. + if data[0] != 5: + raise ProtocolMismatch() + + # Then select the correct handler for the data we just received. + if self.selected_method == Method.not_yet_selected: + self._handle_method(data) + else: + self._handle_connect(data) + + def connection_lost(self, exc): + log.debug('SOCKS5 connection closed.') + self.event('socks5_closed', exc) + + def pause_writing(self): + self.paused = asyncio.Future() + + def resume_writing(self): + self.paused.set_result(None) + + def write(self, data): + yield from self.paused + self.transport.write(data) + + def _send_methods(self): + '''Send the methods request, first thing a client should do.''' + + # Create the buffer for our request. + request = bytearray(len(self.methods) + 2) + + # Protocol version. + request[0] = 5 + + # Number of methods to send. + request[1] = len(self.methods) + + # List every method we support. + for i, method in enumerate(self.methods): + request[i + 2] = method + + # Send the request. + self.transport.write(request) + + def _send_request(self, command): + '''Send a request, should be done after having negociated a method.''' + + # Encode the destination address to embed it in our request. + # We need to do that first because its length is variable. + address, port = self.dest + addr = self._encode_addr(address) + + # Create the buffer for our request. + request = bytearray(5 + len(addr)) + + # Protocol version. + request[0] = 5 + + # Specify the command we want to use. + request[1] = command + + # request[2] is reserved, keeping it at 0. + + # Add our destination address and port. + request[3:3+len(addr)] = addr + request[-2:] = struct.pack('>H', port) + + # Send the request. + log.debug('SOCKS5 message sent.') + self.transport.write(request) + + def _handle_method(self, data): + '''Handle a method reply from the server.''' + + if len(data) != 2: + raise ProtocolError() + selected_method = data[1] + if selected_method not in self.methods: + raise MethodMismatch() + if selected_method == Method.unacceptable: + raise MethodUnacceptable() + self.selected_method = selected_method + self._send_request(Command.connect) + + def _handle_connect(self, data): + '''Handle a connect reply from the server.''' + + try: + addr, port = self._parse_result(data) + except ReplyError as exception: + self.connected.set_exception(exception) + self.connected.set_result((addr, port)) + self.event('socks5_connected', (addr, port)) + + def _parse_result(self, data): + '''Parse a reply from the server.''' + + result = data[1] + if result != 0: + raise ReplyError(result) + addr = self._parse_addr(data[3:-2]) + port = struct.unpack('>H', data[-2:])[0] + return (addr, port) + + @staticmethod + def _parse_addr(addr): + '''Parse an address (IP or domain) from a bytestream.''' + + addr_type = addr[0] + if addr_type == AddressType.ipv6: + try: + return socket.inet_ntop(socket.AF_INET6, addr[1:]) + except ValueError as e: + raise AddressTypeUnacceptable(e) + if addr_type == AddressType.ipv4: + try: + return socket.inet_ntop(socket.AF_INET, addr[1:]) + except ValueError as e: + raise AddressTypeUnacceptable(e) + if addr_type == AddressType.domain: + length = addr[1] + address = addr[2:] + if length != len(address): + raise Exception('Size mismatch') + return address.decode() + raise AddressTypeUnacceptable(addr_type) + + @staticmethod + def _encode_addr(addr): + '''Encode an address (IP or domain) into a bytestream.''' + + try: + ipv6 = socket.inet_pton(socket.AF_INET6, addr) + return b'\x04' + ipv6 + except OSError: + pass + try: + ipv4 = socket.inet_aton(addr) + return b'\x01' + ipv4 + except OSError: + pass + try: + domain = punycode(addr) + return b'\x03' + bytes([len(domain)]) + domain + except StringprepError: + pass + raise Exception('Err…') diff --git a/slixmpp/plugins/xep_0065/stanza.py b/slixmpp/plugins/xep_0065/stanza.py new file mode 100644 index 00000000..5ba15b32 --- /dev/null +++ b/slixmpp/plugins/xep_0065/stanza.py @@ -0,0 +1,47 @@ +from slixmpp.jid import JID +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + + +class Socks5(ElementBase): + name = 'query' + namespace = 'http://jabber.org/protocol/bytestreams' + plugin_attrib = 'socks' + interfaces = set(['sid', 'activate']) + sub_interfaces = set(['activate']) + + def add_streamhost(self, jid, host, port): + sh = StreamHost(parent=self) + sh['jid'] = jid + sh['host'] = host + sh['port'] = port + + +class StreamHost(ElementBase): + name = 'streamhost' + namespace = 'http://jabber.org/protocol/bytestreams' + plugin_attrib = 'streamhost' + plugin_multi_attrib = 'streamhosts' + interfaces = set(['host', 'jid', 'port']) + + def set_jid(self, value): + return self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +class StreamHostUsed(ElementBase): + name = 'streamhost-used' + namespace = 'http://jabber.org/protocol/bytestreams' + plugin_attrib = 'streamhost_used' + interfaces = set(['jid']) + + def set_jid(self, value): + return self._set_attr('jid', str(value)) + + def get_jid(self): + return JID(self._get_attr('jid')) + + +register_stanza_plugin(Socks5, StreamHost, iterable=True) +register_stanza_plugin(Socks5, StreamHostUsed) diff --git a/slixmpp/plugins/xep_0066/__init__.py b/slixmpp/plugins/xep_0066/__init__.py new file mode 100644 index 00000000..7f7e0ebd --- /dev/null +++ b/slixmpp/plugins/xep_0066/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0066 import stanza +from slixmpp.plugins.xep_0066.stanza import OOB, OOBTransfer +from slixmpp.plugins.xep_0066.oob import XEP_0066 + + +register_plugin(XEP_0066) + + +# Retain some backwards compatibility +xep_0066 = XEP_0066 diff --git a/slixmpp/plugins/xep_0066/oob.py b/slixmpp/plugins/xep_0066/oob.py new file mode 100644 index 00000000..c9d4ae5b --- /dev/null +++ b/slixmpp/plugins/xep_0066/oob.py @@ -0,0 +1,158 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza import Message, Presence, Iq +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0066 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0066(BasePlugin): + + """ + 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. + """ + + name = 'xep_0066' + description = 'XEP-0066: Out of Band Data' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + """Start the XEP-0066 plugin.""" + + 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 plugin_end(self): + self.xmpp.remove_handler('OOB Transfer') + self.xmpp['xep_0030'].del_feature(feature=stanza.OOBTransfer.namespace) + self.xmpp['xep_0030'].del_feature(feature=stanza.OOB.namespace) + + def session_bind(self, jid): + 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'][iq['to']](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/slixmpp/plugins/xep_0066/stanza.py b/slixmpp/plugins/xep_0066/stanza.py new file mode 100644 index 00000000..e1da5bdd --- /dev/null +++ b/slixmpp/plugins/xep_0066/stanza.py @@ -0,0 +1,33 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/plugins/xep_0071/__init__.py b/slixmpp/plugins/xep_0071/__init__.py new file mode 100644 index 00000000..19275c7a --- /dev/null +++ b/slixmpp/plugins/xep_0071/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0071.stanza import XHTML_IM +from slixmpp.plugins.xep_0071.xhtml_im import XEP_0071 + + +register_plugin(XEP_0071) diff --git a/slixmpp/plugins/xep_0071/stanza.py b/slixmpp/plugins/xep_0071/stanza.py new file mode 100644 index 00000000..3df686cf --- /dev/null +++ b/slixmpp/plugins/xep_0071/stanza.py @@ -0,0 +1,81 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Message +from slixmpp.util import unicode +from collections import OrderedDict +from slixmpp.xmlstream import ElementBase, ET, register_stanza_plugin, tostring + + +XHTML_NS = 'http://www.w3.org/1999/xhtml' + + +class XHTML_IM(ElementBase): + + namespace = 'http://jabber.org/protocol/xhtml-im' + name = 'html' + interfaces = set(['body']) + lang_interfaces = set(['body']) + plugin_attrib = name + + def set_body(self, content, lang=None): + if lang is None: + lang = self.get_lang() + self.del_body(lang) + if lang == '*': + for sublang, subcontent in content.items(): + self.set_body(subcontent, sublang) + else: + if isinstance(content, type(ET.Element('test'))): + content = unicode(ET.tostring(content)) + else: + content = unicode(content) + header = '<body xmlns="%s"' % XHTML_NS + if lang: + header = '%s xml:lang="%s"' % (header, lang) + content = '%s>%s</body>' % (header, content) + xhtml = ET.fromstring(content) + self.xml.append(xhtml) + + def get_body(self, lang=None): + """Return the contents of the HTML body.""" + if lang is None: + lang = self.get_lang() + + bodies = self.xml.findall('{%s}body' % XHTML_NS) + + if lang == '*': + result = OrderedDict() + for body in bodies: + body_lang = body.attrib.get('{%s}lang' % self.xml_ns, '') + body_result = [] + body_result.append(body.text if body.text else '') + for child in body: + body_result.append(tostring(child, xmlns=XHTML_NS)) + body_result.append(body.tail if body.tail else '') + result[body_lang] = ''.join(body_result) + return result + else: + for body in bodies: + if body.attrib.get('{%s}lang' % self.xml_ns, self.get_lang()) == lang: + result = [] + result.append(body.text if body.text else '') + for child in body: + result.append(tostring(child, xmlns=XHTML_NS)) + result.append(body.tail if body.tail else '') + return ''.join(result) + return '' + + def del_body(self, lang=None): + if lang is None: + lang = self.get_lang() + bodies = self.xml.findall('{%s}body' % XHTML_NS) + for body in bodies: + if body.attrib.get('{%s}lang' % self.xml_ns, self.get_lang()) == lang: + self.xml.remove(body) + return diff --git a/slixmpp/plugins/xep_0071/xhtml_im.py b/slixmpp/plugins/xep_0071/xhtml_im.py new file mode 100644 index 00000000..0b412126 --- /dev/null +++ b/slixmpp/plugins/xep_0071/xhtml_im.py @@ -0,0 +1,30 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +from slixmpp.stanza import Message +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0071 import stanza, XHTML_IM + + +class XEP_0071(BasePlugin): + + name = 'xep_0071' + description = 'XEP-0071: XHTML-IM' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, XHTML_IM) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(feature=XHTML_IM.namespace) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=XHTML_IM.namespace) diff --git a/slixmpp/plugins/xep_0077/__init__.py b/slixmpp/plugins/xep_0077/__init__.py new file mode 100644 index 00000000..a73cfae9 --- /dev/null +++ b/slixmpp/plugins/xep_0077/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0077.stanza import Register, RegisterFeature +from slixmpp.plugins.xep_0077.register import XEP_0077 + + +register_plugin(XEP_0077) + + +# Retain some backwards compatibility +xep_0077 = XEP_0077 diff --git a/slixmpp/plugins/xep_0077/register.py b/slixmpp/plugins/xep_0077/register.py new file mode 100644 index 00000000..eb2e7443 --- /dev/null +++ b/slixmpp/plugins/xep_0077/register.py @@ -0,0 +1,114 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import ssl + +from slixmpp.stanza import StreamFeatures, Iq +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0077 import stanza, Register, RegisterFeature + + +log = logging.getLogger(__name__) + + +class XEP_0077(BasePlugin): + + """ + XEP-0077: In-Band Registration + """ + + name = 'xep_0077' + description = 'XEP-0077: In-Band Registration' + dependencies = set(['xep_0004', 'xep_0066']) + stanza = stanza + default_config = { + 'create_account': True, + 'force_registration': False, + 'order': 50 + } + + def plugin_init(self): + register_stanza_plugin(StreamFeatures, RegisterFeature) + register_stanza_plugin(Iq, Register) + + if not self.xmpp.is_component: + self.xmpp.register_feature('register', + self._handle_register_feature, + restart=False, + order=self.order) + + register_stanza_plugin(Register, self.xmpp['xep_0004'].stanza.Form) + register_stanza_plugin(Register, self.xmpp['xep_0066'].stanza.OOB) + + self.xmpp.add_event_handler('connected', self._force_registration) + + def plugin_end(self): + if not self.xmpp.is_component: + self.xmpp.unregister_feature('register', self.order) + + def _force_registration(self, event): + if self.force_registration: + self.xmpp.add_filter('in', self._force_stream_feature) + + def _force_stream_feature(self, stanza): + if isinstance(stanza, StreamFeatures): + if self.xmpp.use_tls or self.xmpp.use_ssl: + if 'starttls' not in self.xmpp.features: + return stanza + elif not isinstance(self.xmpp.socket, ssl.SSLSocket): + return stanza + if 'mechanisms' not in self.xmpp.features: + log.debug('Forced adding in-band registration stream feature') + stanza.enable('register') + self.xmpp.del_filter('in', self._force_stream_feature) + return stanza + + def _handle_register_feature(self, features): + if 'mechanisms' in self.xmpp.features: + # We have already logged in with an account + return False + + if self.create_account and self.xmpp.event_handled('register'): + form = self.get_registration() + self.xmpp.event('register', form) + return True + return False + + def get_registration(self, jid=None, ifrom=None, + timeout=None, callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['to'] = jid + iq['from'] = ifrom + iq.enable('register') + return iq.send(timeout=timeout, callback=callback) + + def cancel_registration(self, jid=None, ifrom=None, + timeout=None, callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + iq['from'] = ifrom + iq['register']['remove'] = True + return iq.send(timeout=timeout, callback=callback) + + def change_password(self, password, jid=None, ifrom=None, + timeout=None, callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + iq['from'] = ifrom + if self.xmpp.is_component: + ifrom = JID(ifrom) + iq['register']['username'] = ifrom.user + else: + iq['register']['username'] = self.xmpp.boundjid.user + iq['register']['password'] = password + return iq.send(timeout=timeout, callback=callback) diff --git a/slixmpp/plugins/xep_0077/stanza.py b/slixmpp/plugins/xep_0077/stanza.py new file mode 100644 index 00000000..6ac543c2 --- /dev/null +++ b/slixmpp/plugins/xep_0077/stanza.py @@ -0,0 +1,73 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from __future__ import unicode_literals + +from slixmpp.xmlstream import ElementBase, ET + + +class Register(ElementBase): + + namespace = 'jabber:iq:register' + name = 'query' + plugin_attrib = 'register' + interfaces = set(('username', 'password', 'email', 'nick', 'name', + 'first', 'last', 'address', 'city', 'state', 'zip', + 'phone', 'url', 'date', 'misc', 'text', 'key', + 'registered', 'remove', 'instructions', 'fields')) + sub_interfaces = interfaces + form_fields = set(('username', 'password', 'email', 'nick', 'name', + 'first', 'last', 'address', 'city', 'state', 'zip', + 'phone', 'url', 'date', 'misc', 'text', 'key')) + + def get_registered(self): + present = self.xml.find('{%s}registered' % self.namespace) + return present is not None + + def get_remove(self): + present = self.xml.find('{%s}remove' % self.namespace) + return present is not None + + def set_registered(self, value): + if value: + self.add_field('registered') + else: + del self['registered'] + + def set_remove(self, value): + if value: + self.add_field('remove') + else: + del self['remove'] + + def add_field(self, value): + self._set_sub_text(value, '', keep=True) + + def get_fields(self): + fields = set() + for field in self.form_fields: + if self.xml.find('{%s}%s' % (self.namespace, field)) is not None: + fields.add(field) + return fields + + def set_fields(self, fields): + del self['fields'] + for field in fields: + self._set_sub_text(field, '', keep=True) + + def del_fields(self): + for field in self.form_fields: + self._del_sub(field) + + +class RegisterFeature(ElementBase): + + name = 'register' + namespace = 'http://jabber.org/features/iq-register' + plugin_attrib = name + interfaces = set() diff --git a/slixmpp/plugins/xep_0078/__init__.py b/slixmpp/plugins/xep_0078/__init__.py new file mode 100644 index 00000000..21bdc19e --- /dev/null +++ b/slixmpp/plugins/xep_0078/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0078 import stanza +from slixmpp.plugins.xep_0078.stanza import IqAuth, AuthFeature +from slixmpp.plugins.xep_0078.legacyauth import XEP_0078 + + +register_plugin(XEP_0078) + + +# Retain some backwards compatibility +xep_0078 = XEP_0078 diff --git a/slixmpp/plugins/xep_0078/legacyauth.py b/slixmpp/plugins/xep_0078/legacyauth.py new file mode 100644 index 00000000..d949a913 --- /dev/null +++ b/slixmpp/plugins/xep_0078/legacyauth.py @@ -0,0 +1,139 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import uuid +import logging +import hashlib + +from slixmpp.jid import JID +from slixmpp.exceptions import IqError, IqTimeout +from slixmpp.stanza import Iq, StreamFeatures +from slixmpp.xmlstream import ElementBase, ET, register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0078 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0078(BasePlugin): + + """ + 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. + """ + + name = 'xep_0078' + description = 'XEP-0078: Non-SASL Authentication' + dependencies = set() + stanza = stanza + default_config = { + 'order': 15 + } + + def plugin_init(self): + self.xmpp.register_feature('auth', + self._handle_auth, + restart=False, + order=self.order) + + self.xmpp.add_event_handler('legacy_protocol', + self._handle_legacy_protocol) + + register_stanza_plugin(Iq, stanza.IqAuth) + register_stanza_plugin(StreamFeatures, stanza.AuthFeature) + + def plugin_end(self): + self.xmpp.del_event_handler('legacy_protocol', + self._handle_legacy_protocol) + self.xmpp.unregister_feature('auth', self.order) + + def _handle_auth(self, features): + # If we can or have already authenticated with SASL, do nothing. + if 'mechanisms' in features['features']: + return False + return self.authenticate() + + def _handle_legacy_protocol(self, event): + self.authenticate() + + def authenticate(self): + 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.requested_jid.host + iq['auth']['username'] = self.xmpp.requested_jid.user + + try: + resp = iq.send() + except IqError as err: + log.info("Authentication failed: %s", err.iq['error']['condition']) + self.xmpp.event('failed_auth') + self.xmpp.disconnect() + return True + except IqTimeout: + log.info("Authentication failed: %s", 'timeout') + self.xmpp.event('failed_auth') + 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.requested_jid.user + + # A resource is required, so create a random one if necessary + resource = self.xmpp.requested_jid.resource + if not resource: + resource = str(uuid.uuid4()) + + iq['auth']['resource'] = resource + + if 'digest' in resp['auth']['fields']: + log.debug('Authenticating via jabber:iq:auth Digest') + 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() + except IqError as err: + log.info("Authentication failed") + self.xmpp.event("failed_auth") + self.xmpp.disconnect() + except IqTimeout: + log.info("Authentication failed") + self.xmpp.event("failed_auth") + self.xmpp.disconnect() + + self.xmpp.features.add('auth') + + self.xmpp.authenticated = True + + self.xmpp.boundjid = JID(self.xmpp.requested_jid) + self.xmpp.boundjid.resource = resource + self.xmpp.event('session_bind', self.xmpp.boundjid) + + log.debug("Established Session") + self.xmpp.sessionstarted = True + self.xmpp.event('session_start') + + return True diff --git a/slixmpp/plugins/xep_0078/stanza.py b/slixmpp/plugins/xep_0078/stanza.py new file mode 100644 index 00000000..7dc9401d --- /dev/null +++ b/slixmpp/plugins/xep_0078/stanza.py @@ -0,0 +1,41 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/plugins/xep_0079/__init__.py b/slixmpp/plugins/xep_0079/__init__.py new file mode 100644 index 00000000..864c9018 --- /dev/null +++ b/slixmpp/plugins/xep_0079/__init__.py @@ -0,0 +1,18 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0079.stanza import ( + AMP, Rule, InvalidRules, UnsupportedConditions, + UnsupportedActions, FailedRules, FailedRule, + AMPFeature) +from slixmpp.plugins.xep_0079.amp import XEP_0079 + + +register_plugin(XEP_0079) diff --git a/slixmpp/plugins/xep_0079/amp.py b/slixmpp/plugins/xep_0079/amp.py new file mode 100644 index 00000000..6e65d02a --- /dev/null +++ b/slixmpp/plugins/xep_0079/amp.py @@ -0,0 +1,79 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import logging + +from slixmpp.stanza import Message, Error, StreamFeatures +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.matcher import StanzaPath, MatchMany +from slixmpp.xmlstream.handler import Callback +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0079 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0079(BasePlugin): + + """ + XEP-0079 Advanced Message Processing + """ + + name = 'xep_0079' + description = 'XEP-0079: Advanced Message Processing' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, stanza.AMP) + register_stanza_plugin(Error, stanza.InvalidRules) + register_stanza_plugin(Error, stanza.UnsupportedConditions) + register_stanza_plugin(Error, stanza.UnsupportedActions) + register_stanza_plugin(Error, stanza.FailedRules) + + self.xmpp.register_handler( + Callback('AMP Response', + MatchMany([ + StanzaPath('message/error/failed_rules'), + StanzaPath('message/amp') + ]), + self._handle_amp_response)) + + if not self.xmpp.is_component: + self.xmpp.register_feature('amp', + self._handle_amp_feature, + restart=False, + order=9000) + register_stanza_plugin(StreamFeatures, stanza.AMPFeature) + + def plugin_end(self): + self.xmpp.remove_handler('AMP Response') + + def _handle_amp_response(self, msg): + log.debug('>>>>>>>>>>>>>>>>>>>>>>>>>>>>>') + if msg['type'] == 'error': + self.xmpp.event('amp_error', msg) + elif msg['amp']['status'] in ('alert', 'notify'): + self.xmpp.event('amp_%s' % msg['amp']['status'], msg) + + def _handle_amp_feature(self, features): + log.debug('Advanced Message Processing is available.') + self.xmpp.features.add('amp') + + def discover_support(self, jid=None, **iqargs): + if jid is None: + if self.xmpp.is_component: + jid = self.xmpp.server_host + else: + jid = self.xmpp.boundjid.host + + return self.xmpp['xep_0030'].get_info( + jid=jid, + node='http://jabber.org/protocol/amp', + **iqargs) diff --git a/slixmpp/plugins/xep_0079/stanza.py b/slixmpp/plugins/xep_0079/stanza.py new file mode 100644 index 00000000..e3e1553a --- /dev/null +++ b/slixmpp/plugins/xep_0079/stanza.py @@ -0,0 +1,96 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from __future__ import unicode_literals + +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + + +class AMP(ElementBase): + namespace = 'http://jabber.org/protocol/amp' + name = 'amp' + plugin_attrib = 'amp' + interfaces = set(['from', 'to', 'status', 'per_hop']) + + def get_from(self): + return JID(self._get_attr('from')) + + def set_from(self, value): + return self._set_attr('from', str(value)) + + def get_to(self): + return JID(self._get_attr('from')) + + def set_to(self, value): + return self._set_attr('to', str(value)) + + def get_per_hop(self): + return self._get_attr('per-hop') == 'true' + + def set_per_hop(self, value): + if value: + return self._set_attr('per-hop', 'true') + else: + return self._del_attr('per-hop') + + def del_per_hop(self): + return self._del_attr('per-hop') + + def add_rule(self, action, condition, value): + rule = Rule(parent=self) + rule['action'] = action + rule['condition'] = condition + rule['value'] = value + + +class Rule(ElementBase): + namespace = 'http://jabber.org/protocol/amp' + name = 'rule' + plugin_attrib = name + plugin_multi_attrib = 'rules' + interfaces = set(['action', 'condition', 'value']) + + +class InvalidRules(ElementBase): + namespace = 'http://jabber.org/protocol/amp' + name = 'invalid-rules' + plugin_attrib = 'invalid_rules' + + +class UnsupportedConditions(ElementBase): + namespace = 'http://jabber.org/protocol/amp' + name = 'unsupported-conditions' + plugin_attrib = 'unsupported_conditions' + + +class UnsupportedActions(ElementBase): + namespace = 'http://jabber.org/protocol/amp' + name = 'unsupported-actions' + plugin_attrib = 'unsupported_actions' + + +class FailedRule(Rule): + namespace = 'http://jabber.org/protocol/amp#errors' + + +class FailedRules(ElementBase): + namespace = 'http://jabber.org/protocol/amp#errors' + name = 'failed-rules' + plugin_attrib = 'failed_rules' + + +class AMPFeature(ElementBase): + namespace = 'http://jabber.org/features/amp' + name = 'amp' + + +register_stanza_plugin(AMP, Rule, iterable=True) +register_stanza_plugin(InvalidRules, Rule, iterable=True) +register_stanza_plugin(UnsupportedConditions, Rule, iterable=True) +register_stanza_plugin(UnsupportedActions, Rule, iterable=True) +register_stanza_plugin(FailedRules, FailedRule, iterable=True) diff --git a/slixmpp/plugins/xep_0080/__init__.py b/slixmpp/plugins/xep_0080/__init__.py new file mode 100644 index 00000000..c487ef9c --- /dev/null +++ b/slixmpp/plugins/xep_0080/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Erik Reuterborg Larsson + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0080.stanza import Geoloc +from slixmpp.plugins.xep_0080.geoloc import XEP_0080 + + +register_plugin(XEP_0080) diff --git a/slixmpp/plugins/xep_0080/geoloc.py b/slixmpp/plugins/xep_0080/geoloc.py new file mode 100644 index 00000000..c9d97edb --- /dev/null +++ b/slixmpp/plugins/xep_0080/geoloc.py @@ -0,0 +1,121 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Erik Reuterborg Larsson + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +import slixmpp +from slixmpp.plugins.base import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0080 import stanza, Geoloc + + +log = logging.getLogger(__name__) + + +class XEP_0080(BasePlugin): + + """ + XEP-0080: User Location + """ + + name = 'xep_0080' + description = 'XEP-0080: User Location' + dependencies = set(['xep_0163']) + stanza = stanza + + def plugin_end(self): + self.xmpp['xep_0163'].remove_interest(Geoloc.namespace) + self.xmpp['xep_0030'].del_feature(feature=Geoloc.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0163'].register_pep('user_location', Geoloc) + + def publish_location(self, **kwargs): + """ + Publish the user's current location. + + Arguments: + accuracy -- Horizontal GPS error in meters. + alt -- Altitude in meters above or below sea level. + area -- A named area such as a campus or neighborhood. + bearing -- GPS bearing (direction in which the entity is + heading to reach its next waypoint), measured in + decimal degrees relative to true north. + building -- A specific building on a street or in an area. + country -- The nation where the user is located. + countrycode -- The ISO 3166 two-letter country code. + datum -- GPS datum. + description -- A natural-language name for or description of + the location. + error -- Horizontal GPS error in arc minutes. Obsoleted by + the accuracy parameter. + floor -- A particular floor in a building. + lat -- Latitude in decimal degrees North. + locality -- A locality within the administrative region, such + as a town or city. + lon -- Longitude in decimal degrees East. + postalcode -- A code used for postal delivery. + region -- An administrative region of the nation, such + as a state or province. + room -- A particular room in a building. + speed -- The speed at which the entity is moving, + in meters per second. + street -- A thoroughfare within the locality, or a crossing + of two thoroughfares. + text -- A catch-all element that captures any other + information about the location. + timestamp -- UTC timestamp specifying the moment when the + reading was taken. + uri -- A URI or URL pointing to information about + the location. + + options -- Optional form of publish options. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + options = kwargs.get('options', None) + ifrom = kwargs.get('ifrom', None) + callback = kwargs.get('callback', None) + timeout = kwargs.get('timeout', None) + timeout_callback = kwargs.get('timeout_callback', None) + for param in ('ifrom', 'block', 'callback', 'timeout', 'options', 'timeout_callback'): + if param in kwargs: + del kwargs[param] + + geoloc = Geoloc() + geoloc.values = kwargs + + return self.xmpp['xep_0163'].publish(geoloc, + options=options, + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + def stop(self, ifrom=None, callback=None, timeout=None, timeout_callback=None): + """ + Clear existing user location information to stop notifications. + + Arguments: + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + geoloc = Geoloc() + return self.xmpp['xep_0163'].publish(geoloc, + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=None) diff --git a/slixmpp/plugins/xep_0080/stanza.py b/slixmpp/plugins/xep_0080/stanza.py new file mode 100644 index 00000000..e25fea4f --- /dev/null +++ b/slixmpp/plugins/xep_0080/stanza.py @@ -0,0 +1,266 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase +from slixmpp.plugins import xep_0082 + + +class Geoloc(ElementBase): + + """ + XMPP's <geoloc> stanza allows entities to know the current + geographical or physical location of an entity. (XEP-0080: User Location) + + Example <geoloc> stanzas: + <geoloc xmlns='http://jabber.org/protocol/geoloc'/> + + <geoloc xmlns='http://jabber.org/protocol/geoloc' xml:lang='en'> + <accuracy>20</accuracy> + <country>Italy</country> + <lat>45.44</lat> + <locality>Venice</locality> + <lon>12.33</lon> + </geoloc> + + Stanza Interface: + accuracy -- Horizontal GPS error in meters. + alt -- Altitude in meters above or below sea level. + area -- A named area such as a campus or neighborhood. + bearing -- GPS bearing (direction in which the entity is + heading to reach its next waypoint), measured in + decimal degrees relative to true north. + building -- A specific building on a street or in an area. + country -- The nation where the user is located. + countrycode -- The ISO 3166 two-letter country code. + datum -- GPS datum. + description -- A natural-language name for or description of + the location. + error -- Horizontal GPS error in arc minutes. Obsoleted by + the accuracy parameter. + floor -- A particular floor in a building. + lat -- Latitude in decimal degrees North. + locality -- A locality within the administrative region, such + as a town or city. + lon -- Longitude in decimal degrees East. + postalcode -- A code used for postal delivery. + region -- An administrative region of the nation, such + as a state or province. + room -- A particular room in a building. + speed -- The speed at which the entity is moving, + in meters per second. + street -- A thoroughfare within the locality, or a crossing + of two thoroughfares. + text -- A catch-all element that captures any other + information about the location. + timestamp -- UTC timestamp specifying the moment when the + reading was taken. + uri -- A URI or URL pointing to information about + the location. + """ + + namespace = 'http://jabber.org/protocol/geoloc' + name = 'geoloc' + interfaces = set(('accuracy', 'alt', 'area', 'bearing', 'building', + 'country', 'countrycode', 'datum', 'dscription', + 'error', 'floor', 'lat', 'locality', 'lon', + 'postalcode', 'region', 'room', 'speed', 'street', + 'text', 'timestamp', 'uri')) + sub_interfaces = interfaces + plugin_attrib = name + + def exception(self, e): + """ + Override exception passback for presence. + """ + pass + + def set_accuracy(self, accuracy): + """ + Set the value of the <accuracy> element. + + Arguments: + accuracy -- Horizontal GPS error in meters + """ + self._set_sub_text('accuracy', text=str(accuracy)) + return self + + def get_accuracy(self): + """ + Return the value of the <accuracy> element as an integer. + """ + p = self._get_sub_text('accuracy') + if not p: + return None + else: + try: + return int(p) + except ValueError: + return None + + def set_alt(self, alt): + """ + Set the value of the <alt> element. + + Arguments: + alt -- Altitude in meters above or below sea level + """ + self._set_sub_text('alt', text=str(alt)) + return self + + def get_alt(self): + """ + Return the value of the <alt> element as an integer. + """ + p = self._get_sub_text('alt') + if not p: + return None + else: + try: + return int(p) + except ValueError: + return None + + def set_bearing(self, bearing): + """ + Set the value of the <bearing> element. + + Arguments: + bearing -- GPS bearing (direction in which the entity is heading + to reach its next waypoint), measured in decimal + degrees relative to true north + """ + self._set_sub_text('bearing', text=str(bearing)) + return self + + def get_bearing(self): + """ + Return the value of the <bearing> element as a float. + """ + p = self._get_sub_text('bearing') + if not p: + return None + else: + try: + return float(p) + except ValueError: + return None + + def set_error(self, error): + """ + Set the value of the <error> element. + + Arguments: + error -- Horizontal GPS error in arc minutes; this + element is deprecated in favor of <accuracy/> + """ + self._set_sub_text('error', text=str(error)) + return self + + def get_error(self): + """ + Return the value of the <error> element as a float. + """ + p = self._get_sub_text('error') + if not p: + return None + else: + try: + return float(p) + except ValueError: + return None + + def set_lat(self, lat): + """ + Set the value of the <lat> element. + + Arguments: + lat -- Latitude in decimal degrees North + """ + self._set_sub_text('lat', text=str(lat)) + return self + + def get_lat(self): + """ + Return the value of the <lat> element as a float. + """ + p = self._get_sub_text('lat') + if not p: + return None + else: + try: + return float(p) + except ValueError: + return None + + def set_lon(self, lon): + """ + Set the value of the <lon> element. + + Arguments: + lon -- Longitude in decimal degrees East + """ + self._set_sub_text('lon', text=str(lon)) + return self + + def get_lon(self): + """ + Return the value of the <lon> element as a float. + """ + p = self._get_sub_text('lon') + if not p: + return None + else: + try: + return float(p) + except ValueError: + return None + + def set_speed(self, speed): + """ + Set the value of the <speed> element. + + Arguments: + speed -- The speed at which the entity is moving, + in meters per second + """ + self._set_sub_text('speed', text=str(speed)) + return self + + def get_speed(self): + """ + Return the value of the <speed> element as a float. + """ + p = self._get_sub_text('speed') + if not p: + return None + else: + try: + return float(p) + except ValueError: + return None + + def set_timestamp(self, timestamp): + """ + Set the value of the <timestamp> element. + + Arguments: + timestamp -- UTC timestamp specifying the moment when + the reading was taken + """ + self._set_sub_text('timestamp', text=str(xep_0082.datetime(timestamp))) + return self + + def get_timestamp(self): + """ + Return the value of the <timestamp> element as a DateTime. + """ + p = self._get_sub_text('timestamp') + if not p: + return None + else: + return xep_0082.datetime(p) diff --git a/slixmpp/plugins/xep_0082.py b/slixmpp/plugins/xep_0082.py new file mode 100644 index 00000000..24436622 --- /dev/null +++ b/slixmpp/plugins/xep_0082.py @@ -0,0 +1,228 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import datetime as dt + +from slixmpp.plugins import BasePlugin, register_plugin +from slixmpp.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(BasePlugin): + + """ + 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. + """ + + name = 'xep_0082' + description = 'XEP-0082: XMPP Date and Time Profiles' + dependencies = set() + + def plugin_init(self): + """Start the XEP-0082 plugin.""" + 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 + + +register_plugin(XEP_0082) diff --git a/slixmpp/plugins/xep_0084/__init__.py b/slixmpp/plugins/xep_0084/__init__.py new file mode 100644 index 00000000..aa5fdae5 --- /dev/null +++ b/slixmpp/plugins/xep_0084/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0084 import stanza +from slixmpp.plugins.xep_0084.stanza import Data, MetaData +from slixmpp.plugins.xep_0084.avatar import XEP_0084 + + +register_plugin(XEP_0084) diff --git a/slixmpp/plugins/xep_0084/avatar.py b/slixmpp/plugins/xep_0084/avatar.py new file mode 100644 index 00000000..e5f9dfaa --- /dev/null +++ b/slixmpp/plugins/xep_0084/avatar.py @@ -0,0 +1,110 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import hashlib +import logging + +from slixmpp import Iq +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.plugins.xep_0084 import stanza, Data, MetaData + + +log = logging.getLogger(__name__) + + +class XEP_0084(BasePlugin): + + name = 'xep_0084' + description = 'XEP-0084: User Avatar' + dependencies = set(['xep_0163', 'xep_0060']) + stanza = stanza + + def plugin_init(self): + pubsub_stanza = self.xmpp['xep_0060'].stanza + register_stanza_plugin(pubsub_stanza.Item, Data) + register_stanza_plugin(pubsub_stanza.EventItem, Data) + + self.xmpp['xep_0060'].map_node_event(Data.namespace, 'avatar_data') + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=MetaData.namespace) + self.xmpp['xep_0163'].remove_interest(MetaData.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0163'].register_pep('avatar_metadata', MetaData) + + def generate_id(self, data): + return hashlib.sha1(data).hexdigest() + + def retrieve_avatar(self, jid, id, url=None, ifrom=None, + callback=None, timeout=None, timeout_callback=None): + return self.xmpp['xep_0060'].get_item(jid, Data.namespace, id, + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + def publish_avatar(self, data, ifrom=None, callback=None, + timeout=None, timeout_callback=None): + payload = Data() + payload['value'] = data + return self.xmpp['xep_0163'].publish(payload, + id=self.generate_id(data), + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + def publish_avatar_metadata(self, items=None, pointers=None, + ifrom=None, + callback=None, timeout=None, + timeout_callback=None): + metadata = MetaData() + if items is None: + items = [] + if not isinstance(items, (list, set)): + items = [items] + for info in items: + metadata.add_info(info['id'], info['type'], info['bytes'], + height=info.get('height', ''), + width=info.get('width', ''), + url=info.get('url', '')) + + if pointers is not None: + for pointer in pointers: + metadata.add_pointer(pointer) + + return self.xmpp['xep_0163'].publish(metadata, + id=info['id'], + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + def stop(self, ifrom=None, callback=None, timeout=None, timeout_callback=None): + """ + Clear existing avatar metadata information to stop notifications. + + Arguments: + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + metadata = MetaData() + return self.xmpp['xep_0163'].publish(metadata, + node=MetaData.namespace, + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0084/stanza.py b/slixmpp/plugins/xep_0084/stanza.py new file mode 100644 index 00000000..ebcd73e3 --- /dev/null +++ b/slixmpp/plugins/xep_0084/stanza.py @@ -0,0 +1,78 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from base64 import b64encode, b64decode + +from slixmpp.util import bytes +from slixmpp.xmlstream import ET, ElementBase, register_stanza_plugin + + +class Data(ElementBase): + name = 'data' + namespace = 'urn:xmpp:avatar:data' + plugin_attrib = 'avatar_data' + interfaces = set(['value']) + + def get_value(self): + if self.xml.text: + return b64decode(bytes(self.xml.text)) + return b'' + + def set_value(self, value): + if value: + self.xml.text = b64encode(bytes(value)).decode() + else: + self.xml.text = '' + + def del_value(self): + self.xml.text = '' + + +class MetaData(ElementBase): + name = 'metadata' + namespace = 'urn:xmpp:avatar:metadata' + plugin_attrib = 'avatar_metadata' + interfaces = set() + + def add_info(self, id, itype, ibytes, height=None, width=None, url=None): + info = Info() + info.values = {'id': id, + 'type': itype, + 'bytes': '%s' % ibytes, + 'height': height, + 'width': width, + 'url': url} + self.append(info) + + def add_pointer(self, xml): + if not isinstance(xml, Pointer): + pointer = Pointer() + pointer.append(xml) + self.append(pointer) + else: + self.append(xml) + + +class Info(ElementBase): + name = 'info' + namespace = 'urn:xmpp:avatar:metadata' + plugin_attrib = 'info' + plugin_multi_attrib = 'items' + interfaces = set(['bytes', 'height', 'id', 'type', 'url', 'width']) + + +class Pointer(ElementBase): + name = 'pointer' + namespace = 'urn:xmpp:avatar:metadata' + plugin_attrib = 'pointer' + plugin_multi_attrib = 'pointers' + interfaces = set() + + +register_stanza_plugin(MetaData, Info, iterable=True) +register_stanza_plugin(MetaData, Pointer, iterable=True) diff --git a/slixmpp/plugins/xep_0085/__init__.py b/slixmpp/plugins/xep_0085/__init__.py new file mode 100644 index 00000000..9b9da990 --- /dev/null +++ b/slixmpp/plugins/xep_0085/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0085.stanza import ChatState +from slixmpp.plugins.xep_0085.chat_states import XEP_0085 + + +register_plugin(XEP_0085) + + +# Retain some backwards compatibility +xep_0085 = XEP_0085 diff --git a/slixmpp/plugins/xep_0085/chat_states.py b/slixmpp/plugins/xep_0085/chat_states.py new file mode 100644 index 00000000..1aab9eaa --- /dev/null +++ b/slixmpp/plugins/xep_0085/chat_states.py @@ -0,0 +1,56 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import logging + +import slixmpp +from slixmpp.stanza import Message +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin, ElementBase, ET +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0085 import stanza, ChatState + + +log = logging.getLogger(__name__) + + +class XEP_0085(BasePlugin): + + """ + XEP-0085 Chat State Notifications + """ + + name = 'xep_0085' + description = 'XEP-0085: Chat State Notifications' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + self.xmpp.register_handler( + Callback('Chat State', + StanzaPath('message/chat_state'), + self._handle_chat_state)) + + register_stanza_plugin(Message, stanza.Active) + register_stanza_plugin(Message, stanza.Composing) + register_stanza_plugin(Message, stanza.Gone) + register_stanza_plugin(Message, stanza.Inactive) + register_stanza_plugin(Message, stanza.Paused) + + def plugin_end(self): + self.xmpp.remove_handler('Chat State') + + def session_bind(self, jid): + 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', msg) + self.xmpp.event('chatstate_%s' % state, msg) diff --git a/slixmpp/plugins/xep_0085/stanza.py b/slixmpp/plugins/xep_0085/stanza.py new file mode 100644 index 00000000..d1a5b151 --- /dev/null +++ b/slixmpp/plugins/xep_0085/stanza.py @@ -0,0 +1,94 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import slixmpp +from slixmpp.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',)) + sub_interfaces = interfaces + 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) + + +class Active(ChatState): + name = 'active' + + +class Composing(ChatState): + name = 'composing' + + +class Gone(ChatState): + name = 'gone' + + +class Inactive(ChatState): + name = 'inactive' + + +class Paused(ChatState): + name = 'paused' diff --git a/slixmpp/plugins/xep_0086/__init__.py b/slixmpp/plugins/xep_0086/__init__.py new file mode 100644 index 00000000..c6946e5a --- /dev/null +++ b/slixmpp/plugins/xep_0086/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0086.stanza import LegacyError +from slixmpp.plugins.xep_0086.legacy_error import XEP_0086 + + +register_plugin(XEP_0086) + + +# Retain some backwards compatibility +xep_0086 = XEP_0086 diff --git a/slixmpp/plugins/xep_0086/legacy_error.py b/slixmpp/plugins/xep_0086/legacy_error.py new file mode 100644 index 00000000..0a6e0e87 --- /dev/null +++ b/slixmpp/plugins/xep_0086/legacy_error.py @@ -0,0 +1,46 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Error +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0086 import stanza, LegacyError + + +class XEP_0086(BasePlugin): + + """ + 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'] = ... + """ + + name = 'xep_0086' + description = 'XEP-0086: Error Condition Mappings' + dependencies = set() + stanza = stanza + default_config = { + 'override': True + } + + def plugin_init(self): + register_stanza_plugin(Error, LegacyError, + overrides=self.override) diff --git a/slixmpp/plugins/xep_0086/stanza.py b/slixmpp/plugins/xep_0086/stanza.py new file mode 100644 index 00000000..cbc9429d --- /dev/null +++ b/slixmpp/plugins/xep_0086/stanza.py @@ -0,0 +1,91 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Error +from slixmpp.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/slixmpp/plugins/xep_0091/__init__.py b/slixmpp/plugins/xep_0091/__init__.py new file mode 100644 index 00000000..e5d166b1 --- /dev/null +++ b/slixmpp/plugins/xep_0091/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0091 import stanza +from slixmpp.plugins.xep_0091.stanza import LegacyDelay +from slixmpp.plugins.xep_0091.legacy_delay import XEP_0091 + + +register_plugin(XEP_0091) diff --git a/slixmpp/plugins/xep_0091/legacy_delay.py b/slixmpp/plugins/xep_0091/legacy_delay.py new file mode 100644 index 00000000..6aef2ba1 --- /dev/null +++ b/slixmpp/plugins/xep_0091/legacy_delay.py @@ -0,0 +1,29 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +from slixmpp.stanza import Message, Presence +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0091 import stanza + + +class XEP_0091(BasePlugin): + + """ + XEP-0091: Legacy Delayed Delivery + """ + + name = 'xep_0091' + description = 'XEP-0091: Legacy Delayed Delivery' + dependencies = set() + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, stanza.LegacyDelay) + register_stanza_plugin(Presence, stanza.LegacyDelay) diff --git a/slixmpp/plugins/xep_0091/stanza.py b/slixmpp/plugins/xep_0091/stanza.py new file mode 100644 index 00000000..ac6457e6 --- /dev/null +++ b/slixmpp/plugins/xep_0091/stanza.py @@ -0,0 +1,47 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import datetime as dt + +from slixmpp.jid import JID +from slixmpp.xmlstream import ElementBase +from slixmpp.plugins import xep_0082 + + +class LegacyDelay(ElementBase): + + name = 'x' + namespace = 'jabber:x:delay' + plugin_attrib = 'legacy_delay' + interfaces = set(('from', 'stamp', 'text')) + + def get_from(self): + from_ = self._get_attr('from') + return JID(from_) if from_ else None + + def set_from(self, value): + self._set_attr('from', str(value)) + + def get_stamp(self): + timestamp = self._get_attr('stamp') + return xep_0082.parse('%sZ' % timestamp) if timestamp else None + + def set_stamp(self, value): + if isinstance(value, dt.datetime): + value = value.astimezone(xep_0082.tzutc) + value = xep_0082.format_datetime(value) + self._set_attr('stamp', value[0:19].replace('-', '')) + + 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/slixmpp/plugins/xep_0092/__init__.py b/slixmpp/plugins/xep_0092/__init__.py new file mode 100644 index 00000000..93743a14 --- /dev/null +++ b/slixmpp/plugins/xep_0092/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0092 import stanza +from slixmpp.plugins.xep_0092.stanza import Version +from slixmpp.plugins.xep_0092.version import XEP_0092 + + +register_plugin(XEP_0092) + + +# Retain some backwards compatibility +xep_0092 = XEP_0092 diff --git a/slixmpp/plugins/xep_0092/stanza.py b/slixmpp/plugins/xep_0092/stanza.py new file mode 100644 index 00000000..04097a8b --- /dev/null +++ b/slixmpp/plugins/xep_0092/stanza.py @@ -0,0 +1,42 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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>Slixmpp</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/slixmpp/plugins/xep_0092/version.py b/slixmpp/plugins/xep_0092/version.py new file mode 100644 index 00000000..ff0317da --- /dev/null +++ b/slixmpp/plugins/xep_0092/version.py @@ -0,0 +1,87 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +import slixmpp +from slixmpp import Iq +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0092 import Version, stanza + + +log = logging.getLogger(__name__) + + +class XEP_0092(BasePlugin): + + """ + XEP-0092: Software Version + """ + + name = 'xep_0092' + description = 'XEP-0092: Software Version' + dependencies = set(['xep_0030']) + stanza = stanza + default_config = { + 'software_name': 'Slixmpp', + 'version': slixmpp.__version__, + 'os': '' + } + + def plugin_init(self): + """ + Start the XEP-0092 plugin. + """ + if 'name' in self.config: + self.software_name = self.config['name'] + + self.xmpp.register_handler( + Callback('Software Version', + StanzaPath('iq@type=get/software_version'), + self._handle_version)) + + register_stanza_plugin(Iq, Version) + + def plugin_end(self): + self.xmpp.remove_handler('Software Version') + self.xmpp['xep_0030'].del_feature(feature='jabber:iq:version') + + def session_bind(self, jid): + 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 = iq.reply() + iq['software_version']['name'] = self.software_name + iq['software_version']['version'] = self.version + iq['software_version']['os'] = self.os + iq.send() + + def get_version(self, jid, ifrom=None, timeout=None, callback=None, + timeout_callback=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 + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0095/__init__.py b/slixmpp/plugins/xep_0095/__init__.py new file mode 100644 index 00000000..3c6380e1 --- /dev/null +++ b/slixmpp/plugins/xep_0095/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0095 import stanza +from slixmpp.plugins.xep_0095.stanza import SI +from slixmpp.plugins.xep_0095.stream_initiation import XEP_0095 + + +register_plugin(XEP_0095) diff --git a/slixmpp/plugins/xep_0095/stanza.py b/slixmpp/plugins/xep_0095/stanza.py new file mode 100644 index 00000000..62b5f6f8 --- /dev/null +++ b/slixmpp/plugins/xep_0095/stanza.py @@ -0,0 +1,25 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class SI(ElementBase): + name = 'si' + namespace = 'http://jabber.org/protocol/si' + plugin_attrib = 'si' + interfaces = set(['id', 'mime_type', 'profile']) + + def get_mime_type(self): + return self._get_attr('mime-type', 'application/octet-stream') + + def set_mime_type(self, value): + self._set_attr('mime-type', value) + + def del_mime_type(self): + self._del_attr('mime-type') diff --git a/slixmpp/plugins/xep_0095/stream_initiation.py b/slixmpp/plugins/xep_0095/stream_initiation.py new file mode 100644 index 00000000..3f909d93 --- /dev/null +++ b/slixmpp/plugins/xep_0095/stream_initiation.py @@ -0,0 +1,213 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import threading + +from uuid import uuid4 + +from slixmpp import Iq, Message +from slixmpp.exceptions import XMPPError +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.plugins.xep_0095 import stanza, SI + + +log = logging.getLogger(__name__) + + +SOCKS5 = 'http://jabber.org/protocol/bytestreams' +IBB = 'http://jabber.org/protocol/ibb' + + +class XEP_0095(BasePlugin): + + name = 'xep_0095' + description = 'XEP-0095: Stream Initiation' + dependencies = set(['xep_0020', 'xep_0030', 'xep_0047', 'xep_0065']) + stanza = stanza + + def plugin_init(self): + self._profiles = {} + self._methods = {} + self._methods_order = [] + self._pending_lock = threading.Lock() + self._pending= {} + + self.register_method(SOCKS5, 'xep_0065', 100) + self.register_method(IBB, 'xep_0047', 50) + + register_stanza_plugin(Iq, SI) + register_stanza_plugin(SI, self.xmpp['xep_0020'].stanza.FeatureNegotiation) + + self.xmpp.register_handler( + Callback('SI Request', + StanzaPath('iq@type=set/si'), + self._handle_request)) + + self.api.register(self._add_pending, 'add_pending', default=True) + self.api.register(self._get_pending, 'get_pending', default=True) + self.api.register(self._del_pending, 'del_pending', default=True) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(SI.namespace) + + def plugin_end(self): + self.xmpp.remove_handler('SI Request') + self.xmpp['xep_0030'].del_feature(feature=SI.namespace) + + def register_profile(self, profile_name, plugin): + self._profiles[profile_name] = plugin + + def unregister_profile(self, profile_name): + try: + del self._profiles[profile_name] + except KeyError: + pass + + def register_method(self, method, plugin_name, order=50): + self._methods[method] = (plugin_name, order) + self._methods_order.append((order, method, plugin_name)) + self._methods_order.sort() + + def unregister_method(self, method): + if method in self._methods: + plugin_name, order = self._methods[method] + del self._methods[method] + self._methods_order.remove((order, method, plugin_name)) + self._methods_order.sort() + + def _handle_request(self, iq): + profile = iq['si']['profile'] + sid = iq['si']['id'] + + if not sid: + raise XMPPError(etype='modify', condition='bad-request') + if profile not in self._profiles: + raise XMPPError( + etype='modify', + condition='bad-request', + extension='bad-profile', + extension_ns=SI.namespace) + + neg = iq['si']['feature_neg']['form']['fields'] + options = neg['stream-method']['options'] or [] + methods = [] + for opt in options: + methods.append(opt['value']) + for method in methods: + if method in self._methods: + supported = True + break + else: + raise XMPPError('bad-request', + extension='no-valid-streams', + extension_ns=SI.namespace) + + selected_method = None + log.debug('Available: %s', methods) + for order, method, plugin in self._methods_order: + log.debug('Testing: %s', method) + if method in methods: + selected_method = method + break + + receiver = iq['to'] + sender = iq['from'] + + self.api['add_pending'](receiver, sid, sender, { + 'response_id': iq['id'], + 'method': selected_method, + 'profile': profile + }) + self.xmpp.event('si_request', iq) + + def offer(self, jid, sid=None, mime_type=None, profile=None, + methods=None, payload=None, ifrom=None, + **iqargs): + if sid is None: + sid = uuid4().hex + if methods is None: + methods = list(self._methods.keys()) + if not isinstance(methods, (list, tuple, set)): + methods = [methods] + + si = self.xmpp.Iq() + si['to'] = jid + si['from'] = ifrom + si['type'] = 'set' + si['si']['id'] = sid + si['si']['mime_type'] = mime_type + si['si']['profile'] = profile + if not isinstance(payload, (list, tuple, set)): + payload = [payload] + for item in payload: + si['si'].append(item) + si['si']['feature_neg']['form'].add_field( + var='stream-method', + ftype='list-single', + options=methods) + return si.send(**iqargs) + + def accept(self, jid, sid, payload=None, ifrom=None, stream_handler=None): + stream = self.api['get_pending'](ifrom, sid, jid) + iq = self.xmpp.Iq() + iq['id'] = stream['response_id'] + iq['to'] = jid + iq['from'] = ifrom + iq['type'] = 'result' + if payload: + iq['si'].append(payload) + iq['si']['feature_neg']['form']['type'] = 'submit' + iq['si']['feature_neg']['form'].add_field( + var='stream-method', + ftype='list-single', + value=stream['method']) + + if ifrom is None: + ifrom = self.xmpp.boundjid + + method_plugin = self._methods[stream['method']][0] + self.xmpp[method_plugin].api['preauthorize_sid'](ifrom, sid, jid) + + self.api['del_pending'](ifrom, sid, jid) + + if stream_handler: + self.xmpp.add_event_handler('stream:%s:%s' % (sid, jid), + stream_handler, + disposable=True) + return iq.send() + + def decline(self, jid, sid, ifrom=None): + stream = self.api['get_pending'](ifrom, sid, jid) + if not stream: + return + iq = self.xmpp.Iq() + iq['id'] = stream['response_id'] + iq['to'] = jid + iq['from'] = ifrom + iq['type'] = 'error' + iq['error']['condition'] = 'forbidden' + iq['error']['text'] = 'Offer declined' + self.api['del_pending'](ifrom, sid, jid) + return iq.send() + + def _add_pending(self, jid, node, ifrom, data): + with self._pending_lock: + self._pending[(jid, node, ifrom)] = data + + def _get_pending(self, jid, node, ifrom, data): + with self._pending_lock: + return self._pending.get((jid, node, ifrom), None) + + def _del_pending(self, jid, node, ifrom, data): + with self._pending_lock: + if (jid, node, ifrom) in self._pending: + del self._pending[(jid, node, ifrom)] diff --git a/slixmpp/plugins/xep_0096/__init__.py b/slixmpp/plugins/xep_0096/__init__.py new file mode 100644 index 00000000..866a9820 --- /dev/null +++ b/slixmpp/plugins/xep_0096/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0096 import stanza +from slixmpp.plugins.xep_0096.stanza import File +from slixmpp.plugins.xep_0096.file_transfer import XEP_0096 + + +register_plugin(XEP_0096) diff --git a/slixmpp/plugins/xep_0096/file_transfer.py b/slixmpp/plugins/xep_0096/file_transfer.py new file mode 100644 index 00000000..3c09a5b5 --- /dev/null +++ b/slixmpp/plugins/xep_0096/file_transfer.py @@ -0,0 +1,59 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq, Message +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.plugins.xep_0096 import stanza, File + + +log = logging.getLogger(__name__) + + +class XEP_0096(BasePlugin): + + name = 'xep_0096' + description = 'XEP-0096: SI File Transfer' + dependencies = set(['xep_0095']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(self.xmpp['xep_0095'].stanza.SI, File) + + self.xmpp['xep_0095'].register_profile(File.namespace, self) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(File.namespace) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=File.namespace) + self.xmpp['xep_0095'].unregister_profile(File.namespace, self) + + def request_file_transfer(self, jid, sid=None, name=None, size=None, + desc=None, hash=None, date=None, + allow_ranged=False, mime_type=None, + **iqargs): + data = File() + data['name'] = name + data['size'] = size + data['date'] = date + data['desc'] = desc + data['hash'] = hash + if allow_ranged: + data.enable('range') + + return self.xmpp['xep_0095'].offer(jid, + sid=sid, + mime_type=mime_type, + profile=File.namespace, + payload=data, + **iqargs) diff --git a/slixmpp/plugins/xep_0096/stanza.py b/slixmpp/plugins/xep_0096/stanza.py new file mode 100644 index 00000000..d3781c8d --- /dev/null +++ b/slixmpp/plugins/xep_0096/stanza.py @@ -0,0 +1,48 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import datetime as dt + +from slixmpp.xmlstream import ElementBase, register_stanza_plugin +from slixmpp.plugins import xep_0082 + + +class File(ElementBase): + name = 'file' + namespace = 'http://jabber.org/protocol/si/profile/file-transfer' + plugin_attrib = 'file' + interfaces = set(['name', 'size', 'date', 'hash', 'desc']) + sub_interfaces = set(['desc']) + + def set_size(self, value): + self._set_attr('size', str(value)) + + def get_date(self): + timestamp = self._get_attr('date') + return xep_0082.parse(timestamp) + + def set_date(self, value): + if isinstance(value, dt.datetime): + value = xep_0082.format_datetime(value) + self._set_attr('date', value) + + +class Range(ElementBase): + name = 'range' + namespace = 'http://jabber.org/protocol/si/profile/file-transfer' + plugin_attrib = 'range' + interfaces = set(['length', 'offset']) + + def set_length(self, value): + self._set_attr('length', str(value)) + + def set_offset(self, value): + self._set_attr('offset', str(value)) + + +register_stanza_plugin(File, Range) diff --git a/slixmpp/plugins/xep_0106.py b/slixmpp/plugins/xep_0106.py new file mode 100644 index 00000000..a4717956 --- /dev/null +++ b/slixmpp/plugins/xep_0106.py @@ -0,0 +1,26 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +from slixmpp.plugins import BasePlugin, register_plugin + + +class XEP_0106(BasePlugin): + + name = 'xep_0106' + description = 'XEP-0106: JID Escaping' + dependencies = set(['xep_0030']) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(feature='jid\\20escaping') + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature='jid\\20escaping') + + +register_plugin(XEP_0106) diff --git a/slixmpp/plugins/xep_0107/__init__.py b/slixmpp/plugins/xep_0107/__init__.py new file mode 100644 index 00000000..778fd33b --- /dev/null +++ b/slixmpp/plugins/xep_0107/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0107 import stanza +from slixmpp.plugins.xep_0107.stanza import UserMood +from slixmpp.plugins.xep_0107.user_mood import XEP_0107 + + +register_plugin(XEP_0107) diff --git a/slixmpp/plugins/xep_0107/stanza.py b/slixmpp/plugins/xep_0107/stanza.py new file mode 100644 index 00000000..05967de9 --- /dev/null +++ b/slixmpp/plugins/xep_0107/stanza.py @@ -0,0 +1,55 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +class UserMood(ElementBase): + + name = 'mood' + namespace = 'http://jabber.org/protocol/mood' + plugin_attrib = 'mood' + interfaces = set(['value', 'text']) + sub_interfaces = set(['text']) + moods = set(['afraid', 'amazed', 'amorous', 'angry', 'annoyed', 'anxious', + 'aroused', 'ashamed', 'bored', 'brave', 'calm', 'cautious', + 'cold', 'confident', 'confused', 'contemplative', 'contented', + 'cranky', 'crazy', 'creative', 'curious', 'dejected', + 'depressed', 'disappointed', 'disgusted', 'dismayed', + 'distracted', 'embarrassed', 'envious', 'excited', + 'flirtatious', 'frustrated', 'grateful', 'grieving', 'grumpy', + 'guilty', 'happy', 'hopeful', 'hot', 'humbled', 'humiliated', + 'hungry', 'hurt', 'impressed', 'in_awe', 'in_love', + 'indignant', 'interested', 'intoxicated', 'invincible', + 'jealous', 'lonely', 'lost', 'lucky', 'mean', 'moody', + 'nervous', 'neutral', 'offended', 'outraged', 'playful', + 'proud', 'relaxed', 'relieved', 'remorseful', 'restless', + 'sad', 'sarcastic', 'satisfied', 'serious', 'shocked', + 'shy', 'sick', 'sleepy', 'spontaneous', 'stressed', 'strong', + 'surprised', 'thankful', 'thirsty', 'tired', 'undefined', + 'weak', 'worried']) + + def set_value(self, value): + self.del_value() + if value in self.moods: + self._set_sub_text(value, '', keep=True) + else: + raise ValueError('Unknown mood value') + + def get_value(self): + for child in self.xml: + if child.tag.startswith('{%s}' % self.namespace): + elem_name = child.tag.split('}')[-1] + if elem_name in self.moods: + return elem_name + return '' + + def del_value(self): + curr_value = self.get_value() + if curr_value: + self._set_sub_text(curr_value, '', keep=False) diff --git a/slixmpp/plugins/xep_0107/user_mood.py b/slixmpp/plugins/xep_0107/user_mood.py new file mode 100644 index 00000000..c56d15fa --- /dev/null +++ b/slixmpp/plugins/xep_0107/user_mood.py @@ -0,0 +1,85 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Message +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0107 import stanza, UserMood + + +log = logging.getLogger(__name__) + + +class XEP_0107(BasePlugin): + + """ + XEP-0107: User Mood + """ + + name = 'xep_0107' + description = 'XEP-0107: User Mood' + dependencies = set(['xep_0163']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, UserMood) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=UserMood.namespace) + self.xmpp['xep_0163'].remove_interest(UserMood.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0163'].register_pep('user_mood', UserMood) + + def publish_mood(self, value=None, text=None, options=None, ifrom=None, + callback=None, timeout=None, timeout_callback=None): + """ + Publish the user's current mood. + + Arguments: + value -- The name of the mood to publish. + text -- Optional natural-language description or reason + for the mood. + options -- Optional form of publish options. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + mood = UserMood() + mood['value'] = value + mood['text'] = text + self.xmpp['xep_0163'].publish(mood, node=UserMood.namespace, + options=options, ifrom=ifrom, + callback=callback, timeout=timeout, + timeout_callback=timeout_callback) + + def stop(self, ifrom=None, callback=None, timeout=None, + timeout_callback=None): + """ + Clear existing user mood information to stop notifications. + + Arguments: + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + mood = UserMood() + self.xmpp['xep_0163'].publish(mood, node=UserMood.namespace, + ifrom=ifrom, callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0108/__init__.py b/slixmpp/plugins/xep_0108/__init__.py new file mode 100644 index 00000000..54cc5ddd --- /dev/null +++ b/slixmpp/plugins/xep_0108/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0108 import stanza +from slixmpp.plugins.xep_0108.stanza import UserActivity +from slixmpp.plugins.xep_0108.user_activity import XEP_0108 + + +register_plugin(XEP_0108) diff --git a/slixmpp/plugins/xep_0108/stanza.py b/slixmpp/plugins/xep_0108/stanza.py new file mode 100644 index 00000000..d65dfdf9 --- /dev/null +++ b/slixmpp/plugins/xep_0108/stanza.py @@ -0,0 +1,83 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +class UserActivity(ElementBase): + + name = 'activity' + namespace = 'http://jabber.org/protocol/activity' + plugin_attrib = 'activity' + interfaces = set(['value', 'text']) + sub_interfaces = set(['text']) + general = set(['doing_chores', 'drinking', 'eating', 'exercising', + 'grooming', 'having_appointment', 'inactive', 'relaxing', + 'talking', 'traveling', 'undefined', 'working']) + specific = set(['at_the_spa', 'brushing_teeth', 'buying_groceries', + 'cleaning', 'coding', 'commuting', 'cooking', 'cycling', + 'dancing', 'day_off', 'doing_maintenance', + 'doing_the_dishes', 'doing_the_laundry', 'driving', + 'fishing', 'gaming', 'gardening', 'getting_a_haircut', + 'going_out', 'hanging_out', 'having_a_beer', + 'having_a_snack', 'having_breakfast', 'having_coffee', + 'having_dinner', 'having_lunch', 'having_tea', 'hiding', + 'hiking', 'in_a_car', 'in_a_meeting', 'in_real_life', + 'jogging', 'on_a_bus', 'on_a_plane', 'on_a_train', + 'on_a_trip', 'on_the_phone', 'on_vacation', + 'on_video_phone', 'other', 'partying', 'playing_sports', + 'praying', 'reading', 'rehearsing', 'running', + 'running_an_errand', 'scheduled_holiday', 'shaving', + 'shopping', 'skiing', 'sleeping', 'smoking', + 'socializing', 'studying', 'sunbathing', 'swimming', + 'taking_a_bath', 'taking_a_shower', 'thinking', + 'walking', 'walking_the_dog', 'watching_a_movie', + 'watching_tv', 'working_out', 'writing']) + + def set_value(self, value): + self.del_value() + general = value + specific = None + if isinstance(value, tuple) or isinstance(value, list): + general = value[0] + specific = value[1] + + if general in self.general: + gen_xml = ET.Element('{%s}%s' % (self.namespace, general)) + if specific: + spec_xml = ET.Element('{%s}%s' % (self.namespace, specific)) + if specific in self.specific: + gen_xml.append(spec_xml) + else: + raise ValueError('Unknown specific activity') + self.xml.append(gen_xml) + else: + raise ValueError('Unknown general activity') + + def get_value(self): + general = None + specific = None + gen_xml = None + for child in self.xml: + if child.tag.startswith('{%s}' % self.namespace): + elem_name = child.tag.split('}')[-1] + if elem_name in self.general: + general = elem_name + gen_xml = child + if gen_xml is not None: + for child in gen_xml: + if child.tag.startswith('{%s}' % self.namespace): + elem_name = child.tag.split('}')[-1] + if elem_name in self.specific: + specific = elem_name + return (general, specific) + + def del_value(self): + curr_value = self.get_value() + if curr_value[0]: + self._set_sub_text(curr_value[0], '', keep=False) diff --git a/slixmpp/plugins/xep_0108/user_activity.py b/slixmpp/plugins/xep_0108/user_activity.py new file mode 100644 index 00000000..502dfae0 --- /dev/null +++ b/slixmpp/plugins/xep_0108/user_activity.py @@ -0,0 +1,82 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0108 import stanza, UserActivity + + +log = logging.getLogger(__name__) + + +class XEP_0108(BasePlugin): + + """ + XEP-0108: User Activity + """ + + name = 'xep_0108' + description = 'XEP-0108: User Activity' + dependencies = set(['xep_0163']) + stanza = stanza + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=UserActivity.namespace) + self.xmpp['xep_0163'].remove_interest(UserActivity.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0163'].register_pep('user_activity', UserActivity) + + def publish_activity(self, general, specific=None, text=None, + options=None, ifrom=None, callback=None, + timeout=None, timeout_callback=None): + """ + Publish the user's current activity. + + Arguments: + general -- The required general category of the activity. + specific -- Optional specific activity being done as part + of the general category. + text -- Optional natural-language description or reason + for the activity. + options -- Optional form of publish options. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + activity = UserActivity() + activity['value'] = (general, specific) + activity['text'] = text + self.xmpp['xep_0163'].publish(activity, node=UserActivity.namespace, + options=options, ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + def stop(self, ifrom=None, callback=None, timeout=None, + timeout_callback=None): + """ + Clear existing user activity information to stop notifications. + + Arguments: + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + activity = UserActivity() + self.xmpp['xep_0163'].publish(activity, node=UserActivity.namespace, + ifrom=ifrom, callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0115/__init__.py b/slixmpp/plugins/xep_0115/__init__.py new file mode 100644 index 00000000..51c437c6 --- /dev/null +++ b/slixmpp/plugins/xep_0115/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0115.stanza import Capabilities +from slixmpp.plugins.xep_0115.static import StaticCaps +from slixmpp.plugins.xep_0115.caps import XEP_0115 + + +register_plugin(XEP_0115) + + +# Retain some backwards compatibility +xep_0115 = XEP_0115 diff --git a/slixmpp/plugins/xep_0115/caps.py b/slixmpp/plugins/xep_0115/caps.py new file mode 100644 index 00000000..c6f9ea10 --- /dev/null +++ b/slixmpp/plugins/xep_0115/caps.py @@ -0,0 +1,334 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import hashlib +import base64 + +from slixmpp import __version__ +from slixmpp.stanza import StreamFeatures, Presence, Iq +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp import asyncio +from slixmpp.exceptions import XMPPError, IqError, IqTimeout +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0115 import stanza, StaticCaps + + +log = logging.getLogger(__name__) + + +class XEP_0115(BasePlugin): + + """ + XEP-0115: Entity Capabalities + """ + + name = 'xep_0115' + description = 'XEP-0115: Entity Capabilities' + dependencies = set(['xep_0030', 'xep_0128', 'xep_0004']) + stanza = stanza + default_config = { + 'hash': 'sha-1', + 'caps_node': None, + 'broadcast': True + } + + def plugin_init(self): + self.hashes = {'sha-1': hashlib.sha1, + 'sha1': hashlib.sha1, + 'md5': hashlib.md5} + + if self.caps_node is None: + self.caps_node = 'http://slixmpp.com/ver/%s' % __version__ + + 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) + + if not self.xmpp.is_component: + self.xmpp.register_feature('caps', + self._handle_caps_feature, + restart=False, + order=10010) + + disco = self.xmpp['xep_0030'] + self.static = StaticCaps(self.xmpp, disco.static) + + for op in self._disco_ops: + self.api.register(getattr(self.static, op), op, default=True) + + for op in ('supports', 'has_identity'): + self.xmpp['xep_0030'].api.register(getattr(self.static, op), 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 plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=stanza.Capabilities.namespace) + self.xmpp.del_filter('out', self._filter_add_caps) + self.xmpp.del_event_handler('entity_caps', self._process_caps) + self.xmpp.remove_handler('Entity Capabilities') + if not self.xmpp.is_component: + self.xmpp.unregister_feature('caps', 10010) + for op in ('supports', 'has_identity'): + self.xmpp['xep_0030'].restore_defaults(op) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(stanza.Capabilities.namespace) + + def _filter_add_caps(self, stanza): + if not isinstance(stanza, Presence) or not self.broadcast: + return stanza + + if stanza['type'] not in ('available', 'chat', 'away', 'dnd', 'xa'): + return stanza + + 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) + + @asyncio.coroutine + def _process_caps(self, pres): + if not pres['caps']['hash']: + log.debug("Received unsupported legacy caps: %s, %s, %s", + pres['caps']['node'], + pres['caps']['ver'], + pres['caps']['ext']) + self.xmpp.event('entity_caps_legacy', pres) + return + + ver = pres['caps']['ver'] + + existing_verstring = self.get_verstring(pres['from'].full) + if str(existing_verstring) == str(ver): + return + + existing_caps = self.get_caps(verstring=ver) + if existing_caps is not None: + self.assign_verstring(pres['from'], ver) + return + + if pres['caps']['hash'] not in self.hashes: + try: + log.debug("Unknown caps hash: %s", pres['caps']['hash']) + self.xmpp['xep_0030'].get_info(jid=pres['from']) + return + except XMPPError: + return + + log.debug("New caps verification string: %s", ver) + try: + node = '%s#%s' % (pres['caps']['node'], ver) + caps = yield from self.xmpp['xep_0030'].get_info(pres['from'], node, + coroutine=True) + + if isinstance(caps, Iq): + caps = caps['disco_info'] + + if self._validate_caps(caps, 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 for %s", node) + + 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 not isinstance(stanza, self.xmpp['xep_0004'].stanza.Form): + log.debug("Non form extension found, ignoring for caps") + caps.xml.remove(stanza.xml) + continue + 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') + + @asyncio.coroutine + def update_caps(self, jid=None, node=None, preserve=False): + try: + info = yield from 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.xmpp.sessionstarted and self.broadcast: + if self.xmpp.is_component or preserve: + for contact in self.xmpp.roster[jid]: + self.xmpp.roster[jid][contact].send_last_presence() + else: + self.xmpp.roster[jid].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.api['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.api['assign_verstring'](jid, args={ + 'verstring': verstring}) + + def cache_caps(self, verstring=None, info=None): + data = {'verstring': verstring, 'info': info} + return self.api['cache_caps'](args=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.api['get_caps'](jid, args=data) diff --git a/slixmpp/plugins/xep_0115/stanza.py b/slixmpp/plugins/xep_0115/stanza.py new file mode 100644 index 00000000..36fb173c --- /dev/null +++ b/slixmpp/plugins/xep_0115/stanza.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from __future__ import unicode_literals + +from slixmpp.xmlstream import ElementBase + + +class Capabilities(ElementBase): + + namespace = 'http://jabber.org/protocol/caps' + name = 'c' + plugin_attrib = 'caps' + interfaces = set(('hash', 'node', 'ver', 'ext')) diff --git a/slixmpp/plugins/xep_0115/static.py b/slixmpp/plugins/xep_0115/static.py new file mode 100644 index 00000000..0d1caa01 --- /dev/null +++ b/slixmpp/plugins/xep_0115/static.py @@ -0,0 +1,146 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.xmlstream import JID +from slixmpp.exceptions import IqError, IqTimeout + + +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 Slixmpp 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 Slixmpp 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/slixmpp/plugins/xep_0118/__init__.py b/slixmpp/plugins/xep_0118/__init__.py new file mode 100644 index 00000000..7ad48998 --- /dev/null +++ b/slixmpp/plugins/xep_0118/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0118 import stanza +from slixmpp.plugins.xep_0118.stanza import UserTune +from slixmpp.plugins.xep_0118.user_tune import XEP_0118 + + +register_plugin(XEP_0118) diff --git a/slixmpp/plugins/xep_0118/stanza.py b/slixmpp/plugins/xep_0118/stanza.py new file mode 100644 index 00000000..4f5a1795 --- /dev/null +++ b/slixmpp/plugins/xep_0118/stanza.py @@ -0,0 +1,25 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +class UserTune(ElementBase): + + name = 'tune' + namespace = 'http://jabber.org/protocol/tune' + plugin_attrib = 'tune' + interfaces = set(['artist', 'length', 'rating', 'source', + 'title', 'track', 'uri']) + sub_interfaces = interfaces + + def set_length(self, value): + self._set_sub_text('length', str(value)) + + def set_rating(self, value): + self._set_sub_text('rating', str(value)) diff --git a/slixmpp/plugins/xep_0118/user_tune.py b/slixmpp/plugins/xep_0118/user_tune.py new file mode 100644 index 00000000..0882a5ba --- /dev/null +++ b/slixmpp/plugins/xep_0118/user_tune.py @@ -0,0 +1,92 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0118 import stanza, UserTune + + +log = logging.getLogger(__name__) + + +class XEP_0118(BasePlugin): + + """ + XEP-0118: User Tune + """ + + name = 'xep_0118' + description = 'XEP-0118: User Tune' + dependencies = set(['xep_0163']) + stanza = stanza + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=UserTune.namespace) + self.xmpp['xep_0163'].remove_interest(UserTune.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0163'].register_pep('user_tune', UserTune) + + def publish_tune(self, artist=None, length=None, rating=None, source=None, + title=None, track=None, uri=None, options=None, + ifrom=None, callback=None, timeout=None, timeout_callback=None): + """ + Publish the user's current tune. + + Arguments: + artist -- The artist or performer of the song. + length -- The length of the song in seconds. + rating -- The user's rating of the song (from 1 to 10) + source -- The album name, website, or other source of the song. + title -- The title of the song. + track -- The song's track number, or other unique identifier. + uri -- A URL to more information about the song. + options -- Optional form of publish options. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + tune = UserTune() + tune['artist'] = artist + tune['length'] = length + tune['rating'] = rating + tune['source'] = source + tune['title'] = title + tune['track'] = track + tune['uri'] = uri + return self.xmpp['xep_0163'].publish(tune, + node=UserTune.namespace, + options=options, + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + def stop(self, ifrom=None, callback=None, timeout=None, timeout_callback=None): + """ + Clear existing user tune information to stop notifications. + + Arguments: + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + tune = UserTune() + return self.xmpp['xep_0163'].publish(tune, + node=UserTune.namespace, + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0122/__init__.py b/slixmpp/plugins/xep_0122/__init__.py new file mode 100644 index 00000000..76ca80b2 --- /dev/null +++ b/slixmpp/plugins/xep_0122/__init__.py @@ -0,0 +1,11 @@ + +from slixmpp.plugins.base import register_plugin +from slixmpp.plugins.xep_0122.stanza import FormValidation +from slixmpp.plugins.xep_0122.data_validation import XEP_0122 + + +register_plugin(XEP_0122) + + +# Retain some backwards compatibility +xep_0122 = XEP_0122 diff --git a/slixmpp/plugins/xep_0122/data_validation.py b/slixmpp/plugins/xep_0122/data_validation.py new file mode 100644 index 00000000..6129db51 --- /dev/null +++ b/slixmpp/plugins/xep_0122/data_validation.py @@ -0,0 +1,19 @@ +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0004 import stanza +from slixmpp.plugins.xep_0004.stanza import FormField +from slixmpp.plugins.xep_0122.stanza import FormValidation + + +class XEP_0122(BasePlugin): + """ + XEP-0122: Data Forms + """ + + name = 'xep_0122' + description = 'XEP-0122: Data Forms Validation' + dependencies = set(['xep_0004']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(FormField, FormValidation) diff --git a/slixmpp/plugins/xep_0122/stanza.py b/slixmpp/plugins/xep_0122/stanza.py new file mode 100644 index 00000000..9f1c423d --- /dev/null +++ b/slixmpp/plugins/xep_0122/stanza.py @@ -0,0 +1,93 @@ +from slixmpp.xmlstream import ElementBase, ET + + +class FormValidation(ElementBase): + """ + Validation values for form fields. + + Example: + + <field var='evt.date' type='text-single' label='Event Date/Time'> + <validate xmlns='http://jabber.org/protocol/xdata-validate' + datatype='xs:dateTime'/> + <value>2003-10-06T11:22:00-07:00</value> + </field> + + Questions: + Should this look at the datatype value and convert the range values as appropriate? + Should this stanza provide a pass/fail for a value from the field, or convert field value to datatype? + """ + + namespace = 'http://jabber.org/protocol/xdata-validate' + name = 'validate' + plugin_attrib = 'validate' + interfaces = {'datatype', 'basic', 'open', 'range', 'regex', } + sub_interfaces = {'basic', 'open', 'range', 'regex', } + plugin_attrib_map = {} + plugin_tag_map = {} + + def _add_field(self, name): + self.remove_all() + item_xml = ET.Element('{%s}%s' % (self.namespace, name)) + self.xml.append(item_xml) + return item_xml + + def set_basic(self, value): + if value: + self._add_field('basic') + else: + del self['basic'] + + def set_open(self, value): + if value: + self._add_field('open') + else: + del self['open'] + + def set_regex(self, regex): + if regex: + _regex = self._add_field('regex') + _regex.text = regex + else: + del self['regex'] + + def set_range(self, value, minimum=None, maximum=None): + if value: + _range = self._add_field('range') + _range.attrib['min'] = str(minimum) + _range.attrib['max'] = str(maximum) + else: + del self['range'] + + def remove_all(self, except_tag=None): + for a in self.sub_interfaces: + if a != except_tag: + del self[a] + + def get_basic(self): + present = self.xml.find('{%s}basic' % self.namespace) + return present is not None + + def get_open(self): + present = self.xml.find('{%s}open' % self.namespace) + return present is not None + + def get_regex(self): + present = self.xml.find('{%s}regex' % self.namespace) + if present is not None: + return present.text + + return False + + def get_range(self): + present = self.xml.find('{%s}range' % self.namespace) + if present is not None: + attributes = present.attrib + return_value = dict() + if 'min' in attributes: + return_value['minimum'] = attributes['min'] + if 'max' in attributes: + return_value['maximum'] = attributes['max'] + return return_value + + return False diff --git a/slixmpp/plugins/xep_0128/__init__.py b/slixmpp/plugins/xep_0128/__init__.py new file mode 100644 index 00000000..fb6dbf7c --- /dev/null +++ b/slixmpp/plugins/xep_0128/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0128.static import StaticExtendedDisco +from slixmpp.plugins.xep_0128.extended_disco import XEP_0128 + + +register_plugin(XEP_0128) + + +# Retain some backwards compatibility +xep_0128 = XEP_0128 diff --git a/slixmpp/plugins/xep_0128/extended_disco.py b/slixmpp/plugins/xep_0128/extended_disco.py new file mode 100644 index 00000000..5cc1d35a --- /dev/null +++ b/slixmpp/plugins/xep_0128/extended_disco.py @@ -0,0 +1,99 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +import slixmpp +from slixmpp import Iq +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0004 import Form +from slixmpp.plugins.xep_0030 import DiscoInfo +from slixmpp.plugins.xep_0128 import StaticExtendedDisco + + +class XEP_0128(BasePlugin): + + """ + 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 Slixmpp 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. + """ + + name = 'xep_0128' + description = 'XEP-0128: Service Discovery Extensions' + dependencies = set(['xep_0030', 'xep_0004']) + + def plugin_init(self): + """Start the XEP-0128 plugin.""" + self._disco_ops = ['set_extended_info', + 'add_extended_info', + 'del_extended_info'] + + register_stanza_plugin(DiscoInfo, Form, iterable=True) + + 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.api.register(getattr(self.static, op), op, default=True) + + 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.api['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.api['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.api['del_extended_info'](jid, node, None, kwargs) diff --git a/slixmpp/plugins/xep_0128/static.py b/slixmpp/plugins/xep_0128/static.py new file mode 100644 index 00000000..ab1ea590 --- /dev/null +++ b/slixmpp/plugins/xep_0128/static.py @@ -0,0 +1,73 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +import slixmpp +from slixmpp.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/slixmpp/plugins/xep_0131/__init__.py b/slixmpp/plugins/xep_0131/__init__.py new file mode 100644 index 00000000..4151cc72 --- /dev/null +++ b/slixmpp/plugins/xep_0131/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0131 import stanza +from slixmpp.plugins.xep_0131.stanza import Headers +from slixmpp.plugins.xep_0131.headers import XEP_0131 + + +register_plugin(XEP_0131) diff --git a/slixmpp/plugins/xep_0131/headers.py b/slixmpp/plugins/xep_0131/headers.py new file mode 100644 index 00000000..81fc9188 --- /dev/null +++ b/slixmpp/plugins/xep_0131/headers.py @@ -0,0 +1,41 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp import Message, Presence +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0131 import stanza +from slixmpp.plugins.xep_0131.stanza import Headers + + +class XEP_0131(BasePlugin): + + name = 'xep_0131' + description = 'XEP-0131: Stanza Headers and Internet Metadata' + dependencies = set(['xep_0030']) + stanza = stanza + default_config = { + 'supported_headers': set() + } + + def plugin_init(self): + register_stanza_plugin(Message, Headers) + register_stanza_plugin(Presence, Headers) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=Headers.namespace) + for header in self.supported_headers: + self.xmpp['xep_0030'].del_feature( + feature='%s#%s' % (Headers.namespace, header)) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(Headers.namespace) + for header in self.supported_headers: + self.xmpp['xep_0030'].add_feature('%s#%s' % ( + Headers.namespace, + header)) diff --git a/slixmpp/plugins/xep_0131/stanza.py b/slixmpp/plugins/xep_0131/stanza.py new file mode 100644 index 00000000..cbbe61a7 --- /dev/null +++ b/slixmpp/plugins/xep_0131/stanza.py @@ -0,0 +1,51 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from collections import OrderedDict +from slixmpp.xmlstream import ET, ElementBase + + +class Headers(ElementBase): + name = 'headers' + namespace = 'http://jabber.org/protocol/shim' + plugin_attrib = 'headers' + interfaces = set(['headers']) + is_extension = True + + def get_headers(self): + result = OrderedDict() + headers = self.xml.findall('{%s}header' % self.namespace) + for header in headers: + name = header.attrib.get('name', '') + value = header.text + if name in result: + if not isinstance(result[name], set): + result[name] = [result[name]] + else: + result[name] = [] + result[name].add(value) + else: + result[name] = value + return result + + def set_headers(self, values): + self.del_headers() + for name in values: + vals = values[name] + if not isinstance(vals, (list, set)): + vals = [values[name]] + for value in vals: + header = ET.Element('{%s}header' % self.namespace) + header.attrib['name'] = name + header.text = value + self.xml.append(header) + + def del_headers(self): + headers = self.xml.findall('{%s}header' % self.namespace) + for header in headers: + self.xml.remove(header) diff --git a/slixmpp/plugins/xep_0133.py b/slixmpp/plugins/xep_0133.py new file mode 100644 index 00000000..a1eb9e02 --- /dev/null +++ b/slixmpp/plugins/xep_0133.py @@ -0,0 +1,53 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +from slixmpp.plugins import BasePlugin, register_plugin + + +class XEP_0133(BasePlugin): + + name = 'xep_0133' + description = 'XEP-0133: Service Administration' + dependencies = set(['xep_0030', 'xep_0004', 'xep_0050']) + commands = set(['add-user', 'delete-user', 'disable-user', + 'reenable-user', 'end-user-session', 'get-user-password', + 'change-user-password', 'get-user-roster', + 'get-user-lastlogin', 'user-stats', 'edit-blacklist', + 'edit-whitelist', 'get-registered-users-num', + 'get-disabled-users-num', 'get-online-users-num', + 'get-active-users-num', 'get-idle-users-num', + 'get-registered-users-list', 'get-disabled-users-list', + 'get-online-users-list', 'get-online-users', + 'get-active-users', 'get-idle-userslist', 'announce', + 'set-motd', 'edit-motd', 'delete-motd', 'set-welcome', + 'delete-welcome', 'edit-admin', 'restart', 'shutdown']) + + def get_commands(self, jid=None, **kwargs): + if jid is None: + jid = self.xmpp.boundjid.server + return self.xmpp['xep_0050'].get_commands(jid, **kwargs) + + +def create_command(name): + def admin_command(self, jid=None, session=None, ifrom=None): + if jid is None: + jid = self.xmpp.boundjid.server + self.xmpp['xep_0050'].start_command( + jid=jid, + node='http://jabber.org/protocol/admin#%s' % name, + session=session, + ifrom=ifrom) + return admin_command + + +for cmd in XEP_0133.commands: + setattr(XEP_0133, cmd.replace('-', '_'), create_command(cmd)) + + +register_plugin(XEP_0133) diff --git a/slixmpp/plugins/xep_0138.py b/slixmpp/plugins/xep_0138.py new file mode 100644 index 00000000..049060cf --- /dev/null +++ b/slixmpp/plugins/xep_0138.py @@ -0,0 +1,145 @@ +""" + slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import zlib + + +from slixmpp.stanza import StreamFeatures +from slixmpp.xmlstream import RestartStream, register_stanza_plugin, ElementBase, StanzaBase +from slixmpp.xmlstream.matcher import * +from slixmpp.xmlstream.handler import * +from slixmpp.plugins import BasePlugin, register_plugin + +log = logging.getLogger(__name__) + + +class Compression(ElementBase): + name = 'compression' + namespace = 'http://jabber.org/features/compress' + interfaces = set(('methods',)) + plugin_attrib = 'compression' + plugin_tag_map = {} + plugin_attrib_map = {} + + def get_methods(self): + methods = [] + for method in self.xml.findall('{%s}method' % self.namespace): + methods.append(method.text) + return methods + + +class Compress(StanzaBase): + name = 'compress' + namespace = 'http://jabber.org/protocol/compress' + interfaces = set(('method',)) + sub_interfaces = interfaces + plugin_attrib = 'compress' + plugin_tag_map = {} + plugin_attrib_map = {} + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + +class Compressed(StanzaBase): + name = 'compressed' + namespace = 'http://jabber.org/protocol/compress' + interfaces = set() + plugin_tag_map = {} + plugin_attrib_map = {} + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + + + +class ZlibSocket(object): + + def __init__(self, socketobj): + self.__socket = socketobj + self.compressor = zlib.compressobj() + self.decompressor = zlib.decompressobj(zlib.MAX_WBITS) + + def __getattr__(self, name): + return getattr(self.__socket, name) + + def send(self, data): + sentlen = len(data) + data = self.compressor.compress(data) + data += self.compressor.flush(zlib.Z_SYNC_FLUSH) + log.debug(b'>>> (compressed)' + (data.encode("hex"))) + #return self.__socket.send(data) + sentactuallen = self.__socket.send(data) + assert(sentactuallen == len(data)) + + return sentlen + + def recv(self, *args, **kwargs): + data = self.__socket.recv(*args, **kwargs) + log.debug(b'<<< (compressed)' + data.encode("hex")) + return self.decompressor.decompress(self.decompressor.unconsumed_tail + data) + + +class XEP_0138(BasePlugin): + """ + XEP-0138: Compression + """ + name = "xep_0138" + description = "XEP-0138: Compression" + dependencies = set(["xep_0030"]) + + def plugin_init(self): + self.xep = '0138' + self.description = 'Stream Compression (Generic)' + + self.compression_methods = {'zlib': True} + + register_stanza_plugin(StreamFeatures, Compression) + self.xmpp.register_stanza(Compress) + self.xmpp.register_stanza(Compressed) + + self.xmpp.register_handler( + Callback('Compressed', + StanzaPath('compressed'), + self._handle_compressed, + instream=True)) + + self.xmpp.register_feature('compression', + self._handle_compression, + restart=True, + order=self.config.get('order', 5)) + + def register_compression_method(self, name, handler): + self.compression_methods[name] = handler + + def _handle_compression(self, features): + for method in features['compression']['methods']: + if method in self.compression_methods: + log.info('Attempting to use %s compression' % method) + c = Compress(self.xmpp) + c['method'] = method + c.send(now=True) + return True + return False + + def _handle_compressed(self, stanza): + self.xmpp.features.add('compression') + log.debug('Stream Compressed!') + compressed_socket = ZlibSocket(self.xmpp.socket) + self.xmpp.set_socket(compressed_socket) + raise RestartStream() + + def _handle_failure(self, stanza): + pass + +xep_0138 = XEP_0138 +register_plugin(XEP_0138) diff --git a/slixmpp/plugins/xep_0152/__init__.py b/slixmpp/plugins/xep_0152/__init__.py new file mode 100644 index 00000000..4e6d3a09 --- /dev/null +++ b/slixmpp/plugins/xep_0152/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0152 import stanza +from slixmpp.plugins.xep_0152.stanza import Reachability +from slixmpp.plugins.xep_0152.reachability import XEP_0152 + + +register_plugin(XEP_0152) diff --git a/slixmpp/plugins/xep_0152/reachability.py b/slixmpp/plugins/xep_0152/reachability.py new file mode 100644 index 00000000..e6d94b65 --- /dev/null +++ b/slixmpp/plugins/xep_0152/reachability.py @@ -0,0 +1,90 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0152 import stanza, Reachability + + +log = logging.getLogger(__name__) + + +class XEP_0152(BasePlugin): + + """ + XEP-0152: Reachability Addresses + """ + + name = 'xep_0152' + description = 'XEP-0152: Reachability Addresses' + dependencies = set(['xep_0163']) + stanza = stanza + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=Reachability.namespace) + self.xmpp['xep_0163'].remove_interest(Reachability.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0163'].register_pep('reachability', Reachability) + + def publish_reachability(self, addresses, options=None, ifrom=None, + callback=None, timeout=None, + timeout_callback=None): + """ + Publish alternative addresses where the user can be reached. + + Arguments: + addresses -- A list of dictionaries containing the URI and + optional description for each address. + options -- Optional form of publish options. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + if not isinstance(addresses, (list, tuple)): + addresses = [addresses] + reach = Reachability() + for address in addresses: + if not hasattr(address, 'items'): + address = {'uri': address} + + addr = stanza.Address() + for key, val in address.items(): + addr[key] = val + reach.append(addr) + return self.xmpp['xep_0163'].publish(reach, + node=Reachability.namespace, + options=options, + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + def stop(self, ifrom=None, callback=None, timeout=None, timeout_callback=None): + """ + Clear existing user activity information to stop notifications. + + Arguments: + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + reach = Reachability() + return self.xmpp['xep_0163'].publish(reach, + node=Reachability.namespace, + ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0152/stanza.py b/slixmpp/plugins/xep_0152/stanza.py new file mode 100644 index 00000000..661544e3 --- /dev/null +++ b/slixmpp/plugins/xep_0152/stanza.py @@ -0,0 +1,29 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + + +class Reachability(ElementBase): + name = 'reach' + namespace = 'urn:xmpp:reach:0' + plugin_attrib = 'reach' + interfaces = set() + + +class Address(ElementBase): + name = 'addr' + namespace = 'urn:xmpp:reach:0' + plugin_attrib = 'address' + plugin_multi_attrib = 'addresses' + interfaces = set(['uri', 'desc']) + lang_interfaces = set(['desc']) + sub_interfaces = set(['desc']) + + +register_stanza_plugin(Reachability, Address, iterable=True) diff --git a/slixmpp/plugins/xep_0153/__init__.py b/slixmpp/plugins/xep_0153/__init__.py new file mode 100644 index 00000000..378cec90 --- /dev/null +++ b/slixmpp/plugins/xep_0153/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0153.stanza import VCardTempUpdate +from slixmpp.plugins.xep_0153.vcard_avatar import XEP_0153 + + +register_plugin(XEP_0153) diff --git a/slixmpp/plugins/xep_0153/stanza.py b/slixmpp/plugins/xep_0153/stanza.py new file mode 100644 index 00000000..fe8d5e98 --- /dev/null +++ b/slixmpp/plugins/xep_0153/stanza.py @@ -0,0 +1,29 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class VCardTempUpdate(ElementBase): + name = 'x' + namespace = 'vcard-temp:x:update' + plugin_attrib = 'vcard_temp_update' + interfaces = set(['photo']) + sub_interfaces = interfaces + + def set_photo(self, value): + if value is not None: + self._set_sub_text('photo', value, keep=True) + else: + self._del_sub('photo') + + def get_photo(self): + photo = self.xml.find('{%s}photo' % self.namespace) + if photo is None: + return None + return photo.text diff --git a/slixmpp/plugins/xep_0153/vcard_avatar.py b/slixmpp/plugins/xep_0153/vcard_avatar.py new file mode 100644 index 00000000..b2c4caf5 --- /dev/null +++ b/slixmpp/plugins/xep_0153/vcard_avatar.py @@ -0,0 +1,178 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import hashlib +import logging + +from slixmpp.stanza import Presence +from slixmpp.exceptions import XMPPError, IqTimeout +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0153 import stanza, VCardTempUpdate +from slixmpp import asyncio, future_wrapper + + +log = logging.getLogger(__name__) + + +class XEP_0153(BasePlugin): + + name = 'xep_0153' + description = 'XEP-0153: vCard-Based Avatars' + dependencies = set(['xep_0054']) + stanza = stanza + + def plugin_init(self): + self._hashes = {} + + register_stanza_plugin(Presence, VCardTempUpdate) + + self.xmpp.add_filter('out', self._update_presence) + + self.xmpp.add_event_handler('session_start', self._start) + self.xmpp.add_event_handler('session_end', self._end) + + self.xmpp.add_event_handler('presence_available', self._recv_presence) + self.xmpp.add_event_handler('presence_dnd', self._recv_presence) + self.xmpp.add_event_handler('presence_xa', self._recv_presence) + self.xmpp.add_event_handler('presence_chat', self._recv_presence) + self.xmpp.add_event_handler('presence_away', self._recv_presence) + + self.api.register(self._set_hash, 'set_hash', default=True) + self.api.register(self._get_hash, 'get_hash', default=True) + self.api.register(self._reset_hash, 'reset_hash', default=True) + + def plugin_end(self): + self.xmpp.del_filter('out', self._update_presence) + self.xmpp.del_event_handler('session_start', self._start) + self.xmpp.del_event_handler('session_end', self._end) + self.xmpp.del_event_handler('presence_available', self._recv_presence) + self.xmpp.del_event_handler('presence_dnd', self._recv_presence) + self.xmpp.del_event_handler('presence_xa', self._recv_presence) + self.xmpp.del_event_handler('presence_chat', self._recv_presence) + self.xmpp.del_event_handler('presence_away', self._recv_presence) + + @future_wrapper + def set_avatar(self, jid=None, avatar=None, mtype=None, timeout=None, + callback=None, timeout_callback=None): + if jid is None: + jid = self.xmpp.boundjid.bare + + future = asyncio.Future() + + def propagate_timeout_exception(fut): + try: + fut.done() + except IqTimeout as e: + future.set_exception(e) + + def custom_callback(result): + vcard = result['vcard_temp'] + vcard['PHOTO']['TYPE'] = mtype + vcard['PHOTO']['BINVAL'] = avatar + + new_future = self.xmpp['xep_0054'].publish_vcard(jid=jid, + vcard=vcard, + timeout=timeout, + callback=next_callback, + timeout_callback=timeout_callback) + new_future.add_done_callback(propagate_timeout_exception) + + def next_callback(result): + if result['type'] == 'error': + future.set_exception(result) + else: + self.api['reset_hash'](jid) + self.xmpp.roster[jid].send_last_presence() + + future.set_result(result) + + first_future = self.xmpp['xep_0054'].get_vcard(jid, cached=False, timeout=timeout, + callback=custom_callback, + timeout_callback=timeout_callback) + first_future.add_done_callback(propagate_timeout_exception) + return future + + @asyncio.coroutine + def _start(self, event): + try: + vcard = yield from self.xmpp['xep_0054'].get_vcard(self.xmpp.boundjid.bare) + data = vcard['vcard_temp']['PHOTO']['BINVAL'] + if not data: + new_hash = '' + else: + new_hash = hashlib.sha1(data).hexdigest() + self.api['set_hash'](self.xmpp.boundjid, args=new_hash) + except XMPPError: + log.debug('Could not retrieve vCard for %s', self.xmpp.boundjid.bare) + + def _end(self, event): + pass + + def _update_presence(self, stanza): + if not isinstance(stanza, Presence): + return stanza + + if stanza['type'] not in ('available', 'dnd', 'chat', 'away', 'xa'): + return stanza + + current_hash = self.api['get_hash'](stanza['from']) + stanza['vcard_temp_update']['photo'] = current_hash + return stanza + + def _reset_hash(self, jid, node, ifrom, args): + own_jid = (jid.bare == self.xmpp.boundjid.bare) + if self.xmpp.is_component: + own_jid = (jid.domain == self.xmpp.boundjid.domain) + + self.api['set_hash'](jid, args=None) + if own_jid: + self.xmpp.roster[jid].send_last_presence() + + def callback(iq): + if iq['type'] == 'error': + log.debug('Could not retrieve vCard for %s', jid) + return + data = iq['vcard_temp']['PHOTO']['BINVAL'] + if not data: + new_hash = '' + else: + new_hash = hashlib.sha1(data).hexdigest() + + self.api['set_hash'](jid, args=new_hash) + + self.xmpp['xep_0054'].get_vcard(jid=jid.bare, ifrom=ifrom, + callback=callback) + + def _recv_presence(self, pres): + try: + if pres['muc']['affiliation']: + # Don't process vCard avatars for MUC occupants + # since they all share the same bare JID. + return + except: pass + + if not pres.match('presence/vcard_temp_update'): + self.api['set_hash'](pres['from'], args=None) + return + + data = pres['vcard_temp_update']['photo'] + if data is None: + return + elif data == '' or data != self.api['get_hash'](pres['from']): + ifrom = pres['to'] if self.xmpp.is_component else None + self.api['reset_hash'](pres['from'], ifrom=ifrom) + self.xmpp.event('vcard_avatar_update', pres) + + # ================================================================= + + def _get_hash(self, jid, node, ifrom, args): + return self._hashes.get(jid.bare, None) + + def _set_hash(self, jid, node, ifrom, args): + self._hashes[jid.bare] = args diff --git a/slixmpp/plugins/xep_0163.py b/slixmpp/plugins/xep_0163.py new file mode 100644 index 00000000..b85c662c --- /dev/null +++ b/slixmpp/plugins/xep_0163.py @@ -0,0 +1,120 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import asyncio +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.base import BasePlugin, register_plugin + + +log = logging.getLogger(__name__) + + +class XEP_0163(BasePlugin): + + """ + XEP-0163: Personal Eventing Protocol (PEP) + """ + + name = 'xep_0163' + description = 'XEP-0163: Personal Eventing Protocol (PEP)' + dependencies = set(['xep_0030', 'xep_0060', 'xep_0115']) + + def register_pep(self, name, stanza): + """ + Setup and configure events and stanza registration for + the given PEP stanza: + + - Add disco feature for the PEP content. + - Register disco interest in the PEP content. + - Map events from the PEP content's namespace to the given name. + + :param str name: The event name prefix to use for PEP events. + :param stanza: The stanza class for the PEP content. + """ + pubsub_stanza = self.xmpp['xep_0060'].stanza + register_stanza_plugin(pubsub_stanza.EventItem, stanza) + + self.add_interest(stanza.namespace) + self.xmpp['xep_0030'].add_feature(stanza.namespace) + self.xmpp['xep_0060'].map_node_event(stanza.namespace, name) + + def add_interest(self, namespace, jid=None): + """ + Mark an interest in a PEP subscription by including a disco + feature with the '+notify' extension. + + Arguments: + namespace -- The base namespace to register as an interest, such + as 'http://jabber.org/protocol/tune'. This may also + be a list of such namespaces. + jid -- Optionally specify the JID. + """ + if not isinstance(namespace, set) and not isinstance(namespace, list): + namespace = [namespace] + + for ns in namespace: + self.xmpp['xep_0030'].add_feature('%s+notify' % ns, + jid=jid) + asyncio.async(self.xmpp['xep_0115'].update_caps(jid)) + + def remove_interest(self, namespace, jid=None): + """ + Mark an interest in a PEP subscription by including a disco + feature with the '+notify' extension. + + Arguments: + namespace -- The base namespace to remove as an interest, such + as 'http://jabber.org/protocol/tune'. This may also + be a list of such namespaces. + jid -- Optionally specify the JID. + """ + if not isinstance(namespace, (set, list)): + namespace = [namespace] + + for ns in namespace: + self.xmpp['xep_0030'].del_feature(jid=jid, + feature='%s+notify' % namespace) + asyncio.async(self.xmpp['xep_0115'].update_caps(jid)) + + def publish(self, stanza, node=None, id=None, options=None, ifrom=None, + timeout_callback=None, callback=None, timeout=None): + """ + Publish a PEP update. + + This is just a (very) thin wrapper around the XEP-0060 publish() + method to set the defaults expected by PEP. + + Arguments: + stanza -- The PEP update stanza to publish. + node -- The node to publish the item to. If not specified, + the stanza's namespace will be used. + id -- Optionally specify the ID of the item. + options -- A form of publish options. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + if node is None: + node = stanza.namespace + if id is None: + id = 'current' + + return self.xmpp['xep_0060'].publish(ifrom, node, id=id, + payload=stanza.xml, + options=options, ifrom=ifrom, + callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + +register_plugin(XEP_0163) diff --git a/slixmpp/plugins/xep_0172/__init__.py b/slixmpp/plugins/xep_0172/__init__.py new file mode 100644 index 00000000..6e8d25e0 --- /dev/null +++ b/slixmpp/plugins/xep_0172/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0172 import stanza +from slixmpp.plugins.xep_0172.stanza import UserNick +from slixmpp.plugins.xep_0172.user_nick import XEP_0172 + + +register_plugin(XEP_0172) diff --git a/slixmpp/plugins/xep_0172/stanza.py b/slixmpp/plugins/xep_0172/stanza.py new file mode 100644 index 00000000..305f3a00 --- /dev/null +++ b/slixmpp/plugins/xep_0172/stanza.py @@ -0,0 +1,67 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +class UserNick(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) diff --git a/slixmpp/plugins/xep_0172/user_nick.py b/slixmpp/plugins/xep_0172/user_nick.py new file mode 100644 index 00000000..b9f20b27 --- /dev/null +++ b/slixmpp/plugins/xep_0172/user_nick.py @@ -0,0 +1,83 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza.message import Message +from slixmpp.stanza.presence import Presence +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import MatchXPath +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0172 import stanza, UserNick + + +log = logging.getLogger(__name__) + + +class XEP_0172(BasePlugin): + + """ + XEP-0172: User Nickname + """ + + name = 'xep_0172' + description = 'XEP-0172: User Nickname' + dependencies = set(['xep_0163']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, UserNick) + register_stanza_plugin(Presence, UserNick) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=UserNick.namespace) + self.xmpp['xep_0163'].remove_interest(UserNick.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0163'].register_pep('user_nick', UserNick) + + def publish_nick(self, nick=None, options=None, ifrom=None, timeout_callback=None, + callback=None, timeout=None): + """ + Publish the user's current nick. + + Arguments: + nick -- The user nickname to publish. + options -- Optional form of publish options. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + nickname = UserNick() + nickname['nick'] = nick + self.xmpp['xep_0163'].publish(nickname, node=UserNick.namespace, + options=options, ifrom=ifrom, + callback=callback, timeout=timeout, + timeout_callback=timeout_callback) + + def stop(self, ifrom=None, timeout_callback=None, callback=None, timeout=None): + """ + Clear existing user nick information to stop notifications. + + Arguments: + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + nick = UserNick() + return self.xmpp['xep_0163'].publish(nick, node=UserNick.namespace, + ifrom=ifrom, callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0184/__init__.py b/slixmpp/plugins/xep_0184/__init__.py new file mode 100644 index 00000000..83c2d7c2 --- /dev/null +++ b/slixmpp/plugins/xep_0184/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Erik Reuterborg Larsson, Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0184.stanza import Request, Received +from slixmpp.plugins.xep_0184.receipt import XEP_0184 + + +register_plugin(XEP_0184) + + +# Retain some backwards compatibility +xep_0184 = XEP_0184 diff --git a/slixmpp/plugins/xep_0184/receipt.py b/slixmpp/plugins/xep_0184/receipt.py new file mode 100644 index 00000000..2c3555dc --- /dev/null +++ b/slixmpp/plugins/xep_0184/receipt.py @@ -0,0 +1,129 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Erik Reuterborg Larsson, Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza import Message +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0184 import stanza, Request, Received + + +class XEP_0184(BasePlugin): + + """ + XEP-0184: Message Delivery Receipts + """ + + name = 'xep_0184' + description = 'XEP-0184: Message Delivery Receipts' + dependencies = set(['xep_0030']) + stanza = stanza + default_config = { + 'auto_ack': True, + 'auto_request': False + } + + ack_types = ('normal', 'chat', 'headline') + + def plugin_init(self): + register_stanza_plugin(Message, Request) + register_stanza_plugin(Message, Received) + + self.xmpp.add_filter('out', self._filter_add_receipt_request) + + self.xmpp.register_handler( + Callback('Message Receipt', + StanzaPath('message/receipt'), + self._handle_receipt_received)) + + self.xmpp.register_handler( + Callback('Message Receipt Request', + StanzaPath('message/request_receipt'), + self._handle_receipt_request)) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature('urn:xmpp:receipts') + self.xmpp.del_filter('out', self._filter_add_receipt_request) + self.xmpp.remove_handler('Message Receipt') + self.xmpp.remove_handler('Message Receipt Request') + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('urn:xmpp:receipts') + + def ack(self, msg): + """ + Acknowledge a message by sending a receipt. + + Arguments: + msg -- The message to acknowledge. + """ + ack = self.xmpp.Message() + ack['to'] = msg['from'] + ack['receipt'] = msg['id'] + ack.send() + + def _handle_receipt_received(self, msg): + self.xmpp.event('receipt_received', msg) + + def _handle_receipt_request(self, msg): + """ + Auto-ack message receipt requests if ``self.auto_ack`` is ``True``. + + Arguments: + msg -- The incoming message requesting a receipt. + """ + if self.auto_ack: + if msg['type'] in self.ack_types: + if not msg['receipt']: + self.ack(msg) + + def _filter_add_receipt_request(self, stanza): + """ + Auto add receipt requests to outgoing messages, if: + + - ``self.auto_request`` is set to ``True`` + - The message is not for groupchat + - The message does not contain a receipt acknowledgment + - The recipient is a bare JID or, if a full JID, one + that has the ``urn:xmpp:receipts`` feature enabled + + The disco cache is checked if a full JID is specified in + the outgoing message, which may mean a round-trip disco#info + delay for the first message sent to the JID if entity caps + are not used. + """ + + if not self.auto_request: + return stanza + + if not isinstance(stanza, Message): + return stanza + + if stanza['request_receipt']: + return stanza + + if not stanza['type'] in self.ack_types: + return stanza + + if stanza['receipt']: + return stanza + + if not stanza['body']: + return stanza + + if stanza['to'].resource: + if not self.xmpp['xep_0030'].supports(stanza['to'], + feature='urn:xmpp:receipts', + cached=True): + return stanza + + stanza['request_receipt'] = True + return stanza diff --git a/slixmpp/plugins/xep_0184/stanza.py b/slixmpp/plugins/xep_0184/stanza.py new file mode 100644 index 00000000..16a640e7 --- /dev/null +++ b/slixmpp/plugins/xep_0184/stanza.py @@ -0,0 +1,72 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Erik Reuterborg Larsson, Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream.stanzabase import ElementBase, ET + + +class Request(ElementBase): + namespace = 'urn:xmpp:receipts' + name = 'request' + plugin_attrib = 'request_receipt' + interfaces = set(('request_receipt',)) + sub_interfaces = interfaces + is_extension = True + + def setup(self, xml=None): + self.xml = ET.Element('') + return True + + def set_request_receipt(self, val): + self.del_request_receipt() + if val: + parent = self.parent() + parent._set_sub_text("{%s}request" % self.namespace, keep=True) + if not parent['id']: + if parent.stream: + parent['id'] = parent.stream.new_id() + + def get_request_receipt(self): + parent = self.parent() + if parent.find("{%s}request" % self.namespace) is not None: + return True + else: + return False + + def del_request_receipt(self): + self.parent()._del_sub("{%s}request" % self.namespace) + + +class Received(ElementBase): + namespace = 'urn:xmpp:receipts' + name = 'received' + plugin_attrib = 'receipt' + interfaces = set(['receipt']) + sub_interfaces = interfaces + is_extension = True + + def setup(self, xml=None): + self.xml = ET.Element('') + return True + + def set_receipt(self, value): + self.del_receipt() + if value: + parent = self.parent() + xml = ET.Element("{%s}received" % self.namespace) + xml.attrib['id'] = value + parent.append(xml) + + def get_receipt(self): + parent = self.parent() + xml = parent.find("{%s}received" % self.namespace) + if xml is not None: + return xml.attrib.get('id', '') + return '' + + def del_receipt(self): + self.parent()._del_sub('{%s}received' % self.namespace) diff --git a/slixmpp/plugins/xep_0186/__init__.py b/slixmpp/plugins/xep_0186/__init__.py new file mode 100644 index 00000000..0dc09337 --- /dev/null +++ b/slixmpp/plugins/xep_0186/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0186 import stanza +from slixmpp.plugins.xep_0186.stanza import Invisible, Visible +from slixmpp.plugins.xep_0186.invisible_command import XEP_0186 + + +register_plugin(XEP_0186) diff --git a/slixmpp/plugins/xep_0186/invisible_command.py b/slixmpp/plugins/xep_0186/invisible_command.py new file mode 100644 index 00000000..c20a3a06 --- /dev/null +++ b/slixmpp/plugins/xep_0186/invisible_command.py @@ -0,0 +1,44 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0186 import stanza, Visible, Invisible + + +log = logging.getLogger(__name__) + + +class XEP_0186(BasePlugin): + + name = 'xep_0186' + description = 'XEP-0186: Invisible Command' + dependencies = set(['xep_0030']) + + def plugin_init(self): + register_stanza_plugin(Iq, Visible) + register_stanza_plugin(Iq, Invisible) + + def set_invisible(self, ifrom=None, callback=None, + timeout=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq.enable('invisible') + iq.send(callback=callback, timeout=timeout) + + def set_visible(self, ifrom=None, callback=None, + timeout=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq.enable('visible') + iq.send(callback=callback, timeout=timeout) diff --git a/slixmpp/plugins/xep_0186/stanza.py b/slixmpp/plugins/xep_0186/stanza.py new file mode 100644 index 00000000..1ae7e834 --- /dev/null +++ b/slixmpp/plugins/xep_0186/stanza.py @@ -0,0 +1,23 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +class Invisible(ElementBase): + name = 'invisible' + namespace = 'urn:xmpp:invisible:0' + plugin_attrib = 'invisible' + interfaces = set() + + +class Visible(ElementBase): + name = 'visible' + namespace = 'urn:xmpp:visible:0' + plugin_attrib = 'visible' + interfaces = set() diff --git a/slixmpp/plugins/xep_0191/__init__.py b/slixmpp/plugins/xep_0191/__init__.py new file mode 100644 index 00000000..cd75684a --- /dev/null +++ b/slixmpp/plugins/xep_0191/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0191.stanza import Block, Unblock, BlockList +from slixmpp.plugins.xep_0191.blocking import XEP_0191 + + +register_plugin(XEP_0191) diff --git a/slixmpp/plugins/xep_0191/blocking.py b/slixmpp/plugins/xep_0191/blocking.py new file mode 100644 index 00000000..fa2a013e --- /dev/null +++ b/slixmpp/plugins/xep_0191/blocking.py @@ -0,0 +1,89 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin, JID +from slixmpp.plugins.xep_0191 import stanza, Block, Unblock, BlockList + + +log = logging.getLogger(__name__) + + +class XEP_0191(BasePlugin): + + name = 'xep_0191' + description = 'XEP-0191: Blocking Command' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, BlockList) + register_stanza_plugin(Iq, Block) + register_stanza_plugin(Iq, Unblock) + + self.xmpp.register_handler( + Callback('Blocked Contact', + StanzaPath('iq@type=set/block'), + self._handle_blocked)) + + self.xmpp.register_handler( + Callback('Unblocked Contact', + StanzaPath('iq@type=set/unblock'), + self._handle_unblocked)) + + def plugin_end(self): + self.xmpp.remove_handler('Blocked Contact') + self.xmpp.remove_handler('Unblocked Contact') + + def get_blocked(self, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['from'] = ifrom + iq.enable('blocklist') + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def block(self, jids, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + + if not isinstance(jids, (set, list)): + jids = [jids] + + iq['block']['items'] = jids + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def unblock(self, jids=None, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + + if jids is None: + jids = [] + if not isinstance(jids, (set, list)): + jids = [jids] + + iq['unblock']['items'] = jids + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def _handle_blocked(self, iq): + self.xmpp.event('blocked', iq) + + def _handle_unblocked(self, iq): + self.xmpp.event('unblocked', iq) diff --git a/slixmpp/plugins/xep_0191/stanza.py b/slixmpp/plugins/xep_0191/stanza.py new file mode 100644 index 00000000..4dac7bfc --- /dev/null +++ b/slixmpp/plugins/xep_0191/stanza.py @@ -0,0 +1,50 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ET, ElementBase, JID + + +class BlockList(ElementBase): + name = 'blocklist' + namespace = 'urn:xmpp:blocking' + plugin_attrib = 'blocklist' + interfaces = set(['items']) + + def get_items(self): + result = set() + items = self.xml.findall('{%s}item' % self.namespace) + if items is not None: + for item in items: + jid = JID(item.attrib.get('jid', '')) + if jid: + result.add(jid) + return result + + def set_items(self, values): + self.del_items() + for jid in values: + if jid: + item = ET.Element('{%s}item' % self.namespace) + item.attrib['jid'] = JID(jid).full + self.xml.append(item) + + def del_items(self): + items = self.xml.findall('{%s}item' % self.namespace) + if items is not None: + for item in items: + self.xml.remove(item) + + +class Block(BlockList): + name = 'block' + plugin_attrib = 'block' + + +class Unblock(BlockList): + name = 'unblock' + plugin_attrib = 'unblock' diff --git a/slixmpp/plugins/xep_0196/__init__.py b/slixmpp/plugins/xep_0196/__init__.py new file mode 100644 index 00000000..89f0f89e --- /dev/null +++ b/slixmpp/plugins/xep_0196/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0196 import stanza +from slixmpp.plugins.xep_0196.stanza import UserGaming +from slixmpp.plugins.xep_0196.user_gaming import XEP_0196 + + +register_plugin(XEP_0196) diff --git a/slixmpp/plugins/xep_0196/stanza.py b/slixmpp/plugins/xep_0196/stanza.py new file mode 100644 index 00000000..9c3cd0ed --- /dev/null +++ b/slixmpp/plugins/xep_0196/stanza.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +class UserGaming(ElementBase): + + name = 'gaming' + namespace = 'urn:xmpp:gaming:0' + plugin_attrib = 'gaming' + interfaces = set(['character_name', 'character_profile', 'name', + 'level', 'server_address', 'server_name', 'uri']) + sub_interfaces = interfaces + diff --git a/slixmpp/plugins/xep_0196/user_gaming.py b/slixmpp/plugins/xep_0196/user_gaming.py new file mode 100644 index 00000000..f0dee99f --- /dev/null +++ b/slixmpp/plugins/xep_0196/user_gaming.py @@ -0,0 +1,93 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0196 import stanza, UserGaming + + +log = logging.getLogger(__name__) + + +class XEP_0196(BasePlugin): + + """ + XEP-0196: User Gaming + """ + + name = 'xep_0196' + description = 'XEP-0196: User Gaming' + dependencies = set(['xep_0163']) + stanza = stanza + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=UserGaming.namespace) + self.xmpp['xep_0163'].remove_interest(UserGaming.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0163'].register_pep('user_gaming', UserGaming) + + def publish_gaming(self, name=None, level=None, server_name=None, + uri=None, character_name=None, + character_profile=None, server_address=None, + options=None, ifrom=None, callback=None, + timeout=None, timeout_callback=None): + """ + Publish the user's current gaming status. + + Arguments: + name -- The name of the game. + level -- The user's level in the game. + uri -- A URI for the game or relevant gaming service + server_name -- The name of the server where the user is playing. + server_address -- The hostname or IP address of the server where the + user is playing. + character_name -- The name of the user's character in the game. + character_profile -- A URI for a profile of the user's character. + options -- Optional form of publish options. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + gaming = UserGaming() + gaming['name'] = name + gaming['level'] = level + gaming['uri'] = uri + gaming['character_name'] = character_name + gaming['character_profile'] = character_profile + gaming['server_name'] = server_name + gaming['server_address'] = server_address + return self.xmpp['xep_0163'].publish(gaming, + node=UserGaming.namespace, + options=options, ifrom=ifrom, + callback=callback, timeout=timeout, + timeout_callback=timeout_callback) + + def stop(self, ifrom=None, callback=None, timeout=None, + timeout_callback=None): + """ + Clear existing user gaming information to stop notifications. + + Arguments: + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + gaming = UserGaming() + return self.xmpp['xep_0163'].publish(gaming, + node=UserGaming.namespace, + ifrom=ifrom, callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0198/__init__.py b/slixmpp/plugins/xep_0198/__init__.py new file mode 100644 index 00000000..bd709041 --- /dev/null +++ b/slixmpp/plugins/xep_0198/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0198.stanza import Enable, Enabled +from slixmpp.plugins.xep_0198.stanza import Resume, Resumed +from slixmpp.plugins.xep_0198.stanza import Failed +from slixmpp.plugins.xep_0198.stanza import StreamManagement +from slixmpp.plugins.xep_0198.stanza import Ack, RequestAck + +from slixmpp.plugins.xep_0198.stream_management import XEP_0198 + + +register_plugin(XEP_0198) diff --git a/slixmpp/plugins/xep_0198/stanza.py b/slixmpp/plugins/xep_0198/stanza.py new file mode 100644 index 00000000..b1c4c010 --- /dev/null +++ b/slixmpp/plugins/xep_0198/stanza.py @@ -0,0 +1,150 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Error +from slixmpp.xmlstream import ElementBase, StanzaBase + + +class Enable(StanzaBase): + name = 'enable' + namespace = 'urn:xmpp:sm:3' + interfaces = set(['max', 'resume']) + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def get_resume(self): + return self._get_attr('resume', 'false').lower() in ('true', '1') + + def set_resume(self, val): + self._del_attr('resume') + self._set_attr('resume', 'true' if val else 'false') + + +class Enabled(StanzaBase): + name = 'enabled' + namespace = 'urn:xmpp:sm:3' + interfaces = set(['id', 'location', 'max', 'resume']) + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def get_resume(self): + return self._get_attr('resume', 'false').lower() in ('true', '1') + + def set_resume(self, val): + self._del_attr('resume') + self._set_attr('resume', 'true' if val else 'false') + + +class Resume(StanzaBase): + name = 'resume' + namespace = 'urn:xmpp:sm:3' + interfaces = set(['h', 'previd']) + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def get_h(self): + h = self._get_attr('h', None) + if h: + return int(h) + return None + + def set_h(self, val): + self._set_attr('h', str(val)) + + +class Resumed(StanzaBase): + name = 'resumed' + namespace = 'urn:xmpp:sm:3' + interfaces = set(['h', 'previd']) + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def get_h(self): + h = self._get_attr('h', None) + if h: + return int(h) + return None + + def set_h(self, val): + self._set_attr('h', str(val)) + + +class Failed(StanzaBase, Error): + name = 'failed' + namespace = 'urn:xmpp:sm:3' + interfaces = set() + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + +class StreamManagement(ElementBase): + name = 'sm' + namespace = 'urn:xmpp:sm:3' + plugin_attrib = name + interfaces = set(['required', 'optional']) + + def get_required(self): + return self.find('{%s}required' % self.namespace) is not None + + def set_required(self, val): + self.del_required() + if val: + self._set_sub_text('required', '', keep=True) + + def del_required(self): + self._del_sub('required') + + def get_optional(self): + return self.find('{%s}optional' % self.namespace) is not None + + def set_optional(self, val): + self.del_optional() + if val: + self._set_sub_text('optional', '', keep=True) + + def del_optional(self): + self._del_sub('optional') + + +class RequestAck(StanzaBase): + name = 'r' + namespace = 'urn:xmpp:sm:3' + interfaces = set() + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + +class Ack(StanzaBase): + name = 'a' + namespace = 'urn:xmpp:sm:3' + interfaces = set(['h']) + + def setup(self, xml): + StanzaBase.setup(self, xml) + self.xml.tag = self.tag_name() + + def get_h(self): + h = self._get_attr('h', None) + if h: + return int(h) + return None + + def set_h(self, val): + self._set_attr('h', str(val)) diff --git a/slixmpp/plugins/xep_0198/stream_management.py b/slixmpp/plugins/xep_0198/stream_management.py new file mode 100644 index 00000000..acf37cd7 --- /dev/null +++ b/slixmpp/plugins/xep_0198/stream_management.py @@ -0,0 +1,313 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import threading +import collections + +from slixmpp.stanza import Message, Presence, Iq, StreamFeatures +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback, Waiter +from slixmpp.xmlstream.matcher import MatchXPath, MatchMany +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0198 import stanza + + +log = logging.getLogger(__name__) + + +MAX_SEQ = 2 ** 32 + + +class XEP_0198(BasePlugin): + + """ + XEP-0198: Stream Management + """ + + name = 'xep_0198' + description = 'XEP-0198: Stream Management' + dependencies = set() + stanza = stanza + default_config = { + #: The last ack number received from the server. + 'last_ack': 0, + + #: The number of stanzas to wait between sending ack requests to + #: the server. Setting this to ``1`` will send an ack request after + #: every sent stanza. Defaults to ``5``. + 'window': 5, + + #: The stream management ID for the stream. Knowing this value is + #: required in order to do stream resumption. + 'sm_id': None, + + #: A counter of handled incoming stanzas, mod 2^32. + 'handled': 0, + + #: A counter of unacked outgoing stanzas, mod 2^32. + 'seq': 0, + + #: Control whether or not the ability to resume the stream will be + #: requested when enabling stream management. Defaults to ``True``. + 'allow_resume': True, + + 'order': 10100, + 'resume_order': 9000 + } + + def plugin_init(self): + """Start the XEP-0198 plugin.""" + + # Only enable stream management for non-components, + # since components do not yet perform feature negotiation. + if self.xmpp.is_component: + return + + self.window_counter = self.window + self.window_counter_lock = threading.Lock() + + self.enabled = threading.Event() + self.unacked_queue = collections.deque() + + self.seq_lock = threading.Lock() + self.handled_lock = threading.Lock() + self.ack_lock = threading.Lock() + + register_stanza_plugin(StreamFeatures, stanza.StreamManagement) + self.xmpp.register_stanza(stanza.Enable) + self.xmpp.register_stanza(stanza.Enabled) + self.xmpp.register_stanza(stanza.Resume) + self.xmpp.register_stanza(stanza.Resumed) + self.xmpp.register_stanza(stanza.Ack) + self.xmpp.register_stanza(stanza.RequestAck) + + # Only end the session when a </stream> element is sent, + # not just because the connection has died. + self.xmpp.end_session_on_disconnect = False + + # Register the feature twice because it may be ordered two + # different ways: enabling after binding and resumption + # before binding. + self.xmpp.register_feature('sm', + self._handle_sm_feature, + restart=True, + order=self.order) + self.xmpp.register_feature('sm', + self._handle_sm_feature, + restart=True, + order=self.resume_order) + + self.xmpp.register_handler( + Callback('Stream Management Enabled', + MatchXPath(stanza.Enabled.tag_name()), + self._handle_enabled, + instream=True)) + + self.xmpp.register_handler( + Callback('Stream Management Resumed', + MatchXPath(stanza.Resumed.tag_name()), + self._handle_resumed, + instream=True)) + + self.xmpp.register_handler( + Callback('Stream Management Failed', + MatchXPath(stanza.Failed.tag_name()), + self._handle_failed, + instream=True)) + + self.xmpp.register_handler( + Callback('Stream Management Ack', + MatchXPath(stanza.Ack.tag_name()), + self._handle_ack, + instream=True)) + + self.xmpp.register_handler( + Callback('Stream Management Request Ack', + MatchXPath(stanza.RequestAck.tag_name()), + self._handle_request_ack, + instream=True)) + + self.xmpp.add_filter('in', self._handle_incoming) + self.xmpp.add_filter('out_sync', self._handle_outgoing) + + self.xmpp.add_event_handler('session_end', self.session_end) + + def plugin_end(self): + if self.xmpp.is_component: + return + + self.xmpp.unregister_feature('sm', self.order) + self.xmpp.unregister_feature('sm', self.resume_order) + self.xmpp.del_event_handler('session_end', self.session_end) + self.xmpp.del_filter('in', self._handle_incoming) + self.xmpp.del_filter('out_sync', self._handle_outgoing) + self.xmpp.remove_handler('Stream Management Enabled') + self.xmpp.remove_handler('Stream Management Resumed') + self.xmpp.remove_handler('Stream Management Failed') + self.xmpp.remove_handler('Stream Management Ack') + self.xmpp.remove_handler('Stream Management Request Ack') + self.xmpp.remove_stanza(stanza.Enable) + self.xmpp.remove_stanza(stanza.Enabled) + self.xmpp.remove_stanza(stanza.Resume) + self.xmpp.remove_stanza(stanza.Resumed) + self.xmpp.remove_stanza(stanza.Ack) + self.xmpp.remove_stanza(stanza.RequestAck) + + def session_end(self, event): + """Reset stream management state.""" + self.enabled.clear() + self.unacked_queue.clear() + self.sm_id = None + self.handled = 0 + self.seq = 0 + self.last_ack = 0 + + def send_ack(self): + """Send the current ack count to the server.""" + ack = stanza.Ack(self.xmpp) + with self.handled_lock: + ack['h'] = self.handled + self.xmpp.send_raw(str(ack)) + + def request_ack(self, e=None): + """Request an ack from the server.""" + req = stanza.RequestAck(self.xmpp) + self.xmpp.send_queue.put(str(req)) + + def _handle_sm_feature(self, features): + """ + Enable or resume stream management. + + If no SM-ID is stored, and resource binding has taken place, + stream management will be enabled. + + If an SM-ID is known, and the server allows resumption, the + previous stream will be resumed. + """ + if 'stream_management' in self.xmpp.features: + # We've already negotiated stream management, + # so no need to do it again. + return False + if not self.sm_id: + if 'bind' in self.xmpp.features: + self.enabled.set() + enable = stanza.Enable(self.xmpp) + enable['resume'] = self.allow_resume + enable.send() + self.handled = 0 + elif self.sm_id and self.allow_resume: + self.enabled.set() + resume = stanza.Resume(self.xmpp) + resume['h'] = self.handled + resume['previd'] = self.sm_id + resume.send() + + # Wait for a response before allowing stream feature processing + # to continue. The actual result processing will be done in the + # _handle_resumed() or _handle_failed() methods. + waiter = Waiter('resumed_or_failed', + MatchMany([ + MatchXPath(stanza.Resumed.tag_name()), + MatchXPath(stanza.Failed.tag_name())])) + self.xmpp.register_handler(waiter) + result = waiter.wait() + if result is not None and result.name == 'resumed': + return True + return False + + def _handle_enabled(self, stanza): + """Save the SM-ID, if provided. + + Raises an :term:`sm_enabled` event. + """ + self.xmpp.features.add('stream_management') + if stanza['id']: + self.sm_id = stanza['id'] + self.xmpp.event('sm_enabled', stanza) + + def _handle_resumed(self, stanza): + """Finish resuming a stream by resending unacked stanzas. + + Raises a :term:`session_resumed` event. + """ + self.xmpp.features.add('stream_management') + self._handle_ack(stanza) + for id, stanza in self.unacked_queue: + self.xmpp.send(stanza, use_filters=False) + self.xmpp.event('session_resumed', stanza) + + def _handle_failed(self, stanza): + """ + Disable and reset any features used since stream management was + requested (tracked stanzas may have been sent during the interval + between the enable request and the enabled response). + + Raises an :term:`sm_failed` event. + """ + self.enabled.clear() + self.unacked_queue.clear() + self.xmpp.event('sm_failed', stanza) + + def _handle_ack(self, ack): + """Process a server ack by freeing acked stanzas from the queue. + + Raises a :term:`stanza_acked` event for each acked stanza. + """ + if ack['h'] == self.last_ack: + return + + with self.ack_lock: + num_acked = (ack['h'] - self.last_ack) % MAX_SEQ + num_unacked = len(self.unacked_queue) + log.debug("Ack: %s, Last Ack: %s, " + \ + "Unacked: %s, Num Acked: %s, " + \ + "Remaining: %s", + ack['h'], + self.last_ack, + num_unacked, + num_acked, + num_unacked - num_acked) + for x in range(num_acked): + seq, stanza = self.unacked_queue.popleft() + self.xmpp.event('stanza_acked', stanza) + self.last_ack = ack['h'] + + def _handle_request_ack(self, req): + """Handle an ack request by sending an ack.""" + self.send_ack() + + def _handle_incoming(self, stanza): + """Increment the handled counter for each inbound stanza.""" + if not self.enabled.is_set(): + return stanza + + if isinstance(stanza, (Message, Presence, Iq)): + with self.handled_lock: + # Sequence numbers are mod 2^32 + self.handled = (self.handled + 1) % MAX_SEQ + return stanza + + def _handle_outgoing(self, stanza): + """Store outgoing stanzas in a queue to be acked.""" + if not self.enabled.is_set(): + return stanza + + if isinstance(stanza, (Message, Presence, Iq)): + seq = None + with self.seq_lock: + # Sequence numbers are mod 2^32 + self.seq = (self.seq + 1) % MAX_SEQ + seq = self.seq + self.unacked_queue.append((seq, stanza)) + with self.window_counter_lock: + self.window_counter -= 1 + if self.window_counter == 0: + self.window_counter = self.window + self.request_ack() + return stanza diff --git a/slixmpp/plugins/xep_0199/__init__.py b/slixmpp/plugins/xep_0199/__init__.py new file mode 100644 index 00000000..7c7bd221 --- /dev/null +++ b/slixmpp/plugins/xep_0199/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0199.stanza import Ping +from slixmpp.plugins.xep_0199.ping import XEP_0199 + + +register_plugin(XEP_0199) + + +# Backwards compatibility for names +xep_0199 = XEP_0199 +xep_0199.sendPing = xep_0199.send_ping diff --git a/slixmpp/plugins/xep_0199/ping.py b/slixmpp/plugins/xep_0199/ping.py new file mode 100644 index 00000000..bb2ceb38 --- /dev/null +++ b/slixmpp/plugins/xep_0199/ping.py @@ -0,0 +1,187 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import time +import logging + +from slixmpp.jid import JID +from slixmpp.stanza import Iq +from slixmpp import asyncio +from slixmpp.exceptions import IqError, IqTimeout +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream.handler import Callback +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0199 import stanza, Ping + + +log = logging.getLogger(__name__) + + +class XEP_0199(BasePlugin): + + """ + 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. + interval -- 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. + """ + + name = 'xep_0199' + description = 'XEP-0199: XMPP Ping' + dependencies = set(['xep_0030']) + stanza = stanza + default_config = { + 'keepalive': False, + 'interval': 300, + 'timeout': 30 + } + + def plugin_init(self): + """ + Start the XEP-0199 plugin. + """ + + 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.enable_keepalive) + self.xmpp.add_event_handler('session_end', + self.disable_keepalive) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=Ping.namespace) + self.xmpp.remove_handler('Ping') + if self.keepalive: + self.xmpp.del_event_handler('session_start', + self.enable_keepalive) + self.xmpp.del_event_handler('session_end', + self.disable_keepalive) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(Ping.namespace) + + def enable_keepalive(self, interval=None, timeout=None): + if interval: + self.interval = interval + if timeout: + self.timeout = timeout + + self.keepalive = True + self.xmpp.schedule('Ping keepalive', + self.interval, + self._keepalive, + repeat=True) + + def disable_keepalive(self, event=None): + self.xmpp.cancel_schedule('Ping keepalive') + + def _keepalive(self, event=None): + log.debug("Keepalive ping...") + try: + rtt = self.ping(self.xmpp.boundjid.host, timeout=self.timeout) + except IqTimeout: + log.debug("Did not recieve ping back in time." + \ + "Requesting Reconnect.") + self.xmpp.reconnect() + else: + log.debug('Keepalive RTT: %s' % rtt) + + def _handle_ping(self, iq): + """Automatically reply to ping requests.""" + log.debug("Pinged by %s", iq['from']) + iq.reply().send() + + def send_ping(self, jid, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + """Send a ping request. + + Arguments: + jid -- The JID that will receive the ping. + ifrom -- Specifiy the sender JID. + timeout -- Time in seconds to wait for a response. + Defaults to self.timeout. + callback -- Optional handler to execute when a pong + is received. + """ + if not timeout: + timeout = self.timeout + + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['to'] = jid + iq['from'] = ifrom + iq.enable('ping') + + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + @asyncio.coroutine + def ping(self, jid=None, ifrom=None, timeout=None): + """Send a ping request and calculate RTT. + This is a coroutine. + + Arguments: + jid -- The JID that will receive the ping. + ifrom -- Specifiy the sender JID. + timeout -- Time in seconds to wait for a response. + Defaults to self.timeout. + """ + own_host = False + if not jid: + if self.xmpp.is_component: + jid = self.xmpp.server + else: + jid = self.xmpp.boundjid.host + jid = JID(jid) + if jid == self.xmpp.boundjid.host or \ + self.xmpp.is_component and jid == self.xmpp.server: + own_host = True + + if not timeout: + timeout = self.timeout + + start = time.time() + + log.debug('Pinging %s' % jid) + try: + yield from self.send_ping(jid, ifrom=ifrom, timeout=timeout, + coroutine=True) + except IqError as e: + if own_host: + rtt = time.time() - start + log.debug('Pinged %s, RTT: %s', jid, rtt) + return rtt + else: + raise e + else: + rtt = time.time() - start + log.debug('Pinged %s, RTT: %s', jid, rtt) + return rtt diff --git a/slixmpp/plugins/xep_0199/stanza.py b/slixmpp/plugins/xep_0199/stanza.py new file mode 100644 index 00000000..425e891b --- /dev/null +++ b/slixmpp/plugins/xep_0199/stanza.py @@ -0,0 +1,36 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import slixmpp +from slixmpp.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/slixmpp/plugins/xep_0202/__init__.py b/slixmpp/plugins/xep_0202/__init__.py new file mode 100644 index 00000000..1197eabc --- /dev/null +++ b/slixmpp/plugins/xep_0202/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0202 import stanza +from slixmpp.plugins.xep_0202.stanza import EntityTime +from slixmpp.plugins.xep_0202.time import XEP_0202 + + +register_plugin(XEP_0202) + + +# Retain some backwards compatibility +xep_0202 = XEP_0202 diff --git a/slixmpp/plugins/xep_0202/stanza.py b/slixmpp/plugins/xep_0202/stanza.py new file mode 100644 index 00000000..c855663b --- /dev/null +++ b/slixmpp/plugins/xep_0202/stanza.py @@ -0,0 +1,127 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import datetime as dt + +from slixmpp.xmlstream import ElementBase +from slixmpp.plugins import xep_0082 +from slixmpp.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/slixmpp/plugins/xep_0202/time.py b/slixmpp/plugins/xep_0202/time.py new file mode 100644 index 00000000..185200fc --- /dev/null +++ b/slixmpp/plugins/xep_0202/time.py @@ -0,0 +1,99 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza.iq import Iq +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins import BasePlugin +from slixmpp.plugins import xep_0082 +from slixmpp.plugins.xep_0202 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0202(BasePlugin): + + """ + XEP-0202: Entity Time + """ + + name = 'xep_0202' + description = 'XEP-0202: Entity Time' + dependencies = set(['xep_0030', 'xep_0082']) + stanza = stanza + default_config = { + #: 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. + 'local_time': None, + 'tz_offset': 0 + } + + def plugin_init(self): + """Start the XEP-0203 plugin.""" + + if not self.local_time: + def default_local_time(jid): + return xep_0082.datetime(offset=self.tz_offset) + + self.local_time = default_local_time + + self.xmpp.register_handler( + Callback('Entity Time', + StanzaPath('iq/entity_time'), + self._handle_time_request)) + register_stanza_plugin(Iq, stanza.EntityTime) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature='urn:xmpp:time') + self.xmpp.remove_handler('Entity Time') + + def session_bind(self, jid): + 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 = 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/slixmpp/plugins/xep_0203/__init__.py b/slixmpp/plugins/xep_0203/__init__.py new file mode 100644 index 00000000..59cb7af2 --- /dev/null +++ b/slixmpp/plugins/xep_0203/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0203 import stanza +from slixmpp.plugins.xep_0203.stanza import Delay +from slixmpp.plugins.xep_0203.delay import XEP_0203 + + +register_plugin(XEP_0203) + +# Retain some backwards compatibility +xep_0203 = XEP_0203 diff --git a/slixmpp/plugins/xep_0203/delay.py b/slixmpp/plugins/xep_0203/delay.py new file mode 100644 index 00000000..725003e2 --- /dev/null +++ b/slixmpp/plugins/xep_0203/delay.py @@ -0,0 +1,37 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +from slixmpp.stanza import Message, Presence +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0203 import stanza + + +class XEP_0203(BasePlugin): + + """ + 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>. + """ + + name = 'xep_0203' + description = 'XEP-0203: Delayed Delivery' + dependencies = set() + stanza = stanza + + def plugin_init(self): + """Start the XEP-0203 plugin.""" + register_stanza_plugin(Message, stanza.Delay) + register_stanza_plugin(Presence, stanza.Delay) diff --git a/slixmpp/plugins/xep_0203/stanza.py b/slixmpp/plugins/xep_0203/stanza.py new file mode 100644 index 00000000..de907c69 --- /dev/null +++ b/slixmpp/plugins/xep_0203/stanza.py @@ -0,0 +1,46 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import datetime as dt + +from slixmpp.jid import JID +from slixmpp.xmlstream import ElementBase +from slixmpp.plugins import xep_0082 + + +class Delay(ElementBase): + + name = 'delay' + namespace = 'urn:xmpp:delay' + plugin_attrib = 'delay' + interfaces = set(('from', 'stamp', 'text')) + + def get_from(self): + from_ = self._get_attr('from') + return JID(from_) if from_ else None + + def set_from(self, value): + self._set_attr('from', str(value)) + + def get_stamp(self): + timestamp = self._get_attr('stamp') + return xep_0082.parse(timestamp) if timestamp else None + + 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/slixmpp/plugins/xep_0221/__init__.py b/slixmpp/plugins/xep_0221/__init__.py new file mode 100644 index 00000000..b977acc7 --- /dev/null +++ b/slixmpp/plugins/xep_0221/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0221 import stanza +from slixmpp.plugins.xep_0221.stanza import Media, URI +from slixmpp.plugins.xep_0221.media import XEP_0221 + + +register_plugin(XEP_0221) diff --git a/slixmpp/plugins/xep_0221/media.py b/slixmpp/plugins/xep_0221/media.py new file mode 100644 index 00000000..4c34fbd2 --- /dev/null +++ b/slixmpp/plugins/xep_0221/media.py @@ -0,0 +1,27 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0221 import stanza, Media, URI +from slixmpp.plugins.xep_0004 import FormField + + +log = logging.getLogger(__name__) + + +class XEP_0221(BasePlugin): + + name = 'xep_0221' + description = 'XEP-0221: Data Forms Media Element' + dependencies = set(['xep_0004']) + + def plugin_init(self): + register_stanza_plugin(FormField, Media) diff --git a/slixmpp/plugins/xep_0221/stanza.py b/slixmpp/plugins/xep_0221/stanza.py new file mode 100644 index 00000000..2a2bbabd --- /dev/null +++ b/slixmpp/plugins/xep_0221/stanza.py @@ -0,0 +1,42 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + + +class Media(ElementBase): + name = 'media' + namespace = 'urn:xmpp:media-element' + plugin_attrib = 'media' + interfaces = set(['height', 'width', 'alt']) + + def add_uri(self, value, itype): + uri = URI() + uri['value'] = value + uri['type'] = itype + self.append(uri) + + +class URI(ElementBase): + name = 'uri' + namespace = 'urn:xmpp:media-element' + plugin_attrib = 'uri' + plugin_multi_attrib = 'uris' + interfaces = set(['type', 'value']) + + def get_value(self): + return self.xml.text + + def set_value(self, value): + self.xml.text = value + + def del_value(self): + sel.xml.text = '' + + +register_stanza_plugin(Media, URI, iterable=True) diff --git a/slixmpp/plugins/xep_0222.py b/slixmpp/plugins/xep_0222.py new file mode 100644 index 00000000..059f4c85 --- /dev/null +++ b/slixmpp/plugins/xep_0222.py @@ -0,0 +1,120 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.base import BasePlugin, register_plugin + + +log = logging.getLogger(__name__) + + +class XEP_0222(BasePlugin): + + """ + XEP-0222: Persistent Storage of Public Data via PubSub + """ + + name = 'xep_0222' + description = 'XEP-0222: Persistent Storage of Public Data via PubSub' + dependencies = set(['xep_0163', 'xep_0060', 'xep_0004']) + + profile = {'pubsub#persist_items': True, + 'pubsub#send_last_published_item': 'never'} + + def configure(self, node): + """ + Update a node's configuration to match the public storage profile. + """ + config = self.xmpp['xep_0004'].Form() + config['type'] = 'submit' + + for field, value in self.profile.items(): + config.add_field(var=field, value=value) + + return self.xmpp['xep_0060'].set_node_config(None, node, config, + ifrom=ifrom, + callback=callback, + timeout=timeout) + + def store(self, stanza, node=None, id=None, ifrom=None, options=None, + callback=None, timeout=None): + """ + Store public data via PEP. + + This is just a (very) thin wrapper around the XEP-0060 publish() + method to set the defaults expected by PEP. + + Arguments: + stanza -- The private content to store. + node -- The node to publish the content to. If not specified, + the stanza's namespace will be used. + id -- Optionally specify the ID of the item. + options -- Publish options to use, which will be modified to + fit the persistent storage option profile. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + if not options: + options = self.xmpp['xep_0004'].stanza.Form() + options['type'] = 'submit' + options.add_field( + var='FORM_TYPE', + ftype='hidden', + value='http://jabber.org/protocol/pubsub#publish-options') + + fields = options['fields'] + for field, value in self.profile.items(): + if field not in fields: + options.add_field(var=field) + options['fields'][field]['value'] = value + + return self.xmpp['xep_0163'].publish(stanza, node, + options=options, + ifrom=ifrom, + callback=callback, + timeout=timeout) + + def retrieve(self, node, id=None, item_ids=None, ifrom=None, + callback=None, timeout=None): + """ + Retrieve public data via PEP. + + This is just a (very) thin wrapper around the XEP-0060 publish() + method to set the defaults expected by PEP. + + Arguments: + node -- The node to retrieve content from. + id -- Optionally specify the ID of the item. + item_ids -- Specify a group of IDs. If id is also specified, it + will be included in item_ids. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + if item_ids is None: + item_ids = [] + if id is not None: + item_ids.append(id) + + return self.xmpp['xep_0060'].get_items(None, node, + item_ids=item_ids, + ifrom=ifrom, + callback=callback, + timeout=timeout) + + +register_plugin(XEP_0222) diff --git a/slixmpp/plugins/xep_0223.py b/slixmpp/plugins/xep_0223.py new file mode 100644 index 00000000..2461bb20 --- /dev/null +++ b/slixmpp/plugins/xep_0223.py @@ -0,0 +1,119 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.base import BasePlugin, register_plugin + + +log = logging.getLogger(__name__) + + +class XEP_0223(BasePlugin): + + """ + XEP-0223: Persistent Storage of Private Data via PubSub + """ + + name = 'xep_0223' + description = 'XEP-0223: Persistent Storage of Private Data via PubSub' + dependencies = set(['xep_0163', 'xep_0060', 'xep_0004']) + + profile = {'pubsub#persist_items': True, + 'pubsub#send_last_published_item': 'never'} + + def configure(self, node): + """ + Update a node's configuration to match the public storage profile. + """ + # TODO: that cannot possibly work, why is this here? + config = self.xmpp['xep_0004'].Form() + config['type'] = 'submit' + + for field, value in self.profile.items(): + config.add_field(var=field, value=value) + + return self.xmpp['xep_0060'].set_node_config(None, node, config, + ifrom=ifrom, + callback=callback, + timeout=timeout) + + def store(self, stanza, node=None, id=None, ifrom=None, options=None, + callback=None, timeout=None, timeout_callback=None): + """ + Store private data via PEP. + + This is just a (very) thin wrapper around the XEP-0060 publish() + method to set the defaults expected by PEP. + + Arguments: + stanza -- The private content to store. + node -- The node to publish the content to. If not specified, + the stanza's namespace will be used. + id -- Optionally specify the ID of the item. + options -- Publish options to use, which will be modified to + fit the persistent storage option profile. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + if not options: + options = self.xmpp['xep_0004'].stanza.Form() + options['type'] = 'submit' + options.add_field( + var='FORM_TYPE', + ftype='hidden', + value='http://jabber.org/protocol/pubsub#publish-options') + + fields = options['fields'] + for field, value in self.profile.items(): + if field not in fields: + options.add_field(var=field) + options['fields'][field]['value'] = value + + return self.xmpp['xep_0163'].publish(stanza, node, options=options, + ifrom=ifrom, callback=callback, + timeout=timeout, + timeout_callback=timeout_callback) + + def retrieve(self, node, id=None, item_ids=None, ifrom=None, + callback=None, timeout=None, timeout_callback=None): + """ + Retrieve private data via PEP. + + This is just a (very) thin wrapper around the XEP-0060 publish() + method to set the defaults expected by PEP. + + Arguments: + node -- The node to retrieve content from. + id -- Optionally specify the ID of the item. + item_ids -- Specify a group of IDs. If id is also specified, it + will be included in item_ids. + ifrom -- Specify the sender's JID. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to slixmpp.xmlstream.RESPONSE_TIMEOUT + callback -- Optional reference to a stream handler function. Will + be executed when a reply stanza is received. + """ + if item_ids is None: + item_ids = [] + if id is not None: + item_ids.append(id) + + return self.xmpp['xep_0060'].get_items(None, node, + item_ids=item_ids, ifrom=ifrom, + callback=callback, timeout=timeout, + timeout_callback=timeout_callback) + + +register_plugin(XEP_0223) diff --git a/slixmpp/plugins/xep_0224/__init__.py b/slixmpp/plugins/xep_0224/__init__.py new file mode 100644 index 00000000..4fadfd70 --- /dev/null +++ b/slixmpp/plugins/xep_0224/__init__.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0224 import stanza +from slixmpp.plugins.xep_0224.stanza import Attention +from slixmpp.plugins.xep_0224.attention import XEP_0224 + + +register_plugin(XEP_0224) + + +# Retain some backwards compatibility +xep_0224 = XEP_0224 diff --git a/slixmpp/plugins/xep_0224/attention.py b/slixmpp/plugins/xep_0224/attention.py new file mode 100644 index 00000000..2777e1b0 --- /dev/null +++ b/slixmpp/plugins/xep_0224/attention.py @@ -0,0 +1,75 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.stanza import Message +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0224 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0224(BasePlugin): + + """ + XEP-0224: Attention + """ + + name = 'xep_0224' + description = 'XEP-0224: Attention' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + """Start the XEP-0224 plugin.""" + register_stanza_plugin(Message, stanza.Attention) + + self.xmpp.register_handler( + Callback('Attention', + StanzaPath('message/attention'), + self._handle_attention)) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=stanza.Attention.namespace) + self.xmpp.remove_handler('Attention') + + def session_bind(self, jid): + 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/slixmpp/plugins/xep_0224/stanza.py b/slixmpp/plugins/xep_0224/stanza.py new file mode 100644 index 00000000..2b77dadd --- /dev/null +++ b/slixmpp/plugins/xep_0224/stanza.py @@ -0,0 +1,40 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/plugins/xep_0231/__init__.py b/slixmpp/plugins/xep_0231/__init__.py new file mode 100644 index 00000000..57d57846 --- /dev/null +++ b/slixmpp/plugins/xep_0231/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, + Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0231.stanza import BitsOfBinary +from slixmpp.plugins.xep_0231.bob import XEP_0231 + + +register_plugin(XEP_0231) diff --git a/slixmpp/plugins/xep_0231/bob.py b/slixmpp/plugins/xep_0231/bob.py new file mode 100644 index 00000000..a3b5edd4 --- /dev/null +++ b/slixmpp/plugins/xep_0231/bob.py @@ -0,0 +1,141 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, + Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import hashlib + +from slixmpp import future_wrapper +from slixmpp.stanza import Iq, Message, Presence +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0231 import stanza, BitsOfBinary + + +log = logging.getLogger(__name__) + + +class XEP_0231(BasePlugin): + + """ + XEP-0231 Bits of Binary + """ + + name = 'xep_0231' + description = 'XEP-0231: Bits of Binary' + dependencies = set(['xep_0030']) + + def plugin_init(self): + self._cids = {} + + register_stanza_plugin(Iq, BitsOfBinary) + register_stanza_plugin(Message, BitsOfBinary) + register_stanza_plugin(Presence, BitsOfBinary) + + self.xmpp.register_handler( + Callback('Bits of Binary - Iq', + StanzaPath('iq/bob'), + self._handle_bob_iq)) + + self.xmpp.register_handler( + Callback('Bits of Binary - Message', + StanzaPath('message/bob'), + self._handle_bob)) + + self.xmpp.register_handler( + Callback('Bits of Binary - Presence', + StanzaPath('presence/bob'), + self._handle_bob)) + + self.api.register(self._get_bob, 'get_bob', default=True) + self.api.register(self._set_bob, 'set_bob', default=True) + self.api.register(self._del_bob, 'del_bob', default=True) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature='urn:xmpp:bob') + self.xmpp.remove_handler('Bits of Binary - Iq') + self.xmpp.remove_handler('Bits of Binary - Message') + self.xmpp.remove_handler('Bits of Binary - Presence') + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('urn:xmpp:bob') + + def set_bob(self, data, mtype, cid=None, max_age=None): + if cid is None: + cid = 'sha1+%s@bob.xmpp.org' % hashlib.sha1(data).hexdigest() + + bob = BitsOfBinary() + bob['data'] = data + bob['type'] = mtype + bob['cid'] = cid + bob['max_age'] = max_age + + self.api['set_bob'](args=bob) + + return cid + + @future_wrapper + def get_bob(self, jid=None, cid=None, cached=True, ifrom=None, + timeout=None, callback=None): + if cached: + data = self.api['get_bob'](None, None, ifrom, args=cid) + if data is not None: + if not isinstance(data, Iq): + iq = self.xmpp.Iq() + iq.append(data) + return iq + return data + + iq = self.xmpp.Iq() + iq['to'] = jid + iq['from'] = ifrom + iq['type'] = 'get' + iq['bob']['cid'] = cid + return iq.send(timeout=timeout, callback=callback) + + def del_bob(self, cid): + self.api['del_bob'](args=cid) + + def _handle_bob_iq(self, iq): + cid = iq['bob']['cid'] + + if iq['type'] == 'result': + self.api['set_bob'](iq['from'], None, iq['to'], args=iq['bob']) + self.xmpp.event('bob', iq) + elif iq['type'] == 'get': + data = self.api['get_bob'](iq['to'], None, iq['from'], args=cid) + if isinstance(data, Iq): + data['id'] = iq['id'] + data.send() + return + + iq = iq.reply() + iq.append(data) + iq.send() + + def _handle_bob(self, stanza): + self.api['set_bob'](stanza['from'], None, + stanza['to'], args=stanza['bob']) + self.xmpp.event('bob', stanza) + + # ================================================================= + + def _set_bob(self, jid, node, ifrom, bob): + self._cids[bob['cid']] = bob + + def _get_bob(self, jid, node, ifrom, cid): + if cid in self._cids: + return self._cids[cid] + raise XMPPError('item-not-found') + + def _del_bob(self, jid, node, ifrom, cid): + if cid in self._cids: + del self._cids[cid] diff --git a/slixmpp/plugins/xep_0231/stanza.py b/slixmpp/plugins/xep_0231/stanza.py new file mode 100644 index 00000000..b3b96eff --- /dev/null +++ b/slixmpp/plugins/xep_0231/stanza.py @@ -0,0 +1,36 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, + Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import base64 + + +from slixmpp.util import bytes +from slixmpp.xmlstream import ElementBase + + +class BitsOfBinary(ElementBase): + name = 'data' + namespace = 'urn:xmpp:bob' + plugin_attrib = 'bob' + interfaces = set(('cid', 'max_age', 'type', 'data')) + + def get_max_age(self): + return int(self._get_attr('max-age')) + + def set_max_age(self, value): + self._set_attr('max-age', str(value)) + + def get_data(self): + return base64.b64decode(bytes(self.xml.text)) + + def set_data(self, value): + self.xml.text = bytes(base64.b64encode(value)).decode('utf-8') + + def del_data(self): + self.xml.text = '' diff --git a/slixmpp/plugins/xep_0235/__init__.py b/slixmpp/plugins/xep_0235/__init__.py new file mode 100644 index 00000000..8810a84d --- /dev/null +++ b/slixmpp/plugins/xep_0235/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0235 import stanza +from slixmpp.plugins.xep_0235.stanza import OAuth +from slixmpp.plugins.xep_0235.oauth import XEP_0235 + + +register_plugin(XEP_0235) diff --git a/slixmpp/plugins/xep_0235/oauth.py b/slixmpp/plugins/xep_0235/oauth.py new file mode 100644 index 00000000..bcd220b0 --- /dev/null +++ b/slixmpp/plugins/xep_0235/oauth.py @@ -0,0 +1,32 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +import logging + +from slixmpp import Message +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0235 import stanza, OAuth + + +class XEP_0235(BasePlugin): + + name = 'xep_0235' + description = 'XEP-0235: OAuth Over XMPP' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, OAuth) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('urn:xmpp:oauth:0') + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature='urn:xmpp:oauth:0') diff --git a/slixmpp/plugins/xep_0235/stanza.py b/slixmpp/plugins/xep_0235/stanza.py new file mode 100644 index 00000000..abb4a38d --- /dev/null +++ b/slixmpp/plugins/xep_0235/stanza.py @@ -0,0 +1,80 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import hmac +import hashlib +import urllib +import base64 + +from slixmpp.xmlstream import ET, ElementBase, JID + + +class OAuth(ElementBase): + + name = 'oauth' + namespace = 'urn:xmpp:oauth:0' + plugin_attrib = 'oauth' + interfaces = set(['oauth_consumer_key', 'oauth_nonce', 'oauth_signature', + 'oauth_signature_method', 'oauth_timestamp', + 'oauth_token', 'oauth_version']) + sub_interfaces = interfaces + + def generate_signature(self, stanza, sfrom, sto, consumer_secret, + token_secret, method='HMAC-SHA1'): + self['oauth_signature_method'] = method + + request = urllib.quote('%s&%s' % (sfrom, sto), '') + parameters = urllib.quote('&'.join([ + 'oauth_consumer_key=%s' % self['oauth_consumer_key'], + 'oauth_nonce=%s' % self['oauth_nonce'], + 'oauth_signature_method=%s' % self['oauth_signature_method'], + 'oauth_timestamp=%s' % self['oauth_timestamp'], + 'oauth_token=%s' % self['oauth_token'], + 'oauth_version=%s' % self['oauth_version'] + ]), '') + + sigbase = '%s&%s&%s' % (stanza, request, parameters) + + consumer_secret = urllib.quote(consumer_secret, '') + token_secret = urllib.quote(token_secret, '') + key = '%s&%s' % (consumer_secret, token_secret) + + if method == 'HMAC-SHA1': + sig = base64.b64encode(hmac.new(key, sigbase, hashlib.sha1).digest()) + elif method == 'PLAINTEXT': + sig = key + + self['oauth_signature'] = sig + return sig + + def verify_signature(self, stanza, sfrom, sto, consumer_secret, + token_secret): + method = self['oauth_signature_method'] + + request = urllib.quote('%s&%s' % (sfrom, sto), '') + parameters = urllib.quote('&'.join([ + 'oauth_consumer_key=%s' % self['oauth_consumer_key'], + 'oauth_nonce=%s' % self['oauth_nonce'], + 'oauth_signature_method=%s' % self['oauth_signature_method'], + 'oauth_timestamp=%s' % self['oauth_timestamp'], + 'oauth_token=%s' % self['oauth_token'], + 'oauth_version=%s' % self['oauth_version'] + ]), '') + + sigbase = '%s&%s&%s' % (stanza, request, parameters) + + consumer_secret = urllib.quote(consumer_secret, '') + token_secret = urllib.quote(token_secret, '') + key = '%s&%s' % (consumer_secret, token_secret) + + if method == 'HMAC-SHA1': + sig = base64.b64encode(hmac.new(key, sigbase, hashlib.sha1).digest()) + elif method == 'PLAINTEXT': + sig = key + + return self['oauth_signature'] == sig diff --git a/slixmpp/plugins/xep_0242.py b/slixmpp/plugins/xep_0242.py new file mode 100644 index 00000000..ea077a70 --- /dev/null +++ b/slixmpp/plugins/xep_0242.py @@ -0,0 +1,21 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins import BasePlugin, register_plugin + + +class XEP_0242(BasePlugin): + + name = 'xep_0242' + description = 'XEP-0242: XMPP Client Compliance 2009' + dependencies = set(['xep_0030', 'xep_0115', 'xep_0054', + 'xep_0045', 'xep_0085', 'xep_0016', + 'xep_0191']) + + +register_plugin(XEP_0242) diff --git a/slixmpp/plugins/xep_0249/__init__.py b/slixmpp/plugins/xep_0249/__init__.py new file mode 100644 index 00000000..5d120ec6 --- /dev/null +++ b/slixmpp/plugins/xep_0249/__init__.py @@ -0,0 +1,19 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dalek + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0249.stanza import Invite +from slixmpp.plugins.xep_0249.invite import XEP_0249 + + +register_plugin(XEP_0249) + + +# Retain some backwards compatibility +xep_0249 = XEP_0249 diff --git a/slixmpp/plugins/xep_0249/invite.py b/slixmpp/plugins/xep_0249/invite.py new file mode 100644 index 00000000..fe5f5884 --- /dev/null +++ b/slixmpp/plugins/xep_0249/invite.py @@ -0,0 +1,83 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dalek + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +import slixmpp +from slixmpp import Message +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.xep_0249 import Invite, stanza + + +log = logging.getLogger(__name__) + + +class XEP_0249(BasePlugin): + + """ + XEP-0249: Direct MUC Invitations + """ + + name = 'xep_0249' + description = 'XEP-0249: Direct MUC Invitations' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + self.xmpp.register_handler( + Callback('Direct MUC Invitations', + StanzaPath('message/groupchat_invite'), + self._handle_invite)) + + register_stanza_plugin(Message, Invite) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=Invite.namespace) + self.xmpp.remove_handler('Direct MUC Invitations') + + def session_bind(self, jid): + 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/slixmpp/plugins/xep_0249/stanza.py b/slixmpp/plugins/xep_0249/stanza.py new file mode 100644 index 00000000..5979f091 --- /dev/null +++ b/slixmpp/plugins/xep_0249/stanza.py @@ -0,0 +1,39 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2011 Nathanael C. Fritz, Dalek + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/plugins/xep_0256.py b/slixmpp/plugins/xep_0256.py new file mode 100644 index 00000000..4ad4f0ea --- /dev/null +++ b/slixmpp/plugins/xep_0256.py @@ -0,0 +1,73 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Presence +from slixmpp.exceptions import XMPPError +from slixmpp.plugins import BasePlugin, register_plugin +from slixmpp.xmlstream import register_stanza_plugin + +from slixmpp.plugins.xep_0012 import stanza, LastActivity + + +log = logging.getLogger(__name__) + + +class XEP_0256(BasePlugin): + + name = 'xep_0256' + description = 'XEP-0256: Last Activity in Presence' + dependencies = set(['xep_0012']) + stanza = stanza + default_config = { + 'auto_last_activity': False + } + + def plugin_init(self): + register_stanza_plugin(Presence, LastActivity) + + self.xmpp.add_filter('out', self._initial_presence_activity) + self.xmpp.add_event_handler('connected', self._reset_presence_activity) + + self._initial_presence = set() + + def plugin_end(self): + self.xmpp.del_filter('out', self._initial_presence_activity) + self.xmpp.del_event_handler('connected', self._reset_presence_activity) + + def _reset_presence_activity(self, e): + self._initial_presence = set() + + def _initial_presence_activity(self, stanza): + if isinstance(stanza, Presence): + use_last_activity = False + + if self.auto_last_activity and stanza['show'] in ('xa', 'away'): + use_last_activity = True + + if stanza['from'] not in self._initial_presence: + self._initial_presence.add(stanza['from']) + use_last_activity = True + + if use_last_activity: + plugin = self.xmpp['xep_0012'] + try: + result = plugin.api['get_last_activity'](stanza['from'], + None, + stanza['to']) + seconds = result['last_activity']['seconds'] + except XMPPError: + seconds = None + + if seconds is not None: + stanza['last_activity']['seconds'] = seconds + return stanza + + +register_plugin(XEP_0256) diff --git a/slixmpp/plugins/xep_0257/__init__.py b/slixmpp/plugins/xep_0257/__init__.py new file mode 100644 index 00000000..2621ad8f --- /dev/null +++ b/slixmpp/plugins/xep_0257/__init__.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0257 import stanza +from slixmpp.plugins.xep_0257.stanza import Certs, AppendCert +from slixmpp.plugins.xep_0257.stanza import DisableCert, RevokeCert +from slixmpp.plugins.xep_0257.client_cert_management import XEP_0257 + + +register_plugin(XEP_0257) diff --git a/slixmpp/plugins/xep_0257/client_cert_management.py b/slixmpp/plugins/xep_0257/client_cert_management.py new file mode 100644 index 00000000..a6d07506 --- /dev/null +++ b/slixmpp/plugins/xep_0257/client_cert_management.py @@ -0,0 +1,70 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0257 import stanza, Certs +from slixmpp.plugins.xep_0257 import AppendCert, DisableCert, RevokeCert + + +log = logging.getLogger(__name__) + + +class XEP_0257(BasePlugin): + + name = 'xep_0257' + description = 'XEP-0257: Client Certificate Management for SASL EXTERNAL' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, Certs) + register_stanza_plugin(Iq, AppendCert) + register_stanza_plugin(Iq, DisableCert) + register_stanza_plugin(Iq, RevokeCert) + + def get_certs(self, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['from'] = ifrom + iq.enable('sasl_certs') + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def add_cert(self, name, cert, allow_management=True, ifrom=None, + timeout=None, callback=None, timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq['sasl_cert_append']['name'] = name + iq['sasl_cert_append']['x509cert'] = cert + iq['sasl_cert_append']['cert_management'] = allow_management + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def disable_cert(self, name, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq['sasl_cert_disable']['name'] = name + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) + + def revoke_cert(self, name, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq['sasl_cert_revoke']['name'] = name + return iq.send(timeout=timeout, callback=callback, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0257/stanza.py b/slixmpp/plugins/xep_0257/stanza.py new file mode 100644 index 00000000..86b63451 --- /dev/null +++ b/slixmpp/plugins/xep_0257/stanza.py @@ -0,0 +1,87 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Certs(ElementBase): + name = 'items' + namespace = 'urn:xmpp:saslcert:1' + plugin_attrib = 'sasl_certs' + interfaces = set() + + +class CertItem(ElementBase): + name = 'item' + namespace = 'urn:xmpp:saslcert:1' + plugin_attrib = 'item' + plugin_multi_attrib = 'items' + interfaces = set(['name', 'x509cert', 'users']) + sub_interfaces = set(['name', 'x509cert']) + + def get_users(self): + resources = self.xml.findall('{%s}users/{%s}resource' % ( + self.namespace, self.namespace)) + return set([res.text for res in resources]) + + def set_users(self, values): + users = self.xml.find('{%s}users' % self.namespace) + if users is None: + users = ET.Element('{%s}users' % self.namespace) + self.xml.append(users) + for resource in values: + res = ET.Element('{%s}resource' % self.namespace) + res.text = resource + users.append(res) + + def del_users(self): + users = self.xml.find('{%s}users' % self.namespace) + if users is not None: + self.xml.remove(users) + + +class AppendCert(ElementBase): + name = 'append' + namespace = 'urn:xmpp:saslcert:1' + plugin_attrib = 'sasl_cert_append' + interfaces = set(['name', 'x509cert', 'cert_management']) + sub_interfaces = set(['name', 'x509cert']) + + def get_cert_management(self): + manage = self.xml.find('{%s}no-cert-management' % self.namespace) + return manage is None + + def set_cert_management(self, value): + self.del_cert_management() + if not value: + manage = ET.Element('{%s}no-cert-management' % self.namespace) + self.xml.append(manage) + + def del_cert_management(self): + manage = self.xml.find('{%s}no-cert-management' % self.namespace) + if manage is not None: + self.xml.remove(manage) + + +class DisableCert(ElementBase): + name = 'disable' + namespace = 'urn:xmpp:saslcert:1' + plugin_attrib = 'sasl_cert_disable' + interfaces = set(['name']) + sub_interfaces = interfaces + + +class RevokeCert(ElementBase): + name = 'revoke' + namespace = 'urn:xmpp:saslcert:1' + plugin_attrib = 'sasl_cert_revoke' + interfaces = set(['name']) + sub_interfaces = interfaces + + +register_stanza_plugin(Certs, CertItem, iterable=True) diff --git a/slixmpp/plugins/xep_0258/__init__.py b/slixmpp/plugins/xep_0258/__init__.py new file mode 100644 index 00000000..7210072d --- /dev/null +++ b/slixmpp/plugins/xep_0258/__init__.py @@ -0,0 +1,18 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0258 import stanza +from slixmpp.plugins.xep_0258.stanza import SecurityLabel, Label +from slixmpp.plugins.xep_0258.stanza import DisplayMarking, EquivalentLabel +from slixmpp.plugins.xep_0258.stanza import ESSLabel, Catalog, CatalogItem +from slixmpp.plugins.xep_0258.security_labels import XEP_0258 + + +register_plugin(XEP_0258) diff --git a/slixmpp/plugins/xep_0258/security_labels.py b/slixmpp/plugins/xep_0258/security_labels.py new file mode 100644 index 00000000..2fb048c7 --- /dev/null +++ b/slixmpp/plugins/xep_0258/security_labels.py @@ -0,0 +1,44 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq, Message +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0258 import stanza, SecurityLabel, Catalog + + +log = logging.getLogger(__name__) + + +class XEP_0258(BasePlugin): + + name = 'xep_0258' + description = 'XEP-0258: Security Labels in XMPP' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, SecurityLabel) + register_stanza_plugin(Iq, Catalog) + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature=SecurityLabel.namespace) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature(SecurityLabel.namespace) + + def get_catalog(self, jid, ifrom=None, + callback=None, timeout=None): + iq = self.xmpp.Iq() + iq['to'] = jid + iq['from'] = ifrom + iq['type'] = 'get' + iq.enable('security_label_catalog') + return iq.send(callback=callback, timeout=timeout) diff --git a/slixmpp/plugins/xep_0258/stanza.py b/slixmpp/plugins/xep_0258/stanza.py new file mode 100644 index 00000000..e47bd34f --- /dev/null +++ b/slixmpp/plugins/xep_0258/stanza.py @@ -0,0 +1,141 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from base64 import b64encode, b64decode + +from slixmpp.util import bytes +from slixmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class SecurityLabel(ElementBase): + name = 'securitylabel' + namespace = 'urn:xmpp:sec-label:0' + plugin_attrib = 'security_label' + + def add_equivalent(self, label): + equiv = EquivalentLabel(parent=self) + equiv.append(label) + return equiv + + +class Label(ElementBase): + name = 'label' + namespace = 'urn:xmpp:sec-label:0' + plugin_attrib = 'label' + + +class DisplayMarking(ElementBase): + name = 'displaymarking' + namespace = 'urn:xmpp:sec-label:0' + plugin_attrib = 'display_marking' + interfaces = set(['fgcolor', 'bgcolor', 'value']) + + def get_fgcolor(self): + return self._get_attr('fgcolor', 'black') + + def get_bgcolor(self): + return self._get_attr('fgcolor', 'white') + + def get_value(self): + return self.xml.text + + def set_value(self, value): + self.xml.text = value + + def del_value(self): + self.xml.text = '' + + +class EquivalentLabel(ElementBase): + name = 'equivalentlabel' + namespace = 'urn:xmpp:sec-label:0' + plugin_attrib = 'equivalent_label' + plugin_multi_attrib = 'equivalent_labels' + + +class Catalog(ElementBase): + name = 'catalog' + namespace = 'urn:xmpp:sec-label:catalog:2' + plugin_attrib = 'security_label_catalog' + interfaces = set(['to', 'from', 'name', 'desc', 'id', 'size', 'restrict']) + + def get_to(self): + return JID(self._get_attr('to')) + pass + + def set_to(self, value): + return self._set_attr('to', str(value)) + + def get_from(self): + return JID(self._get_attr('from')) + + def set_from(self, value): + return self._set_attr('from', str(value)) + + def get_restrict(self): + value = self._get_attr('restrict', '') + if value and value.lower() in ('true', '1'): + return True + return False + + def set_restrict(self, value): + self._del_attr('restrict') + if value: + self._set_attr('restrict', 'true') + elif value is False: + self._set_attr('restrict', 'false') + + +class CatalogItem(ElementBase): + name = 'catalog' + namespace = 'urn:xmpp:sec-label:catalog:2' + plugin_attrib = 'item' + plugin_multi_attrib = 'items' + interfaces = set(['selector', 'default']) + + def get_default(self): + value = self._get_attr('default', '') + if value.lower() in ('true', '1'): + return True + return False + + def set_default(self, value): + self._del_attr('default') + if value: + self._set_attr('default', 'true') + elif value is False: + self._set_attr('default', 'false') + + +class ESSLabel(ElementBase): + name = 'esssecuritylabel' + namespace = 'urn:xmpp:sec-label:ess:0' + plugin_attrib = 'ess' + interfaces = set(['value']) + + def get_value(self): + if self.xml.text: + return b64decode(bytes(self.xml.text)) + return '' + + def set_value(self, value): + self.xml.text = '' + if value: + self.xml.text = b64encode(bytes(value)) + + def del_value(self): + self.xml.text = '' + + +register_stanza_plugin(Catalog, CatalogItem, iterable=True) +register_stanza_plugin(CatalogItem, SecurityLabel) +register_stanza_plugin(EquivalentLabel, ESSLabel) +register_stanza_plugin(Label, ESSLabel) +register_stanza_plugin(SecurityLabel, DisplayMarking) +register_stanza_plugin(SecurityLabel, EquivalentLabel, iterable=True) +register_stanza_plugin(SecurityLabel, Label) diff --git a/slixmpp/plugins/xep_0270.py b/slixmpp/plugins/xep_0270.py new file mode 100644 index 00000000..4d02a0a1 --- /dev/null +++ b/slixmpp/plugins/xep_0270.py @@ -0,0 +1,20 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins import BasePlugin, register_plugin + + +class XEP_0270(BasePlugin): + + name = 'xep_0270' + description = 'XEP-0270: XMPP Compliance Suites 2010' + dependencies = set(['xep_0030', 'xep_0115', 'xep_0054', + 'xep_0163', 'xep_0045', 'xep_0085']) + + +register_plugin(XEP_0270) diff --git a/slixmpp/plugins/xep_0279/__init__.py b/slixmpp/plugins/xep_0279/__init__.py new file mode 100644 index 00000000..45fd3af0 --- /dev/null +++ b/slixmpp/plugins/xep_0279/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0279 import stanza +from slixmpp.plugins.xep_0279.stanza import IPCheck +from slixmpp.plugins.xep_0279.ipcheck import XEP_0279 + + +register_plugin(XEP_0279) diff --git a/slixmpp/plugins/xep_0279/ipcheck.py b/slixmpp/plugins/xep_0279/ipcheck.py new file mode 100644 index 00000000..e8cea46f --- /dev/null +++ b/slixmpp/plugins/xep_0279/ipcheck.py @@ -0,0 +1,41 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +import logging + +from slixmpp import Iq +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0279 import stanza, IPCheck + + +class XEP_0279(BasePlugin): + + name = 'xep_0279' + description = 'XEP-0279: Server IP Check' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, IPCheck) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('urn:xmpp:sic:0') + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature='urn:xmpp:sic:0') + + def check_ip(self, ifrom=None, block=True, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['from'] = ifrom + iq.enable('ip_check') + return iq.send(block=block, timeout=timeout, callback=callback, + timeout_callback=timeout_callback) diff --git a/slixmpp/plugins/xep_0279/stanza.py b/slixmpp/plugins/xep_0279/stanza.py new file mode 100644 index 00000000..f80623cd --- /dev/null +++ b/slixmpp/plugins/xep_0279/stanza.py @@ -0,0 +1,30 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class IPCheck(ElementBase): + + name = 'ip' + namespace = 'urn:xmpp:sic:0' + plugin_attrib = 'ip_check' + interfaces = set(['ip_check']) + is_extension = True + + def get_ip_check(self): + return self.xml.text + + def set_ip_check(self, value): + if value: + self.xml.text = value + else: + self.xml.text = '' + + def del_ip_check(self): + self.xml.text = '' diff --git a/slixmpp/plugins/xep_0280/__init__.py b/slixmpp/plugins/xep_0280/__init__.py new file mode 100644 index 00000000..ed9a19ef --- /dev/null +++ b/slixmpp/plugins/xep_0280/__init__.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0280.stanza import ReceivedCarbon, SentCarbon +from slixmpp.plugins.xep_0280.stanza import PrivateCarbon +from slixmpp.plugins.xep_0280.stanza import CarbonEnable, CarbonDisable +from slixmpp.plugins.xep_0280.carbons import XEP_0280 + + +register_plugin(XEP_0280) diff --git a/slixmpp/plugins/xep_0280/carbons.py b/slixmpp/plugins/xep_0280/carbons.py new file mode 100644 index 00000000..a64ccbfd --- /dev/null +++ b/slixmpp/plugins/xep_0280/carbons.py @@ -0,0 +1,85 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import logging + +import slixmpp +from slixmpp.stanza import Message, Iq +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0280 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0280(BasePlugin): + + """ + XEP-0280 Message Carbons + """ + + name = 'xep_0280' + description = 'XEP-0280: Message Carbons' + dependencies = set(['xep_0030', 'xep_0297']) + stanza = stanza + + def plugin_init(self): + self.xmpp.register_handler( + Callback('Carbon Received', + StanzaPath('message/carbon_received'), + self._handle_carbon_received)) + self.xmpp.register_handler( + Callback('Carbon Sent', + StanzaPath('message/carbon_sent'), + self._handle_carbon_sent)) + + register_stanza_plugin(Message, stanza.ReceivedCarbon) + register_stanza_plugin(Message, stanza.SentCarbon) + register_stanza_plugin(Message, stanza.PrivateCarbon) + register_stanza_plugin(Iq, stanza.CarbonEnable) + register_stanza_plugin(Iq, stanza.CarbonDisable) + + register_stanza_plugin(stanza.ReceivedCarbon, + self.xmpp['xep_0297'].stanza.Forwarded) + register_stanza_plugin(stanza.SentCarbon, + self.xmpp['xep_0297'].stanza.Forwarded) + + def plugin_end(self): + self.xmpp.remove_handler('Carbon Received') + self.xmpp.remove_handler('Carbon Sent') + self.xmpp.plugin['xep_0030'].del_feature(feature='urn:xmpp:carbons:2') + + def session_bind(self, jid): + self.xmpp.plugin['xep_0030'].add_feature('urn:xmpp:carbons:2') + + def _handle_carbon_received(self, msg): + self.xmpp.event('carbon_received', msg) + + def _handle_carbon_sent(self, msg): + self.xmpp.event('carbon_sent', msg) + + def enable(self, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq.enable('carbon_enable') + return iq.send(timeout_callback=timeout_callback, timeout=timeout, + callback=callback) + + def disable(self, ifrom=None, timeout=None, callback=None, + timeout_callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['from'] = ifrom + iq.enable('carbon_disable') + return iq.send(timeout_callback=timeout_callback, timeout=timeout, + callback=callback) diff --git a/slixmpp/plugins/xep_0280/stanza.py b/slixmpp/plugins/xep_0280/stanza.py new file mode 100644 index 00000000..46276189 --- /dev/null +++ b/slixmpp/plugins/xep_0280/stanza.py @@ -0,0 +1,64 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.xmlstream import ElementBase + + +class ReceivedCarbon(ElementBase): + name = 'received' + namespace = 'urn:xmpp:carbons:2' + plugin_attrib = 'carbon_received' + interfaces = set(['carbon_received']) + is_extension = True + + def get_carbon_received(self): + return self['forwarded']['stanza'] + + def del_carbon_received(self): + del self['forwarded']['stanza'] + + def set_carbon_received(self, stanza): + self['forwarded']['stanza'] = stanza + + +class SentCarbon(ElementBase): + name = 'sent' + namespace = 'urn:xmpp:carbons:2' + plugin_attrib = 'carbon_sent' + interfaces = set(['carbon_sent']) + is_extension = True + + def get_carbon_sent(self): + return self['forwarded']['stanza'] + + def del_carbon_sent(self): + del self['forwarded']['stanza'] + + def set_carbon_sent(self, stanza): + self['forwarded']['stanza'] = stanza + + +class PrivateCarbon(ElementBase): + name = 'private' + namespace = 'urn:xmpp:carbons:2' + plugin_attrib = 'carbon_private' + interfaces = set() + + +class CarbonEnable(ElementBase): + name = 'enable' + namespace = 'urn:xmpp:carbons:2' + plugin_attrib = 'carbon_enable' + interfaces = set() + + +class CarbonDisable(ElementBase): + name = 'disable' + namespace = 'urn:xmpp:carbons:2' + plugin_attrib = 'carbon_disable' + interfaces = set() diff --git a/slixmpp/plugins/xep_0297/__init__.py b/slixmpp/plugins/xep_0297/__init__.py new file mode 100644 index 00000000..8d7adb63 --- /dev/null +++ b/slixmpp/plugins/xep_0297/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0297 import stanza +from slixmpp.plugins.xep_0297.stanza import Forwarded +from slixmpp.plugins.xep_0297.forwarded import XEP_0297 + + +register_plugin(XEP_0297) diff --git a/slixmpp/plugins/xep_0297/forwarded.py b/slixmpp/plugins/xep_0297/forwarded.py new file mode 100644 index 00000000..7c40bf30 --- /dev/null +++ b/slixmpp/plugins/xep_0297/forwarded.py @@ -0,0 +1,64 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +import logging + +from slixmpp import Iq, Message, Presence +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.xep_0297 import stanza, Forwarded + + +class XEP_0297(BasePlugin): + + name = 'xep_0297' + description = 'XEP-0297: Stanza Forwarding' + dependencies = set(['xep_0030', 'xep_0203']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Message, Forwarded) + + # While these are marked as iterable, that is just for + # making it easier to extract the forwarded stanza. There + # still can be only a single forwarded stanza. + register_stanza_plugin(Forwarded, Message, iterable=True) + register_stanza_plugin(Forwarded, Presence, iterable=True) + register_stanza_plugin(Forwarded, Iq, iterable=True) + + register_stanza_plugin(Forwarded, self.xmpp['xep_0203'].stanza.Delay) + + self.xmpp.register_handler( + Callback('Forwarded Stanza', + StanzaPath('message/forwarded'), + self._handle_forwarded)) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('urn:xmpp:forward:0') + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature='urn:xmpp:forward:0') + self.xmpp.remove_handler('Forwarded Stanza') + + def forward(self, stanza=None, mto=None, mbody=None, mfrom=None, delay=None): + stanza.stream = None + + msg = self.xmpp.Message() + msg['to'] = mto + msg['from'] = mfrom + msg['body'] = mbody + msg['forwarded']['stanza'] = stanza + if delay is not None: + msg['forwarded']['delay']['stamp'] = delay + msg.send() + + def _handle_forwarded(self, msg): + self.xmpp.event('forwarded_stanza', msg) diff --git a/slixmpp/plugins/xep_0297/stanza.py b/slixmpp/plugins/xep_0297/stanza.py new file mode 100644 index 00000000..233ad4f6 --- /dev/null +++ b/slixmpp/plugins/xep_0297/stanza.py @@ -0,0 +1,36 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Message, Presence, Iq +from slixmpp.xmlstream import ElementBase + + +class Forwarded(ElementBase): + name = 'forwarded' + namespace = 'urn:xmpp:forward:0' + plugin_attrib = 'forwarded' + interfaces = set(['stanza']) + + def get_stanza(self): + for stanza in self: + if isinstance(stanza, (Message, Presence, Iq)): + return stanza + return '' + + def set_stanza(self, value): + self.del_stanza() + self.append(value) + + def del_stanza(self): + found_stanzas = [] + for stanza in self: + if isinstance(stanza, (Message, Presence, Iq)): + found_stanzas.append(stanza) + for stanza in found_stanzas: + self.iterables.remove(stanza) + self.xml.remove(stanza.xml) diff --git a/slixmpp/plugins/xep_0302.py b/slixmpp/plugins/xep_0302.py new file mode 100644 index 00000000..6092b3e3 --- /dev/null +++ b/slixmpp/plugins/xep_0302.py @@ -0,0 +1,21 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins import BasePlugin, register_plugin + + +class XEP_0302(BasePlugin): + + name = 'xep_0302' + description = 'XEP-0302: XMPP Compliance Suites 2012' + dependencies = set(['xep_0030', 'xep_0115', 'xep_0054', + 'xep_0163', 'xep_0045', 'xep_0085', + 'xep_0184', 'xep_0198']) + + +register_plugin(XEP_0302) diff --git a/slixmpp/plugins/xep_0308/__init__.py b/slixmpp/plugins/xep_0308/__init__.py new file mode 100644 index 00000000..147cbd4b --- /dev/null +++ b/slixmpp/plugins/xep_0308/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0308.stanza import Replace +from slixmpp.plugins.xep_0308.correction import XEP_0308 + + +register_plugin(XEP_0308) diff --git a/slixmpp/plugins/xep_0308/correction.py b/slixmpp/plugins/xep_0308/correction.py new file mode 100644 index 00000000..3802b799 --- /dev/null +++ b/slixmpp/plugins/xep_0308/correction.py @@ -0,0 +1,52 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import logging + +import slixmpp +from slixmpp.stanza import Message +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0308 import stanza, Replace + + +log = logging.getLogger(__name__) + + +class XEP_0308(BasePlugin): + + """ + XEP-0308 Last Message Correction + """ + + name = 'xep_0308' + description = 'XEP-0308: Last Message Correction' + dependencies = set(['xep_0030']) + stanza = stanza + + def plugin_init(self): + self.xmpp.register_handler( + Callback('Message Correction', + StanzaPath('message/replace'), + self._handle_correction)) + + register_stanza_plugin(Message, Replace) + + self.xmpp.use_message_ids = True + + def plugin_end(self): + self.xmpp.remove_handler('Message Correction') + self.xmpp.plugin['xep_0030'].del_feature(feature=Replace.namespace) + + def session_bind(self, jid): + self.xmpp.plugin['xep_0030'].add_feature(Replace.namespace) + + def _handle_correction(self, msg): + self.xmpp.event('message_correction', msg) diff --git a/slixmpp/plugins/xep_0308/stanza.py b/slixmpp/plugins/xep_0308/stanza.py new file mode 100644 index 00000000..eb04d1ad --- /dev/null +++ b/slixmpp/plugins/xep_0308/stanza.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.xmlstream import ElementBase + + +class Replace(ElementBase): + name = 'replace' + namespace = 'urn:xmpp:message-correct:0' + plugin_attrib = 'replace' + interfaces = set(['id']) diff --git a/slixmpp/plugins/xep_0313/__init__.py b/slixmpp/plugins/xep_0313/__init__.py new file mode 100644 index 00000000..42d9025a --- /dev/null +++ b/slixmpp/plugins/xep_0313/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0313.stanza import Result, MAM, Preferences +from slixmpp.plugins.xep_0313.mam import XEP_0313 + + +register_plugin(XEP_0313) diff --git a/slixmpp/plugins/xep_0313/mam.py b/slixmpp/plugins/xep_0313/mam.py new file mode 100644 index 00000000..d1c6b983 --- /dev/null +++ b/slixmpp/plugins/xep_0313/mam.py @@ -0,0 +1,85 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import logging + +import slixmpp +from slixmpp.stanza import Message, Iq +from slixmpp.exceptions import XMPPError +from slixmpp.xmlstream.handler import Collector +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins import BasePlugin +from slixmpp.plugins.xep_0313 import stanza + + +log = logging.getLogger(__name__) + + +class XEP_0313(BasePlugin): + + """ + XEP-0313 Message Archive Management + """ + + name = 'xep_0313' + description = 'XEP-0313: Message Archive Management' + dependencies = set(['xep_0030', 'xep_0050', 'xep_0059', 'xep_0297']) + stanza = stanza + + def plugin_init(self): + register_stanza_plugin(Iq, stanza.MAM) + register_stanza_plugin(Iq, stanza.Preferences) + register_stanza_plugin(Message, stanza.Result) + register_stanza_plugin(Message, stanza.Archived, iterable=True) + register_stanza_plugin(stanza.Result, self.xmpp['xep_0297'].stanza.Forwarded) + register_stanza_plugin(stanza.MAM, self.xmpp['xep_0059'].stanza.Set) + + def retrieve(self, jid=None, start=None, end=None, with_jid=None, ifrom=None, + timeout=None, callback=None, iterator=False): + iq = self.xmpp.Iq() + query_id = iq['id'] + + iq['to'] = jid + iq['from'] = ifrom + iq['type'] = 'get' + iq['mam']['queryid'] = query_id + iq['mam']['start'] = start + iq['mam']['end'] = end + iq['mam']['with'] = with_jid + + collector = Collector( + 'MAM_Results_%s' % query_id, + StanzaPath('message/mam_result@queryid=%s' % query_id)) + self.xmpp.register_handler(collector) + + if iterator: + return self.xmpp['xep_0059'].iterate(iq, 'mam', 'results') + def wrapped_cb(iq): + results = collector.stop() + if iq['type'] == 'result': + iq['mam']['results'] = results + callback(iq) + return iq.send(timeout=timeout, callback=wrapped_cb) + + def set_preferences(self, jid=None, default=None, always=None, never=None, + ifrom=None, block=True, timeout=None, callback=None): + iq = self.xmpp.Iq() + iq['type'] = 'set' + iq['to'] = jid + iq['from'] = ifrom + iq['mam_prefs']['default'] = default + iq['mam_prefs']['always'] = always + iq['mam_prefs']['never'] = never + return iq.send(block=block, timeout=timeout, callback=callback) + + def get_configuration_commands(self, jid, **kwargs): + return self.xmpp['xep_0030'].get_items( + jid=jid, + node='urn:xmpp:mam#configure', + **kwargs) diff --git a/slixmpp/plugins/xep_0313/stanza.py b/slixmpp/plugins/xep_0313/stanza.py new file mode 100644 index 00000000..d7cfa222 --- /dev/null +++ b/slixmpp/plugins/xep_0313/stanza.py @@ -0,0 +1,139 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2012 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permissio +""" + +import datetime as dt + +from slixmpp.jid import JID +from slixmpp.xmlstream import ElementBase, ET +from slixmpp.plugins import xep_0082 + + +class MAM(ElementBase): + name = 'query' + namespace = 'urn:xmpp:mam:tmp' + plugin_attrib = 'mam' + interfaces = set(['queryid', 'start', 'end', 'with', 'results']) + sub_interfaces = set(['start', 'end', 'with']) + + def setup(self, xml=None): + ElementBase.setup(self, xml) + self._results = [] + + def get_start(self): + timestamp = self._get_sub_text('start') + return xep_0082.parse(timestamp) + + def set_start(self, value): + if isinstance(value, dt.datetime): + value = xep_0082.format_datetime(value) + self._set_sub_text('start', value) + + def get_end(self): + timestamp = self._get_sub_text('end') + return xep_0082.parse(timestamp) + + def set_end(self, value): + if isinstance(value, dt.datetime): + value = xep_0082.format_datetime(value) + self._set_sub_text('end', value) + + def get_with(self): + return JID(self._get_sub_text('with')) + + def set_with(self, value): + self._set_sub_text('with', str(value)) + + # The results interface is meant only as an easy + # way to access the set of collected message responses + # from the query. + + def get_results(self): + return self._results + + def set_results(self, values): + self._results = values + + def del_results(self): + self._results = [] + + +class Preferences(ElementBase): + name = 'prefs' + namespace = 'urn:xmpp:mam:tmp' + plugin_attrib = 'mam_prefs' + interfaces = set(['default', 'always', 'never']) + sub_interfaces = set(['always', 'never']) + + def get_always(self): + results = set() + + jids = self.xml.findall('{%s}always/{%s}jid' % ( + self.namespace, self.namespace)) + + for jid in jids: + results.add(JID(jid.text)) + + return results + + def set_always(self, value): + self._set_sub_text('always', '', keep=True) + always = self.xml.find('{%s}always' % self.namespace) + always.clear() + + if not isinstance(value, (list, set)): + value = [value] + + for jid in value: + jid_xml = ET.Element('{%s}jid' % self.namespace) + jid_xml.text = str(jid) + always.append(jid_xml) + + def get_never(self): + results = set() + + jids = self.xml.findall('{%s}never/{%s}jid' % ( + self.namespace, self.namespace)) + + for jid in jids: + results.add(JID(jid.text)) + + return results + + def set_never(self, value): + self._set_sub_text('never', '', keep=True) + never = self.xml.find('{%s}never' % self.namespace) + never.clear() + + if not isinstance(value, (list, set)): + value = [value] + + for jid in value: + jid_xml = ET.Element('{%s}jid' % self.namespace) + jid_xml.text = str(jid) + never.append(jid_xml) + + +class Result(ElementBase): + name = 'result' + namespace = 'urn:xmpp:mam:tmp' + plugin_attrib = 'mam_result' + interfaces = set(['queryid', 'id']) + + +class Archived(ElementBase): + name = 'archived' + namespace = 'urn:xmpp:mam:tmp' + plugin_attrib = 'mam_archived' + plugin_multi_attrib = 'mam_archives' + interfaces = set(['by', 'id']) + + def get_by(self): + return JID(self._get_attr('by')) + + def set_by(self): + return self._set_attr('by', str(value)) diff --git a/slixmpp/plugins/xep_0319/__init__.py b/slixmpp/plugins/xep_0319/__init__.py new file mode 100644 index 00000000..a9253b49 --- /dev/null +++ b/slixmpp/plugins/xep_0319/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0319 import stanza +from slixmpp.plugins.xep_0319.stanza import Idle +from slixmpp.plugins.xep_0319.idle import XEP_0319 + + +register_plugin(XEP_0319) diff --git a/slixmpp/plugins/xep_0319/idle.py b/slixmpp/plugins/xep_0319/idle.py new file mode 100644 index 00000000..1fd980a5 --- /dev/null +++ b/slixmpp/plugins/xep_0319/idle.py @@ -0,0 +1,75 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from datetime import datetime, timedelta + +from slixmpp.stanza import Presence +from slixmpp.plugins import BasePlugin +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.xep_0319 import stanza + + +class XEP_0319(BasePlugin): + name = 'xep_0319' + description = 'XEP-0319: Last User Interaction in Presence' + dependencies = set(['xep_0012']) + stanza = stanza + + def plugin_init(self): + self._idle_stamps = {} + register_stanza_plugin(Presence, stanza.Idle) + self.api.register(self._set_idle, + 'set_idle', + default=True) + self.api.register(self._get_idle, + 'get_idle', + default=True) + self.xmpp.register_handler( + Callback('Idle Presence', + StanzaPath('presence/idle'), + self._idle_presence)) + self.xmpp.add_filter('out', self._stamp_idle_presence) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('urn:xmpp:idle:1') + + def plugin_end(self): + self.xmpp['xep_0030'].del_feature(feature='urn:xmpp:idle:1') + self.xmpp.del_filter('out', self._stamp_idle_presence) + self.xmpp.remove_handler('Idle Presence') + + def idle(self, jid=None, since=None): + seconds = None + if since is None: + since = datetime.now() + else: + seconds = datetime.now() - since + self.api['set_idle'](jid, None, None, since) + self.xmpp['xep_0012'].set_last_activity(jid=jid, seconds=seconds) + + def active(self, jid=None): + self.api['set_idle'](jid, None, None, None) + self.xmpp['xep_0012'].del_last_activity(jid) + + def _set_idle(self, jid, node, ifrom, data): + self._idle_stamps[jid] = data + + def _get_idle(self, jid, node, ifrom, data): + return self._idle_stamps.get(jid, None) + + def _idle_presence(self, pres): + self.xmpp.event('presence_idle', pres) + + def _stamp_idle_presence(self, stanza): + if isinstance(stanza, Presence): + since = self.api['get_idle'](stanza['from'] or self.xmpp.boundjid) + if since: + stanza['idle']['since'] = since + return stanza diff --git a/slixmpp/plugins/xep_0319/stanza.py b/slixmpp/plugins/xep_0319/stanza.py new file mode 100644 index 00000000..ea70087b --- /dev/null +++ b/slixmpp/plugins/xep_0319/stanza.py @@ -0,0 +1,28 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2013 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import datetime as dt + +from slixmpp.xmlstream import ElementBase +from slixmpp.plugins import xep_0082 + + +class Idle(ElementBase): + name = 'idle' + namespace = 'urn:xmpp:idle:1' + plugin_attrib = 'idle' + interfaces = set(['since']) + + def get_since(self): + timestamp = self._get_attr('since') + return xep_0082.parse(timestamp) + + def set_since(self, value): + if isinstance(value, dt.datetime): + value = xep_0082.format_datetime(value) + self._set_attr('since', value) diff --git a/slixmpp/plugins/xep_0323/__init__.py b/slixmpp/plugins/xep_0323/__init__.py new file mode 100644 index 00000000..6bf42fd6 --- /dev/null +++ b/slixmpp/plugins/xep_0323/__init__.py @@ -0,0 +1,18 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0323.sensordata import XEP_0323 +from slixmpp.plugins.xep_0323 import stanza + +register_plugin(XEP_0323) + +xep_0323=XEP_0323 diff --git a/slixmpp/plugins/xep_0323/device.py b/slixmpp/plugins/xep_0323/device.py new file mode 100644 index 00000000..994fc5ce --- /dev/null +++ b/slixmpp/plugins/xep_0323/device.py @@ -0,0 +1,258 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import datetime +import logging + +class Device(object): + """ + Example implementation of a device readout object. + Is registered in the XEP_0323.register_node call + The device object may be any custom implementation to support + specific devices, but it must implement the functions: + has_field + request_fields + """ + + def __init__(self, nodeId, fields=None): + if not fields: + fields = {} + + self.nodeId = nodeId + self.fields = fields # see fields described below + # {'type':'numeric', + # 'name':'myname', + # 'value': 42, + # 'unit':'Z'}] + self.timestamp_data = {} + self.momentary_data = {} + self.momentary_timestamp = "" + logging.debug("Device object started nodeId %s",nodeId) + + def has_field(self, field): + """ + Returns true if the supplied field name exists in this device. + + Arguments: + field -- The field name + """ + if field in self.fields.keys(): + return True + return False + + def refresh(self, fields): + """ + override method to do the refresh work + refresh values from hardware or other + """ + pass + + + def request_fields(self, fields, flags, session, callback): + """ + Starts a data readout. Verifies the requested fields, + refreshes the data (if needed) and calls the callback + with requested data. + + + Arguments: + fields -- List of field names to readout + flags -- [optional] data classifier flags for the field, e.g. momentary + Formatted as a dictionary like { "flag name": "flag value" ... } + session -- Session id, only used in the callback as identifier + callback -- Callback function to call when data is available. + + The callback function must support the following arguments: + + session -- Session id, as supplied in the request_fields call + nodeId -- Identifier for this device + result -- The current result status of the readout. Valid values are: + "error" - Readout failed. + "fields" - Contains readout data. + "done" - Indicates that the readout is complete. May contain + readout data. + timestamp_block -- [optional] Only applies when result != "error" + The readout data. Structured as a dictionary: + { + timestamp: timestamp for this datablock, + fields: list of field dictionary (one per readout field). + readout field dictionary format: + { + type: The field type (numeric, boolean, dateTime, timeSpan, string, enum) + name: The field name + value: The field value + unit: The unit of the field. Only applies to type numeric. + dataType: The datatype of the field. Only applies to type enum. + flags: [optional] data classifier flags for the field, e.g. momentary + Formatted as a dictionary like { "flag name": "flag value" ... } + } + } + error_msg -- [optional] Only applies when result == "error". + Error details when a request failed. + + """ + logging.debug("request_fields called looking for fields %s",fields) + if len(fields) > 0: + # Check availiability + for f in fields: + if f not in self.fields.keys(): + self._send_reject(session, callback) + return False + else: + # Request all fields + fields = self.fields.keys() + + + # Refresh data from device + # ... + logging.debug("about to refresh device fields %s",fields) + self.refresh(fields) + + if "momentary" in flags and flags['momentary'] == "true" or \ + "all" in flags and flags['all'] == "true": + ts_block = {} + timestamp = "" + + if len(self.momentary_timestamp) > 0: + timestamp = self.momentary_timestamp + else: + timestamp = self._get_timestamp() + + field_block = [] + for f in self.momentary_data: + if f in fields: + field_block.append({"name": f, + "type": self.fields[f]["type"], + "unit": self.fields[f]["unit"], + "dataType": self.fields[f]["dataType"], + "value": self.momentary_data[f]["value"], + "flags": self.momentary_data[f]["flags"]}) + ts_block["timestamp"] = timestamp + ts_block["fields"] = field_block + + callback(session, result="done", nodeId=self.nodeId, timestamp_block=ts_block) + return + + from_flag = self._datetime_flag_parser(flags, 'from') + to_flag = self._datetime_flag_parser(flags, 'to') + + for ts in sorted(self.timestamp_data.keys()): + tsdt = datetime.datetime.strptime(ts, "%Y-%m-%dT%H:%M:%S") + if not from_flag is None: + if tsdt < from_flag: + #print (str(tsdt) + " < " + str(from_flag)) + continue + if not to_flag is None: + if tsdt > to_flag: + #print (str(tsdt) + " > " + str(to_flag)) + continue + + ts_block = {} + field_block = [] + + for f in self.timestamp_data[ts]: + if f in fields: + field_block.append({"name": f, + "type": self.fields[f]["type"], + "unit": self.fields[f]["unit"], + "dataType": self.fields[f]["dataType"], + "value": self.timestamp_data[ts][f]["value"], + "flags": self.timestamp_data[ts][f]["flags"]}) + + ts_block["timestamp"] = ts + ts_block["fields"] = field_block + callback(session, result="fields", nodeId=self.nodeId, timestamp_block=ts_block) + callback(session, result="done", nodeId=self.nodeId, timestamp_block=None) + + def _datetime_flag_parser(self, flags, flagname): + if not flagname in flags: + return None + + dt = None + try: + dt = datetime.datetime.strptime(flags[flagname], "%Y-%m-%dT%H:%M:%S") + except ValueError: + # Badly formatted datetime, ignore it + pass + return dt + + + def _get_timestamp(self): + """ + Generates a properly formatted timestamp of current time + """ + return datetime.datetime.now().replace(microsecond=0).isoformat() + + def _send_reject(self, session, callback): + """ + Sends a reject to the caller + + Arguments: + session -- Session id, see definition in request_fields function + callback -- Callback function, see definition in request_fields function + """ + callback(session, result="error", nodeId=self.nodeId, timestamp_block=None, error_msg="Reject") + + def _add_field(self, name, typename, unit=None, dataType=None): + """ + Adds a field to the device + + Arguments: + name -- Name of the field + typename -- Type of the field (numeric, boolean, dateTime, timeSpan, string, enum) + unit -- [optional] only applies to "numeric". Unit for the field. + dataType -- [optional] only applies to "enum". Datatype for the field. + """ + self.fields[name] = {"type": typename, "unit": unit, "dataType": dataType} + + def _add_field_timestamp_data(self, name, timestamp, value, flags=None): + """ + Adds timestamped data to a field + + Arguments: + name -- Name of the field + timestamp -- Timestamp for the data (string) + value -- Field value at the timestamp + flags -- [optional] data classifier flags for the field, e.g. momentary + Formatted as a dictionary like { "flag name": "flag value" ... } + """ + if not name in self.fields.keys(): + return False + if not timestamp in self.timestamp_data: + self.timestamp_data[timestamp] = {} + + self.timestamp_data[timestamp][name] = {"value": value, "flags": flags} + return True + + def _add_field_momentary_data(self, name, value, flags=None): + """ + Sets momentary data to a field + + Arguments: + name -- Name of the field + value -- Field value at the timestamp + flags -- [optional] data classifier flags for the field, e.g. momentary + Formatted as a dictionary like { "flag name": "flag value" ... } + """ + if name not in self.fields: + return False + if flags is None: + flags = {} + + flags["momentary"] = "true" + self.momentary_data[name] = {"value": value, "flags": flags} + return True + + def _set_momentary_timestamp(self, timestamp): + """ + This function is only for unit testing to produce predictable results. + """ + self.momentary_timestamp = timestamp + diff --git a/slixmpp/plugins/xep_0323/sensordata.py b/slixmpp/plugins/xep_0323/sensordata.py new file mode 100644 index 00000000..c88deee9 --- /dev/null +++ b/slixmpp/plugins/xep_0323/sensordata.py @@ -0,0 +1,712 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import time +import datetime +from threading import Thread, Lock, Timer + +from slixmpp.plugins.xep_0323.timerreset import TimerReset + +from slixmpp.xmlstream import JID +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0323 import stanza +from slixmpp.plugins.xep_0323.stanza import Sensordata + +log = logging.getLogger(__name__) + + +class XEP_0323(BasePlugin): + + """ + XEP-0323: IoT Sensor Data + + + This XEP provides the underlying architecture, basic operations and data + structures for sensor data communication over XMPP networks. It includes + a hardware abstraction model, removing any technical detail implemented + in underlying technologies. + + Also see <http://xmpp.org/extensions/xep-0323.html> + + Configuration Values: + threaded -- Indicates if communication with sensors should be threaded. + Defaults to True. + + Events: + Sensor side + ----------- + Sensordata Event:Req -- Received a request for data + Sensordata Event:Cancel -- Received a cancellation for a request + + Client side + ----------- + Sensordata Event:Accepted -- Received a accept from sensor for a request + Sensordata Event:Rejected -- Received a reject from sensor for a request + Sensordata Event:Cancelled -- Received a cancel confirm from sensor + Sensordata Event:Fields -- Received fields from sensor for a request + This may be triggered multiple times since + the sensor can split up its response in + multiple messages. + Sensordata Event:Failure -- Received a failure indication from sensor + for a request. Typically a comm timeout. + + Attributes: + threaded -- Indicates if command events should be threaded. + Defaults to True. + sessions -- A dictionary or equivalent backend mapping + session IDs to dictionaries containing data + relevant to a request's session. This dictionary is used + both by the client and sensor side. On client side, seqnr + is used as key, while on sensor side, a session_id is used + as key. This ensures that the two will not collide, so + one instance can be both client and sensor. + Sensor side + ----------- + nodes -- A dictionary mapping sensor nodes that are serviced through + this XMPP instance to their device handlers ("drivers"). + Client side + ----------- + last_seqnr -- The last used sequence number (integer). One sequence of + communication (e.g. -->request, <--accept, <--fields) + between client and sensor is identified by a unique + sequence number (unique between the client/sensor pair) + + Methods: + plugin_init -- Overrides BasePlugin.plugin_init + post_init -- Overrides BasePlugin.post_init + plugin_end -- Overrides BasePlugin.plugin_end + + Sensor side + ----------- + register_node -- Register a sensor as available from this XMPP + instance. + + Client side + ----------- + request_data -- Initiates a request for data from one or more + sensors. Non-blocking, a callback function will + be called when data is available. + + """ + + name = 'xep_0323' + description = 'XEP-0323 Internet of Things - Sensor Data' + dependencies = set(['xep_0030']) + stanza = stanza + + + default_config = { + 'threaded': True + } + + def plugin_init(self): + """ Start the XEP-0323 plugin """ + + self.xmpp.register_handler( + Callback('Sensordata Event:Req', + StanzaPath('iq@type=get/req'), + self._handle_event_req)) + + self.xmpp.register_handler( + Callback('Sensordata Event:Accepted', + StanzaPath('iq@type=result/accepted'), + self._handle_event_accepted)) + + self.xmpp.register_handler( + Callback('Sensordata Event:Rejected', + StanzaPath('iq@type=error/rejected'), + self._handle_event_rejected)) + + self.xmpp.register_handler( + Callback('Sensordata Event:Cancel', + StanzaPath('iq@type=get/cancel'), + self._handle_event_cancel)) + + self.xmpp.register_handler( + Callback('Sensordata Event:Cancelled', + StanzaPath('iq@type=result/cancelled'), + self._handle_event_cancelled)) + + self.xmpp.register_handler( + Callback('Sensordata Event:Fields', + StanzaPath('message/fields'), + self._handle_event_fields)) + + self.xmpp.register_handler( + Callback('Sensordata Event:Failure', + StanzaPath('message/failure'), + self._handle_event_failure)) + + self.xmpp.register_handler( + Callback('Sensordata Event:Started', + StanzaPath('message/started'), + self._handle_event_started)) + + # Server side dicts + self.nodes = {} + self.sessions = {} + + self.last_seqnr = 0 + self.seqnr_lock = Lock() + + ## For testing only + self.test_authenticated_from = "" + + def post_init(self): + """ Init complete. Register our features in Service discovery. """ + BasePlugin.post_init(self) + self.xmpp['xep_0030'].add_feature(Sensordata.namespace) + self.xmpp['xep_0030'].set_items(node=Sensordata.namespace, items=tuple()) + + def _new_session(self): + """ Return a new session ID. """ + return str(time.time()) + '-' + self.xmpp.new_id() + + def session_bind(self, jid): + logging.debug("setting the Disco discovery for %s" % Sensordata.namespace) + self.xmpp['xep_0030'].add_feature(Sensordata.namespace) + self.xmpp['xep_0030'].set_items(node=Sensordata.namespace, items=tuple()) + + + def plugin_end(self): + """ Stop the XEP-0323 plugin """ + self.sessions.clear() + self.xmpp.remove_handler('Sensordata Event:Req') + self.xmpp.remove_handler('Sensordata Event:Accepted') + self.xmpp.remove_handler('Sensordata Event:Rejected') + self.xmpp.remove_handler('Sensordata Event:Cancel') + self.xmpp.remove_handler('Sensordata Event:Cancelled') + self.xmpp.remove_handler('Sensordata Event:Fields') + self.xmpp['xep_0030'].del_feature(feature=Sensordata.namespace) + + + # ================================================================= + # Sensor side (data provider) API + + def register_node(self, nodeId, device, commTimeout, sourceId=None, cacheType=None): + """ + Register a sensor/device as available for serving of data through this XMPP + instance. + + The device object may by any custom implementation to support + specific devices, but it must implement the functions: + has_field + request_fields + according to the interfaces shown in the example device.py file. + + Arguments: + nodeId -- The identifier for the device + device -- The device object + commTimeout -- Time in seconds to wait between each callback from device during + a data readout. Float. + sourceId -- [optional] identifying the data source controlling the device + cacheType -- [optional] narrowing down the search to a specific kind of node + """ + self.nodes[nodeId] = {"device": device, + "commTimeout": commTimeout, + "sourceId": sourceId, + "cacheType": cacheType} + + def _set_authenticated(self, auth=''): + """ Internal testing function """ + self.test_authenticated_from = auth + + + def _handle_event_req(self, iq): + """ + Event handler for reception of an Iq with req - this is a request. + + Verifies that + - all the requested nodes are available + - at least one of the requested fields is available from at least + one of the nodes + + If the request passes verification, an accept response is sent, and + the readout process is started in a separate thread. + If the verification fails, a reject message is sent. + """ + + seqnr = iq['req']['seqnr'] + error_msg = '' + req_ok = True + + # Authentication + if len(self.test_authenticated_from) > 0 and not iq['from'] == self.test_authenticated_from: + # Invalid authentication + req_ok = False + error_msg = "Access denied" + + # Nodes + process_nodes = [] + if len(iq['req']['nodes']) > 0: + for n in iq['req']['nodes']: + if not n['nodeId'] in self.nodes: + req_ok = False + error_msg = "Invalid nodeId " + n['nodeId'] + process_nodes = [n['nodeId'] for n in iq['req']['nodes']] + else: + process_nodes = self.nodes.keys() + + # Fields - if we just find one we are happy, otherwise we reject + process_fields = [] + if len(iq['req']['fields']) > 0: + found = False + for f in iq['req']['fields']: + for node in self.nodes: + if self.nodes[node]["device"].has_field(f['name']): + found = True + break + if not found: + req_ok = False + error_msg = "Invalid field " + f['name'] + process_fields = [f['name'] for n in iq['req']['fields']] + + req_flags = iq['req']._get_flags() + + request_delay_sec = None + if 'when' in req_flags: + # Timed request - requires datetime string in iso format + # ex. 2013-04-05T15:00:03 + dt = None + try: + dt = datetime.datetime.strptime(req_flags['when'], "%Y-%m-%dT%H:%M:%S") + except ValueError: + req_ok = False + error_msg = "Invalid datetime in 'when' flag, please use ISO format (i.e. 2013-04-05T15:00:03)." + + if not dt is None: + # Datetime properly formatted + dtnow = datetime.datetime.now() + dtdiff = dt - dtnow + request_delay_sec = dtdiff.seconds + dtdiff.days * 24 * 3600 + if request_delay_sec <= 0: + req_ok = False + error_msg = "Invalid datetime in 'when' flag, cannot set a time in the past. Current time: " + dtnow.isoformat() + + if req_ok: + session = self._new_session() + self.sessions[session] = {"from": iq['from'], "to": iq['to'], "seqnr": seqnr} + self.sessions[session]["commTimers"] = {} + self.sessions[session]["nodeDone"] = {} + + iq = iq.reply() + iq['accepted']['seqnr'] = seqnr + if not request_delay_sec is None: + iq['accepted']['queued'] = "true" + iq.send() + + self.sessions[session]["node_list"] = process_nodes + + if not request_delay_sec is None: + # Delay request to requested time + timer = Timer(request_delay_sec, self._event_delayed_req, args=(session, process_fields, req_flags)) + self.sessions[session]["commTimers"]["delaytimer"] = timer + timer.start() + return + + if self.threaded: + tr_req = Thread(target=self._threaded_node_request, args=(session, process_fields, req_flags)) + tr_req.start() + else: + self._threaded_node_request(session, process_fields, req_flags) + + else: + iq = iq.reply() + iq['type'] = 'error' + iq['rejected']['seqnr'] = seqnr + iq['rejected']['error'] = error_msg + iq.send() + + def _threaded_node_request(self, session, process_fields, flags): + """ + Helper function to handle the device readouts in a separate thread. + + Arguments: + session -- The request session id + process_fields -- The fields to request from the devices + flags -- [optional] flags to pass to the devices, e.g. momentary + Formatted as a dictionary like { "flag name": "flag value" ... } + """ + for node in self.sessions[session]["node_list"]: + self.sessions[session]["nodeDone"][node] = False + + for node in self.sessions[session]["node_list"]: + timer = TimerReset(self.nodes[node]['commTimeout'], self._event_comm_timeout, args=(session, node)) + self.sessions[session]["commTimers"][node] = timer + timer.start() + self.nodes[node]['device'].request_fields(process_fields, flags=flags, session=session, callback=self._device_field_request_callback) + + def _event_comm_timeout(self, session, nodeId): + """ + Triggered if any of the readout operations timeout. + Sends a failure message back to the client, stops communicating + with the failing device. + + Arguments: + session -- The request session id + nodeId -- The id of the device which timed out + """ + msg = self.xmpp.Message() + msg['from'] = self.sessions[session]['to'] + msg['to'] = self.sessions[session]['from'] + msg['failure']['seqnr'] = self.sessions[session]['seqnr'] + msg['failure']['error']['text'] = "Timeout" + msg['failure']['error']['nodeId'] = nodeId + msg['failure']['error']['timestamp'] = datetime.datetime.now().replace(microsecond=0).isoformat() + + # Drop communication with this device and check if we are done + self.sessions[session]["nodeDone"][nodeId] = True + if (self._all_nodes_done(session)): + msg['failure']['done'] = 'true' + msg.send() + # The session is complete, delete it + del self.sessions[session] + + def _event_delayed_req(self, session, process_fields, req_flags): + """ + Triggered when the timer from a delayed request fires. + + Arguments: + session -- The request session id + process_fields -- The fields to request from the devices + flags -- [optional] flags to pass to the devices, e.g. momentary + Formatted as a dictionary like { "flag name": "flag value" ... } + """ + msg = self.xmpp.Message() + msg['from'] = self.sessions[session]['to'] + msg['to'] = self.sessions[session]['from'] + msg['started']['seqnr'] = self.sessions[session]['seqnr'] + msg.send() + + if self.threaded: + tr_req = Thread(target=self._threaded_node_request, args=(session, process_fields, req_flags)) + tr_req.start() + else: + self._threaded_node_request(session, process_fields, req_flags) + + def _all_nodes_done(self, session): + """ + Checks whether all devices are done replying to the readout. + + Arguments: + session -- The request session id + """ + for n in self.sessions[session]["nodeDone"]: + if not self.sessions[session]["nodeDone"][n]: + return False + return True + + def _device_field_request_callback(self, session, nodeId, result, timestamp_block, error_msg=None): + """ + Callback function called by the devices when they have any additional data. + Composes a message with the data and sends it back to the client, and resets + the timeout timer for the device. + + Arguments: + session -- The request session id + nodeId -- The device id which initiated the callback + result -- The current result status of the readout. Valid values are: + "error" - Readout failed. + "fields" - Contains readout data. + "done" - Indicates that the readout is complete. May contain + readout data. + timestamp_block -- [optional] Only applies when result != "error" + The readout data. Structured as a dictionary: + { + timestamp: timestamp for this datablock, + fields: list of field dictionary (one per readout field). + readout field dictionary format: + { + type: The field type (numeric, boolean, dateTime, timeSpan, string, enum) + name: The field name + value: The field value + unit: The unit of the field. Only applies to type numeric. + dataType: The datatype of the field. Only applies to type enum. + flags: [optional] data classifier flags for the field, e.g. momentary + Formatted as a dictionary like { "flag name": "flag value" ... } + } + } + error_msg -- [optional] Only applies when result == "error". + Error details when a request failed. + """ + if not session in self.sessions: + # This can happen if a session was deleted, like in a cancellation. Just drop the data. + return + + if result == "error": + self.sessions[session]["commTimers"][nodeId].cancel() + + msg = self.xmpp.Message() + msg['from'] = self.sessions[session]['to'] + msg['to'] = self.sessions[session]['from'] + msg['failure']['seqnr'] = self.sessions[session]['seqnr'] + msg['failure']['error']['text'] = error_msg + msg['failure']['error']['nodeId'] = nodeId + msg['failure']['error']['timestamp'] = datetime.datetime.now().replace(microsecond=0).isoformat() + + # Drop communication with this device and check if we are done + self.sessions[session]["nodeDone"][nodeId] = True + if (self._all_nodes_done(session)): + msg['failure']['done'] = 'true' + # The session is complete, delete it + del self.sessions[session] + msg.send() + else: + msg = self.xmpp.Message() + msg['from'] = self.sessions[session]['to'] + msg['to'] = self.sessions[session]['from'] + msg['fields']['seqnr'] = self.sessions[session]['seqnr'] + + if timestamp_block is not None and len(timestamp_block) > 0: + node = msg['fields'].add_node(nodeId) + ts = node.add_timestamp(timestamp_block["timestamp"]) + + for f in timestamp_block["fields"]: + data = ts.add_data( typename=f['type'], + name=f['name'], + value=f['value'], + unit=f['unit'], + dataType=f['dataType'], + flags=f['flags']) + + if result == "done": + self.sessions[session]["commTimers"][nodeId].cancel() + self.sessions[session]["nodeDone"][nodeId] = True + if (self._all_nodes_done(session)): + # The session is complete, delete it + del self.sessions[session] + msg['fields']['done'] = 'true' + else: + # Restart comm timer + self.sessions[session]["commTimers"][nodeId].reset() + + msg.send() + + def _handle_event_cancel(self, iq): + """ Received Iq with cancel - this is a cancel request. + Delete the session and confirm. """ + + seqnr = iq['cancel']['seqnr'] + # Find the session + for s in self.sessions: + if self.sessions[s]['from'] == iq['from'] and self.sessions[s]['to'] == iq['to'] and self.sessions[s]['seqnr'] == seqnr: + # found it. Cancel all timers + for n in self.sessions[s]["commTimers"]: + self.sessions[s]["commTimers"][n].cancel() + + # Confirm + iq = iq.reply() + iq['type'] = 'result' + iq['cancelled']['seqnr'] = seqnr + iq.send() + + # Delete session + del self.sessions[s] + return + + # Could not find session, send reject + iq = iq.reply() + iq['type'] = 'error' + iq['rejected']['seqnr'] = seqnr + iq['rejected']['error'] = "Cancel request received, no matching request is active." + iq.send() + + # ================================================================= + # Client side (data retriever) API + + def request_data(self, from_jid, to_jid, callback, nodeIds=None, fields=None, flags=None): + """ + Called on the client side to initiate a data readout. + Composes a message with the request and sends it to the device(s). + Does not block, the callback will be called when data is available. + + Arguments: + from_jid -- The jid of the requester + to_jid -- The jid of the device(s) + callback -- The callback function to call when data is available. + + The callback function must support the following arguments: + + from_jid -- The jid of the responding device(s) + result -- The current result status of the readout. Valid values are: + "accepted" - Readout request accepted + "queued" - Readout request accepted and queued + "rejected" - Readout request rejected + "failure" - Readout failed. + "cancelled" - Confirmation of request cancellation. + "started" - Previously queued request is now started + "fields" - Contains readout data. + "done" - Indicates that the readout is complete. + + nodeId -- [optional] Mandatory when result == "fields" or "failure". + The node Id of the responding device. One callback will only + contain data from one device. + timestamp -- [optional] Mandatory when result == "fields". + The timestamp of data in this callback. One callback will only + contain data from one timestamp. + fields -- [optional] Mandatory when result == "fields". + List of field dictionaries representing the readout data. + Dictionary format: + { + typename: The field type (numeric, boolean, dateTime, timeSpan, string, enum) + name: The field name + value: The field value + unit: The unit of the field. Only applies to type numeric. + dataType: The datatype of the field. Only applies to type enum. + flags: [optional] data classifier flags for the field, e.g. momentary. + Formatted as a dictionary like { "flag name": "flag value" ... } + } + + error_msg -- [optional] Mandatory when result == "rejected" or "failure". + Details about why the request is rejected or failed. + "rejected" means that the request is stopped, but note that the + request will continue even after a "failure". "failure" only means + that communication was stopped to that specific device, other + device(s) (if any) will continue their readout. + + nodeIds -- [optional] Limits the request to the node Ids in this list. + fields -- [optional] Limits the request to the field names in this list. + flags -- [optional] Limits the request according to the flags, or sets + readout conditions such as timing. + + Return value: + session -- Session identifier. Client can use this as a reference to cancel + the request. + """ + iq = self.xmpp.Iq() + iq['from'] = from_jid + iq['to'] = to_jid + iq['type'] = "get" + seqnr = self._get_new_seqnr() + iq['id'] = seqnr + iq['req']['seqnr'] = seqnr + if nodeIds is not None: + for nodeId in nodeIds: + iq['req'].add_node(nodeId) + if fields is not None: + for field in fields: + iq['req'].add_field(field) + + iq['req']._set_flags(flags) + + self.sessions[seqnr] = {"from": iq['from'], "to": iq['to'], "seqnr": seqnr, "callback": callback} + iq.send() + + return seqnr + + def cancel_request(self, session): + """ + Called on the client side to cancel a request for data readout. + Composes a message with the cancellation and sends it to the device(s). + Does not block, the callback will be called when cancellation is + confirmed. + + Arguments: + session -- The session id of the request to cancel + """ + seqnr = session + iq = self.xmpp.Iq() + iq['from'] = self.sessions[seqnr]['from'] + iq['to'] = self.sessions[seqnr]['to'] + iq['type'] = "get" + iq['id'] = seqnr + iq['cancel']['seqnr'] = seqnr + iq.send() + + def _get_new_seqnr(self): + """ Returns a unique sequence number (unique across threads) """ + self.seqnr_lock.acquire() + self.last_seqnr += 1 + self.seqnr_lock.release() + return str(self.last_seqnr) + + def _handle_event_accepted(self, iq): + """ Received Iq with accepted - request was accepted """ + seqnr = iq['accepted']['seqnr'] + result = "accepted" + if iq['accepted']['queued'] == 'true': + result = "queued" + + callback = self.sessions[seqnr]["callback"] + callback(from_jid=iq['from'], result=result) + + def _handle_event_rejected(self, iq): + """ Received Iq with rejected - this is a reject. + Delete the session. """ + seqnr = iq['rejected']['seqnr'] + callback = self.sessions[seqnr]["callback"] + callback(from_jid=iq['from'], result="rejected", error_msg=iq['rejected']['error']) + # Session terminated + del self.sessions[seqnr] + + def _handle_event_cancelled(self, iq): + """ + Received Iq with cancelled - this is a cancel confirm. + Delete the session. + """ + seqnr = iq['cancelled']['seqnr'] + callback = self.sessions[seqnr]["callback"] + callback(from_jid=iq['from'], result="cancelled") + # Session cancelled + del self.sessions[seqnr] + + def _handle_event_fields(self, msg): + """ + Received Msg with fields - this is a data response to a request. + If this is the last data block, issue a "done" callback. + """ + seqnr = msg['fields']['seqnr'] + callback = self.sessions[seqnr]["callback"] + for node in msg['fields']['nodes']: + for ts in node['timestamps']: + fields = [] + for d in ts['datas']: + field_block = {} + field_block["name"] = d['name'] + field_block["typename"] = d._get_typename() + field_block["value"] = d['value'] + if not d['unit'] == "": field_block["unit"] = d['unit'] + if not d['dataType'] == "": field_block["dataType"] = d['dataType'] + flags = d._get_flags() + if not len(flags) == 0: + field_block["flags"] = flags + fields.append(field_block) + + callback(from_jid=msg['from'], result="fields", nodeId=node['nodeId'], timestamp=ts['value'], fields=fields) + + if msg['fields']['done'] == "true": + callback(from_jid=msg['from'], result="done") + # Session done + del self.sessions[seqnr] + + def _handle_event_failure(self, msg): + """ + Received Msg with failure - our request failed + Delete the session. + """ + seqnr = msg['failure']['seqnr'] + callback = self.sessions[seqnr]["callback"] + callback(from_jid=msg['from'], result="failure", nodeId=msg['failure']['error']['nodeId'], timestamp=msg['failure']['error']['timestamp'], error_msg=msg['failure']['error']['text']) + + # Session failed + del self.sessions[seqnr] + + def _handle_event_started(self, msg): + """ + Received Msg with started - our request was queued and is now started. + """ + seqnr = msg['started']['seqnr'] + callback = self.sessions[seqnr]["callback"] + callback(from_jid=msg['from'], result="started") + + diff --git a/slixmpp/plugins/xep_0323/stanza/__init__.py b/slixmpp/plugins/xep_0323/stanza/__init__.py new file mode 100644 index 00000000..e1603e41 --- /dev/null +++ b/slixmpp/plugins/xep_0323/stanza/__init__.py @@ -0,0 +1,12 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.xep_0323.stanza.sensordata import * + diff --git a/slixmpp/plugins/xep_0323/stanza/base.py b/slixmpp/plugins/xep_0323/stanza/base.py new file mode 100644 index 00000000..7959b818 --- /dev/null +++ b/slixmpp/plugins/xep_0323/stanza/base.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ET + +pass diff --git a/slixmpp/plugins/xep_0323/stanza/sensordata.py b/slixmpp/plugins/xep_0323/stanza/sensordata.py new file mode 100644 index 00000000..aa223a62 --- /dev/null +++ b/slixmpp/plugins/xep_0323/stanza/sensordata.py @@ -0,0 +1,792 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp import Iq, Message +from slixmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from re import match + +class Sensordata(ElementBase): + """ Placeholder for the namespace, not used as a stanza """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'sensordata' + plugin_attrib = name + interfaces = set(tuple()) + +class FieldTypes(): + """ + All field types are optional booleans that default to False + """ + field_types = set([ 'momentary','peak','status','computed','identity','historicalSecond','historicalMinute','historicalHour', \ + 'historicalDay','historicalWeek','historicalMonth','historicalQuarter','historicalYear','historicalOther']) + +class FieldStatus(): + """ + All field statuses are optional booleans that default to False + """ + field_status = set([ 'missing','automaticEstimate','manualEstimate','manualReadout','automaticReadout','timeOffset','warning','error', \ + 'signed','invoiced','endOfSeries','powerFailure','invoiceConfirmed']) + +class Request(ElementBase): + namespace = 'urn:xmpp:iot:sensordata' + name = 'req' + plugin_attrib = name + interfaces = set(['seqnr','nodes','fields','serviceToken','deviceToken','userToken','from','to','when','historical','all']) + interfaces.update(FieldTypes.field_types) + _flags = set(['serviceToken','deviceToken','userToken','from','to','when','historical','all']) + _flags.update(FieldTypes.field_types) + + def __init__(self, xml=None, parent=None): + ElementBase.__init__(self, xml, parent) + self._nodes = set() + self._fields = 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._nodes = set([node['nodeId'] for node in self['nodes']]) + self._fields = set([field['name'] for field in self['fields']]) + + def _get_flags(self): + """ + Helper function for getting of flags. Returns all flags in + dictionary format: { "flag name": "flag value" ... } + """ + flags = {} + for f in self._flags: + if not self[f] == "": + flags[f] = self[f] + return flags + + def _set_flags(self, flags): + """ + Helper function for setting of flags. + + Arguments: + flags -- Flags in dictionary format: { "flag name": "flag value" ... } + """ + for f in self._flags: + if flags is not None and f in flags: + self[f] = flags[f] + else: + self[f] = None + + def add_node(self, nodeId, sourceId=None, cacheType=None): + """ + Add a new node element. Each item is required to have a + nodeId, but may also specify a sourceId value and cacheType. + + Arguments: + nodeId -- The ID for the node. + sourceId -- [optional] identifying the data source controlling the device + cacheType -- [optional] narrowing down the search to a specific kind of node + """ + if nodeId not in self._nodes: + self._nodes.add((nodeId)) + node = RequestNode(parent=self) + node['nodeId'] = nodeId + node['sourceId'] = sourceId + node['cacheType'] = cacheType + self.iterables.append(node) + return node + return None + + def del_node(self, nodeId): + """ + Remove a single node. + + Arguments: + nodeId -- Node ID of the item to remove. + """ + if nodeId in self._nodes: + nodes = [i for i in self.iterables if isinstance(i, RequestNode)] + for node in nodes: + if node['nodeId'] == nodeId: + self.xml.remove(node.xml) + self.iterables.remove(node) + return True + return False + + def get_nodes(self): + """Return all nodes.""" + nodes = [] + for node in self['substanzas']: + if isinstance(node, RequestNode): + nodes.append(node) + return nodes + + def set_nodes(self, nodes): + """ + Set or replace all nodes. The given nodes must be in a + list or set where each item is a tuple of the form: + (nodeId, sourceId, cacheType) + + Arguments: + nodes -- A series of nodes in tuple format. + """ + self.del_nodes() + for node in nodes: + if isinstance(node, RequestNode): + self.add_node(node['nodeId'], node['sourceId'], node['cacheType']) + else: + nodeId, sourceId, cacheType = node + self.add_node(nodeId, sourceId, cacheType) + + def del_nodes(self): + """Remove all nodes.""" + self._nodes = set() + nodes = [i for i in self.iterables if isinstance(i, RequestNode)] + for node in nodes: + self.xml.remove(node.xml) + self.iterables.remove(node) + + + def add_field(self, name): + """ + Add a new field element. Each item is required to have a + name. + + Arguments: + name -- The name of the field. + """ + if name not in self._fields: + self._fields.add((name)) + field = RequestField(parent=self) + field['name'] = name + self.iterables.append(field) + return field + return None + + def del_field(self, name): + """ + Remove a single field. + + Arguments: + name -- name of field to remove. + """ + if name in self._fields: + fields = [i for i in self.iterables if isinstance(i, RequestField)] + for field in fields: + if field['name'] == name: + self.xml.remove(field.xml) + self.iterables.remove(field) + return True + return False + + def get_fields(self): + """Return all fields.""" + fields = [] + for field in self['substanzas']: + if isinstance(field, RequestField): + fields.append(field) + return fields + + def set_fields(self, fields): + """ + Set or replace all fields. The given fields must be in a + list or set where each item is RequestField or string + + Arguments: + fields -- A series of fields in RequestField or string format. + """ + self.del_fields() + for field in fields: + if isinstance(field, RequestField): + self.add_field(field['name']) + else: + self.add_field(field) + + def del_fields(self): + """Remove all fields.""" + self._fields = set() + fields = [i for i in self.iterables if isinstance(i, RequestField)] + for field in fields: + self.xml.remove(field.xml) + self.iterables.remove(field) + + +class RequestNode(ElementBase): + """ Node element in a request """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'node' + plugin_attrib = name + interfaces = set(['nodeId','sourceId','cacheType']) + +class RequestField(ElementBase): + """ Field element in a request """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'field' + plugin_attrib = name + interfaces = set(['name']) + +class Accepted(ElementBase): + namespace = 'urn:xmpp:iot:sensordata' + name = 'accepted' + plugin_attrib = name + interfaces = set(['seqnr','queued']) + +class Started(ElementBase): + namespace = 'urn:xmpp:iot:sensordata' + name = 'started' + plugin_attrib = name + interfaces = set(['seqnr']) + +class Failure(ElementBase): + namespace = 'urn:xmpp:iot:sensordata' + name = 'failure' + plugin_attrib = name + interfaces = set(['seqnr','done']) + +class Error(ElementBase): + """ Error element in a request failure """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'error' + plugin_attrib = name + interfaces = set(['nodeId','timestamp','sourceId','cacheType','text']) + + def get_text(self): + """Return then contents inside the XML tag.""" + return self.xml.text + + def set_text(self, value): + """Set then contents inside the XML tag. + + :param value: string + """ + + self.xml.text = value + return self + + def del_text(self): + """Remove the contents inside the XML tag.""" + self.xml.text = "" + return self + +class Rejected(ElementBase): + namespace = 'urn:xmpp:iot:sensordata' + name = 'rejected' + plugin_attrib = name + interfaces = set(['seqnr','error']) + sub_interfaces = set(['error']) + +class Fields(ElementBase): + """ Fields element, top level in a response message with data """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'fields' + plugin_attrib = name + interfaces = set(['seqnr','done','nodes']) + + def __init__(self, xml=None, parent=None): + ElementBase.__init__(self, xml, parent) + self._nodes = 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._nodes = set([node['nodeId'] for node in self['nodes']]) + + + def add_node(self, nodeId, sourceId=None, cacheType=None, substanzas=None): + """ + Add a new node element. Each item is required to have a + nodeId, but may also specify a sourceId value and cacheType. + + Arguments: + nodeId -- The ID for the node. + sourceId -- [optional] identifying the data source controlling the device + cacheType -- [optional] narrowing down the search to a specific kind of node + """ + if nodeId not in self._nodes: + self._nodes.add((nodeId)) + node = FieldsNode(parent=self) + node['nodeId'] = nodeId + node['sourceId'] = sourceId + node['cacheType'] = cacheType + if substanzas is not None: + node.set_timestamps(substanzas) + + self.iterables.append(node) + return node + return None + + def del_node(self, nodeId): + """ + Remove a single node. + + Arguments: + nodeId -- Node ID of the item to remove. + """ + if nodeId in self._nodes: + nodes = [i for i in self.iterables if isinstance(i, FieldsNode)] + for node in nodes: + if node['nodeId'] == nodeId: + self.xml.remove(node.xml) + self.iterables.remove(node) + return True + return False + + def get_nodes(self): + """Return all nodes.""" + nodes = [] + for node in self['substanzas']: + if isinstance(node, FieldsNode): + nodes.append(node) + return nodes + + def set_nodes(self, nodes): + """ + Set or replace all nodes. The given nodes must be in a + list or set where each item is a tuple of the form: + (nodeId, sourceId, cacheType) + + Arguments: + nodes -- A series of nodes in tuple format. + """ + #print(str(id(self)) + " set_nodes: got " + str(nodes)) + self.del_nodes() + for node in nodes: + if isinstance(node, FieldsNode): + self.add_node(node['nodeId'], node['sourceId'], node['cacheType'], substanzas=node['substanzas']) + else: + nodeId, sourceId, cacheType = node + self.add_node(nodeId, sourceId, cacheType) + + def del_nodes(self): + """Remove all nodes.""" + self._nodes = set() + nodes = [i for i in self.iterables if isinstance(i, FieldsNode)] + for node in nodes: + self.xml.remove(node.xml) + self.iterables.remove(node) + + +class FieldsNode(ElementBase): + """ Node element in response fields """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'node' + plugin_attrib = name + interfaces = set(['nodeId','sourceId','cacheType','timestamps']) + + def __init__(self, xml=None, parent=None): + ElementBase.__init__(self, xml, parent) + self._timestamps = 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._timestamps = set([ts['value'] for ts in self['timestamps']]) + + def add_timestamp(self, timestamp, substanzas=None): + """ + Add a new timestamp element. + + Arguments: + timestamp -- The timestamp in ISO format. + """ + #print(str(id(self)) + " add_timestamp: " + str(timestamp)) + + if timestamp not in self._timestamps: + self._timestamps.add((timestamp)) + ts = Timestamp(parent=self) + ts['value'] = timestamp + if not substanzas is None: + ts.set_datas(substanzas) + #print("add_timestamp with substanzas: " + str(substanzas)) + self.iterables.append(ts) + #print(str(id(self)) + " added_timestamp: " + str(id(ts))) + return ts + return None + + def del_timestamp(self, timestamp): + """ + Remove a single timestamp. + + Arguments: + timestamp -- timestamp (in ISO format) of the item to remove. + """ + #print("del_timestamp: ") + if timestamp in self._timestamps: + timestamps = [i for i in self.iterables if isinstance(i, Timestamp)] + for ts in timestamps: + if ts['value'] == timestamp: + self.xml.remove(ts.xml) + self.iterables.remove(ts) + return True + return False + + def get_timestamps(self): + """Return all timestamps.""" + #print(str(id(self)) + " get_timestamps: ") + timestamps = [] + for timestamp in self['substanzas']: + if isinstance(timestamp, Timestamp): + timestamps.append(timestamp) + return timestamps + + def set_timestamps(self, timestamps): + """ + Set or replace all timestamps. The given timestamps must be in a + list or set where each item is a timestamp + + Arguments: + timestamps -- A series of timestamps. + """ + #print(str(id(self)) + " set_timestamps: got " + str(timestamps)) + self.del_timestamps() + for timestamp in timestamps: + #print("set_timestamps: subset " + str(timestamp)) + #print("set_timestamps: subset.substanzas " + str(timestamp['substanzas'])) + if isinstance(timestamp, Timestamp): + self.add_timestamp(timestamp['value'], substanzas=timestamp['substanzas']) + else: + #print("set_timestamps: got " + str(timestamp)) + self.add_timestamp(timestamp) + + def del_timestamps(self): + """Remove all timestamps.""" + #print(str(id(self)) + " del_timestamps: ") + self._timestamps = set() + timestamps = [i for i in self.iterables if isinstance(i, Timestamp)] + for timestamp in timestamps: + self.xml.remove(timestamp.xml) + self.iterables.remove(timestamp) + +class Field(ElementBase): + """ + Field element in response Timestamp. This is a base class, + all instances of fields added to Timestamp must be of types: + DataNumeric + DataString + DataBoolean + DataDateTime + DataTimeSpan + DataEnum + """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'field' + plugin_attrib = name + interfaces = set(['name','module','stringIds']) + interfaces.update(FieldTypes.field_types) + interfaces.update(FieldStatus.field_status) + + _flags = set() + _flags.update(FieldTypes.field_types) + _flags.update(FieldStatus.field_status) + + def set_stringIds(self, value): + """Verifies stringIds according to regexp from specification XMPP-0323. + + :param value: string + """ + + pattern = re.compile("^\d+([|]\w+([.]\w+)*([|][^,]*)?)?(,\d+([|]\w+([.]\w+)*([|][^,]*)?)?)*$") + if pattern.match(value) is not None: + self.xml.stringIds = value + else: + # Bad content, add nothing + pass + + return self + + def _get_flags(self): + """ + Helper function for getting of flags. Returns all flags in + dictionary format: { "flag name": "flag value" ... } + """ + flags = {} + for f in self._flags: + if not self[f] == "": + flags[f] = self[f] + return flags + + def _set_flags(self, flags): + """ + Helper function for setting of flags. + + Arguments: + flags -- Flags in dictionary format: { "flag name": "flag value" ... } + """ + for f in self._flags: + if flags is not None and f in flags: + self[f] = flags[f] + else: + self[f] = None + + def _get_typename(self): + return "invalid type, use subclasses!" + + +class Timestamp(ElementBase): + """ Timestamp element in response Node """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'timestamp' + plugin_attrib = name + interfaces = set(['value','datas']) + + def __init__(self, xml=None, parent=None): + ElementBase.__init__(self, xml, parent) + self._datas = 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._datas = set([data['name'] for data in self['datas']]) + + def add_data(self, typename, name, value, module=None, stringIds=None, unit=None, dataType=None, flags=None): + """ + Add a new data element. + + Arguments: + typename -- The type of data element (numeric, string, boolean, dateTime, timeSpan or enum) + value -- The value of the data element + module -- [optional] language module to use for the data element + stringIds -- [optional] The stringIds used to find associated text in the language module + unit -- [optional] The unit. Only applicable for type numeric + dataType -- [optional] The dataType. Only applicable for type enum + """ + if name not in self._datas: + dataObj = None + if typename == "numeric": + dataObj = DataNumeric(parent=self) + dataObj['unit'] = unit + elif typename == "string": + dataObj = DataString(parent=self) + elif typename == "boolean": + dataObj = DataBoolean(parent=self) + elif typename == "dateTime": + dataObj = DataDateTime(parent=self) + elif typename == "timeSpan": + dataObj = DataTimeSpan(parent=self) + elif typename == "enum": + dataObj = DataEnum(parent=self) + dataObj['dataType'] = dataType + + dataObj['name'] = name + dataObj['value'] = value + dataObj['module'] = module + dataObj['stringIds'] = stringIds + + if flags is not None: + dataObj._set_flags(flags) + + self._datas.add(name) + self.iterables.append(dataObj) + return dataObj + return None + + def del_data(self, name): + """ + Remove a single data element. + + Arguments: + data_name -- The data element name to remove. + """ + if name in self._datas: + datas = [i for i in self.iterables if isinstance(i, Field)] + for data in datas: + if data['name'] == name: + self.xml.remove(data.xml) + self.iterables.remove(data) + return True + return False + + def get_datas(self): + """ Return all data elements. """ + datas = [] + for data in self['substanzas']: + if isinstance(data, Field): + datas.append(data) + return datas + + def set_datas(self, datas): + """ + Set or replace all data elements. The given elements must be in a + list or set where each item is a data element (numeric, string, boolean, dateTime, timeSpan or enum) + + Arguments: + datas -- A series of data elements. + """ + self.del_datas() + for data in datas: + self.add_data(typename=data._get_typename(), name=data['name'], value=data['value'], module=data['module'], stringIds=data['stringIds'], unit=data['unit'], dataType=data['dataType'], flags=data._get_flags()) + + def del_datas(self): + """Remove all data elements.""" + self._datas = set() + datas = [i for i in self.iterables if isinstance(i, Field)] + for data in datas: + self.xml.remove(data.xml) + self.iterables.remove(data) + +class DataNumeric(Field): + """ + Field data of type numeric. + Note that the value is expressed as a string. + """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'numeric' + plugin_attrib = name + interfaces = set(['value', 'unit']) + interfaces.update(Field.interfaces) + + def _get_typename(self): + return "numeric" + +class DataString(Field): + """ + Field data of type string + """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'string' + plugin_attrib = name + interfaces = set(['value']) + interfaces.update(Field.interfaces) + + def _get_typename(self): + return "string" + +class DataBoolean(Field): + """ + Field data of type boolean. + Note that the value is expressed as a string. + """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'boolean' + plugin_attrib = name + interfaces = set(['value']) + interfaces.update(Field.interfaces) + + def _get_typename(self): + return "boolean" + +class DataDateTime(Field): + """ + Field data of type dateTime. + Note that the value is expressed as a string. + """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'dateTime' + plugin_attrib = name + interfaces = set(['value']) + interfaces.update(Field.interfaces) + + def _get_typename(self): + return "dateTime" + +class DataTimeSpan(Field): + """ + Field data of type timeSpan. + Note that the value is expressed as a string. + """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'timeSpan' + plugin_attrib = name + interfaces = set(['value']) + interfaces.update(Field.interfaces) + + def _get_typename(self): + return "timeSpan" + +class DataEnum(Field): + """ + Field data of type enum. + Note that the value is expressed as a string. + """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'enum' + plugin_attrib = name + interfaces = set(['value', 'dataType']) + interfaces.update(Field.interfaces) + + def _get_typename(self): + return "enum" + +class Done(ElementBase): + """ Done element used to signal that all data has been transferred """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'done' + plugin_attrib = name + interfaces = set(['seqnr']) + +class Cancel(ElementBase): + """ Cancel element used to signal that a request shall be cancelled """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'cancel' + plugin_attrib = name + interfaces = set(['seqnr']) + +class Cancelled(ElementBase): + """ Cancelled element used to signal that cancellation is confirmed """ + namespace = 'urn:xmpp:iot:sensordata' + name = 'cancelled' + plugin_attrib = name + interfaces = set(['seqnr']) + + +register_stanza_plugin(Iq, Request) +register_stanza_plugin(Request, RequestNode, iterable=True) +register_stanza_plugin(Request, RequestField, iterable=True) + +register_stanza_plugin(Iq, Accepted) +register_stanza_plugin(Message, Failure) +register_stanza_plugin(Failure, Error) + +register_stanza_plugin(Iq, Rejected) + +register_stanza_plugin(Message, Fields) +register_stanza_plugin(Fields, FieldsNode, iterable=True) +register_stanza_plugin(FieldsNode, Timestamp, iterable=True) +register_stanza_plugin(Timestamp, Field, iterable=True) +register_stanza_plugin(Timestamp, DataNumeric, iterable=True) +register_stanza_plugin(Timestamp, DataString, iterable=True) +register_stanza_plugin(Timestamp, DataBoolean, iterable=True) +register_stanza_plugin(Timestamp, DataDateTime, iterable=True) +register_stanza_plugin(Timestamp, DataTimeSpan, iterable=True) +register_stanza_plugin(Timestamp, DataEnum, iterable=True) + +register_stanza_plugin(Message, Started) + +register_stanza_plugin(Iq, Cancel) +register_stanza_plugin(Iq, Cancelled) diff --git a/slixmpp/plugins/xep_0323/timerreset.py b/slixmpp/plugins/xep_0323/timerreset.py new file mode 100644 index 00000000..616380e7 --- /dev/null +++ b/slixmpp/plugins/xep_0323/timerreset.py @@ -0,0 +1,69 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" +from threading import Thread, Event, Timer +import time + +def TimerReset(*args, **kwargs): + """ Global function for Timer """ + return _TimerReset(*args, **kwargs) + + +class _TimerReset(Thread): + """Call a function after a specified number of seconds: + + t = TimerReset(30.0, f, args=[], kwargs={}) + t.start() + t.cancel() # stop the timer's action if it's still waiting + """ + + def __init__(self, interval, function, args=None, kwargs=None): + if not kwargs: + kwargs = {} + if not args: + args = [] + + Thread.__init__(self) + self.interval = interval + self.function = function + self.args = args + self.kwargs = kwargs + self.finished = Event() + self.resetted = True + + def cancel(self): + """Stop the timer if it hasn't finished yet""" + self.finished.set() + + def run(self): + #print "Time: %s - timer running..." % time.asctime() + + while self.resetted: + #print "Time: %s - timer waiting for timeout in %.2f..." % (time.asctime(), self.interval) + self.resetted = False + self.finished.wait(self.interval) + + if not self.finished.isSet(): + self.function(*self.args, **self.kwargs) + self.finished.set() + #print "Time: %s - timer finished!" % time.asctime() + + def reset(self, interval=None): + """ Reset the timer """ + + if interval: + #print "Time: %s - timer resetting to %.2f..." % (time.asctime(), interval) + self.interval = interval + else: + #print "Time: %s - timer resetting..." % time.asctime() + pass + + self.resetted = True + self.finished.set() + self.finished.clear() diff --git a/slixmpp/plugins/xep_0325/__init__.py b/slixmpp/plugins/xep_0325/__init__.py new file mode 100644 index 00000000..ce8cb7ce --- /dev/null +++ b/slixmpp/plugins/xep_0325/__init__.py @@ -0,0 +1,18 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0325.control import XEP_0325 +from slixmpp.plugins.xep_0325 import stanza + +register_plugin(XEP_0325) + +xep_0325=XEP_0325 diff --git a/slixmpp/plugins/xep_0325/control.py b/slixmpp/plugins/xep_0325/control.py new file mode 100644 index 00000000..9a493b02 --- /dev/null +++ b/slixmpp/plugins/xep_0325/control.py @@ -0,0 +1,548 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging +import time + +from slixmpp import asyncio +from functools import partial +from slixmpp.xmlstream import JID +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0325 import stanza +from slixmpp.plugins.xep_0325.stanza import Control + + +log = logging.getLogger(__name__) + + +class XEP_0325(BasePlugin): + + """ + XEP-0325: IoT Control + + + Actuators are devices in sensor networks that can be controlled through + the network and act with the outside world. In sensor networks and + Internet of Things applications, actuators make it possible to automate + real-world processes. + This plugin implements a mechanism whereby actuators can be controlled + in XMPP-based sensor networks, making it possible to integrate sensors + and actuators of different brands, makes and models into larger + Internet of Things applications. + + Also see <http://xmpp.org/extensions/xep-0325.html> + + Events: + Sensor side + ----------- + Control Event:DirectSet -- Received a control message + Control Event:SetReq -- Received a control request + + Client side + ----------- + Control Event:SetResponse -- Received a response to a + control request, type result + Control Event:SetResponseError -- Received a response to a + control request, type error + + Attributes: + sessions -- A dictionary or equivalent backend mapping + session IDs to dictionaries containing data + relevant to a request's session. This dictionary is used + both by the client and sensor side. On client side, seqnr + is used as key, while on sensor side, a session_id is used + as key. This ensures that the two will not collide, so + one instance can be both client and sensor. + Sensor side + ----------- + nodes -- A dictionary mapping sensor nodes that are serviced through + this XMPP instance to their device handlers ("drivers"). + Client side + ----------- + last_seqnr -- The last used sequence number (integer). One sequence of + communication (e.g. -->request, <--accept, <--fields) + between client and sensor is identified by a unique + sequence number (unique between the client/sensor pair) + + Methods: + plugin_init -- Overrides BasePlugin.plugin_init + post_init -- Overrides BasePlugin.post_init + plugin_end -- Overrides BasePlugin.plugin_end + + Sensor side + ----------- + register_node -- Register a sensor as available from this XMPP + instance. + + Client side + ----------- + set_request -- Initiates a control request to modify data in + sensor(s). Non-blocking, a callback function will + be called when the sensor has responded. + set_command -- Initiates a control command to modify data in + sensor(s). Non-blocking. The sensor(s) will not + respond regardless of the result of the command, + so no callback is made. + + """ + + name = 'xep_0325' + description = 'XEP-0325 Internet of Things - Control' + dependencies = set(['xep_0030']) + stanza = stanza + + + default_config = { +# 'session_db': None + } + + def plugin_init(self): + """ Start the XEP-0325 plugin """ + + self.xmpp.register_handler( + Callback('Control Event:DirectSet', + StanzaPath('message/set'), + self._handle_direct_set)) + + self.xmpp.register_handler( + Callback('Control Event:SetReq', + StanzaPath('iq@type=set/set'), + self._handle_set_req)) + + self.xmpp.register_handler( + Callback('Control Event:SetResponse', + StanzaPath('iq@type=result/setResponse'), + self._handle_set_response)) + + self.xmpp.register_handler( + Callback('Control Event:SetResponseError', + StanzaPath('iq@type=error/setResponse'), + self._handle_set_response)) + + # Server side dicts + self.nodes = {} + self.sessions = {} + + self.last_seqnr = 0 + + ## For testning only + self.test_authenticated_from = "" + + def post_init(self): + """ Init complete. Register our features in Service discovery. """ + BasePlugin.post_init(self) + self.xmpp['xep_0030'].add_feature(Control.namespace) + self.xmpp['xep_0030'].set_items(node=Control.namespace, items=tuple()) + + def _new_session(self): + """ Return a new session ID. """ + return str(time.time()) + '-' + self.xmpp.new_id() + + def plugin_end(self): + """ Stop the XEP-0325 plugin """ + self.sessions.clear() + self.xmpp.remove_handler('Control Event:DirectSet') + self.xmpp.remove_handler('Control Event:SetReq') + self.xmpp.remove_handler('Control Event:SetResponse') + self.xmpp.remove_handler('Control Event:SetResponseError') + self.xmpp['xep_0030'].del_feature(feature=Control.namespace) + self.xmpp['xep_0030'].set_items(node=Control.namespace, items=tuple()) + + + # ================================================================= + # Sensor side (data provider) API + + def register_node(self, nodeId, device, commTimeout, sourceId=None, cacheType=None): + """ + Register a sensor/device as available for control requests/commands + through this XMPP instance. + + The device object may by any custom implementation to support + specific devices, but it must implement the functions: + has_control_field + set_control_fields + according to the interfaces shown in the example device.py file. + + Arguments: + nodeId -- The identifier for the device + device -- The device object + commTimeout -- Time in seconds to wait between each callback from device during + a data readout. Float. + sourceId -- [optional] identifying the data source controlling the device + cacheType -- [optional] narrowing down the search to a specific kind of node + """ + self.nodes[nodeId] = {"device": device, + "commTimeout": commTimeout, + "sourceId": sourceId, + "cacheType": cacheType} + + def _set_authenticated(self, auth=''): + """ Internal testing function """ + self.test_authenticated_from = auth + + def _get_new_seqnr(self): + """ Returns a unique sequence number (unique across threads) """ + self.last_seqnr = self.last_seqnr + 1 + return str(self.last_seqnr) + + def _handle_set_req(self, iq): + """ + Event handler for reception of an Iq with set req - this is a + control request. + + Verifies that + - all the requested nodes are available + (if no nodes are specified in the request, assume all nodes) + - all the control fields are available from all requested nodes + (if no nodes are specified in the request, assume all nodes) + + If the request passes verification, the control request is passed + to the devices (in a separate thread). + If the verification fails, a setResponse with error indication + is sent. + """ + + error_msg = '' + req_ok = True + missing_node = None + missing_field = None + + # Authentication + if len(self.test_authenticated_from) > 0 and not iq['from'] == self.test_authenticated_from: + # Invalid authentication + req_ok = False + error_msg = "Access denied" + + # Nodes + if len(iq['set']['nodes']) > 0: + for n in iq['set']['nodes']: + if not n['nodeId'] in self.nodes: + req_ok = False + missing_node = n['nodeId'] + error_msg = "Invalid nodeId " + n['nodeId'] + process_nodes = [n['nodeId'] for n in iq['set']['nodes']] + else: + process_nodes = self.nodes.keys() + + # Fields - for control we need to find all in all devices, otherwise we reject + process_fields = [] + if len(iq['set']['datas']) > 0: + for f in iq['set']['datas']: + for node in self.nodes: + if not self.nodes[node]["device"].has_control_field(f['name'], f._get_typename()): + req_ok = False + missing_field = f['name'] + error_msg = "Invalid field " + f['name'] + break + process_fields = [(f['name'], f._get_typename(), f['value']) for f in iq['set']['datas']] + + if req_ok: + session = self._new_session() + self.sessions[session] = {"from": iq['from'], "to": iq['to'], "seqnr": iq['id']} + self.sessions[session]["commTimers"] = {} + self.sessions[session]["nodeDone"] = {} + # Flag that a reply is exected when we are done + self.sessions[session]["reply"] = True + + self.sessions[session]["node_list"] = process_nodes + self._node_request(session, process_fields) + else: + iq = iq.reply() + iq['type'] = 'error' + iq['setResponse']['responseCode'] = "NotFound" + if missing_node is not None: + iq['setResponse'].add_node(missing_node) + if missing_field is not None: + iq['setResponse'].add_data(missing_field) + iq['setResponse']['error']['var'] = "Output" + iq['setResponse']['error']['text'] = error_msg + iq.send() + + def _handle_direct_set(self, msg): + """ + Event handler for reception of a Message with set command - this is a + direct control command. + + Verifies that + - all the requested nodes are available + (if no nodes are specified in the request, assume all nodes) + - all the control fields are available from all requested nodes + (if no nodes are specified in the request, assume all nodes) + + If the request passes verification, the control request is passed + to the devices (in a separate thread). + If the verification fails, do nothing. + """ + req_ok = True + + # Nodes + if len(msg['set']['nodes']) > 0: + for n in msg['set']['nodes']: + if not n['nodeId'] in self.nodes: + req_ok = False + error_msg = "Invalid nodeId " + n['nodeId'] + process_nodes = [n['nodeId'] for n in msg['set']['nodes']] + else: + process_nodes = self.nodes.keys() + + # Fields - for control we need to find all in all devices, otherwise we reject + process_fields = [] + if len(msg['set']['datas']) > 0: + for f in msg['set']['datas']: + for node in self.nodes: + if not self.nodes[node]["device"].has_control_field(f['name'], f._get_typename()): + req_ok = False + missing_field = f['name'] + error_msg = "Invalid field " + f['name'] + break + process_fields = [(f['name'], f._get_typename(), f['value']) for f in msg['set']['datas']] + + if req_ok: + session = self._new_session() + self.sessions[session] = {"from": msg['from'], "to": msg['to']} + self.sessions[session]["commTimers"] = {} + self.sessions[session]["nodeDone"] = {} + self.sessions[session]["reply"] = False + + self.sessions[session]["node_list"] = process_nodes + self._node_request(session, process_fields) + + + def _node_request(self, session, process_fields): + """ + Helper function to handle the device control in a separate thread. + + Arguments: + session -- The request session id + process_fields -- The fields to set in the devices. List of tuple format: + (name, datatype, value) + """ + for node in self.sessions[session]["node_list"]: + self.sessions[session]["nodeDone"][node] = False + + for node in self.sessions[session]["node_list"]: + timer = self.xmpp.loop.call_later(self.nodes[node]['commTimeout'], partial(self._event_comm_timeout, args=(session, node))) + self.sessions[session]["commTimers"][node] = timer + self.nodes[node]['device'].set_control_fields(process_fields, session=session, callback=self._device_set_command_callback) + + def _event_comm_timeout(self, session, nodeId): + """ + Triggered if any of the control operations timeout. + Stop communicating with the failing device. + If the control command was an Iq request, sends a failure + message back to the client. + + Arguments: + session -- The request session id + nodeId -- The id of the device which timed out + """ + + if self.sessions[session]["reply"]: + # Reply is exected when we are done + iq = self.xmpp.Iq() + iq['from'] = self.sessions[session]['to'] + iq['to'] = self.sessions[session]['from'] + iq['type'] = "error" + iq['id'] = self.sessions[session]['seqnr'] + iq['setResponse']['responseCode'] = "OtherError" + iq['setResponse'].add_node(nodeId) + iq['setResponse']['error']['var'] = "Output" + iq['setResponse']['error']['text'] = "Timeout." + iq.send() + + ## TODO - should we send one timeout per node?? + + # Drop communication with this device and check if we are done + self.sessions[session]["nodeDone"][nodeId] = True + if (self._all_nodes_done(session)): + # The session is complete, delete it + del self.sessions[session] + + def _all_nodes_done(self, session): + """ + Checks wheter all devices are done replying to the control command. + + Arguments: + session -- The request session id + """ + for n in self.sessions[session]["nodeDone"]: + if not self.sessions[session]["nodeDone"][n]: + return False + return True + + def _device_set_command_callback(self, session, nodeId, result, error_field=None, error_msg=None): + """ + Callback function called by the devices when the control command is + complete or failed. + If needed, composes a message with the result and sends it back to the + client. + + Arguments: + session -- The request session id + nodeId -- The device id which initiated the callback + result -- The current result status of the control command. Valid values are: + "error" - Set fields failed. + "ok" - All fields were set. + error_field -- [optional] Only applies when result == "error" + The field name that failed (usually means it is missing) + error_msg -- [optional] Only applies when result == "error". + Error details when a request failed. + """ + + if not session in self.sessions: + # This can happend if a session was deleted, like in a timeout. Just drop the data. + return + + if result == "error": + self.sessions[session]["commTimers"][nodeId].cancel() + + if self.sessions[session]["reply"]: + # Reply is exected when we are done + iq = self.xmpp.Iq() + iq['from'] = self.sessions[session]['to'] + iq['to'] = self.sessions[session]['from'] + iq['type'] = "error" + iq['id'] = self.sessions[session]['seqnr'] + iq['setResponse']['responseCode'] = "OtherError" + iq['setResponse'].add_node(nodeId) + if error_field is not None: + iq['setResponse'].add_data(error_field) + iq['setResponse']['error']['var'] = error_field + iq['setResponse']['error']['text'] = error_msg + iq.send() + + # Drop communication with this device and check if we are done + self.sessions[session]["nodeDone"][nodeId] = True + if (self._all_nodes_done(session)): + # The session is complete, delete it + del self.sessions[session] + else: + self.sessions[session]["commTimers"][nodeId].cancel() + + self.sessions[session]["nodeDone"][nodeId] = True + if (self._all_nodes_done(session)): + if self.sessions[session]["reply"]: + # Reply is exected when we are done + iq = self.xmpp.Iq() + iq['from'] = self.sessions[session]['to'] + iq['to'] = self.sessions[session]['from'] + iq['type'] = "result" + iq['id'] = self.sessions[session]['seqnr'] + iq['setResponse']['responseCode'] = "OK" + iq.send() + + # The session is complete, delete it + del self.sessions[session] + + + # ================================================================= + # Client side (data controller) API + + def set_request(self, from_jid, to_jid, callback, fields, nodeIds=None): + """ + Called on the client side to initiade a control request. + Composes a message with the request and sends it to the device(s). + Does not block, the callback will be called when the device(s) + has responded. + + Arguments: + from_jid -- The jid of the requester + to_jid -- The jid of the device(s) + callback -- The callback function to call when data is availble. + + The callback function must support the following arguments: + + from_jid -- The jid of the responding device(s) + result -- The result of the control request. Valid values are: + "OK" - Control request completed successfully + "NotFound" - One or more nodes or fields are missing + "InsufficientPrivileges" - Not authorized. + "Locked" - Field(s) is locked and cannot + be changed at the moment. + "NotImplemented" - Request feature not implemented. + "FormError" - Error while setting with + a form (not implemented). + "OtherError" - Indicates other types of + errors, such as timeout. + Details in the error_msg. + + + nodeId -- [optional] Only applicable when result == "error" + List of node Ids of failing device(s). + + fields -- [optional] Only applicable when result == "error" + List of fields that failed.[optional] Mandatory when result == "rejected" or "failure". + + error_msg -- Details about why the request failed. + + fields -- Fields to set. List of tuple format: (name, typename, value). + nodeIds -- [optional] Limits the request to the node Ids in this list. + """ + iq = self.xmpp.Iq() + iq['from'] = from_jid + iq['to'] = to_jid + seqnr = self._get_new_seqnr() + iq['id'] = seqnr + iq['type'] = "set" + if nodeIds is not None: + for nodeId in nodeIds: + iq['set'].add_node(nodeId) + if fields is not None: + for name, typename, value in fields: + iq['set'].add_data(name=name, typename=typename, value=value) + + self.sessions[seqnr] = {"from": iq['from'], "to": iq['to'], "callback": callback} + iq.send() + + def set_command(self, from_jid, to_jid, fields, nodeIds=None): + """ + Called on the client side to initiade a control command. + Composes a message with the set commandand sends it to the device(s). + Does not block. Device(s) will not respond, regardless of result. + + Arguments: + from_jid -- The jid of the requester + to_jid -- The jid of the device(s) + + fields -- Fields to set. List of tuple format: (name, typename, value). + nodeIds -- [optional] Limits the request to the node Ids in this list. + """ + msg = self.xmpp.Message() + msg['from'] = from_jid + msg['to'] = to_jid + msg['type'] = "set" + if nodeIds is not None: + for nodeId in nodeIds: + msg['set'].add_node(nodeId) + if fields is not None: + for name, typename, value in fields: + msg['set'].add_data(name, typename, value) + + # We won't get any reply, so don't create a session + msg.send() + + def _handle_set_response(self, iq): + """ Received response from device(s) """ + #print("ooh") + seqnr = iq['id'] + from_jid = str(iq['from']) + result = iq['setResponse']['responseCode'] + nodeIds = [n['name'] for n in iq['setResponse']['nodes']] + fields = [f['name'] for f in iq['setResponse']['datas']] + error_msg = None + + if not iq['setResponse'].find('error') is None and not iq['setResponse']['error']['text'] == "": + error_msg = iq['setResponse']['error']['text'] + + callback = self.sessions[seqnr]["callback"] + callback(from_jid=from_jid, result=result, nodeIds=nodeIds, fields=fields, error_msg=error_msg) + diff --git a/slixmpp/plugins/xep_0325/device.py b/slixmpp/plugins/xep_0325/device.py new file mode 100644 index 00000000..05275088 --- /dev/null +++ b/slixmpp/plugins/xep_0325/device.py @@ -0,0 +1,125 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import datetime + +class Device(object): + """ + Example implementation of a device control object. + + The device object may by any custom implementation to support + specific devices, but it must implement the functions: + has_control_field + set_control_fields + """ + + def __init__(self, nodeId): + self.nodeId = nodeId + self.control_fields = {} + + def has_control_field(self, field, typename): + """ + Returns true if the supplied field name exists + and the type matches for control in this device. + + Arguments: + field -- The field name + typename -- The expected type + """ + if field in self.control_fields and self.control_fields[field]["type"] == typename: + return True + return False + + def set_control_fields(self, fields, session, callback): + """ + Starts a control setting procedure. Verifies the fields, + sets the data and (if needed) and calls the callback. + + Arguments: + fields -- List of control fields in tuple format: + (name, typename, value) + session -- Session id, only used in the callback as identifier + callback -- Callback function to call when control set is complete. + + The callback function must support the following arguments: + + session -- Session id, as supplied in the + request_fields call + nodeId -- Identifier for this device + result -- The current result status of the readout. + Valid values are: + "error" - Set fields failed. + "ok" - All fields were set. + error_field -- [optional] Only applies when result == "error" + The field name that failed + (usually means it is missing) + error_msg -- [optional] Only applies when result == "error". + Error details when a request failed. + """ + + if len(fields) > 0: + # Check availiability + for name, typename, value in fields: + if not self.has_control_field(name, typename): + self._send_control_reject(session, name, "NotFound", callback) + return False + + for name, typename, value in fields: + self._set_field_value(name, value) + + callback(session, result="ok", nodeId=self.nodeId) + return True + + def _send_control_reject(self, session, field, message, callback): + """ + Sends a reject to the caller + + Arguments: + session -- Session id, see definition in + set_control_fields function + callback -- Callback function, see definition in + set_control_fields function + """ + callback(session, result="error", nodeId=self.nodeId, error_field=field, error_msg=message) + + def _add_control_field(self, name, typename, value): + """ + Adds a control field to the device + + Arguments: + name -- Name of the field + typename -- Type of the field, one of: + (boolean, color, string, date, dateTime, + double, duration, int, long, time) + value -- Field value + """ + self.control_fields[name] = {"type": typename, "value": value} + + def _set_field_value(self, name, value): + """ + Set the value of a control field + + Arguments: + name -- Name of the field + value -- New value for the field + """ + if name in self.control_fields: + self.control_fields[name]["value"] = value + + def _get_field_value(self, name): + """ + Get the value of a control field. Only used for unit testing. + + Arguments: + name -- Name of the field + """ + if name in self.control_fields: + return self.control_fields[name]["value"] + return None diff --git a/slixmpp/plugins/xep_0325/stanza/__init__.py b/slixmpp/plugins/xep_0325/stanza/__init__.py new file mode 100644 index 00000000..1466db5d --- /dev/null +++ b/slixmpp/plugins/xep_0325/stanza/__init__.py @@ -0,0 +1,12 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.xep_0325.stanza.control import * + diff --git a/slixmpp/plugins/xep_0325/stanza/base.py b/slixmpp/plugins/xep_0325/stanza/base.py new file mode 100644 index 00000000..7959b818 --- /dev/null +++ b/slixmpp/plugins/xep_0325/stanza/base.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ET + +pass diff --git a/slixmpp/plugins/xep_0325/stanza/control.py b/slixmpp/plugins/xep_0325/stanza/control.py new file mode 100644 index 00000000..c47f3a4e --- /dev/null +++ b/slixmpp/plugins/xep_0325/stanza/control.py @@ -0,0 +1,526 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of xeps for Internet of Things + http://wiki.xmpp.org/web/Tech_pages/IoT_systems + Copyright (C) 2013 Sustainable Innovation, Joachim.lindborg@sust.se, bjorn.westrom@consoden.se + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp import Iq, Message +from slixmpp.xmlstream import register_stanza_plugin, ElementBase, ET, JID +from re import match + +class Control(ElementBase): + """ Placeholder for the namespace, not used as a stanza """ + namespace = 'urn:xmpp:iot:control' + name = 'control' + plugin_attrib = name + interfaces = set(tuple()) + +class ControlSet(ElementBase): + namespace = 'urn:xmpp:iot:control' + name = 'set' + plugin_attrib = name + interfaces = set(['nodes','datas']) + + def __init__(self, xml=None, parent=None): + ElementBase.__init__(self, xml, parent) + self._nodes = set() + self._datas = 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._nodes = set([node['nodeId'] for node in self['nodes']]) + self._datas = set([data['name'] for data in self['datas']]) + + def add_node(self, nodeId, sourceId=None, cacheType=None): + """ + Add a new node element. Each item is required to have a + nodeId, but may also specify a sourceId value and cacheType. + + Arguments: + nodeId -- The ID for the node. + sourceId -- [optional] identifying the data source controlling the device + cacheType -- [optional] narrowing down the search to a specific kind of node + """ + if nodeId not in self._nodes: + self._nodes.add((nodeId)) + node = RequestNode(parent=self) + node['nodeId'] = nodeId + node['sourceId'] = sourceId + node['cacheType'] = cacheType + self.iterables.append(node) + return node + return None + + def del_node(self, nodeId): + """ + Remove a single node. + + Arguments: + nodeId -- Node ID of the item to remove. + """ + if nodeId in self._nodes: + nodes = [i for i in self.iterables if isinstance(i, RequestNode)] + for node in nodes: + if node['nodeId'] == nodeId: + self.xml.remove(node.xml) + self.iterables.remove(node) + return True + return False + + def get_nodes(self): + """Return all nodes.""" + nodes = [] + for node in self['substanzas']: + if isinstance(node, RequestNode): + nodes.append(node) + return nodes + + def set_nodes(self, nodes): + """ + Set or replace all nodes. The given nodes must be in a + list or set where each item is a tuple of the form: + (nodeId, sourceId, cacheType) + + Arguments: + nodes -- A series of nodes in tuple format. + """ + self.del_nodes() + for node in nodes: + if isinstance(node, RequestNode): + self.add_node(node['nodeId'], node['sourceId'], node['cacheType']) + else: + nodeId, sourceId, cacheType = node + self.add_node(nodeId, sourceId, cacheType) + + def del_nodes(self): + """Remove all nodes.""" + self._nodes = set() + nodes = [i for i in self.iterables if isinstance(i, RequestNode)] + for node in nodes: + self.xml.remove(node.xml) + self.iterables.remove(node) + + + def add_data(self, name, typename, value): + """ + Add a new data element. + + Arguments: + name -- The name of the data element + typename -- The type of data element + (boolean, color, string, date, dateTime, + double, duration, int, long, time) + value -- The value of the data element + """ + if name not in self._datas: + dataObj = None + if typename == "boolean": + dataObj = BooleanParameter(parent=self) + elif typename == "color": + dataObj = ColorParameter(parent=self) + elif typename == "string": + dataObj = StringParameter(parent=self) + elif typename == "date": + dataObj = DateParameter(parent=self) + elif typename == "dateTime": + dataObj = DateTimeParameter(parent=self) + elif typename == "double": + dataObj = DoubleParameter(parent=self) + elif typename == "duration": + dataObj = DurationParameter(parent=self) + elif typename == "int": + dataObj = IntParameter(parent=self) + elif typename == "long": + dataObj = LongParameter(parent=self) + elif typename == "time": + dataObj = TimeParameter(parent=self) + + dataObj['name'] = name + dataObj['value'] = value + + self._datas.add(name) + self.iterables.append(dataObj) + return dataObj + return None + + def del_data(self, name): + """ + Remove a single data element. + + Arguments: + data_name -- The data element name to remove. + """ + if name in self._datas: + datas = [i for i in self.iterables if isinstance(i, BaseParameter)] + for data in datas: + if data['name'] == name: + self.xml.remove(data.xml) + self.iterables.remove(data) + return True + return False + + def get_datas(self): + """ Return all data elements. """ + datas = [] + for data in self['substanzas']: + if isinstance(data, BaseParameter): + datas.append(data) + return datas + + def set_datas(self, datas): + """ + Set or replace all data elements. The given elements must be in a + list or set where each item is a data element (numeric, string, boolean, dateTime, timeSpan or enum) + + Arguments: + datas -- A series of data elements. + """ + self.del_datas() + for data in datas: + self.add_data(name=data['name'], typename=data._get_typename(), value=data['value']) + + def del_datas(self): + """Remove all data elements.""" + self._datas = set() + datas = [i for i in self.iterables if isinstance(i, BaseParameter)] + for data in datas: + self.xml.remove(data.xml) + self.iterables.remove(data) + + +class RequestNode(ElementBase): + """ Node element in a request """ + namespace = 'urn:xmpp:iot:control' + name = 'node' + plugin_attrib = name + interfaces = set(['nodeId','sourceId','cacheType']) + + +class ControlSetResponse(ElementBase): + namespace = 'urn:xmpp:iot:control' + name = 'setResponse' + plugin_attrib = name + interfaces = set(['responseCode']) + + def __init__(self, xml=None, parent=None): + ElementBase.__init__(self, xml, parent) + self._nodes = set() + self._datas = 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._nodes = set([node['nodeId'] for node in self['nodes']]) + self._datas = set([data['name'] for data in self['datas']]) + + def add_node(self, nodeId, sourceId=None, cacheType=None): + """ + Add a new node element. Each item is required to have a + nodeId, but may also specify a sourceId value and cacheType. + + Arguments: + nodeId -- The ID for the node. + sourceId -- [optional] identifying the data source controlling the device + cacheType -- [optional] narrowing down the search to a specific kind of node + """ + if nodeId not in self._nodes: + self._nodes.add(nodeId) + node = RequestNode(parent=self) + node['nodeId'] = nodeId + node['sourceId'] = sourceId + node['cacheType'] = cacheType + self.iterables.append(node) + return node + return None + + def del_node(self, nodeId): + """ + Remove a single node. + + Arguments: + nodeId -- Node ID of the item to remove. + """ + if nodeId in self._nodes: + nodes = [i for i in self.iterables if isinstance(i, RequestNode)] + for node in nodes: + if node['nodeId'] == nodeId: + self.xml.remove(node.xml) + self.iterables.remove(node) + return True + return False + + def get_nodes(self): + """Return all nodes.""" + nodes = [] + for node in self['substanzas']: + if isinstance(node, RequestNode): + nodes.append(node) + return nodes + + def set_nodes(self, nodes): + """ + Set or replace all nodes. The given nodes must be in a + list or set where each item is a tuple of the form: + (nodeId, sourceId, cacheType) + + Arguments: + nodes -- A series of nodes in tuple format. + """ + self.del_nodes() + for node in nodes: + if isinstance(node, RequestNode): + self.add_node(node['nodeId'], node['sourceId'], node['cacheType']) + else: + nodeId, sourceId, cacheType = node + self.add_node(nodeId, sourceId, cacheType) + + def del_nodes(self): + """Remove all nodes.""" + self._nodes = set() + nodes = [i for i in self.iterables if isinstance(i, RequestNode)] + for node in nodes: + self.xml.remove(node.xml) + self.iterables.remove(node) + + + def add_data(self, name): + """ + Add a new ResponseParameter element. + + Arguments: + name -- Name of the parameter + """ + if name not in self._datas: + self._datas.add(name) + data = ResponseParameter(parent=self) + data['name'] = name + self.iterables.append(data) + return data + return None + + def del_data(self, name): + """ + Remove a single ResponseParameter element. + + Arguments: + name -- The data element name to remove. + """ + if name in self._datas: + datas = [i for i in self.iterables if isinstance(i, ResponseParameter)] + for data in datas: + if data['name'] == name: + self.xml.remove(data.xml) + self.iterables.remove(data) + return True + return False + + def get_datas(self): + """ Return all ResponseParameter elements. """ + datas = set() + for data in self['substanzas']: + if isinstance(data, ResponseParameter): + datas.add(data) + return datas + + def set_datas(self, datas): + """ + Set or replace all data elements. The given elements must be in a + list or set of ResponseParameter elements + + Arguments: + datas -- A series of data element names. + """ + self.del_datas() + for data in datas: + self.add_data(name=data['name']) + + def del_datas(self): + """Remove all ResponseParameter elements.""" + self._datas = set() + datas = [i for i in self.iterables if isinstance(i, ResponseParameter)] + for data in datas: + self.xml.remove(data.xml) + self.iterables.remove(data) + + +class Error(ElementBase): + namespace = 'urn:xmpp:iot:control' + name = 'error' + plugin_attrib = name + interfaces = set(['var','text']) + + def get_text(self): + """Return then contents inside the XML tag.""" + return self.xml.text + + def set_text(self, value): + """Set then contents inside the XML tag. + + Arguments: + value -- string + """ + + self.xml.text = value + return self + + def del_text(self): + """Remove the contents inside the XML tag.""" + self.xml.text = "" + return self + +class ResponseParameter(ElementBase): + """ + Parameter element in ControlSetResponse. + """ + namespace = 'urn:xmpp:iot:control' + name = 'parameter' + plugin_attrib = name + interfaces = set(['name']) + + +class BaseParameter(ElementBase): + """ + Parameter element in SetCommand. This is a base class, + all instances of parameters added to SetCommand must be of types: + BooleanParameter + ColorParameter + StringParameter + DateParameter + DateTimeParameter + DoubleParameter + DurationParameter + IntParameter + LongParameter + TimeParameter + """ + namespace = 'urn:xmpp:iot:control' + name = 'baseParameter' + plugin_attrib = name + interfaces = set(['name','value']) + + def _get_typename(self): + return self.name + +class BooleanParameter(BaseParameter): + """ + Field data of type boolean. + Note that the value is expressed as a string. + """ + name = 'boolean' + plugin_attrib = name + +class ColorParameter(BaseParameter): + """ + Field data of type color. + Note that the value is expressed as a string. + """ + name = 'color' + plugin_attrib = name + +class StringParameter(BaseParameter): + """ + Field data of type string. + """ + name = 'string' + plugin_attrib = name + +class DateParameter(BaseParameter): + """ + Field data of type date. + Note that the value is expressed as a string. + """ + name = 'date' + plugin_attrib = name + +class DateTimeParameter(BaseParameter): + """ + Field data of type dateTime. + Note that the value is expressed as a string. + """ + name = 'dateTime' + plugin_attrib = name + +class DoubleParameter(BaseParameter): + """ + Field data of type double. + Note that the value is expressed as a string. + """ + name = 'double' + plugin_attrib = name + +class DurationParameter(BaseParameter): + """ + Field data of type duration. + Note that the value is expressed as a string. + """ + name = 'duration' + plugin_attrib = name + +class IntParameter(BaseParameter): + """ + Field data of type int. + Note that the value is expressed as a string. + """ + name = 'int' + plugin_attrib = name + +class LongParameter(BaseParameter): + """ + Field data of type long (64-bit int). + Note that the value is expressed as a string. + """ + name = 'long' + plugin_attrib = name + +class TimeParameter(BaseParameter): + """ + Field data of type time. + Note that the value is expressed as a string. + """ + name = 'time' + plugin_attrib = name + +register_stanza_plugin(Iq, ControlSet) +register_stanza_plugin(Message, ControlSet) + +register_stanza_plugin(ControlSet, RequestNode, iterable=True) + +register_stanza_plugin(ControlSet, BooleanParameter, iterable=True) +register_stanza_plugin(ControlSet, ColorParameter, iterable=True) +register_stanza_plugin(ControlSet, StringParameter, iterable=True) +register_stanza_plugin(ControlSet, DateParameter, iterable=True) +register_stanza_plugin(ControlSet, DateTimeParameter, iterable=True) +register_stanza_plugin(ControlSet, DoubleParameter, iterable=True) +register_stanza_plugin(ControlSet, DurationParameter, iterable=True) +register_stanza_plugin(ControlSet, IntParameter, iterable=True) +register_stanza_plugin(ControlSet, LongParameter, iterable=True) +register_stanza_plugin(ControlSet, TimeParameter, iterable=True) + +register_stanza_plugin(Iq, ControlSetResponse) +register_stanza_plugin(ControlSetResponse, Error) +register_stanza_plugin(ControlSetResponse, RequestNode, iterable=True) +register_stanza_plugin(ControlSetResponse, ResponseParameter, iterable=True) + diff --git a/slixmpp/plugins/xep_0332/__init__.py b/slixmpp/plugins/xep_0332/__init__.py new file mode 100644 index 00000000..8bf6b369 --- /dev/null +++ b/slixmpp/plugins/xep_0332/__init__.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of HTTP over XMPP transport + http://xmpp.org/extensions/xep-0332.html + Copyright (C) 2015 Riptide IO, sangeeth@riptideio.com + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.base import register_plugin + +from slixmpp.plugins.xep_0332 import stanza +from slixmpp.plugins.xep_0332.http import XEP_0332 + + +register_plugin(XEP_0332) diff --git a/slixmpp/plugins/xep_0332/http.py b/slixmpp/plugins/xep_0332/http.py new file mode 100644 index 00000000..7ad14dc8 --- /dev/null +++ b/slixmpp/plugins/xep_0332/http.py @@ -0,0 +1,159 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of HTTP over XMPP transport + http://xmpp.org/extensions/xep-0332.html + Copyright (C) 2015 Riptide IO, sangeeth@riptideio.com + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp import Iq + +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.xmlstream.handler import Callback +from slixmpp.xmlstream.matcher import StanzaPath + +from slixmpp.plugins.base import BasePlugin +from slixmpp.plugins.xep_0332.stanza import ( + HTTPRequest, HTTPResponse, HTTPData +) +from slixmpp.plugins.xep_0131.stanza import Headers + + +log = logging.getLogger(__name__) + + +class XEP_0332(BasePlugin): + """ + XEP-0332: HTTP over XMPP transport + """ + + name = 'xep_0332' + description = 'XEP-0332: HTTP over XMPP transport' + + #: xep_0047 not included. + #: xep_0001, 0137 and 0166 are missing + dependencies = set(['xep_0030', 'xep_0131']) + + #: TODO: Do we really need to mention the supported_headers?! + default_config = { + 'supported_headers': set([ + 'Content-Length', 'Transfer-Encoding', 'DateTime', + 'Accept-Charset', 'Location', 'Content-ID', 'Description', + 'Content-Language', 'Content-Transfer-Encoding', 'Timestamp', + 'Expires', 'User-Agent', 'Host', 'Proxy-Authorization', 'Date', + 'WWW-Authenticate', 'Accept-Encoding', 'Server', 'Error-Info', + 'Identifier', 'Content-Location', 'Content-Encoding', 'Distribute', + 'Accept', 'Proxy-Authenticate', 'ETag', 'Expect', 'Content-Type' + ]) + } + + def plugin_init(self): + self.xmpp.register_handler( + Callback( + 'HTTP Request', + StanzaPath('iq/http-req'), + self._handle_request + ) + ) + self.xmpp.register_handler( + Callback( + 'HTTP Response', + StanzaPath('iq/http-resp'), + self._handle_response + ) + ) + register_stanza_plugin(Iq, HTTPRequest, iterable=True) + register_stanza_plugin(Iq, HTTPResponse, iterable=True) + register_stanza_plugin(HTTPRequest, Headers, iterable=True) + register_stanza_plugin(HTTPRequest, HTTPData, iterable=True) + register_stanza_plugin(HTTPResponse, Headers, iterable=True) + register_stanza_plugin(HTTPResponse, HTTPData, iterable=True) + # TODO: Should we register any api's here? self.api.register() + + def plugin_end(self): + self.xmpp.remove_handler('HTTP Request') + self.xmpp.remove_handler('HTTP Response') + self.xmpp['xep_0030'].del_feature('urn:xmpp:http') + for header in self.supported_headers: + self.xmpp['xep_0030'].del_feature( + feature='%s#%s' % (Headers.namespace, header) + ) + + def session_bind(self, jid): + self.xmpp['xep_0030'].add_feature('urn:xmpp:http') + for header in self.supported_headers: + self.xmpp['xep_0030'].add_feature( + '%s#%s' % (Headers.namespace, header) + ) + # TODO: Do we need to add the supported headers to xep_0131? + # self.xmpp['xep_0131'].supported_headers.add(header) + + def _handle_request(self, iq): + self.xmpp.event('http_request', iq) + + def _handle_response(self, iq): + self.xmpp.event('http_response', iq) + + def send_request(self, to=None, method=None, resource=None, headers=None, + data=None, **kwargs): + iq = self.xmpp.Iq() + iq['from'] = self.xmpp.boundjid + iq['to'] = to + iq['type'] = 'set' + iq['http-req']['headers'] = headers + iq['http-req']['method'] = method + iq['http-req']['resource'] = resource + iq['http-req']['version'] = '1.1' # TODO: set this implicitly + if 'id' in kwargs: + iq['id'] = kwargs["id"] + if data is not None: + iq['http-req']['data'] = data + return iq.send( + timeout=kwargs.get('timeout', None), + block=kwargs.get('block', True), + callback=kwargs.get('callback', None), + timeout_callback=kwargs.get('timeout_callback', None) + ) + + def send_response(self, to=None, code=None, message=None, headers=None, + data=None, **kwargs): + iq = self.xmpp.Iq() + iq['from'] = self.xmpp.boundjid + iq['to'] = to + iq['type'] = 'result' + iq['http-resp']['headers'] = headers + iq['http-resp']['code'] = code + iq['http-resp']['message'] = message + iq['http-resp']['version'] = '1.1' # TODO: set this implicitly + if 'id' in kwargs: + iq['id'] = kwargs["id"] + if data is not None: + iq['http-resp']['data'] = data + return iq.send( + timeout=kwargs.get('timeout', None), + block=kwargs.get('block', True), + callback=kwargs.get('callback', None), + timeout_callback=kwargs.get('timeout_callback', None) + ) + + def send_error(self, to=None, ecode='500', etype='wait', + econd='internal-server-error', **kwargs): + iq = self.xmpp.Iq() + iq['from'] = self.xmpp.boundjid + iq['to'] = to + iq['type'] = 'error' + iq['error']['code'] = ecode + iq['error']['type'] = etype + iq['error']['condition'] = econd + if 'id' in kwargs: + iq['id'] = kwargs["id"] + return iq.send( + timeout=kwargs.get('timeout', None), + block=kwargs.get('block', True), + callback=kwargs.get('callback', None), + timeout_callback=kwargs.get('timeout_callback', None) + ) diff --git a/slixmpp/plugins/xep_0332/stanza/__init__.py b/slixmpp/plugins/xep_0332/stanza/__init__.py new file mode 100644 index 00000000..f98375c6 --- /dev/null +++ b/slixmpp/plugins/xep_0332/stanza/__init__.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of HTTP over XMPP transport + http://xmpp.org/extensions/xep-0332.html + Copyright (C) 2015 Riptide IO, sangeeth@riptideio.com + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.plugins.xep_0332.stanza.request import HTTPRequest +from slixmpp.plugins.xep_0332.stanza.response import HTTPResponse +from slixmpp.plugins.xep_0332.stanza.data import HTTPData diff --git a/slixmpp/plugins/xep_0332/stanza/data.py b/slixmpp/plugins/xep_0332/stanza/data.py new file mode 100644 index 00000000..a19c94f5 --- /dev/null +++ b/slixmpp/plugins/xep_0332/stanza/data.py @@ -0,0 +1,30 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of HTTP over XMPP transport + http://xmpp.org/extensions/xep-0332.html + Copyright (C) 2015 Riptide IO, sangeeth@riptideio.com + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class HTTPData(ElementBase): + """ + The data element. + """ + name = 'data' + namespace = 'urn:xmpp:http' + interfaces = set(['data']) + plugin_attrib = 'data' + is_extension = True + + def get_data(self, encoding='text'): + data = self._get_sub_text(encoding, None) + return str(data) if data is not None else data + + def set_data(self, data, encoding='text'): + self._set_sub_text(encoding, text=data) + diff --git a/slixmpp/plugins/xep_0332/stanza/request.py b/slixmpp/plugins/xep_0332/stanza/request.py new file mode 100644 index 00000000..e3e46361 --- /dev/null +++ b/slixmpp/plugins/xep_0332/stanza/request.py @@ -0,0 +1,71 @@ +""" + slixmpp: The Slick XMPP Library + Implementation of HTTP over XMPP transport + http://xmpp.org/extensions/xep-0332.html + Copyright (C) 2015 Riptide IO, sangeeth@riptideio.com + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class HTTPRequest(ElementBase): + + """ + All HTTP communication is done using the `Request`/`Response` paradigm. + Each HTTP Request is made sending an `iq` stanza containing a `req` + element to the server. Each `iq` stanza sent is of type `set`. + + Examples: + <iq type='set' from='a@b.com/browser' to='x@y.com' id='1'> + <req xmlns='urn:xmpp:http' + method='GET' + resource='/api/users' + version='1.1'> + <headers xmlns='http://jabber.org/protocol/shim'> + <header name='Host'>b.com</header> + </headers> + </req> + </iq> + + <iq type='set' from='a@b.com/browser' to='x@y.com' id='2'> + <req xmlns='urn:xmpp:http' + method='PUT' + resource='/api/users' + version='1.1'> + <headers xmlns='http://jabber.org/protocol/shim'> + <header name='Host'>b.com</header> + <header name='Content-Type'>text/html</header> + <header name='Content-Length'>...</header> + </headers> + <data> + <text>...</text> + </data> + </req> + </iq> + """ + + name = 'request' + namespace = 'urn:xmpp:http' + interfaces = set(['method', 'resource', 'version']) + plugin_attrib = 'http-req' + + def get_method(self): + return self._get_attr('method', None) + + def set_method(self, method): + self._set_attr('method', method) + + def get_resource(self): + return self._get_attr('resource', None) + + def set_resource(self, resource): + self._set_attr('resource', resource) + + def get_version(self): + return self._get_attr('version', None) + + def set_version(self, version='1.1'): + self._set_attr('version', version) diff --git a/slixmpp/plugins/xep_0332/stanza/response.py b/slixmpp/plugins/xep_0332/stanza/response.py new file mode 100644 index 00000000..a0b8fe34 --- /dev/null +++ b/slixmpp/plugins/xep_0332/stanza/response.py @@ -0,0 +1,66 @@ +""" + Slixmpp: The Slick XMPP Library + Implementation of HTTP over XMPP transport + http://xmpp.org/extensions/xep-0332.html + Copyright (C) 2015 Riptide IO, sangeeth@riptideio.com + This file is part of slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase + + +class HTTPResponse(ElementBase): + + """ + When the HTTP Server responds, it does so by sending an `iq` stanza + response (type=`result`) back to the client containing the `resp` element. + Since response are asynchronous, and since multiple requests may be active + at the same time, responses may be returned in a different order than the + in which the original requests were made. + + Examples: + <iq type='result' + from='httpserver@clayster.com' + to='httpclient@clayster.com/browser' id='2'> + <resp xmlns='urn:xmpp:http' + version='1.1' + statusCode='200' + statusMessage='OK'> + <headers xmlns='http://jabber.org/protocol/shim'> + <header name='Date'>Fri, 03 May 2013 16:39:54GMT-4</header> + <header name='Server'>Clayster</header> + <header name='Content-Type'>text/turtle</header> + <header name='Content-Length'>...</header> + <header name='Connection'>Close</header> + </headers> + <data> + <text> + ... + </text> + </data> + </resp> + </iq> + """ + + name = 'response' + namespace = 'urn:xmpp:http' + interfaces = set(['code', 'message', 'version']) + plugin_attrib = 'http-resp' + + def get_code(self): + code = self._get_attr('statusCode', None) + return int(code) if code is not None else code + + def set_code(self, code): + self._set_attr('statusCode', str(code)) + + def get_message(self): + return self._get_attr('statusMessage', '') + + def set_message(self, message): + self._set_attr('statusMessage', message) + + def set_version(self, version='1.1'): + self._set_attr('version', version) diff --git a/slixmpp/roster/__init__.py b/slixmpp/roster/__init__.py new file mode 100644 index 00000000..9a9b69a1 --- /dev/null +++ b/slixmpp/roster/__init__.py @@ -0,0 +1,11 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.roster.item import RosterItem +from slixmpp.roster.single import RosterNode +from slixmpp.roster.multi import Roster diff --git a/slixmpp/roster/item.py b/slixmpp/roster/item.py new file mode 100644 index 00000000..c1eb574a --- /dev/null +++ b/slixmpp/roster/item.py @@ -0,0 +1,497 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + 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 Slixmpp 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 Slixmpp 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, save=True): + """ + Set the datastore interface object for the roster item. + + Arguments: + db -- The new datastore interface. + save -- If True, save the existing state to the new + backend datastore. Defaults to True. + """ + self.db = db + if save: + self.save() + 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, remove=False): + """ + Save the item's state information to an external datastore, + if one has been provided. + + Arguments: + remove -- If True, expunge the item from the datastore. + """ + self['subscription'] = self._subscription() + if remove: + self._state['removed'] = True + if self.db: + self.db.save(self.owner, self.jid, + self._state, self._db_state) + + # Finally, remove the in-memory copy if needed. + if remove: + del self.xmpp.roster[self.owner][self.jid] + + 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, **kwargs): + """ + 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. + pfrom -- The sender of a directed presence, which should + be the owner JID plus resource. + ptype -- The type of presence, such as 'subscribe'. + pnick -- Optional nickname of the presence's sender. + """ + if self.xmpp.is_component and not kwargs.get('pfrom', ''): + kwargs['pfrom'] = self.owner + if not kwargs.get('pto', ''): + kwargs['pto'] = self.jid + self.xmpp.send_presence(**kwargs) + + 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']} + got_online = not self.resources + 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 got_online: + self.xmpp.event('got_online', presence) + 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['from']: + self.send_last_presence() + if self['pending_out']: + self.subscribe() + if not self['from']: + 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/slixmpp/roster/multi.py b/slixmpp/roster/multi.py new file mode 100644 index 00000000..e1a44a08 --- /dev/null +++ b/slixmpp/roster/multi.py @@ -0,0 +1,224 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Presence +from slixmpp.xmlstream import JID +from slixmpp.roster import RosterNode + + +class Roster(object): + + """ + Slixmpp'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 Slixmpp 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 Slixmpp 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) + + self.xmpp.add_filter('out', self._save_last_status) + + def _save_last_status(self, stanza): + + if isinstance(stanza, Presence): + sfrom = stanza['from'].full + sto = stanza['to'].full + + if not sfrom: + sfrom = self.xmpp.boundjid + + if stanza['type'] in stanza.showtypes or \ + stanza['type'] in ('available', 'unavailable'): + if sto: + self[sfrom][sto].last_status = stanza + else: + self[sfrom].last_status = stanza + with self[sfrom]._last_status_lock: + for jid in self[sfrom]: + self[sfrom][jid].last_status = None + + if not self.xmpp.sentpresence: + self.xmpp.event('sent_presence') + self.xmpp.sentpresence = True + + return stanza + + 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 key is None: + key = self.xmpp.boundjid + if not isinstance(key, JID): + key = JID(key) + key = key.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 not isinstance(node, JID): + node = JID(node) + + node = node.bare + if node not in self._rosters: + self._rosters[node] = RosterNode(self.xmpp, node, self.db) + + def set_backend(self, db=None, save=True): + """ + Set the datastore interface object for the roster. + + Arguments: + db -- The new datastore interface. + save -- If True, save the existing state to the new + backend datastore. Defaults to True. + """ + self.db = db + existing_entries = set(self._rosters) + new_entries = set(self.db.entries(None, {})) + + for node in existing_entries: + self._rosters[node].set_backend(db, save) + for node in new_entries - existing_entries: + self.add(node) + + 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, **kwargs): + """ + 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. + pfrom -- The sender of a directed presence, which should + be the owner JID plus resource. + ptype -- The type of presence, such as 'subscribe'. + pnick -- Optional nickname of the presence's sender. + """ + if self.xmpp.is_component and not kwargs.get('pfrom', ''): + kwargs['pfrom'] = self.jid + self.xmpp.send_presence(**kwargs) + + @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/slixmpp/roster/single.py b/slixmpp/roster/single.py new file mode 100644 index 00000000..62fbca41 --- /dev/null +++ b/slixmpp/roster/single.py @@ -0,0 +1,337 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import threading + +from slixmpp.xmlstream import JID +from slixmpp.roster import RosterItem + + +class RosterNode(object): + + """ + A roster node is a roster for a single JID. + + Attributes: + xmpp -- The main Slixmpp 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 Slixmpp 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._version = '' + self._jids = {} + self._last_status_lock = threading.Lock() + + if self.db: + if hasattr(self.db, 'version'): + self._version = self.db.version(self.jid) + for jid in self.db.entries(self.jid): + self.add(jid) + + @property + def version(self): + """Retrieve the roster's version ID.""" + if self.db and hasattr(self.db, 'version'): + self._version = self.db.version(self.jid) + return self._version + + @version.setter + def version(self, version): + """Set the roster's version ID.""" + self._version = version + if self.db and hasattr(self.db, 'set_version'): + self.db.set_version(self.jid, version) + + 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 key is None: + key = JID('') + if not isinstance(key, JID): + key = JID(key) + key = key.bare + if key not in self._jids: + self.add(key, save=True) + return self._jids[key] + + def __delitem__(self, key): + """ + Remove a roster item from the local storage. + + To remove an item from the server, use the remove() method. + """ + if key is None: + key = JID('') + if not isinstance(key, JID): + key = JID(key) + key = key.bare + if key in self._jids: + del 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: + groups = self._jids[jid]['groups'] + if not groups: + if '' not in result: + result[''] = [] + result[''].append(jid) + for group in 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, save=True): + """ + Set the datastore interface object for the roster node. + + Arguments: + db -- The new datastore interface. + save -- If True, save the existing state to the new + backend datastore. Defaults to True. + """ + self.db = db + existing_entries = set(self._jids) + new_entries = set(self.db.entries(self.jid, {})) + + for jid in existing_entries: + self._jids[jid].set_backend(db, save) + for jid in new_entries - existing_entries: + self.add(jid) + + 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=[], + timeout=None, callback=None, timeout_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. + 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. + """ + if not groups: + groups = [] + + 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(timeout=timeout, callback=callback, + timeout_callback=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, **kwargs): + """ + 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. + pfrom -- The sender of a directed presence, which should + be the owner JID plus resource. + ptype -- The type of presence, such as 'subscribe'. + pnick -- Optional nickname of the presence's sender. + """ + if self.xmpp.is_component and not kwargs.get('pfrom', ''): + kwargs['pfrom'] = self.jid + self.xmpp.send_presence(**kwargs) + + 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/slixmpp/stanza/__init__.py b/slixmpp/stanza/__init__.py new file mode 100644 index 00000000..6cd6a2c5 --- /dev/null +++ b/slixmpp/stanza/__init__.py @@ -0,0 +1,15 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + + +from slixmpp.stanza.error import Error +from slixmpp.stanza.iq import Iq +from slixmpp.stanza.message import Message +from slixmpp.stanza.presence import Presence +from slixmpp.stanza.stream_features import StreamFeatures +from slixmpp.stanza.stream_error import StreamError diff --git a/slixmpp/stanza/atom.py b/slixmpp/stanza/atom.py new file mode 100644 index 00000000..ccded724 --- /dev/null +++ b/slixmpp/stanza/atom.py @@ -0,0 +1,43 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, register_stanza_plugin + +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', 'id', 'published', 'updated')) + sub_interfaces = set(('title', 'summary', 'id', 'published', + 'updated')) + +class AtomAuthor(ElementBase): + + """ + An Atom author. + + Stanza Interface: + name -- The printable author name + uri -- The bare jid of the author + """ + + name = 'author' + plugin_attrib = 'author' + interfaces = set(('name', 'uri')) + sub_interfaces = set(('name', 'uri')) + +register_stanza_plugin(AtomEntry, AtomAuthor) diff --git a/slixmpp/stanza/error.py b/slixmpp/stanza/error.py new file mode 100644 index 00000000..67f736c0 --- /dev/null +++ b/slixmpp/stanza/error.py @@ -0,0 +1,164 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream import ElementBase, ET + + +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', + 'gone', 'redirect', 'by')) + 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: + 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: + 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 + + def get_gone(self): + return self._get_sub_text('{%s}gone' % self.condition_ns, '') + + def get_redirect(self): + return self._get_sub_text('{%s}redirect' % self.condition_ns, '') + + def set_gone(self, value): + if value: + del self['condition'] + return self._set_sub_text('{%s}gone' % self.condition_ns, value) + elif self['condition'] == 'gone': + del self['condition'] + + def set_redirect(self, value): + if value: + del self['condition'] + ns = self.condition_ns + return self._set_sub_text('{%s}redirect' % ns, value) + elif self['condition'] == 'redirect': + del self['condition'] + + def del_gone(self): + self._del_sub('{%s}gone' % self.condition_ns) + + def del_redirect(self): + self._del_sub('{%s}redirect' % self.condition_ns) diff --git a/slixmpp/stanza/htmlim.py b/slixmpp/stanza/htmlim.py new file mode 100644 index 00000000..a5a3e5f3 --- /dev/null +++ b/slixmpp/stanza/htmlim.py @@ -0,0 +1,14 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Message +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0071 import XHTML_IM as HTMLIM + + +register_stanza_plugin(Message, HTMLIM) diff --git a/slixmpp/stanza/iq.py b/slixmpp/stanza/iq.py new file mode 100644 index 00000000..a64dfa7f --- /dev/null +++ b/slixmpp/stanza/iq.py @@ -0,0 +1,261 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza.rootstanza import RootStanza +from slixmpp.xmlstream import StanzaBase, ET +from slixmpp.xmlstream.handler import Waiter, Callback, CoroutineCallback +from slixmpp.xmlstream.asyncio import asyncio +from slixmpp.xmlstream.matcher import MatchIDSender, MatcherId +from slixmpp.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: + + .. code-block:: xml + + <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. + """ + + 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'): + reply = self.reply() + reply['error']['condition'] = 'feature-not-implemented' + reply['error']['text'] = 'No handlers registered for this request.' + reply.send() + + def set_payload(self, value): + """ + Set the XML contents of the <iq> stanza. + + :param value: An XML object or a list of XML objects to use as the <iq> + stanza's contents + :type value: list or XML object + """ + 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. + + :param str value: The namespace of the <query> element. + """ + query = self.xml.find("{%s}query" % value) + if query is None and value: + plugin = self.plugin_tag_map.get('{%s}query' % value, None) + if plugin: + self.enable(plugin.plugin_attrib) + else: + 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. + + :rtype: str""" + for child in self.xml: + 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: + if child.tag.endswith('query'): + self.xml.remove(child) + return self + + def reply(self, clear=True): + """ + Create a new <iq> stanza replying to ``self``. + + Overrides StanzaBase.reply + + Sets the 'type' to 'result' in addition to the default + StanzaBase.reply behavior. + + :param bool clear: Indicates if existing content should be + removed before replying. Defaults to True. + """ + new_iq = StanzaBase.reply(self, clear=clear) + new_iq['type'] = 'result' + return new_iq + + def send(self, callback=None, timeout=None, timeout_callback=None): + """Send an <iq> stanza over the XML stream. + + A callback handler can be provided that will be executed when the Iq + stanza's result reply is received. + + Returns a future which result will be set to the result Iq if it is of type 'get' or 'set' + (when it is received), or a future with the result set to None if it has another type. + + Overrides StanzaBase.send + + :param function callback: Optional reference to a stream handler + function. Will be executed when a reply + stanza is received. + :param int timeout: The length of time (in seconds) to wait for a + response before the timeout_callback is called, + instead of the regular callback + :param function timeout_callback: Optional reference to a stream handler + function. Will be executed when the + timeout expires before a response has + been received for the originally-sent + IQ stanza. + :rtype: asyncio.Future + """ + if self.stream.session_bind_event.is_set(): + matcher = MatchIDSender({ + 'id': self['id'], + 'self': self.stream.boundjid, + 'peer': self['to'] + }) + else: + matcher = MatcherId(self['id']) + + future = asyncio.Future() + + def callback_success(result): + if result['type'] == 'error': + future.set_exception(IqError(result)) + else: + future.set_result(result) + + if timeout is not None: + self.stream.cancel_schedule('IqTimeout_%s' % self['id']) + if callback is not None: + callback(result) + + def callback_timeout(): + future.set_exception(IqTimeout(self)) + self.stream.remove_handler('IqCallback_%s' % self['id']) + if timeout_callback is not None: + timeout_callback(self) + + if self['type'] in ('get', 'set'): + handler_name = 'IqCallback_%s' % self['id'] + if asyncio.iscoroutinefunction(callback): + constr = CoroutineCallback + else: + constr = Callback + if timeout is not None: + self.stream.schedule('IqTimeout_%s' % self['id'], + timeout, + callback_timeout, + repeat=False) + handler = constr(handler_name, + matcher, + callback_success, + once=True) + self.stream.register_handler(handler) + else: + future.set_result(None) + StanzaBase.send(self) + return future + + def _handle_result(self, iq): + # we got the IQ, so don't fire the timeout + self.stream.cancel_schedule('IqTimeout_%s' % self['id']) + self.callback(iq) + + def _fire_timeout(self): + # don't fire the handler for the IQ, if it finally does come in + self.stream.remove_handler('IqCallback_%s' % self['id']) + self.timeout_callback(self) + + 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 diff --git a/slixmpp/stanza/message.py b/slixmpp/stanza/message.py new file mode 100644 index 00000000..cbb170fa --- /dev/null +++ b/slixmpp/stanza/message.py @@ -0,0 +1,188 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza.rootstanza import RootStanza +from slixmpp.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: + + .. code-block:: xml + + <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. + """ + + name = 'message' + namespace = 'jabber:client' + plugin_attrib = name + interfaces = set(['type', 'to', 'from', 'id', 'body', 'subject', + 'thread', 'parent_thread', 'mucroom', 'mucnick']) + sub_interfaces = set(['body', 'subject', 'thread']) + lang_interfaces = sub_interfaces + types = set(['normal', 'chat', 'headline', 'error', 'groupchat']) + + def __init__(self, *args, **kwargs): + """ + Initialize a new <message /> stanza with an optional 'id' value. + + Overrides StanzaBase.__init__. + """ + StanzaBase.__init__(self, *args, **kwargs) + if self['id'] == '': + if self.stream is not None and self.stream.use_message_ids: + self['id'] = self.stream.new_id() + + def get_type(self): + """ + Return the message type. + + Overrides default stanza interface behavior. + + Returns 'normal' if no type attribute is present. + + :rtype: str + """ + return self._get_attr('type', 'normal') + + def get_parent_thread(self): + """Return the message thread's parent thread. + + :rtype: str + """ + thread = self.xml.find('{%s}thread' % self.namespace) + if thread is not None: + return thread.attrib.get('parent', '') + return '' + + def set_parent_thread(self, value): + """Add or change the message thread's parent thread. + + :param str value: identifier of the thread""" + thread = self.xml.find('{%s}thread' % self.namespace) + if value: + if thread is None: + thread = ET.Element('{%s}thread' % self.namespace) + self.xml.append(thread) + thread.attrib['parent'] = value + else: + if thread is not None and 'parent' in thread.attrib: + del thread.attrib['parent'] + + def del_parent_thread(self): + """Delete the message thread's parent reference.""" + thread = self.xml.find('{%s}thread' % self.namespace) + if thread is not None and 'parent' in thread.attrib: + del thread.attrib['parent'] + + 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. + + :param str body: Optional text content for the message. + :param bool clear: Indicates if existing content should be removed + before replying. Defaults to True. + + :rtype: :class:`~.Message` + """ + new_message = StanzaBase.reply(self, clear) + + if self['type'] == 'groupchat': + new_message['to'] = new_message['to'].bare + + new_message['thread'] = self['thread'] + new_message['parent_thread'] = self['parent_thread'] + + del new_message['id'] + + if body is not None: + new_message['body'] = body + return new_message + + def get_mucroom(self): + """ + Return the name of the MUC room where the message originated. + + Read-only stanza interface. + + :rtype: str + """ + 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. + + :rtype: str + """ + 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 diff --git a/slixmpp/stanza/nick.py b/slixmpp/stanza/nick.py new file mode 100644 index 00000000..3bb7d63d --- /dev/null +++ b/slixmpp/stanza/nick.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +# The nickname stanza has been moved to its own plugin, but the existing +# references are kept for backwards compatibility. + +from slixmpp.stanza import Message, Presence +from slixmpp.xmlstream import register_stanza_plugin +from slixmpp.plugins.xep_0172 import UserNick as Nick + +register_stanza_plugin(Message, Nick) +register_stanza_plugin(Presence, Nick) diff --git a/slixmpp/stanza/presence.py b/slixmpp/stanza/presence.py new file mode 100644 index 00000000..1e8a940e --- /dev/null +++ b/slixmpp/stanza/presence.py @@ -0,0 +1,173 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza.rootstanza import RootStanza +from slixmpp.xmlstream import StanzaBase + + +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: + + .. code-block:: xml + + <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. + """ + + name = 'presence' + namespace = 'jabber:client' + plugin_attrib = name + interfaces = set(['type', 'to', 'from', 'id', 'show', + 'status', 'priority']) + sub_interfaces = set(['show', 'status', 'priority']) + lang_interfaces = set(['status']) + + types = set(['available', 'unavailable', 'error', 'probe', 'subscribe', + 'subscribed', 'unsubscribe', 'unsubscribed']) + showtypes = set(['dnd', 'chat', 'xa', 'away']) + + def __init__(self, *args, **kwargs): + """ + Initialize a new <presence /> stanza with an optional 'id' value. + + Overrides StanzaBase.__init__. + """ + StanzaBase.__init__(self, *args, **kwargs) + if self['id'] == '': + if self.stream is not None and self.stream.use_presence_ids: + self['id'] = self.stream.new_id() + + def exception(self, e): + """ + Override exception passback for presence. + """ + pass + + def set_show(self, show): + """ + Set the value of the <show> element. + + :param str 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. + + :param str 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. + + :param int 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. + + :rtype: int + """ + 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): + """ + Create a new reply <presence/> stanza from ``self``. + + Overrides StanzaBase.reply. + + :param bool clear: Indicates if the stanza contents should be removed + before replying. Defaults to True. + """ + new_presence = StanzaBase.reply(self, clear) + if self['type'] == 'unsubscribe': + new_presence['type'] = 'unsubscribed' + elif self['type'] == 'subscribe': + new_presence['type'] = 'subscribed' + return new_presence diff --git a/slixmpp/stanza/rootstanza.py b/slixmpp/stanza/rootstanza.py new file mode 100644 index 00000000..a6dd958e --- /dev/null +++ b/slixmpp/stanza/rootstanza.py @@ -0,0 +1,90 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from slixmpp.exceptions import XMPPError, IqError, IqTimeout +from slixmpp.stanza import Error +from slixmpp.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. + reply = self.reply() + reply['error']['condition'] = 'undefined-condition' + reply['error']['text'] = 'External error' + reply['error']['type'] = 'cancel' + log.warning('You should catch IqError exceptions') + reply.send() + elif isinstance(e, IqTimeout): + reply = self.reply() + reply['error']['condition'] = 'remote-server-timeout' + reply['error']['type'] = 'wait' + log.warning('You should catch IqTimeout exceptions') + reply.send() + elif isinstance(e, XMPPError): + # We raised this deliberately + keep_id = self['id'] + reply = self.reply(clear=e.clear) + reply['id'] = keep_id + reply['error']['condition'] = e.condition + reply['error']['text'] = e.text + reply['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) + reply['error'].append(extxml) + reply.send() + else: + # We probably didn't raise this on purpose, so send an error stanza + keep_id = self['id'] + reply = self.reply() + reply['id'] = keep_id + reply['error']['condition'] = 'undefined-condition' + reply['error']['text'] = "Slixmpp got into trouble." + reply['error']['type'] = 'cancel' + reply.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/slixmpp/stanza/roster.py b/slixmpp/stanza/roster.py new file mode 100644 index 00000000..c017c33f --- /dev/null +++ b/slixmpp/stanza/roster.py @@ -0,0 +1,152 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza import Iq +from slixmpp.xmlstream import JID +from slixmpp.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', 'ver')) + + def get_ver(self): + """ + Ensure handling an empty ver attribute propery. + + The ver attribute is special in that the presence of the + attribute with an empty value is important for boostrapping + roster versioning. + """ + return self.xml.attrib.get('ver', None) + + def set_ver(self, ver): + """ + Ensure handling an empty ver attribute propery. + + The ver attribute is special in that the presence of the + attribute with an empty value is important for boostrapping + roster versioning. + """ + if ver is not None: + self.xml.attrib['ver'] = ver + else: + del self.xml.attrib['ver'] + + 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'] + del items[item['jid']]['lang'] + 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_jid(self): + return JID(self._get_attr('jid', '')) + + def set_jid(self, jid): + self._set_attr('jid', str(jid)) + + def get_groups(self): + groups = [] + for group in self.xml.findall('{%s}group' % self.namespace): + if group.text: + groups.append(group.text) + else: + groups.append('') + 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.xml.remove(group) + + +register_stanza_plugin(Iq, Roster) +register_stanza_plugin(Roster, RosterItem, iterable=True) diff --git a/slixmpp/stanza/stream_error.py b/slixmpp/stanza/stream_error.py new file mode 100644 index 00000000..d8b8bb5a --- /dev/null +++ b/slixmpp/stanza/stream_error.py @@ -0,0 +1,83 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.stanza.error import Error +from slixmpp.xmlstream import StanzaBase + + +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', 'see_other_host')) + 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' + + def get_see_other_host(self): + ns = self.condition_ns + return self._get_sub_text('{%s}see-other-host' % ns, '') + + def set_see_other_host(self, value): + if value: + del self['condition'] + ns = self.condition_ns + return self._set_sub_text('{%s}see-other-host' % ns, value) + elif self['condition'] == 'see-other-host': + del self['condition'] + + def del_see_other_host(self): + self._del_sub('{%s}see-other-host' % self.condition_ns) diff --git a/slixmpp/stanza/stream_features.py b/slixmpp/stanza/stream_features.py new file mode 100644 index 00000000..05788771 --- /dev/null +++ b/slixmpp/stanza/stream_features.py @@ -0,0 +1,57 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from collections import OrderedDict +from slixmpp.xmlstream import StanzaBase + + +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): + """ + """ + features = OrderedDict() + for (name, lang), plugin in self.plugins.items(): + features[name] = plugin + return features + + 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/slixmpp/stringprep.py b/slixmpp/stringprep.py new file mode 100644 index 00000000..99506d78 --- /dev/null +++ b/slixmpp/stringprep.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.stringprep + ~~~~~~~~~~~~~~~~~~~~~~~ + + This module is a fallback using python’s stringprep instead of libidn’s. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2015 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> + :license: MIT, see LICENSE for more details +""" + +import logging +import stringprep +from slixmpp.util import stringprep_profiles +import encodings.idna + +class StringprepError(Exception): + pass + +#: These characters are not allowed to appear in a domain part. +ILLEGAL_CHARS = ('\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r' + '\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19' + '\x1a\x1b\x1c\x1d\x1e\x1f' + ' !"#$%&\'()*+,./:;<=>?@[\\]^_`{|}~\x7f') + + +# pylint: disable=c0103 +#: The nodeprep profile of stringprep used to validate the local, +#: or username, portion of a JID. +_nodeprep = stringprep_profiles.create( + nfkc=True, + bidi=True, + mappings=[ + stringprep_profiles.b1_mapping, + stringprep.map_table_b2], + prohibited=[ + stringprep.in_table_c11, + stringprep.in_table_c12, + stringprep.in_table_c21, + stringprep.in_table_c22, + stringprep.in_table_c3, + stringprep.in_table_c4, + stringprep.in_table_c5, + stringprep.in_table_c6, + stringprep.in_table_c7, + stringprep.in_table_c8, + stringprep.in_table_c9, + lambda c: c in ' \'"&/:<>@'], + unassigned=[stringprep.in_table_a1]) + +def nodeprep(node): + try: + return _nodeprep(node) + except stringprep_profiles.StringPrepError: + raise StringprepError + +# pylint: disable=c0103 +#: The resourceprep profile of stringprep, which is used to validate +#: the resource portion of a JID. +_resourceprep = stringprep_profiles.create( + nfkc=True, + bidi=True, + mappings=[stringprep_profiles.b1_mapping], + prohibited=[ + stringprep.in_table_c12, + stringprep.in_table_c21, + stringprep.in_table_c22, + stringprep.in_table_c3, + stringprep.in_table_c4, + stringprep.in_table_c5, + stringprep.in_table_c6, + stringprep.in_table_c7, + stringprep.in_table_c8, + stringprep.in_table_c9], + unassigned=[stringprep.in_table_a1]) + +def resourceprep(resource): + try: + return _resourceprep(resource) + except stringprep_profiles.StringPrepError: + raise StringprepError + +def idna(domain): + domain_parts = [] + for label in domain.split('.'): + try: + label = encodings.idna.nameprep(label) + encodings.idna.ToASCII(label) + except UnicodeError: + raise StringprepError + + if label.startswith('xn--'): + label = encodings.idna.ToUnicode(label) + + for char in label: + if char in ILLEGAL_CHARS: + raise StringprepError + + domain_parts.append(label) + return '.'.join(domain_parts) + +def punycode(domain): + domain_parts = [] + for label in domain.split('.'): + try: + label = encodings.idna.nameprep(label) + encodings.idna.ToASCII(label) + except UnicodeError: + raise StringprepError + + for char in label: + if char in ILLEGAL_CHARS: + raise StringprepError + + domain_parts.append(label) + return b'.'.join(domain_parts) + +logging.getLogger(__name__).warning('Using slower stringprep, consider ' + 'compiling the faster cython/libidn one.') diff --git a/slixmpp/stringprep.pyx b/slixmpp/stringprep.pyx new file mode 100644 index 00000000..e751c8ea --- /dev/null +++ b/slixmpp/stringprep.pyx @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +# cython: language_level = 3 +# distutils: libraries = idn +""" + slixmpp.stringprep + ~~~~~~~~~~~~~~~~~~~~~~~ + + This module wraps libidn’s stringprep and idna functions using Cython. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2015 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> + :license: MIT, see LICENSE for more details +""" + +from libc.stdlib cimport free + + +# Those are Cython declarations for the C function we’ll be using. + +cdef extern from "stringprep.h" nogil: + int stringprep_profile(const char* in_, char** out, const char* profile, + int flags) + +cdef extern from "idna.h" nogil: + int idna_to_ascii_8z(const char* in_, char** out, int flags) + int idna_to_unicode_8z8z(const char* in_, char** out, int flags) + + +class StringprepError(Exception): + pass + + +cdef str _stringprep(str in_, const char* profile): + """Python wrapper for libidn’s stringprep.""" + cdef char* out + ret = stringprep_profile(in_.encode('utf-8'), &out, profile, 0) + if ret != 0: + raise StringprepError(ret) + unicode_out = out.decode('utf-8') + free(out) + return unicode_out + + +def nodeprep(str node): + """The nodeprep profile of stringprep used to validate the local, or + username, portion of a JID.""" + return _stringprep(node, 'Nodeprep') + + +def resourceprep(str resource): + """The resourceprep profile of stringprep, which is used to validate the + resource portion of a JID.""" + return _stringprep(resource, 'Resourceprep') + + +def idna(str domain): + """The idna conversion functions, which are used to validate the domain + portion of a JID.""" + + cdef char* ascii_domain + cdef char* utf8_domain + + ret = idna_to_ascii_8z(domain.encode('utf-8'), &ascii_domain, 0) + if ret != 0: + raise StringprepError(ret) + + ret = idna_to_unicode_8z8z(ascii_domain, &utf8_domain, 0) + free(ascii_domain) + if ret != 0: + raise StringprepError(ret) + + unicode_domain = utf8_domain.decode('utf-8') + free(utf8_domain) + return unicode_domain + + +def punycode(str domain): + """Converts a domain name to its punycode representation.""" + + cdef char* ascii_domain + cdef bytes bytes_domain + + ret = idna_to_ascii_8z(domain.encode('utf-8'), &ascii_domain, 0) + if ret != 0: + raise StringprepError(ret) + bytes_domain = ascii_domain + free(ascii_domain) + return bytes_domain diff --git a/slixmpp/test/__init__.py b/slixmpp/test/__init__.py new file mode 100644 index 00000000..0244afe3 --- /dev/null +++ b/slixmpp/test/__init__.py @@ -0,0 +1,11 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.test.mocksocket import TestSocket, TestTransport +from slixmpp.test.livesocket import TestLiveSocket +from slixmpp.test.slixtest import * diff --git a/slixmpp/test/livesocket.py b/slixmpp/test/livesocket.py new file mode 100644 index 00000000..e7deb617 --- /dev/null +++ b/slixmpp/test/livesocket.py @@ -0,0 +1,171 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import socket +import threading +from queue import 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() + self.send_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/slixmpp/test/mocksocket.py b/slixmpp/test/mocksocket.py new file mode 100644 index 00000000..149df29c --- /dev/null +++ b/slixmpp/test/mocksocket.py @@ -0,0 +1,245 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import socket +from queue import 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() + self.send_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 + +class TestTransport(object): + + """ + A transport 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, xmpp): + self.xmpp = xmpp + self.socket = TestSocket() + # ------------------------------------------------------------------ + # 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. + """ + return self.socket.next_sent() + + def disconnect_error(self): + """ + Simulate a disconnect error by raising a socket.error exception + for any current or further socket operations. + """ + self.socket.disconnect_error() + + # ------------------------------------------------------------------ + # Socket Interface + + def recv(self, *args, **kwargs): + """ + Read a value from the received queue. + + Arguments: + Placeholders. Same as for socket.Socket.recv. + """ + return + + def write(self, data): + """ + Send data by placing it in the send queue. + + Arguments: + data -- String value to write. + """ + self.socket.send(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. + """ + return self.socket.recv(block, timeout, **kwargs) + + def get_extra_info(self, *args, **kwargs): + return self.socket + + def abort(self, *args, **kwargs): + return + + def close(self, *args, **kwargs): + return + diff --git a/slixmpp/test/slixtest.py b/slixmpp/test/slixtest.py new file mode 100644 index 00000000..f66cf6be --- /dev/null +++ b/slixmpp/test/slixtest.py @@ -0,0 +1,710 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import unittest +from queue import Queue +from xml.parsers.expat import ExpatError + +from slixmpp.test import TestTransport +from slixmpp import ClientXMPP, ComponentXMPP +from slixmpp.stanza import Message, Iq, Presence +from slixmpp.xmlstream import ET +from slixmpp.xmlstream import ElementBase +from slixmpp.xmlstream.tostring import tostring, highlight +from slixmpp.xmlstream.matcher import StanzaPath, MatcherId, MatchIDSender +from slixmpp.xmlstream.matcher import MatchXMLMask, MatchXPath + +import asyncio +cls = asyncio.get_event_loop().__class__ + +cls.idle_call = lambda self, callback: callback() + +class SlixTest(unittest.TestCase): + + """ + A Slixmpp 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, ExpatError) as e: + msg = e.msg if hasattr(e, 'msg') else e.message + if 'unbound' in 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 = list(xml)[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, + 'idsender': MatchIDSender, + '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" % highlight(tostring(xml)) + debug += "Given stanza:\n%s\n" % highlight(tostring(stanza.xml)) + debug += "Generated stanza:\n%s\n" % highlight(tostring(stanza2.xml)) + debug += "Second generated stanza:\n%s\n" % highlight(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" % highlight(tostring(xml)) + debug += "Given stanza:\n%s\n" % highlight(tostring(stanza.xml)) + debug += "Generated stanza:\n%s\n" % highlight(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/resource', + 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/resource'. + 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 not plugin_config: + plugin_config = {} + + 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.") + + self.xmpp.connection_made(TestTransport(self.xmpp)) + self.xmpp.session_bind_event.set() + # Remove unique ID prefix to make it easier to test + self.xmpp._id_prefix = '' + self.xmpp.default_lang = None + self.xmpp.peer_default_lang = None + + # Simulate connecting for mock sockets. + self.xmpp.auto_reconnect = False + + # Must have the stream header ready for xmpp.process() to work. + if not header: + header = self.xmpp.stream_header + + self.xmpp.data_received(header) + + if skip: + self.xmpp.socket.next_sent() + if mode == 'component': + self.xmpp.socket.next_sent() + + + if plugins is None: + self.xmpp.register_plugins() + else: + for plugin in plugins: + self.xmpp.register_plugin(plugin) + + # Some plugins require messages to have ID values. Set + # this to True in tests related to those plugins. + self.xmpp.use_message_ids = False + + def make_header(self, sto='', + sfrom='', + sid='', + stream_ns="http://etherx.jabber.org/streams", + default_ns="jabber:client", + default_lang="en", + 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) + if default_lang: + parts.append('xml:lang="%s"' % default_lang) + 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=None, 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. + """ + self.xmpp.data_received(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 list(recv_xml): + # We received more than just the header + for xml in recv_xml: + self.xmpp.data_received(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') + + self.xmpp.socket.data_received(data) + + def send_header(self, sto='', + sfrom='', + sid='', + stream_ns="http://etherx.jabber.org/streams", + default_ns="jabber:client", + default_lang="en", + 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, + default_lang=default_lang, + 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" % ( + highlight(tostring(xml)), highlight(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" % highlight(tostring(xml)) + \ + "Stanza:\n%s" % highlight(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.data_received(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: + 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(list(xml)) != len(list(other)): + 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/slixmpp/thirdparty/__init__.py b/slixmpp/thirdparty/__init__.py new file mode 100644 index 00000000..d950f4f9 --- /dev/null +++ b/slixmpp/thirdparty/__init__.py @@ -0,0 +1,7 @@ +try: + from gnupg import GPG +except: + from slixmpp.thirdparty.gnupg import GPG + +from slixmpp.thirdparty.mini_dateutil import tzutc, tzoffset, parse_iso +from slixmpp.thirdparty.orderedset import OrderedSet diff --git a/slixmpp/thirdparty/gnupg.py b/slixmpp/thirdparty/gnupg.py new file mode 100644 index 00000000..a89289fd --- /dev/null +++ b/slixmpp/thirdparty/gnupg.py @@ -0,0 +1,1017 @@ +""" A wrapper for the 'gpg' command:: + +Portions of this module are derived from A.M. Kuchling's well-designed +GPG.py, using Richard Jones' updated version 1.3, which can be found +in the pycrypto CVS repository on Sourceforge: + +http://pycrypto.cvs.sourceforge.net/viewvc/pycrypto/gpg/GPG.py + +This module is *not* forward-compatible with amk's; some of the +old interface has changed. For instance, since I've added decrypt +functionality, I elected to initialize with a 'gnupghome' argument +instead of 'keyring', so that gpg can find both the public and secret +keyrings. I've also altered some of the returned objects in order for +the caller to not have to know as much about the internals of the +result classes. + +While the rest of ISconf is released under the GPL, I am releasing +this single file under the same terms that A.M. Kuchling used for +pycrypto. + +Steve Traugott, stevegt@terraluna.org +Thu Jun 23 21:27:20 PDT 2005 + +This version of the module has been modified from Steve Traugott's version +(see http://trac.t7a.org/isconf/browser/trunk/lib/python/isconf/GPG.py) by +Vinay Sajip to make use of the subprocess module (Steve's version uses os.fork() +and so does not work on Windows). Renamed to gnupg.py to avoid confusion with +the previous versions. + +Modifications Copyright (C) 2008-2012 Vinay Sajip. All rights reserved. + +A unittest harness (test_gnupg.py) has also been added. +""" +import locale + +__version__ = "0.2.9" +__author__ = "Vinay Sajip" +__date__ = "$29-Mar-2012 21:12:58$" + +try: + from io import StringIO +except ImportError: + from cStringIO import StringIO + +import codecs +import locale +import logging +import os +import socket +from subprocess import Popen +from subprocess import PIPE +import sys +import threading + +try: + import logging.NullHandler as NullHandler +except ImportError: + class NullHandler(logging.Handler): + def handle(self, record): + pass +try: + unicode + _py3k = False +except NameError: + _py3k = True + +logger = logging.getLogger(__name__) +if not logger.handlers: + logger.addHandler(NullHandler()) + +def _copy_data(instream, outstream): + # Copy one stream to another + sent = 0 + if hasattr(sys.stdin, 'encoding'): + enc = sys.stdin.encoding + else: + enc = 'ascii' + while True: + data = instream.read(1024) + if len(data) == 0: + break + sent += len(data) + logger.debug("sending chunk (%d): %r", sent, data[:256]) + try: + outstream.write(data) + except UnicodeError: + outstream.write(data.encode(enc)) + except: + # Can sometimes get 'broken pipe' errors even when the data has all + # been sent + logger.exception('Error sending data') + break + try: + outstream.close() + except IOError: + logger.warning('Exception occurred while closing: ignored', exc_info=1) + logger.debug("closed output, %d bytes sent", sent) + +def _threaded_copy_data(instream, outstream): + wr = threading.Thread(target=_copy_data, args=(instream, outstream)) + wr.setDaemon(True) + logger.debug('data copier: %r, %r, %r', wr, instream, outstream) + wr.start() + return wr + +def _write_passphrase(stream, passphrase, encoding): + passphrase = '%s\n' % passphrase + passphrase = passphrase.encode(encoding) + stream.write(passphrase) + logger.debug("Wrote passphrase: %r", passphrase) + +def _is_sequence(instance): + return isinstance(instance,list) or isinstance(instance,tuple) + +def _make_binary_stream(s, encoding): + try: + if _py3k: + if isinstance(s, str): + s = s.encode(encoding) + else: + if type(s) is not str: + s = s.encode(encoding) + from io import BytesIO + rv = BytesIO(s) + except ImportError: + rv = StringIO(s) + return rv + +class Verify(object): + "Handle status messages for --verify" + + def __init__(self, gpg): + self.gpg = gpg + self.valid = False + self.fingerprint = self.creation_date = self.timestamp = None + self.signature_id = self.key_id = None + self.username = None + + def __nonzero__(self): + return self.valid + + __bool__ = __nonzero__ + + def handle_status(self, key, value): + if key in ("TRUST_UNDEFINED", "TRUST_NEVER", "TRUST_MARGINAL", + "TRUST_FULLY", "TRUST_ULTIMATE", "RSA_OR_IDEA", "NODATA", + "IMPORT_RES", "PLAINTEXT", "PLAINTEXT_LENGTH", + "POLICY_URL", "DECRYPTION_INFO", "DECRYPTION_OKAY", "IMPORTED"): + pass + elif key == "BADSIG": + self.valid = False + self.status = 'signature bad' + self.key_id, self.username = value.split(None, 1) + elif key == "GOODSIG": + self.valid = True + self.status = 'signature good' + self.key_id, self.username = value.split(None, 1) + elif key == "VALIDSIG": + (self.fingerprint, + self.creation_date, + self.sig_timestamp, + self.expire_timestamp) = value.split()[:4] + # may be different if signature is made with a subkey + self.pubkey_fingerprint = value.split()[-1] + self.status = 'signature valid' + elif key == "SIG_ID": + (self.signature_id, + self.creation_date, self.timestamp) = value.split() + elif key == "ERRSIG": + self.valid = False + (self.key_id, + algo, hash_algo, + cls, + self.timestamp) = value.split()[:5] + self.status = 'signature error' + elif key == "DECRYPTION_FAILED": + self.valid = False + self.key_id = value + self.status = 'decryption failed' + elif key == "NO_PUBKEY": + self.valid = False + self.key_id = value + self.status = 'no public key' + elif key in ("KEYEXPIRED", "SIGEXPIRED"): + # these are useless in verify, since they are spit out for any + # pub/subkeys on the key, not just the one doing the signing. + # if we want to check for signatures with expired key, + # the relevant flag is EXPKEYSIG. + pass + elif key in ("EXPKEYSIG", "REVKEYSIG"): + # signed with expired or revoked key + self.valid = False + self.key_id = value.split()[0] + self.status = (('%s %s') % (key[:3], key[3:])).lower() + else: + raise ValueError("Unknown status message: %r" % key) + +class ImportResult(object): + "Handle status messages for --import" + + counts = '''count no_user_id imported imported_rsa unchanged + n_uids n_subk n_sigs n_revoc sec_read sec_imported + sec_dups not_imported'''.split() + def __init__(self, gpg): + self.gpg = gpg + self.imported = [] + self.results = [] + self.fingerprints = [] + for result in self.counts: + setattr(self, result, None) + + def __nonzero__(self): + if self.not_imported: return False + if not self.fingerprints: return False + return True + + __bool__ = __nonzero__ + + ok_reason = { + '0': 'Not actually changed', + '1': 'Entirely new key', + '2': 'New user IDs', + '4': 'New signatures', + '8': 'New subkeys', + '16': 'Contains private key', + } + + problem_reason = { + '0': 'No specific reason given', + '1': 'Invalid Certificate', + '2': 'Issuer Certificate missing', + '3': 'Certificate Chain too long', + '4': 'Error storing certificate', + } + + def handle_status(self, key, value): + if key == "IMPORTED": + # this duplicates info we already see in import_ok & import_problem + pass + elif key == "NODATA": + self.results.append({'fingerprint': None, + 'problem': '0', 'text': 'No valid data found'}) + elif key == "IMPORT_OK": + reason, fingerprint = value.split() + reasons = [] + for code, text in list(self.ok_reason.items()): + if int(reason) | int(code) == int(reason): + reasons.append(text) + reasontext = '\n'.join(reasons) + "\n" + self.results.append({'fingerprint': fingerprint, + 'ok': reason, 'text': reasontext}) + self.fingerprints.append(fingerprint) + elif key == "IMPORT_PROBLEM": + try: + reason, fingerprint = value.split() + except: + reason = value + fingerprint = '<unknown>' + self.results.append({'fingerprint': fingerprint, + 'problem': reason, 'text': self.problem_reason[reason]}) + elif key == "IMPORT_RES": + import_res = value.split() + for i in range(len(self.counts)): + setattr(self, self.counts[i], int(import_res[i])) + elif key == "KEYEXPIRED": + self.results.append({'fingerprint': None, + 'problem': '0', 'text': 'Key expired'}) + elif key == "SIGEXPIRED": + self.results.append({'fingerprint': None, + 'problem': '0', 'text': 'Signature expired'}) + else: + raise ValueError("Unknown status message: %r" % key) + + def summary(self): + l = [] + l.append('%d imported'%self.imported) + if self.not_imported: + l.append('%d not imported'%self.not_imported) + return ', '.join(l) + +class ListKeys(list): + ''' Handle status messages for --list-keys. + + Handle pub and uid (relating the latter to the former). + + Don't care about (info from src/DETAILS): + + crt = X.509 certificate + crs = X.509 certificate and private key available + sub = subkey (secondary key) + ssb = secret subkey (secondary key) + uat = user attribute (same as user id except for field 10). + sig = signature + rev = revocation signature + pkd = public key data (special field format, see below) + grp = reserved for gpgsm + rvk = revocation key + ''' + def __init__(self, gpg): + self.gpg = gpg + self.curkey = None + self.fingerprints = [] + self.uids = [] + + def key(self, args): + vars = (""" + type trust length algo keyid date expires dummy ownertrust uid + """).split() + self.curkey = {} + for i in range(len(vars)): + self.curkey[vars[i]] = args[i] + self.curkey['uids'] = [] + if self.curkey['uid']: + self.curkey['uids'].append(self.curkey['uid']) + del self.curkey['uid'] + self.append(self.curkey) + + pub = sec = key + + def fpr(self, args): + self.curkey['fingerprint'] = args[9] + self.fingerprints.append(args[9]) + + def uid(self, args): + self.curkey['uids'].append(args[9]) + self.uids.append(args[9]) + + def handle_status(self, key, value): + pass + +class Crypt(Verify): + "Handle status messages for --encrypt and --decrypt" + def __init__(self, gpg): + Verify.__init__(self, gpg) + self.data = '' + self.ok = False + self.status = '' + + def __nonzero__(self): + if self.ok: return True + return False + + __bool__ = __nonzero__ + + def __str__(self): + return self.data.decode(self.gpg.encoding, self.gpg.decode_errors) + + def handle_status(self, key, value): + if key in ("ENC_TO", "USERID_HINT", "GOODMDC", "END_DECRYPTION", + "BEGIN_SIGNING", "NO_SECKEY", "ERROR", "NODATA", + "CARDCTRL"): + # in the case of ERROR, this is because a more specific error + # message will have come first + pass + elif key in ("NEED_PASSPHRASE", "BAD_PASSPHRASE", "GOOD_PASSPHRASE", + "MISSING_PASSPHRASE", "DECRYPTION_FAILED", + "KEY_NOT_CREATED"): + self.status = key.replace("_", " ").lower() + elif key == "NEED_PASSPHRASE_SYM": + self.status = 'need symmetric passphrase' + elif key == "BEGIN_DECRYPTION": + self.status = 'decryption incomplete' + elif key == "BEGIN_ENCRYPTION": + self.status = 'encryption incomplete' + elif key == "DECRYPTION_OKAY": + self.status = 'decryption ok' + self.ok = True + elif key == "END_ENCRYPTION": + self.status = 'encryption ok' + self.ok = True + elif key == "INV_RECP": + self.status = 'invalid recipient' + elif key == "KEYEXPIRED": + self.status = 'key expired' + elif key == "SIG_CREATED": + self.status = 'sig created' + elif key == "SIGEXPIRED": + self.status = 'sig expired' + else: + Verify.handle_status(self, key, value) + +class GenKey(object): + "Handle status messages for --gen-key" + def __init__(self, gpg): + self.gpg = gpg + self.type = None + self.fingerprint = None + + def __nonzero__(self): + if self.fingerprint: return True + return False + + __bool__ = __nonzero__ + + def __str__(self): + return self.fingerprint or '' + + def handle_status(self, key, value): + if key in ("PROGRESS", "GOOD_PASSPHRASE", "NODATA"): + pass + elif key == "KEY_CREATED": + (self.type,self.fingerprint) = value.split() + else: + raise ValueError("Unknown status message: %r" % key) + +class DeleteResult(object): + "Handle status messages for --delete-key and --delete-secret-key" + def __init__(self, gpg): + self.gpg = gpg + self.status = 'ok' + + def __str__(self): + return self.status + + problem_reason = { + '1': 'No such key', + '2': 'Must delete secret key first', + '3': 'Ambigious specification', + } + + def handle_status(self, key, value): + if key == "DELETE_PROBLEM": + self.status = self.problem_reason.get(value, + "Unknown error: %r" % value) + else: + raise ValueError("Unknown status message: %r" % key) + +class Sign(object): + "Handle status messages for --sign" + def __init__(self, gpg): + self.gpg = gpg + self.type = None + self.fingerprint = None + + def __nonzero__(self): + return self.fingerprint is not None + + __bool__ = __nonzero__ + + def __str__(self): + return self.data.decode(self.gpg.encoding, self.gpg.decode_errors) + + def handle_status(self, key, value): + if key in ("USERID_HINT", "NEED_PASSPHRASE", "BAD_PASSPHRASE", + "GOOD_PASSPHRASE", "BEGIN_SIGNING", "CARDCTRL"): + pass + elif key == "SIG_CREATED": + (self.type, + algo, hashalgo, cls, + self.timestamp, self.fingerprint + ) = value.split() + else: + raise ValueError("Unknown status message: %r" % key) + + +class GPG(object): + + decode_errors = 'strict' + + result_map = { + 'crypt': Crypt, + 'delete': DeleteResult, + 'generate': GenKey, + 'import': ImportResult, + 'list': ListKeys, + 'sign': Sign, + 'verify': Verify, + } + + "Encapsulate access to the gpg executable" + def __init__(self, gpgbinary='gpg', gnupghome=None, verbose=False, + use_agent=False, keyring=None): + """Initialize a GPG process wrapper. Options are: + + gpgbinary -- full pathname for GPG binary. + + gnupghome -- full pathname to where we can find the public and + private keyrings. Default is whatever gpg defaults to. + keyring -- name of alternative keyring file to use. If specified, + the default keyring is not used. + """ + self.gpgbinary = gpgbinary + self.gnupghome = gnupghome + self.keyring = keyring + self.verbose = verbose + self.use_agent = use_agent + self.encoding = locale.getpreferredencoding() + if self.encoding is None: # This happens on Jython! + self.encoding = sys.stdin.encoding + if gnupghome and not os.path.isdir(self.gnupghome): + os.makedirs(self.gnupghome,0x1C0) + p = self._open_subprocess(["--version"]) + result = self.result_map['verify'](self) # any result will do for this + self._collect_output(p, result, stdin=p.stdin) + if p.returncode != 0: + raise ValueError("Error invoking gpg: %s: %s" % (p.returncode, + result.stderr)) + + def _open_subprocess(self, args, passphrase=False): + # Internal method: open a pipe to a GPG subprocess and return + # the file objects for communicating with it. + cmd = [self.gpgbinary, '--status-fd 2 --no-tty'] + if self.gnupghome: + cmd.append('--homedir "%s" ' % self.gnupghome) + if self.keyring: + cmd.append('--no-default-keyring --keyring "%s" ' % self.keyring) + if passphrase: + cmd.append('--batch --passphrase-fd 0') + if self.use_agent: + cmd.append('--use-agent') + cmd.extend(args) + cmd = ' '.join(cmd) + if self.verbose: + print(cmd) + logger.debug("%s", cmd) + return Popen(cmd, shell=True, stdin=PIPE, stdout=PIPE, stderr=PIPE) + + def _read_response(self, stream, result): + # Internal method: reads all the stderr output from GPG, taking notice + # only of lines that begin with the magic [GNUPG:] prefix. + # + # Calls methods on the response object for each valid token found, + # with the arg being the remainder of the status line. + lines = [] + while True: + line = stream.readline() + if len(line) == 0: + break + lines.append(line) + line = line.rstrip() + if self.verbose: + print(line) + logger.debug("%s", line) + if line[0:9] == '[GNUPG:] ': + # Chop off the prefix + line = line[9:] + L = line.split(None, 1) + keyword = L[0] + if len(L) > 1: + value = L[1] + else: + value = "" + result.handle_status(keyword, value) + result.stderr = ''.join(lines) + + def _read_data(self, stream, result): + # Read the contents of the file from GPG's stdout + chunks = [] + while True: + data = stream.read(1024) + if len(data) == 0: + break + logger.debug("chunk: %r" % data[:256]) + chunks.append(data) + if _py3k: + # Join using b'' or '', as appropriate + result.data = type(data)().join(chunks) + else: + result.data = ''.join(chunks) + + def _collect_output(self, process, result, writer=None, stdin=None): + """ + Drain the subprocesses output streams, writing the collected output + to the result. If a writer thread (writing to the subprocess) is given, + make sure it's joined before returning. If a stdin stream is given, + close it before returning. + """ + stderr = codecs.getreader(self.encoding)(process.stderr) + rr = threading.Thread(target=self._read_response, args=(stderr, result)) + rr.setDaemon(True) + logger.debug('stderr reader: %r', rr) + rr.start() + + stdout = process.stdout + dr = threading.Thread(target=self._read_data, args=(stdout, result)) + dr.setDaemon(True) + logger.debug('stdout reader: %r', dr) + dr.start() + + dr.join() + rr.join() + if writer is not None: + writer.join() + process.wait() + if stdin is not None: + try: + stdin.close() + except IOError: + pass + stderr.close() + stdout.close() + + def _handle_io(self, args, file, result, passphrase=None, binary=False): + "Handle a call to GPG - pass input data, collect output data" + # Handle a basic data call - pass data to GPG, handle the output + # including status information. Garbage In, Garbage Out :) + p = self._open_subprocess(args, passphrase is not None) + if not binary: + stdin = codecs.getwriter(self.encoding)(p.stdin) + else: + stdin = p.stdin + if passphrase: + _write_passphrase(stdin, passphrase, self.encoding) + writer = _threaded_copy_data(file, stdin) + self._collect_output(p, result, writer, stdin) + return result + + # + # SIGNATURE METHODS + # + def sign(self, message, **kwargs): + """sign message""" + f = _make_binary_stream(message, self.encoding) + result = self.sign_file(f, **kwargs) + f.close() + return result + + def sign_file(self, file, keyid=None, passphrase=None, clearsign=True, + detach=False, binary=False): + """sign file""" + logger.debug("sign_file: %s", file) + if binary: + args = ['-s'] + else: + args = ['-sa'] + # You can't specify detach-sign and clearsign together: gpg ignores + # the detach-sign in that case. + if detach: + args.append("--detach-sign") + elif clearsign: + args.append("--clearsign") + if keyid: + args.append('--default-key "%s"' % keyid) + args.extend(['--no-version', "--comment ''"]) + result = self.result_map['sign'](self) + #We could use _handle_io here except for the fact that if the + #passphrase is bad, gpg bails and you can't write the message. + p = self._open_subprocess(args, passphrase is not None) + try: + stdin = p.stdin + if passphrase: + _write_passphrase(stdin, passphrase, self.encoding) + writer = _threaded_copy_data(file, stdin) + except IOError: + logging.exception("error writing message") + writer = None + self._collect_output(p, result, writer, stdin) + return result + + def verify(self, data): + """Verify the signature on the contents of the string 'data' + + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input(Passphrase='foo') + >>> key = gpg.gen_key(input) + >>> assert key + >>> sig = gpg.sign('hello',keyid=key.fingerprint,passphrase='bar') + >>> assert not sig + >>> sig = gpg.sign('hello',keyid=key.fingerprint,passphrase='foo') + >>> assert sig + >>> verify = gpg.verify(sig.data) + >>> assert verify + + """ + f = _make_binary_stream(data, self.encoding) + result = self.verify_file(f) + f.close() + return result + + def verify_file(self, file, data_filename=None): + "Verify the signature on the contents of the file-like object 'file'" + logger.debug('verify_file: %r, %r', file, data_filename) + result = self.result_map['verify'](self) + args = ['--verify'] + if data_filename is None: + self._handle_io(args, file, result, binary=True) + else: + logger.debug('Handling detached verification') + import tempfile + fd, fn = tempfile.mkstemp(prefix='pygpg') + s = file.read() + file.close() + logger.debug('Wrote to temp file: %r', s) + os.write(fd, s) + os.close(fd) + args.append(fn) + args.append('"%s"' % data_filename) + try: + p = self._open_subprocess(args) + self._collect_output(p, result, stdin=p.stdin) + finally: + os.unlink(fn) + return result + + # + # KEY MANAGEMENT + # + + def import_keys(self, key_data): + """ import the key_data into our keyring + + >>> import shutil + >>> shutil.rmtree("keys") + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input() + >>> result = gpg.gen_key(input) + >>> print1 = result.fingerprint + >>> result = gpg.gen_key(input) + >>> print2 = result.fingerprint + >>> pubkey1 = gpg.export_keys(print1) + >>> seckey1 = gpg.export_keys(print1,secret=True) + >>> seckeys = gpg.list_keys(secret=True) + >>> pubkeys = gpg.list_keys() + >>> assert print1 in seckeys.fingerprints + >>> assert print1 in pubkeys.fingerprints + >>> str(gpg.delete_keys(print1)) + 'Must delete secret key first' + >>> str(gpg.delete_keys(print1,secret=True)) + 'ok' + >>> str(gpg.delete_keys(print1)) + 'ok' + >>> str(gpg.delete_keys("nosuchkey")) + 'No such key' + >>> seckeys = gpg.list_keys(secret=True) + >>> pubkeys = gpg.list_keys() + >>> assert not print1 in seckeys.fingerprints + >>> assert not print1 in pubkeys.fingerprints + >>> result = gpg.import_keys('foo') + >>> assert not result + >>> result = gpg.import_keys(pubkey1) + >>> pubkeys = gpg.list_keys() + >>> seckeys = gpg.list_keys(secret=True) + >>> assert not print1 in seckeys.fingerprints + >>> assert print1 in pubkeys.fingerprints + >>> result = gpg.import_keys(seckey1) + >>> assert result + >>> seckeys = gpg.list_keys(secret=True) + >>> pubkeys = gpg.list_keys() + >>> assert print1 in seckeys.fingerprints + >>> assert print1 in pubkeys.fingerprints + >>> assert print2 in pubkeys.fingerprints + + """ + result = self.result_map['import'](self) + logger.debug('import_keys: %r', key_data[:256]) + data = _make_binary_stream(key_data, self.encoding) + self._handle_io(['--import'], data, result, binary=True) + logger.debug('import_keys result: %r', result.__dict__) + data.close() + return result + + def recv_keys(self, keyserver, *keyids): + """Import a key from a keyserver + + >>> import shutil + >>> shutil.rmtree("keys") + >>> gpg = GPG(gnupghome="keys") + >>> result = gpg.recv_keys('pgp.mit.edu', '3FF0DB166A7476EA') + >>> assert result + + """ + result = self.result_map['import'](self) + logger.debug('recv_keys: %r', keyids) + data = _make_binary_stream("", self.encoding) + #data = "" + args = ['--keyserver', keyserver, '--recv-keys'] + args.extend(keyids) + self._handle_io(args, data, result, binary=True) + logger.debug('recv_keys result: %r', result.__dict__) + data.close() + return result + + def delete_keys(self, fingerprints, secret=False): + which='key' + if secret: + which='secret-key' + if _is_sequence(fingerprints): + fingerprints = ' '.join(fingerprints) + args = ['--batch --delete-%s "%s"' % (which, fingerprints)] + result = self.result_map['delete'](self) + p = self._open_subprocess(args) + self._collect_output(p, result, stdin=p.stdin) + return result + + def export_keys(self, keyids, secret=False): + "export the indicated keys. 'keyid' is anything gpg accepts" + which='' + if secret: + which='-secret-key' + if _is_sequence(keyids): + keyids = ' '.join(['"%s"' % k for k in keyids]) + args = ["--armor --export%s %s" % (which, keyids)] + p = self._open_subprocess(args) + # gpg --export produces no status-fd output; stdout will be + # empty in case of failure + #stdout, stderr = p.communicate() + result = self.result_map['delete'](self) # any result will do + self._collect_output(p, result, stdin=p.stdin) + logger.debug('export_keys result: %r', result.data) + return result.data.decode(self.encoding, self.decode_errors) + + def list_keys(self, secret=False): + """ list the keys currently in the keyring + + >>> import shutil + >>> shutil.rmtree("keys") + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input() + >>> result = gpg.gen_key(input) + >>> print1 = result.fingerprint + >>> result = gpg.gen_key(input) + >>> print2 = result.fingerprint + >>> pubkeys = gpg.list_keys() + >>> assert print1 in pubkeys.fingerprints + >>> assert print2 in pubkeys.fingerprints + + """ + + which='keys' + if secret: + which='secret-keys' + args = "--list-%s --fixed-list-mode --fingerprint --with-colons" % (which,) + args = [args] + p = self._open_subprocess(args) + + # there might be some status thingumy here I should handle... (amk) + # ...nope, unless you care about expired sigs or keys (stevegt) + + # Get the response information + result = self.result_map['list'](self) + self._collect_output(p, result, stdin=p.stdin) + lines = result.data.decode(self.encoding, + self.decode_errors).splitlines() + valid_keywords = 'pub uid sec fpr'.split() + for line in lines: + if self.verbose: + print(line) + logger.debug("line: %r", line.rstrip()) + if not line: + break + L = line.strip().split(':') + if not L: + continue + keyword = L[0] + if keyword in valid_keywords: + getattr(result, keyword)(L) + return result + + def gen_key(self, input): + """Generate a key; you might use gen_key_input() to create the + control input. + + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input() + >>> result = gpg.gen_key(input) + >>> assert result + >>> result = gpg.gen_key('foo') + >>> assert not result + + """ + args = ["--gen-key --batch"] + result = self.result_map['generate'](self) + f = _make_binary_stream(input, self.encoding) + self._handle_io(args, f, result, binary=True) + f.close() + return result + + def gen_key_input(self, **kwargs): + """ + Generate --gen-key input per gpg doc/DETAILS + """ + parms = {} + for key, val in list(kwargs.items()): + key = key.replace('_','-').title() + parms[key] = val + parms.setdefault('Key-Type','RSA') + parms.setdefault('Key-Length',1024) + parms.setdefault('Name-Real', "Autogenerated Key") + parms.setdefault('Name-Comment', "Generated by gnupg.py") + try: + logname = os.environ['LOGNAME'] + except KeyError: + logname = os.environ['USERNAME'] + hostname = socket.gethostname() + parms.setdefault('Name-Email', "%s@%s" % (logname.replace(' ', '_'), + hostname)) + out = "Key-Type: %s\n" % parms.pop('Key-Type') + for key, val in list(parms.items()): + out += "%s: %s\n" % (key, val) + out += "%commit\n" + return out + + # Key-Type: RSA + # Key-Length: 1024 + # Name-Real: ISdlink Server on %s + # Name-Comment: Created by %s + # Name-Email: isdlink@%s + # Expire-Date: 0 + # %commit + # + # + # Key-Type: DSA + # Key-Length: 1024 + # Subkey-Type: ELG-E + # Subkey-Length: 1024 + # Name-Real: Joe Tester + # Name-Comment: with stupid passphrase + # Name-Email: joe@foo.bar + # Expire-Date: 0 + # Passphrase: abc + # %pubring foo.pub + # %secring foo.sec + # %commit + + # + # ENCRYPTION + # + def encrypt_file(self, file, recipients, sign=None, + always_trust=False, passphrase=None, + armor=True, output=None, symmetric=False): + "Encrypt the message read from the file-like object 'file'" + args = ['--no-version', "--comment ''"] + if symmetric: + args.append('--symmetric') + else: + args.append('--encrypt') + if not _is_sequence(recipients): + recipients = (recipients,) + for recipient in recipients: + args.append('--recipient "%s"' % recipient) + if armor: # create ascii-armored output - set to False for binary output + args.append('--armor') + if output: # write the output to a file with the specified name + if os.path.exists(output): + os.remove(output) # to avoid overwrite confirmation message + args.append('--output "%s"' % output) + if sign: + args.append('--sign --default-key "%s"' % sign) + if always_trust: + args.append("--always-trust") + result = self.result_map['crypt'](self) + self._handle_io(args, file, result, passphrase=passphrase, binary=True) + logger.debug('encrypt result: %r', result.data) + return result + + def encrypt(self, data, recipients, **kwargs): + """Encrypt the message contained in the string 'data' + + >>> import shutil + >>> if os.path.exists("keys"): + ... shutil.rmtree("keys") + >>> gpg = GPG(gnupghome="keys") + >>> input = gpg.gen_key_input(passphrase='foo') + >>> result = gpg.gen_key(input) + >>> print1 = result.fingerprint + >>> input = gpg.gen_key_input() + >>> result = gpg.gen_key(input) + >>> print2 = result.fingerprint + >>> result = gpg.encrypt("hello",print2) + >>> message = str(result) + >>> assert message != 'hello' + >>> result = gpg.decrypt(message) + >>> assert result + >>> str(result) + 'hello' + >>> result = gpg.encrypt("hello again",print1) + >>> message = str(result) + >>> result = gpg.decrypt(message) + >>> result.status == 'need passphrase' + True + >>> result = gpg.decrypt(message,passphrase='bar') + >>> result.status in ('decryption failed', 'bad passphrase') + True + >>> assert not result + >>> result = gpg.decrypt(message,passphrase='foo') + >>> result.status == 'decryption ok' + True + >>> str(result) + 'hello again' + >>> result = gpg.encrypt("signed hello",print2,sign=print1) + >>> result.status == 'need passphrase' + True + >>> result = gpg.encrypt("signed hello",print2,sign=print1,passphrase='foo') + >>> result.status == 'encryption ok' + True + >>> message = str(result) + >>> result = gpg.decrypt(message) + >>> result.status == 'decryption ok' + True + >>> assert result.fingerprint == print1 + + """ + data = _make_binary_stream(data, self.encoding) + result = self.encrypt_file(data, recipients, **kwargs) + data.close() + return result + + def decrypt(self, message, **kwargs): + data = _make_binary_stream(message, self.encoding) + result = self.decrypt_file(data, **kwargs) + data.close() + return result + + def decrypt_file(self, file, always_trust=False, passphrase=None, + output=None): + args = ["--decrypt"] + if output: # write the output to a file with the specified name + if os.path.exists(output): + os.remove(output) # to avoid overwrite confirmation message + args.append('--output "%s"' % output) + if always_trust: + args.append("--always-trust") + result = self.result_map['crypt'](self) + self._handle_io(args, file, result, passphrase, binary=True) + logger.debug('decrypt result: %r', result.data) + return result + diff --git a/slixmpp/thirdparty/mini_dateutil.py b/slixmpp/thirdparty/mini_dateutil.py new file mode 100644 index 00000000..e751a448 --- /dev/null +++ b/slixmpp/thirdparty/mini_dateutil.py @@ -0,0 +1,273 @@ +# 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 math +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(minutes=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(name,offsetmins) + _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})? + + (?P<time> + (?: # 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 ['time', 'ymdsep', 'hmssep', 'tzempty']: + vals[key] = int(vals[key]) + + year = vals['year'] + month = vals['month'] + day = vals['day'] + + if m.group('time') is None: + return datetime.date(year, month, 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/slixmpp/thirdparty/orderedset.py b/slixmpp/thirdparty/orderedset.py new file mode 100644 index 00000000..f6642db3 --- /dev/null +++ b/slixmpp/thirdparty/orderedset.py @@ -0,0 +1,89 @@ +# 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. + +import collections + +class OrderedSet(collections.MutableSet): + + def __init__(self, iterable=None): + self.end = end = [] + end += [None, end, end] # sentinel node for doubly linked list + self.map = {} # key --> [key, prev, next] + if iterable is not None: + self |= iterable + + def __len__(self): + return len(self.map) + + def __contains__(self, key): + return key in self.map + + def add(self, key): + if key not in self.map: + end = self.end + curr = end[1] + curr[2] = end[1] = self.map[key] = [key, curr, end] + + def discard(self, key): + if key in self.map: + 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 pop(self, last=True): + if not self: + raise KeyError('set is empty') + key = self.end[1][0] if last else self.end[2][0] + self.discard(key) + return key + + def __repr__(self): + if not self: + return '%s()' % (self.__class__.__name__,) + return '%s(%r)' % (self.__class__.__name__, list(self)) + + def __eq__(self, other): + if isinstance(other, OrderedSet): + return len(self) == len(other) and list(self) == list(other) + return set(self) == set(other) + + +if __name__ == '__main__': + s = OrderedSet('abracadaba') + t = OrderedSet('simsalabim') + print(s | t) + print(s & t) + print(s - t)
\ No newline at end of file diff --git a/slixmpp/util/__init__.py b/slixmpp/util/__init__.py new file mode 100644 index 00000000..8f70b2bb --- /dev/null +++ b/slixmpp/util/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.util + ~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2012 Nathanael C. Fritz, Lance J.T. Stout + :license: MIT, see LICENSE for more details +""" + + +from slixmpp.util.misc_ops import bytes, unicode, hashes, hash, \ + num_to_bytes, bytes_to_num, quote, \ + XOR diff --git a/slixmpp/util/misc_ops.py b/slixmpp/util/misc_ops.py new file mode 100644 index 00000000..2e661045 --- /dev/null +++ b/slixmpp/util/misc_ops.py @@ -0,0 +1,143 @@ +import sys +import hashlib + + +def unicode(text): + if not isinstance(text, str): + return text.decode('utf-8') + else: + return text + + +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 text is None: + return b'' + + 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): + 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 + + +def setdefaultencoding(encoding): + """ + Set the current default string encoding used by the Unicode implementation. + + Actually calls sys.setdefaultencoding under the hood - see the docs for that + for more details. This method exists only as a way to call find/call it + even after it has been 'deleted' when the site module is executed. + + :param string encoding: An encoding name, compatible with sys.setdefaultencoding + """ + func = getattr(sys, 'setdefaultencoding', None) + if func is None: + import gc + import types + for obj in gc.get_objects(): + if (isinstance(obj, types.BuiltinFunctionType) + and obj.__name__ == 'setdefaultencoding'): + func = obj + break + if func is None: + raise RuntimeError("Could not find setdefaultencoding") + sys.setdefaultencoding = func + return func(encoding) diff --git a/slixmpp/util/sasl/__init__.py b/slixmpp/util/sasl/__init__.py new file mode 100644 index 00000000..0e7e7fbd --- /dev/null +++ b/slixmpp/util/sasl/__init__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.util.sasl + ~~~~~~~~~~~~~~~~~~~ + + This module was originally based on Dave Cridland's Suelta library. + + Part of Slixmpp: The Slick XMPP Library + + :copryight: (c) 2004-2013 David Alan Cridland + :copyright: (c) 2013 Nathanael C. Fritz, Lance J.T. Stout + + :license: MIT, see LICENSE for more details +""" + +from slixmpp.util.sasl.client import * +from slixmpp.util.sasl.mechanisms import * diff --git a/slixmpp/util/sasl/client.py b/slixmpp/util/sasl/client.py new file mode 100644 index 00000000..d5daf4be --- /dev/null +++ b/slixmpp/util/sasl/client.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.util.sasl.client + ~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module was originally based on Dave Cridland's Suelta library. + + Part of Slixmpp: The Slick XMPP Library + + :copryight: (c) 2004-2013 David Alan Cridland + :copyright: (c) 2013 Nathanael C. Fritz, Lance J.T. Stout + + :license: MIT, see LICENSE for more details +""" + +import logging +import stringprep + +from slixmpp.util import hashes, bytes, stringprep_profiles + + +log = logging.getLogger(__name__) + + +#: Global registry mapping mechanism names to implementation classes. +MECHANISMS = {} + + +#: Global registry mapping mechanism names to security scores. +MECH_SEC_SCORES = {} + + +#: The SASLprep profile of stringprep used to validate simple username +#: and password credentials. +saslprep = stringprep_profiles.create( + nfkc=True, + bidi=True, + mappings=[ + stringprep_profiles.b1_mapping, + stringprep_profiles.c12_mapping], + prohibited=[ + stringprep.in_table_c12, + stringprep.in_table_c21, + stringprep.in_table_c22, + stringprep.in_table_c3, + stringprep.in_table_c4, + stringprep.in_table_c5, + stringprep.in_table_c6, + stringprep.in_table_c7, + stringprep.in_table_c8, + stringprep.in_table_c9], + unassigned=[stringprep.in_table_a1]) + + +def sasl_mech(score): + sec_score = score + def register(mech): + n = 0 + mech.score = sec_score + if mech.use_hashes: + for hashing_alg in hashes(): + n += 1 + score = mech.score + n + name = '%s-%s' % (mech.name, hashing_alg) + MECHANISMS[name] = mech + MECH_SEC_SCORES[name] = score + + if mech.channel_binding: + name += '-PLUS' + score += 10 + MECHANISMS[name] = mech + MECH_SEC_SCORES[name] = score + else: + MECHANISMS[mech.name] = mech + MECH_SEC_SCORES[mech.name] = mech.score + if mech.channel_binding: + MECHANISMS[mech.name + '-PLUS'] = mech + MECH_SEC_SCORES[name] = mech.score + 10 + return mech + return register + + +class SASLNoAppropriateMechanism(Exception): + def __init__(self, value=''): + self.message = value + + +class SASLCancelled(Exception): + def __init__(self, value=''): + self.message = value + + +class SASLFailed(Exception): + def __init__(self, value=''): + self.message = value + + +class SASLMutualAuthFailed(SASLFailed): + def __init__(self, value=''): + self.message = value + + +class Mech(object): + + name = 'GENERIC' + score = -1 + use_hashes = False + channel_binding = False + required_credentials = set() + optional_credentials = set() + security = set() + + def __init__(self, name, credentials, security_settings): + self.credentials = credentials + self.security_settings = security_settings + self.values = {} + self.base_name = self.name + self.name = name + self.setup(name) + + def setup(self, name): + pass + + def process(self, challenge=b''): + return b'' + + +def choose(mech_list, credentials, security_settings, limit=None, min_mech=None): + available_mechs = set(MECHANISMS.keys()) + if limit is None: + limit = set(mech_list) + if not isinstance(limit, set): + limit = set(limit) + if not isinstance(mech_list, set): + mech_list = set(mech_list) + + mech_list = mech_list.intersection(limit) + available_mechs = available_mechs.intersection(mech_list) + + best_score = MECH_SEC_SCORES.get(min_mech, -1) + best_mech = None + for name in available_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 None: + raise SASLNoAppropriateMechanism() + + mech_class = MECHANISMS[best_mech] + + try: + creds = credentials(mech_class.required_credentials, + mech_class.optional_credentials) + for req in mech_class.required_credentials: + if req not in creds: + raise SASLCancelled('Missing credential: %s' % req) + for opt in mech_class.optional_credentials: + if opt not in creds: + creds[opt] = b'' + for cred in creds: + if cred in ('username', 'password', 'authzid'): + creds[cred] = bytes(saslprep(creds[cred])) + else: + creds[cred] = bytes(creds[cred]) + security_opts = security_settings(mech_class.security) + + return mech_class(best_mech, creds, security_opts) + except SASLCancelled as e: + log.info('SASL: %s: %s', best_mech, e.message) + mech_list.remove(best_mech) + return choose(mech_list, credentials, security_settings, + limit=limit, + min_mech=min_mech) diff --git a/slixmpp/util/sasl/mechanisms.py b/slixmpp/util/sasl/mechanisms.py new file mode 100644 index 00000000..de0203c0 --- /dev/null +++ b/slixmpp/util/sasl/mechanisms.py @@ -0,0 +1,548 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.util.sasl.mechanisms + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + A collection of supported SASL mechanisms. + + This module was originally based on Dave Cridland's Suelta library. + + Part of Slixmpp: The Slick XMPP Library + + :copryight: (c) 2004-2013 David Alan Cridland + :copyright: (c) 2013 Nathanael C. Fritz, Lance J.T. Stout + + :license: MIT, see LICENSE for more details +""" + +import hmac +import random + +from base64 import b64encode, b64decode + +from slixmpp.util import bytes, hash, XOR, quote, num_to_bytes +from slixmpp.util.sasl.client import sasl_mech, Mech, \ + SASLCancelled, SASLFailed, \ + SASLMutualAuthFailed + + +@sasl_mech(0) +class ANONYMOUS(Mech): + + name = 'ANONYMOUS' + + def process(self, challenge=b''): + return b'Anonymous, Suelta' + + +@sasl_mech(1) +class LOGIN(Mech): + + name = 'LOGIN' + required_credentials = set(['username', 'password']) + + def setup(self, name): + self.step = 0 + + def process(self, challenge=b''): + if not challenge: + return b'' + + if self.step == 0: + self.step = 1 + return self.credentials['username'] + else: + return self.credentials['password'] + + +@sasl_mech(2) +class PLAIN(Mech): + + name = 'PLAIN' + required_credentials = set(['username', 'password']) + optional_credentials = set(['authzid']) + security = set(['encrypted', 'encrypted_plain', 'unencrypted_plain']) + + def setup(self, name): + if not self.security_settings['encrypted']: + if not self.security_settings['unencrypted_plain']: + raise SASLCancelled('PLAIN without encryption') + else: + if not self.security_settings['encrypted_plain']: + raise SASLCancelled('PLAIN with encryption') + + def process(self, challenge=b''): + authzid = self.credentials['authzid'] + authcid = self.credentials['username'] + password = self.credentials['password'] + return authzid + b'\x00' + authcid + b'\x00' + password + + +@sasl_mech(100) +class EXTERNAL(Mech): + + name = 'EXTERNAL' + optional_credentials = set(['authzid']) + + def process(self, challenge=b''): + return self.credentials['authzid'] + + +@sasl_mech(31) +class X_FACEBOOK_PLATFORM(Mech): + + name = 'X-FACEBOOK-PLATFORM' + required_credentials = set(['api_key', 'access_token']) + + def process(self, challenge=b''): + if challenge: + values = {} + for kv in challenge.split(b'&'): + key, value = kv.split(b'=') + values[key] = value + + resp_data = { + b'method': values[b'method'], + b'v': b'1.0', + b'call_id': b'1.0', + b'nonce': values[b'nonce'], + b'access_token': self.credentials['access_token'], + b'api_key': self.credentials['api_key'] + } + + resp = '&'.join(['%s=%s' % (k.decode("utf-8"), v.decode("utf-8")) for k, v in resp_data.items()]) + return bytes(resp) + return b'' + + +@sasl_mech(10) +class X_MESSENGER_OAUTH2(Mech): + + name = 'X-MESSENGER-OAUTH2' + required_credentials = set(['access_token']) + + def process(self, challenge=b''): + return self.credentials['access_token'] + + +@sasl_mech(10) +class X_OAUTH2(Mech): + + name = 'X-OAUTH2' + required_credentials = set(['username', 'access_token']) + + def process(self, challenge=b''): + return b'\x00' + self.credentials['username'] + \ + b'\x00' + self.credentials['access_token'] + + +@sasl_mech(3) +class X_GOOGLE_TOKEN(Mech): + + name = 'X-GOOGLE-TOKEN' + required_credentials = set(['email', 'access_token']) + + def process(self, challenge=b''): + email = self.credentials['email'] + token = self.credentials['access_token'] + return b'\x00' + email + b'\x00' + token + + +@sasl_mech(20) +class CRAM(Mech): + + name = 'CRAM' + use_hashes = True + required_credentials = set(['username', 'password']) + security = set(['encrypted', 'unencrypted_cram']) + + def setup(self, name): + self.hash_name = name[5:] + self.hash = hash(self.hash_name) + if self.hash is None: + raise SASLCancelled('Unknown hash: %s' % self.hash_name) + if not self.security_settings['encrypted']: + if not self.security_settings['unencrypted_cram']: + raise SASLCancelled('Unecrypted CRAM-%s' % self.hash_name) + + def process(self, challenge=b''): + if not challenge: + return None + + username = self.credentials['username'] + password = self.credentials['password'] + + mac = hmac.HMAC(key=password, digestmod=self.hash) + mac.update(challenge) + + return username + b' ' + bytes(mac.hexdigest()) + + +@sasl_mech(60) +class SCRAM(Mech): + + name = 'SCRAM' + use_hashes = True + channel_binding = True + required_credentials = set(['username', 'password']) + optional_credentials = set(['authzid', 'channel_binding']) + security = set(['encrypted', 'unencrypted_scram']) + + def setup(self, name): + self.use_channel_binding = False + if name[-5:] == '-PLUS': + name = name[:-5] + self.use_channel_binding = True + + self.hash_name = name[6:] + self.hash = hash(self.hash_name) + + if self.hash is None: + raise SASLCancelled('Unknown hash: %s' % self.hash_name) + if not self.security_settings['encrypted']: + if not self.security_settings['unencrypted_scram']: + raise SASLCancelled('Unencrypted SCRAM') + + self.step = 0 + self._mutual_auth = 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) + ui1 = self.HMAC(text, salt + b'\0\0\0\01') + ui = ui1 + for i in range(iterations - 1): + ui1 = self.HMAC(text, ui1) + ui = XOR(ui, ui1) + return ui + + def H(self, text): + return self.hash(text).digest() + + def saslname(self, value): + value = value.decode("utf-8") + escaped = [] + for char in value: + if char == ',': + escaped += b'=2C' + elif char == '=': + escaped += b'=3D' + else: + escaped += char + return "".join(escaped).encode("utf-8") + + def parse(self, challenge): + items = {} + for key, value in [item.split(b'=', 1) for item in challenge.split(b',')]: + items[key] = value + return items + + def process(self, challenge=b''): + steps = [self.process_1, self.process_2, self.process_3] + return steps[self.step](challenge) + + def process_1(self, challenge): + self.step = 1 + data = {} + + self.cnonce = bytes(('%s' % random.random())[2:]) + + gs2_cbind_flag = b'n' + if self.credentials['channel_binding']: + if self.use_channel_binding: + gs2_cbind_flag = b'p=tls-unique' + else: + gs2_cbind_flag = b'y' + + authzid = b'' + if self.credentials['authzid']: + authzid = b'a=' + self.saslname(self.credentials['authzid']) + + self.gs2_header = gs2_cbind_flag + b',' + authzid + b',' + + nonce = b'r=' + self.cnonce + username = b'n=' + self.saslname(self.credentials['username']) + + self.client_first_message_bare = username + b',' + nonce + self.client_first_message = self.gs2_header + \ + self.client_first_message_bare + + return self.client_first_message + + def process_2(self, challenge): + self.step = 2 + + data = self.parse(challenge) + if b'm' in data: + raise SASLCancelled('Received reserved attribute.') + + salt = b64decode(data[b's']) + iteration_count = int(data[b'i']) + nonce = data[b'r'] + + if nonce[:len(self.cnonce)] != self.cnonce: + raise SASLCancelled('Invalid nonce') + + cbind_data = b'' + if self.use_channel_binding: + cbind_data = self.credentials['channel_binding'] + cbind_input = self.gs2_header + cbind_data + channel_binding = b'c=' + b64encode(cbind_input).replace(b'\n', b'') + + client_final_message_without_proof = channel_binding + b',' + \ + b'r=' + nonce + + salted_password = self.Hi(self.credentials['password'], + salt, + iteration_count) + client_key = self.HMAC(salted_password, b'Client Key') + stored_key = self.H(client_key) + auth_message = self.client_first_message_bare + b',' + \ + challenge + b',' + \ + client_final_message_without_proof + client_signature = self.HMAC(stored_key, auth_message) + client_proof = XOR(client_key, client_signature) + server_key = self.HMAC(salted_password, b'Server Key') + + self.server_signature = self.HMAC(server_key, auth_message) + + client_final_message = client_final_message_without_proof + \ + b',p=' + b64encode(client_proof) + + return client_final_message + + def process_3(self, challenge): + data = self.parse(challenge) + verifier = data.get(b'v', None) + error = data.get(b'e', 'Unknown error') + + if not verifier: + raise SASLFailed(error) + + if b64decode(verifier) != self.server_signature: + raise SASLMutualAuthFailed() + + self._mutual_auth = True + + return b'' + + +@sasl_mech(30) +class DIGEST(Mech): + + name = 'DIGEST' + use_hashes = True + required_credentials = set(['username', 'password', 'realm', 'service', 'host']) + optional_credentials = set(['authzid', 'service-name']) + security = set(['encrypted', 'unencrypted_digest']) + + def setup(self, name): + self.hash_name = name[7:] + self.hash = hash(self.hash_name) + if self.hash is None: + raise SASLCancelled('Unknown hash: %s' % self.hash_name) + if not self.security_settings['encrypted']: + if not self.security_settings['unencrypted_digest']: + raise SASLCancelled('Unencrypted DIGEST') + + self.qops = [b'auth'] + self.qop = b'auth' + self.maxbuf = b'65536' + self.nonce = b'' + self.cnonce = b'' + self.nonce_count = 1 + + def parse(self, challenge=b''): + data = {} + var_name = b'' + var_value = b'' + + # States: var, new_var, end, quote, escaped_quote + state = 'var' + + + for char in challenge: + char = bytes([char]) + + if state == 'var': + if char.isspace(): + continue + if char == b'=': + state = 'value' + else: + var_name += char + elif state == 'value': + if char == b'"': + state = 'quote' + elif char == b',': + if var_name: + data[var_name.decode('utf-8')] = var_value + var_name = b'' + var_value = b'' + state = 'var' + else: + var_value += char + elif state == 'escaped': + var_value += char + elif state == 'quote': + if char == b'\\': + state = 'escaped' + elif char == b'"': + state = 'end' + else: + var_value += char + else: + if char == b',': + if var_name: + data[var_name.decode('utf-8')] = var_value + var_name = b'' + var_value = b'' + state = 'var' + else: + var_value += char + + if var_name: + data[var_name.decode('utf-8')] = var_value + var_name = b'' + var_value = b'' + state = 'var' + return data + + def MAC(self, key, seq, msg): + 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 A1(self): + username = self.credentials['username'] + password = self.credentials['password'] + authzid = self.credentials['authzid'] + realm = self.credentials['realm'] + + a1 = self.hash() + a1.update(username + b':' + realm + b':' + password) + a1 = a1.digest() + a1 += b':' + self.nonce + b':' + self.cnonce + if authzid: + a1 += b':' + authzid + + return bytes(a1) + + def A2(self, prefix=b''): + a2 = prefix + b':' + self.digest_uri() + if self.qop in (b'auth-int', b'auth-conf'): + a2 += b':00000000000000000000000000000000' + return bytes(a2) + + def response(self, prefix=b''): + nc = bytes('%08x' % self.nonce_count) + + a1 = bytes(self.hash(self.A1()).hexdigest().lower()) + a2 = bytes(self.hash(self.A2(prefix)).hexdigest().lower()) + s = self.nonce + b':' + nc + b':' + self.cnonce + \ + b':' + self.qop + b':' + a2 + + return bytes(self.hash(a1 + b':' + s).hexdigest().lower()) + + def digest_uri(self): + serv_type = self.credentials['service'] + serv_name = self.credentials['service-name'] + host = self.credentials['host'] + + uri = serv_type + b'/' + host + if serv_name and host != serv_name: + uri += b'/' + serv_name + return uri + + def respond(self): + data = { + 'username': quote(self.credentials['username']), + 'authzid': quote(self.credentials['authzid']), + 'realm': quote(self.credentials['realm']), + 'nonce': quote(self.nonce), + 'cnonce': quote(self.cnonce), + 'nc': bytes('%08x' % self.nonce_count), + 'qop': self.qop, + 'digest-uri': quote(self.digest_uri()), + 'response': self.response(b'AUTHENTICATE'), + 'maxbuf': self.maxbuf, + 'charset': 'utf-8' + } + resp = b'' + for key, value in data.items(): + if value and value != b'""': + resp += b',' + bytes(key) + b'=' + bytes(value) + return resp[1:] + + def process(self, challenge=b''): + if not challenge: + if self.cnonce and self.nonce and self.nonce_count and self.qop: + self.nonce_count += 1 + return self.respond() + return None + + data = self.parse(challenge) + if 'rspauth' in data: + if data['rspauth'] != self.response(): + raise SASLMutualAuthFailed() + else: + self.nonce_count = 1 + self.cnonce = bytes('%s' % random.random())[2:] + self.qops = data.get('qop', [b'auth']) + self.qop = b'auth' + if 'nonce' in data: + self.nonce = data['nonce'] + if 'realm' in data and not self.credentials['realm']: + self.credentials['realm'] = data['realm'] + + return self.respond() + + +try: + import kerberos +except ImportError: + pass +else: + @sasl_mech(75) + class GSSAPI(Mech): + + name = 'GSSAPI' + required_credentials = set(['username', 'service-name']) + optional_credentials = set(['authzid']) + + def setup(self, name): + authzid = self.credentials['authzid'] + if not authzid: + authzid = 'xmpp@%s' % self.credentials['service-name'] + + _, self.gss = kerberos.authGSSClientInit(authzid) + self.step = 0 + + def process(self, challenge=b''): + b64_challenge = b64encode(challenge) + try: + if self.step == 0: + result = kerberos.authGSSClientStep(self.gss, b64_challenge) + if result != kerberos.AUTH_GSS_CONTINUE: + self.step = 1 + elif not challenge: + kerberos.authGSSClientClean(self.gss) + return b'' + elif self.step == 1: + username = self.credentials['username'] + + kerberos.authGSSClientUnwrap(self.gss, b64_challenge) + resp = kerberos.authGSSClientResponse(self.gss) + kerberos.authGSSClientWrap(self.gss, resp, username) + + resp = kerberos.authGSSClientResponse(self.gss) + except kerberos.GSSError as e: + raise SASLCancelled('Kerberos error: %s' % e) + if not resp: + return b'' + else: + return b64decode(resp) diff --git a/slixmpp/util/stringprep_profiles.py b/slixmpp/util/stringprep_profiles.py new file mode 100644 index 00000000..5fb0b4b7 --- /dev/null +++ b/slixmpp/util/stringprep_profiles.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.util.stringprep_profiles + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module makes it easier to define profiles of stringprep, + such as nodeprep and resourceprep for JID validation, and + SASLprep for SASL. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2012 Nathanael C. Fritz, Lance J.T. Stout + :license: MIT, see LICENSE for more details +""" + + +from __future__ import unicode_literals + +import stringprep +from unicodedata import ucd_3_2_0 as unicodedata + +from slixmpp.util import unicode + + +class StringPrepError(UnicodeError): + pass + + +def b1_mapping(char): + """Map characters that are commonly mapped to nothing.""" + return '' if stringprep.in_table_b1(char) else None + + +def c12_mapping(char): + """Map non-ASCII whitespace to spaces.""" + return ' ' if stringprep.in_table_c12(char) else None + + +def map_input(data, tables=None): + """ + Each character in the input stream MUST be checked against + a mapping table. + """ + result = [] + for char in data: + replacement = None + + for mapping in tables: + replacement = mapping(char) + if replacement is not None: + break + + if replacement is None: + replacement = char + result.append(replacement) + return ''.join(result) + + +def normalize(data, nfkc=True): + """ + A profile can specify one of two options for Unicode normalization: + - no normalization + - Unicode normalization with form KC + """ + if nfkc: + data = unicodedata.normalize('NFKC', data) + return data + + +def prohibit_output(data, tables=None): + """ + Before the text can be emitted, it MUST be checked for prohibited + code points. + """ + for char in data: + for check in tables: + if check(char): + raise StringPrepError("Prohibited code point: %s" % char) + + +def check_bidi(data): + """ + 1) The characters in section 5.8 MUST be prohibited. + + 2) If a string contains any RandALCat character, the string MUST NOT + contain any LCat character. + + 3) If a string contains any RandALCat character, a RandALCat + character MUST be the first character of the string, and a + RandALCat character MUST be the last character of the string. + """ + if not data: + return data + + has_lcat = False + has_randal = False + + for c in data: + if stringprep.in_table_c8(c): + raise StringPrepError("BIDI violation: seciton 6 (1)") + if stringprep.in_table_d1(c): + has_randal = True + elif stringprep.in_table_d2(c): + has_lcat = True + + if has_randal and has_lcat: + raise StringPrepError("BIDI violation: section 6 (2)") + + first_randal = stringprep.in_table_d1(data[0]) + last_randal = stringprep.in_table_d1(data[-1]) + if has_randal and not (first_randal and last_randal): + raise StringPrepError("BIDI violation: section 6 (3)") + + +def create(nfkc=True, bidi=True, mappings=None, + prohibited=None, unassigned=None): + """Create a profile of stringprep. + + :param bool nfkc: + If `True`, perform NFKC Unicode normalization. Defaults to `True`. + :param bool bidi: + If `True`, perform bidirectional text checks. Defaults to `True`. + :param list mappings: + Optional list of functions for mapping characters to + suitable replacements. + :param list prohibited: + Optional list of functions which check for the presence of + prohibited characters. + :param list unassigned: + Optional list of functions for detecting the use of unassigned + code points. + + :raises: StringPrepError + :return: Unicode string of the resulting text passing the + profile's requirements. + """ + def profile(data, query=False): + try: + data = unicode(data) + except UnicodeError: + raise StringPrepError + + data = map_input(data, mappings) + data = normalize(data, nfkc) + prohibit_output(data, prohibited) + if bidi: + check_bidi(data) + if query and unassigned: + check_unassigned(data, unassigned) + return data + return profile diff --git a/slixmpp/version.py b/slixmpp/version.py new file mode 100644 index 00000000..98a20603 --- /dev/null +++ b/slixmpp/version.py @@ -0,0 +1,13 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + 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) diff --git a/slixmpp/xmlstream/__init__.py b/slixmpp/xmlstream/__init__.py new file mode 100644 index 00000000..b5302292 --- /dev/null +++ b/slixmpp/xmlstream/__init__.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.jid import JID +from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase, ET +from slixmpp.xmlstream.stanzabase import register_stanza_plugin +from slixmpp.xmlstream.tostring import tostring, highlight +from slixmpp.xmlstream.xmlstream import XMLStream, RESPONSE_TIMEOUT + +__all__ = ['JID', 'StanzaBase', 'ElementBase', + 'ET', 'StateMachine', 'tostring', 'highlight', 'XMLStream', + 'RESPONSE_TIMEOUT'] diff --git a/slixmpp/xmlstream/asyncio.py b/slixmpp/xmlstream/asyncio.py new file mode 100644 index 00000000..0e0f610a --- /dev/null +++ b/slixmpp/xmlstream/asyncio.py @@ -0,0 +1,50 @@ +""" +A module that monkey patches the standard asyncio module to add an +idle_call() method to the main loop. This method is used to execute a +callback whenever the loop is not busy handling anything else. This means +that it is a callback with lower priority than IO, timer, or even +call_soon() ones. These callback are called only once each. +""" + +import asyncio +from asyncio import events +from functools import wraps + +import collections + +def idle_call(self, callback): + if asyncio.iscoroutinefunction(callback): + raise TypeError("coroutines cannot be used with idle_call()") + handle = events.Handle(callback, [], self) + self._idle.append(handle) + +def my_run_once(self): + if self._idle: + self._ready.append(events.Handle(lambda: None, (), self)) + real_run_once(self) + if self._idle: + handle = self._idle.popleft() + handle._run() + +cls = asyncio.get_event_loop().__class__ + +cls._idle = collections.deque() +cls.idle_call = idle_call +real_run_once = cls._run_once +cls._run_once = my_run_once + +def future_wrapper(func): + """ + Make sure the result of a function call is an asyncio.Future() + object. + """ + @wraps(func) + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + if isinstance(result, asyncio.Future): + return result + future = asyncio.Future() + future.set_result(result) + return future + + return wrapper diff --git a/slixmpp/xmlstream/cert.py b/slixmpp/xmlstream/cert.py new file mode 100644 index 00000000..d357b326 --- /dev/null +++ b/slixmpp/xmlstream/cert.py @@ -0,0 +1,184 @@ +import logging +from datetime import datetime, timedelta + +# Make a call to strptime before starting threads to +# prevent thread safety issues. +datetime.strptime('1970-01-01 12:00:00', "%Y-%m-%d %H:%M:%S") + + +try: + from pyasn1.codec.der import decoder, encoder + from pyasn1.type.univ import Any, ObjectIdentifier, OctetString + from pyasn1.type.char import BMPString, IA5String, UTF8String + from pyasn1.type.useful import GeneralizedTime + from pyasn1_modules.rfc2459 import (Certificate, DirectoryString, + SubjectAltName, GeneralNames, + GeneralName) + from pyasn1_modules.rfc2459 import id_ce_subjectAltName as SUBJECT_ALT_NAME + from pyasn1_modules.rfc2459 import id_at_commonName as COMMON_NAME + + XMPP_ADDR = ObjectIdentifier('1.3.6.1.5.5.7.8.5') + SRV_NAME = ObjectIdentifier('1.3.6.1.5.5.7.8.7') + + HAVE_PYASN1 = True +except ImportError: + HAVE_PYASN1 = False + + +log = logging.getLogger(__name__) + + +class CertificateError(Exception): + pass + + +def decode_str(data): + encoding = 'utf-16-be' if isinstance(data, BMPString) else 'utf-8' + return bytes(data).decode(encoding) + + +def extract_names(raw_cert): + results = {'CN': set(), + 'DNS': set(), + 'SRV': set(), + 'URI': set(), + 'XMPPAddr': set()} + + cert = decoder.decode(raw_cert, asn1Spec=Certificate())[0] + tbs = cert.getComponentByName('tbsCertificate') + subject = tbs.getComponentByName('subject') + extensions = tbs.getComponentByName('extensions') or [] + + # Extract the CommonName(s) from the cert. + for rdnss in subject: + for rdns in rdnss: + for name in rdns: + oid = name.getComponentByName('type') + value = name.getComponentByName('value') + + if oid != COMMON_NAME: + continue + + value = decoder.decode(value, asn1Spec=DirectoryString())[0] + value = decode_str(value.getComponent()) + results['CN'].add(value) + + # Extract the Subject Alternate Names (DNS, SRV, URI, XMPPAddr) + for extension in extensions: + oid = extension.getComponentByName('extnID') + if oid != SUBJECT_ALT_NAME: + continue + + value = decoder.decode(extension.getComponentByName('extnValue'), + asn1Spec=OctetString())[0] + sa_names = decoder.decode(value, asn1Spec=SubjectAltName())[0] + for name in sa_names: + name_type = name.getName() + if name_type == 'dNSName': + results['DNS'].add(decode_str(name.getComponent())) + if name_type == 'uniformResourceIdentifier': + value = decode_str(name.getComponent()) + if value.startswith('xmpp:'): + results['URI'].add(value[5:]) + elif name_type == 'otherName': + name = name.getComponent() + + oid = name.getComponentByName('type-id') + value = name.getComponentByName('value') + + if oid == XMPP_ADDR: + value = decoder.decode(value, asn1Spec=UTF8String())[0] + results['XMPPAddr'].add(decode_str(value)) + elif oid == SRV_NAME: + value = decoder.decode(value, asn1Spec=IA5String())[0] + results['SRV'].add(decode_str(value)) + + return results + + +def extract_dates(raw_cert): + if not HAVE_PYASN1: + log.warning("Could not find pyasn1 and pyasn1_modules. " + \ + "SSL certificate expiration COULD NOT BE VERIFIED.") + return None, None + + cert = decoder.decode(raw_cert, asn1Spec=Certificate())[0] + tbs = cert.getComponentByName('tbsCertificate') + validity = tbs.getComponentByName('validity') + + not_before = validity.getComponentByName('notBefore') + not_before = str(not_before.getComponent()) + + not_after = validity.getComponentByName('notAfter') + not_after = str(not_after.getComponent()) + + if isinstance(not_before, GeneralizedTime): + not_before = datetime.strptime(not_before, '%Y%m%d%H%M%SZ') + else: + not_before = datetime.strptime(not_before, '%y%m%d%H%M%SZ') + + if isinstance(not_after, GeneralizedTime): + not_after = datetime.strptime(not_after, '%Y%m%d%H%M%SZ') + else: + not_after = datetime.strptime(not_after, '%y%m%d%H%M%SZ') + + return not_before, not_after + + +def get_ttl(raw_cert): + not_before, not_after = extract_dates(raw_cert) + if not_after is None: + return None + return not_after - datetime.utcnow() + + +def verify(expected, raw_cert): + if not HAVE_PYASN1: + log.warning("Could not find pyasn1 and pyasn1_modules. " + \ + "SSL certificate COULD NOT BE VERIFIED.") + return + + not_before, not_after = extract_dates(raw_cert) + cert_names = extract_names(raw_cert) + + now = datetime.utcnow() + + if not_before > now: + raise CertificateError( + 'Certificate has not entered its valid date range.') + + if not_after <= now: + raise CertificateError( + 'Certificate has expired.') + + if '.' in expected: + expected_wild = expected[expected.index('.'):] + else: + expected_wild = expected + expected_srv = '_xmpp-client.%s' % expected + + for name in cert_names['XMPPAddr']: + if name == expected: + return True + for name in cert_names['SRV']: + if name == expected_srv or name == expected: + return True + for name in cert_names['DNS']: + if name == expected: + return True + if name.startswith('*'): + if '.' in name: + name_wild = name[name.index('.'):] + else: + name_wild = name + if expected_wild == name_wild: + return True + for name in cert_names['URI']: + if name == expected: + return True + for name in cert_names['CN']: + if name == expected: + return True + + raise CertificateError( + 'Could not match certificate against hostname: %s' % expected) diff --git a/slixmpp/xmlstream/handler/__init__.py b/slixmpp/xmlstream/handler/__init__.py new file mode 100644 index 00000000..51a7ca6a --- /dev/null +++ b/slixmpp/xmlstream/handler/__init__.py @@ -0,0 +1,16 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream.handler.callback import Callback +from slixmpp.xmlstream.handler.coroutine_callback import CoroutineCallback +from slixmpp.xmlstream.handler.collector import Collector +from slixmpp.xmlstream.handler.waiter import Waiter +from slixmpp.xmlstream.handler.xmlcallback import XMLCallback +from slixmpp.xmlstream.handler.xmlwaiter import XMLWaiter + +__all__ = ['Callback', 'CoroutineCallback', 'Waiter', 'XMLCallback', 'XMLWaiter'] diff --git a/slixmpp/xmlstream/handler/base.py b/slixmpp/xmlstream/handler/base.py new file mode 100644 index 00000000..b6bff096 --- /dev/null +++ b/slixmpp/xmlstream/handler/base.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.handler.base + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick 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:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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 diff --git a/slixmpp/xmlstream/handler/callback.py b/slixmpp/xmlstream/handler/callback.py new file mode 100644 index 00000000..4cb329af --- /dev/null +++ b/slixmpp/xmlstream/handler/callback.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.handler.callback + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from slixmpp.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 Slixmpp + object's :meth:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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:`~slixmpp.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/slixmpp/xmlstream/handler/collector.py b/slixmpp/xmlstream/handler/collector.py new file mode 100644 index 00000000..d9e20279 --- /dev/null +++ b/slixmpp/xmlstream/handler/collector.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.handler.collector + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2012 Nathanael C. Fritz, Lance J.T. Stout + :license: MIT, see LICENSE for more details +""" + +import logging +from queue import Queue, Empty + +from slixmpp.xmlstream.handler.base import BaseHandler + + +log = logging.getLogger(__name__) + + +class Collector(BaseHandler): + + """ + The Collector handler allows for collecting a set of stanzas + that match a given pattern. Unlike the Waiter handler, a + Collector does not block execution, and will continue to + accumulate matching stanzas until told to stop. + + :param string name: The name of the handler. + :param matcher: A :class:`~slixmpp.xmlstream.matcher.base.MatcherBase` + derived object for matching stanza objects. + :param stream: The :class:`~slixmpp.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() + + def prerun(self, payload): + """Store the matched stanza when received during processing. + + :param payload: The matched + :class:`~slixmpp.xmlstream.stanzabase.ElementBase` object. + """ + self._payload.put(payload) + + def run(self, payload): + """Do not process this handler during the main event loop.""" + pass + + def stop(self): + """ + Stop collection of matching stanzas, and return the ones that + have been stored so far. + """ + self._destroy = True + results = [] + try: + while True: + results.append(self._payload.get(False)) + except Empty: + pass + + self.stream().remove_handler(self.name) + return results diff --git a/slixmpp/xmlstream/handler/coroutine_callback.py b/slixmpp/xmlstream/handler/coroutine_callback.py new file mode 100644 index 00000000..8ad9572e --- /dev/null +++ b/slixmpp/xmlstream/handler/coroutine_callback.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.handler.callback + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from slixmpp.xmlstream.handler.base import BaseHandler +from slixmpp.xmlstream.asyncio import asyncio + + +class CoroutineCallback(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. + + The event will be scheduled to be run soon in the event loop instead + of immediately. + + :param string name: The name of the handler. + :param matcher: A :class:`~slixmpp.xmlstream.matcher.base.MatcherBase` + derived object for matching stanza objects. + :param pointer: The function to execute during callback. If ``pointer`` + is not a coroutine, this function will raise a ValueError. + :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:`~slixmpp.xmlstream.xmlstream.XMLStream` + instance this handler should monitor. + """ + + def __init__(self, name, matcher, pointer, once=False, + instream=False, stream=None): + BaseHandler.__init__(self, name, matcher, stream) + if not asyncio.iscoroutinefunction(pointer): + raise ValueError("Given function is not a coroutine") + + @asyncio.coroutine + def pointer_wrapper(stanza, *args, **kwargs): + try: + yield from pointer(stanza, *args, **kwargs) + except Exception as e: + stanza.exception(e) + + self._pointer = pointer_wrapper + 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:`~slixmpp.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:`~slixmpp.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: + asyncio.async(self._pointer(payload)) + if self._once: + self._destroy = True + del self._pointer diff --git a/slixmpp/xmlstream/handler/waiter.py b/slixmpp/xmlstream/handler/waiter.py new file mode 100644 index 00000000..c25063db --- /dev/null +++ b/slixmpp/xmlstream/handler/waiter.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.handler.waiter + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import logging +from queue import Queue, Empty + +from slixmpp.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:`~slixmpp.xmlstream.matcher.base.MatcherBase` + derived object for matching stanza objects. + :param stream: The :class:`~slixmpp.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() + + def prerun(self, payload): + """Store the matched stanza when received during processing. + + :param payload: The matched + :class:`~slixmpp.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:`~slixmpp.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 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/slixmpp/xmlstream/handler/xmlcallback.py b/slixmpp/xmlstream/handler/xmlcallback.py new file mode 100644 index 00000000..60ccbaed --- /dev/null +++ b/slixmpp/xmlstream/handler/xmlcallback.py @@ -0,0 +1,36 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/xmlstream/handler/xmlwaiter.py b/slixmpp/xmlstream/handler/xmlwaiter.py new file mode 100644 index 00000000..dc014da0 --- /dev/null +++ b/slixmpp/xmlstream/handler/xmlwaiter.py @@ -0,0 +1,33 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/xmlstream/matcher/__init__.py b/slixmpp/xmlstream/matcher/__init__.py new file mode 100644 index 00000000..47487d4a --- /dev/null +++ b/slixmpp/xmlstream/matcher/__init__.py @@ -0,0 +1,17 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.xmlstream.matcher.id import MatcherId +from slixmpp.xmlstream.matcher.idsender import MatchIDSender +from slixmpp.xmlstream.matcher.many import MatchMany +from slixmpp.xmlstream.matcher.stanzapath import StanzaPath +from slixmpp.xmlstream.matcher.xmlmask import MatchXMLMask +from slixmpp.xmlstream.matcher.xpath import MatchXPath + +__all__ = ['MatcherId', 'MatchMany', 'StanzaPath', + 'MatchXMLMask', 'MatchXPath'] diff --git a/slixmpp/xmlstream/matcher/base.py b/slixmpp/xmlstream/matcher/base.py new file mode 100644 index 00000000..4f15c63d --- /dev/null +++ b/slixmpp/xmlstream/matcher/base.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.matcher.base + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick 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/slixmpp/xmlstream/matcher/id.py b/slixmpp/xmlstream/matcher/id.py new file mode 100644 index 00000000..ddef75dc --- /dev/null +++ b/slixmpp/xmlstream/matcher/id.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.matcher.id + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from slixmpp.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:`~slixmpp.xmlstream.stanzabase.ElementBase` + stanza to compare against. + """ + return xml['id'] == self._criteria diff --git a/slixmpp/xmlstream/matcher/idsender.py b/slixmpp/xmlstream/matcher/idsender.py new file mode 100644 index 00000000..79f73911 --- /dev/null +++ b/slixmpp/xmlstream/matcher/idsender.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.matcher.id + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from slixmpp.xmlstream.matcher.base import MatcherBase + + +class MatchIDSender(MatcherBase): + + """ + The IDSender matcher selects stanzas that have the same stanza 'id' + interface value as the desired ID, and that the 'from' value is one + of a set of approved entities that can respond to a request. + """ + + def match(self, xml): + """Compare the given stanza's ``'id'`` attribute to the stored + ``id`` value, and verify the sender's JID. + + :param xml: The :class:`~slixmpp.xmlstream.stanzabase.ElementBase` + stanza to compare against. + """ + + selfjid = self._criteria['self'] + peerjid = self._criteria['peer'] + + allowed = {} + allowed[''] = True + allowed[selfjid.bare] = True + allowed[selfjid.host] = True + allowed[peerjid.full] = True + allowed[peerjid.bare] = True + allowed[peerjid.host] = True + + _from = xml['from'] + + try: + return xml['id'] == self._criteria['id'] and allowed[_from] + except KeyError: + return False diff --git a/slixmpp/xmlstream/matcher/many.py b/slixmpp/xmlstream/matcher/many.py new file mode 100644 index 00000000..ef6a64d3 --- /dev/null +++ b/slixmpp/xmlstream/matcher/many.py @@ -0,0 +1,40 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +from slixmpp.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/slixmpp/xmlstream/matcher/stanzapath.py b/slixmpp/xmlstream/matcher/stanzapath.py new file mode 100644 index 00000000..c9f245e1 --- /dev/null +++ b/slixmpp/xmlstream/matcher/stanzapath.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.matcher.stanzapath + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from slixmpp.xmlstream.matcher.base import MatcherBase +from slixmpp.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:`~slixmpp.xmlstream.stanzabase.ElementBase.match()` method + for more information. + + :param stanza: The :class:`~slixmpp.xmlstream.stanzabase.ElementBase` + stanza to compare against. + """ + return stanza.match(self._criteria) or stanza.match(self._raw_criteria) diff --git a/slixmpp/xmlstream/matcher/xmlmask.py b/slixmpp/xmlstream/matcher/xmlmask.py new file mode 100644 index 00000000..7e26abe2 --- /dev/null +++ b/slixmpp/xmlstream/matcher/xmlmask.py @@ -0,0 +1,117 @@ +""" + Slixmpp: The Slick XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of Slixmpp. + + See the file LICENSE for copying permission. +""" + +import logging + +from xml.parsers.expat import ExpatError + +from slixmpp.xmlstream.stanzabase import ET +from slixmpp.xmlstream.matcher.base import MatcherBase + + +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:`~slixmpp.xmlstream.matcher.xpath.MatchXPath` or + :class:`~slixmpp.xmlstream.matcher.stanzapath.StanzaPath` + should be used instead. + + :param criteria: Either an :class:`~xml.etree.ElementTree.Element` XML + object or XML string to use as a mask. + """ + + def __init__(self, criteria, default_ns='jabber:client'): + MatcherBase.__init__(self, criteria) + if isinstance(criteria, str): + self._criteria = ET.fromstring(self._criteria) + self.default_ns = default_ns + + 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__"``. + """ + 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) + + 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: + 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 + + # Everything matches. + return True diff --git a/slixmpp/xmlstream/matcher/xpath.py b/slixmpp/xmlstream/matcher/xpath.py new file mode 100644 index 00000000..31ab1b8c --- /dev/null +++ b/slixmpp/xmlstream/matcher/xpath.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.matcher.xpath + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from slixmpp.xmlstream.stanzabase import ET, fix_ns +from slixmpp.xmlstream.matcher.base import MatcherBase + + +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:`~slixmpp.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 __init__(self, criteria): + self._criteria = fix_ns(criteria) + + 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:`~slixmpp.xmlstream.stanzabase.ElementBase` + stanza to compare against. + """ + if hasattr(xml, 'xml'): + xml = xml.xml + x = ET.Element('x') + x.append(xml) + + return x.find(self._criteria) is not None diff --git a/slixmpp/xmlstream/resolver.py b/slixmpp/xmlstream/resolver.py new file mode 100644 index 00000000..778f7dc3 --- /dev/null +++ b/slixmpp/xmlstream/resolver.py @@ -0,0 +1,314 @@ +# -*- encoding: utf-8 -*- + +""" + slixmpp.xmlstream.dns + ~~~~~~~~~~~~~~~~~~~~~~~ + + :copyright: (c) 2012 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from slixmpp.xmlstream.asyncio import asyncio +import socket +import logging +import random + + +log = logging.getLogger(__name__) + + +#: Global flag indicating the availability of the ``aiodns`` package. +#: Installing ``aiodns`` can be done via: +#: +#: .. code-block:: sh +#: +#: pip install aiodns +AIODNS_AVAILABLE = False +try: + import aiodns + AIODNS_AVAILABLE = True +except ImportError as e: + log.debug("Could not find aiodns package. " + \ + "Not all features will be available") + + +def default_resolver(loop): + """Return a basic DNS resolver object. + + :returns: A :class:`aiodns.DNSResolver` object if aiodns + is available. Otherwise, ``None``. + """ + if AIODNS_AVAILABLE: + return aiodns.DNSResolver(loop=loop, + tries=1, + timeout=1.0) + return None + + +@asyncio.coroutine +def resolve(host, port=None, service=None, proto='tcp', + resolver=None, use_ipv6=True, use_aiodns=True, loop=None): + """Peform DNS resolution for a given hostname. + + Resolution may perform SRV record lookups if a service and protocol + are specified. The returned addresses will be sorted according to + the SRV priorities and weights. + + If no resolver is provided, the aiodns resolver will be used if + available. Otherwise the built-in socket facilities will be used, + but those do not provide SRV support. + + If SRV records were used, queries to resolve alternative hosts will + be made as needed instead of all at once. + + :param host: The hostname to resolve. + :param port: A default port to connect with. SRV records may + dictate use of a different port. + :param service: Optional SRV service name without leading underscore. + :param proto: Optional SRV protocol name without leading underscore. + :param resolver: Optionally provide a DNS resolver object that has + been custom configured. + :param use_ipv6: Optionally control the use of IPv6 in situations + where it is either not available, or performance + is degraded. Defaults to ``True``. + :param use_aiodns: Optionally control if aiodns is used to make + the DNS queries instead of the built-in DNS + library. + + :type host: string + :type port: int + :type service: string + :type proto: string + :type resolver: :class:`aiodns.DNSResolver` + :type use_ipv6: bool + :type use_aiodns: bool + + :return: An iterable of IP address, port pairs in the order + dictated by SRV priorities and weights, if applicable. + """ + + if not use_aiodns: + if AIODNS_AVAILABLE: + log.debug("DNS: Not using aiodns, but aiodns is installed.") + else: + log.debug("DNS: Not using aiodns.") + + if not use_ipv6: + log.debug("DNS: Use of IPv6 has been disabled.") + + if resolver is None and AIODNS_AVAILABLE and use_aiodns: + resolver = aiodns.DNSResolver(loop=loop) + + # An IPv6 literal is allowed to be enclosed in square brackets, but + # the brackets must be stripped in order to process the literal; + # otherwise, things break. + host = host.strip('[]') + + try: + # If `host` is an IPv4 literal, we can return it immediately. + ipv4 = socket.inet_aton(host) + return [(host, host, port)] + except socket.error: + pass + + if use_ipv6: + try: + # Likewise, If `host` is an IPv6 literal, we can return + # it immediately. + if hasattr(socket, 'inet_pton'): + ipv6 = socket.inet_pton(socket.AF_INET6, host) + return [(host, host, port)] + except (socket.error, ValueError): + pass + + # If no service was provided, then we can just do A/AAAA lookups on the + # provided host. Otherwise we need to get an ordered list of hosts to + # resolve based on SRV records. + if not service: + hosts = [(host, port)] + else: + hosts = yield from get_SRV(host, port, service, proto, + resolver=resolver, + use_aiodns=use_aiodns) + if not hosts: + hosts = [(host, port)] + + results = [] + for host, port in hosts: + if host == 'localhost': + if use_ipv6: + results.append((host, '::1', port)) + results.append((host, '127.0.0.1', port)) + + if use_ipv6: + aaaa = yield from get_AAAA(host, resolver=resolver, + use_aiodns=use_aiodns, loop=loop) + for address in aaaa: + results.append((host, address, port)) + + a = yield from get_A(host, resolver=resolver, + use_aiodns=use_aiodns, loop=loop) + for address in a: + results.append((host, address, port)) + + return results + +@asyncio.coroutine +def get_A(host, resolver=None, use_aiodns=True, loop=None): + """Lookup DNS A records for a given host. + + If ``resolver`` is not provided, or is ``None``, then resolution will + be performed using the built-in :mod:`socket` module. + + :param host: The hostname to resolve for A record IPv4 addresses. + :param resolver: Optional DNS resolver object to use for the query. + :param use_aiodns: Optionally control if aiodns is used to make + the DNS queries instead of the built-in DNS + library. + + :type host: string + :type resolver: :class:`aiodns.DNSResolver` or ``None`` + :type use_aiodns: bool + + :return: A list of IPv4 literals. + """ + log.debug("DNS: Querying %s for A records." % host) + + # If not using aiodns, attempt lookup using the OS level + # getaddrinfo() method. + if resolver is None or not use_aiodns: + try: + recs = yield from loop.getaddrinfo(host, None, + family=socket.AF_INET, + type=socket.SOCK_STREAM) + return [rec[4][0] for rec in recs] + except socket.gaierror: + log.debug("DNS: Error retrieving A address info for %s." % host) + return [] + + # Using aiodns: + future = resolver.query(host, 'A') + try: + recs = yield from future + except Exception as e: + log.debug('DNS: Exception while querying for %s A records: %s', host, e) + recs = [] + return [rec.host for rec in recs] + + +@asyncio.coroutine +def get_AAAA(host, resolver=None, use_aiodns=True, loop=None): + """Lookup DNS AAAA records for a given host. + + If ``resolver`` is not provided, or is ``None``, then resolution will + be performed using the built-in :mod:`socket` module. + + :param host: The hostname to resolve for AAAA record IPv6 addresses. + :param resolver: Optional DNS resolver object to use for the query. + :param use_aiodns: Optionally control if aiodns is used to make + the DNS queries instead of the built-in DNS + library. + + :type host: string + :type resolver: :class:`aiodns.DNSResolver` or ``None`` + :type use_aiodns: bool + + :return: A list of IPv6 literals. + """ + log.debug("DNS: Querying %s for AAAA records." % host) + + # If not using aiodns, attempt lookup using the OS level + # getaddrinfo() method. + if resolver is None or not use_aiodns: + if not socket.has_ipv6: + log.debug("DNS: Unable to query %s for AAAA records: IPv6 is not supported", host) + return [] + try: + recs = yield from loop.getaddrinfo(host, None, + family=socket.AF_INET6, + type=socket.SOCK_STREAM) + return [rec[4][0] for rec in recs] + except (OSError, socket.gaierror): + log.debug("DNS: Error retreiving AAAA address " + \ + "info for %s." % host) + return [] + + # Using aiodns: + future = resolver.query(host, 'AAAA') + try: + recs = yield from future + except Exception as e: + log.debug('DNS: Exception while querying for %s AAAA records: %s', host, e) + recs = [] + return recs + +@asyncio.coroutine +def get_SRV(host, port, service, proto='tcp', resolver=None, use_aiodns=True): + """Perform SRV record resolution for a given host. + + .. note:: + + This function requires the use of the ``aiodns`` package. Calling + :func:`get_SRV` without ``aiodns`` will return the provided host + and port without performing any DNS queries. + + :param host: The hostname to resolve. + :param port: A default port to connect with. SRV records may + dictate use of a different port. + :param service: Optional SRV service name without leading underscore. + :param proto: Optional SRV protocol name without leading underscore. + :param resolver: Optionally provide a DNS resolver object that has + been custom configured. + + :type host: string + :type port: int + :type service: string + :type proto: string + :type resolver: :class:`aiodns.DNSResolver` + + :return: A list of hostname, port pairs in the order dictacted + by SRV priorities and weights. + """ + if resolver is None or not use_aiodns: + log.warning("DNS: aiodns not found. Can not use SRV lookup.") + return [(host, port)] + + log.debug("DNS: Querying SRV records for %s" % host) + try: + future = resolver.query('_%s._%s.%s' % (service, proto, host), + 'SRV') + recs = yield from future + except Exception as e: + log.debug('DNS: Exception while querying for %s SRV records: %s', host, e) + return [] + + answers = {} + for rec in recs: + if rec.priority not in answers: + answers[rec.priority] = [] + if rec.weight == 0: + answers[rec.priority].insert(0, rec) + else: + answers[rec.priority].append(rec) + + sorted_recs = [] + for priority in sorted(answers.keys()): + while answers[priority]: + running_sum = 0 + sums = {} + for rec in answers[priority]: + running_sum += rec.weight + sums[running_sum] = rec + + selected = random.randint(0, running_sum + 1) + for running_sum in sums: + if running_sum >= selected: + rec = sums[running_sum] + host = rec.host + if host.endswith('.'): + host = host[:-1] + sorted_recs.append((host, rec.port)) + answers[priority].remove(rec) + break + + return sorted_recs diff --git a/slixmpp/xmlstream/stanzabase.py b/slixmpp/xmlstream/stanzabase.py new file mode 100644 index 00000000..1ddee825 --- /dev/null +++ b/slixmpp/xmlstream/stanzabase.py @@ -0,0 +1,1631 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.stanzabase + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + module implements a wrapper layer for XML objects + that allows them to be treated like dictionaries. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +from __future__ import with_statement, unicode_literals + +import copy +import logging +import weakref +from xml.etree import cElementTree as ET + +from slixmpp.xmlstream import JID +from slixmpp.xmlstream.tostring import tostring +from collections import OrderedDict + + +log = logging.getLogger(__name__) + + +# Used to check if an argument is an XML object. +XML_TYPE = type(ET.Element('xml')) + + +XML_NS = 'http://www.w3.org/XML/1998/namespace' + + +def register_stanza_plugin(stanza, plugin, iterable=False, overrides=False): + """ + Associate a stanza object as a plugin for another stanza. + + >>> from slixmpp.xmlstream import register_stanza_plugin + >>> register_stanza_plugin(Iq, CustomStanza) + + Plugin stanzas marked as iterable will be included in the list of + substanzas for the parent, using ``parent['substanzas']``. If the + attribute ``plugin_multi_attrib`` was defined for the plugin, then + the substanza set can be filtered to only instances of the plugin + class. For example, given a plugin class ``Foo`` with + ``plugin_multi_attrib = 'foos'`` then:: + + parent['foos'] + + would return a collection of all ``Foo`` substanzas. + + :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 plugin.plugin_multi_attrib: + multiplugin = multifactory(plugin, plugin.plugin_multi_attrib) + register_stanza_plugin(stanza, multiplugin) + if overrides: + for interface in plugin.overrides: + stanza.plugin_overrides[interface] = plugin.plugin_attrib + + +def multifactory(stanza, plugin_attrib): + """ + Returns a ElementBase class for handling reoccuring child stanzas + """ + + def plugin_filter(self): + return lambda x: isinstance(x, self._multistanza) + + def plugin_lang_filter(self, lang): + return lambda x: isinstance(x, self._multistanza) and \ + x['lang'] == lang + + class Multi(ElementBase): + """ + Template class for multifactory + """ + def setup(self, xml=None): + self.xml = ET.Element('') + + def get_multi(self, lang=None): + parent = self.parent() + if not lang or lang == '*': + res = filter(plugin_filter(self), parent) + else: + res = filter(plugin_filter(self, lang), parent) + return list(res) + + def set_multi(self, val, lang=None): + parent = self.parent() + del_multi = getattr(self, 'del_%s' % plugin_attrib) + del_multi(lang) + for sub in val: + parent.append(sub) + + def del_multi(self, lang=None): + parent = self.parent() + if not lang or lang == '*': + res = filter(plugin_filter(self), parent) + else: + res = filter(plugin_filter(self, lang), parent) + res = list(res) + if not res: + del parent.plugins[(plugin_attrib, None)] + parent.loaded_plugins.remove(plugin_attrib) + try: + parent.xml.remove(self.xml) + except ValueError: + pass + else: + for stanza in list(res): + parent.iterables.remove(stanza) + parent.xml.remove(stanza.xml) + + Multi.is_extension = True + Multi.plugin_attrib = plugin_attrib + Multi._multistanza = stanza + Multi.interfaces = set([plugin_attrib]) + Multi.lang_interfaces = set([plugin_attrib]) + setattr(Multi, "get_%s" % plugin_attrib, get_multi) + setattr(Multi, "set_%s" % plugin_attrib, set_multi) + setattr(Multi, "del_%s" % plugin_attrib, del_multi) + return Multi + + +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 and element[0] != '*': + tag = '{%s}%s' % (namespace, element) + else: + tag = element + fixed.append(tag) + if split: + return fixed + return '/'.join(fixed) + + +class ElementBase(object): + + """ + The core of Slixmpp'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:: + + >>> # Same as using message['custom']['custom'] + >>> message['custom'] = 'bar' + >>> # Must use all interfaces + >>> message['custom']['custom'] + '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 + 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' + + #: For :class:`ElementBase` subclasses that are intended to be an + #: iterable group of items, the ``plugin_multi_attrib`` value defines + #: an interface for the parent stanza which returns the entire group + #: of matching substanzas. So the following are equivalent:: + #: + #: # Given stanza class Foo, with plugin_multi_attrib = 'foos' + #: parent['foos'] + #: filter(isinstance(item, Foo), parent['substanzas']) + plugin_multi_attrib = '' + + #: 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 = set() + + #: A subset of :attr:`interfaces` which maps the presence of + #: subelements to boolean values. Using this set allows for quickly + #: checking for the existence of empty subelements like ``<required />``. + #: + #: .. versionadded:: 1.1 + bool_interfaces = set() + + #: .. versionadded:: 1.1.2 + lang_interfaces = set() + + #: 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 = XML_NS + + 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() + self.loaded_plugins = set() + + #: 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: + if not isinstance(parent, weakref.ReferenceType): + self.parent = weakref.ref(parent) + else: + self.parent = 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: + if child.tag in self.plugin_tag_map: + plugin_class = self.plugin_tag_map[child.tag] + self.init_plugin(plugin_class.plugin_attrib, + existing_xml=child, + reuse=False) + + 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 + + last_xml = self.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, lang=None): + """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, lang) + + def _get_plugin(self, name, lang=None, check=False): + if lang is None: + lang = self.get_lang() + + if name not in self.plugin_attrib_map: + return None + + plugin_class = self.plugin_attrib_map[name] + + if plugin_class.is_extension: + if (name, None) in self.plugins: + return self.plugins[(name, None)] + else: + return None if check else self.init_plugin(name, lang) + else: + if (name, lang) in self.plugins: + return self.plugins[(name, lang)] + else: + return None if check else self.init_plugin(name, lang) + + def init_plugin(self, attrib, lang=None, existing_xml=None, reuse=True): + """Enable and initialize a stanza plugin. + + :param string attrib: The :attr:`plugin_attrib` value of the + plugin to enable. + """ + default_lang = self.get_lang() + if not lang: + lang = default_lang + + plugin_class = self.plugin_attrib_map[attrib] + + if plugin_class.is_extension and (attrib, None) in self.plugins: + return self.plugins[(attrib, None)] + if reuse and (attrib, lang) in self.plugins: + return self.plugins[(attrib, lang)] + + plugin = plugin_class(parent=self, xml=existing_xml) + + if plugin.is_extension: + self.plugins[(attrib, None)] = plugin + else: + if lang != default_lang: + plugin['lang'] = lang + self.plugins[(attrib, lang)] = plugin + + if plugin_class in self.plugin_iterables: + self.iterables.append(plugin) + if plugin_class.plugin_multi_attrib: + self.init_plugin(plugin_class.plugin_multi_attrib) + + self.loaded_plugins.add(attrib) + + return plugin + + 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 = OrderedDict() + values['lang'] = self['lang'] + for interface in self.interfaces: + if isinstance(self[interface], JID): + values[interface] = self[interface].jid + else: + values[interface] = self[interface] + if interface in self.lang_interfaces: + values['%s|*' % interface] = self['%s|*' % interface] + for plugin, stanza in self.plugins.items(): + lang = stanza['lang'] + if lang: + values['%s|%s' % (plugin[0], lang)] = stanza.values + else: + values[plugin[0]] = 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] + + if 'lang' in values: + self['lang'] = values['lang'] + + if 'substanzas' in values: + # Remove existing substanzas + for stanza in self.iterables: + try: + self.xml.remove(stanza.xml) + except ValueError: + pass + self.iterables = [] + + # Add new substanzas + for subdict in values['substanzas']: + 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) + + for interface, value in values.items(): + full_interface = interface + interface_lang = ('%s|' % interface).split('|') + interface = interface_lang[0] + lang = interface_lang[1] or self.get_lang() + + if interface == 'lang': + continue + elif interface == 'substanzas': + continue + elif interface in self.interfaces: + self[full_interface] = value + elif interface in self.plugin_attrib_map: + if interface not in iterable_interfaces: + plugin = self._get_plugin(interface, lang) + if plugin: + plugin.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. True or False depending on the existence of a ``foo`` + subelement and ``foo`` is in :attr:`bool_interfaces`. + 7. The value of the ``foo`` attribute of the XML object. + 8. The plugin named ``'foo'`` + 9. An empty string. + + :param string attrib: The name of the requested stanza interface. + """ + full_attrib = attrib + attrib_lang = ('%s|' % attrib).split('|') + attrib = attrib_lang[0] + lang = attrib_lang[1] or None + + kwargs = {} + if lang and attrib in self.lang_interfaces: + kwargs['lang'] = lang + + kwargs = OrderedDict(kwargs) + + if attrib == 'substanzas': + return self.iterables + elif attrib in self.interfaces or attrib == 'lang': + get_method = "get_%s" % attrib.lower() + get_method2 = "get%s" % attrib.title() + + if self.plugin_overrides: + name = self.plugin_overrides.get(get_method, None) + if name: + plugin = self._get_plugin(name, lang) + if plugin: + handler = getattr(plugin, get_method, None) + if handler: + return handler(**kwargs) + + if hasattr(self, get_method): + return getattr(self, get_method)(**kwargs) + elif hasattr(self, get_method2): + return getattr(self, get_method2)(**kwargs) + else: + if attrib in self.sub_interfaces: + return self._get_sub_text(attrib, lang=lang) + elif attrib in self.bool_interfaces: + elem = self.xml.find('{%s}%s' % (self.namespace, attrib)) + return elem is not None + else: + return self._get_attr(attrib) + elif attrib in self.plugin_attrib_map: + plugin = self._get_plugin(attrib, lang) + if plugin and plugin.is_extension: + return plugin[full_attrib] + return plugin + 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. Add or remove an empty subelement ``foo`` + if ``foo`` is in :attr:`bool_interfaces`. + 7. Set the value of a top level XML attribute named ``foo``. + 8. Attempt to pass the value to a plugin named ``'foo'`` using + the plugin's ``'foo'`` interface. + 9. Do nothing. + + :param string attrib: The name of the stanza interface to modify. + :param value: The new value of the stanza interface. + """ + full_attrib = attrib + attrib_lang = ('%s|' % attrib).split('|') + attrib = attrib_lang[0] + lang = attrib_lang[1] or None + + kwargs = {} + if lang and attrib in self.lang_interfaces: + kwargs['lang'] = lang + + kwargs = OrderedDict(kwargs) + + if attrib in self.interfaces or attrib == 'lang': + if value is not None: + set_method = "set_%s" % attrib.lower() + set_method2 = "set%s" % attrib.title() + + if self.plugin_overrides: + name = self.plugin_overrides.get(set_method, None) + if name: + plugin = self._get_plugin(name, lang) + if plugin: + handler = getattr(plugin, set_method, None) + if handler: + return handler(value, **kwargs) + + if hasattr(self, set_method): + getattr(self, set_method)(value, **kwargs) + elif hasattr(self, set_method2): + getattr(self, set_method2)(value, **kwargs) + else: + if attrib in self.sub_interfaces: + if lang == '*': + return self._set_all_sub_text(attrib, + value, + lang='*') + return self._set_sub_text(attrib, text=value, + lang=lang) + elif attrib in self.bool_interfaces: + if value: + return self._set_sub_text(attrib, '', + keep=True, + lang=lang) + else: + return self._set_sub_text(attrib, '', + keep=False, + lang=lang) + else: + self._set_attr(attrib, value) + else: + self.__delitem__(attrib) + elif attrib in self.plugin_attrib_map: + plugin = self._get_plugin(attrib, lang) + if plugin: + plugin[full_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. Remove ``foo`` element if ``'foo'`` is in + :attr:`bool_interfaces`. + 6. Delete top level XML attribute named ``foo``. + 7. Remove the ``foo`` plugin, if it was loaded. + 8. Do nothing. + + :param attrib: The name of the affected stanza interface. + """ + full_attrib = attrib + attrib_lang = ('%s|' % attrib).split('|') + attrib = attrib_lang[0] + lang = attrib_lang[1] or None + + kwargs = {} + if lang and attrib in self.lang_interfaces: + kwargs['lang'] = lang + + kwargs = OrderedDict(kwargs) + + if attrib in self.interfaces or attrib == 'lang': + del_method = "del_%s" % attrib.lower() + del_method2 = "del%s" % attrib.title() + + if self.plugin_overrides: + name = self.plugin_overrides.get(del_method, None) + if name: + plugin = self._get_plugin(attrib, lang) + if plugin: + handler = getattr(plugin, del_method, None) + if handler: + return handler(**kwargs) + + if hasattr(self, del_method): + getattr(self, del_method)(**kwargs) + elif hasattr(self, del_method2): + getattr(self, del_method2)(**kwargs) + else: + if attrib in self.sub_interfaces: + return self._del_sub(attrib, lang=lang) + elif attrib in self.bool_interfaces: + return self._del_sub(attrib, lang=lang) + else: + self._del_attr(attrib) + elif attrib in self.plugin_attrib_map: + plugin = self._get_plugin(attrib, lang, check=True) + if not plugin: + return self + if plugin.is_extension: + del plugin[full_attrib] + del self.plugins[(attrib, None)] + else: + del self.plugins[(attrib, plugin['lang'])] + self.loaded_plugins.remove(attrib) + try: + self.xml.remove(plugin.xml) + except ValueError: + 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='', lang=None): + """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) + if lang == '*': + return self._get_all_sub_text(name, default, None) + + default_lang = self.get_lang() + if not lang: + lang = default_lang + + stanzas = self.xml.findall(name) + if not stanzas: + return default + for stanza in stanzas: + if stanza.attrib.get('{%s}lang' % XML_NS, default_lang) == lang: + if stanza.text is None: + return default + return stanza.text + return default + + def _get_all_sub_text(self, name, default='', lang=None): + name = self._fix_ns(name) + + default_lang = self.get_lang() + results = OrderedDict() + stanzas = self.xml.findall(name) + if stanzas: + for stanza in stanzas: + stanza_lang = stanza.attrib.get('{%s}lang' % XML_NS, + default_lang) + if not lang or lang == '*' or stanza_lang == lang: + results[stanza_lang] = stanza.text + return results + + def _set_sub_text(self, name, text=None, keep=False, lang=None): + """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. + """ + default_lang = self.get_lang() + if lang is None: + lang = default_lang + + if not text and not keep: + return self._del_sub(name, lang=lang) + + path = self._fix_ns(name, split=True) + name = path[-1] + parent = self.xml + + # The first goal is to find the parent of the subelement, or, if + # we can't find that, the closest grandparent element. + missing_path = [] + search_order = path[:-1] + while search_order: + parent = self.xml.find('/'.join(search_order)) + ename = search_order.pop() + if parent is not None: + break + else: + missing_path.append(ename) + missing_path.reverse() + + # Find all existing elements that match the desired + # element path (there may be multiples due to different + # languages values). + if parent is not None: + elements = self.xml.findall('/'.join(path)) + else: + parent = self.xml + elements = [] + + # Insert the remaining grandparent elements that don't exist yet. + for ename in missing_path: + element = ET.Element(ename) + parent.append(element) + parent = element + + # Re-use an existing element with the proper language, if one exists. + for element in elements: + elang = element.attrib.get('{%s}lang' % XML_NS, default_lang) + if not lang and elang == default_lang or lang and lang == elang: + element.text = text + return element + + # No useable element exists, so create a new one. + element = ET.Element(name) + element.text = text + if lang and lang != default_lang: + element.attrib['{%s}lang' % XML_NS] = lang + parent.append(element) + return element + + def _set_all_sub_text(self, name, values, keep=False, lang=None): + self._del_sub(name, lang) + for value_lang, value in values.items(): + if not lang or lang == '*' or value_lang == lang: + self._set_sub_text(name, text=value, + keep=keep, + lang=value_lang) + + def _del_sub(self, name, all=False, lang=None): + """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] + + default_lang = self.get_lang() + if not lang: + lang = default_lang + + 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 list(element): + # Only delete the originally requested elements, and + # any parent elements that have become empty. + elem_lang = element.attrib.get('{%s}lang' % XML_NS, + default_lang) + if lang == '*' or elem_lang == lang: + 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.loaded_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] + langs = [name[1] for name in self.plugins if name[0] == next_tag] + for lang in langs: + plugin = self._get_plugin(next_tag, lang) + if plugin and plugin.match(xpath[1:]): + return True + 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.loaded_plugins] + out.append('lang') + 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) + if item.__class__ in self.plugin_iterables: + if item.__class__.plugin_multi_attrib: + self.init_plugin(item.__class__.plugin_multi_attrib) + elif item.__class__ == self.plugin_tag_map.get(item.tag_name(), None): + self.init_plugin(item.plugin_attrib, + existing_xml=item.xml, + reuse=False) + 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 list(self.xml): + 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) + + def get_lang(self, lang=None): + result = self.xml.attrib.get('{%s}lang' % XML_NS, '') + if not result and self.parent and self.parent(): + return self.parent()['lang'] + return result + + def set_lang(self, lang): + self.del_lang() + attr = '{%s}lang' % XML_NS + if lang: + self.xml.attrib[attr] = lang + + def del_lang(self): + attr = '{%s}lang' % XML_NS + if attr in self.xml.attrib: + del self.xml.attrib[attr] + + @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. + """ + return tostring(self.xml, xmlns='', + top_level=True) + + 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 Slixmpp, 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:`slixmpp.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:`slixmpp.xmlstream.JID` + object of the recipient's JID. + :param sfrom: Optional string or :class:`slixmpp.xmlstream.JID` + object of the sender's JID. + :param string sid: Optional ID value for the stanza. + :param parent: Optionally specify a parent stanza object will + contain this substanza. + """ + + #: 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, parent=None): + self.stream = stream + if stream is not None: + self.namespace = stream.default_ns + ElementBase.__init__(self, xml, parent) + if stype is not None: + self['type'] = stype + if sto is not None: + self['to'] = sto + if sfrom is not None: + self['from'] = sfrom + if sid is not None: + self['id'] = sid + 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 str 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:`slixmpp.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. + + :param from: A string or JID object representing the sender's JID. + :type from: str or :class:`.JID` + """ + return self._set_attr('from', str(value)) + + def get_payload(self): + """Return a list of XML objects contained in the stanza.""" + return list(self.xml) + + 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``. + """ + new_stanza = copy.copy(self) + # if it's a component, use from + if self.stream and hasattr(self.stream, "is_component") and \ + self.stream.is_component: + new_stanza['from'], new_stanza['to'] = self['to'], self['from'] + else: + new_stanza['to'] = self['from'] + del new_stanza['from'] + if clear: + new_stanza.clear() + return new_stanza + + 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): + """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) + + 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``. + """ + xmlns = self.stream.default_ns if self.stream else '' + return tostring(self.xml, xmlns=xmlns, + stream=self.stream, + top_level=(self.stream is None)) + + +#: 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) + +ElementBase.get_stanza_values = ElementBase._get_stanza_values +ElementBase.set_stanza_values = ElementBase._set_stanza_values diff --git a/slixmpp/xmlstream/tostring.py b/slixmpp/xmlstream/tostring.py new file mode 100644 index 00000000..6726bf1e --- /dev/null +++ b/slixmpp/xmlstream/tostring.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +""" + slixmpp.xmlstream.tostring + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module converts XML objects into Unicode strings and + intelligently includes namespaces only when necessary to + keep the output readable. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +XML_NS = 'http://www.w3.org/XML/1998/namespace' + + +def tostring(xml=None, xmlns='', stream=None, outbuffer='', + top_level=False, open_only=False, namespaces=None): + """Serialize an XML object to a Unicode string. + + If an outer xmlns is provided using ``xmlns``, then the current element's + namespace will not be included if it matches the outer namespace. An + exception is made for elements that have an attached stream, and appear + at the stream root. + + :param XML xml: The XML object to serialize. + :param string xmlns: Optional namespace of an element wrapping 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. + :param set namespaces: Track which namespaces are in active use so + that new ones can be declared when needed. + + :type xml: :py:class:`~xml.etree.ElementTree.Element` + :type stream: :class:`~slixmpp.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 = '' + use_cdata = False + + if stream: + default_ns = stream.default_ns + stream_ns = stream.stream_ns + use_cdata = stream.use_cdata + + # Output the tag name and derived namespace of the element. + namespace = '' + if tag_xmlns: + if top_level and tag_xmlns not in [default_ns, xmlns, stream_ns] \ + or not top_level and tag_xmlns != xmlns: + 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. + new_namespaces = set() + for attrib, value in xml.attrib.items(): + value = escape(value, use_cdata) + if '}' not in attrib: + output.append(' %s="%s"' % (attrib, value)) + else: + attrib_ns = attrib.split('}')[0][1:] + attrib = attrib.split('}')[1] + if attrib_ns == XML_NS: + output.append(' xml:%s="%s"' % (attrib, value)) + elif stream and attrib_ns in stream.namespace_map: + mapped_ns = stream.namespace_map[attrib_ns] + if mapped_ns: + if namespaces is None: + namespaces = set() + if attrib_ns not in namespaces: + namespaces.add(attrib_ns) + new_namespaces.add(attrib_ns) + output.append(' xmlns:%s="%s"' % ( + mapped_ns, attrib_ns)) + output.append(' %s:%s="%s"' % ( + mapped_ns, attrib, value)) + + if open_only: + # Only output the opening tag, regardless of content. + output.append(">") + return ''.join(output) + + if len(xml) or xml.text: + # If there are additional child elements to serialize. + output.append(">") + if xml.text: + output.append(escape(xml.text, use_cdata)) + if len(xml): + for child in xml: + output.append(tostring(child, tag_xmlns, stream, + namespaces=namespaces)) + output.append("</%s>" % tag_name) + elif xml.text: + # If we only have text content. + output.append(">%s</%s>" % (escape(xml.text, use_cdata), tag_name)) + else: + # Empty element. + output.append(" />") + if xml.tail: + # If there is additional text after the element. + output.append(escape(xml.tail, use_cdata)) + for ns in new_namespaces: + # Remove namespaces introduced in this context. This is necessary + # because the namespaces object continues to be shared with other + # contexts. + namespaces.remove(ns) + return ''.join(output) + + +def escape(text, use_cdata=False): + """Convert special characters in XML to escape sequences. + + :param string text: The XML text to convert. + :rtype: Unicode string + """ + escapes = {'&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"'} + + if not use_cdata: + text = list(text) + for i, c in enumerate(text): + text[i] = escapes.get(c, c) + return ''.join(text) + else: + escape_needed = False + for c in text: + if c in escapes: + escape_needed = True + break + if escape_needed: + escaped = map(lambda x : "<![CDATA[%s]]>" % x, text.split("]]>")) + return "<![CDATA[]]]><![CDATA[]>]]>".join(escaped) + return text + + +def _get_highlight(): + try: + from pygments import highlight + from pygments.lexers import get_lexer_by_name + from pygments.formatters import Terminal256Formatter + + LEXER = get_lexer_by_name('xml') + FORMATTER = Terminal256Formatter() + + class Highlighter: + __slots__ = ['string'] + def __init__(self, string): + self.string = string + def __str__(self): + return highlight(str(self.string), LEXER, FORMATTER).strip() + + return Highlighter + except ImportError: + return lambda x: x + +highlight = _get_highlight() diff --git a/slixmpp/xmlstream/xmlstream.py b/slixmpp/xmlstream/xmlstream.py new file mode 100644 index 00000000..28bfb17c --- /dev/null +++ b/slixmpp/xmlstream/xmlstream.py @@ -0,0 +1,943 @@ +""" + slixmpp.xmlstream.xmlstream + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module provides the module for creating and + interacting with generic XML streams, along with + the necessary eventing infrastructure. + + Part of Slixmpp: The Slick XMPP Library + + :copyright: (c) 2011 Nathanael C. Fritz + :license: MIT, see LICENSE for more details +""" + +import functools +import logging +import socket as Socket +import ssl +import weakref +import uuid + +import xml.etree.ElementTree + +from slixmpp.xmlstream.asyncio import asyncio +from slixmpp.xmlstream import tostring, highlight +from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase +from slixmpp.xmlstream.resolver import resolve, default_resolver + +#: The time in seconds to wait before timing out waiting for response stanzas. +RESPONSE_TIMEOUT = 30 + +log = logging.getLogger(__name__) + +class NotConnectedError(Exception): + """ + Raised when we try to send something over the wire but we are not + connected. + """ + +class XMLStream(asyncio.BaseProtocol): + """ + 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): + # The asyncio.Transport object provided by the connection_made() + # callback when we are connected + self.transport = None + + # The socket the is used internally by the transport object + self.socket = None + + self.connect_loop_wait = 0 + + self.parser = None + self.xml_depth = 0 + self.xml_root = None + + self.force_starttls = None + self.disable_starttls = None + + # A dict of {name: handle} + self.scheduled_events = {} + + self.ssl_context = ssl.create_default_context() + self.ssl_context.check_hostname = False + self.ssl_context.verify_mode = ssl.CERT_NONE + + # The event to trigger when the create_connection() succeeds. It can + # be "connected" or "tls_success" depending on the step we are at. + self.event_when_connected = "connected" + + #: The list of accepted ciphers, in OpenSSL Format. + #: It might be useful to override it for improved security + #: over the python defaults. + self.ciphers = None + + #: 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 + + #: Path to a file containing a client certificate to use for + #: authenticating via SASL EXTERNAL. If set, there must also + #: be a corresponding `:attr:keyfile` value. + self.certfile = None + + #: Path to a file containing the private key for the selected + #: client certificate to use for authenticating via SASL EXTERNAL. + self.keyfile = None + + self._der_cert = None + + # The asyncio event loop + self._loop = None + + #: 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 expected name of the server, for validation. + self._expected_server_name = '' + self._service_name = '' + + #: The desired, or actual, address of the connected server. + self.address = (host, int(port)) + + #: 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 + + #: If set to ``True``, attempt to connect through an HTTP + #: proxy based on the settings in :attr:`proxy_config`. + self.use_proxy = False + + #: If set to ``True``, attempt to use IPv6. + self.use_ipv6 = True + + #: If set to ``True``, allow using the ``dnspython`` DNS library + #: if available. If set to ``False``, the builtin DNS resolver + #: will be used, even if ``dnspython`` is installed. + self.use_aiodns = True + + #: Use CDATA for escaping instead of XML entities. Defaults + #: to ``False``. + self.use_cdata = 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 = '' + + self.default_lang = None + self.peer_default_lang = None + + #: 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 + + #: Flag for controlling if the session can be considered ended + #: if the connection is terminated. + self.end_session_on_disconnect = True + + #: A mapping of XML namespaces to well-known prefixes. + self.namespace_map = {StanzaBase.xml_ns: 'xml'} + + self.__root_stanza = [] + self.__handlers = [] + self.__event_handlers = {} + self.__filters = {'in': [], 'out': [], 'out_sync': []} + + self._id = 0 + + #: We use an ID prefix to ensure that all ID values are unique. + self._id_prefix = '%s-' % uuid.uuid4() + + #: A list of DNS results that have not yet been tried. + self.dns_answers = None + + #: The service name to check with DNS SRV records. For + #: example, setting this to ``'xmpp-client'`` would query the + #: ``_xmpp-client._tcp`` service. + self.dns_service = None + + #: An asyncio Future being done when the stream is disconnected. + self.disconnected = asyncio.Future() + + self.add_event_handler('disconnected', self._remove_schedules) + self.add_event_handler('session_start', self._start_keepalive) + + @property + def loop(self): + if self._loop is None: + self._loop = asyncio.get_event_loop() + return self._loop + + @loop.setter + def loop(self, value): + self._loop = value + + 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. + """ + self._id += 1 + return self.get_id() + + def get_id(self): + """Return the current unique stream ID in hexadecimal form.""" + return "%s%X" % (self._id_prefix, self._id) + + def connect(self, host='', port=0, use_ssl=False, + force_starttls=True, disable_starttls=False): + """Create a new socket and connect to the server. + + :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. If it is False, the + connection will be upgraded to SSL/TLS later, using + STARTTLS. Only use this value for old servers that + have specific port for SSL/TLS + TODO fix the comment + :param force_starttls: If True, the connection will be aborted if + the server does not initiate a STARTTLS + negociation. If None, the connection will be + upgraded to TLS only if the server initiate + the STARTTLS negociation, otherwise it will + connect in clear. If False it will never + upgrade to TLS, even if the server provides + it. Use this for example if you’re on + localhost + + """ + if host and port: + self.address = (host, int(port)) + try: + Socket.inet_aton(self.address[0]) + except (Socket.error, ssl.SSLError): + self.default_domain = self.address[0] + + # Respect previous TLS usage. + if use_ssl is not None: + self.use_ssl = use_ssl + if force_starttls is not None: + self.force_starttls = force_starttls + if disable_starttls is not None: + self.disable_starttls = disable_starttls + + self.event("connecting") + asyncio.async(self._connect_routine()) + + @asyncio.coroutine + def _connect_routine(self): + self.event_when_connected = "connected" + + record = yield from self.pick_dns_answer(self.default_domain) + if record is not None: + host, address, port = record + self.address = (address, port) + self._service_name = host + else: + # No DNS records left, stop iterating + # and try (host, port) as a last resort + self.dns_answers = None + + yield from asyncio.sleep(self.connect_loop_wait) + try: + yield from self.loop.create_connection(lambda: self, + self.address[0], + self.address[1], + ssl=self.use_ssl) + except Socket.gaierror as e: + self.event('connection_failed', + 'No DNS record available for %s' % self.default_domain) + except OSError as e: + log.debug('Connection failed: %s', e) + self.event("connection_failed", e) + self.connect_loop_wait = self.connect_loop_wait * 2 + 1 + asyncio.async(self._connect_routine()) + else: + self.connect_loop_wait = 0 + + def process(self, *, forever=True, timeout=None): + """Process all the available XMPP events (receiving or sending data on the + socket(s), calling various registered callbacks, calling expired + timers, handling signal events, etc). If timeout is None, this + function will run forever. If timeout is a number, this function + will return after the given time in seconds. + """ + if timeout is None: + if forever: + self.loop.run_forever() + else: + self.loop.run_until_complete(self.disconnected) + else: + tasks = [asyncio.sleep(timeout)] + if not forever: + tasks.append(self.disconnected) + self.loop.run_until_complete(asyncio.wait(tasks)) + + def init_parser(self): + """init the XML parser. The parser must always be reset for each new + connexion + """ + self.xml_depth = 0 + self.xml_root = None + self.parser = xml.etree.ElementTree.XMLPullParser(("start", "end")) + + def connection_made(self, transport): + """Called when the TCP connection has been established with the server + """ + self.event(self.event_when_connected) + self.transport = transport + self.socket = self.transport.get_extra_info("socket") + self.init_parser() + self.send_raw(self.stream_header) + + def data_received(self, data): + """Called when incoming data is received on the socket. + + We feed that data to the parser and the see if this produced any XML + event. This could trigger one or more event (a stanza is received, + the stream is opened, etc). + """ + self.parser.feed(data) + for event, xml in self.parser.read_events(): + if event == 'start': + if self.xml_depth == 0: + # We have received the start of the root element. + self.xml_root = xml + log.debug('[33;1mRECV[0m: %s', highlight(tostring(self.xml_root, xmlns=self.default_ns, + stream=self, + top_level=True, + open_only=True))) + self.start_stream_handler(self.xml_root) + self.xml_depth += 1 + if event == 'end': + self.xml_depth -= 1 + if self.xml_depth == 0: + # The stream's root element has closed, + # terminating the stream. + log.debug("End of stream received") + self.abort() + elif self.xml_depth == 1: + # A stanza is an XML element that is a direct child of + # the root element, hence the check of depth == 1 + self.loop.idle_call(functools.partial(self.__spawn_event, xml)) + if self.xml_root is not None: + # Keep the root element empty of children to + # save on memory use. + self.xml_root.clear() + + def is_connected(self): + return self.transport is not None + + def eof_received(self): + """When the TCP connection is properly closed by the remote end + """ + self.event("eof_received") + + def connection_lost(self, exception): + """On any kind of disconnection, initiated by us or not. This signals the + closure of the TCP connection + """ + log.info("connection_lost: %s", (exception,)) + self.event("disconnected") + if self.end_session_on_disconnect: + self.event('session_end') + # All these objects are associated with one TCP connection. Since + # we are not connected anymore, destroy them + self.parser = None + self.transport = None + self.socket = None + + def disconnect(self, wait=2.0): + """Close the XML stream and wait for an acknowldgement from the server for + at most `wait` seconds. After the given number of seconds has + passed without a response from the serveur, or when the server + successfuly responds with a closure of its own stream, abort() is + called. If wait is 0.0, this is almost equivalent to calling abort() + directly. + + Does nothing if we are not connected. + + :param wait: Time to wait for a response from the server. + + """ + if self.transport: + self.send_raw(self.stream_footer) + self.schedule('Disconnect wait', wait, + self.abort, repeat=False) + + def abort(self): + """ + Forcibly close the connection + """ + if self.transport: + self.transport.close() + self.transport.abort() + self.event("killed") + self.disconnected.set_result(True) + self.disconnected = asyncio.Future() + + def reconnect(self, wait=2.0): + """Calls disconnect(), and once we are disconnected (after the timeout, or + when the server acknowledgement is received), call connect() + """ + log.debug("reconnecting...") + self.disconnect(wait) + self.add_event_handler('disconnected', self.connect, disposable=True) + + def configure_socket(self): + """Set timeout and other options for self.socket. + + Meant to be overridden. + """ + pass + + 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. + """ + self.event_when_connected = "tls_success" + + if self.ciphers is not None: + self.ssl_context.set_ciphers(self.ciphers) + if self.keyfile and self.certfile: + try: + self.ssl_context.load_cert_chain(self.certfile, self.keyfile) + except (ssl.SSLError, OSError): + log.debug('Error loading the cert chain:', exc_info=True) + else: + log.debug('Loaded cert file %s and key file %s', + self.certfile, self.keyfile) + if self.ca_certs is not None: + self.ssl_context.verify_mode = ssl.CERT_REQUIRED + self.ssl_context.load_verify_locations(cafile=self.ca_certs) + + ssl_connect_routine = self.loop.create_connection(lambda: self, ssl=self.ssl_context, + sock=self.socket, + server_hostname=self.default_domain) + @asyncio.coroutine + def ssl_coro(): + try: + transp, prot = yield from ssl_connect_routine + except ssl.SSLError as e: + log.debug('SSL: Unable to connect', exc_info=True) + log.error('CERT: Invalid certificate trust chain.') + if not self.event_handled('ssl_invalid_chain'): + self.disconnect() + else: + self.event('ssl_invalid_chain', e) + else: + der_cert = transp.get_extra_info("socket").getpeercert(True) + pem_cert = ssl.DER_cert_to_PEM_cert(der_cert) + self.event('ssl_cert', pem_cert) + + asyncio.async(ssl_coro()) + + 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 + """ + self.schedule('Whitespace Keepalive', + self.whitespace_keepalive_interval, + self.send_raw, + args=(' ',), + repeat=True) + + def _remove_schedules(self, event): + """Remove some schedules that become pointless when disconnected""" + self.cancel_schedule('Whitespace Keepalive') + self.cancel_schedule('Disconnect wait') + + 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 + slixmpp.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. + """ + self.__root_stanza.remove(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 del_filter(self, mode, handler): + """Remove an incoming or outgoing filter.""" + self.__filters[mode].remove(handler) + + 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:`~slixmpp.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 + + @asyncio.coroutine + 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 + + resolver = default_resolver(loop=self.loop) + self.configure_dns(resolver, domain=domain, port=port) + + result = yield from resolve(domain, port, + service=self.dns_service, + resolver=resolver, + use_ipv6=self.use_ipv6, + use_aiodns=self.use_aiodns, + loop=self.loop) + return result + + @asyncio.coroutine + 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 self.dns_answers is None: + dns_records = yield from self.get_dns_records(domain, port) + self.dns_answers = iter(dns_records) + + try: + return next(self.dns_answers) + except StopIteration: + return + + def add_event_handler(self, name, pointer, 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 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, 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={}): + """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. + """ + log.debug("Event triggered: %s", name) + + handlers = self.__event_handlers.get(name, []) + for handler in handlers: + handler_callback, disposable = handler + old_exception = getattr(data, 'exception', None) + + # If the callback is a coroutine, schedule it instead of + # running it directly + if asyncio.iscoroutinefunction(handler_callback): + @asyncio.coroutine + def handler_callback_routine(cb): + try: + yield from cb(data) + except Exception as e: + if old_exception: + old_exception(e) + else: + self.exception(e) + asyncio.async(handler_callback_routine(handler_callback)) + else: + try: + handler_callback(data) + except Exception as e: + if old_exception: + old_exception(e) + else: + self.exception(e) + if disposable: + # If the handler is disposable, we will go ahead and + # remove it now instead of waiting for it to be + # processed in the queue. + try: + self.__event_handlers[name].remove(handler) + except ValueError: + pass + + def schedule(self, name, seconds, callback, args=tuple(), + kwargs={}, 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. + """ + if seconds is None: + seconds = RESPONSE_TIMEOUT + cb = functools.partial(callback, *args, **kwargs) + if repeat: + handle = self.loop.call_later(seconds, self._execute_and_reschedule, + name, cb, seconds) + else: + handle = self.loop.call_later(seconds, self._execute_and_unschedule, + name, cb) + + # Save that handle, so we can just cancel this scheduled event by + # canceling scheduled_events[name] + self.scheduled_events[name] = handle + + def cancel_schedule(self, name): + try: + handle = self.scheduled_events.pop(name) + handle.cancel() + except KeyError: + log.debug("Tried to cancel unscheduled event: %s" % (name,)) + + def _safe_cb_run(self, name, cb): + log.debug('Scheduled event: %s', name) + try: + cb() + except Exception as e: + self.exception(e) + + def _execute_and_reschedule(self, name, cb, seconds): + """Simple method that calls the given callback, and then schedule itself to + be called after the given number of seconds. + """ + self._safe_cb_run(name, cb) + handle = self.loop.call_later(seconds, self._execute_and_reschedule, + name, cb, seconds) + self.scheduled_events[name] = handle + + def _execute_and_unschedule(self, name, cb): + """ + Execute the callback and remove the handler for it. + """ + self._safe_cb_run(name, cb) + del self.scheduled_events[name] + + 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, use_filters=True): + """A wrapper for :meth:`send_raw()` for sending stanza objects. + + May optionally block until an expected response is received. + + :param data: The :class:`~slixmpp.xmlstream.stanzabase.ElementBase` + stanza to send on the stream. + :param bool use_filters: Indicates if outgoing filters should be + applied to the given stanza data. Disabling + filters is useful when resending stanzas. + Defaults to ``True``. + """ + if isinstance(data, ElementBase): + if use_filters: + for filter in self.__filters['out']: + data = filter(data) + if data is None: + return + + if isinstance(data, ElementBase): + if use_filters: + for filter in self.__filters['out_sync']: + data = filter(data) + if data is None: + return + str_data = tostring(data.xml, xmlns=self.default_ns, + stream=self, + top_level=True) + self.send_raw(str_data) + else: + self.send_raw(data) + + def send_xml(self, data): + """Send an XML object on the stream + + :param data: The :class:`~xml.etree.ElementTree.Element` XML object + to send on the stream. + """ + return self.send(tostring(data)) + + def send_raw(self, data): + """Send raw data across the stream. + + :param string data: Any bytes or utf-8 string value. + """ + log.debug("[36;1mSEND[0m: %s", highlight(data)) + if not self.transport: + raise NotConnectedError() + if isinstance(data, str): + data = data.encode('utf-8') + self.transport.write(data) + + 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:`~slixmpp.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) + if stanza['lang'] is None and self.peer_default_lang: + stanza['lang'] = self.peer_default_lang + 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:`~slixmpp.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("[33;1mRECV[0m: %s", highlight(stanza)) + + # Match the stanza against registered handlers. Handlers marked + # to run "in stream" will be executed immediately; the rest will + # be queued. + handled = False + matched_handlers = [h for h in self.__handlers if h.match(stanza)] + for handler in matched_handlers: + handler.prerun(stanza) + try: + handler.run(stanza) + except Exception as e: + stanza.exception(e) + if handler.check_delete(): + self.__handlers.remove(handler) + handled = True + + # Some stanzas require responses, such as Iq queries. A default + # handler will be executed immediately for this case. + if not handled: + stanza.unhandled() + + def exception(self, exception): + """Process an unknown exception. + + Meant to be overridden. + + :param exception: An unhandled exception object. + """ + pass + |