Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0050.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_xep_0050.py@524856bd7b19 |
children | 50c919dfe61b |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # SàT plugin for Ad-Hoc Commands (XEP-0050) | |
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 | |
20 from collections import namedtuple | |
21 from uuid import uuid4 | |
22 from typing import List, Optional | |
23 | |
24 from zope.interface import implementer | |
25 from twisted.words.protocols.jabber import jid | |
26 from twisted.words.protocols import jabber | |
27 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
28 from twisted.words.xish import domish | |
29 from twisted.internet import defer | |
30 from wokkel import disco, iwokkel, data_form | |
31 from libervia.backend.core.i18n import _, D_ | |
32 from libervia.backend.core.constants import Const as C | |
33 from libervia.backend.core.log import getLogger | |
34 from libervia.backend.core.xmpp import SatXMPPEntity | |
35 from libervia.backend.core import exceptions | |
36 from libervia.backend.memory.memory import Sessions | |
37 from libervia.backend.tools import xml_tools, utils | |
38 from libervia.backend.tools.common import data_format | |
39 | |
40 | |
41 log = getLogger(__name__) | |
42 | |
43 | |
44 IQ_SET = '/iq[@type="set"]' | |
45 NS_COMMANDS = "http://jabber.org/protocol/commands" | |
46 ID_CMD_LIST = disco.DiscoIdentity("automation", "command-list") | |
47 ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node") | |
48 CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]' | |
49 | |
50 SHOWS = { | |
51 "default": _("Online"), | |
52 "away": _("Away"), | |
53 "chat": _("Free for chat"), | |
54 "dnd": _("Do not disturb"), | |
55 "xa": _("Left"), | |
56 "disconnect": _("Disconnect"), | |
57 } | |
58 | |
59 PLUGIN_INFO = { | |
60 C.PI_NAME: "Ad-Hoc Commands", | |
61 C.PI_IMPORT_NAME: "XEP-0050", | |
62 C.PI_MODES: C.PLUG_MODE_BOTH, | |
63 C.PI_TYPE: "XEP", | |
64 C.PI_PROTOCOLS: ["XEP-0050"], | |
65 C.PI_MAIN: "XEP_0050", | |
66 C.PI_HANDLER: "yes", | |
67 C.PI_DESCRIPTION: _("""Implementation of Ad-Hoc Commands"""), | |
68 } | |
69 | |
70 | |
71 class AdHocError(Exception): | |
72 def __init__(self, error_const): | |
73 """ Error to be used from callback | |
74 @param error_const: one of XEP_0050.ERROR | |
75 """ | |
76 assert error_const in XEP_0050.ERROR | |
77 self.callback_error = error_const | |
78 | |
79 | |
80 @implementer(iwokkel.IDisco) | |
81 class AdHocCommand(XMPPHandler): | |
82 | |
83 def __init__(self, callback, label, node, features, timeout, | |
84 allowed_jids, allowed_groups, allowed_magics, forbidden_jids, | |
85 forbidden_groups): | |
86 XMPPHandler.__init__(self) | |
87 self.callback = callback | |
88 self.label = label | |
89 self.node = node | |
90 self.features = [disco.DiscoFeature(feature) for feature in features] | |
91 self.allowed_jids = allowed_jids | |
92 self.allowed_groups = allowed_groups | |
93 self.allowed_magics = allowed_magics | |
94 self.forbidden_jids = forbidden_jids | |
95 self.forbidden_groups = forbidden_groups | |
96 self.sessions = Sessions(timeout=timeout) | |
97 | |
98 @property | |
99 def client(self): | |
100 return self.parent | |
101 | |
102 def getName(self, xml_lang=None): | |
103 return self.label | |
104 | |
105 def is_authorised(self, requestor): | |
106 if "@ALL@" in self.allowed_magics: | |
107 return True | |
108 forbidden = set(self.forbidden_jids) | |
109 for group in self.forbidden_groups: | |
110 forbidden.update(self.client.roster.get_jids_from_group(group)) | |
111 if requestor.userhostJID() in forbidden: | |
112 return False | |
113 allowed = set(self.allowed_jids) | |
114 for group in self.allowed_groups: | |
115 try: | |
116 allowed.update(self.client.roster.get_jids_from_group(group)) | |
117 except exceptions.UnknownGroupError: | |
118 log.warning(_("The groups [{group}] is unknown for profile [{profile}])") | |
119 .format(group=group, profile=self.client.profile)) | |
120 if requestor.userhostJID() in allowed: | |
121 return True | |
122 return False | |
123 | |
124 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
125 if ( | |
126 nodeIdentifier != NS_COMMANDS | |
127 ): # FIXME: we should manage other disco nodes here | |
128 return [] | |
129 # identities = [ID_CMD_LIST if self.node == NS_COMMANDS else ID_CMD_NODE] # FIXME | |
130 return [disco.DiscoFeature(NS_COMMANDS)] + self.features | |
131 | |
132 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
133 return [] | |
134 | |
135 def _sendAnswer(self, callback_data, session_id, request): | |
136 """ Send result of the command | |
137 | |
138 @param callback_data: tuple (payload, status, actions, note) with: | |
139 - payload (domish.Element, None) usualy containing data form | |
140 - status: current status, see XEP_0050.STATUS | |
141 - actions(list[str], None): list of allowed actions (see XEP_0050.ACTION). | |
142 First action is the default one. Default to EXECUTE | |
143 - note(tuple[str, unicode]): optional additional note: either None or a | |
144 tuple with (note type, human readable string), "note type" being in | |
145 XEP_0050.NOTE | |
146 @param session_id: current session id | |
147 @param request: original request (domish.Element) | |
148 @return: deferred | |
149 """ | |
150 payload, status, actions, note = callback_data | |
151 assert isinstance(payload, domish.Element) or payload is None | |
152 assert status in XEP_0050.STATUS | |
153 if not actions: | |
154 actions = [XEP_0050.ACTION.EXECUTE] | |
155 result = domish.Element((None, "iq")) | |
156 result["type"] = "result" | |
157 result["id"] = request["id"] | |
158 result["to"] = request["from"] | |
159 command_elt = result.addElement("command", NS_COMMANDS) | |
160 command_elt["sessionid"] = session_id | |
161 command_elt["node"] = self.node | |
162 command_elt["status"] = status | |
163 | |
164 if status != XEP_0050.STATUS.CANCELED: | |
165 if status != XEP_0050.STATUS.COMPLETED: | |
166 actions_elt = command_elt.addElement("actions") | |
167 actions_elt["execute"] = actions[0] | |
168 for action in actions: | |
169 actions_elt.addElement(action) | |
170 | |
171 if note is not None: | |
172 note_type, note_mess = note | |
173 note_elt = command_elt.addElement("note", content=note_mess) | |
174 note_elt["type"] = note_type | |
175 | |
176 if payload is not None: | |
177 command_elt.addChild(payload) | |
178 | |
179 self.client.send(result) | |
180 if status in (XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED): | |
181 del self.sessions[session_id] | |
182 | |
183 def _sendError(self, error_constant, session_id, request): | |
184 """ Send error stanza | |
185 | |
186 @param error_constant: one of XEP_OO50.ERROR | |
187 @param request: original request (domish.Element) | |
188 """ | |
189 xmpp_condition, cmd_condition = error_constant | |
190 iq_elt = jabber.error.StanzaError(xmpp_condition).toResponse(request) | |
191 if cmd_condition: | |
192 error_elt = next(iq_elt.elements(None, "error")) | |
193 error_elt.addElement(cmd_condition, NS_COMMANDS) | |
194 self.client.send(iq_elt) | |
195 del self.sessions[session_id] | |
196 | |
197 def _request_eb(self, failure_, request, session_id): | |
198 if failure_.check(AdHocError): | |
199 error_constant = failure_.value.callback_error | |
200 else: | |
201 log.error(f"unexpected error while handling request: {failure_}") | |
202 error_constant = XEP_0050.ERROR.INTERNAL | |
203 | |
204 self._sendError(error_constant, session_id, request) | |
205 | |
206 def on_request(self, command_elt, requestor, action, session_id): | |
207 if not self.is_authorised(requestor): | |
208 return self._sendError( | |
209 XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent | |
210 ) | |
211 if session_id: | |
212 try: | |
213 session_data = self.sessions[session_id] | |
214 except KeyError: | |
215 return self._sendError( | |
216 XEP_0050.ERROR.SESSION_EXPIRED, session_id, command_elt.parent | |
217 ) | |
218 if session_data["requestor"] != requestor: | |
219 return self._sendError( | |
220 XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent | |
221 ) | |
222 else: | |
223 session_id, session_data = self.sessions.new_session() | |
224 session_data["requestor"] = requestor | |
225 if action == XEP_0050.ACTION.CANCEL: | |
226 d = defer.succeed((None, XEP_0050.STATUS.CANCELED, None, None)) | |
227 else: | |
228 d = utils.as_deferred( | |
229 self.callback, | |
230 self.client, | |
231 command_elt, | |
232 session_data, | |
233 action, | |
234 self.node, | |
235 ) | |
236 d.addCallback(self._sendAnswer, session_id, command_elt.parent) | |
237 d.addErrback(self._request_eb, command_elt.parent, session_id) | |
238 | |
239 | |
240 class XEP_0050(object): | |
241 STATUS = namedtuple("Status", ("EXECUTING", "COMPLETED", "CANCELED"))( | |
242 "executing", "completed", "canceled" | |
243 ) | |
244 ACTION = namedtuple("Action", ("EXECUTE", "CANCEL", "NEXT", "PREV"))( | |
245 "execute", "cancel", "next", "prev" | |
246 ) | |
247 NOTE = namedtuple("Note", ("INFO", "WARN", "ERROR"))("info", "warn", "error") | |
248 ERROR = namedtuple( | |
249 "Error", | |
250 ( | |
251 "MALFORMED_ACTION", | |
252 "BAD_ACTION", | |
253 "BAD_LOCALE", | |
254 "BAD_PAYLOAD", | |
255 "BAD_SESSIONID", | |
256 "SESSION_EXPIRED", | |
257 "FORBIDDEN", | |
258 "ITEM_NOT_FOUND", | |
259 "FEATURE_NOT_IMPLEMENTED", | |
260 "INTERNAL", | |
261 ), | |
262 )( | |
263 ("bad-request", "malformed-action"), | |
264 ("bad-request", "bad-action"), | |
265 ("bad-request", "bad-locale"), | |
266 ("bad-request", "bad-payload"), | |
267 ("bad-request", "bad-sessionid"), | |
268 ("not-allowed", "session-expired"), | |
269 ("forbidden", None), | |
270 ("item-not-found", None), | |
271 ("feature-not-implemented", None), | |
272 ("internal-server-error", None), | |
273 ) # XEP-0050 §4.4 Table 5 | |
274 | |
275 def __init__(self, host): | |
276 log.info(_("plugin XEP-0050 initialization")) | |
277 self.host = host | |
278 self.requesting = Sessions() | |
279 host.bridge.add_method( | |
280 "ad_hoc_run", | |
281 ".plugin", | |
282 in_sign="sss", | |
283 out_sign="s", | |
284 method=self._run, | |
285 async_=True, | |
286 ) | |
287 host.bridge.add_method( | |
288 "ad_hoc_list", | |
289 ".plugin", | |
290 in_sign="ss", | |
291 out_sign="s", | |
292 method=self._list_ui, | |
293 async_=True, | |
294 ) | |
295 host.bridge.add_method( | |
296 "ad_hoc_sequence", | |
297 ".plugin", | |
298 in_sign="ssss", | |
299 out_sign="s", | |
300 method=self._sequence, | |
301 async_=True, | |
302 ) | |
303 self.__requesting_id = host.register_callback( | |
304 self._requesting_entity, with_data=True | |
305 ) | |
306 host.import_menu( | |
307 (D_("Service"), D_("Commands")), | |
308 self._commands_menu, | |
309 security_limit=2, | |
310 help_string=D_("Execute ad-hoc commands"), | |
311 ) | |
312 host.register_namespace('commands', NS_COMMANDS) | |
313 | |
314 def get_handler(self, client): | |
315 return XEP_0050_handler(self) | |
316 | |
317 def profile_connected(self, client): | |
318 # map from node to AdHocCommand instance | |
319 client._XEP_0050_commands = {} | |
320 if not client.is_component: | |
321 self.add_ad_hoc_command(client, self._status_callback, _("Status")) | |
322 | |
323 def do(self, client, entity, node, action=ACTION.EXECUTE, session_id=None, | |
324 form_values=None, timeout=30): | |
325 """Do an Ad-Hoc Command | |
326 | |
327 @param entity(jid.JID): entity which will execture the command | |
328 @param node(unicode): node of the command | |
329 @param action(unicode): one of XEP_0050.ACTION | |
330 @param session_id(unicode, None): id of the ad-hoc session | |
331 None if no session is involved | |
332 @param form_values(dict, None): values to use to create command form | |
333 values will be passed to data_form.Form.makeFields | |
334 @return: iq result element | |
335 """ | |
336 iq_elt = client.IQ(timeout=timeout) | |
337 iq_elt["to"] = entity.full() | |
338 command_elt = iq_elt.addElement("command", NS_COMMANDS) | |
339 command_elt["node"] = node | |
340 command_elt["action"] = action | |
341 if session_id is not None: | |
342 command_elt["sessionid"] = session_id | |
343 | |
344 if form_values: | |
345 # We add the XMLUI result to the command payload | |
346 form = data_form.Form("submit") | |
347 form.makeFields(form_values) | |
348 command_elt.addChild(form.toElement()) | |
349 d = iq_elt.send() | |
350 return d | |
351 | |
352 def get_command_elt(self, iq_elt): | |
353 try: | |
354 return next(iq_elt.elements(NS_COMMANDS, "command")) | |
355 except StopIteration: | |
356 raise exceptions.NotFound(_("Missing command element")) | |
357 | |
358 def ad_hoc_error(self, error_type): | |
359 """Shortcut to raise an AdHocError | |
360 | |
361 @param error_type(unicode): one of XEP_0050.ERROR | |
362 """ | |
363 raise AdHocError(error_type) | |
364 | |
365 def _items_2_xmlui(self, items, no_instructions): | |
366 """Convert discovery items to XMLUI dialog """ | |
367 # TODO: manage items on different jids | |
368 form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id) | |
369 | |
370 if not no_instructions: | |
371 form_ui.addText(_("Please select a command"), "instructions") | |
372 | |
373 options = [(item.nodeIdentifier, item.name) for item in items] | |
374 form_ui.addList("node", options) | |
375 return form_ui | |
376 | |
377 def _get_data_lvl(self, type_): | |
378 """Return the constant corresponding to <note/> type attribute value | |
379 | |
380 @param type_: note type (see XEP-0050 §4.3) | |
381 @return: a C.XMLUI_DATA_LVL_* constant | |
382 """ | |
383 if type_ == "error": | |
384 return C.XMLUI_DATA_LVL_ERROR | |
385 elif type_ == "warn": | |
386 return C.XMLUI_DATA_LVL_WARNING | |
387 else: | |
388 if type_ != "info": | |
389 log.warning(_("Invalid note type [%s], using info") % type_) | |
390 return C.XMLUI_DATA_LVL_INFO | |
391 | |
392 def _merge_notes(self, notes): | |
393 """Merge notes with level prefix (e.g. "ERROR: the message") | |
394 | |
395 @param notes (list): list of tuple (level, message) | |
396 @return: list of messages | |
397 """ | |
398 lvl_map = { | |
399 C.XMLUI_DATA_LVL_INFO: "", | |
400 C.XMLUI_DATA_LVL_WARNING: "%s: " % _("WARNING"), | |
401 C.XMLUI_DATA_LVL_ERROR: "%s: " % _("ERROR"), | |
402 } | |
403 return ["%s%s" % (lvl_map[lvl], msg) for lvl, msg in notes] | |
404 | |
405 def parse_command_answer(self, iq_elt): | |
406 command_elt = self.get_command_elt(iq_elt) | |
407 data = {} | |
408 data["status"] = command_elt.getAttribute("status", XEP_0050.STATUS.EXECUTING) | |
409 data["session_id"] = command_elt.getAttribute("sessionid") | |
410 data["notes"] = notes = [] | |
411 for note_elt in command_elt.elements(NS_COMMANDS, "note"): | |
412 notes.append( | |
413 ( | |
414 self._get_data_lvl(note_elt.getAttribute("type", "info")), | |
415 str(note_elt), | |
416 ) | |
417 ) | |
418 | |
419 return command_elt, data | |
420 | |
421 | |
422 def _commands_answer_2_xmlui(self, iq_elt, session_id, session_data): | |
423 """Convert command answer to an ui for frontend | |
424 | |
425 @param iq_elt: command result | |
426 @param session_id: id of the session used with the frontend | |
427 @param profile_key: %(doc_profile_key)s | |
428 """ | |
429 command_elt, answer_data = self.parse_command_answer(iq_elt) | |
430 status = answer_data["status"] | |
431 if status in [XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED]: | |
432 # the command session is finished, we purge our session | |
433 del self.requesting[session_id] | |
434 if status == XEP_0050.STATUS.COMPLETED: | |
435 session_id = None | |
436 else: | |
437 return None | |
438 remote_session_id = answer_data["session_id"] | |
439 if remote_session_id: | |
440 session_data["remote_id"] = remote_session_id | |
441 notes = answer_data["notes"] | |
442 for data_elt in command_elt.elements(data_form.NS_X_DATA, "x"): | |
443 if data_elt["type"] in ("form", "result"): | |
444 break | |
445 else: | |
446 # no matching data element found | |
447 if status != XEP_0050.STATUS.COMPLETED: | |
448 log.warning( | |
449 _("No known payload found in ad-hoc command result, aborting") | |
450 ) | |
451 del self.requesting[session_id] | |
452 return xml_tools.XMLUI( | |
453 C.XMLUI_DIALOG, | |
454 dialog_opt={ | |
455 C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE, | |
456 C.XMLUI_DATA_MESS: _("No payload found"), | |
457 C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_ERROR, | |
458 }, | |
459 ) | |
460 if not notes: | |
461 # the status is completed, and we have no note to show | |
462 return None | |
463 | |
464 # if we have only one note, we show a dialog with the level of the note | |
465 # if we have more, we show a dialog with "info" level, and all notes merged | |
466 dlg_level = notes[0][0] if len(notes) == 1 else C.XMLUI_DATA_LVL_INFO | |
467 return xml_tools.XMLUI( | |
468 C.XMLUI_DIALOG, | |
469 dialog_opt={ | |
470 C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE, | |
471 C.XMLUI_DATA_MESS: "\n".join(self._merge_notes(notes)), | |
472 C.XMLUI_DATA_LVL: dlg_level, | |
473 }, | |
474 session_id=session_id, | |
475 ) | |
476 | |
477 if session_id is None: | |
478 xmlui = xml_tools.data_form_elt_result_2_xmlui(data_elt) | |
479 if notes: | |
480 for level, note in notes: | |
481 if level != "info": | |
482 note = f"[{level}] {note}" | |
483 xmlui.add_widget("text", note) | |
484 return xmlui | |
485 | |
486 form = data_form.Form.fromElement(data_elt) | |
487 # we add any present note to the instructions | |
488 form.instructions.extend(self._merge_notes(notes)) | |
489 return xml_tools.data_form_2_xmlui(form, self.__requesting_id, session_id=session_id) | |
490 | |
491 def _requesting_entity(self, data, profile): | |
492 def serialise(ret_data): | |
493 if "xmlui" in ret_data: | |
494 ret_data["xmlui"] = ret_data["xmlui"].toXml() | |
495 return ret_data | |
496 | |
497 d = self.requesting_entity(data, profile) | |
498 d.addCallback(serialise) | |
499 return d | |
500 | |
501 def requesting_entity(self, data, profile): | |
502 """Request and entity and create XMLUI accordingly. | |
503 | |
504 @param data: data returned by previous XMLUI (first one must come from | |
505 self._commands_menu) | |
506 @param profile: %(doc_profile)s | |
507 @return: callback dict result (with "xmlui" corresponding to the answering | |
508 dialog, or empty if it's finished without error) | |
509 """ | |
510 if C.bool(data.get("cancelled", C.BOOL_FALSE)): | |
511 return defer.succeed({}) | |
512 data_form_values = xml_tools.xmlui_result_2_data_form_result(data) | |
513 client = self.host.get_client(profile) | |
514 # TODO: cancel, prev and next are not managed | |
515 # TODO: managed answerer errors | |
516 # TODO: manage nodes with a non data form payload | |
517 if "session_id" not in data: | |
518 # we just had the jid, we now request it for the available commands | |
519 session_id, session_data = self.requesting.new_session(profile=client.profile) | |
520 entity = jid.JID(data[xml_tools.SAT_FORM_PREFIX + "jid"]) | |
521 session_data["jid"] = entity | |
522 d = self.list_ui(client, entity) | |
523 | |
524 def send_items(xmlui): | |
525 xmlui.session_id = session_id # we need to keep track of the session | |
526 return {"xmlui": xmlui} | |
527 | |
528 d.addCallback(send_items) | |
529 else: | |
530 # we have started a several forms sessions | |
531 try: | |
532 session_data = self.requesting.profile_get( | |
533 data["session_id"], client.profile | |
534 ) | |
535 except KeyError: | |
536 log.warning("session id doesn't exist, session has probably expired") | |
537 # TODO: send error dialog | |
538 return defer.succeed({}) | |
539 session_id = data["session_id"] | |
540 entity = session_data["jid"] | |
541 try: | |
542 session_data["node"] | |
543 # node has already been received | |
544 except KeyError: | |
545 # it's the first time we know the node, we save it in session data | |
546 session_data["node"] = data_form_values.pop("node") | |
547 | |
548 # remote_id is the XEP_0050 sessionid used by answering command | |
549 # while session_id is our own session id used with the frontend | |
550 remote_id = session_data.get("remote_id") | |
551 | |
552 # we request execute node's command | |
553 d = self.do(client, entity, session_data["node"], action=XEP_0050.ACTION.EXECUTE, | |
554 session_id=remote_id, form_values=data_form_values) | |
555 d.addCallback(self._commands_answer_2_xmlui, session_id, session_data) | |
556 d.addCallback(lambda xmlui: {"xmlui": xmlui} if xmlui is not None else {}) | |
557 | |
558 return d | |
559 | |
560 def _commands_menu(self, menu_data, profile): | |
561 """First XMLUI activated by menu: ask for target jid | |
562 | |
563 @param profile: %(doc_profile)s | |
564 """ | |
565 form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id) | |
566 form_ui.addText(_("Please enter target jid"), "instructions") | |
567 form_ui.change_container("pairs") | |
568 form_ui.addLabel("jid") | |
569 form_ui.addString("jid", value=self.host.get_client(profile).jid.host) | |
570 return {"xmlui": form_ui.toXml()} | |
571 | |
572 def _status_callback(self, client, command_elt, session_data, action, node): | |
573 """Ad-hoc command used to change the "show" part of status""" | |
574 actions = session_data.setdefault("actions", []) | |
575 actions.append(action) | |
576 | |
577 if len(actions) == 1: | |
578 # it's our first request, we ask the desired new status | |
579 status = XEP_0050.STATUS.EXECUTING | |
580 form = data_form.Form("form", title=_("status selection")) | |
581 show_options = [ | |
582 data_form.Option(name, label) for name, label in list(SHOWS.items()) | |
583 ] | |
584 field = data_form.Field( | |
585 "list-single", "show", options=show_options, required=True | |
586 ) | |
587 form.addField(field) | |
588 | |
589 payload = form.toElement() | |
590 note = None | |
591 | |
592 elif len(actions) == 2: | |
593 # we should have the answer here | |
594 try: | |
595 x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x")) | |
596 answer_form = data_form.Form.fromElement(x_elt) | |
597 show = answer_form["show"] | |
598 except (KeyError, StopIteration): | |
599 self.ad_hoc_error(XEP_0050.ERROR.BAD_PAYLOAD) | |
600 if show not in SHOWS: | |
601 self.ad_hoc_error(XEP_0050.ERROR.BAD_PAYLOAD) | |
602 if show == "disconnect": | |
603 self.host.disconnect(client.profile) | |
604 else: | |
605 self.host.presence_set(show=show, profile_key=client.profile) | |
606 | |
607 # job done, we can end the session | |
608 status = XEP_0050.STATUS.COMPLETED | |
609 payload = None | |
610 note = (self.NOTE.INFO, _("Status updated")) | |
611 else: | |
612 self.ad_hoc_error(XEP_0050.ERROR.INTERNAL) | |
613 | |
614 return (payload, status, None, note) | |
615 | |
616 def _run(self, service_jid_s="", node="", profile_key=C.PROF_KEY_NONE): | |
617 client = self.host.get_client(profile_key) | |
618 service_jid = jid.JID(service_jid_s) if service_jid_s else None | |
619 d = defer.ensureDeferred(self.run(client, service_jid, node or None)) | |
620 d.addCallback(lambda xmlui: xmlui.toXml()) | |
621 return d | |
622 | |
623 async def run(self, client, service_jid=None, node=None): | |
624 """Run an ad-hoc command | |
625 | |
626 @param service_jid(jid.JID, None): jid of the ad-hoc service | |
627 None to use profile's server | |
628 @param node(unicode, None): node of the ad-hoc commnad | |
629 None to get initial list | |
630 @return(unicode): command page XMLUI | |
631 """ | |
632 if service_jid is None: | |
633 service_jid = jid.JID(client.jid.host) | |
634 session_id, session_data = self.requesting.new_session(profile=client.profile) | |
635 session_data["jid"] = service_jid | |
636 if node is None: | |
637 xmlui = await self.list_ui(client, service_jid) | |
638 else: | |
639 session_data["node"] = node | |
640 cb_data = await self.requesting_entity( | |
641 {"session_id": session_id}, client.profile | |
642 ) | |
643 xmlui = cb_data["xmlui"] | |
644 | |
645 xmlui.session_id = session_id | |
646 return xmlui | |
647 | |
648 def list(self, client, to_jid): | |
649 """Request available commands | |
650 | |
651 @param to_jid(jid.JID, None): the entity answering the commands | |
652 None to use profile's server | |
653 @return D(disco.DiscoItems): found commands | |
654 """ | |
655 d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS) | |
656 return d | |
657 | |
658 def _list_ui(self, to_jid_s, profile_key): | |
659 client = self.host.get_client(profile_key) | |
660 to_jid = jid.JID(to_jid_s) if to_jid_s else None | |
661 d = self.list_ui(client, to_jid, no_instructions=True) | |
662 d.addCallback(lambda xmlui: xmlui.toXml()) | |
663 return d | |
664 | |
665 def list_ui(self, client, to_jid, no_instructions=False): | |
666 """Request available commands and generate XMLUI | |
667 | |
668 @param to_jid(jid.JID, None): the entity answering the commands | |
669 None to use profile's server | |
670 @param no_instructions(bool): if True, don't add instructions widget | |
671 @return D(xml_tools.XMLUI): UI with the commands | |
672 """ | |
673 d = self.list(client, to_jid) | |
674 d.addCallback(self._items_2_xmlui, no_instructions) | |
675 return d | |
676 | |
677 def _sequence(self, sequence, node, service_jid_s="", profile_key=C.PROF_KEY_NONE): | |
678 sequence = data_format.deserialise(sequence, type_check=list) | |
679 client = self.host.get_client(profile_key) | |
680 service_jid = jid.JID(service_jid_s) if service_jid_s else None | |
681 d = defer.ensureDeferred(self.sequence(client, sequence, node, service_jid)) | |
682 d.addCallback(lambda data: data_format.serialise(data)) | |
683 return d | |
684 | |
685 async def sequence( | |
686 self, | |
687 client: SatXMPPEntity, | |
688 sequence: List[dict], | |
689 node: str, | |
690 service_jid: Optional[jid.JID] = None, | |
691 ) -> dict: | |
692 """Send a series of data to an ad-hoc service | |
693 | |
694 @param sequence: list of values to send | |
695 value are specified by a dict mapping var name to value. | |
696 @param node: node of the ad-hoc commnad | |
697 @param service_jid: jid of the ad-hoc service | |
698 None to use profile's server | |
699 @return: data received in final answer | |
700 """ | |
701 if service_jid is None: | |
702 service_jid = jid.JID(client.jid.host) | |
703 | |
704 session_id = None | |
705 | |
706 for data_to_send in sequence: | |
707 iq_result_elt = await self.do( | |
708 client, | |
709 service_jid, | |
710 node, | |
711 session_id=session_id, | |
712 form_values=data_to_send, | |
713 ) | |
714 __, answer_data = self.parse_command_answer(iq_result_elt) | |
715 session_id = answer_data.pop("session_id") | |
716 | |
717 return answer_data | |
718 | |
719 def add_ad_hoc_command(self, client, callback, label, node=None, features=None, | |
720 timeout=600, allowed_jids=None, allowed_groups=None, | |
721 allowed_magics=None, forbidden_jids=None, forbidden_groups=None, | |
722 ): | |
723 """Add an ad-hoc command for the current profile | |
724 | |
725 @param callback: method associated with this ad-hoc command which return the | |
726 payload data (see AdHocCommand._sendAnswer), can return a | |
727 deferred | |
728 @param label: label associated with this command on the main menu | |
729 @param node: disco item node associated with this command. None to use | |
730 autogenerated node | |
731 @param features: features associated with the payload (list of strings), usualy | |
732 data form | |
733 @param timeout: delay between two requests before canceling the session (in | |
734 seconds) | |
735 @param allowed_jids: list of allowed entities | |
736 @param allowed_groups: list of allowed roster groups | |
737 @param allowed_magics: list of allowed magic keys, can be: | |
738 @ALL@: allow everybody | |
739 @PROFILE_BAREJID@: allow only the jid of the profile | |
740 @param forbidden_jids: black list of entities which can't access this command | |
741 @param forbidden_groups: black list of groups which can't access this command | |
742 @return: node of the added command, useful to remove the command later | |
743 """ | |
744 # FIXME: "@ALL@" for profile_key seems useless and dangerous | |
745 | |
746 if node is None: | |
747 node = "%s_%s" % ("COMMANDS", uuid4()) | |
748 | |
749 if features is None: | |
750 features = [data_form.NS_X_DATA] | |
751 | |
752 if allowed_jids is None: | |
753 allowed_jids = [] | |
754 if allowed_groups is None: | |
755 allowed_groups = [] | |
756 if allowed_magics is None: | |
757 allowed_magics = ["@PROFILE_BAREJID@"] | |
758 if forbidden_jids is None: | |
759 forbidden_jids = [] | |
760 if forbidden_groups is None: | |
761 forbidden_groups = [] | |
762 | |
763 # TODO: manage newly created/removed profiles | |
764 _allowed_jids = ( | |
765 (allowed_jids + [client.jid.userhostJID()]) | |
766 if "@PROFILE_BAREJID@" in allowed_magics | |
767 else allowed_jids | |
768 ) | |
769 ad_hoc_command = AdHocCommand( | |
770 callback, | |
771 label, | |
772 node, | |
773 features, | |
774 timeout, | |
775 _allowed_jids, | |
776 allowed_groups, | |
777 allowed_magics, | |
778 forbidden_jids, | |
779 forbidden_groups, | |
780 ) | |
781 ad_hoc_command.setHandlerParent(client) | |
782 commands = client._XEP_0050_commands | |
783 commands[node] = ad_hoc_command | |
784 | |
785 def on_cmd_request(self, request, client): | |
786 request.handled = True | |
787 requestor = jid.JID(request["from"]) | |
788 command_elt = next(request.elements(NS_COMMANDS, "command")) | |
789 action = command_elt.getAttribute("action", self.ACTION.EXECUTE) | |
790 node = command_elt.getAttribute("node") | |
791 if not node: | |
792 client.sendError(request, "bad-request") | |
793 return | |
794 sessionid = command_elt.getAttribute("sessionid") | |
795 commands = client._XEP_0050_commands | |
796 try: | |
797 command = commands[node] | |
798 except KeyError: | |
799 client.sendError(request, "item-not-found") | |
800 return | |
801 command.on_request(command_elt, requestor, action, sessionid) | |
802 | |
803 | |
804 @implementer(iwokkel.IDisco) | |
805 class XEP_0050_handler(XMPPHandler): | |
806 | |
807 def __init__(self, plugin_parent): | |
808 self.plugin_parent = plugin_parent | |
809 | |
810 @property | |
811 def client(self): | |
812 return self.parent | |
813 | |
814 def connectionInitialized(self): | |
815 self.xmlstream.addObserver( | |
816 CMD_REQUEST, self.plugin_parent.on_cmd_request, client=self.parent | |
817 ) | |
818 | |
819 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
820 identities = [] | |
821 if nodeIdentifier == NS_COMMANDS and self.client._XEP_0050_commands: | |
822 # we only add the identity if we have registred commands | |
823 identities.append(ID_CMD_LIST) | |
824 return [disco.DiscoFeature(NS_COMMANDS)] + identities | |
825 | |
826 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
827 ret = [] | |
828 if nodeIdentifier == NS_COMMANDS: | |
829 commands = self.client._XEP_0050_commands | |
830 for command in list(commands.values()): | |
831 if command.is_authorised(requestor): | |
832 ret.append( | |
833 disco.DiscoItem(self.parent.jid, command.node, command.getName()) | |
834 ) # TODO: manage name language | |
835 return ret |