comparison src/plugins/plugin_xep_0050.py @ 728:e07afabc4a25

plugin XEP-0050: Ad-Hoc commands first draft (answering part)
author Goffi <goffi@goffi.org>
date Tue, 10 Dec 2013 17:25:31 +0100
parents
children 7f98f53f6997
comparison
equal deleted inserted replaced
727:c1cd6c0c2c38 728:e07afabc4a25
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for Ad-Hoc Commands (XEP-0050)
5 # Copyright (C) 2009, 2010, 2011, 2012, 2013 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from logging import debug, info, warning, error
21 from twisted.words.protocols.jabber import jid
22 from twisted.words.protocols.jabber import error as xmpp_error
23 from twisted.words.xish import domish
24 from twisted.internet import defer, reactor
25 from wokkel import disco, iwokkel, data_form
26 from sat.core import exceptions
27 from uuid import uuid4
28
29 from zope.interface import implements
30
31 try:
32 from twisted.words.protocols.xmlstream import XMPPHandler
33 except ImportError:
34 from wokkel.subprotocols import XMPPHandler
35
36 from collections import namedtuple
37
38 try:
39 from collections import OrderedDict # only available from python 2.7
40 except ImportError:
41 from ordereddict import OrderedDict
42
43 IQ_SET = '/iq[@type="set"]'
44 NS_COMMANDS = "http://jabber.org/protocol/commands"
45 ID_CMD_LIST = disco.DiscoIdentity("automation", "command-list")
46 ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node")
47 CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]'
48
49 SHOWS = OrderedDict([('default', _('Online')),
50 ('away', _('Away')),
51 ('chat', _('Free for chat')),
52 ('dnd', _('Do not disturb')),
53 ('xa', _('Left')),
54 ('disconnect', _('Disconnect'))])
55
56 PLUGIN_INFO = {
57 "name": "Ad-Hoc Commands",
58 "import_name": "XEP-0050",
59 "type": "XEP",
60 "protocols": ["XEP-0050"],
61 "main": "XEP_0050",
62 "handler": "yes",
63 "description": _("""Implementation of Ad-Hoc Commands""")
64 }
65
66
67 class AdHocError(Exception):
68
69 def __init__(self, error_const):
70 """ Error to be used from callback
71 @param error_const: one of XEP_0050.ERROR
72 """
73 assert(error_const in XEP_0050.ERROR)
74 self.callback_error = error_const
75
76 class AdHocCommand(XMPPHandler):
77 implements(iwokkel.IDisco)
78
79 def __init__(self, parent, callback, label, node, features, timeout, allowed_jids, allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, client):
80 self.parent = parent
81 self.callback = callback
82 self.label = label
83 self.node = node
84 self.features = [disco.DiscoFeature(feature) for feature in features]
85 self.timeout = timeout
86 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
87 self.client = client
88 self.sessions = {}
89
90 def getName(self, xml_lang=None):
91 return self.label
92
93 def isAuthorised(self, requestor):
94 if '@ALL@' in self.allowed_magics:
95 return True
96 forbidden = set(self.forbidden_jids)
97 for group in self.forbidden_groups:
98 forbidden.update(self.client.roster.getJidsFromGroup(group))
99 if requestor.userhostJID() in forbidden:
100 return False
101 allowed = set(self.allowed_jids)
102 for group in self.allowed_groups:
103 allowed.update(self.client.roster.getJidsFromGroup(group))
104 if requestor.userhostJID() in allowed:
105 return True
106 return False
107
108 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
109 identities = [ID_CMD_LIST if self.node == NS_COMMANDS else ID_CMD_NODE]
110 return [disco.DiscoFeature(NS_COMMANDS)] + self.features
111
112 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
113 return []
114
115 def _sendAnswer(self, callback_data, session_id, request):
116 """ Send result of the command
117 @param callback_data: tuple (payload, status, actions, note) with:
118 - payload (domish.Element) usualy containing data form
119 - status: current status, see XEP_0050.STATUS
120 - actions: list of allowed actions (see XEP_0050.ACTION). First action is the default one. Default to EXECUTE
121 - note: optional additional note: either None or a tuple with (note type, human readable string),
122 note type being in XEP_0050.NOTE
123 @param session_id: current session id
124 @param request: original request (domish.Element)
125 @return: deferred
126 """
127 payload, status, actions, note = callback_data
128 assert(isinstance(payload, domish.Element) or payload is None)
129 assert(status in XEP_0050.STATUS)
130 if not actions:
131 actions = [XEP_0050.ACTION.EXECUTE]
132 result = domish.Element((None, 'iq'))
133 result['type'] = 'result'
134 result['id'] = request['id']
135 result['to'] = request['from']
136 command_elt = result.addElement('command', NS_COMMANDS)
137 command_elt['sessionid'] = session_id
138 command_elt['node'] = self.node
139 command_elt['status'] = status
140
141 if status != XEP_0050.STATUS.CANCELED:
142 if status != XEP_0050.STATUS.COMPLETED:
143 actions_elt = command_elt.addElement('actions')
144 actions_elt['execute'] = actions[0]
145 for action in actions:
146 actions_elt.addElement(action)
147
148 if note is not None:
149 note_type, note_mess = note
150 note_elt = command_elt.addElement('note', content=note_mess)
151 note_elt['type'] = note_type
152
153 if payload is not None:
154 command_elt.addChild(payload)
155
156 self.client.xmlstream.send(result)
157 if status in (XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED):
158 timer = self.sessions[session_id][0]
159 timer.cancel()
160 self._purgeSession(session_id)
161
162 def _sendError(self, error_constant, session_id, request):
163 """ Send error stanza
164 @param error_constant: one of XEP_OO50.ERROR
165 @param request: original request (domish.Element)
166 """
167 xmpp_condition, cmd_condition = error_constant
168 iq_elt = xmpp_error.StanzaError(xmpp_condition).toResponse(request)
169 if cmd_condition:
170 error_elt = iq_elt.elements(None, "error").next()
171 error_elt.addElement(cmd_condition, NS_COMMANDS)
172 self.client.xmlstream.send(iq_elt)
173 try:
174 timer = self.sessions[session_id][0]
175 timer.cancel()
176 self._purgeSession(session_id)
177 except KeyError:
178 pass
179
180 def _purgeSession(self, session_id):
181 del self.sessions[session_id]
182
183 def onRequest(self, command_elt, requestor, action, session_id):
184 if not self.isAuthorised(requestor):
185 return self._sendError(XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent)
186 if session_id:
187 try:
188 timer, session_data = self.sessions[session_id]
189 except KeyError:
190 return self._sendError(XEP_0050.ERROR.SESSION_EXPIRED, session_id, command_elt.parent)
191 if session_data['requestor'] != requestor:
192 return self._sendError(XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent)
193 timer.reset(self.timeout)
194 else:
195 # we are starting a new session
196 session_id = str(uuid4())
197 session_data = {'requestor': requestor}
198 timer = reactor.callLater(self.timeout, self._purgeSession, session_id)
199 self.sessions[session_id] = (timer, session_data)
200 if action == XEP_0050.ACTION.CANCEL:
201 d = defer.succeed((None, XEP_0050.STATUS.CANCELED, None, None))
202 else:
203 d = defer.maybeDeferred(self.callback, command_elt, session_data, action, self.node, self.client.profile)
204 d.addCallback(self._sendAnswer, session_id, command_elt.parent)
205 d.addErrback(lambda failure, request: self._sendError(failure.value.callback_error, session_id, request), command_elt.parent)
206
207
208 class XEP_0050(object):
209 STATUS = namedtuple('Status', ('EXECUTING', 'COMPLETED', 'CANCELED'))('executing', 'completed', 'canceled')
210 ACTION = namedtuple('Action', ('EXECUTE', 'CANCEL', 'NEXT', 'PREV'))('execute', 'cancel', 'next', 'prev')
211 NOTE = namedtuple('Note', ('INFO','WARN','ERROR'))('info','warn','error')
212 ERROR = namedtuple('Error', ('MALFORMED_ACTION', 'BAD_ACTION', 'BAD_LOCALE', 'BAD_PAYLOAD', 'BAD_SESSIONID', 'SESSION_EXPIRED',
213 'FORBIDDEN', 'ITEM_NOT_FOUND', 'FEATURE_NOT_IMPLEMENTED', 'INTERNAL'))(('bad-request', 'malformed-action'),
214 ('bad-request', 'bad-action'), ('bad-request', 'bad-locale'), ('bad-request','bad-payload'),
215 ('bad-request','bad-sessionid'), ('not-allowed','session-expired'), ('forbidden', None),
216 ('item-not-found', None), ('feature-not-implemented', None), ('internal-server-error', None)) # XEP-0050 §4.6 Table 5
217
218 def __init__(self, host):
219 info(_("plugin XEP-0050 initialization"))
220 self.host = host
221 self.requesting = {}
222 self.answering = {}
223
224 def getHandler(self, profile):
225 return XEP_0050_handler(self)
226
227 def profileConnected(self, profile):
228 self.addAdHocCommand(self._statusCallback, _("Status"), profile_key="@ALL@")
229
230 def _statusCallback(self, command_elt, session_data, action, node, profile):
231 """ Ad-hoc command used to change the "show" part of status """
232 actions = session_data.setdefault('actions',[])
233 actions.append(action)
234
235 if len(actions) == 1:
236 status = XEP_0050.STATUS.EXECUTING
237 form = data_form.Form('form', title=_('status selection'))
238 show_options = [data_form.Option(name, label) for name, label in SHOWS.items()]
239 field = data_form.Field('list-single', 'show', options=show_options, required=True)
240 form.addField(field)
241
242 payload = form.toElement()
243 note = None
244
245 elif len(actions) == 2: # we should have the answer here
246 try:
247 x_elt = command_elt.elements(data_form.NS_X_DATA,'x').next()
248 answer_form = data_form.Form.fromElement(x_elt)
249 show = answer_form['show']
250 except KeyError, StopIteration:
251 raise AdHocError(XEP_0050.ERROR.BAD_PAYLOAD)
252 if show not in SHOWS:
253 raise AdHocError(XEP_0050.ERROR.BAD_PAYLOAD)
254 if show == "disconnect":
255 self.host.disconnect(profile)
256 else:
257 self.host.setPresence(show=show, profile_key=profile)
258
259 # job done, we can end the session
260 form = data_form.Form('form', title=_(u'Updated'))
261 form.addField(data_form.Field('fixed', u'Status updated'))
262 status = XEP_0050.STATUS.COMPLETED
263 payload = None
264 note = (self.NOTE.INFO, _(u"Status updated"))
265 else:
266 raise AdHocError(XEP_0050.ERROR.INTERNAL)
267
268 return (payload, status, None, note)
269
270 def addAdHocCommand(self, callback, label, node="", features = None, timeout = 600, allowed_jids = None, allowed_groups = None,
271 allowed_magics = None, forbidden_jids = None, forbidden_groups = None, profile_key="@NONE@"):
272 """
273
274 Add an ad-hoc command for the current profile
275
276 @param callback: method associated with this ad-hoc command which return the payload data (see AdHocCommand._sendAnswer), can return a deferred
277 @param label: label associated with this command on the main menu
278 @param node: disco item node associated with this command. None or "" to use autogenerated node
279 @param features: features associated with the payload (list of strings), usualy data form
280 @param timeout: delay between two requests before canceling the session (in seconds)
281 @param allowed_jids: list of allowed entities
282 @param allowed_groups: list of allowed roster groups
283 @param allowed_magics: list of allowed magic keys, can be:
284 @ALL@: allow everybody
285 @PROFILE_BAREJID@: allow only the jid of the profile
286 @param forbidden_jids: black list of entities which can't access this command
287 @param forbidden_groups: black list of groups which can't access this command
288 @param profile_key: profile key associated with this command, @ALL@ means can be accessed with every profiles
289 @return: node of the added command, useful to remove the command later
290 """
291
292 node = node.strip()
293 if not node:
294 node = "%s_%s" % ('COMMANDS', uuid4())
295
296 if features is None:
297 features = [data_form.NS_X_DATA]
298
299 if allowed_jids is None:
300 allowed_jids = []
301 if allowed_groups is None:
302 allowed_groups = []
303 if allowed_magics is None:
304 allowed_magics = ['@PROFILE_BAREJID@']
305 if forbidden_jids is None:
306 forbidden_jids = []
307 if forbidden_groups is None:
308 forbidden_groups = []
309
310 for client in self.host.getClients(profile_key):
311 #TODO: manage newly created/removed profiles
312 _allowed_jids = (allowed_jids + [client.jid.userhostJID()]) if '@PROFILE_BAREJID@' in allowed_magics else allowed_jids
313 ad_hoc_command = AdHocCommand(self, callback, label, node, features, timeout, _allowed_jids,
314 allowed_groups, allowed_magics, forbidden_jids, forbidden_groups, client)
315 ad_hoc_command.setHandlerParent(client)
316 profile_commands = self.answering.setdefault(client.profile, {})
317 profile_commands[node] = ad_hoc_command
318
319 def onCmdRequest(self, request, profile):
320 request.handled = True
321 requestor = jid.JID(request['from'])
322 command_elt = request.elements(NS_COMMANDS, 'command').next()
323 action = command_elt.getAttribute('action', self.ACTION.EXECUTE)
324 node = command_elt.getAttribute('node')
325 if not node:
326 raise exceptions.DataError
327 sessionid = command_elt.getAttribute('sessionid')
328 try:
329 command = self.answering[profile][node]
330 except KeyError:
331 raise exceptions.DataError
332 command.onRequest(command_elt, requestor, action, sessionid)
333
334
335 class XEP_0050_handler(XMPPHandler):
336 implements(iwokkel.IDisco)
337
338 def __init__(self, plugin_parent):
339 self.plugin_parent = plugin_parent
340
341 def connectionInitialized(self):
342 self.xmlstream.addObserver(CMD_REQUEST, self.plugin_parent.onCmdRequest, profile=self.parent.profile)
343
344 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
345 identities = []
346 if nodeIdentifier == NS_COMMANDS and self.plugin_parent.answering.get(self.parent.profile): # we only add the identity if we have registred commands
347 identities.append(ID_CMD_LIST)
348 return [disco.DiscoFeature(NS_COMMANDS)] + identities
349
350 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
351 ret = []
352 if nodeIdentifier == NS_COMMANDS:
353 for command in self.plugin_parent.answering[self.parent.profile].values():
354 if command.isAuthorised(requestor):
355 ret.append(disco.DiscoItem(self.parent.jid, command.node, command.getName())) #TODO: manage name language
356 return ret