view src/plugins/plugin_xep_0050.py @ 751:1def5b7edf9f

core, bridge: better GenericException handling
author Goffi <goffi@goffi.org>
date Tue, 17 Dec 2013 00:56:39 +0100
parents e07afabc4a25
children 7f98f53f6997
line wrap: on
line source

#!/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