Mercurial > libervia-backend
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