plugin XEP-0050: Ad-Hoc commands first draft (answering part)

This commit is contained in:
Goffi 2013-12-10 17:25:31 +01:00
commit f7efb167c8
4 changed files with 381 additions and 5 deletions

View file

@ -37,6 +37,10 @@ class NotConnectedProfileError(Exception):
pass
class ProfileKeyUnknownError(Exception):
pass
class UnknownEntityError(Exception):
pass

View file

@ -341,6 +341,19 @@ class SAT(service.Service):
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)

View file

@ -181,12 +181,13 @@ class Params(object):
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 @@ class Params(object):
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 @@ class Memory(object):
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

View file

@ -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