changeset 728:e07afabc4a25

plugin XEP-0050: Ad-Hoc commands first draft (answering part)
author Goffi <goffi@goffi.org>
date Tue, 10 Dec 2013 17:25:31 +0100
parents c1cd6c0c2c38
children 8f50a0079769
files src/core/exceptions.py src/core/sat_main.py src/memory/memory.py src/plugins/plugin_xep_0050.py
diffstat 4 files changed, 381 insertions(+), 5 deletions(-) [+]
line wrap: on
line diff
--- a/src/core/exceptions.py	Tue Dec 10 17:25:31 2013 +0100
+++ b/src/core/exceptions.py	Tue Dec 10 17:25:31 2013 +0100
@@ -37,6 +37,10 @@
     pass
 
 
+class ProfileKeyUnknownError(Exception):
+    pass
+
+
 class UnknownEntityError(Exception):
     pass
 
--- a/src/core/sat_main.py	Tue Dec 10 17:25:31 2013 +0100
+++ b/src/core/sat_main.py	Tue Dec 10 17:25:31 2013 +0100
@@ -341,6 +341,19 @@
             return None
         return self.profiles[profile]
 
+    def getClients(self, profile_key):
+        """Convenient method to get list of clients from profile key (manage list through profile_key like @ALL@)
+        @param profile_key: %(doc_profile_key)s
+        @return: list of clients"""
+        profile = self.memory.getProfileName(profile_key, True)
+        if not profile:
+            return []
+        if profile == "@ALL@":
+            return self.profiles.values()
+        if profile.count('@') > 1:
+            raise exceptions.ProfileKeyUnknownError
+        return [self.profiles[profile]]
+
     def registerNewAccount(self, login, password, email, server, port=5222, id=None, profile_key='@DEFAULT@'):
         """Connect to a server and create a new account using in-band registration"""
         profile = self.memory.getProfileName(profile_key)
--- a/src/memory/memory.py	Tue Dec 10 17:25:31 2013 +0100
+++ b/src/memory/memory.py	Tue Dec 10 17:25:31 2013 +0100
@@ -181,12 +181,13 @@
         self.storage.deleteProfile(profile)
         return False
 
-    def getProfileName(self, profile_key):
+    def getProfileName(self, profile_key, return_profile_keys = False):
         """return profile according to profile_key
         @param profile_key: profile name or key which can be
                             @ALL@ for all profiles
                             @DEFAULT@ for default profile
-        @return: requested profile name or None if it doesn't exist"""
+        @param return_profile_keys: if True, return unmanaged profile keys (like "@ALL@"). This keys must be managed by the caller
+        @return: requested profile name or emptry string if it doesn't exist"""
         if profile_key == '@DEFAULT@':
             default = self.host.memory.memory_data.get('Profile_default')
             if not default:
@@ -199,9 +200,11 @@
             return default  # FIXME: temporary, must use real default value, and fallback to first one if it doesn't exists
         elif profile_key == '@NONE@':
             raise exceptions.ProfileNotSetError
+        elif return_profile_keys and profile_key in ["@ALL@"]:
+            return profile_key # this value must be managed by the caller
         if not self.storage.hasProfile(profile_key):
             info(_('Trying to access an unknown profile'))
-            return ""
+            return "" # FIXME: raise exceptions.ProfileUnknownError here (must be well checked, this method is used in lot of places)
         return profile_key
 
     def __get_unique_node(self, parent, tag, name):
@@ -711,11 +714,11 @@
     def getProfilesList(self):
         return self.storage.getProfilesList()
 
-    def getProfileName(self, profile_key):
+    def getProfileName(self, profile_key, return_profile_keys = False):
         """Return name of profile from keyword
         @param profile_key: can be the profile name or a keywork (like @DEFAULT@)
         @return: profile name or None if it doesn't exist"""
