summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLance Stout <lancestout@gmail.com>2011-03-23 23:00:41 -0400
committerLance Stout <lancestout@gmail.com>2011-03-24 09:35:36 -0400
commita3d111be12144d9dea80165d84cd8168714d3d54 (patch)
treec7627d3d8f15837a2b0ada6fb0a3cb8a66f70a28
parent4916a12b6f2515b22b504c650bd97dd4c30c4471 (diff)
downloadslixmpp-a3d111be12144d9dea80165d84cd8168714d3d54.tar.gz
slixmpp-a3d111be12144d9dea80165d84cd8168714d3d54.tar.bz2
slixmpp-a3d111be12144d9dea80165d84cd8168714d3d54.tar.xz
slixmpp-a3d111be12144d9dea80165d84cd8168714d3d54.zip
Added new XEP-0050 implementation.
Backward incompatibility alert! Please see examples/adhoc_provider.py for how to use the new plugin implementation, or the test examples in the files tests/test_stream_xep_0050.py and tests/test_stanza_xep_0050.py. Major changes: - May now have zero-step commands. Useful if a command is intended to be a dynamic status report that doesn't require any user input. - May use payloads other than data forms, such as a completely custom stanza type. - May include multiple payload items, such as multiple data forms, or a form and a custom stanza type. - Includes a command user API for calling adhoc commands on remote agents and managing the workflow. - Added support for note elements. Todo: - Add prev action support. You may use register_plugin('old_0050') to continue using the previous XEP-0050 implementation.
-rwxr-xr-xexamples/adhoc_provider.py199
-rwxr-xr-xexamples/adhoc_user.py276
-rw-r--r--setup.py1
-rw-r--r--sleekxmpp/plugins/old_0050.py (renamed from sleekxmpp/plugins/xep_0050.py)0
-rw-r--r--sleekxmpp/plugins/xep_0050/__init__.py10
-rw-r--r--sleekxmpp/plugins/xep_0050/adhoc.py593
-rw-r--r--sleekxmpp/plugins/xep_0050/stanza.py185
-rw-r--r--tests/test_stanza_xep_0050.py114
-rw-r--r--tests/test_stream_xep_0050.py686
9 files changed, 2064 insertions, 0 deletions
diff --git a/examples/adhoc_provider.py b/examples/adhoc_provider.py
new file mode 100755
index 00000000..3316a0c6
--- /dev/null
+++ b/examples/adhoc_provider.py
@@ -0,0 +1,199 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+ 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 logging
+import time
+import getpass
+from optparse import OptionParser
+
+import sleekxmpp
+
+# Python versions before 3.0 do not use UTF-8 encoding
+# by default. To ensure that Unicode is handled properly
+# throughout SleekXMPP, we will set the default encoding
+# ourselves to UTF-8.
+if sys.version_info < (3, 0):
+ reload(sys)
+ sys.setdefaultencoding('utf8')
+
+
+class CommandBot(sleekxmpp.ClientXMPP):
+
+ """
+ A simple SleekXMPP bot that provides a basic
+ adhoc command.
+ """
+
+ def __init__(self, jid, password):
+ sleekxmpp.ClientXMPP.__init__(self, jid, password)
+
+ # The session_start event will be triggered when
+ # the bot establishes its connection with the server
+ # and the XML streams are ready for use. We want to
+ # listen for this event so that we we can intialize
+ # our roster.
+ self.add_event_handler("session_start", self.start)
+
+ def start(self, event):
+ """
+ Process the session_start event.
+
+ Typical actions for the session_start event are
+ requesting the roster and broadcasting an intial
+ presence stanza.
+
+ Arguments:
+ event -- An empty dictionary. The session_start
+ event does not provide any additional
+ data.
+ """
+ self.send_presence()
+ self.get_roster()
+
+ # We add the command after session_start has fired
+ # to ensure that the correct full JID is used.
+
+ # If using a component, may also pass jid keyword parameter.
+
+ self['xep_0050'].add_command(node='greeting',
+ name='Greeting',
+ handler=self._handle_command)
+
+ def _handle_command(self, iq, session):
+ """
+ Respond to the intial request for a command.
+
+ Arguments:
+ iq -- The iq stanza containing the command request.
+ session -- A dictionary of data relevant to the command
+ session. Additional, custom data may be saved
+ here to persist across handler callbacks.
+ """
+ form = self['xep_0004'].makeForm('form', 'Greeting')
+ form.addField(var='greeting',
+ ftype='text-single',
+ label='Your greeting')
+
+ session['payload'] = form
+ session['next'] = self._handle_command_complete
+ session['has_next'] = False
+
+ # Other useful session values:
+ # session['to'] -- The JID that received the
+ # command request.
+ # session['from'] -- The JID that sent the
+ # command request.
+ # session['has_next'] = True -- There are more steps to complete
+ # session['allow_complete'] = True -- Allow user to finish immediately
+ # and possibly skip steps
+ # session['cancel'] = handler -- Assign a handler for if the user
+ # cancels the command.
+ # session['notes'] = [ -- Add informative notes about the
+ # ('info', 'Info message'), command's results.
+ # ('warning', 'Warning message'),
+ # ('error', 'Error message')]
+
+ return session
+
+ def _handle_command_complete(self, payload, session):
+ """
+ Process a command result from the user.
+
+ Arguments:
+ payload -- Either a single item, such as a form, or a list
+ of items or forms if more than one form was
+ provided to the user. The payload may be any
+ stanza, such as jabber:x:oob for out of band
+ data, or jabber:x:data for typical data forms.
+ session -- A dictionary of data relevant to the command
+ session. Additional, custom data may be saved
+ here to persist across handler callbacks.
+ """
+
+ # In this case (as is typical), the payload is a form
+ form = payload
+
+ greeting = form['values']['greeting']
+ self.send_message(mto=session['from'],
+ mbody="%s, World!" % greeting)
+
+ # Having no return statement is the same as unsetting the 'payload'
+ # and 'next' session values and returning the session.
+
+ # Unless it is the final step, always return the session dictionary.
+
+ session['payload'] = None
+ session['next'] = None
+
+ return session
+
+
+if __name__ == '__main__':
+ # Setup the command line arguments.
+ optp = OptionParser()
+
+ # Output verbosity options.
+ optp.add_option('-q', '--quiet', help='set logging to ERROR',
+ action='store_const', dest='loglevel',
+ const=logging.ERROR, default=logging.INFO)
+ optp.add_option('-d', '--debug', help='set logging to DEBUG',
+ action='store_const', dest='loglevel',
+ const=logging.DEBUG, default=logging.INFO)
+ optp.add_option('-v', '--verbose', help='set logging to COMM',
+ action='store_const', dest='loglevel',
+ const=5, default=logging.INFO)
+
+ # JID and password options.
+ optp.add_option("-j", "--jid", dest="jid",
+ help="JID to use")
+ optp.add_option("-p", "--password", dest="password",
+ help="password to use")
+
+ opts, args = optp.parse_args()
+
+ # Setup logging.
+ logging.basicConfig(level=opts.loglevel,
+ format='%(levelname)-8s %(message)s')
+
+ if opts.jid is None:
+ opts.jid = raw_input("Username: ")
+ if opts.password is None:
+ opts.password = getpass.getpass("Password: ")
+
+ # Setup the CommandBot and register plugins. Note that while plugins may
+ # have interdependencies, the order in which you register them does
+ # not matter.
+ xmpp = CommandBot(opts.jid, opts.password)
+ xmpp.register_plugin('xep_0030') # Service Discovery
+ xmpp.register_plugin('xep_0004') # Data Forms
+ xmpp.register_plugin('xep_0050') # Adhoc Commands
+
+ # If you are working with an OpenFire server, you may need
+ # to adjust the SSL version used:
+ # xmpp.ssl_version = ssl.PROTOCOL_SSLv3
+
+ # If you want to verify the SSL certificates offered by a server:
+ # xmpp.ca_certs = "path/to/ca/cert"
+
+ # Connect to the XMPP server and start processing XMPP stanzas.
+ if xmpp.connect():
+ # If you do not have the pydns library installed, you will need
+ # to manually specify the name of the server if it does not match
+ # the one in the JID. For example, to use Google Talk you would
+ # need to use:
+ #
+ # if xmpp.connect(('talk.google.com', 5222)):
+ # ...
+ xmpp.process(threaded=False)
+ print("Done")
+ else:
+ print("Unable to connect.")
diff --git a/examples/adhoc_user.py b/examples/adhoc_user.py
new file mode 100755
index 00000000..30e83f9b
--- /dev/null
+++ b/examples/adhoc_user.py
@@ -0,0 +1,276 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+
+"""
+ 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 logging
+import time
+import getpass
+from optparse import OptionParser
+
+import sleekxmpp
+
+# Python versions before 3.0 do not use UTF-8 encoding
+# by default. To ensure that Unicode is handled properly
+# throughout SleekXMPP, we will set the default encoding
+# ourselves to UTF-8.
+if sys.version_info < (3, 0):
+ reload(sys)
+ sys.setdefaultencoding('utf8')
+
+
+class CommandUserBot(sleekxmpp.ClientXMPP):
+
+ """
+ A simple SleekXMPP bot that uses the adhoc command
+ provided by the adhoc_provider.py example.
+ """
+
+ def __init__(self, jid, password, other, greeting):
+ sleekxmpp.ClientXMPP.__init__(self, jid, password)
+
+ self.command_provider = other
+ self.greeting = greeting
+
+ # The session_start event will be triggered when
+ # the bot establishes its connection with the server
+ # and the XML streams are ready for use. We want to
+ # listen for this event so that we we can intialize
+ # our roster.
+ self.add_event_handler("session_start", self.start)
+ self.add_event_handler("message", self.message)
+
+ def start(self, event):
+ """
+ Process the session_start event.
+
+ Typical actions for the session_start event are
+ requesting the roster and broadcasting an intial
+ presence stanza.
+
+ Arguments:
+ event -- An empty dictionary. The session_start
+ event does not provide any additional
+ data.
+ """
+ self.send_presence()
+ self.get_roster()
+
+ # We first create a session dictionary containing:
+ # 'next' -- the handler to execute on a successful response
+ # 'error' -- the handler to execute if an error occurs
+
+ # The session may also contain custom data.
+
+ session = {'greeting': self.greeting,
+ 'next': self._command_start,
+ 'error': self._command_error}
+
+ self['xep_0050'].start_command(jid=self.command_provider,
+ node='greeting',
+ session=session)
+
+ def message(self, msg):
+ """
+ Process incoming message stanzas.
+
+ Arguments:
+ msg -- The received message stanza.
+ """
+ logging.info(msg['body'])
+
+ def _command_start(self, iq, session):
+ """
+ Process the initial command result.
+
+ Arguments:
+ iq -- The iq stanza containing the command result.
+ session -- A dictionary of data relevant to the command
+ session. Additional, custom data may be saved
+ here to persist across handler callbacks.
+ """
+
+ # The greeting command provides a form with a single field:
+ # <x xmlns="jabber:x:data" type="form">
+ # <field var="greeting"
+ # type="text-single"
+ # label="Your greeting" />
+ # </x>
+
+ form = self['xep_0004'].makeForm(ftype='submit')
+ form.addField(var='greeting',
+ value=session['greeting'])
+
+ session['payload'] = form
+
+ # We don't need to process the next result.
+ session['next'] = None
+
+ # Other options include using:
+ # continue_command() -- Continue to the next step in the workflow
+ # cancel_command() -- Stop command execution.
+
+ self['xep_0050'].complete_command(session)
+
+ def _command_error(self, iq, session):
+ """
+ Process an error that occurs during command execution.
+
+ Arguments:
+ iq -- The iq stanza containing the error.
+ session -- A dictionary of data relevant to the command
+ session. Additional, custom data may be saved
+ here to persist across handler callbacks.
+ """
+ logging.error("COMMAND: %s %s" % (iq['error']['condition'],
+ iq['error']['text']))
+
+ # Terminate the command's execution and clear its session.
+ # The session will automatically be cleared if no error
+ # handler is provided.
+ self['xep_0050'].terminate_command(session)
+
+ def _handle_command(self, iq, session):
+ """
+ Respond to the intial request for a command.
+
+ Arguments:
+ iq -- The iq stanza containing the command request.
+ session -- A dictionary of data relevant to the command
+ session. Additional, custom data may be saved
+ here to persist across handler callbacks.
+ """
+ form = self['xep_0004'].makeForm('form', 'Greeting')
+ form.addField(var='greeting',
+ ftype='text-single',
+ label='Your greeting')
+
+ session['payload'] = form
+ session['next'] = self._handle_command_complete
+ session['has_next'] = False
+
+ # Other useful session values:
+ # session['to'] -- The JID that received the
+ # command request.
+ # session['from'] -- The JID that sent the
+ # command request.
+ # session['has_next'] = True -- There are more steps to complete
+ # session['allow_complete'] = True -- Allow user to finish immediately
+ # and possibly skip steps
+ # session['cancel'] = handler -- Assign a handler for if the user
+ # cancels the command.
+ # session['notes'] = [ -- Add informative notes about the
+ # ('info', 'Info message'), command's results.
+ # ('warning', 'Warning message'),
+ # ('error', 'Error message')]
+
+ return session
+
+ def _handle_command_complete(self, payload, session):
+ """
+ Process a command result from the user.
+
+ Arguments:
+ payload -- Either a single item, such as a form, or a list
+ of items or forms if more than one form was
+ provided to the user. The payload may be any
+ stanza, such as jabber:x:oob for out of band
+ data, or jabber:x:data for typical data forms.
+ session -- A dictionary of data relevant to the command
+ session. Additional, custom data may be saved
+ here to persist across handler callbacks.
+ """
+
+ # In this case (as is typical), the payload is a form
+ form = payload
+
+ greeting = form['values']['greeting']
+ self.send_message(mto=session['from'],
+ mbody="%s, World!" % greeting)
+
+ # Having no return statement is the same as unsetting the 'payload'
+ # and 'next' session values and returning the session.
+
+ # Unless it is the final step, always return the session dictionary.
+
+ session['payload'] = None
+ session['next'] = None
+
+ return session
+
+
+if __name__ == '__main__':
+ # Setup the command line arguments.
+ optp = OptionParser()
+
+ # Output verbosity options.
+ optp.add_option('-q', '--quiet', help='set logging to ERROR',
+ action='store_const', dest='loglevel',
+ const=logging.ERROR, default=logging.INFO)
+ optp.add_option('-d', '--debug', help='set logging to DEBUG',
+ action='store_const', dest='loglevel',
+ const=logging.DEBUG, default=logging.INFO)
+ optp.add_option('-v', '--verbose', help='set logging to COMM',
+ action='store_const', dest='loglevel',
+ const=5, default=logging.INFO)
+
+ # JID and password options.
+ optp.add_option("-j", "--jid", dest="jid",
+ help="JID to use")
+ optp.add_option("-p", "--password", dest="password",
+ help="password to use")
+ optp.add_option("-o", "--other", dest="other",
+ help="JID providing commands")
+ optp.add_option("-g", "--greeting", dest="greeting",
+ help="Greeting")
+
+ opts, args = optp.parse_args()
+
+ # Setup logging.
+ logging.basicConfig(level=opts.loglevel,
+ format='%(levelname)-8s %(message)s')
+
+ if opts.jid is None:
+ opts.jid = raw_input("Username: ")
+ if opts.password is None:
+ opts.password = getpass.getpass("Password: ")
+ if opts.other is None:
+ opts.other = raw_input("JID Providing Commands: ")
+ if opts.greeting is None:
+ opts.other = raw_input("Greeting: ")
+
+ # Setup the CommandBot and register plugins. Note that while plugins may
+ # have interdependencies, the order in which you register them does
+ # not matter.
+ xmpp = CommandUserBot(opts.jid, opts.password, opts.other, opts.greeting)
+ xmpp.register_plugin('xep_0030') # Service Discovery
+ xmpp.register_plugin('xep_0004') # Data Forms
+ xmpp.register_plugin('xep_0050') # Adhoc Commands
+
+ # If you are working with an OpenFire server, you may need
+ # to adjust the SSL version used:
+ # xmpp.ssl_version = ssl.PROTOCOL_SSLv3
+
+ # If you want to verify the SSL certificates offered by a server:
+ # xmpp.ca_certs = "path/to/ca/cert"
+
+ # Connect to the XMPP server and start processing XMPP stanzas.
+ if xmpp.connect():
+ # If you do not have the pydns library installed, you will need
+ # to manually specify the name of the server if it does not match
+ # the one in the JID. For example, to use Google Talk you would
+ # need to use:
+ #
+ # if xmpp.connect(('talk.google.com', 5222)):
+ # ...
+ xmpp.process(threaded=False)
+ print("Done")
+ else:
+ print("Unable to connect.")
diff --git a/setup.py b/setup.py
index 26160ca0..de3c34cf 100644
--- a/setup.py
+++ b/setup.py
@@ -49,6 +49,7 @@ packages = [ 'sleekxmpp',
'sleekxmpp/plugins/xep_0009/stanza',
'sleekxmpp/plugins/xep_0030',
'sleekxmpp/plugins/xep_0030/stanza',
+ 'sleekxmpp/plugins/xep_0050',
'sleekxmpp/plugins/xep_0059',
'sleekxmpp/plugins/xep_0085',
'sleekxmpp/plugins/xep_0092',
diff --git a/sleekxmpp/plugins/xep_0050.py b/sleekxmpp/plugins/old_0050.py
index 439bebb9..439bebb9 100644
--- a/sleekxmpp/plugins/xep_0050.py
+++ b/sleekxmpp/plugins/old_0050.py
diff --git a/sleekxmpp/plugins/xep_0050/__init__.py b/sleekxmpp/plugins/xep_0050/__init__.py
new file mode 100644
index 00000000..99f44f2a
--- /dev/null
+++ b/sleekxmpp/plugins/xep_0050/__init__.py
@@ -0,0 +1,10 @@
+"""
+ SleekXMPP: The Sleek XMPP Library
+ Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
+ This file is part of SleekXMPP.
+
+ See the file LICENSE for copying permission.
+"""
+
+from sleekxmpp.plugins.xep_0050.stanza import Command
+from sleekxmpp.plugins.xep_0050.adhoc import xep_0050
diff --git a/sleekxmpp/plugins/xep_0050/adhoc.py b/sleekxmpp/plugins/xep_0050/adhoc.py
new file mode 100644
index 00000000..fe964e96
--- /dev/null
+++ b/sleekxmpp/plugins/xep_0050/adhoc.py
@@ -0,0 +1,593 @@
+"""
+ SleekXMPP: The Sleek XMPP Library
+ Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
+ This file is part of SleekXMPP.
+
+ See the file LICENSE for copying permission.
+"""
+
+import logging
+import time
+
+from sleekxmpp import Iq
+from sleekxmpp.xmlstream.handler import Callback
+from sleekxmpp.xmlstream.matcher import StanzaPath
+from sleekxmpp.xmlstream import register_stanza_plugin, JID
+from sleekxmpp.plugins.base import base_plugin
+from sleekxmpp.plugins.xep_0050 import stanza
+from sleekxmpp.plugins.xep_0050 import Command
+
+
+log = logging.getLogger(__name__)
+
+
+class xep_0050(base_plugin):
+
+ """
+ XEP-0050: Ad-Hoc Commands
+
+ XMPP's Adhoc Commands provides a generic workflow mechanism for
+ interacting with applications. The result is similar to menu selections
+ and multi-step dialogs in normal desktop applications. Clients do not
+ need to know in advance what commands are provided by any particular
+ application or agent. While adhoc commands provide similar functionality
+ to Jabber-RPC, adhoc commands are used primarily for human interaction.
+
+ Also see <http://xmpp.org/extensions/xep-0050.html>
+
+ Configuration Values:
+ threaded -- Indicates if command events should be threaded.
+ Defaults to True.
+
+ Events:
+ command_execute -- Received a command with action="execute"
+ command_next -- Received a command with action="next"
+ command_complete -- Received a command with action="complete"
+ command_cancel -- Received a command with action="cancel"
+
+ Attributes:
+ threaded -- Indicates if command events should be threaded.
+ Defaults to True.
+ commands -- A dictionary mapping JID/node pairs to command
+ names and handlers.
+ sessions -- A dictionary or equivalent backend mapping
+ session IDs to dictionaries containing data
+ relevant to a command's session.
+
+ Methods:
+ plugin_init -- Overrides base_plugin.plugin_init
+ post_init -- Overrides base_plugin.post_init
+ new_session -- Return a new session ID.
+ prep_handlers -- Placeholder. May call with a list of handlers
+ to prepare them for use with the session storage
+ backend, if needed.
+ set_backend -- Replace the default session storage with some
+ external storage mechanism, such as a database.
+ The provided backend wrapper must be able to
+ act using the same syntax as a dictionary.
+ add_command -- Add a command for use by external entitites.
+ get_commands -- Retrieve a list of commands provided by a
+ remote agent.
+ send_command -- Send a command request to a remote agent.
+ start_command -- Command user API: initiate a command session
+ continue_command -- Command user API: proceed to the next step
+ cancel_command -- Command user API: cancel a command
+ complete_command -- Command user API: finish a command
+ terminate_command -- Command user API: delete a command's session
+ """
+
+ def plugin_init(self):
+ """Start the XEP-0050 plugin."""
+ self.xep = '0050'
+ self.description = 'Ad-Hoc Commands'
+ self.stanza = stanza
+
+ self.threaded = self.config.get('threaded', True)
+ self.commands = {}
+ self.sessions = self.config.get('session_db', {})
+
+ self.xmpp.register_handler(
+ Callback("Ad-Hoc Execute",
+ StanzaPath('iq@type=set/command'),
+ self._handle_command))
+
+ self.xmpp.register_handler(
+ Callback("Ad-Hoc Result",
+ StanzaPath('iq@type=result/command'),
+ self._handle_command_result))
+
+ self.xmpp.register_handler(
+ Callback("Ad-Hoc Error",
+ StanzaPath('iq@type=error/command'),
+ self._handle_command_result))
+
+ register_stanza_plugin(Iq, stanza.Command)
+
+ self.xmpp.add_event_handler('command_execute',
+ self._handle_command_start,
+ threaded=self.threaded)
+ self.xmpp.add_event_handler('command_next',
+ self._handle_command_next,
+ threaded=self.threaded)
+ self.xmpp.add_event_handler('command_cancel',
+ self._handle_command_cancel,
+ threaded=self.threaded)
+ self.xmpp.add_event_handler('command_complete',
+ self._handle_command_complete,
+ threaded=self.threaded)
+
+ def post_init(self):
+ """Handle cross-plugin interactions."""
+ base_plugin.post_init(self)
+ self.xmpp['xep_0030'].add_feature(Command.namespace)
+
+ def set_backend(self, db):
+ """
+ Replace the default session storage dictionary with
+ a generic, external data storage mechanism.
+
+ The replacement backend must be able to interact through
+ the same syntax and interfaces as a normal dictionary.
+
+ Arguments:
+ db -- The new session storage mechanism.
+ """
+ self.sessions = db
+
+ def prep_handlers(self, handlers, **kwargs):
+ """
+ Prepare a list of functions for use by the backend service.
+
+ Intended to be replaced by the backend service as needed.
+
+ Arguments:
+ handlers -- A list of function pointers
+ **kwargs -- Any additional parameters required by the backend.
+ """
+ pass
+
+ # =================================================================
+ # Server side (command provider) API
+
+ def add_command(self, jid=None, node=None, name='', handler=None):
+ """
+ Make a new command available to external entities.
+
+ Access control may be implemented in the provided handler.
+
+ Command workflow is done across a sequence of command handlers. The
+ first handler is given the intial Iq stanza of the request in order
+ to support access control. Subsequent handlers are given only the
+ payload items of the command. All handlers will receive the command's
+ session data.
+
+ Arguments:
+ jid -- The JID that will expose the command.
+ node -- The node associated with the command.
+ name -- A human readable name for the command.
+ handler -- A function that will generate the response to the
+ initial command request, as well as enforcing any
+ access control policies.
+ """
+ if jid is None:
+ jid = self.xmpp.boundjid
+ elif isinstance(jid, str):
+ jid = JID(jid)
+ item_jid = jid.full
+
+ # Client disco uses only the bare JID
+ if self.xmpp.is_component:
+ jid = jid.full
+ else:
+ jid = jid.bare
+
+ self.xmpp['xep_0030'].add_identity(category='automation',
+ itype='command-list',
+ name='Ad-Hoc commands',
+ node=Command.namespace,
+ jid=jid)
+ self.xmpp['xep_0030'].add_item(jid=item_jid,
+ name=name,
+ node=Command.namespace,
+ subnode=node,
+ ijid=jid)
+ self.xmpp['xep_0030'].add_identity(category='automation',
+ itype='command-node',
+ name=name,
+ node=node,
+ jid=jid)
+ self.xmpp['xep_0030'].add_feature(Command.namespace, None, jid)
+
+ self.commands[(item_jid, node)] = (name, handler)
+
+ def new_session(self):
+ """Return a new session ID."""
+ return str(time.time()) + '-' + self.xmpp.new_id()
+
+ def _handle_command(self, iq):
+ """Raise command events based on the command action."""
+ self.xmpp.event('command_%s' % iq['command']['action'], iq)
+
+ def _handle_command_start(self, iq):
+ """
+ Process an initial request to execute a command.
+
+ Arguments:
+ iq -- The command execution request.
+ """
+ sessionid = self.new_session()
+ node = iq['command']['node']
+ key = (iq['to'].full, node)
+ name, handler = self.commands.get(key, ('Not found', None))
+ if not handler:
+ log.debug('Command not found: %s, %s' % (key, self.commands))
+
+ initial_session = {'id': sessionid,
+ 'from': iq['from'],
+ 'to': iq['to'],
+ 'node': node,
+ 'payload': None,
+ 'interfaces': '',
+ 'payload_classes': None,
+ 'notes': None,
+ 'has_next': False,
+ 'allow_complete': False,
+ 'allow_prev': False,
+ 'past': [],
+ 'next': None,
+ 'prev': None,
+ 'cancel': None}
+
+ session = handler(iq, initial_session)
+
+ self._process_command_response(iq, session)
+
+ def _handle_command_next(self, iq):
+ """
+ Process a request for the next step in the workflow
+ for a command with multiple steps.
+
+ Arguments:
+ iq -- The command continuation request.
+ """
+ sessionid = iq['command']['sessionid']
+ session = self.sessions[sessionid]
+
+ handler = session['next']
+ interfaces = session['interfaces']
+ results = []
+ for stanza in iq['command']['substanzas']:
+ if stanza.plugin_attrib in interfaces:
+ results.append(stanza)
+ if len(results) == 1:
+ results = results[0]
+
+ session = handler(results, session)
+
+ self._process_command_response(iq, session)
+
+ def _process_command_response(self, iq, session):
+ """
+ Generate a command reply stanza based on the
+ provided session data.
+
+ Arguments:
+ iq -- The command request stanza.
+ session -- A dictionary of relevant session data.
+ """
+ sessionid = session['id']
+
+ payload = session['payload']
+ if not isinstance(payload, list):
+ payload = [payload]
+
+ session['interfaces'] = [item.plugin_attrib for item in payload]
+ session['payload_classes'] = [item.__class__ for item in payload]
+
+ self.sessions[sessionid] = session
+
+ for item in payload:
+ register_stanza_plugin(Command, item.__class__, iterable=True)
+
+ iq.reply()
+ iq['command']['node'] = session['node']
+ iq['command']['sessionid'] = session['id']
+
+ if session['next'] is None:
+ iq['command']['actions'] = []
+ iq['command']['status'] = 'completed'
+ elif session['has_next']:
+ actions = ['next']
+ if session['allow_complete']:
+ actions.append('complete')
+ if session['allow_prev']:
+ actions.append('prev')
+ iq['command']['actions'] = actions
+ iq['command']['status'] = 'executing'
+ else:
+ iq['command']['actions'] = ['complete']
+ iq['command']['status'] = 'executing'
+
+ iq['command']['notes'] = session['notes']
+
+ for item in payload:
+ iq['command'].append(item)
+
+ iq.send()
+
+ def _handle_command_cancel(self, iq):
+ """
+ Process a request to cancel a command's execution.
+
+ Arguments:
+ iq -- The command cancellation request.
+ """
+ node = iq['command']['node']
+ sessionid = iq['command']['sessionid']
+ session = self.sessions[sessionid]
+ handler = session['cancel']
+
+ if handler:
+ handler(iq, session)
+
+ try:
+ del self.sessions[sessionid]
+ except:
+ pass
+
+ iq.reply()
+ iq['command']['node'] = node
+ iq['command']['sessionid'] = sessionid
+ iq['command']['status'] = 'canceled'
+ iq['command']['notes'] = session['notes']
+ iq.send()
+
+ def _handle_command_complete(self, iq):
+ """
+ Process a request to finish the execution of command
+ and terminate the workflow.
+
+ All data related to the command session will be removed.
+
+ Arguments:
+ iq -- The command completion request.
+ """
+ node = iq['command']['node']
+ sessionid = iq['command']['sessionid']
+ session = self.sessions[sessionid]
+ handler = session['next']
+ interfaces = session['interfaces']
+ results = []
+ for stanza in iq['command']['substanzas']:
+ if stanza.plugin_attrib in interfaces:
+ results.append(stanza)
+ if len(results) == 1:
+ results = results[0]
+
+ if handler:
+ handler(results, session)
+
+ iq.reply()
+ iq['command']['node'] = node
+ iq['command']['sessionid'] = sessionid
+ iq['command']['actions'] = []
+ iq['command']['status'] = 'completed'
+ iq['command']['notes'] = session['notes']
+ iq.send()
+
+ del self.sessions[sessionid]
+
+
+ # =================================================================
+ # Client side (command user) API
+
+ def get_commands(self, jid, **kwargs):
+ """
+ Return a list of commands provided by a given JID.
+
+ Arguments:
+ jid -- The JID to query for commands.
+ local -- If true, then the query is for a JID/node
+ combination handled by this Sleek instance and
+ no stanzas need to be sent.
+ Otherwise, a disco stanza must be sent to the
+ remove JID to retrieve the items.
+ ifrom -- Specifiy the sender's JID.
+ block -- If true, block and wait for the stanzas' reply.
+ timeout -- The time in seconds to block while waiting for
+ a reply. If None, then wait indefinitely.
+ callback -- Optional callback to execute when a reply is
+ received instead of blocking and waiting for
+ the reply.
+ iterator -- If True, return a result set iterator using
+ the XEP-0059 plugin, if the plugin is loaded.
+ Otherwise the parameter is ignored.
+ """
+ return self.xmpp['xep_0030'].get_items(jid=jid,
+ node=Command.namespace,
+ **kwargs)
+
+ def send_command(self, jid, node, ifrom=None, action='execute',
+ payload=None, sessionid=None, **kwargs):
+ """
+ Create and send a command stanza, without using the provided
+ workflow management APIs.
+
+ Arguments:
+ jid -- The JID to send the command request or result.
+ node -- The node for the command.
+ ifrom -- Specify the sender's JID.
+ action -- May be one of: execute, cancel, complete,
+ or cancel.
+ payload -- Either a list of payload items, or a single
+ payload item such as a data form.
+ sessionid -- The current session's ID value.
+ block -- Specify if the send call will block until a
+ response is received, or a timeout occurs.
+ Defaults to True.
+ timeout -- The length of time (in seconds) to wait for a
+ response before exiting the send call
+ if blocking is used. Defaults to
+ sleekxmpp.xmlstream.RESPONSE_TIMEOUT
+ callback -- Optional reference to a stream handler
+ function. Will be executed when a reply
+ stanza is received.
+ """
+ iq = self.xmpp.Iq()
+ iq['type'] = 'set'
+ iq['to'] = jid
+ if ifrom:
+ iq['from'] = ifrom
+ iq['command']['node'] = node
+ iq['command']['action'] = action
+ if sessionid is not None:
+ iq['command']['sessionid'] = sessionid
+ if payload is not None:
+ if not isinstance(payload, list):
+ payload = [payload]
+ for item in payload:
+ iq['command'].append(item)
+ return iq.send(**kwargs)
+
+ def start_command(self, jid, node, session, ifrom=None):
+ """
+ Initiate executing a command provided by a remote agent.
+
+ The workflow provided is always non-blocking.
+
+ The provided session dictionary should contain:
+ next -- A handler for processing the command result.
+ error -- A handler for processing any error stanzas
+ generated by the request.
+
+ Arguments:
+ jid -- The JID to send the command request.
+ node -- The node for the desired command.
+ session -- A dictionary of relevant session data.
+ ifrom -- Optionally specify the sender's JID.
+ """
+ session['jid'] = jid
+ session['node'] = node
+ session['timestamp'] = time.time()
+ session['payload'] = None
+ iq = self.xmpp.Iq()
+ iq['type'] = 'set'
+ iq['to'] = jid
+ if ifrom:
+ iq['from'] = ifrom
+ session['from'] = ifrom
+ iq['command']['node'] = node
+ iq['command']['action'] = 'execute'
+ sessionid = 'client:pending_' + iq['id']
+ session['id'] = sessionid
+ self.sessions[sessionid] = session
+ iq.send(block=False)
+
+ def continue_command(self, session):
+ """
+ Execute the next action of the command.
+
+ Arguments:
+ session -- All stored data relevant to the current
+ command session.
+ """
+ sessionid = 'client:' + session['id']
+ self.sessions[sessionid] = session
+
+ self.send_command(session['jid'],
+ session['node'],
+ ifrom=session.get('from', None),
+ action='next',
+ payload=session.get('payload', None),
+ sessionid=session['id'])
+
+ def cancel_command(self, session):
+ """
+ Cancel the execution of a command.
+
+ Arguments:
+ session -- All stored data relevant to the current
+ command session.
+ """
+ sessionid = 'client:' + session['id']
+ self.sessions[sessionid] = session
+
+ self.send_command(session['jid'],
+ session['node'],
+ ifrom=session.get('from', None),
+ action='cancel',
+ payload=session.get('payload', None),
+ sessionid=session['id'])
+
+ def complete_command(self, session):
+ """
+ Finish the execution of a command workflow.
+
+ Arguments:
+ session -- All stored data relevant to the current
+ command session.
+ """
+ sessionid = 'client:' + session['id']
+ self.sessions[sessionid] = session
+
+ self.send_command(session['jid'],
+ session['node'],
+ ifrom=session.get('from', None),
+ action='complete',
+ payload=session.get('payload', None),
+ sessionid=session['id'])
+
+ def terminate_command(self, session):
+ """
+ Delete a command's session after a command has completed
+ or an error has occured.
+
+ Arguments:
+ session -- All stored data relevant to the current
+ command session.
+ """
+ try:
+ del self.sessions[session['id']]
+ except:
+ pass
+
+ def _handle_command_result(self, iq):
+ """
+ Process the results of a command request.
+
+ Will execute the 'next' handler stored in the session
+ data, or the 'error' handler depending on the Iq's type.
+
+ Arguments:
+ iq -- The command response.
+ """
+ sessionid = 'client:' + iq['command']['sessionid']
+ pending = False
+
+ if sessionid not in self.sessions:
+ pending = True
+ pendingid = 'client:pending_' + iq['id']
+ if pendingid not in self.sessions:
+ return
+ sessionid = pendingid
+
+ session = self.sessions[sessionid]
+ sessionid = 'client:' + iq['command']['sessionid']
+ session['id'] = iq['command']['sessionid']
+
+ self.sessions[sessionid] = session
+
+ if pending:
+ del self.sessions[pendingid]
+
+ handler_type = 'next'
+ if iq['type'] == 'error':
+ handler_type = 'error'
+ handler = session.get(handler_type, None)
+ if handler:
+ handler(iq, session)
+ elif iq['type'] == 'error':
+ self.terminate_command(session)
+
+ if iq['command']['status'] == 'completed':
+ self.terminate_command(session)
diff --git a/sleekxmpp/plugins/xep_0050/stanza.py b/sleekxmpp/plugins/xep_0050/stanza.py
new file mode 100644
index 00000000..31a4a5d5
--- /dev/null
+++ b/sleekxmpp/plugins/xep_0050/stanza.py
@@ -0,0 +1,185 @@
+"""
+ SleekXMPP: The Sleek XMPP Library
+ Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
+ This file is part of SleekXMPP.
+
+ See the file LICENSE for copying permission.
+"""
+
+from sleekxmpp.xmlstream import ElementBase, ET
+
+
+class Command(ElementBase):
+
+ """
+ XMPP's Adhoc Commands provides a generic workflow mechanism for
+ interacting with applications. The result is similar to menu selections
+ and multi-step dialogs in normal desktop applications. Clients do not
+ need to know in advance what commands are provided by any particular
+ application or agent. While adhoc commands provide similar functionality
+ to Jabber-RPC, adhoc commands are used primarily for human interaction.
+
+ Also see <http://xmpp.org/extensions/xep-0050.html>
+
+ Example command stanzas:
+ <iq type="set">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="run_foo"
+ action="execute" />
+ </iq>
+
+ <iq type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="run_foo"
+ sessionid="12345"
+ status="executing">
+ <actions>
+ <complete />
+ </actions>
+ <note type="info">Information!</note>
+ <x xmlns="jabber:x:data">
+ <field var="greeting"
+ type="text-single"
+ label="Greeting" />
+ </x>
+ </command>
+ </iq>
+
+ Stanza Interface:
+ action -- The action to perform.
+ actions -- The set of allowable next actions.
+ node -- The node associated with the command.
+ notes -- A list of tuples for informative notes.
+ sessionid -- A unique identifier for a command session.
+ status -- May be one of: canceled, completed, or executing.
+
+ Attributes:
+ actions -- A set of allowed action values.
+ statuses -- A set of allowed status values.
+ next_actions -- A set of allowed next action names.
+
+ Methods:
+ get_action -- Return the requested action.
+ get_actions -- Return the allowable next actions.
+ set_actions -- Set the allowable next actions.
+ del_actions -- Remove the current set of next actions.
+ get_notes -- Return a list of informative note data.
+ set_notes -- Set informative notes.
+ del_notes -- Remove any note data.
+ add_note -- Add a single note.
+ """
+
+ name = 'command'
+ namespace = 'http://jabber.org/protocol/commands'
+ plugin_attrib = 'command'
+ interfaces = set(('action', 'sessionid', 'node',
+ 'status', 'actions', 'notes'))
+ actions = set(('cancel', 'complete', 'execute', 'next', 'prev'))
+ statuses = set(('canceled', 'completed', 'executing'))
+ next_actions = set(('prev', 'next', 'complete'))
+
+ def get_action(self):
+ """
+ Return the value of the action attribute.
+
+ If the Iq stanza's type is "set" then use a default
+ value of "execute".
+ """
+ if self.parent()['type'] == 'set':
+ return self._get_attr('action', default='execute')
+ return self._get_attr('action')
+
+ def set_actions(self, values):
+ """
+ Assign the set of allowable next actions.
+
+ Arguments:
+ values -- A list containing any combination of:
+ 'prev', 'next', and 'complete'
+ """
+ self.del_actions()
+ if values:
+ self._set_sub_text('{%s}actions' % self.namespace, '', True)
+ actions = self.find('{%s}actions' % self.namespace)
+ for val in values:
+ if val in self.next_actions:
+ action = ET.Element('{%s}%s' % (self.namespace, val))
+ actions.append(action)
+
+ def get_actions(self):
+ """
+ Return the set of allowable next actions.
+ """
+ actions = []
+ actions_xml = self.find('{%s}actions' % self.namespace)
+ if actions_xml is not None:
+ for action in self.next_actions:
+ action_xml = actions_xml.find('{%s}%s' % (self.namespace,
+ action))
+ if action_xml is not None:
+ actions.append(action)
+ return actions
+
+ def del_actions(self):
+ """
+ Remove all allowable next actions.
+ """
+ self._del_sub('{%s}actions' % self.namespace)
+
+ def get_notes(self):
+ """
+ Return a list of note information.
+
+ Example:
+ [('info', 'Some informative data'),
+ ('warning', 'Use caution'),
+ ('error', 'The command ran, but had errors')]
+ """
+ notes = []
+ notes_xml = self.findall('{%s}note' % self.namespace)
+ for note in notes_xml:
+ notes.append((note.attrib.get('type', 'info'),
+ note.text))
+ return notes
+
+ def set_notes(self, notes):
+ """
+ Add multiple notes to the command result.
+
+ Each note is a tuple, with the first item being one of:
+ 'info', 'warning', or 'error', and the second item being
+ any human readable message.
+
+ Example:
+ [('info', 'Some informative data'),
+ ('warning', 'Use caution'),
+ ('error', 'The command ran, but had errors')]
+
+
+ Arguments:
+ notes -- A list of tuples of note information.
+ """
+ self.del_notes()
+ for note in notes:
+ self.add_note(note[1], note[0])
+
+ def del_notes(self):
+ """
+ Remove all notes associated with the command result.
+ """
+ notes_xml = self.findall('{%s}note' % self.namespace)
+ for note in notes_xml:
+ self.xml.remove(note)
+
+ def add_note(self, msg='', ntype='info'):
+ """
+ Add a single note annotation to the command.
+
+ Arguments:
+ msg -- A human readable message.
+ ntype -- One of: 'info', 'warning', 'error'
+ """
+ xml = ET.Element('{%s}note' % self.namespace)
+ xml.attrib['type'] = ntype
+ xml.text = msg
+ self.xml.append(xml)
diff --git a/tests/test_stanza_xep_0050.py b/tests/test_stanza_xep_0050.py
new file mode 100644
index 00000000..ae584de4
--- /dev/null
+++ b/tests/test_stanza_xep_0050.py
@@ -0,0 +1,114 @@
+from sleekxmpp import Iq
+from sleekxmpp.test import *
+from sleekxmpp.plugins.xep_0050 import Command
+
+
+class TestAdHocCommandStanzas(SleekTest):
+
+ def setUp(self):
+ register_stanza_plugin(Iq, Command)
+
+ def testAction(self):
+ """Test using the action attribute."""
+ iq = self.Iq()
+ iq['type'] = 'set'
+ iq['command']['node'] = 'foo'
+
+ iq['command']['action'] = 'execute'
+ self.failUnless(iq['command']['action'] == 'execute')
+
+ iq['command']['action'] = 'complete'
+ self.failUnless(iq['command']['action'] == 'complete')
+
+ iq['command']['action'] = 'cancel'
+ self.failUnless(iq['command']['action'] == 'cancel')
+
+ def testSetActions(self):
+ """Test setting next actions in a command stanza."""
+ iq = self.Iq()
+ iq['type'] = 'result'
+ iq['command']['node'] = 'foo'
+ iq['command']['actions'] = ['prev', 'next']
+
+ self.check(iq, """
+ <iq id="0" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo">
+ <actions>
+ <prev />
+ <next />
+ </actions>
+ </command>
+ </iq>
+ """)
+
+ def testGetActions(self):
+ """Test retrieving next actions from a command stanza."""
+ iq = self.Iq()
+ iq['command']['node'] = 'foo'
+ iq['command']['actions'] = ['prev', 'next']
+
+ results = iq['command']['actions']
+ expected = ['prev', 'next']
+ self.assertEqual(results, expected,
+ "Incorrect next actions: %s" % results)
+
+ def testDelActions(self):
+ """Test removing next actions from a command stanza."""
+ iq = self.Iq()
+ iq['type'] = 'result'
+ iq['command']['node'] = 'foo'
+ iq['command']['actions'] = ['prev', 'next']
+
+ del iq['command']['actions']
+
+ self.check(iq, """
+ <iq id="0" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo" />
+ </iq>
+ """)
+
+ def testAddNote(self):
+ """Test adding a command note."""
+ iq = self.Iq()
+ iq['type'] = 'result'
+ iq['command']['node'] = 'foo'
+ iq['command'].add_note('Danger!', ntype='warning')
+
+ self.check(iq, """
+ <iq id="0" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo">
+ <note type="warning">Danger!</note>
+ </command>
+ </iq>
+ """)
+
+ def testNotes(self):
+ """Test using command notes."""
+ iq = self.Iq()
+ iq['type'] = 'result'
+ iq['command']['node'] = 'foo'
+
+ notes = [('info', 'Interesting...'),
+ ('warning', 'Danger!'),
+ ('error', "I can't let you do that")]
+ iq['command']['notes'] = notes
+
+ self.failUnless(iq['command']['notes'] == notes,
+ "Notes don't match: %s %s" % (notes, iq['command']['notes']))
+
+ self.check(iq, """
+ <iq id="0" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo">
+ <note type="info">Interesting...</note>
+ <note type="warning">Danger!</note>
+ <note type="error">I can't let you do that</note>
+ </command>
+ </iq>
+ """)
+
+
+suite = unittest.TestLoader().loadTestsFromTestCase(TestAdHocCommandStanzas)
diff --git a/tests/test_stream_xep_0050.py b/tests/test_stream_xep_0050.py
new file mode 100644
index 00000000..11b293c8
--- /dev/null
+++ b/tests/test_stream_xep_0050.py
@@ -0,0 +1,686 @@
+import time
+import threading
+
+from sleekxmpp.test import *
+
+
+class TestAdHocCommands(SleekTest):
+
+ def setUp(self):
+ self.stream_start(mode='client',
+ plugins=['xep_0030', 'xep_0004', 'xep_0050'])
+
+ # Real session IDs don't make for nice tests, so use
+ # a dummy value.
+ self.xmpp['xep_0050'].new_session = lambda: '_sessionid_'
+
+ def tearDown(self):
+ self.stream_close()
+
+ def testZeroStepCommand(self):
+ """Test running a command with no steps."""
+
+ def handle_command(iq, session):
+ form = self.xmpp['xep_0004'].makeForm(ftype='result')
+ form.addField(var='foo', ftype='text-single',
+ label='Foo', value='bar')
+
+ session['payload'] = form
+ session['next'] = None
+ session['has_next'] = False
+
+ return session
+
+ self.xmpp['xep_0050'].add_command('tester@localhost', 'foo',
+ 'Do Foo', handle_command)
+
+ self.recv("""
+ <iq id="11" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="execute" />
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="11" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="completed"
+ sessionid="_sessionid_">
+ <x xmlns="jabber:x:data" type="result">
+ <field var="foo" label="Foo" type="text-single">
+ <value>bar</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+
+ def testOneStepCommand(self):
+ """Test running a single step command."""
+ results = []
+
+ def handle_command(iq, session):
+
+ def handle_form(form, session):
+ results.append(form['values']['foo'])
+
+ form = self.xmpp['xep_0004'].makeForm('form')
+ form.addField(var='foo', ftype='text-single', label='Foo')
+
+ session['payload'] = form
+ session['next'] = handle_form
+ session['has_next'] = False
+
+ return session
+
+ self.xmpp['xep_0050'].add_command('tester@localhost', 'foo',
+ 'Do Foo', handle_command)
+
+ self.recv("""
+ <iq id="11" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="execute" />
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="11" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="executing"
+ sessionid="_sessionid_">
+ <actions>
+ <complete />
+ </actions>
+ <x xmlns="jabber:x:data" type="form">
+ <field var="foo" label="Foo" type="text-single" />
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="12" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="complete"
+ sessionid="_sessionid_">
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="foo" label="Foo" type="text-single">
+ <value>blah</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="12" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="completed"
+ sessionid="_sessionid_" />
+ </iq>
+ """)
+
+ self.assertEqual(results, ['blah'],
+ "Command handler was not executed: %s" % results)
+
+ def testTwoStepCommand(self):
+ """Test using a two-stage command."""
+ results = []
+
+ def handle_command(iq, session):
+
+ def handle_step2(form, session):
+ results.append(form['values']['bar'])
+
+ def handle_step1(form, session):
+ results.append(form['values']['foo'])
+
+ form = self.xmpp['xep_0004'].makeForm('form')
+ form.addField(var='bar', ftype='text-single', label='Bar')
+
+ session['payload'] = form
+ session['next'] = handle_step2
+ session['has_next'] = False
+
+ return session
+
+ form = self.xmpp['xep_0004'].makeForm('form')
+ form.addField(var='foo', ftype='text-single', label='Foo')
+
+ session['payload'] = form
+ session['next'] = handle_step1
+ session['has_next'] = True
+
+ return session
+
+ self.xmpp['xep_0050'].add_command('tester@localhost', 'foo',
+ 'Do Foo', handle_command)
+
+ self.recv("""
+ <iq id="11" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="execute" />
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="11" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="executing"
+ sessionid="_sessionid_">
+ <actions>
+ <next />
+ </actions>
+ <x xmlns="jabber:x:data" type="form">
+ <field var="foo" label="Foo" type="text-single" />
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="12" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="next"
+ sessionid="_sessionid_">
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="foo" label="Foo" type="text-single">
+ <value>blah</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="12" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="executing"
+ sessionid="_sessionid_">
+ <actions>
+ <complete />
+ </actions>
+ <x xmlns="jabber:x:data" type="form">
+ <field var="bar" label="Bar" type="text-single" />
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="13" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="complete"
+ sessionid="_sessionid_">
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="bar" label="Bar" type="text-single">
+ <value>meh</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+ self.send("""
+ <iq id="13" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="completed"
+ sessionid="_sessionid_" />
+ </iq>
+ """)
+
+ self.assertEqual(results, ['blah', 'meh'],
+ "Command handler was not executed: %s" % results)
+
+ def testCancelCommand(self):
+ """Test canceling command."""
+ results = []
+
+ def handle_command(iq, session):
+
+ def handle_form(form, session):
+ results.append(form['values']['foo'])
+
+ def handle_cancel(iq, session):
+ results.append('canceled')
+
+ form = self.xmpp['xep_0004'].makeForm('form')
+ form.addField(var='foo', ftype='text-single', label='Foo')
+
+ session['payload'] = form
+ session['next'] = handle_form
+ session['cancel'] = handle_cancel
+ session['has_next'] = False
+
+ return session
+
+ self.xmpp['xep_0050'].add_command('tester@localhost', 'foo',
+ 'Do Foo', handle_command)
+
+ self.recv("""
+ <iq id="11" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="execute" />
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="11" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="executing"
+ sessionid="_sessionid_">
+ <actions>
+ <complete />
+ </actions>
+ <x xmlns="jabber:x:data" type="form">
+ <field var="foo" label="Foo" type="text-single" />
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="12" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="cancel"
+ sessionid="_sessionid_">
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="foo" label="Foo" type="text-single">
+ <value>blah</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="12" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="canceled"
+ sessionid="_sessionid_" />
+ </iq>
+ """)
+
+ self.assertEqual(results, ['canceled'],
+ "Cancelation handler not executed: %s" % results)
+
+ def testCommandNote(self):
+ """Test adding notes to commands."""
+
+ def handle_command(iq, session):
+ form = self.xmpp['xep_0004'].makeForm(ftype='result')
+ form.addField(var='foo', ftype='text-single',
+ label='Foo', value='bar')
+
+ session['payload'] = form
+ session['next'] = None
+ session['has_next'] = False
+ session['notes'] = [('info', 'testing notes')]
+
+ return session
+
+ self.xmpp['xep_0050'].add_command('tester@localhost', 'foo',
+ 'Do Foo', handle_command)
+
+ self.recv("""
+ <iq id="11" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="execute" />
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="11" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="completed"
+ sessionid="_sessionid_">
+ <note type="info">testing notes</note>
+ <x xmlns="jabber:x:data" type="result">
+ <field var="foo" label="Foo" type="text-single">
+ <value>bar</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+
+
+
+ def testMultiPayloads(self):
+ """Test using commands with multiple payloads."""
+ results = []
+
+ def handle_command(iq, session):
+
+ def handle_form(forms, session):
+ for form in forms:
+ results.append(form['values']['FORM_TYPE'])
+
+ form1 = self.xmpp['xep_0004'].makeForm('form')
+ form1.addField(var='FORM_TYPE', ftype='hidden', value='form_1')
+ form1.addField(var='foo', ftype='text-single', label='Foo')
+
+ form2 = self.xmpp['xep_0004'].makeForm('form')
+ form2.addField(var='FORM_TYPE', ftype='hidden', value='form_2')
+ form2.addField(var='foo', ftype='text-single', label='Foo')
+
+ session['payload'] = [form1, form2]
+ session['next'] = handle_form
+ session['has_next'] = False
+
+ return session
+
+ self.xmpp['xep_0050'].add_command('tester@localhost', 'foo',
+ 'Do Foo', handle_command)
+
+ self.recv("""
+ <iq id="11" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="execute" />
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="11" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="executing"
+ sessionid="_sessionid_">
+ <actions>
+ <complete />
+ </actions>
+ <x xmlns="jabber:x:data" type="form">
+ <field var="FORM_TYPE" type="hidden">
+ <value>form_1</value>
+ </field>
+ <field var="foo" label="Foo" type="text-single" />
+ </x>
+ <x xmlns="jabber:x:data" type="form">
+ <field var="FORM_TYPE" type="hidden">
+ <value>form_2</value>
+ </field>
+ <field var="foo" label="Foo" type="text-single" />
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="12" type="set" to="tester@localhost" from="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ action="complete"
+ sessionid="_sessionid_">
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="FORM_TYPE" type="hidden">
+ <value>form_1</value>
+ </field>
+ <field var="foo" type="text-single">
+ <value>bar</value>
+ </field>
+ </x>
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="FORM_TYPE" type="hidden">
+ <value>form_2</value>
+ </field>
+ <field var="foo" type="text-single">
+ <value>bar</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="12" type="result" to="foo@bar">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="foo"
+ status="completed"
+ sessionid="_sessionid_" />
+ </iq>
+ """)
+
+ self.assertEqual(results, [['form_1'], ['form_2']],
+ "Command handler was not executed: %s" % results)
+
+ def testClientAPI(self):
+ """Test using client-side API for commands."""
+ results = []
+
+ def handle_complete(iq, session):
+ for item in session['custom_data']:
+ results.append(item)
+
+ def handle_step2(iq, session):
+ form = self.xmpp['xep_0004'].makeForm(ftype='submit')
+ form.addField(var='bar', value='123')
+
+ session['custom_data'].append('baz')
+ session['payload'] = form
+ session['next'] = handle_complete
+ self.xmpp['xep_0050'].complete_command(session)
+
+ def handle_step1(iq, session):
+ form = self.xmpp['xep_0004'].makeForm(ftype='submit')
+ form.addField(var='foo', value='42')
+
+ session['custom_data'].append('bar')
+ session['payload'] = form
+ session['next'] = handle_step2
+ self.xmpp['xep_0050'].continue_command(session)
+
+ session = {'custom_data': ['foo'],
+ 'next': handle_step1}
+
+ self.xmpp['xep_0050'].start_command(
+ 'foo@example.com',
+ 'test_client',
+ session)
+
+ self.send("""
+ <iq id="1" to="foo@example.com" type="set">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ action="execute" />
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="1" to="foo@example.com" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ sessionid="_sessionid_"
+ status="executing">
+ <x xmlns="jabber:x:data" type="form">
+ <field var="foo" type="text-single" />
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="2" to="foo@example.com" type="set">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ sessionid="_sessionid_"
+ action="next">
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="foo">
+ <value>42</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="2" to="foo@example.com" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ sessionid="_sessionid_"
+ status="executing">
+ <x xmlns="jabber:x:data" type="form">
+ <field var="bar" type="text-single" />
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="3" to="foo@example.com" type="set">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ sessionid="_sessionid_"
+ action="complete">
+ <x xmlns="jabber:x:data" type="submit">
+ <field var="bar">
+ <value>123</value>
+ </field>
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="3" to="foo@example.com" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ sessionid="_sessionid_"
+ status="completed" />
+ </iq>
+ """)
+
+ # Give the event queue time to process
+ time.sleep(0.3)
+
+ self.failUnless(results == ['foo', 'bar', 'baz'],
+ 'Incomplete command workflow: %s' % results)
+
+ def testClientAPICancel(self):
+ """Test using client-side cancel API for commands."""
+ results = []
+
+ def handle_canceled(iq, session):
+ for item in session['custom_data']:
+ results.append(item)
+
+ def handle_step1(iq, session):
+ session['custom_data'].append('bar')
+ session['next'] = handle_canceled
+ self.xmpp['xep_0050'].cancel_command(session)
+
+ session = {'custom_data': ['foo'],
+ 'next': handle_step1}
+
+ self.xmpp['xep_0050'].start_command(
+ 'foo@example.com',
+ 'test_client',
+ session)
+
+ self.send("""
+ <iq id="1" to="foo@example.com" type="set">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ action="execute" />
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="1" to="foo@example.com" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ sessionid="_sessionid_"
+ status="executing">
+ <x xmlns="jabber:x:data" type="form">
+ <field var="foo" type="text-single" />
+ </x>
+ </command>
+ </iq>
+ """)
+
+ self.send("""
+ <iq id="2" to="foo@example.com" type="set">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ sessionid="_sessionid_"
+ action="cancel" />
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="2" to="foo@example.com" type="result">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ sessionid="_sessionid_"
+ status="canceled" />
+ </iq>
+ """)
+
+ # Give the event queue time to process
+ time.sleep(0.3)
+
+ self.failUnless(results == ['foo', 'bar'],
+ 'Incomplete command workflow: %s' % results)
+
+ def testClientAPIError(self):
+ """Test using client-side error API for commands."""
+ results = []
+
+ def handle_error(iq, session):
+ for item in session['custom_data']:
+ results.append(item)
+
+ session = {'custom_data': ['foo'],
+ 'error': handle_error}
+
+ self.xmpp['xep_0050'].start_command(
+ 'foo@example.com',
+ 'test_client',
+ session)
+
+ self.send("""
+ <iq id="1" to="foo@example.com" type="set">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ action="execute" />
+ </iq>
+ """)
+
+ self.recv("""
+ <iq id="1" to="foo@example.com" type="error">
+ <command xmlns="http://jabber.org/protocol/commands"
+ node="test_client"
+ action="execute" />
+ <error type='cancel'>
+ <item-not-found xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+ </error>
+ </iq>
+ """)
+
+ # Give the event queue time to process
+ time.sleep(0.3)
+
+ self.failUnless(results == ['foo'],
+ 'Incomplete command workflow: %s' % results)
+
+
+
+suite = unittest.TestLoader().loadTestsFromTestCase(TestAdHocCommands)