plugin XEP-0050: Ad-Hoc commands first draft (answering part)
This commit is contained in:
parent
05bf17c777
commit
f7efb167c8
1 changed files with 381 additions and 5 deletions
356
src/plugins/plugin_xep_0050.py
Normal file
356
src/plugins/plugin_xep_0050.py
Normal 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
|
||||
Loading…
Reference in a new issue