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