# HG changeset patch # User Goffi # Date 1386692731 -3600 # Node ID e07afabc4a25fce28ebcfc3d63b180430840a426 # Parent c1cd6c0c2c38cee1487afe1986660b667b49f672 plugin XEP-0050: Ad-Hoc commands first draft (answering part) diff -r c1cd6c0c2c38 -r e07afabc4a25 src/core/exceptions.py --- 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 diff -r c1cd6c0c2c38 -r e07afabc4a25 src/core/sat_main.py --- 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) diff -r c1cd6c0c2c38 -r e07afabc4a25 src/memory/memory.py --- 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 diff -r c1cd6c0c2c38 -r e07afabc4a25 src/plugins/plugin_xep_0050.py --- /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 . + +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