-        return self.params.getProfileName(profile_key)
+        return self.params.getProfileName(profile_key, return_profile_keys)
 
     def createProfile(self, name):
         """Create a new profile
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/plugins/plugin_xep_0050.py	Tue Dec 10 17:25:31 2013 +0100
@@ -0,0 +1,356 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# SAT plugin for Ad-Hoc Commands (XEP-0050)
+# Copyright (C) 2009, 2010, 2011, 2012, 2013 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from logging import debug, info, warning, error
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error as xmpp_error
+from twisted.words.xish import domish
+from twisted.internet import defer, reactor
+from wokkel import disco, iwokkel, data_form
+from sat.core import exceptions
+from uuid import uuid4
+
+from zope.interface import implements
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+from collections import namedtuple
+
+try:
+    from collections import OrderedDict # only available from python 2.7
+except ImportError:
+    from ordereddict import OrderedDict
+
+IQ_SET = '/iq[@type="set"]'
+NS_COMMANDS = "http://jabber.org/protocol/commands"
+ID_CMD_LIST = disco.DiscoIdentity("automation", "command-list")
+ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node")
+CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]'
+
+SHOWS = OrderedDict([('default', _('Online')),
+                     ('away', _('Away')),
+                     ('chat', _('Free for chat')),
+                     ('dnd', _('Do not disturb')),
+                     ('xa', _('Left')),
+                     ('disconnect', _('Disconnect'))])
+
+PLUGIN_INFO = {
+    "name": "Ad-Hoc Commands",
+    "import_name": "XEP-0050",
+    "type": "XEP",
+    "protocols": ["XEP-0050"],
+    "main": "XEP_0050",
+    "handler": "yes",
+    "description": _("""Implementation of Ad-Hoc Commands""")
+}
+
+
+class AdHocError(Exception):
+
+    def __init__(self, error_const):
+        """ Error to be used from callback
+        @param error_const: one of XEP_0050.ERROR
+        """
+        assert(error_const in XEP_0050.ERROR)
+        self.callback_error = error_const
+
+class AdHocCommand(XMPPHandler):
+    implements(iwokkel.IDisco)
+
+    def  __init__(self, parent, callback, label, node, features, timeout, allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, client):
+        self.parent = parent
+        self.callback = callback
+        self.label = label
+        self.node = node
+        self.features = [disco.DiscoFeature(feature) for feature in features]
+        self.timeout = timeout
+        self.allowed_jids, self.allowed_groups, self.allowed_magics, self.forbidden_jids, self.forbidden_groups = allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups
+        self.client = client
+        self.sessions = {}
+
+    def getName(self, xml_lang=None):
+        return self.label
+
+    def isAuthorised(self, requestor):
+        if '@ALL@' in self.allowed_magics:
+            return True
+        forbidden = set(self.forbidden_jids)
+        for group in self.forbidden_groups:
+            forbidden.update(self.client.roster.getJidsFromGroup(group))
+        if requestor.userhostJID() in forbidden:
+            return False
+        allowed = set(self.allowed_jids)
+        for group in self.allowed_groups:
+            allowed.update(self.client.roster.getJidsFromGroup(group))
+        if requestor.userhostJID() in allowed:
+            return True
+        return False
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
+        identities = [ID_CMD_LIST if self.node == NS_COMMANDS else ID_CMD_NODE]
+        return [disco.DiscoFeature(NS_COMMANDS)] + self.features
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
+        return []
+
+    def _sendAnswer(self, callback_data, session_id, request):
+        """ Send result of the command
+        @param callback_data: tuple (payload, status, actions, note) with:
+            - payload (domish.Element) usualy containing data form
+            - status: current status, see XEP_0050.STATUS
+            - actions: list of allowed actions (see XEP_0050.ACTION). First action is the default one. Default to EXECUTE
+            - note: optional additional note: either None or a tuple with (note type, human readable string),
+                    note type being in XEP_0050.NOTE
+        @param session_id: current session id
+        @param request: original request (domish.Element)
+        @return: deferred
+        """
+        payload, status, actions, note = callback_data
+        assert(isinstance(payload, domish.Element) or payload is None)
+        assert(status in XEP_0050.STATUS)
+        if not actions:
+            actions = [XEP_0050.ACTION.EXECUTE]
+        result = domish.Element((None, 'iq'))
+        result['type'] = 'result'
+        result['id'] = request['id']
+        result['to'] = request['from']
+        command_elt = result.addElement('command', NS_COMMANDS)
+        command_elt['sessionid'] = session_id
+        command_elt['node'] = self.node
+        command_elt['status'] = status
+
+        if status != XEP_0050.STATUS.CANCELED:
+            if status != XEP_0050.STATUS.COMPLETED:
+                actions_elt = command_elt.addElement('actions')
+                actions_elt['execute'] = actions[0]
+                for action in actions:
+                    actions_elt.addElement(action)
+
+            if note is not None:
+                note_type, note_mess = note
+                note_elt = command_elt.addElement('note', content=note_mess)
+                note_elt['type'] = note_type
+
+            if payload is not None:
+                command_elt.addChild(payload)
+
+        self.client.xmlstream.send(result)
+        if status in (XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED):
+            timer = self.sessions[session_id][0]
+            timer.cancel()
+            self._purgeSession(session_id)
+
+    def _sendError(self, error_constant, session_id, request):
+        """ Send error stanza
+        @param error_constant: one of XEP_OO50.ERROR
+        @param request: original request (domish.Element)
+        """
+        xmpp_condition, cmd_condition = error_constant
+        iq_elt = xmpp_error.StanzaError(xmpp_condition).toResponse(request)
+        if cmd_condition:
+            error_elt = iq_elt.elements(None, "error").next()
+            error_elt.addElement(cmd_condition, NS_COMMANDS)
+        self.client.xmlstream.send(iq_elt)
+        try:
+            timer = self.sessions[session_id][0]
+            timer.cancel()
+            self._purgeSession(session_id)
+        except KeyError:
+            pass
+
+    def _purgeSession(self, session_id):
+        del self.sessions[session_id]
+
+    def onRequest(self, command_elt, requestor, action, session_id):
+        if not self.isAuthorised(requestor):
+            return self._sendError(XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent)
+        if session_id:
+            try:
+                timer, session_data = self.sessions[session_id]
+            except KeyError:
+                return self._sendError(XEP_0050.ERROR.SESSION_EXPIRED, session_id, command_elt.parent)
+            if session_data['requestor'] != requestor:
+                return self._sendError(XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent)
+            timer.reset(self.timeout)
+        else:
+            # we are starting a new session
+            session_id = str(uuid4())
+            session_data  = {'requestor': requestor}
+            timer = reactor.callLater(self.timeout, self._purgeSession, session_id)
+            self.sessions[session_id] = (timer, session_data)
+        if action == XEP_0050.ACTION.CANCEL:
+            d = defer.succeed((None, XEP_0050.STATUS.CANCELED, None, None))
+        else:
+            d = defer.maybeDeferred(self.callback, command_elt, session_data, action, self.node, self.client.profile)
+        d.addCallback(self._sendAnswer, session_id, command_elt.parent)
+        d.addErrback(lambda failure, request: self._sendError(failure.value.callback_error, session_id, request), command_elt.parent)
+
+
+class XEP_0050(object):
+    STATUS = namedtuple('Status', ('EXECUTING', 'COMPLETED', 'CANCELED'))('executing', 'completed', 'canceled')
+    ACTION = namedtuple('Action', ('EXECUTE', 'CANCEL', 'NEXT', 'PREV'))('execute', 'cancel', 'next', 'prev')
+    NOTE = namedtuple('Note', ('INFO','WARN','ERROR'))('info','warn','error')
+    ERROR = namedtuple('Error', ('MALFORMED_ACTION', 'BAD_ACTION', 'BAD_LOCALE', 'BAD_PAYLOAD', 'BAD_SESSIONID', 'SESSION_EXPIRED',
+                                 'FORBIDDEN', 'ITEM_NOT_FOUND', 'FEATURE_NOT_IMPLEMENTED', 'INTERNAL'))(('bad-request', 'malformed-action'),
+                                 ('bad-request', 'bad-action'), ('bad-request', 'bad-locale'), ('bad-request','bad-payload'),
+                                 ('bad-request','bad-sessionid'), ('not-allowed','session-expired'), ('forbidden', None),
+                                 ('item-not-found', None), ('feature-not-implemented', None), ('internal-server-error', None)) # XEP-0050 §4.6 Table 5
+
+    def __init__(self, host):
+        info(_("plugin XEP-0050 initialization"))
+        self.host = host
+        self.requesting = {}
+        self.answering = {}
+
+    def getHandler(self, profile):
+        return XEP_0050_handler(self)
+
+    def profileConnected(self, profile):
+        self.addAdHocCommand(self._statusCallback, _("Status"), profile_key="@ALL@")
+
+    def _statusCallback(self, command_elt, session_data, action, node, profile):
+        """ Ad-hoc command used to change the "show" part of status """
+        actions = session_data.setdefault('actions',[])
+        actions.append(action)
+
+        if len(actions) == 1:
+            status = XEP_0050.STATUS.EXECUTING
+            form = data_form.Form('form', title=_('status selection'))
+            show_options = [data_form.Option(name, label) for name, label in SHOWS.items()]
+            field = data_form.Field('list-single', 'show', options=show_options, required=True)
+            form.addField(field)
+
+            payload = form.toElement()
+            note = None
+
+        elif len(actions) == 2: # we should have the answer here
+            try:
+                x_elt = command_elt.elements(data_form.NS_X_DATA,'x').next()
+                answer_form = data_form.Form.fromElement(x_elt)
+                show = answer_form['show']
+            except KeyError, StopIteration:
+                raise AdHocError(XEP_0050.ERROR.BAD_PAYLOAD)
+            if show not in SHOWS:
+                raise AdHocError(XEP_0050.ERROR.BAD_PAYLOAD)
+            if show == "disconnect":
+               self.host.disconnect(profile)
+            else:
+                self.host.setPresence(show=show, profile_key=profile)
+
+            # job done, we can end the session
+            form = data_form.Form('form', title=_(u'Updated'))
+            form.addField(data_form.Field('fixed', u'Status updated'))
+            status = XEP_0050.STATUS.COMPLETED
+            payload = None
+            note = (self.NOTE.INFO, _(u"Status updated"))
+        else:
+            raise AdHocError(XEP_0050.ERROR.INTERNAL)
+
+        return (payload, status, None, note)
+
+    def addAdHocCommand(self, callback, label, node="", features = None, timeout = 600, allowed_jids = None, allowed_groups = None,
+                        allowed_magics = None, forbidden_jids = None, forbidden_groups = None, profile_key="@NONE@"):
+        """
+
+        Add an ad-hoc command for the current profile
+
+        @param callback: method associated with this ad-hoc command which return the payload data (see AdHocCommand._sendAnswer), can return a deferred
+        @param label: label associated with this command on the main menu
+        @param node: disco item node associated with this command. None or "" to use autogenerated node
+        @param features: features associated with the payload (list of strings), usualy data form
+        @param timeout: delay between two requests before canceling the session (in seconds)
+        @param allowed_jids: list of allowed entities
+        @param allowed_groups: list of allowed roster groups
+        @param allowed_magics: list of allowed magic keys, can be:
+                               @ALL@: allow everybody
+                               @PROFILE_BAREJID@: allow only the jid of the profile
+        @param forbidden_jids: black list of entities which can't access this command
+        @param forbidden_groups: black list of groups which can't access this command
+        @param profile_key: profile key associated with this command, @ALL@ means can be accessed with every profiles
+        @return: node of the added command, useful to remove the command later
+        """
+
+        node = node.strip()
+        if not node:
+            node = "%s_%s" % ('COMMANDS', uuid4())
+
+        if features is None:
+            features = [data_form.NS_X_DATA]
+
+        if allowed_jids is None:
+            allowed_jids = []
+        if allowed_groups is None:
+            allowed_groups = []
+        if allowed_magics is None:
+            allowed_magics = ['@PROFILE_BAREJID@']
+        if forbidden_jids is None:
+            forbidden_jids = []
+        if forbidden_groups is None:
+            forbidden_groups = []
+
+        for client in self.host.getClients(profile_key):
+            #TODO: manage newly created/removed profiles
+            _allowed_jids = (allowed_jids + [client.jid.userhostJID()]) if '@PROFILE_BAREJID@' in allowed_magics else allowed_jids
+            ad_hoc_command = AdHocCommand(self, callback, label, node, features, timeout, _allowed_jids,
+                                          allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, client)
+            ad_hoc_command.setHandlerParent(client)
+            profile_commands = self.answering.setdefault(client.profile, {})
+            profile_commands[node] = ad_hoc_command
+
+    def onCmdRequest(self, request, profile):
+        request.handled = True
+        requestor = jid.JID(request['from'])
+        command_elt = request.elements(NS_COMMANDS, 'command').next()
+        action = command_elt.getAttribute('action', self.ACTION.EXECUTE)
+        node = command_elt.getAttribute('node')
+        if not node:
+            raise exceptions.DataError
+        sessionid = command_elt.getAttribute('sessionid')
+        try:
+            command = self.answering[profile][node]
+        except KeyError:
+            raise exceptions.DataError
+        command.onRequest(command_elt, requestor, action, sessionid)
+
+
+class XEP_0050_handler(XMPPHandler):
+    implements(iwokkel.IDisco)
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(CMD_REQUEST, self.plugin_parent.onCmdRequest, profile=self.parent.profile)
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
+        identities = []
+        if nodeIdentifier == NS_COMMANDS and self.plugin_parent.answering.get(self.parent.profile): # we only add the identity if we have registred commands
+            identities.append(ID_CMD_LIST)
+        return [disco.DiscoFeature(NS_COMMANDS)] + identities
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
+        ret = []
+        if nodeIdentifier == NS_COMMANDS:
+            for command in self.plugin_parent.answering[self.parent.profile].values():
+                if command.isAuthorised(requestor):
+                    ret.append(disco.DiscoItem(self.parent.jid, command.node, command.getName())) #TODO: manage name language
+        return ret