diff options
| author | Thibaut Horel <thibaut.horel@gmail.com> | 2010-11-08 00:59:14 +0100 |
|---|---|---|
| committer | Thibaut Horel <thibaut.horel@gmail.com> | 2010-11-08 00:59:14 +0100 |
| commit | b0a2a305028bf284fc5dcf7e1a696d85787f128f (patch) | |
| tree | e6463e36e381b4342b7c864200a3482cca182618 /sleekxmpp | |
| parent | b8499306ce329ca3881b1d1dfc3362a3a5c115d0 (diff) | |
| download | alias-b0a2a305028bf284fc5dcf7e1a696d85787f128f.tar.gz | |
Add the sleekxmpp library (will be added as a submodule later)
Diffstat (limited to 'sleekxmpp')
65 files changed, 10669 insertions, 0 deletions
diff --git a/sleekxmpp/__init__.py b/sleekxmpp/__init__.py new file mode 100644 index 0000000..20f4367 --- /dev/null +++ b/sleekxmpp/__init__.py @@ -0,0 +1,16 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.basexmpp import BaseXMPP +from sleekxmpp.clientxmpp import ClientXMPP +from sleekxmpp.componentxmpp import ComponentXMPP +from sleekxmpp.stanza import Message, Presence, Iq +from sleekxmpp.xmlstream.handler import * +from sleekxmpp.xmlstream import XMLStream, RestartStream +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.stanzabase import StanzaBase, ET diff --git a/sleekxmpp/basexmpp.py b/sleekxmpp/basexmpp.py new file mode 100644 index 0000000..1e8441a --- /dev/null +++ b/sleekxmpp/basexmpp.py @@ -0,0 +1,640 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from __future__ import with_statement, unicode_literals + +import sys +import copy +import logging + +import sleekxmpp +from sleekxmpp import plugins + +from sleekxmpp.stanza import Message, Presence, Iq, Error +from sleekxmpp.stanza.roster import Roster +from sleekxmpp.stanza.nick import Nick +from sleekxmpp.stanza.htmlim import HTMLIM + +from sleekxmpp.xmlstream import XMLStream, JID, tostring +from sleekxmpp.xmlstream import ET, register_stanza_plugin +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * + + +# Flag indicating if DNS SRV records are available for use. +SRV_SUPPORT = True +try: + import dns.resolver +except: + SRV_SUPPORT = False + + +# In order to make sure that Unicode is handled properly +# in Python 2.x, reset the default encoding. +if sys.version_info < (3, 0): + reload(sys) + sys.setdefaultencoding('utf8') + + +class BaseXMPP(XMLStream): + + """ + The BaseXMPP class adapts the generic XMLStream class for use + with XMPP. It also provides a plugin mechanism to easily extend + and add support for new XMPP features. + + Attributes: + auto_authorize -- Manage automatically accepting roster + subscriptions. + auto_subscribe -- Manage automatically requesting mutual + subscriptions. + is_component -- Indicates if this stream is for an XMPP component. + jid -- The XMPP JID for this stream. + plugin -- A dictionary of loaded plugins. + plugin_config -- A dictionary of plugin configurations. + plugin_whitelist -- A list of approved plugins. + sentpresence -- Indicates if an initial presence has been sent. + roster -- A dictionary containing subscribed JIDs and + their presence statuses. + + Methods: + Iq -- Factory for creating an Iq stanzas. + Message -- Factory for creating Message stanzas. + Presence -- Factory for creating Presence stanzas. + get -- Return a plugin given its name. + make_iq -- Create and initialize an Iq stanza. + make_iq_error -- Create an Iq stanza of type 'error'. + make_iq_get -- Create an Iq stanza of type 'get'. + make_iq_query -- Create an Iq stanza with a given query. + make_iq_result -- Create an Iq stanza of type 'result'. + make_iq_set -- Create an Iq stanza of type 'set'. + make_message -- Create and initialize a Message stanza. + make_presence -- Create and initialize a Presence stanza. + make_query_roster -- Create a roster query. + process -- Overrides XMLStream.process. + register_plugin -- Load and configure a plugin. + register_plugins -- Load and configure multiple plugins. + send_message -- Create and send a Message stanza. + send_presence -- Create and send a Presence stanza. + send_presence_subscribe -- Send a subscription request. + """ + + def __init__(self, default_ns='jabber:client'): + """ + Adapt an XML stream for use with XMPP. + + Arguments: + default_ns -- Ensure that the correct default XML namespace + is used during initialization. + """ + XMLStream.__init__(self) + + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.registerPlugin = self.register_plugin + self.makeIq = self.make_iq + self.makeIqGet = self.make_iq_get + self.makeIqResult = self.make_iq_result + self.makeIqSet = self.make_iq_set + self.makeIqError = self.make_iq_error + self.makeIqQuery = self.make_iq_query + self.makeQueryRoster = self.make_query_roster + self.makeMessage = self.make_message + self.makePresence = self.make_presence + self.sendMessage = self.send_message + self.sendPresence = self.send_presence + self.sendPresenceSubscription = self.send_presence_subscription + + self.default_ns = default_ns + self.stream_ns = 'http://etherx.jabber.org/streams' + + self.boundjid = JID("") + + self.plugin = {} + self.roster = {} + self.is_component = False + self.auto_authorize = True + self.auto_subscribe = True + + self.sentpresence = False + + self.register_handler( + Callback('IM', + MatchXPath('{%s}message/{%s}body' % (self.default_ns, + self.default_ns)), + self._handle_message)) + self.register_handler( + Callback('Presence', + MatchXPath("{%s}presence" % self.default_ns), + self._handle_presence)) + + self.add_event_handler('presence_subscribe', + self._handle_subscribe) + self.add_event_handler('disconnected', + self._handle_disconnected) + + # Set up the XML stream with XMPP's root stanzas. + self.registerStanza(Message) + self.registerStanza(Iq) + self.registerStanza(Presence) + + # Initialize a few default stanza plugins. + register_stanza_plugin(Iq, Roster) + register_stanza_plugin(Message, Nick) + register_stanza_plugin(Message, HTMLIM) + + def process(self, *args, **kwargs): + """ + Ensure that plugin inter-dependencies are handled before starting + event processing. + + Overrides XMLStream.process. + """ + for name in self.plugin: + if not self.plugin[name].post_inited: + self.plugin[name].post_init() + return XMLStream.process(self, *args, **kwargs) + + def register_plugin(self, plugin, pconfig={}, module=None): + """ + Register and configure a plugin for use in this stream. + + Arguments: + plugin -- The name of the plugin class. Plugin names must + be unique. + pconfig -- A dictionary of configuration data for the plugin. + Defaults to an empty dictionary. + module -- Optional refence to the module containing the plugin + class if using custom plugins. + """ + try: + # Import the given module that contains the plugin. + if not module: + module = sleekxmpp.plugins + module = __import__("%s.%s" % (module.__name__, plugin), + globals(), locals(), [plugin]) + if isinstance(module, str): + # We probably want to load a module from outside + # the sleekxmpp package, so leave out the globals(). + module = __import__(module, fromlist=[plugin]) + + # Load the plugin class from the module. + self.plugin[plugin] = getattr(module, plugin)(self, pconfig) + + # Let XEP implementing plugins have some extra logging info. + xep = '' + if hasattr(self.plugin[plugin], 'xep'): + xep = "(XEP-%s) " % self.plugin[plugin].xep + + desc = (xep, self.plugin[plugin].description) + logging.debug("Loaded Plugin %s%s" % desc) + except: + logging.exception("Unable to load plugin: %s", plugin) + + def register_plugins(self): + """ + Register and initialize all built-in plugins. + + Optionally, the list of plugins loaded may be limited to those + contained in self.plugin_whitelist. + + Plugin configurations stored in self.plugin_config will be used. + """ + if self.plugin_whitelist: + plugin_list = self.plugin_whitelist + else: + plugin_list = plugins.__all__ + + for plugin in plugin_list: + if plugin in plugins.__all__: + self.register_plugin(plugin, + self.plugin_config.get(plugin, {})) + else: + raise NameError("Plugin %s not in plugins.__all__." % plugin) + + # Resolve plugin inter-dependencies. + for plugin in self.plugin: + self.plugin[plugin].post_init() + + def __getitem__(self, key): + """ + Return a plugin given its name, if it has been registered. + """ + if key in self.plugin: + return self.plugin[key] + else: + logging.warning("""Plugin "%s" is not loaded.""" % key) + return False + + def get(self, key, default): + """ + Return a plugin given its name, if it has been registered. + """ + return self.plugin.get(key, default) + + def Message(self, *args, **kwargs): + """Create a Message stanza associated with this stream.""" + return Message(self, *args, **kwargs) + + def Iq(self, *args, **kwargs): + """Create an Iq stanza associated with this stream.""" + return Iq(self, *args, **kwargs) + + def Presence(self, *args, **kwargs): + """Create a Presence stanza associated with this stream.""" + return Presence(self, *args, **kwargs) + + def make_iq(self, id=0, ifrom=None): + """ + Create a new Iq stanza with a given Id and from JID. + + Arguments: + id -- An ideally unique ID value for this stanza thread. + Defaults to 0. + ifrom -- The from JID to use for this stanza. + """ + return self.Iq()._set_stanza_values({'id': str(id), + 'from': ifrom}) + + def make_iq_get(self, queryxmlns=None): + """ + Create an Iq stanza of type 'get'. + + Optionally, a query element may be added. + + Arguments: + queryxmlns -- The namespace of the query to use. + """ + return self.Iq()._set_stanza_values({'type': 'get', + 'query': queryxmlns}) + + def make_iq_result(self, id): + """ + Create an Iq stanza of type 'result' with the given ID value. + + Arguments: + id -- An ideally unique ID value. May use self.new_id(). + """ + return self.Iq()._set_stanza_values({'id': id, + 'type': 'result'}) + + def make_iq_set(self, sub=None): + """ + Create an Iq stanza of type 'set'. + + Optionally, a substanza may be given to use as the + stanza's payload. + + Arguments: + sub -- A stanza or XML object to use as the Iq's payload. + """ + iq = self.Iq()._set_stanza_values({'type': 'set'}) + if sub != None: + iq.append(sub) + return iq + + def make_iq_error(self, id, type='cancel', + condition='feature-not-implemented', text=None): + """ + Create an Iq stanza of type 'error'. + + Arguments: + id -- An ideally unique ID value. May use self.new_id(). + type -- The type of the error, such as 'cancel' or 'modify'. + Defaults to 'cancel'. + condition -- The error condition. + Defaults to 'feature-not-implemented'. + text -- A message describing the cause of the error. + """ + iq = self.Iq()._set_stanza_values({'id': id}) + iq['error']._set_stanza_values({'type': type, + 'condition': condition, + 'text': text}) + return iq + + def make_iq_query(self, iq=None, xmlns=''): + """ + Create or modify an Iq stanza to use the given + query namespace. + + Arguments: + iq -- Optional Iq stanza to modify. A new + stanza is created otherwise. + xmlns -- The query's namespace. + """ + if not iq: + iq = self.Iq() + iq['query'] = xmlns + return iq + + def make_query_roster(self, iq=None): + """ + Create a roster query element. + + Arguments: + iq -- Optional Iq stanza to modify. A new stanza + is created otherwise. + """ + 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 Message stanza. + + Arguments: + mto -- The recipient of the message. + mbody -- The main contents of the message. + msubject -- Optional subject for the message. + mtype -- The message's type, such as 'chat' or 'groupchat'. + mhtml -- Optional HTML body content. + 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. + 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): + """ + Create and initialize a new Presence stanza. + + Arguments: + pshow -- The presence's show value. + pstatus -- The presence's status message. + ppriority -- This connections' priority. + pto -- The recipient of a directed presence. + ptype -- The type of presence, such as 'subscribe'. + pfrom -- The sender of the presence. + """ + presence = self.Presence(stype=ptype, sfrom=pfrom, sto=pto) + if pshow is not None: + presence['type'] = pshow + if pfrom is None: + presence['from'] = self.boundjid.full + presence['priority'] = ppriority + presence['status'] = pstatus + return presence + + def send_message(self, mto, mbody, msubject=None, mtype=None, + mhtml=None, mfrom=None, mnick=None): + """ + Create, initialize, and send a Message stanza. + + + """ + self.makeMessage(mto, mbody, msubject, mtype, + mhtml, mfrom, mnick).send() + + def send_presence(self, pshow=None, pstatus=None, ppriority=None, + pto=None, pfrom=None, ptype=None): + """ + Create, initialize, and send a Presence stanza. + + Arguments: + pshow -- The presence's show value. + pstatus -- The presence's status message. + ppriority -- This connections' priority. + pto -- The recipient of a directed presence. + ptype -- The type of presence, such as 'subscribe'. + pfrom -- The sender of the presence. + """ + self.makePresence(pshow, pstatus, ppriority, pto, + ptype=ptype, pfrom=pfrom).send() + # Unexpected errors may occur if + if not self.sentpresence: + self.event('sent_presence') + self.sentpresence = True + + def send_presence_subscription(self, pto, pfrom=None, + ptype='subscribe', pnick=None): + """ + Create, initialize, and send a Presence stanza of type 'subscribe'. + + Arguments: + pto -- The recipient of a directed presence. + pfrom -- The sender of the presence. + ptype -- The type of presence. Defaults to 'subscribe'. + pnick -- Nickname of the presence's sender. + """ + presence = self.makePresence(ptype=ptype, + pfrom=pfrom, + pto=self.getjidbare(pto)) + if pnick: + nick = ET.Element('{http://jabber.org/protocol/nick}nick') + nick.text = pnick + presence.append(nick) + presence.send() + + @property + def jid(self): + """ + Attribute accessor for bare jid + """ + logging.warning("jid property deprecated. Use boundjid.bare") + return self.boundjid.bare + + @jid.setter + def jid(self, value): + logging.warning("jid property deprecated. Use boundjid.bare") + self.boundjid.bare = value + + @property + def fulljid(self): + """ + Attribute accessor for full jid + """ + logging.warning("fulljid property deprecated. Use boundjid.full") + return self.boundjid.full + + @fulljid.setter + def fulljid(self, value): + logging.warning("fulljid property deprecated. Use boundjid.full") + self.boundjid.full = value + + @property + def resource(self): + """ + Attribute accessor for jid resource + """ + logging.warning("resource property deprecated. Use boundjid.resource") + return self.boundjid.resource + + @resource.setter + def resource(self, value): + logging.warning("fulljid property deprecated. Use boundjid.full") + self.boundjid.resource = value + + @property + def username(self): + """ + Attribute accessor for jid usernode + """ + logging.warning("username property deprecated. Use boundjid.user") + return self.boundjid.user + + @username.setter + def username(self, value): + logging.warning("username property deprecated. Use boundjid.user") + self.boundjid.user = value + + @property + def server(self): + """ + Attribute accessor for jid host + """ + logging.warning("server property deprecated. Use boundjid.host") + return self.boundjid.server + + @server.setter + def server(self, value): + logging.warning("server property deprecated. Use boundjid.host") + self.boundjid.server = value + + def set_jid(self, jid): + """Rip a JID apart and claim it as our own.""" + logging.debug("setting jid to %s" % jid) + self.boundjid.full = jid + + def getjidresource(self, fulljid): + if '/' in fulljid: + return fulljid.split('/', 1)[-1] + else: + return '' + + def getjidbare(self, fulljid): + return fulljid.split('/', 1)[0] + + def _handle_disconnected(self, event): + """When disconnected, reset the roster""" + self.roster = {} + + def _handle_message(self, msg): + """Process incoming message stanzas.""" + self.event('message', msg) + + def _handle_presence(self, presence): + """ + Process incoming presence stanzas. + + Update the roster with presence information. + """ + self.event("presence_%s" % presence['type'], presence) + + # Check for changes in subscription state. + if presence['type'] in ('subscribe', 'subscribed', + 'unsubscribe', 'unsubscribed'): + self.event('changed_subscription', presence) + return + elif not presence['type'] in ('available', 'unavailable') and \ + not presence['type'] in presence.showtypes: + return + + # Strip the information from the stanza. + jid = presence['from'].bare + resource = presence['from'].resource + show = presence['type'] + status = presence['status'] + priority = presence['priority'] + + was_offline = False + got_online = False + old_roster = self.roster.get(jid, {}).get(resource, {}) + + # Create a new roster entry if needed. + if not jid in self.roster: + self.roster[jid] = {'groups': [], + 'name': '', + 'subscription': 'none', + 'presence': {}, + 'in_roster': False} + + # Alias to simplify some references. + connections = self.roster[jid]['presence'] + + # Determine if the user has just come online. + if not resource in connections: + if show == 'available' or show in presence.showtypes: + got_online = True + was_offline = True + connections[resource] = {} + + if connections[resource].get('show', 'unavailable') == 'unavailable': + was_offline = True + + # Update the roster's state for this JID's resource. + connections[resource] = {'show': show, + 'status': status, + 'priority': priority} + + name = self.roster[jid].get('name', '') + + # Remove unneeded state information after a resource + # disconnects. Determine if this was the last connection + # for the JID. + if show == 'unavailable': + logging.debug("%s %s got offline" % (jid, resource)) + del connections[resource] + + if not connections and not self.roster[jid]['in_roster']: + del self.roster[jid] + if not was_offline: + self.event("got_offline", presence) + else: + return False + + name = '(%s) ' % name if name else '' + + # Presence state has changed. + self.event("changed_status", presence) + if got_online: + self.event("got_online", presence) + logging.debug("STATUS: %s%s/%s[%s]: %s" % (name, jid, resource, + show, status)) + + def _handle_subscribe(self, presence): + """ + Automatically managage subscription requests. + + Subscription behavior is controlled by the settings + self.auto_authorize and self.auto_subscribe. + + auto_auth auto_sub Result: + True True Create bi-directional subsriptions. + True False Create only directed subscriptions. + False * Decline all subscriptions. + None * Disable automatic handling and use + a custom handler. + """ + presence.reply() + presence['to'] = presence['to'].bare + + # We are using trinary logic, so conditions have to be + # more explicit than usual. + if self.auto_authorize == True: + presence['type'] = 'subscribed' + presence.send() + if self.auto_subscribe: + presence['type'] = 'subscribe' + presence.send() + elif self.auto_authorize == False: + presence['type'] = 'unsubscribed' + presence.send() + +# Restore the old, lowercased name for backwards compatibility. +basexmpp = BaseXMPP diff --git a/sleekxmpp/clientxmpp.py b/sleekxmpp/clientxmpp.py new file mode 100644 index 0000000..dc4d0e4 --- /dev/null +++ b/sleekxmpp/clientxmpp.py @@ -0,0 +1,433 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from __future__ import absolute_import, unicode_literals + +import logging +import base64 +import sys +import hashlib +import random +import threading + +from sleekxmpp import plugins +from sleekxmpp import stanza +from sleekxmpp.basexmpp import BaseXMPP +from sleekxmpp.stanza import Message, Presence, Iq +from sleekxmpp.xmlstream import XMLStream, RestartStream +from sleekxmpp.xmlstream import StanzaBase, ET +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * + +# Flag indicating if DNS SRV records are available for use. +SRV_SUPPORT = True +try: + import dns.resolver +except: + SRV_SUPPORT = False + + +class ClientXMPP(BaseXMPP): + + """ + SleekXMPP's client class. + + Use only for good, not for evil. + + Attributes: + + Methods: + connect -- Overrides XMLStream.connect. + del_roster_item -- Delete a roster item. + get_roster -- Retrieve the roster from the server. + register_feature -- Register a stream feature. + update_roster -- Update a roster item. + """ + + def __init__(self, jid, password, ssl=False, plugin_config={}, + plugin_whitelist=[], escape_quotes=True): + """ + Create a new SleekXMPP client. + + Arguments: + jid -- The JID of the XMPP user account. + password -- The password for the XMPP user account. + ssl -- Deprecated. + plugin_config -- A dictionary of plugin configurations. + plugin_whitelist -- A list of approved plugins that will be loaded + when calling register_plugins. + escape_quotes -- Deprecated. + """ + BaseXMPP.__init__(self, 'jabber:client') + + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.updateRoster = self.update_roster + self.delRosterItem = self.del_roster_item + self.getRoster = self.get_roster + self.registerFeature = self.register_feature + + self.set_jid(jid) + self.password = password + self.escape_quotes = escape_quotes + self.plugin_config = plugin_config + self.plugin_whitelist = plugin_whitelist + self.srv_support = SRV_SUPPORT + + self.session_started_event = threading.Event() + self.session_started_event.clear() + + self.stream_header = "<stream:stream to='%s' %s %s version='1.0'>" % ( + self.boundjid.host, + "xmlns:stream='%s'" % self.stream_ns, + "xmlns='%s'" % self.default_ns) + self.stream_footer = "</stream:stream>" + + self.features = [] + self.registered_features = [] + + #TODO: Use stream state here + self.authenticated = False + self.sessionstarted = False + self.bound = False + self.bindfail = False + self.add_event_handler('connected', self.handle_connected) + + self.register_handler( + Callback('Stream Features', + MatchXPath('{%s}features' % self.stream_ns), + self._handle_stream_features)) + self.register_handler( + Callback('Roster Update', + MatchXPath('{%s}iq/{%s}query' % ( + self.default_ns, + 'jabber:iq:roster')), + self._handle_roster)) + + self.register_feature( + "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls' />", + self._handle_starttls, True) + self.register_feature( + "<mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl' />", + self._handle_sasl_auth, True) + self.register_feature( + "<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind' />", + self._handle_bind_resource) + self.register_feature( + "<session xmlns='urn:ietf:params:xml:ns:xmpp-session' />", + self._handle_start_session) + + def handle_connected(self, event=None): + #TODO: Use stream state here + self.authenticated = False + self.sessionstarted = False + self.bound = False + self.bindfail = False + self.schedule("session timeout checker", 15, + self._session_timeout_check) + + def _session_timeout_check(self): + if not self.session_started_event.isSet(): + logging.debug("Session start has taken more than 15 seconds") + self.disconnect(reconnect=self.auto_reconnect) + + def connect(self, address=tuple()): + """ + 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. + + Arguments: + address -- A tuple containing the server's host and port. + """ + self.session_started_event.clear() + if not address or len(address) < 2: + if not self.srv_support: + logging.debug("Did not supply (address, port) to connect" + \ + " to and no SRV support is installed" + \ + " (http://www.dnspython.org)." + \ + " Continuing to attempt connection, using" + \ + " server hostname from JID.") + else: + logging.debug("Since no address is supplied," + \ + "attempting SRV lookup.") + try: + xmpp_srv = "_xmpp-client._tcp.%s" % self.server + answers = dns.resolver.query(xmpp_srv, dns.rdatatype.SRV) + except dns.resolver.NXDOMAIN: + logging.debug("No appropriate SRV record found." + \ + " Using JID server name.") + else: + # Pick a random server, weighted by priority. + + addresses = {} + intmax = 0 + for answer in answers: + intmax += answer.priority + addresses[intmax] = (answer.target.to_text()[:-1], + answer.port) + #python3 returns a generator for dictionary keys + priorities = [x for x in addresses.keys()] + priorities.sort() + + picked = random.randint(0, intmax) + for priority in priorities: + if picked <= priority: + address = addresses[priority] + break + + if not address: + # If all else fails, use the server from the JID. + address = (self.boundjid.host, 5222) + + return XMLStream.connect(self, address[0], address[1], use_tls=True) + + def register_feature(self, mask, pointer, breaker=False): + """ + Register a stream feature. + + Arguments: + mask -- An XML string matching the feature's element. + pointer -- The function to execute if the feature is received. + breaker -- Indicates if feature processing should halt with + this feature. Defaults to False. + """ + self.registered_features.append((MatchXMLMask(mask), + pointer, + breaker)) + + def update_roster(self, jid, name=None, subscription=None, groups=[]): + """ + Add or change a roster item. + + Arguments: + jid -- The JID of the entry to modify. + name -- The user's nickname for this JID. + subscription -- The subscription status. May be one of + 'to', 'from', 'both', or 'none'. If set + to 'remove', the entry will be deleted. + groups -- The roster groups that contain this item. + """ + iq = self.Iq()._set_stanza_values({'type': 'set'}) + iq['roster']['items'] = {jid: {'name': name, + 'subscription': subscription, + 'groups': groups}} + response = iq.send() + return response['type'] == 'result' + + def del_roster_item(self, jid): + """ + Remove an item from the roster by setting its subscription + status to 'remove'. + + Arguments: + jid -- The JID of the item to remove. + """ + return self.update_roster(jid, subscription='remove') + + def get_roster(self): + """Request the roster from the server.""" + iq = self.Iq()._set_stanza_values({'type': 'get'}).enable('roster') + response = iq.send() + self._handle_roster(response, request=True) + + def _handle_stream_features(self, features): + """ + Process the received stream features. + + Arguments: + features -- The features stanza. + """ + # Record all of the features. + self.features = [] + for sub in features.xml: + self.features.append(sub.tag) + + # Process the features. + for sub in features.xml: + for feature in self.registered_features: + mask, handler, halt = feature + if mask.match(sub): + if handler(sub) and halt: + # Don't continue if the feature was + # marked as a breaker. + return True + + def _handle_starttls(self, xml): + """ + Handle notification that the server supports TLS. + + Arguments: + xml -- The STARTLS proceed element. + """ + if not self.authenticated and self.ssl_support: + tls_ns = 'urn:ietf:params:xml:ns:xmpp-tls' + self.add_handler("<proceed xmlns='%s' />" % tls_ns, + self._handle_tls_start, + name='TLS Proceed', + instream=True) + self.send_xml(xml) + return True + else: + logging.warning("The module tlslite is required to log in" +\ + " to some servers, and has not been found.") + return False + + def _handle_tls_start(self, xml): + """ + Handle encrypting the stream using TLS. + + Restarts the stream. + """ + logging.debug("Starting TLS") + if self.start_tls(): + raise RestartStream() + + def _handle_sasl_auth(self, xml): + """ + Handle authenticating using SASL. + + Arguments: + xml -- The SASL mechanisms stanza. + """ + if '{urn:ietf:params:xml:ns:xmpp-tls}starttls' in self.features: + return False + + logging.debug("Starting SASL Auth") + sasl_ns = 'urn:ietf:params:xml:ns:xmpp-sasl' + self.add_handler("<success xmlns='%s' />" % sasl_ns, + self._handle_auth_success, + name='SASL Sucess', + instream=True) + self.add_handler("<failure xmlns='%s' />" % sasl_ns, + self._handle_auth_fail, + name='SASL Failure', + instream=True) + + sasl_mechs = xml.findall('{%s}mechanism' % sasl_ns) + if sasl_mechs: + for sasl_mech in sasl_mechs: + self.features.append("sasl:%s" % sasl_mech.text) + if 'sasl:PLAIN' in self.features and self.boundjid.user: + if sys.version_info < (3, 0): + user = bytes(self.boundjid.user) + password = bytes(self.password) + else: + user = bytes(self.boundjid.user, 'utf-8') + password = bytes(self.password, 'utf-8') + + auth = base64.b64encode(b'\x00' + user + \ + b'\x00' + password).decode('utf-8') + + self.send("<auth xmlns='%s' mechanism='PLAIN'>%s</auth>" % ( + sasl_ns, + auth)) + elif 'sasl:ANONYMOUS' in self.features and not self.boundjid.user: + self.send("<auth xmlns='%s' mechanism='%s' />" % ( + sasl_ns, + 'ANONYMOUS')) + else: + logging.error("No appropriate login method.") + self.disconnect() + return True + + def _handle_auth_success(self, xml): + """ + SASL authentication succeeded. Restart the stream. + + Arguments: + xml -- The SASL authentication success element. + """ + self.authenticated = True + self.features = [] + raise RestartStream() + + def _handle_auth_fail(self, xml): + """ + SASL authentication failed. Disconnect and shutdown. + + Arguments: + xml -- The SASL authentication failure element. + """ + logging.info("Authentication failed.") + self.event("failed_auth", direct=True) + self.disconnect() + + def _handle_bind_resource(self, xml): + """ + Handle requesting a specific resource. + + Arguments: + xml -- The bind feature element. + """ + logging.debug("Requesting resource: %s" % self.boundjid.resource) + xml.clear() + iq = self.Iq(stype='set') + if self.boundjid.resource: + res = ET.Element('resource') + res.text = self.boundjid.resource + xml.append(res) + iq.append(xml) + response = iq.send() + + bind_ns = 'urn:ietf:params:xml:ns:xmpp-bind' + self.set_jid(response.xml.find('{%s}bind/{%s}jid' % (bind_ns, + bind_ns)).text) + self.bound = True + logging.info("Node set to: %s" % self.boundjid.fulljid) + session_ns = 'urn:ietf:params:xml:ns:xmpp-session' + if "{%s}session" % session_ns not in self.features or self.bindfail: + logging.debug("Established Session") + self.sessionstarted = True + self.session_started_event.set() + self.event("session_start") + + def _handle_start_session(self, xml): + """ + Handle the start of the session. + + Arguments: + xml -- The session feature element. + """ + if self.authenticated and self.bound: + iq = self.makeIqSet(xml) + response = iq.send() + logging.debug("Established Session") + self.sessionstarted = True + self.session_started_event.set() + self.event("session_start") + else: + # Bind probably hasn't happened yet. + self.bindfail = True + + def _handle_roster(self, iq, request=False): + """ + Update the roster after receiving a roster stanza. + + Arguments: + iq -- The roster stanza. + request -- Indicates if this stanza is a response + to a request for the roster. + """ + if iq['type'] == 'set' or (iq['type'] == 'result' and request): + for jid in iq['roster']['items']: + if not jid in self.roster: + self.roster[jid] = {'groups': [], + 'name': '', + 'subscription': 'none', + 'presence': {}, + 'in_roster': True} + self.roster[jid].update(iq['roster']['items'][jid]) + + self.event("roster_update", iq) + if iq['type'] == 'set': + iq.reply() + iq.enable('roster') + iq.send() diff --git a/sleekxmpp/componentxmpp.py b/sleekxmpp/componentxmpp.py new file mode 100644 index 0000000..3fcb88d --- /dev/null +++ b/sleekxmpp/componentxmpp.py @@ -0,0 +1,138 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from __future__ import absolute_import + +import logging +import base64 +import sys +import hashlib + +from sleekxmpp import plugins +from sleekxmpp import stanza +from sleekxmpp.basexmpp import BaseXMPP, SRV_SUPPORT +from sleekxmpp.xmlstream import XMLStream, RestartStream +from sleekxmpp.xmlstream import StanzaBase, ET +from sleekxmpp.xmlstream.matcher import * +from sleekxmpp.xmlstream.handler import * + + +class ComponentXMPP(BaseXMPP): + + """ + SleekXMPP's basic XMPP server component. + + Use only for good, not for evil. + + Methods: + connect -- Overrides XMLStream.connect. + incoming_filter -- Overrides XMLStream.incoming_filter. + start_stream_handler -- Overrides XMLStream.start_stream_handler. + """ + + def __init__(self, jid, secret, host, port, + plugin_config={}, plugin_whitelist=[], use_jc_ns=False): + """ + Arguments: + jid -- The JID of the component. + secret -- The secret or password for the component. + host -- The server accepting the component. + port -- The port used to connect to the server. + plugin_config -- A dictionary of plugin configurations. + plugin_whitelist -- A list of desired plugins to load + when using register_plugins. + use_js_ns -- Indicates if the 'jabber:client' namespace + should be used instead of the standard + 'jabber:component:accept' namespace. + Defaults to False. + """ + if use_jc_ns: + default_ns = 'jabber:client' + else: + default_ns = 'jabber:component:accept' + BaseXMPP.__init__(self, 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.set_jid(jid) + self.secret = secret + self.plugin_config = plugin_config + self.plugin_whitelist = plugin_whitelist + self.is_component = True + + self.register_handler( + Callback('Handshake', + MatchXPath('{jabber:component:accept}handshake'), + self._handle_handshake)) + + def connect(self): + """ + Connect to the server. + + Overrides XMLStream.connect. + """ + logging.debug("Connecting to %s:%s" % (self.server_host, + self.server_port)) + return XMLStream.connect(self, self.server_host, + self.server_port) + + def incoming_filter(self, xml): + """ + Pre-process incoming XML stanzas by converting any 'jabber:client' + namespaced elements to the component's default namespace. + + Overrides XMLStream.incoming_filter. + + Arguments: + xml -- The XML stanza to pre-process. + """ + if xml.tag.startswith('{jabber:client}'): + xml.tag = xml.tag.replace('jabber:client', self.default_ns) + + # The incoming_filter call is only made on top level stanza + # elements. So we manually continue filtering on sub-elements. + for sub in xml: + self.incoming_filter(sub) + + return xml + + def start_stream_handler(self, xml): + """ + Once the streams are established, attempt to handshake + with the server to be accepted as a component. + + Overrides XMLStream.start_stream_handler. + + Arguments: + xml -- The incoming stream's root element. + """ + # Construct a hash of the stream ID and the component secret. + sid = xml.get('id', '') + pre_hash = '%s%s' % (sid, self.secret) + if sys.version_info >= (3, 0): + # Handle Unicode byte encoding in Python 3. + pre_hash = bytes(pre_hash, 'utf-8') + + handshake = ET.Element('{jabber:component:accept}handshake') + handshake.text = hashlib.sha1(pre_hash).hexdigest().lower() + self.send_xml(handshake) + + def _handle_handshake(self, xml): + """ + The handshake has been accepted. + + Arguments: + xml -- The reply handshake stanza. + """ + self.event("session_start") diff --git a/sleekxmpp/exceptions.py b/sleekxmpp/exceptions.py new file mode 100644 index 0000000..d3988b4 --- /dev/null +++ b/sleekxmpp/exceptions.py @@ -0,0 +1,49 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +class 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 RootStanza will create + an error stanza and initialize any additional substanzas using the + extension information included in the exception. + + Meant for use in SleekXMPP plugins and applications using SleekXMPP. + """ + + def __init__(self, condition='undefined-condition', text=None, etype=None, + extension=None, extension_ns=None, extension_args=None): + """ + Create a new XMPPError exception. + + Extension information can be included to add additional XML elements + to the generated error stanza. + + Arguments: + condition -- The XMPP defined error condition. + text -- Human readable text describing the error. + etype -- The XMPP error type, such as cancel or modify. + extension -- Tag name of the extension's XML content. + extension_ns -- XML namespace of the extensions' XML content. + extension_args -- Content and attributes for the extension + element. Same as the additional arguments to + the ET.Element constructor. + """ + if extension_args is None: + extension_args = {} + + self.condition = condition + self.text = text + self.etype = etype + self.extension = extension + self.extension_ns = extension_ns + self.extension_args = extension_args diff --git a/sleekxmpp/plugins/__init__.py b/sleekxmpp/plugins/__init__.py new file mode 100644 index 0000000..36fa177 --- /dev/null +++ b/sleekxmpp/plugins/__init__.py @@ -0,0 +1,9 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +__all__ = ['xep_0004', 'xep_0030', 'xep_0033', 'xep_0045', 'xep_0050', +'xep_0078', 'xep_0085', 'xep_0092', 'xep_0199', 'gmail_notify', 'xep_0060'] diff --git a/sleekxmpp/plugins/base.py b/sleekxmpp/plugins/base.py new file mode 100644 index 0000000..254397e --- /dev/null +++ b/sleekxmpp/plugins/base.py @@ -0,0 +1,26 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +class base_plugin(object): + + def __init__(self, xmpp, config): + self.xep = 'base' + self.description = 'Base Plugin' + self.xmpp = xmpp + self.config = config + self.post_inited = False + self.enable = config.get('enable', True) + if self.enable: + self.plugin_init() + + def plugin_init(self): + pass + + def post_init(self): + self.post_inited = True diff --git a/sleekxmpp/plugins/gmail_notify.py b/sleekxmpp/plugins/gmail_notify.py new file mode 100644 index 0000000..7e44234 --- /dev/null +++ b/sleekxmpp/plugins/gmail_notify.py @@ -0,0 +1,146 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +from . import base +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.iq import Iq + + +class GmailQuery(ElementBase): + namespace = 'google:mail:notify' + name = 'query' + plugin_attrib = 'gmail' + interfaces = set(('newer-than-time', 'newer-than-tid', 'q', 'search')) + + def getSearch(self): + return self['q'] + + def setSearch(self, search): + self['q'] = search + + def delSearch(self): + del self['q'] + + +class MailBox(ElementBase): + namespace = 'google:mail:notify' + name = 'mailbox' + plugin_attrib = 'mailbox' + interfaces = set(('result-time', 'total-matched', 'total-estimate', + 'url', 'threads', 'matched', 'estimate')) + + def getThreads(self): + threads = [] + for threadXML in self.xml.findall('{%s}%s' % (MailThread.namespace, + MailThread.name)): + threads.append(MailThread(xml=threadXML, parent=None)) + return threads + + def getMatched(self): + return self['total-matched'] + + def getEstimate(self): + return self['total-estimate'] == '1' + + +class MailThread(ElementBase): + namespace = 'google:mail:notify' + name = 'mail-thread-info' + plugin_attrib = 'thread' + interfaces = set(('tid', 'participation', 'messages', 'date', + 'senders', 'url', 'labels', 'subject', 'snippet')) + sub_interfaces = set(('labels', 'subject', 'snippet')) + + def getSenders(self): + senders = [] + sendersXML = self.xml.find('{%s}senders' % self.namespace) + if sendersXML is not None: + for senderXML in sendersXML.findall('{%s}sender' % self.namespace): + senders.append(MailSender(xml=senderXML, parent=None)) + return senders + + +class MailSender(ElementBase): + namespace = 'google:mail:notify' + name = 'sender' + plugin_attrib = 'sender' + interfaces = set(('address', 'name', 'originator', 'unread')) + + def getOriginator(self): + return self.xml.attrib.get('originator', '0') == '1' + + def getUnread(self): + return self.xml.attrib.get('unread', '0') == '1' + + +class NewMail(ElementBase): + namespace = 'google:mail:notify' + name = 'new-mail' + plugin_attrib = 'new-mail' + + +class gmail_notify(base.base_plugin): + """ + Google Talk: Gmail Notifications + """ + + def plugin_init(self): + self.description = 'Google Talk: Gmail Notifications' + + self.xmpp.registerHandler( + Callback('Gmail Result', + MatchXPath('{%s}iq/{%s}%s' % (self.xmpp.default_ns, + MailBox.namespace, + MailBox.name)), + self.handle_gmail)) + + self.xmpp.registerHandler( + Callback('Gmail New Mail', + MatchXPath('{%s}iq/{%s}%s' % (self.xmpp.default_ns, + NewMail.namespace, + NewMail.name)), + self.handle_new_mail)) + + registerStanzaPlugin(Iq, GmailQuery) + registerStanzaPlugin(Iq, MailBox) + registerStanzaPlugin(Iq, NewMail) + + self.last_result_time = None + + def handle_gmail(self, iq): + mailbox = iq['mailbox'] + approx = ' approximately' if mailbox['estimated'] else '' + logging.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): + logging.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: + logging.info("Gmail: Checking for new emails") + else: + logging.info('Gmail: Searching for emails matching: "%s"' % query) + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['to'] = self.xmpp.jid + iq['gmail']['q'] = query + iq['gmail']['newer-than-time'] = newer + return iq.send() diff --git a/sleekxmpp/plugins/jobs.py b/sleekxmpp/plugins/jobs.py new file mode 100644 index 0000000..c52e524 --- /dev/null +++ b/sleekxmpp/plugins/jobs.py @@ -0,0 +1,46 @@ +from . import base +import logging +from xml.etree import cElementTree as ET +import types + +class jobs(base.base_plugin): + def plugin_init(self): + self.xep = 'pubsubjob' + self.description = "Job distribution over Pubsub" + + def post_init(self): + pass + #TODO add event + + def createJobNode(self, host, jid, node, config=None): + pass + + def createJob(self, host, node, jobid=None, payload=None): + return self.xmpp.plugin['xep_0060'].setItem(host, node, ((jobid, payload),)) + + def claimJob(self, host, node, jobid, ifrom=None): + return self._setState(host, node, jobid, ET.Element('{http://andyet.net/protocol/pubsubjob}claimed')) + + def unclaimJob(self, host, node, jobid): + return self._setState(host, node, jobid, ET.Element('{http://andyet.net/protocol/pubsubjob}unclaimed')) + + def finishJob(self, host, node, jobid, payload=None): + finished = ET.Element('{http://andyet.net/protocol/pubsubjob}finished') + if payload is not None: + finished.append(payload) + return self._setState(host, node, jobid, finished) + + def _setState(self, host, node, jobid, state, ifrom=None): + iq = self.xmpp.Iq() + iq['to'] = host + if ifrom: iq['from'] = ifrom + iq['type'] = 'set' + iq['psstate']['node'] = node + iq['psstate']['item'] = jobid + iq['psstate']['payload'] = state + result = iq.send() + if result is None or type(result) == types.BooleanType or result['type'] != 'result': + logging.error("Unable to change %s:%s to %s" % (node, jobid, state)) + return False + return True + diff --git a/sleekxmpp/plugins/old_0004.py b/sleekxmpp/plugins/old_0004.py new file mode 100644 index 0000000..651408a --- /dev/null +++ b/sleekxmpp/plugins/old_0004.py @@ -0,0 +1,417 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from . import base +import logging +from xml.etree import cElementTree as ET +import copy +import logging +#TODO support item groups and results + +class old_0004(base.base_plugin): + + def plugin_init(self): + self.xep = '0004' + self.description = '*Deprecated Data Forms' + self.xmpp.add_handler("<message><x xmlns='jabber:x:data' /></message>", self.handler_message_xform, name='Old Message Form') + + def post_init(self): + base.base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature('jabber:x:data') + logging.warning("This implementation of XEP-0004 is deprecated.") + + def handler_message_xform(self, xml): + object = self.handle_form(xml) + self.xmpp.event("message_form", object) + + def handler_presence_xform(self, xml): + object = self.handle_form(xml) + self.xmpp.event("presence_form", object) + + def handle_form(self, xml): + xmlform = xml.find('{jabber:x:data}x') + object = self.buildForm(xmlform) + self.xmpp.event("message_xform", object) + return object + + def buildForm(self, xml): + form = Form(ftype=xml.attrib['type']) + form.fromXML(xml) + return form + + def makeForm(self, ftype='form', title='', instructions=''): + return Form(self.xmpp, ftype, title, instructions) + +class FieldContainer(object): + def __init__(self, stanza = 'form'): + self.fields = [] + self.field = {} + self.stanza = stanza + + def addField(self, var, ftype='text-single', label='', desc='', required=False, value=None): + self.field[var] = FormField(var, ftype, label, desc, required, value) + self.fields.append(self.field[var]) + return self.field[var] + + def buildField(self, xml): + self.field[xml.get('var', '__unnamed__')] = FormField(xml.get('var', '__unnamed__'), xml.get('type', 'text-single')) + self.fields.append(self.field[xml.get('var', '__unnamed__')]) + self.field[xml.get('var', '__unnamed__')].buildField(xml) + + def buildContainer(self, xml): + self.stanza = xml.tag + for field in xml.findall('{jabber:x:data}field'): + self.buildField(field) + + def getXML(self, ftype): + container = ET.Element(self.stanza) + for field in self.fields: + container.append(field.getXML(ftype)) + return container + +class Form(FieldContainer): + types = ('form', 'submit', 'cancel', 'result') + def __init__(self, xmpp=None, ftype='form', title='', instructions=''): + if not ftype in self.types: + raise ValueError("Invalid Form Type") + FieldContainer.__init__(self) + self.xmpp = xmpp + self.type = ftype + self.title = title + self.instructions = instructions + self.reported = [] + self.items = [] + + def merge(self, form2): + form1 = Form(ftype=self.type) + form1.fromXML(self.getXML(self.type)) + for field in form2.fields: + if not field.var in form1.field: + form1.addField(field.var, field.type, field.label, field.desc, field.required, field.value) + else: + form1.field[field.var].value = field.value + for option, label in field.options: + if (option, label) not in form1.field[field.var].options: + form1.fields[field.var].addOption(option, label) + return form1 + + def copy(self): + newform = Form(ftype=self.type) + newform.fromXML(self.getXML(self.type)) + return newform + + def update(self, form): + values = form.getValues() + for var in values: + if var in self.fields: + self.fields[var].setValue(self.fields[var]) + + def getValues(self): + result = {} + for field in self.fields: + value = field.value + if len(value) == 1: + value = value[0] + result[field.var] = value + return result + + def setValues(self, values={}): + for field in values: + if field in self.field: + if isinstance(values[field], list) or isinstance(values[field], tuple): + for value in values[field]: + self.field[field].setValue(value) + else: + self.field[field].setValue(values[field]) + + def fromXML(self, xml): + self.buildForm(xml) + + def addItem(self): + newitem = FieldContainer('item') + self.items.append(newitem) + return newitem + + def buildItem(self, xml): + newitem = self.addItem() + newitem.buildContainer(xml) + + def addReported(self): + reported = FieldContainer('reported') + self.reported.append(reported) + return reported + + def buildReported(self, xml): + reported = self.addReported() + reported.buildContainer(xml) + + def setTitle(self, title): + self.title = title + + def setInstructions(self, instructions): + self.instructions = instructions + + def setType(self, ftype): + self.type = ftype + + def getXMLMessage(self, to): + msg = self.xmpp.makeMessage(to) + msg.append(self.getXML()) + return msg + + def buildForm(self, xml): + self.type = xml.get('type', 'form') + if xml.find('{jabber:x:data}title') is not None: + self.setTitle(xml.find('{jabber:x:data}title').text) + if xml.find('{jabber:x:data}instructions') is not None: + self.setInstructions(xml.find('{jabber:x:data}instructions').text) + for field in xml.findall('{jabber:x:data}field'): + self.buildField(field) + for reported in xml.findall('{jabber:x:data}reported'): + self.buildReported(reported) + for item in xml.findall('{jabber:x:data}item'): + self.buildItem(item) + + #def getXML(self, tostring = False): + def getXML(self, ftype=None): + if ftype: + self.type = ftype + form = ET.Element('{jabber:x:data}x') + form.attrib['type'] = self.type + if self.title and self.type in ('form', 'result'): + title = ET.Element('{jabber:x:data}title') + title.text = self.title + form.append(title) + if self.instructions and self.type == 'form': + instructions = ET.Element('{jabber:x:data}instructions') + instructions.text = self.instructions + form.append(instructions) + for field in self.fields: + form.append(field.getXML(self.type)) + for reported in self.reported: + form.append(reported.getXML('{jabber:x:data}reported')) + for item in self.items: + form.append(item.getXML(self.type)) + #if tostring: + # form = self.xmpp.tostring(form) + return form + + def getXHTML(self): + form = ET.Element('{http://www.w3.org/1999/xhtml}form') + if self.title: + title = ET.Element('h2') + title.text = self.title + form.append(title) + if self.instructions: + instructions = ET.Element('p') + instructions.text = self.instructions + form.append(instructions) + for field in self.fields: + form.append(field.getXHTML()) + for field in self.reported: + form.append(field.getXHTML()) + for field in self.items: + form.append(field.getXHTML()) + return form + + + def makeSubmit(self): + self.setType('submit') + +class FormField(object): + types = ('boolean', 'fixed', 'hidden', 'jid-multi', 'jid-single', 'list-multi', 'list-single', 'text-multi', 'text-private', 'text-single') + listtypes = ('jid-multi', 'jid-single', 'list-multi', 'list-single') + lbtypes = ('fixed', 'text-multi') + def __init__(self, var, ftype='text-single', label='', desc='', required=False, value=None): + if not ftype in self.types: + raise ValueError("Invalid Field Type") + self.type = ftype + self.var = var + self.label = label + self.desc = desc + self.options = [] + self.required = False + self.value = [] + if self.type in self.listtypes: + self.islist = True + else: + self.islist = False + if self.type in self.lbtypes: + self.islinebreak = True + else: + self.islinebreak = False + if value: + self.setValue(value) + + def addOption(self, value, label): + if self.islist: + self.options.append((value, label)) + else: + raise ValueError("Cannot add options to non-list type field.") + + def setTrue(self): + if self.type == 'boolean': + self.value = [True] + + def setFalse(self): + if self.type == 'boolean': + self.value = [False] + + def require(self): + self.required = True + + def setDescription(self, desc): + self.desc = desc + + def setValue(self, value): + if self.type == 'boolean': + if value in ('1', 1, True, 'true', 'True', 'yes'): + value = True + else: + value = False + if self.islinebreak and value is not None: + self.value += value.split('\n') + else: + if len(self.value) and (not self.islist or self.type == 'list-single'): + self.value = [value] + else: + self.value.append(value) + + def delValue(self, value): + if type(self.value) == type([]): + try: + idx = self.value.index(value) + if idx != -1: + self.value.pop(idx) + except ValueError: + pass + else: + self.value = '' + + def setAnswer(self, value): + self.setValue(value) + + def buildField(self, xml): + self.type = xml.get('type', 'text-single') + self.label = xml.get('label', '') + for option in xml.findall('{jabber:x:data}option'): + self.addOption(option.find('{jabber:x:data}value').text, option.get('label', '')) + for value in xml.findall('{jabber:x:data}value'): + self.setValue(value.text) + if xml.find('{jabber:x:data}required') is not None: + self.require() + if xml.find('{jabber:x:data}desc') is not None: + self.setDescription(xml.find('{jabber:x:data}desc').text) + + def getXML(self, ftype): + field = ET.Element('{jabber:x:data}field') + if ftype != 'result': + field.attrib['type'] = self.type + if self.type != 'fixed': + if self.var: + field.attrib['var'] = self.var + if self.label: + field.attrib['label'] = self.label + if ftype == 'form': + for option in self.options: + optionxml = ET.Element('{jabber:x:data}option') + optionxml.attrib['label'] = option[1] + optionval = ET.Element('{jabber:x:data}value') + optionval.text = option[0] + optionxml.append(optionval) + field.append(optionxml) + if self.required: + required = ET.Element('{jabber:x:data}required') + field.append(required) + if self.desc: + desc = ET.Element('{jabber:x:data}desc') + desc.text = self.desc + field.append(desc) + for value in self.value: + valuexml = ET.Element('{jabber:x:data}value') + if value is True or value is False: + if value: + valuexml.text = '1' + else: + valuexml.text = '0' + else: + valuexml.text = value + field.append(valuexml) + return field + + def getXHTML(self): + field = ET.Element('div', {'class': 'xmpp-xforms-%s' % self.type}) + if self.label: + label = ET.Element('p') + label.text = "%s: " % self.label + else: + label = ET.Element('p') + label.text = "%s: " % self.var + field.append(label) + if self.type == 'boolean': + formf = ET.Element('input', {'type': 'checkbox', 'name': self.var}) + if len(self.value) and self.value[0] in (True, 'true', '1'): + formf.attrib['checked'] = 'checked' + elif self.type == 'fixed': + formf = ET.Element('p') + try: + formf.text = ', '.join(self.value) + except: + pass + field.append(formf) + formf = ET.Element('input', {'type': 'hidden', 'name': self.var}) + try: + formf.text = ', '.join(self.value) + except: + pass + elif self.type == 'hidden': + formf = ET.Element('input', {'type': 'hidden', 'name': self.var}) + try: + formf.text = ', '.join(self.value) + except: + pass + elif self.type in ('jid-multi', 'list-multi'): + formf = ET.Element('select', {'name': self.var}) + for option in self.options: + optf = ET.Element('option', {'value': option[0], 'multiple': 'multiple'}) + optf.text = option[1] + if option[1] in self.value: + optf.attrib['selected'] = 'selected' + formf.append(option) + elif self.type in ('jid-single', 'text-single'): + formf = ET.Element('input', {'type': 'text', 'name': self.var}) + try: + formf.attrib['value'] = ', '.join(self.value) + except: + pass + elif self.type == 'list-single': + formf = ET.Element('select', {'name': self.var}) + for option in self.options: + optf = ET.Element('option', {'value': option[0]}) + optf.text = option[1] + if not optf.text: + optf.text = option[0] + if option[1] in self.value: + optf.attrib['selected'] = 'selected' + formf.append(optf) + elif self.type == 'text-multi': + formf = ET.Element('textarea', {'name': self.var}) + try: + formf.text = ', '.join(self.value) + except: + pass + if not formf.text: + formf.text = ' ' + elif self.type == 'text-private': + formf = ET.Element('input', {'type': 'password', 'name': self.var}) + try: + formf.attrib['value'] = ', '.join(self.value) + except: + pass + label.append(formf) + return field + diff --git a/sleekxmpp/plugins/stanza_pubsub.py b/sleekxmpp/plugins/stanza_pubsub.py new file mode 100644 index 0000000..2d809a3 --- /dev/null +++ b/sleekxmpp/plugins/stanza_pubsub.py @@ -0,0 +1,555 @@ +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.iq import Iq +from .. stanza.message import Message +from .. basexmpp import basexmpp +from .. xmlstream.xmlstream import XMLStream +import logging +from . import xep_0004 + + +class PubsubState(ElementBase): + namespace = 'http://jabber.org/protocol/psstate' + name = 'state' + plugin_attrib = 'psstate' + interfaces = set(('node', 'item', 'payload')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setPayload(self, value): + self.xml.append(value) + + def getPayload(self): + childs = self.xml.getchildren() + if len(childs) > 0: + return childs[0] + + def delPayload(self): + for child in self.xml.getchildren(): + self.xml.remove(child) + +registerStanzaPlugin(Iq, PubsubState) + +class PubsubStateEvent(ElementBase): + namespace = 'http://jabber.org/protocol/psstate#event' + name = 'event' + plugin_attrib = 'psstate_event' + intefaces = set(tuple()) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Message, PubsubStateEvent) +registerStanzaPlugin(PubsubStateEvent, PubsubState) + +class Pubsub(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'pubsub' + plugin_attrib = 'pubsub' + interfaces = set(tuple()) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Iq, Pubsub) + +class PubsubOwner(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'pubsub' + plugin_attrib = 'pubsub_owner' + interfaces = set(tuple()) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Iq, PubsubOwner) + +class Affiliation(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'affiliation' + plugin_attrib = name + interfaces = set(('node', 'affiliation')) + plugin_attrib_map = {} + plugin_tag_map = {} + +class Affiliations(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'affiliations' + plugin_attrib = 'affiliations' + interfaces = set(tuple()) + plugin_attrib_map = {} + plugin_tag_map = {} + subitem = (Affiliation,) + + def append(self, affiliation): + if not isinstance(affiliation, Affiliation): + raise TypeError + self.xml.append(affiliation.xml) + return self.iterables.append(affiliation) + +registerStanzaPlugin(Pubsub, Affiliations) + + +class Subscription(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'subscription' + plugin_attrib = name + interfaces = set(('jid', 'node', 'subscription', 'subid')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setjid(self, value): + self._setattr('jid', str(value)) + + def getjid(self): + return jid(self._getattr('jid')) + +registerStanzaPlugin(Pubsub, Subscription) + +class Subscriptions(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'subscriptions' + plugin_attrib = 'subscriptions' + interfaces = set(tuple()) + plugin_attrib_map = {} + plugin_tag_map = {} + subitem = (Subscription,) + +registerStanzaPlugin(Pubsub, Subscriptions) + +class OptionalSetting(object): + interfaces = set(('required',)) + + def setRequired(self, value): + value = bool(value) + if value and not self['required']: + self.xml.append(ET.Element("{%s}required" % self.namespace)) + elif not value and self['required']: + self.delRequired() + + def getRequired(self): + required = self.xml.find("{%s}required" % self.namespace) + if required is not None: + return True + else: + return False + + def delRequired(self): + required = self.xml.find("{%s}required" % self.namespace) + if required is not None: + self.xml.remove(required) + + +class SubscribeOptions(ElementBase, OptionalSetting): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'subscribe-options' + plugin_attrib = 'suboptions' + plugin_attrib_map = {} + plugin_tag_map = {} + interfaces = set(('required',)) + +registerStanzaPlugin(Subscription, SubscribeOptions) + +class Item(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'item' + plugin_attrib = name + interfaces = set(('id', 'payload')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setPayload(self, value): + self.xml.append(value) + + def getPayload(self): + childs = self.xml.getchildren() + if len(childs) > 0: + return childs[0] + + def delPayload(self): + for child in self.xml.getchildren(): + self.xml.remove(child) + +class Items(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'items' + plugin_attrib = 'items' + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + subitem = (Item,) + +registerStanzaPlugin(Pubsub, Items) + +class Create(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'create' + plugin_attrib = name + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Pubsub, Create) + +#class Default(ElementBase): +# namespace = 'http://jabber.org/protocol/pubsub' +# name = 'default' +# plugin_attrib = name +# interfaces = set(('node', 'type')) +# plugin_attrib_map = {} +# plugin_tag_map = {} +# +# def getType(self): +# t = self._getAttr('type') +# if not t: t == 'leaf' +# return t +# +#registerStanzaPlugin(Pubsub, Default) + +class Publish(Items): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'publish' + plugin_attrib = name + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + subitem = (Item,) + +registerStanzaPlugin(Pubsub, Publish) + +class Retract(Items): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'retract' + plugin_attrib = name + interfaces = set(('node', 'notify')) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Pubsub, Retract) + +class Unsubscribe(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'unsubscribe' + plugin_attrib = name + interfaces = set(('node', 'jid')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setJid(self, value): + self._setAttr('jid', str(value)) + + def getJid(self): + return JID(self._getAttr('jid')) + +class Subscribe(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'subscribe' + plugin_attrib = name + interfaces = set(('node', 'jid')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setJid(self, value): + self._setAttr('jid', str(value)) + + def getJid(self): + return JID(self._getAttr('jid')) + +registerStanzaPlugin(Pubsub, Subscribe) + +class Configure(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'configure' + plugin_attrib = name + interfaces = set(('node', 'type')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def getType(self): + t = self._getAttr('type') + if not t: t == 'leaf' + return t + +registerStanzaPlugin(Pubsub, Configure) +registerStanzaPlugin(Configure, xep_0004.Form) + +class DefaultConfig(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'default' + plugin_attrib = 'default' + interfaces = set(('node', 'type', 'config')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def __init__(self, *args, **kwargs): + ElementBase.__init__(self, *args, **kwargs) + + def getType(self): + t = self._getAttr('type') + if not t: t = 'leaf' + return t + + def getConfig(self): + return self['form'] + + def setConfig(self, value): + self['form'].setStanzaValues(value.getStanzaValues()) + return self + +registerStanzaPlugin(PubsubOwner, DefaultConfig) +registerStanzaPlugin(DefaultConfig, xep_0004.Form) + +class Options(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub' + name = 'options' + plugin_attrib = 'options' + interfaces = set(('jid', 'node', 'options')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def __init__(self, *args, **kwargs): + ElementBase.__init__(self, *args, **kwargs) + + def getOptions(self): + config = self.xml.find('{jabber:x:data}x') + form = xep_0004.Form() + if config is not None: + form.fromXML(config) + return form + + def setOptions(self, value): + self.xml.append(value.getXML()) + return self + + def delOptions(self): + config = self.xml.find('{jabber:x:data}x') + self.xml.remove(config) + + def setJid(self, value): + self._setAttr('jid', str(value)) + + def getJid(self): + return JID(self._getAttr('jid')) + +registerStanzaPlugin(Pubsub, Options) +registerStanzaPlugin(Subscribe, Options) + +class OwnerAffiliations(Affiliations): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('node')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def append(self, affiliation): + if not isinstance(affiliation, OwnerAffiliation): + raise TypeError + self.xml.append(affiliation.xml) + return self.affiliations.append(affiliation) + +registerStanzaPlugin(PubsubOwner, OwnerAffiliations) + +class OwnerAffiliation(Affiliation): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('affiliation', 'jid')) + plugin_attrib_map = {} + plugin_tag_map = {} + +class OwnerConfigure(Configure): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('node', 'config')) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(PubsubOwner, OwnerConfigure) + +class OwnerDefault(OwnerConfigure): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('node', 'config')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def getConfig(self): + return self['form'] + + def setConfig(self, value): + self['form'].setStanzaValues(value.getStanzaValues()) + return self + +registerStanzaPlugin(PubsubOwner, OwnerDefault) +registerStanzaPlugin(OwnerDefault, xep_0004.Form) + +class OwnerDelete(ElementBase, OptionalSetting): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'delete' + plugin_attrib = 'delete' + plugin_attrib_map = {} + plugin_tag_map = {} + interfaces = set(('node',)) + +registerStanzaPlugin(PubsubOwner, OwnerDelete) + +class OwnerPurge(ElementBase, OptionalSetting): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'purge' + plugin_attrib = name + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(PubsubOwner, OwnerPurge) + +class OwnerRedirect(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'redirect' + plugin_attrib = name + interfaces = set(('node', 'jid')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setJid(self, value): + self._setAttr('jid', str(value)) + + def getJid(self): + return JID(self._getAttr('jid')) + +registerStanzaPlugin(OwnerDelete, OwnerRedirect) + +class OwnerSubscriptions(Subscriptions): + namespace = 'http://jabber.org/protocol/pubsub#owner' + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + + def append(self, subscription): + if not isinstance(subscription, OwnerSubscription): + raise TypeError + self.xml.append(subscription.xml) + return self.subscriptions.append(subscription) + +registerStanzaPlugin(PubsubOwner, OwnerSubscriptions) + +class OwnerSubscription(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#owner' + name = 'subscription' + plugin_attrib = name + interfaces = set(('jid', 'subscription')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setJid(self, value): + self._setAttr('jid', str(value)) + + def getJid(self): + return JID(self._getAttr('from')) + +class Event(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'event' + plugin_attrib = 'pubsub_event' + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Message, Event) + +class EventItem(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'item' + plugin_attrib = 'item' + interfaces = set(('id', 'payload')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setPayload(self, value): + self.xml.append(value) + + def getPayload(self): + childs = self.xml.getchildren() + if len(childs) > 0: + return childs[0] + + def delPayload(self): + for child in self.xml.getchildren(): + self.xml.remove(child) + + +class EventRetract(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'retract' + plugin_attrib = 'retract' + interfaces = set(('id',)) + plugin_attrib_map = {} + plugin_tag_map = {} + +class EventItems(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'items' + plugin_attrib = 'items' + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + subitem = (EventItem, EventRetract) + +registerStanzaPlugin(Event, EventItems) + +class EventCollection(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'collection' + plugin_attrib = name + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Event, EventCollection) + +class EventAssociate(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'associate' + plugin_attrib = name + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(EventCollection, EventAssociate) + +class EventDisassociate(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'disassociate' + plugin_attrib = name + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(EventCollection, EventDisassociate) + +class EventConfiguration(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'configuration' + plugin_attrib = name + interfaces = set(('node', 'config')) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Event, EventConfiguration) +registerStanzaPlugin(EventConfiguration, xep_0004.Form) + +class EventPurge(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'purge' + plugin_attrib = name + interfaces = set(('node',)) + plugin_attrib_map = {} + plugin_tag_map = {} + +registerStanzaPlugin(Event, EventPurge) + +class EventSubscription(ElementBase): + namespace = 'http://jabber.org/protocol/pubsub#event' + name = 'subscription' + plugin_attrib = name + interfaces = set(('node','expiry', 'jid', 'subid', 'subscription')) + plugin_attrib_map = {} + plugin_tag_map = {} + + def setJid(self, value): + self._setAttr('jid', str(value)) + + def getJid(self): + return JID(self._getAttr('jid')) + +registerStanzaPlugin(Event, EventSubscription) diff --git a/sleekxmpp/plugins/xep_0004.py b/sleekxmpp/plugins/xep_0004.py new file mode 100644 index 0000000..e8dba74 --- /dev/null +++ b/sleekxmpp/plugins/xep_0004.py @@ -0,0 +1,392 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import copy +from . import base +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.message import Message +import types + + +class Form(ElementBase): + namespace = 'jabber:x:data' + name = 'x' + plugin_attrib = 'form' + interfaces = set(('fields', 'instructions', 'items', 'reported', 'title', 'type', 'values')) + sub_interfaces = set(('title',)) + form_types = set(('cancel', 'form', 'result', 'submit')) + + def __init__(self, *args, **kwargs): + title = None + if 'title' in kwargs: + title = kwargs['title'] + del kwargs['title'] + ElementBase.__init__(self, *args, **kwargs) + if title is not None: + self['title'] = title + self.field = FieldAccessor(self) + + def setup(self, xml=None): + if ElementBase.setup(self, xml): #if we had to generate xml + self['type'] = 'form' + + def addField(self, var='', ftype=None, label='', desc='', required=False, value=None, options=None, **kwargs): + kwtype = kwargs.get('type', None) + if kwtype is None: + kwtype = ftype + + field = FormField(parent=self) + field['var'] = var + field['type'] = kwtype + field['label'] = label + field['desc'] = desc + field['required'] = required + field['value'] = value + if options is not None: + field['options'] = options + return field + + def getXML(self, type='submit'): + logging.warning("Form.getXML() is deprecated API compatibility with plugins/old_0004.py") + return self.xml + + def fromXML(self, xml): + logging.warning("Form.fromXML() is deprecated API compatibility with plugins/old_0004.py") + n = Form(xml=xml) + return n + + def addItem(self, values): + itemXML = ET.Element('{%s}item' % self.namespace) + self.xml.append(itemXML) + reported_vars = self['reported'].keys() + for var in reported_vars: + fieldXML = ET.Element('{%s}field' % FormField.namespace) + itemXML.append(fieldXML) + field = FormField(xml=fieldXML) + field['var'] = var + field['value'] = values.get(var, None) + + def addReported(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 delFields(self): + fieldsXML = self.xml.findall('{%s}field' % FormField.namespace) + for fieldXML in fieldsXML: + self.xml.remove(fieldXML) + + def delInstructions(self): + instsXML = self.xml.findall('{%s}instructions') + for instXML in instsXML: + self.xml.remove(instXML) + + def delItems(self): + itemsXML = self.xml.find('{%s}item' % self.namespace) + for itemXML in itemsXML: + self.xml.remove(itemXML) + + def delReported(self): + reportedXML = self.xml.find('{%s}reported' % self.namespace) + if reportedXML is not None: + self.xml.remove(reportedXML) + + def getFields(self, use_dict=False): + fields = {} if use_dict else [] + fieldsXML = self.xml.findall('{%s}field' % FormField.namespace) + for fieldXML in fieldsXML: + field = FormField(xml=fieldXML) + if use_dict: + fields[field['var']] = field + else: + fields.append((field['var'], field)) + return fields + + def getInstructions(self): + instructions = '' + instsXML = self.xml.findall('{%s}instructions' % self.namespace) + return "\n".join([instXML.text for instXML in instsXML]) + + def getItems(self): + items = [] + itemsXML = self.xml.findall('{%s}item' % self.namespace) + for itemXML in itemsXML: + item = {} + 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 getReported(self): + fields = {} + fieldsXML = self.xml.findall('{%s}reported/{%s}field' % (self.namespace, + FormField.namespace)) + for fieldXML in fieldsXML: + field = FormField(xml=fieldXML) + fields[field['var']] = field + return fields + + def getValues(self): + values = {} + fields = self.getFields(use_dict=True) + 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 setFields(self, fields, default=None): + del self['fields'] + for field_data in fields: + var = field_data[0] + field = field_data[1] + field['var'] = var + + self.addField(**field) + + def setInstructions(self, instructions): + del self['instructions'] + if instructions in [None, '']: + return + instructions = instructions.split('\n') + for instruction in instructions: + inst = ET.Element('{%s}instructions' % self.namespace) + inst.text = instruction + self.xml.append(inst) + + def setItems(self, items): + for item in items: + self.addItem(item) + + def setReported(self, reported, default=None): + for var in reported: + field = reported[var] + field['var'] = var + self.addReported(var, **field) + + def setValues(self, values): + fields = self.getFields(use_dict=True) + for field in values: + fields[field]['value'] = values[field] + + def merge(self, other): + new = copy.copy(self) + if type(other) == types.DictType: + new.setValues(other) + return new + nfields = new.getFields(use_dict=True) + ofields = other.getFields(use_dict=True) + nfields.update(ofields) + new.setFields([(x, nfields[x]) for x in nfields]) + return new + +class FieldAccessor(object): + def __init__(self, form): + self.form = form + + def __getitem__(self, key): + return self.form.getFields(use_dict=True)[key] + + def __contains__(self, key): + return key in self.form.getFields(use_dict=True) + + def has_key(self, key): + return key in self.form.getFields(use_dict=True) + + +class FormField(ElementBase): + namespace = 'jabber:x:data' + name = 'field' + plugin_attrib = 'field' + interfaces = set(('answer', 'desc', 'required', 'value', 'options', 'label', 'type', 'var')) + sub_interfaces = set(('desc',)) + field_types = set(('boolean', 'fixed', 'hidden', 'jid-multi', 'jid-single', 'list-multi', + 'list-single', 'text-multi', 'text-private', 'text-single')) + multi_value_types = set(('hidden', 'jid-multi', 'list-multi', 'text-multi')) + multi_line_types = set(('hidden', 'text-multi')) + option_types = set(('list-multi', 'list-single')) + true_values = set((True, '1', 'true')) + + def addOption(self, label='', value=''): + if self['type'] in self.option_types: + opt = FieldOption(parent=self) + opt['label'] = label + opt['value'] = value + else: + raise ValueError("Cannot add options to a %s field." % self['type']) + + def delOptions(self): + optsXML = self.xml.findall('{%s}option' % self.namespace) + for optXML in optsXML: + self.xml.remove(optXML) + + def delRequired(self): + reqXML = self.xml.find('{%s}required' % self.namespace) + if reqXML is not None: + self.xml.remove(reqXML) + + def delValue(self): + valsXML = self.xml.findall('{%s}value' % self.namespace) + for valXML in valsXML: + self.xml.remove(valXML) + + def getAnswer(self): + return self.getValue() + + def getOptions(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 getRequired(self): + reqXML = self.xml.find('{%s}required' % self.namespace) + return reqXML is not None + + def getValue(self): + valsXML = self.xml.findall('{%s}value' % self.namespace) + if len(valsXML) == 0: + return None + elif self['type'] == 'boolean': + return valsXML[0].text in self.true_values + elif self['type'] in self.multi_value_types: + values = [] + for valXML in valsXML: + if valXML.text is None: + valXML.text = '' + values.append(valXML.text) + if self['type'] == 'text-multi': + values = "\n".join(values) + return values + else: + return valsXML[0].text + + def setAnswer(self, answer): + self.setValue(answer) + + def setFalse(self): + self.setValue(False) + + def setOptions(self, options): + for value in options: + if isinstance(value, dict): + self.addOption(**value) + else: + self.addOption(value=value) + + def setRequired(self, required): + exists = self.getRequired() + if not exists and required: + self.xml.append(ET.Element('{%s}required' % self.namespace)) + elif exists and not required: + self.delRequired() + + def setTrue(self): + self.setValue(True) + + def setValue(self, value): + self.delValue() + 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 self['type'] in self.multi_line_types and isinstance(value, str): + value = value.split('\n') + if not isinstance(value, list): + value = [value] + 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',)) + + +class xep_0004(base.base_plugin): + """ + XEP-0004: Data Forms + """ + + def plugin_init(self): + self.xep = '0004' + self.description = 'Data Forms' + + self.xmpp.registerHandler( + Callback('Data Form', + MatchXPath('{%s}message/{%s}x' % (self.xmpp.default_ns, + Form.namespace)), + self.handle_form)) + + registerStanzaPlugin(FormField, FieldOption) + registerStanzaPlugin(Form, FormField) + registerStanzaPlugin(Message, Form) + + def makeForm(self, ftype='form', title='', instructions=''): + f = Form() + f['type'] = ftype + f['title'] = title + f['instructions'] = instructions + return f + + def post_init(self): + base.base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature('jabber:x:data') + + def handle_form(self, message): + self.xmpp.event("message_xform", message) + + def buildForm(self, xml): + return Form(xml=xml) diff --git a/sleekxmpp/plugins/xep_0009.py b/sleekxmpp/plugins/xep_0009.py new file mode 100644 index 0000000..625b03f --- /dev/null +++ b/sleekxmpp/plugins/xep_0009.py @@ -0,0 +1,277 @@ +"""
+XEP-0009 XMPP Remote Procedure Calls
+"""
+from __future__ import with_statement
+from . import base
+import logging
+from xml.etree import cElementTree as ET
+import copy
+import time
+import base64
+
+def py2xml(*args):
+ params = ET.Element("params")
+ for x in args:
+ param = ET.Element("param")
+ param.append(_py2xml(x))
+ params.append(param) #<params><param>...
+ return params
+
+def _py2xml(*args):
+ for x in args:
+ val = ET.Element("value")
+ if type(x) is int:
+ i4 = ET.Element("i4")
+ i4.text = str(x)
+ val.append(i4)
+ if type(x) is bool:
+ boolean = ET.Element("boolean")
+ boolean.text = str(int(x))
+ val.append(boolean)
+ elif type(x) is str:
+ string = ET.Element("string")
+ string.text = x
+ val.append(string)
+ elif type(x) is float:
+ double = ET.Element("double")
+ double.text = str(x)
+ val.append(double)
+ elif type(x) is rpcbase64:
+ b64 = ET.Element("Base64")
+ b64.text = x.encoded()
+ val.append(b64)
+ elif type(x) is rpctime:
+ iso = ET.Element("dateTime.iso8601")
+ iso.text = str(x)
+ val.append(iso)
+ elif type(x) is list:
+ array = ET.Element("array")
+ data = ET.Element("data")
+ for y in x:
+ data.append(_py2xml(y))
+ array.append(data)
+ val.append(array)
+ elif type(x) is dict:
+ struct = ET.Element("struct")
+ for y in x.keys():
+ member = ET.Element("member")
+ name = ET.Element("name")
+ name.text = y
+ member.append(name)
+ member.append(_py2xml(x[y]))
+ struct.append(member)
+ val.append(struct)
+ return val
+
+def xml2py(params):
+ vals = []
+ for param in params.findall('param'):
+ vals.append(_xml2py(param.find('value')))
+ return vals
+
+def _xml2py(value):
+ if value.find('i4') is not None:
+ return int(value.find('i4').text)
+ if value.find('int') is not None:
+ return int(value.find('int').text)
+ if value.find('boolean') is not None:
+ return bool(value.find('boolean').text)
+ if value.find('string') is not None:
+ return value.find('string').text
+ if value.find('double') is not None:
+ return float(value.find('double').text)
+ if value.find('Base64') is not None:
+ return rpcbase64(value.find('Base64').text)
+ if value.find('dateTime.iso8601') is not None:
+ return rpctime(value.find('dateTime.iso8601'))
+ if value.find('struct') is not None:
+ struct = {}
+ for member in value.find('struct').findall('member'):
+ struct[member.find('name').text] = _xml2py(member.find('value'))
+ return struct
+ if value.find('array') is not None:
+ array = []
+ for val in value.find('array').find('data').findall('value'):
+ array.append(_xml2py(val))
+ return array
+ raise ValueError()
+
+class rpcbase64(object):
+ def __init__(self, data):
+ #base 64 encoded string
+ self.data = data
+
+ def decode(self):
+ return base64.decodestring(data)
+
+ def __str__(self):
+ return self.decode()
+
+ def encoded(self):
+ return self.data
+
+class rpctime(object):
+ def __init__(self,data=None):
+ #assume string data is in iso format YYYYMMDDTHH:MM:SS
+ if type(data) is str:
+ self.timestamp = time.strptime(data,"%Y%m%dT%H:%M:%S")
+ elif type(data) is time.struct_time:
+ self.timestamp = data
+ elif data is None:
+ self.timestamp = time.gmtime()
+ else:
+ raise ValueError()
+
+ def iso8601(self):
+ #return a iso8601 string
+ return time.strftime("%Y%m%dT%H:%M:%S",self.timestamp)
+
+ def __str__(self):
+ return self.iso8601()
+
+class JabberRPCEntry(object):
+ def __init__(self,call):
+ self.call = call
+ self.result = None
+ self.error = None
+ self.allow = {} #{'<jid>':['<resource1>',...],...}
+ self.deny = {}
+
+ def check_acl(self, jid, resource):
+ #Check for deny
+ if jid in self.deny.keys():
+ if self.deny[jid] == None or resource in self.deny[jid]:
+ return False
+ #Check for allow
+ if allow == None:
+ return True
+ if jid in self.allow.keys():
+ if self.allow[jid] == None or resource in self.allow[jid]:
+ return True
+ return False
+
+ def acl_allow(self, jid, resource):
+ if jid == None:
+ self.allow = None
+ elif resource == None:
+ self.allow[jid] = None
+ elif jid in self.allow.keys():
+ self.allow[jid].append(resource)
+ else:
+ self.allow[jid] = [resource]
+
+ def acl_deny(self, jid, resource):
+ if jid == None:
+ self.deny = None
+ elif resource == None:
+ self.deny[jid] = None
+ elif jid in self.deny.keys():
+ self.deny[jid].append(resource)
+ else:
+ self.deny[jid] = [resource]
+
+ def call_method(self, args):
+ ret = self.call(*args)
+
+class xep_0009(base.base_plugin):
+
+ def plugin_init(self):
+ self.xep = '0009'
+ self.description = 'Jabber-RPC'
+ self.xmpp.add_handler("<iq type='set'><query xmlns='jabber:iq:rpc' /></iq>",
+ self._callMethod, name='Jabber RPC Call')
+ self.xmpp.add_handler("<iq type='result'><query xmlns='jabber:iq:rpc' /></iq>",
+ self._callResult, name='Jabber RPC Result')
+ self.xmpp.add_handler("<iq type='error'><query xmlns='jabber:iq:rpc' /></iq>",
+ self._callError, name='Jabber RPC Error')
+ self.entries = {}
+ self.activeCalls = []
+
+ def post_init(self):
+ base.base_plugin.post_init(self)
+ self.xmpp.plugin['xep_0030'].add_feature('jabber:iq:rpc')
+ self.xmpp.plugin['xep_0030'].add_identity('automatition','rpc')
+
+ def register_call(self, method, name=None):
+ #@returns an string that can be used in acl commands.
+ with self.lock:
+ if name is None:
+ self.entries[method.__name__] = JabberRPCEntry(method)
+ return method.__name__
+ else:
+ self.entries[name] = JabberRPCEntry(method)
+ return name
+
+ def acl_allow(self, entry, jid=None, resource=None):
+ #allow the method entry to be called by the given jid and resource.
+ #if jid is None it will allow any jid/resource.
+ #if resource is None it will allow any resource belonging to the jid.
+ with self.lock:
+ if self.entries[entry]:
+ self.entries[entry].acl_allow(jid,resource)
+ else:
+ raise ValueError()
+
+ def acl_deny(self, entry, jid=None, resource=None):
+ #Note: by default all requests are denied unless allowed with acl_allow.
+ #If you deny an entry it will not be allowed regardless of acl_allow
+ with self.lock:
+ if self.entries[entry]:
+ self.entries[entry].acl_deny(jid,resource)
+ else:
+ raise ValueError()
+
+ def unregister_call(self, entry):
+ #removes the registered call
+ with self.lock:
+ if self.entries[entry]:
+ del self.entries[entry]
+ else:
+ raise ValueError()
+
+ def makeMethodCallQuery(self,pmethod,params):
+ query = self.xmpp.makeIqQuery(iq,"jabber:iq:rpc")
+ methodCall = ET.Element('methodCall')
+ methodName = ET.Element('methodName')
+ methodName.text = pmethod
+ methodCall.append(methodName)
+ methodCall.append(params)
+ query.append(methodCall)
+ return query
+
+ def makeIqMethodCall(self,pto,pmethod,params):
+ iq = self.xmpp.makeIqSet()
+ iq.set('to',pto)
+ iq.append(self.makeMethodCallQuery(pmethod,params))
+ return iq
+
+ def makeIqMethodResponse(self,pto,pid,params):
+ iq = self.xmpp.makeIqResult(pid)
+ iq.set('to',pto)
+ query = self.xmpp.makeIqQuery(iq,"jabber:iq:rpc")
+ methodResponse = ET.Element('methodResponse')
+ methodResponse.append(params)
+ query.append(methodResponse)
+ return iq
+
+ def makeIqMethodError(self,pto,id,pmethod,params,condition):
+ iq = self.xmpp.makeIqError(id)
+ iq.set('to',pto)
+ iq.append(self.makeMethodCallQuery(pmethod,params))
+ iq.append(self.xmpp['xep_0086'].makeError(condition))
+ return iq
+
+
+
+ def call_remote(self, pto, pmethod, *args):
+ #calls a remote method. Returns the id of the Iq.
+ pass
+
+ def _callMethod(self,xml):
+ pass
+
+ def _callResult(self,xml):
+ pass
+
+ def _callError(self,xml):
+ pass
diff --git a/sleekxmpp/plugins/xep_0030.py b/sleekxmpp/plugins/xep_0030.py new file mode 100644 index 0000000..a9d8d6a --- /dev/null +++ b/sleekxmpp/plugins/xep_0030.py @@ -0,0 +1,327 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +from . import base +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.iq import Iq + +class DiscoInfo(ElementBase): + namespace = 'http://jabber.org/protocol/disco#info' + name = 'query' + plugin_attrib = 'disco_info' + interfaces = set(('node', 'features', 'identities')) + + def getFeatures(self): + features = [] + featuresXML = self.xml.findall('{%s}feature' % self.namespace) + for feature in featuresXML: + features.append(feature.attrib['var']) + return features + + def setFeatures(self, features): + self.delFeatures() + for name in features: + self.addFeature(name) + + def delFeatures(self): + featuresXML = self.xml.findall('{%s}feature' % self.namespace) + for feature in featuresXML: + self.xml.remove(feature) + + def addFeature(self, feature): + featureXML = ET.Element('{%s}feature' % self.namespace, + {'var': feature}) + self.xml.append(featureXML) + + def delFeature(self, feature): + featuresXML = self.xml.findall('{%s}feature' % self.namespace) + for featureXML in featuresXML: + if featureXML.attrib['var'] == feature: + self.xml.remove(featureXML) + + def getIdentities(self): + ids = [] + idsXML = self.xml.findall('{%s}identity' % self.namespace) + for idXML in idsXML: + idData = (idXML.attrib['category'], + idXML.attrib['type'], + idXML.attrib.get('name', '')) + ids.append(idData) + return ids + + def setIdentities(self, ids): + self.delIdentities() + for idData in ids: + self.addIdentity(*idData) + + def delIdentities(self): + idsXML = self.xml.findall('{%s}identity' % self.namespace) + for idXML in idsXML: + self.xml.remove(idXML) + + def addIdentity(self, category, id_type, name=''): + idXML = ET.Element('{%s}identity' % self.namespace, + {'category': category, + 'type': id_type, + 'name': name}) + self.xml.append(idXML) + + def delIdentity(self, category, id_type, name=''): + idsXML = self.xml.findall('{%s}identity' % self.namespace) + for idXML in idsXML: + idData = (idXML.attrib['category'], + idXML.attrib['type']) + delId = (category, id_type) + if idData == delId: + self.xml.remove(idXML) + + +class DiscoItems(ElementBase): + namespace = 'http://jabber.org/protocol/disco#items' + name = 'query' + plugin_attrib = 'disco_items' + interfaces = set(('node', 'items')) + + def getItems(self): + items = [] + itemsXML = self.xml.findall('{%s}item' % self.namespace) + for item in itemsXML: + itemData = (item.attrib['jid'], + item.attrib.get('node'), + item.attrib.get('name')) + items.append(itemData) + return items + + def setItems(self, items): + self.delItems() + for item in items: + self.addItem(*item) + + def delItems(self): + itemsXML = self.xml.findall('{%s}item' % self.namespace) + for item in itemsXML: + self.xml.remove(item) + + def addItem(self, jid, node='', name=''): + itemXML = ET.Element('{%s}item' % self.namespace, {'jid': jid}) + if name: + itemXML.attrib['name'] = name + if node: + itemXML.attrib['node'] = node + self.xml.append(itemXML) + + def delItem(self, jid, node=''): + itemsXML = self.xml.findall('{%s}item' % self.namespace) + for itemXML in itemsXML: + itemData = (itemXML.attrib['jid'], + itemXML.attrib.get('node', '')) + itemDel = (jid, node) + if itemData == itemDel: + self.xml.remove(itemXML) + + +class DiscoNode(object): + """ + Collection object for grouping info and item information + into nodes. + """ + def __init__(self, name): + self.name = name + self.info = DiscoInfo() + self.items = DiscoItems() + + self.info['node'] = name + self.items['node'] = name + + # This is a bit like poor man's inheritance, but + # to simplify adding information to the node we + # map node functions to either the info or items + # stanza objects. + # + # We don't want to make DiscoNode inherit from + # DiscoInfo and DiscoItems because DiscoNode is + # not an actual stanza, and doing so would create + # confusion and potential bugs. + + self._map(self.items, 'items', ['get', 'set', 'del']) + self._map(self.items, 'item', ['add', 'del']) + self._map(self.info, 'identities', ['get', 'set', 'del']) + self._map(self.info, 'identity', ['add', 'del']) + self._map(self.info, 'features', ['get', 'set', 'del']) + self._map(self.info, 'feature', ['add', 'del']) + + def isEmpty(self): + """ + Test if the node contains any information. Useful for + determining if a node can be deleted. + """ + ids = self.getIdentities() + features = self.getFeatures() + items = self.getItems() + + if not ids and not features and not items: + return True + return False + + def _map(self, obj, interface, access): + """ + Map functions of the form obj.accessInterface + to self.accessInterface for each given access type. + """ + interface = interface.title() + for access_type in access: + method = access_type + interface + if hasattr(obj, method): + setattr(self, method, getattr(obj, method)) + + +class xep_0030(base.base_plugin): + """ + XEP-0030 Service Discovery + """ + + def plugin_init(self): + self.xep = '0030' + self.description = 'Service Discovery' + + self.xmpp.registerHandler( + Callback('Disco Items', + MatchXPath('{%s}iq/{%s}query' % (self.xmpp.default_ns, + DiscoItems.namespace)), + self.handle_item_query)) + + self.xmpp.registerHandler( + Callback('Disco Info', + MatchXPath('{%s}iq/{%s}query' % (self.xmpp.default_ns, + DiscoInfo.namespace)), + self.handle_info_query)) + + registerStanzaPlugin(Iq, DiscoInfo) + registerStanzaPlugin(Iq, DiscoItems) + + self.xmpp.add_event_handler('disco_items_request', self.handle_disco_items) + self.xmpp.add_event_handler('disco_info_request', self.handle_disco_info) + + self.nodes = {'main': DiscoNode('main')} + + def add_node(self, node): + if node not in self.nodes: + self.nodes[node] = DiscoNode(node) + + def del_node(self, node): + if node in self.nodes: + del self.nodes[node] + + def handle_item_query(self, iq): + if iq['type'] == 'get': + logging.debug("Items requested by %s" % iq['from']) + self.xmpp.event('disco_items_request', iq) + elif iq['type'] == 'result': + logging.debug("Items result from %s" % iq['from']) + self.xmpp.event('disco_items', iq) + + def handle_info_query(self, iq): + if iq['type'] == 'get': + logging.debug("Info requested by %s" % iq['from']) + self.xmpp.event('disco_info_request', iq) + elif iq['type'] == 'result': + logging.debug("Info result from %s" % iq['from']) + self.xmpp.event('disco_info', iq) + + def handle_disco_info(self, iq, forwarded=False): + """ + A default handler for disco#info requests. If another + handler is registered, this one will defer and not run. + """ + handlers = self.xmpp.event_handlers['disco_info_request'] + if not forwarded and len(handlers) > 1: + return + + node_name = iq['disco_info']['node'] + if not node_name: + node_name = 'main' + + logging.debug("Using default handler for disco#info on node '%s'." % node_name) + + if node_name in self.nodes: + node = self.nodes[node_name] + iq.reply().setPayload(node.info.xml).send() + else: + logging.debug("Node %s requested, but does not exist." % node_name) + iq.reply().error().setPayload(iq['disco_info'].xml) + iq['error']['code'] = '404' + iq['error']['type'] = 'cancel' + iq['error']['condition'] = 'item-not-found' + iq.send() + + def handle_disco_items(self, iq, forwarded=False): + """ + A default handler for disco#items requests. 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. + """ + handlers = self.xmpp.event_handlers['disco_items_request'] + if not forwarded and len(handlers) > 1: + return + + node_name = iq['disco_items']['node'] + if not node_name: + node_name = 'main' + + logging.debug("Using default handler for disco#items on node '%s'." % node_name) + + if node_name in self.nodes: + node = self.nodes[node_name] + iq.reply().setPayload(node.items.xml).send() + else: + logging.debug("Node %s requested, but does not exist." % node_name) + iq.reply().error().setPayload(iq['disco_items'].xml) + iq['error']['code'] = '404' + iq['error']['type'] = 'cancel' + iq['error']['condition'] = 'item-not-found' + iq.send() + + # Older interface methods for backwards compatibility + + def getInfo(self, jid, node='', dfrom=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['to'] = jid + iq['from'] = dfrom + iq['disco_info']['node'] = node + return iq.send() + + def getItems(self, jid, node='', dfrom=None): + iq = self.xmpp.Iq() + iq['type'] = 'get' + iq['to'] = jid + iq['from'] = dfrom + iq['disco_items']['node'] = node + return iq.send() + + def add_feature(self, feature, node='main'): + self.add_node(node) + self.nodes[node].addFeature(feature) + + def add_identity(self, category='', itype='', name='', node='main'): + self.add_node(node) + self.nodes[node].addIdentity(category=category, + id_type=itype, + name=name) + + def add_item(self, jid=None, name='', node='main', subnode=''): + self.add_node(node) + self.add_node(subnode) + if jid is None: + jid = self.xmpp.fulljid + self.nodes[node].addItem(jid=jid, name=name, node=subnode) diff --git a/sleekxmpp/plugins/xep_0033.py b/sleekxmpp/plugins/xep_0033.py new file mode 100644 index 0000000..c0c4d89 --- /dev/null +++ b/sleekxmpp/plugins/xep_0033.py @@ -0,0 +1,161 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +from . import base +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.message import Message + + +class Addresses(ElementBase): + namespace = 'http://jabber.org/protocol/address' + name = 'addresses' + plugin_attrib = 'addresses' + interfaces = set(('addresses', 'bcc', 'cc', 'noreply', 'replyroom', 'replyto', 'to')) + + def addAddress(self, atype='to', jid='', node='', uri='', desc='', delivered=False): + address = Address(parent=self) + address['type'] = atype + address['jid'] = jid + address['node'] = node + address['uri'] = uri + address['desc'] = desc + address['delivered'] = delivered + return address + + def getAddresses(self, atype=None): + addresses = [] + for addrXML in self.xml.findall('{%s}address' % Address.namespace): + # ElementTree 1.2.6 does not support [@attr='value'] in findall + if atype is None or addrXML.attrib.get('type') == atype: + addresses.append(Address(xml=addrXML, parent=None)) + return addresses + + def setAddresses(self, addresses, set_type=None): + self.delAddresses(set_type) + for addr in addresses: + addr = dict(addr) + # Remap 'type' to 'atype' to match the add method + if set_type is not None: + addr['type'] = set_type + curr_type = addr.get('type', None) + if curr_type is not None: + del addr['type'] + addr['atype'] = curr_type + self.addAddress(**addr) + + def delAddresses(self, atype=None): + if atype is None: + return + for addrXML in self.xml.findall('{%s}address' % Address.namespace): + # ElementTree 1.2.6 does not support [@attr='value'] in findall + if addrXML.attrib.get('type') == atype: + self.xml.remove(addrXML) + + # -------------------------------------------------------------- + + def delBcc(self): + self.delAddresses('bcc') + + def delCc(self): + self.delAddresses('cc') + + def delNoreply(self): + self.delAddresses('noreply') + + def delReplyroom(self): + self.delAddresses('replyroom') + + def delReplyto(self): + self.delAddresses('replyto') + + def delTo(self): + self.delAddresses('to') + + # -------------------------------------------------------------- + + def getBcc(self): + return self.getAddresses('bcc') + + def getCc(self): + return self.getAddresses('cc') + + def getNoreply(self): + return self.getAddresses('noreply') + + def getReplyroom(self): + return self.getAddresses('replyroom') + + def getReplyto(self): + return self.getAddresses('replyto') + + def getTo(self): + return self.getAddresses('to') + + # -------------------------------------------------------------- + + def setBcc(self, addresses): + self.setAddresses(addresses, 'bcc') + + def setCc(self, addresses): + self.setAddresses(addresses, 'cc') + + def setNoreply(self, addresses): + self.setAddresses(addresses, 'noreply') + + def setReplyroom(self, addresses): + self.setAddresses(addresses, 'replyroom') + + def setReplyto(self, addresses): + self.setAddresses(addresses, 'replyto') + + def setTo(self, addresses): + self.setAddresses(addresses, 'to') + + +class Address(ElementBase): + namespace = 'http://jabber.org/protocol/address' + name = 'address' + plugin_attrib = 'address' + interfaces = set(('delivered', 'desc', 'jid', 'node', 'type', 'uri')) + address_types = set(('bcc', 'cc', 'noreply', 'replyroom', 'replyto', 'to')) + + def getDelivered(self): + return self.xml.attrib.get('delivered', False) + + def setDelivered(self, delivered): + if delivered: + self.xml.attrib['delivered'] = "true" + else: + del self['delivered'] + + def setUri(self, uri): + if uri: + del self['jid'] + del self['node'] + self.xml.attrib['uri'] = uri + elif 'uri' in self.xml.attrib: + del self.xml.attrib['uri'] + + +class xep_0033(base.base_plugin): + """ + XEP-0033: Extended Stanza Addressing + """ + + def plugin_init(self): + self.xep = '0033' + self.description = 'Extended Stanza Addressing' + + registerStanzaPlugin(Message, Addresses) + + def post_init(self): + base.base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature(Addresses.namespace) diff --git a/sleekxmpp/plugins/xep_0045.py b/sleekxmpp/plugins/xep_0045.py new file mode 100644 index 0000000..bf472a4 --- /dev/null +++ b/sleekxmpp/plugins/xep_0045.py @@ -0,0 +1,333 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from __future__ import with_statement +from . import base +import logging +from xml.etree import cElementTree as ET +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, JID +from .. stanza.presence import Presence +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.matcher.xmlmask import MatchXMLMask + +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): + logging.warning("Cannot set nick through mucpresence plugin.") + return self + + def setRoom(self, value): + logging.warning("Cannot set room through mucpresence plugin.") + return self + + def delNick(self): + logging.warning("Cannot delete nick through mucpresence plugin.") + return self + + def delRoom(self): + logging.warning("Cannot delete room through mucpresence plugin.") + return self + +class xep_0045(base.base_plugin): + """ + Impliments XEP-0045 Multi User Chat + """ + + def plugin_init(self): + self.rooms = {} + self.ourNicks = {} + self.xep = '0045' + self.description = 'Multi User Chat' + # load MUC support in presence stanzas + registerStanzaPlugin(Presence, MUCPresence) + self.xmpp.registerHandler(Callback('MUCPresence', MatchXMLMask("<presence xmlns='%s' />" % self.xmpp.default_ns), self.handle_groupchat_presence)) + self.xmpp.registerHandler(Callback('MUCMessage', MatchXMLMask("<message xmlns='%s' type='groupchat'><body/></message>" % self.xmpp.default_ns), self.handle_groupchat_message)) + + def handle_groupchat_presence(self, pr): + """ Handle a presence in a muc. + """ + got_offline = False + got_online = False + if pr['muc']['room'] not in self.rooms.keys(): + return + entry = pr['muc'].getStanzaValues() + entry['show'] = pr['show'] + entry['status'] = pr['status'] + if pr['type'] == 'unavailable': + if entry['nick'] in self.rooms[entry['room']]: + del self.rooms[entry['room']][entry['nick']] + got_offline = True + else: + if entry['nick'] not in self.rooms[entry['room']]: + got_online = True + self.rooms[entry['room']][entry['nick']] = entry + logging.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 jidInRoom(self, room, jid): + for nick in self.rooms[room]: + entry = self.rooms[room][nick] + if entry is not None and entry['jid'].full == jid: + return True + return False + + def getNick(self, room, jid): + for nick in self.rooms[room]: + entry = self.rooms[room][nick] + if entry is not None and entry['jid'].full == jid: + return nick + + def getRoomForm(self, room, ifrom=None): + iq = self.xmpp.makeIqGet() + iq['to'] = room + if ifrom is not None: + iq['from'] = ifrom + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + iq.append(query) + result = iq.send() + if result['type'] == 'error': + return False + xform = result.xml.find('{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x') + if xform is None: return False + form = self.xmpp.plugin['old_0004'].buildForm(xform) + return form + + def configureRoom(self, room, form=None, ifrom=None): + if form is None: + form = self.getRoomForm(room, ifrom=ifrom) + #form = self.xmpp.plugin['old_0004'].makeForm(ftype='submit') + #form.addField('FORM_TYPE', value='http://jabber.org/protocol/muc#roomconfig') + iq = self.xmpp.makeIqSet() + iq['to'] = room + if ifrom is not None: + iq['from'] = ifrom + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + form = form.getXML('submit') + query.append(form) + iq.append(query) + result = iq.send() + if result['type'] == 'error': + return False + return True + + def joinMUC(self, room, nick, maxhistory="0", password='', wait=False, pstatus=None, pshow=None): + """ Join the specified room, requesting 'maxhistory' lines of history. + """ + stanza = self.xmpp.makePresence(pto="%s/%s" % (room, nick), pstatus=pstatus, pshow=pshow) + x = ET.Element('{http://jabber.org/protocol/muc}x') + if password: + passelement = ET.Element('password') + passelement.text = password + x.append(passelement) + if maxhistory: + history = ET.Element('history') + if maxhistory == "0": + history.attrib['maxchars'] = maxhistory + else: + history.attrib['maxstanzas'] = maxhistory + x.append(history) + stanza.append(x) + if not wait: + self.xmpp.send(stanza) + else: + #wait for our own room presence back + expect = ET.Element("{%s}presence" % self.xmpp.default_ns, {'from':"%s/%s" % (room, nick)}) + self.xmpp.send(stanza, expect) + self.rooms[room] = {} + self.ourNicks[room] = nick + + def destroy(self, room, reason='', altroom = '', ifrom=None): + iq = self.xmpp.makeIqSet() + if ifrom is not None: + iq['from'] = ifrom + iq['to'] = room + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + destroy = ET.Element('destroy') + if altroom: + destroy.attrib['jid'] = altroom + xreason = ET.Element('reason') + xreason.text = reason + destroy.append(xreason) + query.append(destroy) + iq.append(query) + r = iq.send() + if r is False or r['type'] == 'error': + return False + return True + + def setAffiliation(self, room, jid=None, nick=None, affiliation='member'): + """ Change room affiliation.""" + if affiliation not in ('outcast', 'member', 'admin', 'owner', 'none'): + raise TypeError + query = ET.Element('{http://jabber.org/protocol/muc#admin}query') + if nick is not None: + item = ET.Element('item', {'affiliation':affiliation, 'nick':nick}) + else: + item = ET.Element('item', {'affiliation':affiliation, 'jid':jid}) + query.append(item) + iq = self.xmpp.makeIqSet(query) + iq['to'] = room + result = iq.send() + if result is False or result['type'] != 'result': + raise ValueError + return True + + def invite(self, room, jid, reason=''): + """ Invite a jid to a room.""" + msg = self.xmpp.makeMessage(room) + msg['from'] = self.xmpp.jid + x = ET.Element('{http://jabber.org/protocol/muc#user}x') + invite = ET.Element('{http://jabber.org/protocol/muc#user}invite', {'to': jid}) + if reason: + rxml = ET.Element('reason') + rxml.text = reason + invite.append(rxml) + x.append(invite) + msg.append(x) + self.xmpp.send(msg) + + def leaveMUC(self, room, nick, msg=''): + """ Leave the specified room. + """ + if msg: + self.xmpp.sendPresence(pshow='unavailable', pto="%s/%s" % (room, nick), pstatus=msg) + else: + self.xmpp.sendPresence(pshow='unavailable', pto="%s/%s" % (room, nick)) + del self.rooms[room] + + def getRoomConfig(self, room): + iq = self.xmpp.makeIqGet('http://jabber.org/protocol/muc#owner') + iq['to'] = room + iq['from'] = self.xmpp.jid + result = iq.send() + if result is None or result['type'] != 'result': + 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): + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + x = ET.Element('{jabber:x:data}x', type='cancel') + query.append(x) + iq = self.xmpp.makeIqSet(query) + iq.send() + + def setRoomConfig(self, room, config): + query = ET.Element('{http://jabber.org/protocol/muc#owner}query') + x = config.getXML('submit') + query.append(x) + iq = self.xmpp.makeIqSet(query) + iq['to'] = room + iq['from'] = self.xmpp.jid + iq.send() + + def getJoinedRooms(self): + return self.rooms.keys() + + def getOurJidInRoom(self, roomJid): + """ Return the jid we're using in a room. + """ + return "%s/%s" % (roomJid, self.ourNicks[roomJid]) + + def getJidProperty(self, room, nick, jidProperty): + """ Get the property of a nick in a room, such as its 'jid' or 'affiliation' + If not found, return None. + """ + if room in self.rooms and nick in self.rooms[room] and jidProperty in self.rooms[room][nick]: + return self.rooms[room][nick][jidProperty] + else: + return None + + def getRoster(self, room): + """ Get the list of nicks in a room. + """ + if room not in self.rooms.keys(): + return None + return self.rooms[room].keys() diff --git a/sleekxmpp/plugins/xep_0050.py b/sleekxmpp/plugins/xep_0050.py new file mode 100644 index 0000000..5efb911 --- /dev/null +++ b/sleekxmpp/plugins/xep_0050.py @@ -0,0 +1,133 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from __future__ import with_statement +from . import base +import logging +from xml.etree import cElementTree as ET +import time + +class xep_0050(base.base_plugin): + """ + XEP-0050 Ad-Hoc Commands + """ + + def plugin_init(self): + self.xep = '0050' + self.description = 'Ad-Hoc Commands' + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='__None__'/></iq>" % self.xmpp.default_ns, self.handler_command, name='Ad-Hoc None') + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='execute'/></iq>" % self.xmpp.default_ns, self.handler_command, name='Ad-Hoc Execute') + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='next'/></iq>" % self.xmpp.default_ns, self.handler_command_next, name='Ad-Hoc Next', threaded=True) + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='cancel'/></iq>" % self.xmpp.default_ns, self.handler_command_cancel, name='Ad-Hoc Cancel') + self.xmpp.add_handler("<iq type='set' xmlns='%s'><command xmlns='http://jabber.org/protocol/commands' action='complete'/></iq>" % self.xmpp.default_ns, self.handler_command_complete, name='Ad-Hoc Complete') + self.commands = {} + self.sessions = {} + self.sd = self.xmpp.plugin['xep_0030'] + + def post_init(self): + base.base_plugin.post_init(self) + self.sd.add_feature('http://jabber.org/protocol/commands') + + def addCommand(self, node, name, form, pointer=None, multi=False): + self.sd.add_item(None, name, 'http://jabber.org/protocol/commands', node) + self.sd.add_identity('automation', 'command-node', name, node) + self.sd.add_feature('http://jabber.org/protocol/commands', node) + self.sd.add_feature('jabber:x:data', node) + self.commands[node] = (name, form, pointer, multi) + + def getNewSession(self): + return str(time.time()) + '-' + self.xmpp.getNewId() + + def handler_command(self, xml): + in_command = xml.find('{http://jabber.org/protocol/commands}command') + sessionid = in_command.get('sessionid', None) + node = in_command.get('node') + sessionid = self.getNewSession() + name, form, pointer, multi = self.commands[node] + self.sessions[sessionid] = {} + self.sessions[sessionid]['jid'] = xml.get('from') + self.sessions[sessionid]['to'] = xml.get('to') + self.sessions[sessionid]['past'] = [(form, None)] + self.sessions[sessionid]['next'] = pointer + npointer = pointer + if multi: + actions = ['next'] + status = 'executing' + else: + if pointer is None: + status = 'completed' + actions = [] + else: + status = 'executing' + actions = ['complete'] + self.xmpp.send(self.makeCommand(xml.attrib['from'], in_command.attrib['node'], form=form, id=xml.attrib['id'], sessionid=sessionid, status=status, actions=actions)) + + def handler_command_complete(self, xml): + in_command = xml.find('{http://jabber.org/protocol/commands}command') + sessionid = in_command.get('sessionid', None) + pointer = self.sessions[sessionid]['next'] + results = self.xmpp.plugin['old_0004'].makeForm('result') + results.fromXML(in_command.find('{jabber:x:data}x')) + pointer(results,sessionid) + self.xmpp.send(self.makeCommand(xml.attrib['from'], in_command.attrib['node'], form=None, id=xml.attrib['id'], sessionid=sessionid, status='completed', actions=[])) + del self.sessions[in_command.get('sessionid')] + + + def handler_command_next(self, xml): + in_command = xml.find('{http://jabber.org/protocol/commands}command') + sessionid = in_command.get('sessionid', None) + pointer = self.sessions[sessionid]['next'] + results = self.xmpp.plugin['old_0004'].makeForm('result') + results.fromXML(in_command.find('{jabber:x:data}x')) + form, npointer, next = pointer(results,sessionid) + self.sessions[sessionid]['next'] = npointer + self.sessions[sessionid]['past'].append((form, pointer)) + actions = [] + actions.append('prev') + if npointer is None: + status = 'completed' + else: + status = 'executing' + if next: + actions.append('next') + else: + actions.append('complete') + self.xmpp.send(self.makeCommand(xml.attrib['from'], in_command.attrib['node'], form=form, id=xml.attrib['id'], sessionid=sessionid, status=status, actions=actions)) + + def handler_command_cancel(self, xml): + command = xml.find('{http://jabber.org/protocol/commands}command') + try: + del self.sessions[command.get('sessionid')] + except: + pass + self.xmpp.send(self.makeCommand(xml.attrib['from'], command.attrib['node'], id=xml.attrib['id'], sessionid=command.attrib['sessionid'], status='canceled')) + + def makeCommand(self, to, node, id=None, form=None, sessionid=None, status='executing', actions=[]): + if not id: + id = self.xmpp.getNewId() + iq = self.xmpp.makeIqResult(id) + iq.attrib['from'] = self.xmpp.fulljid + iq.attrib['to'] = to + command = ET.Element('{http://jabber.org/protocol/commands}command') + command.attrib['node'] = node + command.attrib['status'] = status + xmlactions = ET.Element('actions') + for action in actions: + xmlactions.append(ET.Element(action)) + if xmlactions: + command.append(xmlactions) + if not sessionid: + sessionid = self.getNewSession() + else: + iq.attrib['from'] = self.sessions[sessionid]['to'] + command.attrib['sessionid'] = sessionid + if form is not None: + if hasattr(form,'getXML'): + form = form.getXML() + command.append(form) + iq.append(command) + return iq diff --git a/sleekxmpp/plugins/xep_0060.py b/sleekxmpp/plugins/xep_0060.py new file mode 100644 index 0000000..0b056f0 --- /dev/null +++ b/sleekxmpp/plugins/xep_0060.py @@ -0,0 +1,309 @@ +from __future__ import with_statement +from . import base +import logging +#from xml.etree import cElementTree as ET +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET +from . import stanza_pubsub +from . xep_0004 import Form + +class xep_0060(base.base_plugin): + """ + XEP-0060 Publish Subscribe + """ + + def plugin_init(self): + self.xep = '0060' + self.description = 'Publish-Subscribe' + + def create_node(self, jid, node, config=None, collection=False, ntype=None): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + create = ET.Element('create') + create.set('node', node) + pubsub.append(create) + configure = ET.Element('configure') + if collection: + ntype = 'collection' + #if config is None: + # submitform = self.xmpp.plugin['xep_0004'].makeForm('submit') + #else: + if config is not None: + submitform = config + if 'FORM_TYPE' in submitform.field: + submitform.field['FORM_TYPE'].setValue('http://jabber.org/protocol/pubsub#node_config') + else: + submitform.addField('FORM_TYPE', 'hidden', value='http://jabber.org/protocol/pubsub#node_config') + if ntype: + if 'pubsub#node_type' in submitform.field: + submitform.field['pubsub#node_type'].setValue(ntype) + else: + submitform.addField('pubsub#node_type', value=ntype) + else: + if 'pubsub#node_type' in submitform.field: + submitform.field['pubsub#node_type'].setValue('leaf') + else: + submitform.addField('pubsub#node_type', value='leaf') + submitform['type'] = 'submit' + configure.append(submitform.xml) + pubsub.append(configure) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is False or result is None or result['type'] == 'error': return False + return True + + def subscribe(self, jid, node, bare=True, subscribee=None): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + subscribe = ET.Element('subscribe') + subscribe.attrib['node'] = node + if subscribee is None: + if bare: + subscribe.attrib['jid'] = self.xmpp.jid + else: + subscribe.attrib['jid'] = self.xmpp.fulljid + else: + subscribe.attrib['jid'] = subscribee + pubsub.append(subscribe) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is False or result is None or result['type'] == 'error': return False + return True + + def unsubscribe(self, jid, node, bare=True, subscribee=None): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + unsubscribe = ET.Element('unsubscribe') + unsubscribe.attrib['node'] = node + if subscribee is None: + if bare: + unsubscribe.attrib['jid'] = self.xmpp.jid + else: + unsubscribe.attrib['jid'] = self.xmpp.fulljid + else: + unsubscribe.attrib['jid'] = subscribee + pubsub.append(unsubscribe) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is False or result is None or result['type'] == 'error': return False + return True + + def getNodeConfig(self, jid, node=None): # if no node, then grab default + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + if node is not None: + configure = ET.Element('configure') + configure.attrib['node'] = node + else: + configure = ET.Element('default') + pubsub.append(configure) + #TODO: Add configure support. + iq = self.xmpp.makeIqGet() + iq.append(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + #self.xmpp.add_handler("<iq id='%s'/>" % id, self.handlerCreateNodeResponse) + result = iq.send() + if result is None or result == False or result['type'] == 'error': + logging.warning("got error instead of config") + return False + if node is not None: + form = result.find('{http://jabber.org/protocol/pubsub#owner}pubsub/{http://jabber.org/protocol/pubsub#owner}configure/{jabber:x:data}x') + else: + form = result.find('{http://jabber.org/protocol/pubsub#owner}pubsub/{http://jabber.org/protocol/pubsub#owner}default/{jabber:x:data}x') + if not form or form is None: + logging.error("No form found.") + return False + return Form(xml=form) + + def getNodeSubscriptions(self, jid, node): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + subscriptions = ET.Element('subscriptions') + subscriptions.attrib['node'] = node + pubsub.append(subscriptions) + iq = self.xmpp.makeIqGet() + iq.append(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is None or result == False or result['type'] == 'error': + logging.warning("got error instead of config") + return False + else: + results = result.findall('{http://jabber.org/protocol/pubsub#owner}pubsub/{http://jabber.org/protocol/pubsub#owner}subscriptions/{http://jabber.org/protocol/pubsub#owner}subscription') + if results is None: + return False + subs = {} + for sub in results: + subs[sub.get('jid')] = sub.get('subscription') + return subs + + def getNodeAffiliations(self, jid, node): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + affiliations = ET.Element('affiliations') + affiliations.attrib['node'] = node + pubsub.append(affiliations) + iq = self.xmpp.makeIqGet() + iq.append(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is None or result == False or result['type'] == 'error': + logging.warning("got error instead of config") + return False + else: + results = result.findall('{http://jabber.org/protocol/pubsub#owner}pubsub/{http://jabber.org/protocol/pubsub#owner}affiliations/{http://jabber.org/protocol/pubsub#owner}affiliation') + if results is None: + return False + subs = {} + for sub in results: + subs[sub.get('jid')] = sub.get('affiliation') + return subs + + def deleteNode(self, jid, node): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + iq = self.xmpp.makeIqSet() + delete = ET.Element('delete') + delete.attrib['node'] = node + pubsub.append(delete) + iq.append(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + result = iq.send() + if result is not None and result is not False and result['type'] != 'error': + return True + else: + return False + + + def setNodeConfig(self, jid, node, config): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + configure = ET.Element('configure') + configure.attrib['node'] = node + config = config.getXML('submit') + configure.append(config) + pubsub.append(configure) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is None or result['type'] == 'error': + return False + return True + + def setItem(self, jid, node, items=[]): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + publish = ET.Element('publish') + publish.attrib['node'] = node + for pub_item in items: + id, payload = pub_item + item = ET.Element('item') + if id is not None: + item.attrib['id'] = id + item.append(payload) + publish.append(item) + pubsub.append(publish) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is None or result is False or result['type'] == 'error': return False + return True + + def addItem(self, jid, node, items=[]): + return self.setItem(jid, node, items) + + def deleteItem(self, jid, node, item): + pubsub = ET.Element('{http://jabber.org/protocol/pubsub}pubsub') + retract = ET.Element('retract') + retract.attrib['node'] = node + itemn = ET.Element('item') + itemn.attrib['id'] = item + retract.append(itemn) + pubsub.append(retract) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is None or result is False or result['type'] == 'error': return False + return True + + def getNodes(self, jid): + response = self.xmpp.plugin['xep_0030'].getItems(jid) + items = response.findall('{http://jabber.org/protocol/disco#items}query/{http://jabber.org/protocol/disco#items}item') + nodes = {} + if items is not None and items is not False: + for item in items: + nodes[item.get('node')] = item.get('name') + return nodes + + def getItems(self, jid, node): + response = self.xmpp.plugin['xep_0030'].getItems(jid, node) + items = response.findall('{http://jabber.org/protocol/disco#items}query/{http://jabber.org/protocol/disco#items}item') + nodeitems = [] + if items is not None and items is not False: + for item in items: + nodeitems.append(item.get('node')) + return nodeitems + + def addNodeToCollection(self, jid, child, parent=''): + config = self.getNodeConfig(jid, child) + if not config or config is None: + self.lasterror = "Config Error" + return False + try: + config.field['pubsub#collection'].setValue(parent) + except KeyError: + logging.warning("pubsub#collection doesn't exist in config, trying to add it") + config.addField('pubsub#collection', value=parent) + if not self.setNodeConfig(jid, child, config): + return False + return True + + def modifyAffiliation(self, ps_jid, node, user_jid, affiliation): + if affiliation not in ('owner', 'publisher', 'member', 'none', 'outcast'): + raise TypeError + pubsub = ET.Element('{http://jabber.org/protocol/pubsub#owner}pubsub') + affs = ET.Element('affiliations') + affs.attrib['node'] = node + aff = ET.Element('affiliation') + aff.attrib['jid'] = user_jid + aff.attrib['affiliation'] = affiliation + affs.append(aff) + pubsub.append(affs) + iq = self.xmpp.makeIqSet(pubsub) + iq.attrib['to'] = ps_jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq['id'] + result = iq.send() + if result is None or result is False or result['type'] == 'error': + return False + return True + + def addNodeToCollection(self, jid, child, parent=''): + config = self.getNodeConfig(jid, child) + if not config or config is None: + self.lasterror = "Config Error" + return False + try: + config.field['pubsub#collection'].setValue(parent) + except KeyError: + logging.warning("pubsub#collection doesn't exist in config, trying to add it") + config.addField('pubsub#collection', value=parent) + if not self.setNodeConfig(jid, child, config): + return False + return True + + def removeNodeFromCollection(self, jid, child): + self.addNodeToCollection(jid, child, '') + diff --git a/sleekxmpp/plugins/xep_0078.py b/sleekxmpp/plugins/xep_0078.py new file mode 100644 index 0000000..4b3ab82 --- /dev/null +++ b/sleekxmpp/plugins/xep_0078.py @@ -0,0 +1,69 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from __future__ import with_statement +from xml.etree import cElementTree as ET +import logging +import hashlib +from . import base + + +class xep_0078(base.base_plugin): + """ + XEP-0078 NON-SASL Authentication + """ + def plugin_init(self): + self.description = "Non-SASL Authentication (broken)" + self.xep = "0078" + self.xmpp.add_event_handler("session_start", self.check_stream) + #disabling until I fix conflict with PLAIN + #self.xmpp.registerFeature("<auth xmlns='http://jabber.org/features/iq-auth'/>", self.auth) + self.streamid = '' + + def check_stream(self, xml): + self.streamid = xml.attrib['id'] + if xml.get('version', '0') != '1.0': + self.auth() + + def auth(self, xml=None): + logging.debug("Starting jabber:iq:auth Authentication") + auth_request = self.xmpp.makeIqGet() + auth_request_query = ET.Element('{jabber:iq:auth}query') + auth_request.attrib['to'] = self.xmpp.server + username = ET.Element('username') + username.text = self.xmpp.username + auth_request_query.append(username) + auth_request.append(auth_request_query) + result = auth_request.send() + rquery = result.find('{jabber:iq:auth}query') + attempt = self.xmpp.makeIqSet() + query = ET.Element('{jabber:iq:auth}query') + resource = ET.Element('resource') + resource.text = self.xmpp.resource + query.append(username) + query.append(resource) + if rquery.find('{jabber:iq:auth}digest') is None: + logging.warning("Authenticating via jabber:iq:auth Plain.") + password = ET.Element('password') + password.text = self.xmpp.password + query.append(password) + else: + logging.debug("Authenticating via jabber:iq:auth Digest") + digest = ET.Element('digest') + digest.text = hashlib.sha1(b"%s%s" % (self.streamid, self.xmpp.password)).hexdigest() + query.append(digest) + attempt.append(query) + result = attempt.send() + if result.attrib['type'] == 'result': + with self.xmpp.lock: + self.xmpp.authenticated = True + self.xmpp.sessionstarted = True + self.xmpp.event("session_start") + else: + logging.info("Authentication failed") + self.xmpp.disconnect() + self.xmpp.event("failed_auth") diff --git a/sleekxmpp/plugins/xep_0085.py b/sleekxmpp/plugins/xep_0085.py new file mode 100644 index 0000000..b7b5d6d --- /dev/null +++ b/sleekxmpp/plugins/xep_0085.py @@ -0,0 +1,101 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permissio +""" + +import logging +from . import base +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.message import Message + + +class ChatState(ElementBase): + namespace = 'http://jabber.org/protocol/chatstates' + plugin_attrib = 'chat_state' + interface = set(('state',)) + states = set(('active', 'composing', 'gone', 'inactive', 'paused')) + + def active(self): + self.setState('active') + + def composing(self): + self.setState('composing') + + def gone(self): + self.setState('gone') + + def inactive(self): + self.setState('inactive') + + def paused(self): + self.setState('paused') + + def setState(self, state): + if state in self.states: + self.name = state + self.xml.tag = '{%s}%s' % (self.namespace, state) + else: + raise ValueError('Invalid chat state') + + def getState(self): + return self.name + +# In order to match the various chat state elements, +# we need one stanza object per state, even though +# they are all the same except for the initial name +# value. Do not depend on the type of the chat state +# stanza object for the actual state. + +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' + + +class xep_0085(base.base_plugin): + """ + XEP-0085 Chat State Notifications + """ + + def plugin_init(self): + self.xep = '0085' + self.description = 'Chat State Notifications' + + handlers = [('Active Chat State', 'active'), + ('Composing Chat State', 'composing'), + ('Gone Chat State', 'gone'), + ('Inactive Chat State', 'inactive'), + ('Paused Chat State', 'paused')] + for handler in handlers: + self.xmpp.registerHandler( + Callback(handler[0], + MatchXPath("{%s}message/{%s}%s" % (self.xmpp.default_ns, + ChatState.namespace, + handler[1])), + self._handleChatState)) + + registerStanzaPlugin(Message, Active) + registerStanzaPlugin(Message, Composing) + registerStanzaPlugin(Message, Gone) + registerStanzaPlugin(Message, Inactive) + registerStanzaPlugin(Message, Paused) + + def post_init(self): + base.base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature('http://jabber.org/protocol/chatstates') + + def _handleChatState(self, msg): + state = msg['chat_state'].name + logging.debug("Chat State: %s, %s" % (state, msg['from'].jid)) + self.xmpp.event('chatstate_%s' % state, msg) diff --git a/sleekxmpp/plugins/xep_0086.py b/sleekxmpp/plugins/xep_0086.py new file mode 100644 index 0000000..e6c18c7 --- /dev/null +++ b/sleekxmpp/plugins/xep_0086.py @@ -0,0 +1,49 @@ +
+from __future__ import with_statement
+from . import base
+import logging
+from xml.etree import cElementTree as ET
+import copy
+
+class xep_0086(base.base_plugin):
+ """
+ XEP-0086 Error Condition Mappings
+ """
+
+ def plugin_init(self):
+ self.xep = '0086'
+ self.description = 'Error Condition Mappings'
+ self.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 makeError(self, condition, cdata=None, errorType=None, text=None, customElem=None):
+ conditionElem = self.xmpp.makeStanzaErrorCondition(condition, cdata)
+ if errorType is None:
+ error = self.xmpp.makeStanzaError(conditionElem, self.error_map[condition][0], self.error_map[condition][1], text, customElem)
+ else:
+ error = self.xmpp.makeStanzaError(conditionElem, errorType, self.error_map[condition][1], text, customElem)
+ error.append(conditionElem)
+ return error
diff --git a/sleekxmpp/plugins/xep_0092.py b/sleekxmpp/plugins/xep_0092.py new file mode 100644 index 0000000..ca02c4a --- /dev/null +++ b/sleekxmpp/plugins/xep_0092.py @@ -0,0 +1,56 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from xml.etree import cElementTree as ET +from . import base +from .. xmlstream.handler.xmlwaiter import XMLWaiter + +class xep_0092(base.base_plugin): + """ + XEP-0092 Software Version + """ + def plugin_init(self): + self.description = "Software Version" + self.xep = "0092" + self.name = self.config.get('name', 'SleekXMPP') + self.version = self.config.get('version', '0.1-dev') + self.xmpp.add_handler("<iq type='get' xmlns='%s'><query xmlns='jabber:iq:version' /></iq>" % self.xmpp.default_ns, self.report_version, name='Sofware Version') + + def post_init(self): + base.base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature('jabber:iq:version') + + def report_version(self, xml): + iq = self.xmpp.makeIqResult(xml.get('id', 'unknown')) + iq.attrib['to'] = xml.get('from', self.xmpp.server) + query = ET.Element('{jabber:iq:version}query') + name = ET.Element('name') + name.text = self.name + version = ET.Element('version') + version.text = self.version + query.append(name) + query.append(version) + iq.append(query) + self.xmpp.send(iq) + + def getVersion(self, jid): + iq = self.xmpp.makeIqGet() + query = ET.Element('{jabber:iq:version}query') + iq.append(query) + iq.attrib['to'] = jid + iq.attrib['from'] = self.xmpp.fulljid + id = iq.get('id') + result = iq.send() + if result and result is not None and result.get('type', 'error') != 'error': + qry = result.find('{jabber:iq:version}query') + version = {} + for child in qry.getchildren(): + version[child.tag.split('}')[-1]] = child.text + return version + else: + return False + diff --git a/sleekxmpp/plugins/xep_0128.py b/sleekxmpp/plugins/xep_0128.py new file mode 100644 index 0000000..824977b --- /dev/null +++ b/sleekxmpp/plugins/xep_0128.py @@ -0,0 +1,51 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +from . import base +from .. xmlstream.handler.callback import Callback +from .. xmlstream.matcher.xpath import MatchXPath +from .. xmlstream.stanzabase import registerStanzaPlugin, ElementBase, ET, JID +from .. stanza.iq import Iq +from . xep_0030 import DiscoInfo, DiscoItems +from . xep_0004 import Form + + +class xep_0128(base.base_plugin): + """ + XEP-0128 Service Discovery Extensions + """ + + def plugin_init(self): + self.xep = '0128' + self.description = 'Service Discovery Extensions' + + registerStanzaPlugin(DiscoInfo, Form) + registerStanzaPlugin(DiscoItems, Form) + + def extend_info(self, node, data=None): + if data is None: + data = {} + node = self.xmpp['xep_0030'].nodes.get(node, None) + if node is None: + self.xmpp['xep_0030'].add_node(node) + + info = node.info + info['form']['type'] = 'result' + info['form'].setFields(data, default=None) + + def extend_items(self, node, data=None): + if data is None: + data = {} + node = self.xmpp['xep_0030'].nodes.get(node, None) + if node is None: + self.xmpp['xep_0030'].add_node(node) + + items = node.items + items['form']['type'] = 'result' + items['form'].setFields(data, default=None) diff --git a/sleekxmpp/plugins/xep_0199.py b/sleekxmpp/plugins/xep_0199.py new file mode 100644 index 0000000..3fc62f5 --- /dev/null +++ b/sleekxmpp/plugins/xep_0199.py @@ -0,0 +1,59 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +from xml.etree import cElementTree as ET +from . import base +import time +import logging + +class xep_0199(base.base_plugin): + """XEP-0199 XMPP Ping""" + + def plugin_init(self): + self.description = "XMPP Ping" + self.xep = "0199" + self.xmpp.add_handler("<iq type='get' xmlns='%s'><ping xmlns='http://www.xmpp.org/extensions/xep-0199.html#ns'/></iq>" % self.xmpp.default_ns, self.handler_ping, name='XMPP Ping') + self.running = False + #if self.config.get('keepalive', True): + #self.xmpp.add_event_handler('session_start', self.handler_pingserver, threaded=True) + + def post_init(self): + base.base_plugin.post_init(self) + self.xmpp.plugin['xep_0030'].add_feature('http://www.xmpp.org/extensions/xep-0199.html#ns') + + def handler_pingserver(self, xml): + if not self.running: + time.sleep(self.config.get('frequency', 300)) + while self.sendPing(self.xmpp.server, self.config.get('timeout', 30)) is not False: + time.sleep(self.config.get('frequency', 300)) + logging.debug("Did not recieve ping back in time. Requesting Reconnect.") + self.xmpp.disconnect(reconnect=True) + + def handler_ping(self, xml): + iq = self.xmpp.makeIqResult(xml.get('id', 'unknown')) + iq.attrib['to'] = xml.get('from', self.xmpp.server) + self.xmpp.send(iq) + + def sendPing(self, jid, timeout = 30): + """ sendPing(jid, timeout) + Sends a ping to the specified jid, returning the time (in seconds) + to receive a reply, or None if no reply is received in timeout seconds. + """ + id = self.xmpp.getNewId() + iq = self.xmpp.makeIq(id) + iq.attrib['type'] = 'get' + iq.attrib['to'] = jid + ping = ET.Element('{http://www.xmpp.org/extensions/xep-0199.html#ns}ping') + iq.append(ping) + startTime = time.clock() + #pingresult = self.xmpp.send(iq, self.xmpp.makeIq(id), timeout) + pingresult = iq.send() + endTime = time.clock() + if pingresult == False: + #self.xmpp.disconnect(reconnect=True) + return False + return endTime - startTime diff --git a/sleekxmpp/stanza/__init__.py b/sleekxmpp/stanza/__init__.py new file mode 100644 index 0000000..8302c43 --- /dev/null +++ b/sleekxmpp/stanza/__init__.py @@ -0,0 +1,13 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +from sleekxmpp.stanza.error import Error +from sleekxmpp.stanza.iq import Iq +from sleekxmpp.stanza.message import Message +from sleekxmpp.stanza.presence import Presence diff --git a/sleekxmpp/stanza/atom.py b/sleekxmpp/stanza/atom.py new file mode 100644 index 0000000..244ef31 --- /dev/null +++ b/sleekxmpp/stanza/atom.py @@ -0,0 +1,26 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase + + +class AtomEntry(ElementBase): + + """ + A simple Atom feed entry. + + Stanza Interface: + title -- The title of the Atom feed entry. + summary -- The summary of the Atom feed entry. + """ + + namespace = 'http://www.w3.org/2005/Atom' + name = 'entry' + plugin_attrib = 'entry' + interfaces = set(('title', 'summary')) + sub_interfaces = set(('title', 'summary')) diff --git a/sleekxmpp/stanza/error.py b/sleekxmpp/stanza/error.py new file mode 100644 index 0000000..09229bc --- /dev/null +++ b/sleekxmpp/stanza/error.py @@ -0,0 +1,141 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Error(ElementBase): + + """ + XMPP stanzas of type 'error' should include an <error> stanza that + describes the nature of the error and how it should be handled. + + Use the 'XEP-0086: Error Condition Mappings' plugin to include error + codes used in older XMPP versions. + + Example error stanza: + <error type="cancel" code="404"> + <item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" /> + <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"> + The item was not found. + </text> + </error> + + Stanza Interface: + code -- The error code used in older XMPP versions. + condition -- The name of the condition element. + text -- Human readable description of the error. + type -- Error type indicating how the error should be handled. + + Attributes: + conditions -- The set of allowable error condition elements. + condition_ns -- The namespace for the condition element. + types -- A set of values indicating how the error + should be treated. + + Methods: + setup -- Overrides ElementBase.setup. + get_condition -- Retrieve the name of the condition element. + set_condition -- Add a condition element. + del_condition -- Remove the condition element. + get_text -- Retrieve the contents of the <text> element. + set_text -- Set the contents of the <text> element. + del_text -- Remove the <text> element. + """ + + namespace = 'jabber:client' + name = 'error' + plugin_attrib = 'error' + interfaces = set(('code', 'condition', 'text', 'type')) + sub_interfaces = set(('text',)) + 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. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.getCondition = self.get_condition + self.setCondition = self.set_condition + self.delCondition = self.del_condition + self.getText = self.get_text + self.setText = self.set_text + self.delText = self.del_text + + if ElementBase.setup(self, xml): + #If we had to generate XML then set default values. + self['type'] = 'cancel' + self['condition'] = 'feature-not-implemented' + if self.parent is not None: + self.parent()['type'] = 'error' + + def get_condition(self): + """Return the condition element's name.""" + for child in self.xml.getchildren(): + if "{%s}" % self.condition_ns in child.tag: + return child.tag.split('}', 1)[-1] + return '' + + def set_condition(self, value): + """ + Set the tag name of the condition element. + + Arguments: + value -- The tag name of the condition element. + """ + if value in self.conditions: + del self['condition'] + self.xml.append(ET.Element("{%s}%s" % (self.condition_ns, value))) + return self + + def del_condition(self): + """Remove the condition element.""" + for child in self.xml.getchildren(): + if "{%s}" % self.condition_ns in child.tag: + tag = child.tag.split('}', 1)[-1] + if tag in self.conditions: + self.xml.remove(child) + return self + + def get_text(self): + """Retrieve the contents of the <text> element.""" + return self._get_sub_text('{%s}text' % self.condition_ns) + + def set_text(self, value): + """ + Set the contents of the <text> element. + + Arguments: + value -- The new contents for the <text> element. + """ + self._set_sub_text('{%s}text' % self.condition_ns, text=value) + return self + + def del_text(self): + """Remove the <text> element.""" + self._del_sub('{%s}text' % self.condition_ns) + return self diff --git a/sleekxmpp/stanza/htmlim.py b/sleekxmpp/stanza/htmlim.py new file mode 100644 index 0000000..4586828 --- /dev/null +++ b/sleekxmpp/stanza/htmlim.py @@ -0,0 +1,97 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Message +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class HTMLIM(ElementBase): + + """ + XEP-0071: XHTML-IM defines a method for embedding XHTML content + within a <message> stanza so that lightweight markup can be used + to format the message contents and to create links. + + Only a subset of XHTML is recommended for use with XHTML-IM. + See the full spec at 'http://xmpp.org/extensions/xep-0071.html' + for more information. + + Example stanza: + <message to="user@example.com"> + <body>Non-html message content.</body> + <html xmlns="http://jabber.org/protocol/xhtml-im"> + <body xmlns="http://www.w3.org/1999/xhtml"> + <p><b>HTML!</b></p> + </body> + </html> + </message> + + Stanza Interface: + body -- The contents of the HTML body tag. + + Methods: + setup -- Overrides ElementBase.setup. + get_body -- Return the HTML body contents. + set_body -- Set the HTML body contents. + del_body -- Remove the HTML body contents. + """ + + namespace = 'http://jabber.org/protocol/xhtml-im' + name = 'html' + interfaces = set(('body',)) + plugin_attrib = name + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides StanzaBase.setup. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.setBody = self.set_body + self.getBody = self.get_body + self.delBody = self.del_body + + return ElementBase.setup(self, xml) + + def set_body(self, html): + """ + Set the contents of the HTML body. + + Arguments: + html -- Either a string or XML object. If the top level + element is not <body> with a namespace of + 'http://www.w3.org/1999/xhtml', it will be wrapped. + """ + if isinstance(html, str): + html = ET.XML(html) + if html.tag != '{http://www.w3.org/1999/xhtml}body': + body = ET.Element('{http://www.w3.org/1999/xhtml}body') + body.append(html) + self.xml.append(body) + else: + self.xml.append(html) + + def get_body(self): + """Return the contents of the HTML body.""" + html = self.xml.find('{http://www.w3.org/1999/xhtml}body') + if html is None: + return '' + return html + + def del_body(self): + """Remove the HTML body contents.""" + if self.parent is not None: + self.parent().xml.remove(self.xml) + + +register_stanza_plugin(Message, HTMLIM) diff --git a/sleekxmpp/stanza/iq.py b/sleekxmpp/stanza/iq.py new file mode 100644 index 0000000..614d14f --- /dev/null +++ b/sleekxmpp/stanza/iq.py @@ -0,0 +1,183 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Error +from sleekxmpp.stanza.rootstanza import RootStanza +from sleekxmpp.xmlstream import RESPONSE_TIMEOUT, StanzaBase, ET +from sleekxmpp.xmlstream.handler import Waiter +from sleekxmpp.xmlstream.matcher import MatcherId + + +class Iq(RootStanza): + + """ + XMPP <iq> stanzas, or info/query stanzas, are XMPP's method of + requesting and modifying information, similar to HTTP's GET and + POST methods. + + Each <iq> stanza must have an 'id' value which associates the + stanza with the response stanza. XMPP entities must always + be given a response <iq> stanza with a type of 'result' after + sending a stanza of type 'get' or 'set'. + + Most uses cases for <iq> stanzas will involve adding a <query> + element whose namespace indicates the type of information + desired. However, some custom XMPP applications use <iq> stanzas + as a carrier stanza for an application-specific protocol instead. + + Example <iq> Stanzas: + <iq to="user@example.com" type="get" id="314"> + <query xmlns="http://jabber.org/protocol/disco#items" /> + </iq> + + <iq to="user@localhost" type="result" id="17"> + <query xmlns='jabber:iq:roster'> + <item jid='otheruser@example.net' + name='John Doe' + subscription='both'> + <group>Friends</group> + </item> + </query> + </iq> + + Stanza Interface: + query -- The namespace of the <query> element if one exists. + + Attributes: + types -- May be one of: get, set, result, or error. + + Methods: + __init__ -- Overrides StanzaBase.__init__. + unhandled -- Send error if there are no handlers. + set_payload -- Overrides StanzaBase.set_payload. + set_query -- Add or modify a <query> element. + get_query -- Return the namespace of the <query> element. + del_query -- Remove the <query> element. + reply -- Overrides StanzaBase.reply + send -- Overrides StanzaBase.send + """ + + namespace = 'jabber:client' + name = 'iq' + interfaces = set(('type', 'to', 'from', 'id', 'query')) + types = set(('get', 'result', 'set', 'error')) + plugin_attrib = name + + def __init__(self, *args, **kwargs): + """ + Initialize a new <iq> stanza with an 'id' value. + + Overrides StanzaBase.__init__. + """ + StanzaBase.__init__(self, *args, **kwargs) + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.setPayload = self.set_payload + self.getQuery = self.get_query + self.setQuery = self.set_query + self.delQuery = self.del_query + + if self['id'] == '': + if self.stream is not None: + self['id'] = self.stream.getNewId() + else: + self['id'] = '0' + + def unhandled(self): + """ + Send a feature-not-implemented error if the stanza is not handled. + + Overrides StanzaBase.unhandled. + """ + if self['type'] in ('get', 'set'): + self.reply() + self['error']['condition'] = 'feature-not-implemented' + self['error']['text'] = 'No handlers registered for this request.' + self.send() + + def set_payload(self, value): + """ + Set the XML contents of the <iq> stanza. + + Arguments: + value -- An XML object to use as the <iq> stanza's contents + """ + self.clear() + StanzaBase.set_payload(self, value) + return self + + def set_query(self, value): + """ + Add or modify a <query> element. + + Query elements are differentiated by their namespace. + + Arguments: + value -- The namespace of the <query> element. + """ + query = self.xml.find("{%s}query" % value) + if query is None and value: + self.clear() + query = ET.Element("{%s}query" % value) + self.xml.append(query) + return self + + def get_query(self): + """Return the namespace of the <query> element.""" + for child in self.xml.getchildren(): + if child.tag.endswith('query'): + ns = child.tag.split('}')[0] + if '{' in ns: + ns = ns[1:] + return ns + return '' + + def del_query(self): + """Remove the <query> element.""" + for child in self.xml.getchildren(): + if child.tag.endswith('query'): + self.xml.remove(child) + return self + + def reply(self): + """ + Send a reply <iq> stanza. + + Overrides StanzaBase.reply + + Sets the 'type' to 'result' in addition to the default + StanzaBase.reply behavior. + """ + self['type'] = 'result' + StanzaBase.reply(self) + return self + + def send(self, block=True, timeout=RESPONSE_TIMEOUT): + """ + Send an <iq> stanza over the XML stream. + + The send call can optionally block until a response is received or + a timeout occurs. Be aware that using blocking in non-threaded event + handlers can drastically impact performance. + + Overrides StanzaBase.send + + Arguments: + block -- Specify if the send call will block until a response + is received, or a timeout occurs. Defaults to True. + timeout -- The length of time (in seconds) to wait for a response + before exiting the send call if blocking is used. + Defaults to sleekxmpp.xmlstream.RESPONSE_TIMEOUT + """ + if block and self['type'] in ('get', 'set'): + waitfor = Waiter('IqWait_%s' % self['id'], MatcherId(self['id'])) + self.stream.registerHandler(waitfor) + StanzaBase.send(self) + return waitfor.wait(timeout) + else: + return StanzaBase.send(self) diff --git a/sleekxmpp/stanza/message.py b/sleekxmpp/stanza/message.py new file mode 100644 index 0000000..66c74d8 --- /dev/null +++ b/sleekxmpp/stanza/message.py @@ -0,0 +1,165 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Error +from sleekxmpp.stanza.rootstanza import RootStanza +from sleekxmpp.xmlstream import StanzaBase, ET + + +class Message(RootStanza): + + """ + XMPP's <message> stanzas are a "push" mechanism to send information + to other XMPP entities without requiring a response. + + Chat clients will typically use <message> stanzas that have a type + of either "chat" or "groupchat". + + When handling a message event, be sure to check if the message is + an error response. + + Example <message> stanzas: + <message to="user1@example.com" from="user2@example.com"> + <body>Hi!</body> + </message> + + <message type="groupchat" to="room@conference.example.com"> + <body>Hi everyone!</body> + </message> + + Stanza Interface: + body -- The main contents of the message. + subject -- An optional description of the message's contents. + mucroom -- (Read-only) The name of the MUC room that sent the message. + mucnick -- (Read-only) The MUC nickname of message's sender. + + Attributes: + types -- May be one of: normal, chat, headline, groupchat, or error. + + Methods: + setup -- Overrides StanzaBase.setup. + chat -- Set the message type to 'chat'. + normal -- Set the message type to 'normal'. + reply -- Overrides StanzaBase.reply + get_type -- Overrides StanzaBase interface + get_mucroom -- Return the name of the MUC room of the message. + set_mucroom -- Dummy method to prevent assignment. + del_mucroom -- Dummy method to prevent deletion. + get_mucnick -- Return the MUC nickname of the message's sender. + set_mucnick -- Dummy method to prevent assignment. + del_mucnick -- Dummy method to prevent deletion. + """ + + namespace = 'jabber:client' + name = 'message' + interfaces = set(('type', 'to', 'from', 'id', 'body', 'subject', + 'mucroom', 'mucnick')) + sub_interfaces = set(('body', 'subject')) + plugin_attrib = name + types = set((None, 'normal', 'chat', 'headline', 'error', 'groupchat')) + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides StanzaBase.setup. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.getType = self.get_type + self.getMucroom = self.get_mucroom + self.setMucroom = self.set_mucroom + self.delMucroom = self.del_mucroom + self.getMucnick = self.get_mucnick + self.setMucnick = self.set_mucnick + self.delMucnick = self.del_mucnick + + return StanzaBase.setup(self, xml) + + def get_type(self): + """ + Return the message type. + + Overrides default stanza interface behavior. + + Returns 'normal' if no type attribute is present. + """ + return self._get_attr('type', 'normal') + + def chat(self): + """Set the message type to 'chat'.""" + self['type'] = 'chat' + return self + + def normal(self): + """Set the message type to 'chat'.""" + self['type'] = 'normal' + return self + + def reply(self, body=None): + """ + Create a message reply. + + Overrides StanzaBase.reply. + + Sets proper 'to' attribute if the message is from a MUC, and + adds a message body if one is given. + + Arguments: + body -- Optional text content for the message. + """ + StanzaBase.reply(self) + if self['type'] == 'groupchat': + self['to'] = self['to'].bare + + del self['id'] + + if body is not None: + self['body'] = body + return self + + def get_mucroom(self): + """ + Return the name of the MUC room where the message originated. + + Read-only stanza interface. + """ + if self['type'] == 'groupchat': + return self['from'].bare + else: + return '' + + def get_mucnick(self): + """ + Return the nickname of the MUC user that sent the message. + + Read-only stanza interface. + """ + if self['type'] == 'groupchat': + return self['from'].resource + else: + return '' + + def set_mucroom(self, value): + """Dummy method to prevent modification.""" + pass + + def del_mucroom(self): + """Dummy method to prevent deletion.""" + pass + + def set_mucnick(self, value): + """Dummy method to prevent modification.""" + pass + + def del_mucnick(self): + """Dummy method to prevent deletion.""" + pass diff --git a/sleekxmpp/stanza/nick.py b/sleekxmpp/stanza/nick.py new file mode 100644 index 0000000..a9243d1 --- /dev/null +++ b/sleekxmpp/stanza/nick.py @@ -0,0 +1,89 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Message, Presence +from sleekxmpp.xmlstream import ElementBase, ET, register_stanza_plugin + + +class Nick(ElementBase): + + """ + XEP-0172: User Nickname allows the addition of a <nick> element + in several stanza types, including <message> and <presence> stanzas. + + The nickname contained in a <nick> should be the global, friendly or + informal name chosen by the owner of a bare JID. The <nick> element + may be included when establishing communications with new entities, + such as normal XMPP users or MUC services. + + The nickname contained in a <nick> element will not necessarily be + the same as the nickname used in a MUC. + + Example stanzas: + <message to="user@example.com"> + <nick xmlns="http://jabber.org/nick/nick">The User</nick> + <body>...</body> + </message> + + <presence to="otheruser@example.com" type="subscribe"> + <nick xmlns="http://jabber.org/nick/nick">The User</nick> + </presence> + + Stanza Interface: + nick -- A global, friendly or informal name chosen by a user. + + Methods: + setup -- Overrides ElementBase.setup. + get_nick -- Return the nickname in the <nick> element. + set_nick -- Add a <nick> element with the given nickname. + del_nick -- Remove the <nick> element. + """ + + namespace = 'http://jabber.org/nick/nick' + name = 'nick' + plugin_attrib = name + interfaces = set(('nick',)) + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides StanzaBase.setup. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.setNick = self.set_nick + self.getNick = self.get_nick + self.delNick = self.del_nick + + return ElementBase.setup(self, xml) + + def set_nick(self, nick): + """ + Add a <nick> element with the given nickname. + + Arguments: + nick -- A human readable, informal name. + """ + self.xml.text = nick + + def get_nick(self): + """Return the nickname in the <nick> element.""" + return self.xml.text + + def del_nick(self): + """Remove the <nick> element.""" + if self.parent is not None: + self.parent().xml.remove(self.xml) + + +register_stanza_plugin(Message, Nick) +register_stanza_plugin(Presence, Nick) diff --git a/sleekxmpp/stanza/presence.py b/sleekxmpp/stanza/presence.py new file mode 100644 index 0000000..7dcd8f9 --- /dev/null +++ b/sleekxmpp/stanza/presence.py @@ -0,0 +1,186 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Error +from sleekxmpp.stanza.rootstanza import RootStanza +from sleekxmpp.xmlstream import StanzaBase, ET + + +class Presence(RootStanza): + + """ + XMPP's <presence> stanza allows entities to know the status of other + clients and components. Since it is currently the only multi-cast + stanza in XMPP, many extensions add more information to <presence> + stanzas to broadcast to every entry in the roster, such as + capabilities, music choices, or locations (XEP-0115: Entity Capabilities + and XEP-0163: Personal Eventing Protocol). + + Since <presence> stanzas are broadcast when an XMPP entity changes + its status, the bulk of the traffic in an XMPP network will be from + <presence> stanzas. Therefore, do not include more information than + necessary in a status message or within a <presence> stanza in order + to help keep the network running smoothly. + + Example <presence> stanzas: + <presence /> + + <presence from="user@example.com"> + <show>away</show> + <status>Getting lunch.</status> + <priority>5</priority> + </presence> + + <presence type="unavailable" /> + + <presence to="user@otherhost.com" type="subscribe" /> + + Stanza Interface: + priority -- A value used by servers to determine message routing. + show -- The type of status, such as away or available for chat. + status -- Custom, human readable status message. + + Attributes: + types -- One of: available, unavailable, error, probe, + subscribe, subscribed, unsubscribe, + and unsubscribed. + showtypes -- One of: away, chat, dnd, and xa. + + Methods: + setup -- Overrides StanzaBase.setup + reply -- Overrides StanzaBase.reply + set_show -- Set the value of the <show> element. + get_type -- Get the value of the type attribute or <show> element. + set_type -- Set the value of the type attribute or <show> element. + get_priority -- Get the value of the <priority> element. + set_priority -- Set the value of the <priority> element. + """ + + namespace = 'jabber:client' + name = 'presence' + interfaces = set(('type', 'to', 'from', 'id', 'show', + 'status', 'priority')) + sub_interfaces = set(('show', 'status', 'priority')) + plugin_attrib = name + + types = set(('available', 'unavailable', 'error', 'probe', 'subscribe', + 'subscribed', 'unsubscribe', 'unsubscribed')) + showtypes = set(('dnd', 'chat', 'xa', 'away')) + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides ElementBase.setup. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.setShow = self.set_show + self.getType = self.get_type + self.setType = self.set_type + self.delType = self.get_type + self.getPriority = self.get_priority + self.setPriority = self.set_priority + + return StanzaBase.setup(self, xml) + + def exception(self, e): + """ + Override exception passback for presence. + """ + pass + + def set_show(self, show): + """ + Set the value of the <show> element. + + Arguments: + show -- Must be one of: away, chat, dnd, or xa. + """ + if show is None: + self._del_sub('show') + elif show in self.showtypes: + self._set_sub_text('show', text=show) + return self + + def get_type(self): + """ + Return the value of the <presence> stanza's type attribute, or + the value of the <show> element. + """ + out = self._get_attr('type') + if not out: + out = self['show'] + if not out or out is None: + out = 'available' + return out + + def set_type(self, value): + """ + Set the type attribute's value, and the <show> element + if applicable. + + Arguments: + value -- Must be in either self.types or self.showtypes. + """ + if value in self.types: + self['show'] = None + if value == 'available': + value = '' + self._set_attr('type', value) + elif value in self.showtypes: + self['show'] = value + return self + + def del_type(self): + """ + Remove both the type attribute and the <show> element. + """ + self._del_attr('type') + self._del_sub('show') + + def set_priority(self, value): + """ + Set the entity's priority value. Some server use priority to + determine message routing behavior. + + Bot clients should typically use a priority of 0 if the same + JID is used elsewhere by a human-interacting client. + + Arguments: + value -- An integer value greater than or equal to 0. + """ + self._set_sub_text('priority', text=str(value)) + + def get_priority(self): + """ + Return the value of the <presence> element as an integer. + """ + p = self._get_sub_text('priority') + if not p: + p = 0 + try: + return int(p) + except ValueError: + # The priority is not a number: we consider it 0 as a default + return 0 + + def reply(self): + """ + Set the appropriate presence reply type. + + Overrides StanzaBase.reply. + """ + if self['type'] == 'unsubscribe': + self['type'] = 'unsubscribed' + elif self['type'] == 'subscribe': + self['type'] = 'subscribed' + return StanzaBase.reply(self) diff --git a/sleekxmpp/stanza/rootstanza.py b/sleekxmpp/stanza/rootstanza.py new file mode 100644 index 0000000..2677ea9 --- /dev/null +++ b/sleekxmpp/stanza/rootstanza.py @@ -0,0 +1,66 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +import traceback +import sys + +from sleekxmpp.exceptions import XMPPError +from sleekxmpp.stanza import Error +from sleekxmpp.xmlstream import ET, StanzaBase, register_stanza_plugin + + +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 + """ + self.reply() + if isinstance(e, XMPPError): + # We raised this deliberately + self['error']['condition'] = e.condition + self['error']['text'] = e.text + if e.extension is not None: + # Extended error tag + extxml = ET.Element("{%s}%s" % (e.extension_ns, e.extension), + e.extension_args) + self['error'].append(extxml) + self['error']['type'] = e.etype + else: + # We probably didn't raise this on purpose, so send a traceback + self['error']['condition'] = 'undefined-condition' + if sys.version_info < (3, 0): + self['error']['text'] = "SleekXMPP got into trouble." + else: + self['error']['text'] = traceback.format_tb(e.__traceback__) + logging.exception('Error handling {%s}%s stanza' % + (self.namespace, self.name)) + self.send() + + +register_stanza_plugin(RootStanza, Error) diff --git a/sleekxmpp/stanza/roster.py b/sleekxmpp/stanza/roster.py new file mode 100644 index 0000000..8f154a2 --- /dev/null +++ b/sleekxmpp/stanza/roster.py @@ -0,0 +1,125 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.stanza import Iq +from sleekxmpp.xmlstream import JID +from sleekxmpp.xmlstream import ET, ElementBase, register_stanza_plugin + + +class Roster(ElementBase): + + """ + Example roster stanzas: + <iq type="set"> + <query xmlns="jabber:iq:roster"> + <item jid="user@example.com" subscription="both" name="User"> + <group>Friends</group> + </item> + </query> + </iq> + + Stanza Inteface: + items -- A dictionary of roster entries contained + in the stanza. + + Methods: + get_items -- Return a dictionary of roster entries. + set_items -- Add <item> elements. + del_items -- Remove all <item> elements. + """ + + namespace = 'jabber:iq:roster' + name = 'query' + plugin_attrib = 'roster' + interfaces = set(('items',)) + + def setup(self, xml=None): + """ + Populate the stanza object using an optional XML object. + + Overrides StanzaBase.setup. + + Arguments: + xml -- Use an existing XML object for the stanza's values. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.setItems = self.set_items + self.getItems = self.get_items + self.delItems = self.del_items + + return ElementBase.setup(self, xml) + + 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: + ijid = str(jid) + item = ET.Element('{jabber:iq:roster}item', {'jid': ijid}) + if 'subscription' in items[jid]: + item.attrib['subscription'] = items[jid]['subscription'] + if 'name' in items[jid]: + name = items[jid]['name'] + if name is not None: + item.attrib['name'] = name + if 'groups' in items[jid]: + for group in items[jid]['groups']: + groupxml = ET.Element('{jabber:iq:roster}group') + groupxml.text = group + item.append(groupxml) + self.xml.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 = {} + itemsxml = self.xml.findall('{jabber:iq:roster}item') + if itemsxml is not None: + for itemxml in itemsxml: + item = {} + item['name'] = itemxml.get('name', '') + item['subscription'] = itemxml.get('subscription', '') + item['groups'] = [] + groupsxml = itemxml.findall('{jabber:iq:roster}group') + if groupsxml is not None: + for groupxml in groupsxml: + item['groups'].append(groupxml.text) + items[itemxml.get('jid')] = item + return items + + def del_items(self): + """ + Remove all <item> elements from the roster stanza. + """ + for child in self.xml.getchildren(): + self.xml.remove(child) + + +register_stanza_plugin(Iq, Roster) diff --git a/sleekxmpp/test/__init__.py b/sleekxmpp/test/__init__.py new file mode 100644 index 0000000..54d4dc5 --- /dev/null +++ b/sleekxmpp/test/__init__.py @@ -0,0 +1,11 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.test.mocksocket import TestSocket +from sleekxmpp.test.livesocket import TestLiveSocket +from sleekxmpp.test.sleektest import * diff --git a/sleekxmpp/test/livesocket.py b/sleekxmpp/test/livesocket.py new file mode 100644 index 0000000..5e8c547 --- /dev/null +++ b/sleekxmpp/test/livesocket.py @@ -0,0 +1,145 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import socket +try: + import queue +except ImportError: + import Queue as queue + + +class TestLiveSocket(object): + + """ + A live test socket that reads and writes to queues in + addition to an actual networking socket. + + Methods: + next_sent -- Return the next sent stanza. + next_recv -- Return the next received stanza. + recv_data -- Dummy method to have same interface as TestSocket. + recv -- Read the next stanza from the socket. + send -- Write a stanza to the socket. + makefile -- Dummy call, returns self. + read -- Read the next stanza from the socket. + """ + + def __init__(self, *args, **kwargs): + """ + Create a new, live test socket. + + Arguments: + Same as arguments for socket.socket + """ + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.recv_buffer = [] + self.recv_queue = queue.Queue() + self.send_queue = queue.Queue() + self.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 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) + 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. + """ + self.send_queue.put(data) + 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) diff --git a/sleekxmpp/test/mocksocket.py b/sleekxmpp/test/mocksocket.py new file mode 100644 index 0000000..e3ddd70 --- /dev/null +++ b/sleekxmpp/test/mocksocket.py @@ -0,0 +1,140 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import socket +try: + import queue +except ImportError: + import Queue as queue + + +class TestSocket(object): + + """ + A dummy socket that reads and writes to queues instead + of an actual networking socket. + + Methods: + next_sent -- Return the next sent stanza. + recv_data -- Make a stanza available to read next. + recv -- Read the next stanza from the socket. + send -- Write a stanza to the socket. + makefile -- Dummy call, returns self. + read -- Read the next stanza from the socket. + """ + + def __init__(self, *args, **kwargs): + """ + Create a new test socket. + + Arguments: + Same as arguments for socket.socket + """ + self.socket = socket.socket(*args, **kwargs) + self.recv_queue = queue.Queue() + self.send_queue = queue.Queue() + self.is_live = False + + 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) + + # ------------------------------------------------------------------ + # Socket Interface + + def recv(self, *args, **kwargs): + """ + Read a value from the received queue. + + Arguments: + Placeholders. Same as for socket.Socket.recv. + """ + return self.read(block=True) + + def send(self, data): + """ + Send data by placing it in the send queue. + + Arguments: + data -- String value to write. + """ + self.send_queue.put(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 timeout is not None: + block = True + try: + return self.recv_queue.get(block, timeout) + except: + return None diff --git a/sleekxmpp/test/sleektest.py b/sleekxmpp/test/sleektest.py new file mode 100644 index 0000000..2901e59 --- /dev/null +++ b/sleekxmpp/test/sleektest.py @@ -0,0 +1,763 @@ +""" + + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz, Lance J.T. Stout + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import unittest + +import sleekxmpp +from sleekxmpp import ClientXMPP, ComponentXMPP +from sleekxmpp.stanza import Message, Iq, Presence +from sleekxmpp.test import TestSocket, TestLiveSocket +from sleekxmpp.xmlstream import StanzaBase, ET, register_stanza_plugin +from sleekxmpp.xmlstream.tostring import tostring + + +class SleekTest(unittest.TestCase): + + """ + A SleekXMPP specific TestCase class that provides + methods for comparing message, iq, and presence stanzas. + + Methods: + Message -- Create a Message stanza object. + Iq -- Create an Iq stanza object. + Presence -- Create a Presence stanza object. + check_stanza -- Compare a generic stanza against an XML string. + check_message -- Compare a Message stanza against an XML string. + check_iq -- Compare an Iq stanza against an XML string. + check_presence -- Compare a Presence stanza against an XML string. + stream_start -- Initialize a dummy XMPP client. + stream_recv -- Queue data for XMPP client to receive. + stream_make_header -- Create a stream header. + stream_send_header -- Check that the given header has been sent. + stream_send_message -- Check that the XMPP client sent the given + Message stanza. + stream_send_iq -- Check that the XMPP client sent the given + Iq stanza. + stream_send_presence -- Check thatt the XMPP client sent the given + Presence stanza. + stream_send_stanza -- Check that the XMPP client sent the given + generic stanza. + stream_close -- Disconnect the XMPP client. + fix_namespaces -- Add top-level namespace to an XML object. + compare -- Compare XML objects against each other. + """ + + def parse_xml(self, xml_string): + try: + xml = ET.fromstring(xml_string) + return xml + except SyntaxError as e: + if 'unbound' in e.msg: + known_prefixes = { + 'stream': 'http://etherx.jabber.org/streams'} + + prefix = xml_string.split('<')[1].split(':')[0] + if prefix in known_prefixes: + xml_string = '<fixns xmlns:%s="%s">%s</fixns>' % ( + prefix, + known_prefixes[prefix], + xml_string) + xml = self.parse_xml(xml_string) + xml = xml.getchildren()[0] + return xml + + # ------------------------------------------------------------------ + # 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(None, *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(None, *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(None, *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)) + + # ------------------------------------------------------------------ + # Methods for comparing stanza objects to XML strings + + def check_stanza(self, stanza_class, stanza, xml_string, + defaults=None, use_values=True): + """ + Create and compare several stanza objects to a correct XML string. + + If use_values is False, test using getStanzaValues() and + setStanzaValues() 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_class -- The class of the stanza being tested. + stanza -- The stanza object to test. + xml_string -- A string version of the correct XML expected. + 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 getStanzaValues() and + setStanzaValues() should be used. Defaults to + True. + """ + xml = self.parse_xml(xml_string) + + # 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 getStanzaValues() and setStanzaValues() 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: + defaults = [] + 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.getStanzaValues() + stanza3 = stanza_class() + stanza3.setStanzaValues(values) + + debug = "Three methods for creating stanzas do not match.\n" + debug += "Given XML:\n%s\n" % tostring(xml) + debug += "Given stanza:\n%s\n" % tostring(stanza.xml) + debug += "Generated stanza:\n%s\n" % tostring(stanza2.xml) + debug += "Second generated stanza:\n%s\n" % tostring(stanza3.xml) + result = self.compare(xml, stanza.xml, stanza2.xml, stanza3.xml) + else: + debug = "Two methods for creating stanzas do not match.\n" + debug += "Given XML:\n%s\n" % tostring(xml) + debug += "Given stanza:\n%s\n" % tostring(stanza.xml) + debug += "Generated stanza:\n%s\n" % tostring(stanza2.xml) + result = self.compare(xml, stanza.xml, stanza2.xml) + + self.failUnless(result, debug) + + def check_message(self, msg, xml_string, use_values=True): + """ + Create and compare several message stanza objects to a + correct XML string. + + If use_values is False, the test using getStanzaValues() and + setStanzaValues() will not be used. + + Arguments: + msg -- The Message stanza object to check. + xml_string -- The XML contents to compare against. + use_values -- Indicates if the test using getStanzaValues + and setStanzaValues should be used. Defaults + to True. + """ + + return self.check_stanza(Message, msg, xml_string, + defaults=['type'], + use_values=use_values) + + def check_iq(self, iq, xml_string, use_values=True): + """ + Create and compare several iq stanza objects to a + correct XML string. + + If use_values is False, the test using getStanzaValues() and + setStanzaValues() will not be used. + + Arguments: + iq -- The Iq stanza object to check. + xml_string -- The XML contents to compare against. + use_values -- Indicates if the test using getStanzaValues + and setStanzaValues should be used. Defaults + to True. + """ + return self.check_stanza(Iq, iq, xml_string, use_values=use_values) + + def check_presence(self, pres, xml_string, use_values=True): + """ + Create and compare several presence stanza objects to a + correct XML string. + + If use_values is False, the test using getStanzaValues() and + setStanzaValues() will not be used. + + Arguments: + iq -- The Iq stanza object to check. + xml_string -- The XML contents to compare against. + use_values -- Indicates if the test using getStanzaValues + and setStanzaValues should be used. Defaults + to True. + """ + return self.check_stanza(Presence, pres, xml_string, + defaults=['priority'], + use_values=use_values) + + # ------------------------------------------------------------------ + # Methods for simulating stanza streams. + + def stream_start(self, mode='client', skip=True, header=None, + socket='mock', jid='tester@localhost', + password='test', server='localhost', + port=5222): + """ + Initialize an XMPP client or component using a dummy XML stream. + + Arguments: + mode -- Either 'client' or 'component'. Defaults to 'client'. + skip -- Indicates if the first item in the sent queue (the + stream header) should be removed. Tests that wish + to test initializing the stream should set this to + False. Otherwise, the default of True should be used. + socket -- Either 'mock' or 'live' to indicate if the socket + should be a dummy, mock socket or a live, functioning + socket. Defaults to 'mock'. + jid -- The JID to use for the connection. + Defaults to 'tester@localhost'. + password -- The password to use for the connection. + Defaults to 'test'. + server -- The name of the XMPP server. Defaults to 'localhost'. + port -- The port to use when connecting to the server. + Defaults to 5222. + """ + + if mode == 'client': + self.xmpp = ClientXMPP(jid, password) + elif mode == 'component': + self.xmpp = ComponentXMPP(jid, password, + server, port) + else: + raise ValueError("Unknown XMPP connection mode.") + + if socket == 'mock': + self.xmpp.set_socket(TestSocket()) + + # Simulate connecting for mock sockets. + self.xmpp.auto_reconnect = False + self.xmpp.is_client = True + self.xmpp.state._set_state('connected') + + # Must have the stream header ready for xmpp.process() to work. + if not header: + header = self.xmpp.stream_header + self.xmpp.socket.recv_data(header) + elif socket == 'live': + self.xmpp.socket_class = TestLiveSocket + self.xmpp.connect() + else: + raise ValueError("Unknown socket type.") + + self.xmpp.register_plugins() + self.xmpp.process(threaded=True) + if skip: + # Clear startup stanzas + self.xmpp.socket.next_sent(timeout=1) + if mode == 'component': + self.xmpp.socket.next_sent(timeout=1) + + def stream_make_header(self, sto='', + sfrom='', + sid='', + stream_ns="http://etherx.jabber.org/streams", + default_ns="jabber:client", + version="1.0", + xml_header=True): + """ + Create a stream header to be received by the test XMPP agent. + + The header must be saved and passed to stream_start. + + Arguments: + sto -- The recipient of the stream header. + sfrom -- The agent sending the stream header. + sid -- The stream's id. + stream_ns -- The namespace of the stream's root element. + default_ns -- The default stanza namespace. + version -- The stream version. + xml_header -- Indicates if the XML version header should be + appended before the stream header. + """ + header = '<stream:stream %s>' + parts = [] + if xml_header: + header = '<?xml version="1.0"?>' + header + if sto: + parts.append('to="%s"' % sto) + if sfrom: + parts.append('from="%s"' % sfrom) + if sid: + parts.append('id="%s"' % sid) + parts.append('version="%s"' % version) + parts.append('xmlns:stream="%s"' % stream_ns) + parts.append('xmlns="%s"' % default_ns) + return header % ' '.join(parts) + + def stream_recv(self, data, stanza_class=StanzaBase, defaults=[], + 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 -- String stanza XML to be received and processed by + the XMPP client or component. + stanza_class -- The stanza object class for verifying data received + by a live connection. Defaults to StanzaBase. + defaults -- A list of stanza interfaces with default values that + may interfere with comparisons. + use_values -- Indicates if stanza comparisons should test using + getStanzaValues() and setStanzaValues(). + Defaults to True. + timeout -- Time to wait in seconds for data to be received by + a live connection. + """ + if self.xmpp.socket.is_live: + # we are working with a live connection, so we should + # verify what has been received instead of simulating + # receiving data. + recv_data = self.xmpp.socket.next_recv(timeout) + if recv_data is None: + return False + stanza = stanza_class(xml=self.parse_xml(recv_data)) + return self.check_stanza(stanza_class, stanza, data, + defaults=defaults, + use_values=use_values) + else: + # place the data in the dummy socket receiving queue. + data = str(data) + self.xmpp.socket.recv_data(data) + + def stream_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.stream_make_header(sto, sfrom, sid, + stream_ns=stream_ns, + default_ns=default_ns, + version=version, + xml_header=xml_header) + recv_header = self.xmpp.socket.next_recv(timeout) + if recv_header is None: + raise ValueError("Socket did not return data.") + + # Apply closing elements so that we can construct + # XML objects for comparison. + header2 = header + '</stream:stream>' + recv_header2 = recv_header + '</stream:stream>' + + xml = self.parse_xml(header2) + recv_xml = self.parse_xml(recv_header2) + + if sid is None: + # Ignore the id sent by the server since + # we can't know in advance what it will be. + if 'id' in recv_xml.attrib: + del recv_xml.attrib['id'] + + # Ignore the xml:lang attribute for now. + if 'xml:lang' in recv_xml.attrib: + del recv_xml.attrib['xml:lang'] + xml_ns = 'http://www.w3.org/XML/1998/namespace' + if '{%s}lang' % xml_ns in recv_xml.attrib: + del recv_xml.attrib['{%s}lang' % xml_ns] + + if recv_xml.getchildren: + # We received more than just the header + for xml in recv_xml.getchildren(): + self.xmpp.socket.recv_data(tostring(xml)) + + attrib = recv_xml.attrib + recv_xml.clear() + recv_xml.attrib = attrib + + self.failUnless( + self.compare(xml, recv_xml), + "Stream headers do not match:\nDesired:\n%s\nReceived:\n%s" % ( + '%s %s' % (xml.tag, xml.attrib), + '%s %s' % (recv_xml.tag, recv_xml.attrib))) + #tostring(xml), tostring(recv_xml)))#recv_header)) + + def stream_recv_feature(self, data, use_values=True, timeout=1): + """ + """ + if self.xmpp.socket.is_live: + # we are working with a live connection, so we should + # verify what has been received instead of simulating + # receiving data. + recv_data = self.xmpp.socket.next_recv(timeout) + if recv_data is None: + return False + xml = self.parse_xml(data) + recv_xml = self.parse_xml(recv_data) + self.failUnless(self.compare(xml, recv_xml), + "Features do not match.\nDesired:\n%s\nReceived:\n%s" % ( + tostring(xml), tostring(recv_xml))) + else: + # place the data in the dummy socket receiving queue. + data = str(data) + self.xmpp.socket.recv_data(data) + + + + def stream_recv_message(self, data, use_values=True, timeout=1): + """ + """ + return self.stream_recv(data, stanza_class=Message, + defaults=['type'], + use_values=use_values, + timeout=timeout) + + def stream_recv_iq(self, data, use_values=True, timeout=1): + """ + """ + return self.stream_recv(data, stanza_class=Iq, + use_values=use_values, + timeout=timeout) + + def stream_recv_presence(self, data, use_values=True, timeout=1): + """ + """ + return self.stream_recv(data, stanza_class=Presence, + defaults=['priority'], + use_values=use_values, + timeout=timeout) + + def stream_send_header(self, sto='', + sfrom='', + sid='', + stream_ns="http://etherx.jabber.org/streams", + default_ns="jabber:client", + version="1.0", + xml_header=False, + timeout=1): + """ + Check that a given stream header was sent. + + Arguments: + sto -- The recipient of the stream header. + sfrom -- The agent sending the stream header. + sid -- The stream's id. + stream_ns -- The namespace of the stream's root element. + default_ns -- The default stanza namespace. + version -- The stream version. + xml_header -- Indicates if the XML version header should be + appended before the stream header. + timeout -- Length of time to wait in seconds for a + response. + """ + header = self.stream_make_header(sto, sfrom, sid, + stream_ns=stream_ns, + default_ns=default_ns, + version=version, + xml_header=xml_header) + sent_header = self.xmpp.socket.next_sent(timeout) + if sent_header is None: + raise ValueError("Socket did not return data.") + + # Apply closing elements so that we can construct + # XML objects for comparison. + header2 = header + '</stream:stream>' + sent_header2 = sent_header + b'</stream:stream>' + + xml = self.parse_xml(header2) + sent_xml = self.parse_xml(sent_header2) + + self.failUnless( + self.compare(xml, sent_xml), + "Stream headers do not match:\nDesired:\n%s\nSent:\n%s" % ( + header, sent_header)) + + def stream_send_feature(self, data, use_values=True, timeout=1): + """ + """ + sent_data = self.xmpp.socket.next_sent(timeout) + if sent_data is None: + return False + xml = self.parse_xml(data) + sent_xml = self.parse_xml(sent_data) + self.failUnless(self.compare(xml, sent_xml), + "Features do not match.\nDesired:\n%s\nSent:\n%s" % ( + tostring(xml), tostring(sent_xml))) + + def stream_send_stanza(self, stanza_class, data, defaults=None, + use_values=True, timeout=.1): + """ + Check that the XMPP client sent the given stanza XML. + + Extracts the next sent stanza and compares it with the given + XML using check_stanza. + + 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. + """ + if isintance(data, str): + data = stanza_class(xml=self.parse_xml(data)) + sent = self.xmpp.socket.next_sent(timeout) + self.check_stanza(stanza_class, data, sent, + defaults=defaults, + use_values=use_values) + + def stream_send_message(self, data, use_values=True, timeout=.1): + """ + Check that the XMPP client sent the given stanza XML. + + Extracts the next sent stanza and compares it with the given + XML using check_message. + + Arguments: + 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. + timeout -- Time in seconds to wait for a stanza before + failing the check. + """ + if isinstance(data, str): + data = self.Message(xml=self.parse_xml(data)) + sent = self.xmpp.socket.next_sent(timeout) + self.check_message(data, sent, use_values) + + def stream_send_iq(self, data, use_values=True, timeout=.1): + """ + Check that the XMPP client sent the given stanza XML. + + Extracts the next sent stanza and compares it with the given + XML using check_iq. + + Arguments: + data -- The XML string of the expected Iq stanza, + or an equivalent stanza object. + use_values -- Modifies the type of tests used by check_iq. + timeout -- Time in seconds to wait for a stanza before + failing the check. + """ + if isinstance(data, str): + data = self.Iq(xml=self.parse_xml(data)) + sent = self.xmpp.socket.next_sent(timeout) + self.check_iq(data, sent, use_values) + + def stream_send_presence(self, data, use_values=True, timeout=.1): + """ + Check that the XMPP client sent the given stanza XML. + + Extracts the next sent stanza and compares it with the given + XML using check_presence. + + Arguments: + data -- The XML string of the expected Presence stanza, + or an equivalent stanza object. + use_values -- Modifies the type of tests used by check_presence. + timeout -- Time in seconds to wait for a stanza before + failing the check. + """ + if isinstance(data, str): + data = self.Presence(xml=self.parse_xml(data)) + sent = self.xmpp.socket.next_sent(timeout) + self.check_presence(data, sent, use_values) + + def stream_close(self): + """ + Disconnect the dummy XMPP client. + + Can be safely called even if stream_start has not been called. + + Must be placed in the tearDown method of a test class to ensure + that the XMPP client is disconnected after an error. + """ + if hasattr(self, 'xmpp') and self.xmpp is not None: + self.xmpp.socket.recv_data(self.xmpp.stream_footer) + self.xmpp.disconnect() + + # ------------------------------------------------------------------ + # XML Comparison and Cleanup + + def fix_namespaces(self, xml, ns): + """ + Assign a namespace to an element and any children that + don't have a namespace. + + Arguments: + xml -- The XML object to fix. + ns -- The namespace to add to the XML object. + """ + if xml.tag.startswith('{'): + return + xml.tag = '{%s}%s' % (ns, xml.tag) + for child in xml.getchildren(): + self.fix_namespaces(child, ns) + + def compare(self, xml, *other): + """ + Compare XML objects. + + Arguments: + xml -- The XML object to compare against. + *other -- The list of XML objects to compare. + """ + if not other: + return False + + # Compare multiple objects + if len(other) > 1: + for xml2 in other: + if not self.compare(xml, xml2): + return False + return True + + other = other[0] + + # Step 1: Check tags + if xml.tag != other.tag: + return False + + # Step 2: Check attributes + if xml.attrib != other.attrib: + return False + + # Step 3: Check text + if xml.text is None: + xml.text = "" + if other.text is None: + other.text = "" + xml.text = xml.text.strip() + other.text = other.text.strip() + + if xml.text != other.text: + return False + + # Step 4: Check children count + if len(xml.getchildren()) != len(other.getchildren()): + return False + + # Step 5: Recursively check children + for child in xml: + child2s = other.findall("%s" % child.tag) + if child2s is None: + return False + for child2 in child2s: + if self.compare(child, child2): + break + else: + return False + + # Step 6: Recursively check children the other way. + for child in other: + child2s = xml.findall("%s" % child.tag) + if child2s is None: + return False + for child2 in child2s: + if self.compare(child, child2): + break + else: + return False + + # Everything matches + return True diff --git a/sleekxmpp/thirdparty/__init__.py b/sleekxmpp/thirdparty/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/sleekxmpp/thirdparty/__init__.py diff --git a/sleekxmpp/thirdparty/statemachine.py b/sleekxmpp/thirdparty/statemachine.py new file mode 100644 index 0000000..b176df0 --- /dev/null +++ b/sleekxmpp/thirdparty/statemachine.py @@ -0,0 +1,287 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" +import threading +import time +import logging + +log = logging.getLogger(__name__) + + +class StateMachine(object): + + def __init__(self, states=[]): + self.lock = threading.Lock() + self.notifier = threading.Event() + self.__states = [] + self.addStates(states) + self.__default_state = self.__states[0] + self.__current_state = self.__default_state + + def addStates(self, states): + self.lock.acquire() + try: + for state in states: + if state in self.__states: + raise IndexError("The state '%s' is already in the StateMachine." % state) + self.__states.append(state) + finally: self.lock.release() + + + def transition(self, from_state, to_state, wait=0.0, func=None, args=[], kwargs={}): + ''' + Transition from the given `from_state` to the given `to_state`. + This method will return `True` if the state machine is now in `to_state`. It + will return `False` if a timeout occurred the transition did not occur. + If `wait` is 0 (the default,) this method returns immediately if the state machine + is not in `from_state`. + + If you want the thread to block and transition once the state machine to enters + `from_state`, set `wait` to a non-negative value. Note there is no 'block + indefinitely' flag since this leads to deadlock. If you want to wait indefinitely, + choose a reasonable value for `wait` (e.g. 20 seconds) and do so in a while loop like so: + + :: + + while not thread_should_exit and not state_machine.transition('disconnected', 'connecting', wait=20 ): + pass # timeout will occur every 20s unless transition occurs + if thread_should_exit: return + # perform actions here after successful transition + + This allows the thread to be responsive by setting `thread_should_exit=True`. + + The optional `func` argument allows the user to pass a callable operation which occurs + within the context of the state transition (e.g. while the state machine is locked.) + If `func` returns a True value, the transition will occur. If `func` returns a non- + True value or if an exception is thrown, the transition will not occur. Any thrown + exception is not caught by the state machine and is the caller's responsibility to handle. + If `func` completes normally, this method will return the value returned by `func.` If + values for `args` and `kwargs` are provided, they are expanded and passed like so: + `func( *args, **kwargs )`. + ''' + + return self.transition_any((from_state,), to_state, wait=wait, + func=func, args=args, kwargs=kwargs) + + + def transition_any(self, from_states, to_state, wait=0.0, func=None, args=[], kwargs={}): + ''' + Transition from any of the given `from_states` to the given `to_state`. + ''' + + if not (isinstance(from_states,tuple) or isinstance(from_states,list)): + raise ValueError("from_states should be a list or tuple") + + for state in from_states: + if not state in self.__states: + raise ValueError("StateMachine does not contain from_state %s." % state) + if not to_state in self.__states: + raise ValueError("StateMachine does not contain to_state %s." % to_state) + + start = time.time() + while not self.lock.acquire(False): + time.sleep(.001) + if (start + wait - time.time()) <= 0.0: + logging.debug("Could not acquire lock") + return False + + while not self.__current_state in from_states: + # detect timeout: + remainder = start + wait - time.time() + if remainder > 0: + self.notifier.wait(remainder) + else: + logging.debug("State was not ready") + self.lock.release() + return False + + try: # lock is acquired; all other threads will return false or wait until notify/timeout + if self.__current_state in from_states: # should always be True due to lock + + # Note that func might throw an exception, but that's OK, it aborts the transition + return_val = func(*args,**kwargs) if func is not None else True + + # some 'false' value returned from func, + # indicating that transition should not occur: + if not return_val: return return_val + + log.debug(' ==== TRANSITION %s -> %s', self.__current_state, to_state) + self._set_state(to_state) + return return_val # some 'true' value returned by func or True if func was None + else: + log.error("StateMachine bug!! The lock should ensure this doesn't happen!") + return False + finally: + self.notifier.set() # notify any waiting threads that the state has changed. + self.notifier.clear() + self.lock.release() + + + def transition_ctx(self, from_state, to_state, wait=0.0): + ''' + Use the state machine as a context manager. The transition occurs on /exit/ from + the `with` context, so long as no exception is thrown. For example: + + :: + + with state_machine.transition_ctx('one','two', wait=5) as locked: + if locked: + # the state machine is currently locked in state 'one', and will + # transition to 'two' when the 'with' statement ends, so long as + # no exception is thrown. + print 'Currently locked in state one: %s' % state_machine['one'] + + else: + # The 'wait' timed out, and no lock has been acquired + print 'Timed out before entering state "one"' + + print 'Since no exception was thrown, we are now in state "two": %s' % state_machine['two'] + + + The other main difference between this method and `transition()` is that the + state machine is locked for the duration of the `with` statement. Normally, + after a `transition()` occurs, the state machine is immediately unlocked and + available to another thread to call `transition()` again. + ''' + + if not from_state in self.__states: + raise ValueError("StateMachine does not contain from_state %s." % from_state) + if not to_state in self.__states: + raise ValueError("StateMachine does not contain to_state %s." % to_state) + + return _StateCtx(self, from_state, to_state, wait) + + + def ensure(self, state, wait=0.0, block_on_transition=False): + ''' + Ensure the state machine is currently in `state`, or wait until it enters `state`. + ''' + return self.ensure_any((state,), wait=wait, block_on_transition=block_on_transition) + + + def ensure_any(self, states, wait=0.0, block_on_transition=False): + ''' + Ensure we are currently in one of the given `states` or wait until + we enter one of those states. + + Note that due to the nature of the function, you cannot guarantee that + the entirety of some operation completes while you remain in a given + state. That would require acquiring and holding a lock, which + would mean no other threads could do the same. (You'd essentially + be serializing all of the threads that are 'ensuring' their tasks + occurred in some state. + ''' + if not (isinstance(states,tuple) or isinstance(states,list)): + raise ValueError('states arg should be a tuple or list') + + for state in states: + if not state in self.__states: + raise ValueError("StateMachine does not contain state '%s'" % state) + + # if we're in the middle of a transition, determine whether we should + # 'fall back' to the 'current' state, or wait for the new state, in order to + # avoid an operation occurring in the wrong state. + # TODO another option would be an ensure_ctx that uses a semaphore to allow + # threads to indicate they want to remain in a particular state. + + # will return immediately if no transition is in process. + if block_on_transition: + # we're not in the middle of a transition; don't hold the lock + if self.lock.acquire(False): self.lock.release() + # wait for the transition to complete + else: self.notifier.wait() + + start = time.time() + while not self.__current_state in states: + # detect timeout: + remainder = start + wait - time.time() + if remainder > 0: self.notifier.wait(remainder) + else: return False + return True + + + def reset(self): + # TODO need to lock before calling this? + self.transition(self.__current_state, self.__default_state) + + + def _set_state(self, state): #unsynchronized, only call internally after lock is acquired + self.__current_state = state + return state + + + def current_state(self): + ''' + Return the current state name. + ''' + return self.__current_state + + + def __getitem__(self, state): + ''' + Non-blocking, non-synchronized test to determine if we are in the given state. + Use `StateMachine.ensure(state)` to wait until the machine enters a certain state. + ''' + return self.__current_state == state + + def __str__(self): + return "".join(("StateMachine(", ','.join(self.__states), "): ", self.__current_state)) + + + +class _StateCtx: + + def __init__(self, state_machine, from_state, to_state, wait): + self.state_machine = state_machine + self.from_state = from_state + self.to_state = to_state + self.wait = wait + self._locked = False + + def __enter__(self): + start = time.time() + while not self.state_machine[self.from_state] or not self.state_machine.lock.acquire(False): + # detect timeout: + remainder = start + self.wait - time.time() + if remainder > 0: self.state_machine.notifier.wait(remainder) + else: + log.debug('StateMachine timeout while waiting for state: %s', self.from_state) + return False + + self._locked = True # lock has been acquired at this point + self.state_machine.notifier.clear() + log.debug('StateMachine entered context in state: %s', + self.state_machine.current_state()) + return True + + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_val is not None: + log.exception("StateMachine exception in context, remaining in state: %s\n%s:%s", + self.state_machine.current_state(), exc_type.__name__, exc_val) + + if self._locked: + if exc_val is None: + log.debug(' ==== TRANSITION %s -> %s', + self.state_machine.current_state(), self.to_state) + self.state_machine._set_state(self.to_state) + + self.state_machine.notifier.set() + self.state_machine.lock.release() + + return False # re-raise any exception + +if __name__ == '__main__': + + def callback(s, s2): + print((1, s.transition('on', 'off', wait=0.0, func=callback, args=[s,s2]))) + print((2, s2.transition('off', 'on', func=callback, args=[s,s2]))) + return True + + s = StateMachine(('off', 'on')) + s2 = StateMachine(('off', 'on')) + print((3, s.transition('off', 'on', wait=0.0, func=callback, args=[s,s2]),)) + print((s.current_state(), s2.current_state())) diff --git a/sleekxmpp/xmlstream/__init__.py b/sleekxmpp/xmlstream/__init__.py new file mode 100644 index 0000000..67b20c5 --- /dev/null +++ b/sleekxmpp/xmlstream/__init__.py @@ -0,0 +1,19 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.jid import JID +from sleekxmpp.xmlstream.scheduler import Scheduler +from sleekxmpp.xmlstream.stanzabase import StanzaBase, ElementBase, ET +from sleekxmpp.xmlstream.stanzabase import register_stanza_plugin +from sleekxmpp.xmlstream.tostring import tostring +from sleekxmpp.xmlstream.xmlstream import XMLStream, RESPONSE_TIMEOUT +from sleekxmpp.xmlstream.xmlstream import RestartStream + +__all__ = ['JID', 'Scheduler', 'StanzaBase', 'ElementBase', + 'ET', 'StateMachine', 'tostring', 'XMLStream', + 'RESPONSE_TIMEOUT', 'RestartStream'] diff --git a/sleekxmpp/xmlstream/filesocket.py b/sleekxmpp/xmlstream/filesocket.py new file mode 100644 index 0000000..441ff87 --- /dev/null +++ b/sleekxmpp/xmlstream/filesocket.py @@ -0,0 +1,41 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from socket import _fileobject +import socket + + +class FileSocket(_fileobject): + + """ + Create a file object wrapper for a socket to work around + issues present in Python 2.6 when using sockets as file objects. + + The parser for xml.etree.cElementTree requires a file, but we will + be reading from the XMPP connection socket instead. + """ + + def read(self, size=4096): + """Read data from the socket as if it were a file.""" + data = self._sock.recv(size) + if data is not None: + return data + + +class Socket26(socket._socketobject): + + """ + A custom socket implementation that uses our own FileSocket class + to work around issues in Python 2.6 when using sockets as files. + """ + + def makefile(self, mode='r', bufsize=-1): + """makefile([mode[, bufsize]]) -> file object + Return a regular file object corresponding to the socket. The mode + and bufsize arguments are as for the built-in open() function.""" + return FileSocket(self._sock, mode, bufsize) diff --git a/sleekxmpp/xmlstream/handler/__init__.py b/sleekxmpp/xmlstream/handler/__init__.py new file mode 100644 index 0000000..7bcf0b7 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/__init__.py @@ -0,0 +1,14 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.handler.callback import Callback +from sleekxmpp.xmlstream.handler.waiter import Waiter +from sleekxmpp.xmlstream.handler.xmlcallback import XMLCallback +from sleekxmpp.xmlstream.handler.xmlwaiter import XMLWaiter + +__all__ = ['Callback', 'Waiter', 'XMLCallback', 'XMLWaiter'] diff --git a/sleekxmpp/xmlstream/handler/base.py b/sleekxmpp/xmlstream/handler/base.py new file mode 100644 index 0000000..9c704ec --- /dev/null +++ b/sleekxmpp/xmlstream/handler/base.py @@ -0,0 +1,89 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +class 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. The first is during + the stream processing itself. The second is after stream processing + and during SleekXMPP's main event loop. The prerun method is used + for execution during stream processing, and the run method is used + during the main event loop. + + Attributes: + name -- The name of the handler. + stream -- The stream this handler is assigned to. + + Methods: + match -- Compare a stanza with the handler's matcher. + prerun -- Handler execution during stream processing. + run -- Handler execution during the main event loop. + check_delete -- Indicate if the handler may be removed from use. + """ + + def __init__(self, name, matcher, stream=None): + """ + Create a new stream handler. + + Arguments: + name -- The name of the handler. + matcher -- A matcher object from xmlstream.matcher that will be + used to determine if a stanza should be accepted by + this handler. + stream -- The XMLStream instance the handler should monitor. + """ + self.checkDelete = self.check_delete + + self.name = name + self.stream = stream + self._destroy = False + self._payload = None + self._matcher = matcher + if stream is not None: + stream.registerHandler(self) + + def match(self, xml): + """ + Compare a stanza or XML object with the handler's matcher. + + Arguments + xml -- An XML or stanza object. + """ + return self._matcher.match(xml) + + def prerun(self, payload): + """ + Prepare the handler for execution while the XML stream is being + processed. + + Arguments: + payload -- A stanza object. + """ + self._payload = payload + + def run(self, payload): + """ + Execute the handler after XML stream processing and during the + main event loop. + + Arguments: + payload -- A stanza 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/sleekxmpp/xmlstream/handler/callback.py b/sleekxmpp/xmlstream/handler/callback.py new file mode 100644 index 0000000..f0a7285 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/callback.py @@ -0,0 +1,84 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.handler.base import BaseHandler + + +class Callback(BaseHandler): + + """ + The Callback handler will execute a callback function with + matched stanzas. + + The handler may execute the callback either during stream + processing or during the main event loop. + + Callback functions are all executed in the same thread, so be + aware if you are executing functions that will block for extended + periods of time. Typically, you should signal your own events using the + SleekXMPP object's event() method to pass the stanza off to a threaded + event handler for further processing. + + Methods: + prerun -- Overrides BaseHandler.prerun + run -- Overrides BaseHandler.run + """ + + def __init__(self, name, matcher, pointer, thread=False, + once=False, instream=False, stream=None): + """ + Create a new callback handler. + + Arguments: + name -- The name of the handler. + matcher -- A matcher object for matching stanza objects. + pointer -- The function to execute during callback. + thread -- DEPRECATED. Remains only for backwards compatibility. + once -- Indicates if the handler should be used only + once. Defaults to False. + instream -- Indicates if the callback should be executed + during stream processing instead of in the + main event loop. + stream -- The XMLStream instance this handler should monitor. + """ + 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. + + Overrides BaseHandler.prerun + + Arguments: + payload -- The matched stanza object. + """ + BaseHandler.prerun(self, payload) + if self._instream: + self.run(payload, True) + + def run(self, payload, instream=False): + """ + Execute the callback function with the matched stanza payload. + + 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. + """ + if not self._instream or instream: + BaseHandler.run(self, payload) + self._pointer(payload) + if self._once: + self._destroy = True diff --git a/sleekxmpp/xmlstream/handler/waiter.py b/sleekxmpp/xmlstream/handler/waiter.py new file mode 100644 index 0000000..8072022 --- /dev/null +++ b/sleekxmpp/xmlstream/handler/waiter.py @@ -0,0 +1,98 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import logging +try: + import queue +except ImportError: + import Queue as queue + +from sleekxmpp.xmlstream import StanzaBase, RESPONSE_TIMEOUT +from sleekxmpp.xmlstream.handler.base import BaseHandler + + +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. + + Methods: + check_delete -- Overrides BaseHandler.check_delete + prerun -- Overrides BaseHandler.prerun + run -- Overrides BaseHandler.run + wait -- Wait for a stanza to arrive and return it to + an event handler. + """ + + def __init__(self, name, matcher, stream=None): + """ + Create a new Waiter. + + Arguments: + name -- The name of the waiter. + matcher -- A matcher object to detect the desired stanza. + stream -- Optional XMLStream instance to monitor. + """ + BaseHandler.__init__(self, name, matcher, stream=stream) + self._payload = queue.Queue() + + def prerun(self, payload): + """ + Store the matched stanza. + + Overrides BaseHandler.prerun + + Arguments: + payload -- The matched stanza object. + """ + self._payload.put(payload) + + def run(self, payload): + """ + Do not process this handler during the main event loop. + + Overrides BaseHandler.run + + Arguments: + payload -- The matched stanza object. + """ + pass + + def wait(self, timeout=RESPONSE_TIMEOUT): + """ + 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. + + Arguments: + timeout -- The number of seconds to wait for the stanza to + arrive. Defaults to the global default timeout + value sleekxmpp.xmlstream.RESPONSE_TIMEOUT. + """ + try: + stanza = self._payload.get(True, timeout) + except queue.Empty: + stanza = False + logging.warning("Timed out waiting for %s" % self.name) + self.stream.removeHandler(self.name) + return stanza + + def check_delete(self): + """ + Always remove waiters after use. + + Overrides BaseHandler.check_delete + """ + return True diff --git a/sleekxmpp/xmlstream/handler/xmlcallback.py b/sleekxmpp/xmlstream/handler/xmlcallback.py new file mode 100644 index 0000000..11607ff --- /dev/null +++ b/sleekxmpp/xmlstream/handler/xmlcallback.py @@ -0,0 +1,36 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.handler import Callback + + +class XMLCallback(Callback): + + """ + The XMLCallback class is identical to the normal Callback class, + except that XML contents of matched stanzas will be processed instead + of the stanza objects themselves. + + Methods: + run -- Overrides Callback.run + """ + + def run(self, payload, instream=False): + """ + Execute the callback function with the matched stanza's + XML contents, instead of the stanza itself. + + Overrides BaseHandler.run + + Arguments: + payload -- The matched stanza object. + instream -- Force the handler to execute during + stream processing. Used only by prerun. + Defaults to False. + """ + Callback.run(self, payload.xml, instream) diff --git a/sleekxmpp/xmlstream/handler/xmlwaiter.py b/sleekxmpp/xmlstream/handler/xmlwaiter.py new file mode 100644 index 0000000..5201caf --- /dev/null +++ b/sleekxmpp/xmlstream/handler/xmlwaiter.py @@ -0,0 +1,33 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.handler import Waiter + + +class XMLWaiter(Waiter): + + """ + The XMLWaiter class is identical to the normal Waiter class + except that it returns the XML contents of the stanza instead + of the full stanza object itself. + + Methods: + prerun -- Overrides Waiter.prerun + """ + + def prerun(self, payload): + """ + Store the XML contents of the stanza to return to the + waiting event handler. + + Overrides Waiter.prerun + + Arguments: + payload -- The matched stanza object. + """ + Waiter.prerun(self, payload.xml) diff --git a/sleekxmpp/xmlstream/jid.py b/sleekxmpp/xmlstream/jid.py new file mode 100644 index 0000000..33d845a --- /dev/null +++ b/sleekxmpp/xmlstream/jid.py @@ -0,0 +1,123 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +class JID(object): + """ + A representation of a Jabber ID, or JID. + + Each JID may have three components: a user, a domain, and an optional + resource. For example: user@domain/resource + + When a resource is not used, the JID is called a bare JID. + The JID is a full JID otherwise. + + Attributes: + jid -- Alias for 'full'. + full -- The value of the full JID. + bare -- The value of the bare JID. + user -- The username portion of the JID. + domain -- The domain name portion of the JID. + server -- Alias for 'domain'. + resource -- The resource portion of the JID. + + Methods: + reset -- Use a new JID value. + regenerate -- Recreate the JID from its components. + """ + + def __init__(self, jid): + """Initialize a new JID""" + self.reset(jid) + + def reset(self, jid): + """ + Start fresh from a new JID string. + + Arguments: + jid - The new JID value. + """ + self._full = self._jid = str(jid) + self._domain = None + self._resource = None + self._user = None + self._bare = None + + def __getattr__(self, name): + """ + Handle getting the JID values, using cache if available. + + Arguments: + name -- One of: user, server, domain, resource, + full, or bare. + """ + if name == 'resource': + if self._resource is None and '/' in self._jid: + self._resource = self._jid.split('/', 1)[-1] + return self._resource or "" + elif name == 'user': + if self._user is None: + if '@' in self._jid: + self._user = self._jid.split('@', 1)[0] + else: + self._user = self._user + return self._user or "" + elif name in ('server', 'domain', 'host'): + if self._domain is None: + self._domain = self._jid.split('@', 1)[-1].split('/', 1)[0] + return self._domain or "" + elif name == 'full': + return self._jid or "" + elif name == 'bare': + if self._bare is None: + self._bare = self._jid.split('/', 1)[0] + return self._bare or "" + + def __setattr__(self, name, value): + """ + Edit a JID by updating it's individual values, resetting the + generated JID in the end. + + Arguments: + name -- The name of the JID part. One of: user, domain, + server, resource, full, jid, or bare. + value -- The new value for the JID part. + """ + if name in ('resource', 'user', 'domain'): + object.__setattr__(self, "_%s" % name, value) + self.regenerate() + elif name in ('server', 'domain', 'host'): + self.domain = value + elif name in ('full', 'jid'): + self.reset(value) + self.regenerate() + elif name == 'bare': + if '@' in value: + u, d = value.split('@', 1) + object.__setattr__(self, "_user", u) + object.__setattr__(self, "_domain", d) + else: + object.__setattr__(self, "_user", '') + object.__setattr__(self, "_domain", value) + self.regenerate() + else: + object.__setattr__(self, name, value) + + def regenerate(self): + """Generate a new JID based on current values, useful after editing.""" + jid = "" + if self.user: + jid = "%s@" % self.user + jid += self.domain + if self.resource: + jid += "/%s" % self.resource + self.reset(jid) + + def __str__(self): + """Use the full JID as the string value.""" + return self.full diff --git a/sleekxmpp/xmlstream/matcher/__init__.py b/sleekxmpp/xmlstream/matcher/__init__.py new file mode 100644 index 0000000..1038d1b --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/__init__.py @@ -0,0 +1,16 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.matcher.id import MatcherId +from sleekxmpp.xmlstream.matcher.many import MatchMany +from sleekxmpp.xmlstream.matcher.stanzapath import StanzaPath +from sleekxmpp.xmlstream.matcher.xmlmask import MatchXMLMask +from sleekxmpp.xmlstream.matcher.xpath import MatchXPath + +__all__ = ['MatcherId', 'MatchMany', 'StanzaPath', + 'MatchXMLMask', 'MatchXPath'] diff --git a/sleekxmpp/xmlstream/matcher/base.py b/sleekxmpp/xmlstream/matcher/base.py new file mode 100644 index 0000000..701ab32 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/base.py @@ -0,0 +1,34 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +class 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. + """ + + def __init__(self, criteria): + """ + Create a new stanza matcher. + + Arguments: + criteria -- Object to compare some aspect of a stanza + against. + """ + self._criteria = criteria + + def match(self, xml): + """ + Check if a stanza matches the stored criteria. + + Meant to be overridden. + """ + return False diff --git a/sleekxmpp/xmlstream/matcher/id.py b/sleekxmpp/xmlstream/matcher/id.py new file mode 100644 index 0000000..0c8ce2d --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/id.py @@ -0,0 +1,32 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +class MatcherId(MatcherBase): + + """ + The ID matcher selects stanzas that have the same stanza 'id' + interface value as the desired ID. + + Methods: + match -- Overrides MatcherBase.match. + """ + + def match(self, xml): + """ + Compare the given stanza's 'id' attribute to the stored + id value. + + Overrides MatcherBase.match. + + Arguments: + xml -- The stanza to compare against. + """ + return xml['id'] == self._criteria diff --git a/sleekxmpp/xmlstream/matcher/many.py b/sleekxmpp/xmlstream/matcher/many.py new file mode 100644 index 0000000..f470ec9 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/many.py @@ -0,0 +1,40 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +class MatchMany(MatcherBase): + + """ + The MatchMany matcher may compare a stanza against multiple + criteria. It is essentially an OR relation combining multiple + matchers. + + Each of the criteria must implement a match() method. + + Methods: + match -- Overrides MatcherBase.match. + """ + + def match(self, xml): + """ + Match a stanza against multiple criteria. The match is successful + if one of the criteria matches. + + Each of the criteria must implement a match() method. + + Overrides MatcherBase.match. + + Arguments: + xml -- The stanza object to compare against. + """ + for m in self._criteria: + if m.match(xml): + return True + return False diff --git a/sleekxmpp/xmlstream/matcher/stanzapath.py b/sleekxmpp/xmlstream/matcher/stanzapath.py new file mode 100644 index 0000000..f8ff283 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/stanzapath.py @@ -0,0 +1,38 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +class 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. + + In most cases, the stanza path and XPath should be identical, but be + aware that differences may occur. + + Methods: + match -- Overrides MatcherBase.match. + """ + + 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. For most cases, the stanza path and + XPath should be identical, but be aware that differences may occur. + + Overrides MatcherBase.match. + + Arguments: + stanza -- The stanza object to compare against. + """ + return stanza.match(self._criteria) diff --git a/sleekxmpp/xmlstream/matcher/xmlmask.py b/sleekxmpp/xmlstream/matcher/xmlmask.py new file mode 100644 index 0000000..2967a2a --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/xmlmask.py @@ -0,0 +1,155 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from xml.parsers.expat import ExpatError + +from sleekxmpp.xmlstream.stanzabase import ET +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +# Flag indicating if the builtin XPath matcher should be used, which +# uses namespaces, or a custom matcher that ignores namespaces. +# Changing this will affect ALL XMLMask matchers. +IGNORE_NS = False + + +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: + + <message xmlns="jabber:client"><body /></message> + + Use of XMLMask is discouraged, and XPath or StanzaPath should be used + instead. + + The use of namespaces in the mask comparison is controlled by + IGNORE_NS. Setting IGNORE_NS to True will disable namespace based matching + for ALL XMLMask matchers. + + Methods: + match -- Overrides MatcherBase.match. + setDefaultNS -- Set the default namespace for the mask. + """ + + def __init__(self, criteria): + """ + Create a new XMLMask matcher. + + Arguments: + criteria -- Either an XML object or XML string to use as a mask. + """ + MatcherBase.__init__(self, criteria) + if isinstance(criteria, str): + self._criteria = ET.fromstring(self._criteria) + self.default_ns = 'jabber:client' + + def setDefaultNS(self, ns): + """ + Set the default namespace to use during comparisons. + + Arguments: + 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. + + Arguments: + 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. + + Arguments: + source -- The XML object to compare against the mask. + mask -- The XML object serving as the mask. + use_ns -- Indicates if namespaces should be respected during + the comparison. + default_ns -- The default namespace to apply to elements that + do not have a specified namespace. + Defaults to "__no_ns__". + """ + use_ns = not IGNORE_NS + + if source is None: + # If the element was not found. May happend during recursive calls. + return False + + # Convert the mask to an XML object if it is a string. + if not hasattr(mask, 'attrib'): + try: + mask = ET.fromstring(mask) + except ExpatError: + logging.log(logging.WARNING, + "Expat error: %s\nIn parsing: %s" % ('', mask)) + + if not use_ns: + # Compare the element without using namespaces. + source_tag = source.tag.split('}', 1)[-1] + mask_tag = mask.tag.split('}', 1)[-1] + if source_tag != mask_tag: + return False + else: + # Compare the element using namespaces + mask_ns_tag = "{%s}%s" % (self.default_ns, mask.tag) + if source.tag not in [mask.tag, mask_ns_tag]: + return False + + # If the mask includes text, compare it. + if mask.text and source.text != mask.text: + 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. + for subelement in mask: + if use_ns: + if not self._mask_cmp(source.find(subelement.tag), + subelement, use_ns): + return False + else: + if not self._mask_cmp(self._get_child(source, subelement.tag), + subelement, use_ns): + return False + + # Everything matches. + return True + + def _get_child(self, xml, tag): + """ + Return a child element given its tag, ignoring namespace values. + + Returns None if the child was not found. + + Arguments: + xml -- The XML object to search for the given child tag. + tag -- The name of the subelement to find. + """ + tag = tag.split('}')[-1] + try: + children = [c.tag.split('}')[-1] for c in xml.getchildren()] + index = children.index(tag) + except ValueError: + return None + return xml.getchildren()[index] diff --git a/sleekxmpp/xmlstream/matcher/xpath.py b/sleekxmpp/xmlstream/matcher/xpath.py new file mode 100644 index 0000000..669c9f1 --- /dev/null +++ b/sleekxmpp/xmlstream/matcher/xpath.py @@ -0,0 +1,79 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from sleekxmpp.xmlstream.stanzabase import ET +from sleekxmpp.xmlstream.matcher.base import MatcherBase + + +# Flag indicating if the builtin XPath matcher should be used, which +# uses namespaces, or a custom matcher that ignores namespaces. +# Changing this will affect ALL XPath matchers. +IGNORE_NS = False + + +class MatchXPath(MatcherBase): + + """ + The XPath matcher selects stanzas whose XML contents matches a given + XPath expression. + + Note that using this matcher may not produce expected behavior when using + attribute selectors. For Python 2.6 and 3.1, the ElementTree 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 StanzaPath matcher. + + If the value of IGNORE_NS is set to true, then XPath expressions will + be matched without using namespaces. + + Methods: + match -- Overrides MatcherBase.match. + """ + + def match(self, xml): + """ + Compare a stanza's XML contents to an XPath expression. + + If the value of IGNORE_NS is set to true, then XPath expressions + will be matched without using namespaces. + + Note that in Python 2.6 and 3.1 the ElementTree find method does + not support attribute selectors in the XPath expression. + + Arguments: + xml -- The stanza object to compare against. + """ + if hasattr(xml, 'xml'): + xml = xml.xml + x = ET.Element('x') + x.append(xml) + + if not IGNORE_NS: + # Use builtin, namespace respecting, XPath matcher. + if x.find(self._criteria) is not None: + return True + return False + else: + # Remove namespaces from the XPath expression. + criteria = [] + for ns_block in self._criteria.split('{'): + criteria.extend(ns_block.split('}')[-1].split('/')) + + # Walk the XPath expression. + xml = x + for tag in criteria: + if not tag: + # Skip empty tag name artifacts from the cleanup phase. + continue + + children = [c.tag.split('}')[-1] for c in xml.getchildren()] + try: + index = children.index(tag) + except ValueError: + return False + xml = xml.getchildren()[index] + return True diff --git a/sleekxmpp/xmlstream/scheduler.py b/sleekxmpp/xmlstream/scheduler.py new file mode 100644 index 0000000..240d4a4 --- /dev/null +++ b/sleekxmpp/xmlstream/scheduler.py @@ -0,0 +1,202 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import time +import threading +import logging +try: + import queue +except ImportError: + import Queue as queue + + +class Task(object): + + """ + A scheduled task that will be executed by the scheduler + after a given time interval has passed. + + Attributes: + name -- The name of the task. + seconds -- The number of seconds to wait before executing. + callback -- The function to execute. + args -- The arguments to pass to the callback. + kwargs -- The keyword arguments to pass to the callback. + repeat -- Indicates if the task should repeat. + Defaults to False. + qpointer -- A pointer to an event queue for queuing callback + execution instead of executing immediately. + + Methods: + run -- Either queue or execute the callback. + reset -- Reset the task's timer. + """ + + def __init__(self, name, seconds, callback, args=None, + kwargs=None, repeat=False, qpointer=None): + """ + Create a new task. + + Arguments: + name -- The name of the task. + seconds -- The number of seconds to wait before executing. + callback -- The function to execute. + args -- The arguments to pass to the callback. + kwargs -- The keyword arguments to pass to the callback. + repeat -- Indicates if the task should repeat. + Defaults to False. + qpointer -- A pointer to an event queue for queuing callback + execution instead of executing immediately. + """ + self.name = name + self.seconds = seconds + self.callback = callback + self.args = args or tuple() + self.kwargs = kwargs or {} + self.repeat = repeat + self.next = time.time() + self.seconds + self.qpointer = qpointer + + def run(self): + """ + Execute the task's callback. + + If an event queue was supplied, place the callback in the queue; + otherwise, execute the callback immediately. + """ + if self.qpointer is not None: + self.qpointer.put(('schedule', self.callback, self.args)) + else: + self.callback(*self.args, **self.kwargs) + self.reset() + return self.repeat + + def reset(self): + """ + Reset the task's timer so that it will repeat. + """ + self.next = time.time() + self.seconds + + +class Scheduler(object): + + """ + A threaded scheduler that allows for updates mid-execution unlike the + scheduler in the standard library. + + http://docs.python.org/library/sched.html#module-sched + + Attributes: + addq -- A queue storing added tasks. + schedule -- A list of tasks in order of execution times. + thread -- If threaded, the thread processing the schedule. + run -- Indicates if the scheduler is running. + parentqueue -- A parent event queue in control of this scheduler. + + Methods: + add -- Add a new task to the schedule. + process -- Process and schedule tasks. + quit -- Stop the scheduler. + """ + + def __init__(self, parentqueue=None, parentstop=None): + """ + Create a new scheduler. + + Arguments: + parentqueue -- A separate event queue controlling this scheduler. + """ + self.addq = queue.Queue() + self.schedule = [] + self.thread = None + self.run = False + self.parentqueue = parentqueue + self.parentstop = parentstop + + def process(self, threaded=True): + """ + Begin accepting and processing scheduled tasks. + + Arguments: + threaded -- Indicates if the scheduler should execute in its own + thread. Defaults to True. + """ + if threaded: + self.thread = threading.Thread(name='sheduler_process', + target=self._process) + self.thread.start() + else: + self._process() + + def _process(self): + """Process scheduled tasks.""" + self.run = True + try: + while self.run and (self.parentstop is None or not self.parentstop.isSet()): + wait = 1 + updated = False + if self.schedule: + wait = self.schedule[0].next - time.time() + try: + if wait <= 0.0: + newtask = self.addq.get(False) + else: + newtask = self.addq.get(True, wait) + except queue.Empty: + cleanup = [] + for task in self.schedule: + if time.time() >= task.next: + updated = True + if not task.run(): + cleanup.append(task) + else: + break + for task in cleanup: + x = self.schedule.pop(self.schedule.index(task)) + else: + updated = True + self.schedule.append(newtask) + finally: + if updated: + self.schedule = sorted(self.schedule, + key=lambda task: task.next) + except KeyboardInterrupt: + self.run = False + if self.parentstop is not None: + logging.debug("stopping parent") + self.parentstop.set() + except SystemExit: + self.run = False + if self.parentstop is not None: + self.parentstop.set() + logging.debug("Quitting Scheduler thread") + if self.parentqueue is not None: + self.parentqueue.put(('quit', None, None)) + + def add(self, name, seconds, callback, args=None, + kwargs=None, repeat=False, qpointer=None): + """ + Schedule a new task. + + Arguments: + name -- The name of the task. + seconds -- The number of seconds to wait before executing. + callback -- The function to execute. + args -- The arguments to pass to the callback. + kwargs -- The keyword arguments to pass to the callback. + repeat -- Indicates if the task should repeat. + Defaults to False. + qpointer -- A pointer to an event queue for queuing callback + execution instead of executing immediately. + """ + self.addq.put(Task(name, seconds, callback, args, + kwargs, repeat, qpointer)) + + def quit(self): + """Shutdown the scheduler.""" + self.run = False diff --git a/sleekxmpp/xmlstream/stanzabase.py b/sleekxmpp/xmlstream/stanzabase.py new file mode 100644 index 0000000..f4d66aa --- /dev/null +++ b/sleekxmpp/xmlstream/stanzabase.py @@ -0,0 +1,1162 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import copy +import logging +import sys +import weakref +from xml.etree import cElementTree as ET + +from sleekxmpp.xmlstream import JID +from sleekxmpp.xmlstream.tostring import tostring + + +# Used to check if an argument is an XML object. +XML_TYPE = type(ET.Element('xml')) + + +def register_stanza_plugin(stanza, plugin): + """ + Associate a stanza object as a plugin for another stanza. + + Arguments: + stanza -- The class of the parent stanza. + plugin -- The class of the plugin stanza. + """ + tag = "{%s}%s" % (plugin.namespace, plugin.name) + stanza.plugin_attrib_map[plugin.plugin_attrib] = plugin + stanza.plugin_tag_map[tag] = plugin + + +# To maintain backwards compatibility for now, preserve the camel case name. +registerStanzaPlugin = register_stanza_plugin + + +class ElementBase(object): + + """ + The core of SleekXMPP's stanza XML manipulation and handling is provided + by ElementBase. ElementBase wraps XML cElementTree objects and enables + access to the XML contents through dictionary syntax, similar in style + to the Ruby XMPP library Blather's stanza implementation. + + Stanzas are defined by their name, namespace, and interfaces. For + example, a simplistic Message stanza could be defined as: + + >>> class Message(ElementBase): + ... name = "message" + ... namespace = "jabber:client" + ... interfaces = set(('to', 'from', 'type', 'body')) + ... sub_interfaces = set(('body',)) + + The resulting Message stanza's contents may be accessed as so: + + >>> message['to'] = "user@example.com" + >>> message['body'] = "Hi!" + >>> message['body'] + "Hi!" + >>> del message['body'] + >>> message['body'] + "" + + The interface values map to either custom access methods, stanza + XML attributes, or (if the interface is also in sub_interfaces) the + text contents of a stanza's subelement. + + Custom access methods may be created by adding methods of the + form "getInterface", "setInterface", or "delInterface", where + "Interface" is the titlecase version of the interface name. + + Stanzas may be extended through the use of plugins. A plugin + is simply a stanza that has a plugin_attrib value. For example: + + >>> class MessagePlugin(ElementBase): + ... name = "custom_plugin" + ... namespace = "custom" + ... interfaces = set(('useful_thing', 'custom')) + ... plugin_attrib = "custom" + + The plugin stanza class must be associated with its intended + container stanza by using register_stanza_plugin as so: + + >>> register_stanza_plugin(Message, MessagePlugin) + + The plugin may then be accessed as if it were built-in to the parent + stanza. + + >>> message['custom']['useful_thing'] = 'foo' + + If a plugin provides an interface that is the same as the plugin's + plugin_attrib value, then the plugin's interface may be accessed + directly from the parent stanza, as so: + + >>> message['custom'] = 'bar' # Same as using message['custom']['custom'] + + Class Attributes: + name -- The name of the stanza's main element. + namespace -- The namespace of the stanza's main element. + interfaces -- A set of attribute and element names that may + be accessed using dictionary syntax. + sub_interfaces -- A subset of the set of interfaces which map + to subelements instead of attributes. + subitem -- A set of stanza classes which are allowed to + be added as substanzas. + types -- A set of generic type attribute values. + plugin_attrib -- The interface name that the stanza uses to be + accessed as a plugin from another stanza. + plugin_attrib_map -- A mapping of plugin attribute names with the + associated plugin stanza classes. + plugin_tag_map -- A mapping of plugin stanza tag names with + the associated plugin stanza classes. + + Instance Attributes: + xml -- The stanza's XML contents. + parent -- The parent stanza of this stanza. + plugins -- A map of enabled plugin names with the + initialized plugin stanza objects. + values -- A dictionary of the stanza's interfaces + and interface values, including plugins. + + Methods: + setup -- Initialize the stanza's XML contents. + enable -- Instantiate a stanza plugin. + Alias for init_plugin. + init_plugin -- Instantiate a stanza plugin. + _get_stanza_values -- Return a dictionary of stanza interfaces and + their values. + _set_stanza_values -- Set stanza interface values given a dictionary + of interfaces and values. + __getitem__ -- Return the value of a stanza interface. + __setitem__ -- Set the value of a stanza interface. + __delitem__ -- Remove the value of a stanza interface. + _set_attr -- Set an attribute value of the main + stanza element. + _del_attr -- Remove an attribute from the main + stanza element. + _get_attr -- Return an attribute's value from the main + stanza element. + _get_sub_text -- Return the text contents of a subelement. + _set_sub_ext -- Set the text contents of a subelement. + _del_sub -- Remove a subelement. + match -- Compare the stanza against an XPath expression. + find -- Return subelement matching an XPath expression. + findall -- Return subelements matching an XPath expression. + get -- Return the value of a stanza interface, with an + optional default value. + keys -- Return the set of interface names accepted by + the stanza. + append -- Add XML content or a substanza to the stanza. + appendxml -- Add XML content to the stanza. + pop -- Remove a substanza. + next -- Return the next iterable substanza. + _fix_ns -- Apply the stanza's namespace to non-namespaced + elements in an XPath expression. + """ + + name = 'stanza' + plugin_attrib = 'plugin' + namespace = 'jabber:client' + interfaces = set(('type', 'to', 'from', 'id', 'payload')) + types = set(('get', 'set', 'error', None, 'unavailable', 'normal', 'chat')) + sub_interfaces = tuple() + plugin_attrib_map = {} + plugin_tag_map = {} + subitem = None + + def __init__(self, xml=None, parent=None): + """ + Create a new stanza object. + + Arguments: + xml -- Initialize the stanza with optional existing XML. + parent -- Optional stanza object that contains this stanza. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.initPlugin = self.init_plugin + self._getAttr = self._get_attr + self._setAttr = self._set_attr + self._delAttr = self._del_attr + self._getSubText = self._get_sub_text + self._setSubText = self._set_sub_text + self._delSub = self._del_sub + self.getStanzaValues = self._get_stanza_values + self.setStanzaValues = self._set_stanza_values + + self.xml = xml + self.plugins = {} + self.iterables = [] + self._index = 0 + if parent is None: + self.parent = None + else: + self.parent = weakref.ref(parent) + + ElementBase.values = property(ElementBase._get_stanza_values, + ElementBase._set_stanza_values) + + if self.setup(xml): + # If we generated our own XML, then everything is ready. + return + + # Initialize values using provided XML + for child in self.xml.getchildren(): + if child.tag in self.plugin_tag_map: + plugin = self.plugin_tag_map[child.tag] + self.plugins[plugin.plugin_attrib] = plugin(child, self) + if self.subitem is not None: + for sub in self.subitem: + if child.tag == "{%s}%s" % (sub.namespace, sub.name): + self.iterables.append(sub(child, self)) + break + + def setup(self, xml=None): + """ + Initialize the stanza's XML contents. + + Will return True if XML was generated according to the stanza's + definition. + + Arguments: + xml -- Optional XML object to use for the stanza's content + instead of generating XML. + """ + if self.xml is None: + self.xml = xml + + if self.xml is None: + # Generate XML from the stanza definition + for ename in self.name.split('/'): + new = ET.Element("{%s}%s" % (self.namespace, ename)) + if self.xml is None: + self.xml = new + else: + last_xml.append(new) + last_xml = new + if self.parent is not None: + self.parent().xml.append(self.xml) + + # We had to generate XML + return True + else: + # We did not generate XML + return False + + def enable(self, attrib): + """ + Enable and initialize a stanza plugin. + + Alias for init_plugin. + + Arguments: + attrib -- The stanza interface for the plugin. + """ + return self.init_plugin(attrib) + + def init_plugin(self, attrib): + """ + Enable and initialize a stanza plugin. + + Arguments: + attrib -- The stanza interface for the plugin. + """ + if attrib not in self.plugins: + plugin_class = self.plugin_attrib_map[attrib] + self.plugins[attrib] = plugin_class(parent=self) + return self + + def _get_stanza_values(self): + """ + Return a dictionary of the stanza's interface values. + + Stanza plugin values are included as nested dictionaries. + """ + values = {} + for interface in self.interfaces: + values[interface] = self[interface] + for plugin, stanza in self.plugins.items(): + values[plugin] = stanza._get_stanza_values() + if self.iterables: + iterables = [] + for stanza in self.iterables: + iterables.append(stanza._get_stanza_values()) + iterables[-1].update({ + '__childtag__': "{%s}%s" % (stanza.namespace, + stanza.name)}) + 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. + + Arguments: + values -- A dictionary mapping stanza interface with values. + Plugin interfaces may accept a nested dictionary that + will be used recursively. + """ + for interface, value in values.items(): + if interface == 'substanzas': + for subdict in value: + if '__childtag__' in subdict: + for subclass in self.subitem: + child_tag = "{%s}%s" % (subclass.namespace, + subclass.name) + if subdict['__childtag__'] == child_tag: + sub = subclass(parent=self) + sub._set_stanza_values(subdict) + self.iterables.append(sub) + break + elif interface in self.interfaces: + self[interface] = value + elif interface in self.plugin_attrib_map: + if interface not in self.plugins: + self.init_plugin(interface) + self.plugins[interface]._set_stanza_values(value) + return self + + def __getitem__(self, attrib): + """ + Return the value of a stanza interface using dictionary-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. + 2. The result of calling get_foo. + 3. The result of calling getFoo. + 4. The contents of the foo subelement, if foo is a sub interface. + 5. The value of the foo attribute of the XML object. + 6. The plugin named 'foo' + 7. An empty string. + + Arguments: + attrib -- The name of the requested stanza interface. + """ + if attrib == 'substanzas': + return self.iterables + elif attrib in self.interfaces: + get_method = "get_%s" % attrib.lower() + get_method2 = "get%s" % attrib.title() + if hasattr(self, get_method): + return getattr(self, get_method)() + elif hasattr(self, get_method2): + return getattr(self, get_method2)() + else: + if attrib in self.sub_interfaces: + return self._get_sub_text(attrib) + else: + return self._get_attr(attrib) + elif attrib in self.plugin_attrib_map: + if attrib not in self.plugins: + self.init_plugin(attrib) + return self.plugins[attrib] + else: + return '' + + def __setitem__(self, attrib, value): + """ + Set the value of a stanza interface using dictionary-like syntax. + + Example: + >>> msg['body'] = "Hi!" + >>> msg['body'] + 'Hi!' + + Stanza interfaces are typically mapped directly to the underlying XML + object, but can be overridden by the presence of a set_attrib method + (or set_foo where the interface is named foo, etc). + + The effect of interface value assignment for an interface + named 'foo' will be one of: + 1. Delete the interface's contents if the value is None. + 2. Call set_foo, if it exists. + 3. Call setFoo, if it exists. + 4. Set the text of a foo element, if foo is in sub_interfaces. + 5. Set the value of a top level XML attribute name foo. + 6. Attempt to pass value to a plugin named foo using the plugin's + foo interface. + 7. Do nothing. + + Arguments: + attrib -- The name of the stanza interface to modify. + value -- The new value of the stanza interface. + """ + if attrib in self.interfaces: + if value is not None: + set_method = "set_%s" % attrib.lower() + set_method2 = "set%s" % attrib.title() + if hasattr(self, set_method): + getattr(self, set_method)(value,) + elif hasattr(self, set_method2): + getattr(self, set_method2)(value,) + else: + if attrib in self.sub_interfaces: + return self._set_sub_text(attrib, text=value) + else: + self._set_attr(attrib, value) + else: + self.__delitem__(attrib) + elif attrib in self.plugin_attrib_map: + if attrib not in self.plugins: + self.init_plugin(attrib) + self.plugins[attrib][attrib] = value + return self + + def __delitem__(self, attrib): + """ + Delete the value of a stanza interface using dictionary-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, if it exists. + 2. Call delFoo, if it exists. + 3. Delete foo element, if foo is in sub_interfaces. + 4. Delete top level XML attribute named foo. + 5. Remove the foo plugin, if it was loaded. + 6. Do nothing. + + Arguments: + attrib -- The name of the affected stanza interface. + """ + if attrib in self.interfaces: + del_method = "del_%s" % attrib.lower() + del_method2 = "del%s" % attrib.title() + if hasattr(self, del_method): + getattr(self, del_method)() + elif hasattr(self, del_method2): + getattr(self, del_method2)() + else: + if attrib in self.sub_interfaces: + return self._del_sub(attrib) + else: + self._del_attr(attrib) + elif attrib in self.plugin_attrib_map: + if attrib in self.plugins: + xml = self.plugins[attrib].xml + del self.plugins[attrib] + self.xml.remove(xml) + return self + + def _set_attr(self, name, value): + """ + Set the value of a top level attribute of the underlying XML object. + + If the new value is None or an empty string, then the attribute will + be removed. + + Arguments: + name -- The name of the attribute. + 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 underlying XML object. + + Arguments: + 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 underlying + 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. + + Arguments: + name -- The name of the attribute. + default -- Optional value to return if the attribute has not + been set. An empty string is returned otherwise. + """ + return self.xml.attrib.get(name, default) + + def _get_sub_text(self, name, default=''): + """ + Return the text contents of a sub element. + + In case the element does not exist, or it has no textual content, + a default value can be returned instead. An empty string is returned + if no other default is supplied. + + Arguments: + name -- The name or XPath expression of the element. + default -- Optional default to return if the element does + not exists. An empty string is returned otherwise. + """ + name = self._fix_ns(name) + stanza = self.xml.find(name) + if stanza is None or stanza.text is None: + return default + else: + return stanza.text + + def _set_sub_text(self, name, text=None, keep=False): + """ + Set the text contents of a sub element. + + In case the element does not exist, a element will be created, + and its text contents will be set. + + If the text is set to an empty string, or None, then the + element will be removed, unless keep is set to True. + + Arguments: + name -- The name or XPath expression of the element. + 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. + keep -- Indicates if the element should be kept if its text is + removed. Defaults to False. + """ + path = self._fix_ns(name, split=True) + element = self.xml.find(name) + + if not text and not keep: + return self._del_sub(name) + + if element is None: + # We need to add the element. If the provided name was + # an XPath expression, some of the intermediate elements + # may already exist. If so, we want to use those instead + # of generating new elements. + last_xml = self.xml + walked = [] + for ename in path: + walked.append(ename) + element = self.xml.find("/".join(walked)) + if element is None: + element = ET.Element(ename) + last_xml.append(element) + last_xml = element + element = last_xml + + element.text = text + return element + + def _del_sub(self, name, all=False): + """ + Remove sub elements that match the given name or XPath. + + If the element is in a path, then any parent elements that become + empty after deleting the element may also be deleted if requested + by setting all=True. + + Arguments: + name -- The name or XPath expression for the element(s) to remove. + all -- If True, remove all empty elements in the path to the + deleted element. Defaults to False. + """ + path = self._fix_ns(name, split=True) + original_target = path[-1] + + for level, _ in enumerate(path): + # Generate the paths to the target elements and their parent. + element_path = "/".join(path[:len(path) - level]) + parent_path = "/".join(path[:len(path) - level - 1]) + + elements = self.xml.findall(element_path) + parent = self.xml.find(parent_path) + + if elements: + if parent is None: + parent = self.xml + for element in elements: + if element.tag == original_target or \ + not element.getchildren(): + # Only delete the originally requested elements, and + # any parent elements that have become empty. + parent.remove(element) + if not all: + # If we don't want to delete elements up the tree, stop + # after deleting the first level of elements. + return + + def match(self, xpath): + """ + Compare a stanza object with an XPath 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. + + Arguments: + xpath -- The XPath expression to check against. It may be either a + string or a list of element names with attribute checks. + """ + if isinstance(xpath, str): + xpath = self._fix_ns(xpath, split=True, propagate_ns=False) + + # Extract the tag name and attribute checks for the first XPath node. + components = xpath[0].split('@') + tag = components[0] + attributes = components[1:] + + if tag not in (self.name, "{%s}%s" % (self.namespace, self.name)) and \ + tag not in self.plugins and tag not in self.plugin_attrib: + # The requested tag is not in this stanza, so no match. + return False + + # Check the rest of the XPath against any substanzas. + matched_substanzas = False + for substanza in self.iterables: + if xpath[1:] == []: + break + matched_substanzas = substanza.match(xpath[1:]) + if matched_substanzas: + break + + # Check attribute values. + for attribute in attributes: + name, value = attribute.split('=') + if self[name] != value: + return False + + # Check sub interfaces. + if len(xpath) > 1: + next_tag = xpath[1] + if next_tag in self.sub_interfaces and self[next_tag]: + return True + + # Attempt to continue matching the XPath using the stanza's plugins. + if not matched_substanzas and len(xpath) > 1: + # Convert {namespace}tag@attribs to just tag + next_tag = xpath[1].split('@')[0].split('}')[-1] + if next_tag in self.plugins: + return self.plugins[next_tag].match(xpath[1:]) + else: + return False + + # Everything matched. + return True + + def find(self, xpath): + """ + Find an XML object in this stanza given an XPath expression. + + Exposes ElementTree interface for backwards compatibility. + + Note that matching on attribute values is not supported in Python 2.6 + or Python 3.1 + + Arguments: + 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 that matching on attribute values is not supported in Python 2.6 + or Python 3.1. + + Arguments: + 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. + + Arguments: + key -- The name of the stanza interface to check. + default -- Value to return if the stanza interface has a value + of None or "". Will default to returning None. + """ + value = self[key] + if value is None or value == '': + return default + return value + + def keys(self): + """ + Return the names of all stanza interfaces provided by the + stanza object. + + Allows stanza objects to be used like dictionaries. + """ + out = [] + out += [x for x in self.interfaces] + out += [x for x in self.plugins] + if self.iterables: + out.append('substanzas') + return out + + def append(self, item): + """ + Append either an XML object or a substanza to this stanza object. + + If a substanza object is appended, it will be added to the list + of iterable stanzas. + + Allows stanza objects to be used like lists. + + Arguments: + item -- Either an XML object or a stanza object to add to + this stanza's contents. + """ + if not isinstance(item, ElementBase): + if type(item) == XML_TYPE: + return self.appendxml(item) + else: + raise TypeError + self.xml.append(item.xml) + self.iterables.append(item) + return self + + def appendxml(self, xml): + """ + Append an XML object to the stanza's XML. + + The added XML will not be included in the list of + iterable substanzas. + + Arguments: + 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. + + Arguments: + 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__() + + @property + def attrib(self): + """ + DEPRECATED + + For backwards compatibility, stanza.attrib returns the stanza 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. + """ + return self + + def _fix_ns(self, xpath, split=False, propagate_ns=True): + """ + Apply the stanza's namespace to elements in an XPath expression. + + Arguments: + xpath -- The XPath expression to fix with namespaces. + 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. + 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 = self.namespace + elements = ns_block.split('/') + + for element in elements: + if element: + # Skip empty entry artifacts from splitting. + if propagate_ns: + tag = '{%s}%s' % (namespace, element) + else: + tag = element + fixed.append(tag) + if split: + return fixed + return '/'.join(fixed) + + 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. + + Arguments: + 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._get_stanza_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._get_stanza_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. + + Arguments: + 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 contained in this stanza. + """ + return len(self.iterables) + + def __iter__(self): + """ + Return an iterator object for iterating over 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): + """ + Return a string serialization of the underlying XML object. + """ + return tostring(self.xml, xmlns='', stanza_ns=self.namespace) + + def __repr__(self): + """ + Use the stanza's serialized XML as its representation. + """ + return self.__str__() + + +class StanzaBase(ElementBase): + + """ + StanzaBase provides the foundation for all other stanza objects used by + SleekXMPP, and defines a basic set of interfaces common to nearly + all stanzas. These interfaces are the 'id', 'type', 'to', and 'from' + attributes. An additional interface, 'payload', is available to access + the XML contents of the stanza. Most stanza objects will provided more + specific interfaces, however. + + Stanza Interface: + from -- A JID object representing the sender's JID. + id -- An optional id value that can be used to associate stanzas + with their replies. + payload -- The XML contents of the stanza. + to -- A JID object representing the recipient's JID. + type -- The type of stanza, typically will be 'normal', 'error', + 'get', or 'set', etc. + + Attributes: + stream -- The XMLStream instance that will handle sending this stanza. + tag -- The namespaced version of the stanza's name. + + Methods: + set_type -- Set the type of the stanza. + get_to -- Return the stanza recipients JID. + set_to -- Set the stanza recipient's JID. + get_from -- Return the stanza sender's JID. + set_from -- Set the stanza sender's JID. + get_payload -- Return the stanza's XML contents. + set_payload -- Append to the stanza's XML contents. + del_payload -- Remove the stanza's XML contents. + clear -- Reset the stanza's XML contents. + reply -- Reset the stanza and modify the 'to' and 'from' + attributes to prepare for sending a reply. + error -- Set the stanza's type to 'error'. + unhandled -- Callback for when the stanza is not handled by a + stream handler. + exception -- Callback for if an exception is raised while + handling the stanza. + send -- Send the stanza using the stanza's stream. + """ + + name = 'stanza' + namespace = 'jabber:client' + interfaces = set(('type', 'to', 'from', 'id', 'payload')) + types = set(('get', 'set', 'error', None, 'unavailable', 'normal', 'chat')) + sub_interfaces = tuple() + + def __init__(self, stream=None, xml=None, stype=None, + sto=None, sfrom=None, sid=None): + """ + Create a new stanza. + + Arguments: + stream -- Optional XMLStream responsible for sending this stanza. + xml -- Optional XML contents to initialize stanza values. + stype -- Optional stanza type value. + sto -- Optional string or JID object of the recipient's JID. + sfrom -- Optional string or JID object of the sender's JID. + sid -- Optional ID value for the stanza. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.setType = self.set_type + self.getTo = self.get_to + self.setTo = self.set_to + self.getFrom = self.get_from + self.setFrom = self.set_from + self.getPayload = self.get_payload + self.setPayload = self.set_payload + self.delPayload = self.del_payload + + self.stream = stream + if stream is not None: + self.namespace = stream.default_ns + ElementBase.__init__(self, xml) + if stype is not None: + self['type'] = stype + if sto is not None: + self['to'] = sto + if sfrom is not None: + self['from'] = sfrom + self.tag = "{%s}%s" % (self.namespace, self.name) + + def set_type(self, value): + """ + Set the stanza's 'type' attribute. + + Only type values contained in StanzaBase.types are accepted. + + Arguments: + value -- One of the values contained in StanzaBase.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. + + Arguments: + value -- A string or JID object representing the recipient's JID. + """ + return self._set_attr('to', str(value)) + + def get_from(self): + """Return the value of the stanza's 'from' attribute.""" + return JID(self._get_attr('from')) + + def set_from(self, value): + """ + Set the 'from' attribute of the stanza. + + Arguments: + from -- A string or JID object representing the sender's JID. + """ + return self._set_attr('from', str(value)) + + def get_payload(self): + """Return a list of XML objects contained in the stanza.""" + return self.xml.getchildren() + + def set_payload(self, value): + """ + Add XML content to the stanza. + + Arguments: + 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 clear(self): + """ + Remove all XML element contents and plugins. + + Any attribute values will be preserved. + """ + for child in self.xml.getchildren(): + self.xml.remove(child) + for plugin in list(self.plugins.keys()): + del self.plugins[plugin] + return self + + def reply(self): + """ + Reset the stanza and swap its 'from' and 'to' attributes to prepare + for sending a reply stanza. + + For client streams, the 'from' attribute is removed. + """ + # if it's a component, use from + if self.stream and hasattr(self.stream, "is_component") and \ + self.stream.is_component: + self['from'], self['to'] = self['to'], self['from'] + else: + self['to'] = self['from'] + del self['from'] + self.clear() + return self + + def error(self): + """Set the stanza's type to 'error'.""" + self['type'] = 'error' + return self + + def unhandled(self): + """ + Called when 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. + """ + logging.exception('Error handling {%s}%s stanza' % (self.namespace, + self.name)) + + def send(self): + """Queue the stanza to be sent on the XML stream.""" + self.stream.sendRaw(self.__str__()) + + 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): + """Serialize the stanza's XML to a string.""" + return tostring(self.xml, xmlns='', + stanza_ns=self.namespace, + stream=self.stream) diff --git a/sleekxmpp/xmlstream/test.py b/sleekxmpp/xmlstream/test.py new file mode 100644 index 0000000..a45fb8b --- /dev/null +++ b/sleekxmpp/xmlstream/test.py @@ -0,0 +1,23 @@ +import xmlstream +import time +import socket +from handler.callback import Callback +from matcher.xpath import MatchXPath + +def server(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(('localhost', 5228)) + s.listen(1) + servers = [] + while True: + conn, addr = s.accept() + server = xmlstream.XMLStream(conn, 'localhost', 5228) + server.registerHandler(Callback('test', MatchXPath('test'), testHandler)) + server.process() + servers.append(server) + +def testHandler(xml): + print("weeeeeeeee!") + +server() diff --git a/sleekxmpp/xmlstream/test.xml b/sleekxmpp/xmlstream/test.xml new file mode 100644 index 0000000..d20dd82 --- /dev/null +++ b/sleekxmpp/xmlstream/test.xml @@ -0,0 +1,2 @@ +<stream> +</stream> diff --git a/sleekxmpp/xmlstream/testclient.py b/sleekxmpp/xmlstream/testclient.py new file mode 100644 index 0000000..50eb6c5 --- /dev/null +++ b/sleekxmpp/xmlstream/testclient.py @@ -0,0 +1,13 @@ +import socket +import time + +s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +s.connect(('localhost', 5228)) +s.send("<stream>") +#s.flush() +s.send("<test/>") +s.send("<test/>") +s.send("<test/>") +s.send("</stream>") +#s.flush() +s.close() diff --git a/sleekxmpp/xmlstream/tostring/__init__.py b/sleekxmpp/xmlstream/tostring/__init__.py new file mode 100644 index 0000000..5852cba --- /dev/null +++ b/sleekxmpp/xmlstream/tostring/__init__.py @@ -0,0 +1,19 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +import sys + +# Import the correct tostring and xml_escape functions based on the Python +# version in order to properly handle Unicode. + +if sys.version_info < (3, 0): + from sleekxmpp.xmlstream.tostring.tostring26 import tostring, xml_escape +else: + from sleekxmpp.xmlstream.tostring.tostring import tostring, xml_escape + +__all__ = ['tostring', 'xml_escape'] diff --git a/sleekxmpp/xmlstream/tostring/tostring.py b/sleekxmpp/xmlstream/tostring/tostring.py new file mode 100644 index 0000000..d8f5c5b --- /dev/null +++ b/sleekxmpp/xmlstream/tostring/tostring.py @@ -0,0 +1,95 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + + +def tostring(xml=None, xmlns='', stanza_ns='', stream=None, outbuffer=''): + """ + Serialize an XML object to a Unicode string. + + If namespaces are provided using xmlns or stanza_ns, then elements + that use those namespaces will not include the xmlns attribute in + the output. + + Arguments: + xml -- The XML object to serialize. If the value is None, + then the XML object contained in this stanza + object will be used. + xmlns -- Optional namespace of an element wrapping the XML + object. + stanza_ns -- The namespace of the stanza object that contains + the XML object. + stream -- The XML stream that generated the XML object. + outbuffer -- Optional buffer for storing serializations during + recursive calls. + """ + # 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 = '' + + # Output the tag name and derived namespace of the element. + namespace = '' + if tag_xmlns not in ['', xmlns, stanza_ns]: + namespace = ' xmlns="%s"' % tag_xmlns + if stream and tag_xmlns in stream.namespace_map: + mapped_namespace = stream.namespace_map[tag_xmlns] + if mapped_namespace: + tag_name = "%s:%s" % (mapped_namespace, tag_name) + output.append("<%s" % tag_name) + output.append(namespace) + + # Output escaped attribute values. + for attrib, value in xml.attrib.items(): + if '{' not in attrib: + value = xml_escape(value) + output.append(' %s="%s"' % (attrib, value)) + + if len(xml) or xml.text: + # If there are additional child elements to serialize. + output.append(">") + if xml.text: + output.append(xml_escape(xml.text)) + if len(xml): + for child in xml.getchildren(): + output.append(tostring(child, tag_xmlns, stanza_ns, stream)) + output.append("</%s>" % tag_name) + elif xml.text: + # If we only have text content. + output.append(">%s</%s>" % (xml_escape(xml.text), tag_name)) + else: + # Empty element. + output.append(" />") + if xml.tail: + # If there is additional text after the element. + output.append(xml_escape(xml.tail)) + return ''.join(output) + + +def xml_escape(text): + """ + Convert special characters in XML to escape sequences. + + Arguments: + text -- The XML text to convert. + """ + text = list(text) + escapes = {'&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"'} + for i, c in enumerate(text): + text[i] = escapes.get(c, c) + return ''.join(text) diff --git a/sleekxmpp/xmlstream/tostring/tostring26.py b/sleekxmpp/xmlstream/tostring/tostring26.py new file mode 100644 index 0000000..0ee432c --- /dev/null +++ b/sleekxmpp/xmlstream/tostring/tostring26.py @@ -0,0 +1,101 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from __future__ import unicode_literals +import types + + +def tostring(xml=None, xmlns='', stanza_ns='', stream=None, outbuffer=''): + """ + Serialize an XML object to a Unicode string. + + If namespaces are provided using xmlns or stanza_ns, then elements + that use those namespaces will not include the xmlns attribute in + the output. + + Arguments: + xml -- The XML object to serialize. If the value is None, + then the XML object contained in this stanza + object will be used. + xmlns -- Optional namespace of an element wrapping the XML + object. + stanza_ns -- The namespace of the stanza object that contains + the XML object. + stream -- The XML stream that generated the XML object. + outbuffer -- Optional buffer for storing serializations during + recursive calls. + """ + # 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 = u'' + + # Output the tag name and derived namespace of the element. + namespace = u'' + if tag_xmlns not in ['', xmlns, stanza_ns]: + namespace = u' 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 = u"%s:%s" % (mapped_namespace, tag_name) + output.append(u"<%s" % tag_name) + output.append(namespace) + + # Output escaped attribute values. + for attrib, value in xml.attrib.items(): + if '{' not in attrib: + value = xml_escape(value) + output.append(u' %s="%s"' % (attrib, value)) + + if len(xml) or xml.text: + # If there are additional child elements to serialize. + output.append(u">") + if xml.text: + output.append(xml_escape(xml.text)) + if len(xml): + for child in xml.getchildren(): + output.append(tostring(child, tag_xmlns, stanza_ns, stream)) + output.append(u"</%s>" % tag_name) + elif xml.text: + # If we only have text content. + output.append(u">%s</%s>" % (xml_escape(xml.text), tag_name)) + else: + # Empty element. + output.append(u" />") + if xml.tail: + # If there is additional text after the element. + output.append(xml_escape(xml.tail)) + return u''.join(output) + + +def xml_escape(text): + """ + Convert special characters in XML to escape sequences. + + Arguments: + text -- The XML text to convert. + """ + if type(text) != types.UnicodeType: + text = list(unicode(text, 'utf-8', 'ignore')) + else: + text = list(text) + escapes = {u'&': u'&', + u'<': u'<', + u'>': u'>', + u"'": u''', + u'"': u'"'} + for i, c in enumerate(text): + text[i] = escapes.get(c, c) + return u''.join(text) diff --git a/sleekxmpp/xmlstream/xmlstream.py b/sleekxmpp/xmlstream/xmlstream.py new file mode 100644 index 0000000..ace93cc --- /dev/null +++ b/sleekxmpp/xmlstream/xmlstream.py @@ -0,0 +1,892 @@ +""" + SleekXMPP: The Sleek XMPP Library + Copyright (C) 2010 Nathanael C. Fritz + This file is part of SleekXMPP. + + See the file LICENSE for copying permission. +""" + +from __future__ import with_statement, unicode_literals + +import copy +import logging +import socket as Socket +import ssl +import sys +import threading +import time +import types +import signal +try: + import queue +except ImportError: + import Queue as queue + +from sleekxmpp.thirdparty.statemachine import StateMachine +from sleekxmpp.xmlstream import Scheduler, tostring +from sleekxmpp.xmlstream.stanzabase import StanzaBase, ET + +# In Python 2.x, file socket objects are broken. A patched socket +# wrapper is provided for this case in filesocket.py. +if sys.version_info < (3, 0): + from sleekxmpp.xmlstream.filesocket import FileSocket, Socket26 + + +# The time in seconds to wait before timing out waiting for response stanzas. +RESPONSE_TIMEOUT = 10 + +# The number of threads to use to handle XML stream events. This is not the +# same as the number of custom event handling threads. HANDLER_THREADS must +# be at least 1. +HANDLER_THREADS = 1 + +# Flag indicating if the SSL library is available for use. +SSL_SUPPORT = True + + +class RestartStream(Exception): + """ + Exception to restart stream processing, including + resending the stream header. + """ + + +class XMLStream(object): + """ + An XML stream connection manager and event dispatcher. + + The XMLStream class abstracts away the issues of establishing a + connection with a server and sending and receiving XML "stanzas". + A stanza is a complete XML element that is a direct child of a root + document element. Two streams are used, one for each communication + direction, over the same socket. Once the connection is closed, both + streams should be complete and valid XML documents. + + Three types of events are provided to manage the stream: + Stream -- Triggered based on received stanzas, similar in concept + to events in a SAX XML parser. + Custom -- Triggered manually. + Scheduled -- Triggered based on time delays. + + Typically, stanzas are first processed by a stream event handler which + will then trigger custom events to continue further processing, + especially since custom event handlers may run in individual threads. + + + Attributes: + address -- The hostname and port of the server. + default_ns -- The default XML namespace that will be applied + to all non-namespaced stanzas. + event_queue -- A queue of stream, custom, and scheduled + events to be processed. + filesocket -- A filesocket created from the main connection socket. + Required for ElementTree.iterparse. + namespace_map -- Optional mapping of namespaces to namespace prefixes. + scheduler -- A scheduler object for triggering events + after a given period of time. + send_queue -- A queue of stanzas to be sent on the stream. + socket -- The connection to the server. + ssl_support -- Indicates if a SSL library is available for use. + state -- A state machine for managing the stream's + connection state. + stream_footer -- The start tag and any attributes for the stream's + root element. + stream_header -- The closing tag of the stream's root element. + use_ssl -- Flag indicating if SSL should be used. + use_tls -- Flag indicating if TLS should be used. + stop -- threading Event used to stop all threads. + auto_reconnect-- Flag to determine whether we auto reconnect. + + Methods: + add_event_handler -- Add a handler for a custom event. + add_handler -- Shortcut method for registerHandler. + connect -- Connect to the given server. + del_event_handler -- Remove a handler for a custom event. + disconnect -- Disconnect from the server and terminate + processing. + event -- Trigger a custom event. + get_id -- Return the current stream ID. + incoming_filter -- Optionally filter stanzas before processing. + new_id -- Generate a new, unique ID value. + process -- Read XML stanzas from the stream and apply + matching stream handlers. + reconnect -- Reestablish a connection to the server. + register_handler -- Add a handler for a stream event. + register_stanza -- Add a new stanza object type that may appear + as a direct child of the stream's root. + remove_handler -- Remove a stream handler. + remove_stanza -- Remove a stanza object type. + schedule -- Schedule an event handler to execute after a + given delay. + send -- Send a stanza object on the stream. + send_raw -- Send a raw string on the stream. + send_xml -- Send an XML string on the stream. + set_socket -- Set the stream's socket and generate a new + filesocket. + start_stream_handler -- Perform any stream initialization such + as handshakes. + start_tls -- Establish a TLS connection and restart + the stream. + """ + + def __init__(self, socket=None, host='', port=0): + """ + Establish a new XML stream. + + Arguments: + socket -- Use an existing socket for the stream. + Defaults to None to generate a new socket. + host -- The name of the target server. + Defaults to the empty string. + port -- The port to use for the connection. + Defaults to 0. + """ + # To comply with PEP8, method names now use underscores. + # Deprecated method names are re-mapped for backwards compatibility. + self.startTLS = self.start_tls + self.registerStanza = self.register_stanza + self.removeStanza = self.remove_stanza + self.registerHandler = self.register_handler + self.removeHandler = self.remove_handler + self.setSocket = self.set_socket + self.sendRaw = self.send_raw + self.getId = self.get_id + self.getNewId = self.new_id + self.sendXML = self.send_xml + + self.ssl_support = SSL_SUPPORT + + self.state = StateMachine(('disconnected', 'connected')) + self.state._set_state('disconnected') + + self.address = (host, int(port)) + self.filesocket = None + self.set_socket(socket) + + if sys.version_info < (3, 0): + self.socket_class = Socket26 + else: + self.socket_class = Socket.socket + + self.use_ssl = False + self.use_tls = False + + self.default_ns = '' + self.stream_header = "<stream>" + self.stream_footer = "</stream>" + + self.stop = threading.Event() + self.stream_end_event = threading.Event() + self.stream_end_event.set() + self.event_queue = queue.Queue() + self.send_queue = queue.Queue() + self.scheduler = Scheduler(self.event_queue, self.stop) + + self.namespace_map = {} + + self.__thread = {} + self.__root_stanza = [] + self.__handlers = [] + self.__event_handlers = {} + self.__event_handlers_lock = threading.Lock() + + self._id = 0 + self._id_lock = threading.Lock() + + self.auto_reconnect = True + self.is_client = False + + signal.signal(signal.SIGHUP, self._handle_kill) + signal.signal(signal.SIGTERM, self._handle_kill) # used in Windows + + def _handle_kill(self, signum, frame): + """ + Capture kill event and disconnect cleanly after first + spawning the "killed" event. + """ + self.event("killed", direct=True) + self.disconnect() + + def new_id(self): + """ + Generate and return a new stream ID in hexadecimal form. + + Many stanzas, handlers, or matchers may require unique + ID values. Using this method ensures that all new ID values + are unique in this stream. + """ + with self._id_lock: + self._id += 1 + return self.get_id() + + def get_id(self): + """ + Return the current unique stream ID in hexadecimal form. + """ + return "%X" % self._id + + def connect(self, host='', port=0, use_ssl=False, + use_tls=True, reattempt=True): + """ + Create a new socket and connect to the server. + + Setting reattempt to True will cause connection attempts to be made + every second until a successful connection is established. + + Arguments: + host -- The name of the desired server for the connection. + port -- Port to connect to on the server. + use_ssl -- Flag indicating if SSL should be used. + use_tls -- Flag indicating if TLS should be used. + reattempt -- Flag indicating if the socket should reconnect + after disconnections. + """ + if host and port: + self.address = (host, int(port)) + + self.is_client = True + # Respect previous SSL and TLS usage directives. + if use_ssl is not None: + self.use_ssl = use_ssl + if use_tls is not None: + self.use_tls = use_tls + + # Repeatedly attempt to connect until a successful connection + # is established. + connected = self.state.transition('disconnected', 'connected', + func=self._connect) + while reattempt and not connected: + connected = self.state.transition('disconnected', 'connected', + func=self._connect) + return connected + + def _connect(self): + self.stop.clear() + self.socket = self.socket_class(Socket.AF_INET, Socket.SOCK_STREAM) + self.socket.settimeout(None) + if self.use_ssl and self.ssl_support: + logging.debug("Socket Wrapped for SSL") + ssl_socket = ssl.wrap_socket(self.socket) + if hasattr(self.socket, 'socket'): + # We are using a testing socket, so preserve the top + # layer of wrapping. + self.socket.socket = ssl_socket + else: + self.socket = ssl_socket + + try: + logging.debug("Connecting to %s:%s" % self.address) + self.socket.connect(self.address) + self.set_socket(self.socket, ignore=True) + #this event is where you should set your application state + self.event("connected", direct=True) + return True + except Socket.error as serr: + error_msg = "Could not connect to %s:%s. Socket Error #%s: %s" + logging.error(error_msg % (self.address[0], self.address[1], + serr.errno, serr.strerror)) + time.sleep(1) + return False + + def disconnect(self, reconnect=False): + """ + Terminate processing and close the XML streams. + + Optionally, the connection may be reconnected and + resume processing afterwards. + + Arguments: + reconnect -- Flag indicating if the connection + and processing should be restarted. + Defaults to False. + """ + self.state.transition('connected', 'disconnected', wait=0.0, + func=self._disconnect, args=(reconnect,)) + + def _disconnect(self, reconnect=False): + # Send the end of stream marker. + self.send_raw(self.stream_footer) + # Wait for confirmation that the stream was + # closed in the other direction. + if not reconnect: + self.auto_reconnect = False + self.stream_end_event.wait(4) + if not self.auto_reconnect: + self.stop.set() + try: + self.socket.close() + self.filesocket.close() + self.socket.shutdown(Socket.SHUT_RDWR) + except Socket.error as serr: + pass + finally: + #clear your application state + self.event("disconnected", direct=True) + return True + + def reconnect(self): + """ + Reset the stream's state and reconnect to the server. + """ + logging.debug("reconnecting...") + self.state.transition('connected', 'disconnected', wait=2.0, + func=self._disconnect, args=(True,)) + logging.debug("connecting...") + return self.state.transition('disconnected', 'connected', + wait=2.0, func=self._connect) + + def set_socket(self, socket, ignore=False): + """ + Set the socket to use for the stream. + + The filesocket will be recreated as well. + + Arguments: + socket -- The new socket to use. + ignore -- don't set the state + """ + self.socket = socket + if socket is not None: + # ElementTree.iterparse requires a file. + # 0 buffer files have to be binary. + + # Use the correct fileobject type based on the Python + # version to work around a broken implementation in + # Python 2.x. + if sys.version_info < (3, 0): + self.filesocket = FileSocket(self.socket) + else: + self.filesocket = self.socket.makefile('rb', 0) + if not ignore: + self.state._set_state('connected') + + def start_tls(self): + """ + Perform handshakes for TLS. + + If the handshake is successful, the XML stream will need + to be restarted. + """ + if self.ssl_support: + logging.info("Negotiating TLS") + ssl_socket = ssl.wrap_socket(self.socket, + ssl_version=ssl.PROTOCOL_TLSv1, + do_handshake_on_connect=False) + if hasattr(self.socket, 'socket'): + # We are using a testing socket, so preserve the top + # layer of wrapping. + self.socket.socket = ssl_socket + else: + self.socket = ssl_socket + self.socket.do_handshake() + self.set_socket(self.socket) + return True + else: + logging.warning("Tried to enable TLS, but ssl module not found.") + return False + + def start_stream_handler(self, xml): + """ + Perform any initialization actions, such as handshakes, once the + stream header has been sent. + + Meant to be overridden. + """ + pass + + def register_stanza(self, stanza_class): + """ + Add a stanza object class as a known root stanza. A root stanza is + one that appears as a direct child of the stream's root element. + + Stanzas that appear as substanzas of a root stanza do not need to + be registered here. That is done using register_stanza_plugin() from + sleekxmpp.xmlstream.stanzabase. + + Stanzas that are not registered will not be converted into + stanza objects, but may still be processed using handlers and + matchers. + + Arguments: + stanza_class -- The top-level stanza object's class. + """ + self.__root_stanza.append(stanza_class) + + def remove_stanza(self, stanza_class): + """ + Remove a stanza from being a known root stanza. A root stanza is + one that appears as a direct child of the stream's root element. + + Stanzas that are not registered will not be converted into + stanza objects, but may still be processed using handlers and + matchers. + """ + del self.__root_stanza[stanza_class] + + def add_handler(self, mask, pointer, name=None, disposable=False, + threaded=False, filter=False, instream=False): + """ + A shortcut method for registering a handler using XML masks. + + Arguments: + mask -- An XML snippet matching the structure of the + stanzas that will be passed to this handler. + pointer -- The handler function itself. + name -- A unique name for the handler. A name will + be generated if one is not provided. + disposable -- Indicates if the handler should be discarded + after one use. + threaded -- Deprecated. Remains for backwards compatibility. + filter -- Deprecated. Remains for backwards compatibility. + instream -- Indicates if the handler should execute during + stream processing and not during normal event + processing. + """ + # To prevent circular dependencies, we must load the matcher + # and handler classes here. + from sleekxmpp.xmlstream.matcher import MatchXMLMask + from sleekxmpp.xmlstream.handler import XMLCallback + + if name is None: + name = 'add_handler_%s' % self.getNewId() + self.registerHandler(XMLCallback(name, MatchXMLMask(mask), pointer, + once=disposable, instream=instream)) + + def register_handler(self, handler, before=None, after=None): + """ + Add a stream event handler that will be executed when a matching + stanza is received. + + Arguments: + handler -- The handler object to execute. + """ + if handler.stream is None: + self.__handlers.append(handler) + handler.stream = self + + def remove_handler(self, name): + """ + Remove any stream event handlers with the given name. + + Arguments: + name -- The name of the handler. + """ + idx = 0 + for handler in self.__handlers: + if handler.name == name: + self.__handlers.pop(idx) + return True + idx += 1 + return False + + def add_event_handler(self, name, pointer, + threaded=False, disposable=False): + """ + Add a custom event handler that will be executed whenever + its event is manually triggered. + + Arguments: + name -- The name of the event that will trigger + this handler. + pointer -- The function to execute. + threaded -- If set to True, the handler will execute + in its own thread. Defaults to False. + disposable -- If set to True, the handler will be + discarded after one use. Defaults to False. + """ + if not name in self.__event_handlers: + self.__event_handlers[name] = [] + self.__event_handlers[name].append((pointer, threaded, disposable)) + + def del_event_handler(self, name, pointer): + """ + Remove a function as a handler for an event. + + Arguments: + name -- The name of the event. + 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] = filter(filter_pointers, + self.__event_handlers[name]) + + def event(self, name, data={}, direct=False): + """ + Manually trigger a custom event. + + Arguments: + name -- The name of the event to trigger. + data -- Data that will be passed to each event handler. + Defaults to an empty dictionary. + direct -- Runs the event directly if True. + """ + for handler in self.__event_handlers.get(name, []): + if direct: + handler[0](copy.copy(data)) + else: + self.event_queue.put(('event', handler, copy.copy(data))) + if handler[2]: + # If the handler is disposable, we will go ahead and + # remove it now instead of waiting for it to be + # processed in the queue. + with self.__event_handlers_lock: + try: + h_index = self.__event_handlers[name].index(handler) + self.__event_handlers[name].pop(h_index) + except: + pass + + def schedule(self, name, seconds, callback, args=None, + kwargs=None, repeat=False): + """ + Schedule a callback function to execute after a given delay. + + Arguments: + name -- A unique name for the scheduled callback. + seconds -- The time in seconds to wait before executing. + callback -- A pointer to the function to execute. + args -- A tuple of arguments to pass to the function. + kwargs -- A dictionary of keyword arguments to pass to + the function. + repeat -- Flag indicating if the scheduled event should + be reset and repeat after executing. + """ + self.scheduler.add(name, seconds, callback, args, kwargs, + repeat, qpointer=self.event_queue) + + def incoming_filter(self, xml): + """ + Filter incoming XML objects before they are processed. + + Possible uses include remapping namespaces, or correcting elements + from sources with incorrect behavior. + + Meant to be overridden. + """ + return xml + + def send(self, data, mask=None, timeout=RESPONSE_TIMEOUT): + """ + A wrapper for send_raw for sending stanza objects. + + May optionally block until an expected response is received. + + Arguments: + data -- The stanza object to send on the stream. + mask -- Deprecated. An XML snippet matching the structure + of the expected response. Execution will block + in this thread until the response is received + or a timeout occurs. + timeout -- Time in seconds to wait for a response before + continuing. Defaults to RESPONSE_TIMEOUT. + """ + if hasattr(mask, 'xml'): + mask = mask.xml + data = str(data) + if mask is not None: + logging.warning("Use of send mask waiters is deprecated.") + wait_for = Waiter("SendWait_%s" % self.new_id(), + MatchXMLMask(mask)) + self.register_handler(wait_for) + self.send_raw(data) + if mask is not None: + return wait_for.wait(timeout) + + def send_raw(self, data): + """ + Send raw data across the stream. + + Arguments: + data -- Any string value. + """ + self.send_queue.put(data) + return True + + def send_xml(self, data, mask=None, timeout=RESPONSE_TIMEOUT): + """ + Send an XML object on the stream, and optionally wait + for a response. + + Arguments: + data -- The XML object to send on the stream. + mask -- Deprecated. An XML snippet matching the structure + of the expected response. Execution will block + in this thread until the response is received + or a timeout occurs. + timeout -- Time in seconds to wait for a response before + continuing. Defaults to RESPONSE_TIMEOUT. + """ + return self.send(tostring(data), mask, timeout) + + def process(self, threaded=True): + """ + Initialize the XML streams and begin processing events. + + The number of threads used for processing stream events is determined + by HANDLER_THREADS. + + Arguments: + threaded -- If threaded=True then event dispatcher will run + in a separate thread, allowing for the stream to be + used in the background for another application. + Defaults to True. + + Event handlers and the send queue will be threaded + regardless of this parameter's value. + """ + self.scheduler.process(threaded=True) + + def start_thread(name, target): + self.__thread[name] = threading.Thread(name=name, target=target) + self.__thread[name].start() + + for t in range(0, HANDLER_THREADS): + logging.debug("Starting HANDLER THREAD") + start_thread('stream_event_handler_%s' % t, self._event_runner) + + start_thread('send_thread', self._send_thread) + + if threaded: + # Run the XML stream in the background for another application. + start_thread('process', self._process) + else: + self._process() + + def _process(self): + """ + Start processing the XML streams. + + Processing will continue after any recoverable errors + if reconnections are allowed. + """ + firstrun = True + + # The body of this loop will only execute once per connection. + # Additional passes will be made only if an error occurs and + # reconnecting is permitted. + while firstrun or (self.auto_reconnect and not self.stop.isSet()): + firstrun = False + try: + if self.is_client: + self.send_raw(self.stream_header) + # The call to self.__read_xml will block and prevent + # the body of the loop from running until a disconnect + # occurs. After any reconnection, the stream header will + # be resent and processing will resume. + while not self.stop.isSet() and self.__read_xml(): + # Ensure the stream header is sent for any + # new connections. + if self.is_client: + self.send_raw(self.stream_header) + except KeyboardInterrupt: + logging.debug("Keyboard Escape Detected in _process") + self.stop.set() + except SystemExit: + logging.debug("SystemExit in _process") + self.stop.set() + except Socket.error: + logging.exception('Socket Error') + except: + if not self.stop.isSet(): + logging.exception('Connection error.') + if not self.stop.isSet() and self.auto_reconnect: + self.reconnect() + else: + self.disconnect() + self.event_queue.put(('quit', None, None)) + self.scheduler.run = False + + def __read_xml(self): + """ + Parse the incoming XML stream, raising stream events for + each received stanza. + """ + depth = 0 + root = None + for (event, xml) in ET.iterparse(self.filesocket, (b'end', b'start')): + if event == b'start': + if depth == 0: + # We have received the start of the root element. + root = xml + # Perform any stream initialization actions, such + # as handshakes. + self.stream_end_event.clear() + self.start_stream_handler(root) + depth += 1 + if event == b'end': + depth -= 1 + if depth == 0: + # The stream's root element has closed, + # terminating the stream. + logging.debug("End of stream recieved") + self.stream_end_event.set() + return False + elif depth == 1: + # We only raise events for stanzas that are direct + # children of the root element. + try: + self.__spawn_event(xml) + except RestartStream: + return True + if root: + # Keep the root element empty of children to + # save on memory use. + root.clear() + logging.debug("Ending read XML loop") + + 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. + + Arguments: + xml -- The XML stanza to analyze. + """ + logging.debug("RECV: %s" % tostring(xml, + xmlns=self.default_ns, + stream=self)) + # 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_type = StanzaBase + for stanza_class in self.__root_stanza: + if xml.tag == "{%s}%s" % (self.default_ns, stanza_class.name): + stanza_type = stanza_class + break + stanza = stanza_type(self, xml) + + # Match the stanza against registered handlers. Handlers marked + # to run "in stream" will be executed immediately; the rest will + # be queued. + unhandled = True + for handler in self.__handlers: + if handler.match(stanza): + stanza_copy = stanza_type(self, copy.deepcopy(xml)) + handler.prerun(stanza_copy) + self.event_queue.put(('stanza', handler, stanza_copy)) + try: + if handler.check_delete(): + self.__handlers.pop(self.__handlers.index(handler)) + except: + pass # not thread safe + unhandled = False + + # Some stanzas require responses, such as Iq queries. A default + # handler will be executed immediately for this case. + if unhandled: + stanza.unhandled() + + def _threaded_event_wrapper(self, func, args): + """ + Capture exceptions for event handlers that run + in individual threads. + + Arguments: + func -- The event handler to execute. + args -- Arguments to the event handler. + """ + try: + func(*args) + except Exception as e: + error_msg = 'Error processing event handler: %s' + logging.exception(error_msg % str(func)) + if hasattr(args[0], 'exception'): + args[0].exception(e) + + def _event_runner(self): + """ + Process the event queue and execute handlers. + + The number of event runner threads is controlled by HANDLER_THREADS. + + Stream event handlers will all execute in this thread. Custom event + handlers may be spawned in individual threads. + """ + logging.debug("Loading event runner") + try: + while not self.stop.isSet(): + try: + event = self.event_queue.get(True, timeout=5) + except queue.Empty: + event = None + if event is None: + continue + + etype, handler = event[0:2] + args = event[2:] + + if etype == 'stanza': + try: + handler.run(args[0]) + except Exception as e: + error_msg = 'Error processing stream handler: %s' + logging.exception(error_msg % handler.name) + args[0].exception(e) + elif etype == 'schedule': + try: + logging.debug(args) + handler(*args[0]) + except: + logging.exception('Error processing scheduled task') + elif etype == 'event': + func, threaded, disposable = handler + try: + if threaded: + x = threading.Thread( + name="Event_%s" % str(func), + target=self._threaded_event_wrapper, + args=(func, args)) + x.start() + else: + func(*args) + except Exception as e: + error_msg = 'Error processing event handler: %s' + logging.exception(error_msg % str(func)) + if hasattr(args[0], 'exception'): + args[0].exception(e) + elif etype == 'quit': + logging.debug("Quitting event runner thread") + return False + except KeyboardInterrupt: + logging.debug("Keyboard Escape Detected in _event_runner") + self.disconnect() + return + except SystemExit: + self.disconnect() + self.event_queue.put(('quit', None, None)) + return + + def _send_thread(self): + """ + Extract stanzas from the send queue and send them on the stream. + """ + try: + while not self.stop.isSet(): + try: + data = self.send_queue.get(True, 1) + except queue.Empty: + continue + logging.debug("SEND: %s" % data) + try: + self.socket.send(data.encode('utf-8')) + except: + logging.warning("Failed to send %s" % data) + self.disconnect(self.auto_reconnect) + except KeyboardInterrupt: + logging.debug("Keyboard Escape Detected in _send_thread") + self.disconnect() + return + except SystemExit: + self.disconnect() + self.event_queue.put(('quit', None, None)) + return |
