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