Mercurial > libervia-backend
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/plugins/plugin_xep_0050.py Fri Jun 02 11:49:51 2023 +0200 @@ -0,0 +1,835 @@ +#!/usr/bin/env python3 + +# SàT plugin for Ad-Hoc Commands (XEP-0050) +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + + +from collections import namedtuple +from uuid import uuid4 +from typing import List, Optional + +from zope.interface import implementer +from twisted.words.protocols.jabber import jid +from twisted.words.protocols import jabber +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.words.xish import domish +from twisted.internet import defer +from wokkel import disco, iwokkel, data_form +from libervia.backend.core.i18n import _, D_ +from libervia.backend.core.constants import Const as C +from libervia.backend.core.log import getLogger +from libervia.backend.core.xmpp import SatXMPPEntity +from libervia.backend.core import exceptions +from libervia.backend.memory.memory import Sessions +from libervia.backend.tools import xml_tools, utils +from libervia.backend.tools.common import data_format + + +log = getLogger(__name__) + + +IQ_SET = '/iq[@type="set"]' +NS_COMMANDS = "http://jabber.org/protocol/commands" +ID_CMD_LIST = disco.DiscoIdentity("automation", "command-list") +ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node") +CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]' + +SHOWS = { + "default": _("Online"), + "away": _("Away"), + "chat": _("Free for chat"), + "dnd": _("Do not disturb"), + "xa": _("Left"), + "disconnect": _("Disconnect"), +} + +PLUGIN_INFO = { + C.PI_NAME: "Ad-Hoc Commands", + C.PI_IMPORT_NAME: "XEP-0050", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0050"], + C.PI_MAIN: "XEP_0050", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of Ad-Hoc Commands"""), +} + + +class AdHocError(Exception): + def __init__(self, error_const): + """ Error to be used from callback + @param error_const: one of XEP_0050.ERROR + """ + assert error_const in XEP_0050.ERROR + self.callback_error = error_const + + +@implementer(iwokkel.IDisco) +class AdHocCommand(XMPPHandler): + + def __init__(self, callback, label, node, features, timeout, + allowed_jids, allowed_groups, allowed_magics, forbidden_jids, + forbidden_groups): + XMPPHandler.__init__(self) + self.callback = callback + self.label = label + self.node = node + self.features = [disco.DiscoFeature(feature) for feature in features] + self.allowed_jids = allowed_jids + self.allowed_groups = allowed_groups + self.allowed_magics = allowed_magics + self.forbidden_jids = forbidden_jids + self.forbidden_groups = forbidden_groups + self.sessions = Sessions(timeout=timeout) + + @property + def client(self): + return self.parent + + def getName(self, xml_lang=None): + return self.label + + def is_authorised(self, requestor): + if "@ALL@" in self.allowed_magics: + return True + forbidden = set(self.forbidden_jids) + for group in self.forbidden_groups: + forbidden.update(self.client.roster.get_jids_from_group(group)) + if requestor.userhostJID() in forbidden: + return False + allowed = set(self.allowed_jids) + for group in self.allowed_groups: + try: + allowed.update(self.client.roster.get_jids_from_group(group)) + except exceptions.UnknownGroupError: + log.warning(_("The groups [{group}] is unknown for profile [{profile}])") + .format(group=group, profile=self.client.profile)) + if requestor.userhostJID() in allowed: + return True + return False + + def getDiscoInfo(self, requestor, target, nodeIdentifier=""): + if ( + nodeIdentifier != NS_COMMANDS + ): # FIXME: we should manage other disco nodes here + return [] + # identities = [ID_CMD_LIST if self.node == NS_COMMANDS else ID_CMD_NODE] # FIXME + return [disco.DiscoFeature(NS_COMMANDS)] + self.features + + def getDiscoItems(self, requestor, target, nodeIdentifier=""): + return [] + + def _sendAnswer(self, callback_data, session_id, request): + """ Send result of the command + + @param callback_data: tuple (payload, status, actions, note) with: + - payload (domish.Element, None) usualy containing data form + - status: current status, see XEP_0050.STATUS + - actions(list[str], None): list of allowed actions (see XEP_0050.ACTION). + First action is the default one. Default to EXECUTE + - note(tuple[str, unicode]): optional additional note: either None or a + tuple with (note type, human readable string), "note type" being in + XEP_0050.NOTE + @param session_id: current session id + @param request: original request (domish.Element) + @return: deferred + """ + payload, status, actions, note = callback_data + assert isinstance(payload, domish.Element) or payload is None + assert status in XEP_0050.STATUS + if not actions: + actions = [XEP_0050.ACTION.EXECUTE] + result = domish.Element((None, "iq")) + result["type"] = "result" + result["id"] = request["id"] + result["to"] = request["from"] + command_elt = result.addElement("command", NS_COMMANDS) + command_elt["sessionid"] = session_id + command_elt["node"] = self.node + command_elt["status"] = status + + if status != XEP_0050.STATUS.CANCELED: + if status != XEP_0050.STATUS.COMPLETED: + actions_elt = command_elt.addElement("actions") + actions_elt["execute"] = actions[0] + for action in actions: + actions_elt.addElement(action) + + if note is not None: + note_type, note_mess = note + note_elt = command_elt.addElement("note", content=note_mess) + note_elt["type"] = note_type + + if payload is not None: + command_elt.addChild(payload) + + self.client.send(result) + if status in (XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED): + del self.sessions[session_id] + + def _sendError(self, error_constant, session_id, request): + """ Send error stanza + + @param error_constant: one of XEP_OO50.ERROR + @param request: original request (domish.Element) + """ + xmpp_condition, cmd_condition = error_constant + iq_elt = jabber.error.StanzaError(xmpp_condition).toResponse(request) + if cmd_condition: + error_elt = next(iq_elt.elements(None, "error")) + error_elt.addElement(cmd_condition, NS_COMMANDS) + self.client.send(iq_elt) + del self.sessions[session_id] + + def _request_eb(self, failure_, request, session_id): + if failure_.check(AdHocError): + error_constant = failure_.value.callback_error + else: + log.error(f"unexpected error while handling request: {failure_}") + error_constant = XEP_0050.ERROR.INTERNAL + + self._sendError(error_constant, session_id, request) + + def on_request(self, command_elt, requestor, action, session_id): + if not self.is_authorised(requestor): + return self._sendError( + XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent + ) + if session_id: + try: + session_data = self.sessions[session_id] + except KeyError: + return self._sendError( + XEP_0050.ERROR.SESSION_EXPIRED, session_id, command_elt.parent + ) + if session_data["requestor"] != requestor: + return self._sendError( + XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent + ) + else: + session_id, session_data = self.sessions.new_session() + session_data["requestor"] = requestor + if action == XEP_0050.ACTION.CANCEL: + d = defer.succeed((None, XEP_0050.STATUS.CANCELED, None, None)) + else: + d = utils.as_deferred( + self.callback, + self.client, + command_elt, + session_data, + action, + self.node, + ) + d.addCallback(self._sendAnswer, session_id, command_elt.parent) + d.addErrback(self._request_eb, command_elt.parent, session_id) + + +class XEP_0050(object): + STATUS = namedtuple("Status", ("EXECUTING", "COMPLETED", "CANCELED"))( + "executing", "completed", "canceled" + ) + ACTION = namedtuple("Action", ("EXECUTE", "CANCEL", "NEXT", "PREV"))( + "execute", "cancel", "next", "prev" + ) + NOTE = namedtuple("Note", ("INFO", "WARN", "ERROR"))("info", "warn", "error") + ERROR = namedtuple( + "Error", + ( + "MALFORMED_ACTION", + "BAD_ACTION", + "BAD_LOCALE", + "BAD_PAYLOAD", + "BAD_SESSIONID", + "SESSION_EXPIRED", + "FORBIDDEN", + "ITEM_NOT_FOUND", + "FEATURE_NOT_IMPLEMENTED", + "INTERNAL", + ), + )( + ("bad-request", "malformed-action"), + ("bad-request", "bad-action"), + ("bad-request", "bad-locale"), + ("bad-request", "bad-payload"), + ("bad-request", "bad-sessionid"), + ("not-allowed", "session-expired"), + ("forbidden", None), + ("item-not-found", None), + ("feature-not-implemented", None), + ("internal-server-error", None), + ) # XEP-0050 §4.4 Table 5 + + def __init__(self, host): + log.info(_("plugin XEP-0050 initialization")) + self.host = host + self.requesting = Sessions() + host.bridge.add_method( + "ad_hoc_run", + ".plugin", + in_sign="sss", + out_sign="s", + method=self._run, + async_=True, + ) + host.bridge.add_method( + "ad_hoc_list", + ".plugin", + in_sign="ss", + out_sign="s", + method=self._list_ui, + async_=True, + ) + host.bridge.add_method( + "ad_hoc_sequence", + ".plugin", + in_sign="ssss", + out_sign="s", + method=self._sequence, + async_=True, + ) + self.__requesting_id = host.register_callback( + self._requesting_entity, with_data=True + ) + host.import_menu( + (D_("Service"), D_("Commands")), + self._commands_menu, + security_limit=2, + help_string=D_("Execute ad-hoc commands"), + ) + host.register_namespace('commands', NS_COMMANDS) + + def get_handler(self, client): + return XEP_0050_handler(self) + + def profile_connected(self, client): + # map from node to AdHocCommand instance + client._XEP_0050_commands = {} + if not client.is_component: + self.add_ad_hoc_command(client, self._status_callback, _("Status")) + + def do(self, client, entity, node, action=ACTION.EXECUTE, session_id=None, + form_values=None, timeout=30): + """Do an Ad-Hoc Command + + @param entity(jid.JID): entity which will execture the command + @param node(unicode): node of the command + @param action(unicode): one of XEP_0050.ACTION + @param session_id(unicode, None): id of the ad-hoc session + None if no session is involved + @param form_values(dict, None): values to use to create command form + values will be passed to data_form.Form.makeFields + @return: iq result element + """ + iq_elt = client.IQ(timeout=timeout) + iq_elt["to"] = entity.full() + command_elt = iq_elt.addElement("command", NS_COMMANDS) + command_elt["node"] = node + command_elt["action"] = action + if session_id is not None: + command_elt["sessionid"] = session_id + + if form_values: + # We add the XMLUI result to the command payload + form = data_form.Form("submit") + form.makeFields(form_values) + command_elt.addChild(form.toElement()) + d = iq_elt.send() + return d + + def get_command_elt(self, iq_elt): + try: + return next(iq_elt.elements(NS_COMMANDS, "command")) + except StopIteration: + raise exceptions.NotFound(_("Missing command element")) + + def ad_hoc_error(self, error_type): + """Shortcut to raise an AdHocError + + @param error_type(unicode): one of XEP_0050.ERROR + """ + raise AdHocError(error_type) + + def _items_2_xmlui(self, items, no_instructions): + """Convert discovery items to XMLUI dialog """ + # TODO: manage items on different jids + form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id) + + if not no_instructions: + form_ui.addText(_("Please select a command"), "instructions") + + options = [(item.nodeIdentifier, item.name) for item in items] + form_ui.addList("node", options) + return form_ui + + def _get_data_lvl(self, type_): + """Return the constant corresponding to <note/> type attribute value + + @param type_: note type (see XEP-0050 §4.3) + @return: a C.XMLUI_DATA_LVL_* constant + """ + if type_ == "error": + return C.XMLUI_DATA_LVL_ERROR + elif type_ == "warn": + return C.XMLUI_DATA_LVL_WARNING + else: + if type_ != "info": + log.warning(_("Invalid note type [%s], using info") % type_) + return C.XMLUI_DATA_LVL_INFO + + def _merge_notes(self, notes): + """Merge notes with level prefix (e.g. "ERROR: the message") + + @param notes (list): list of tuple (level, message) + @return: list of messages + """ + lvl_map = { + C.XMLUI_DATA_LVL_INFO: "", + C.XMLUI_DATA_LVL_WARNING: "%s: " % _("WARNING"), + C.XMLUI_DATA_LVL_ERROR: "%s: " % _("ERROR"), + } + return ["%s%s" % (lvl_map[lvl], msg) for lvl, msg in notes] + + def parse_command_answer(self, iq_elt): + command_elt = self.get_command_elt(iq_elt) + data = {} + data["status"] = command_elt.getAttribute("status", XEP_0050.STATUS.EXECUTING) + data["session_id"] = command_elt.getAttribute("sessionid") + data["notes"] = notes = [] + for note_elt in command_elt.elements(NS_COMMANDS, "note"): + notes.append( + ( + self._get_data_lvl(note_elt.getAttribute("type", "info")), + str(note_elt), + ) + ) + + return command_elt, data + + + def _commands_answer_2_xmlui(self, iq_elt, session_id, session_data): + """Convert command answer to an ui for frontend + + @param iq_elt: command result + @param session_id: id of the session used with the frontend + @param profile_key: %(doc_profile_key)s + """ + command_elt, answer_data = self.parse_command_answer(iq_elt) + status = answer_data["status"] + if status in [XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED]: + # the command session is finished, we purge our session + del self.requesting[session_id] + if status == XEP_0050.STATUS.COMPLETED: + session_id = None + else: + return None + remote_session_id = answer_data["session_id"] + if remote_session_id: + session_data["remote_id"] = remote_session_id + notes = answer_data["notes"] + for data_elt in command_elt.elements(data_form.NS_X_DATA, "x"): + if data_elt["type"] in ("form", "result"): + break + else: + # no matching data element found + if status != XEP_0050.STATUS.COMPLETED: + log.warning( + _("No known payload found in ad-hoc command result, aborting") + ) + del self.requesting[session_id] + return xml_tools.XMLUI( + C.XMLUI_DIALOG, + dialog_opt={ + C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE, + C.XMLUI_DATA_MESS: _("No payload found"), + C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_ERROR, + }, + ) + if not notes: + # the status is completed, and we have no note to show + return None + + # if we have only one note, we show a dialog with the level of the note + # if we have more, we show a dialog with "info" level, and all notes merged + dlg_level = notes[0][0] if len(notes) == 1 else C.XMLUI_DATA_LVL_INFO + return xml_tools.XMLUI( + C.XMLUI_DIALOG, + dialog_opt={ + C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE, + C.XMLUI_DATA_MESS: "\n".join(self._merge_notes(notes)), + C.XMLUI_DATA_LVL: dlg_level, + }, + session_id=session_id, + ) + + if session_id is None: + xmlui = xml_tools.data_form_elt_result_2_xmlui(data_elt) + if notes: + for level, note in notes: + if level != "info": + note = f"[{level}] {note}" + xmlui.add_widget("text", note) + return xmlui + + form = data_form.Form.fromElement(data_elt) + # we add any present note to the instructions + form.instructions.extend(self._merge_notes(notes)) + return xml_tools.data_form_2_xmlui(form, self.__requesting_id, session_id=session_id) + + def _requesting_entity(self, data, profile): + def serialise(ret_data): + if "xmlui" in ret_data: + ret_data["xmlui"] = ret_data["xmlui"].toXml() + return ret_data + + d = self.requesting_entity(data, profile) + d.addCallback(serialise) + return d + + def requesting_entity(self, data, profile): + """Request and entity and create XMLUI accordingly. + + @param data: data returned by previous XMLUI (first one must come from + self._commands_menu) + @param profile: %(doc_profile)s + @return: callback dict result (with "xmlui" corresponding to the answering + dialog, or empty if it's finished without error) + """ + if C.bool(data.get("cancelled", C.BOOL_FALSE)): + return defer.succeed({}) + data_form_values = xml_tools.xmlui_result_2_data_form_result(data) + client = self.host.get_client(profile) + # TODO: cancel, prev and next are not managed + # TODO: managed answerer errors + # TODO: manage nodes with a non data form payload + if "session_id" not in data: + # we just had the jid, we now request it for the available commands + session_id, session_data = self.requesting.new_session(profile=client.profile) + entity = jid.JID(data[xml_tools.SAT_FORM_PREFIX + "jid"]) + session_data["jid"] = entity + d = self.list_ui(client, entity) + + def send_items(xmlui): + xmlui.session_id = session_id # we need to keep track of the session + return {"xmlui": xmlui} + + d.addCallback(send_items) + else: + # we have started a several forms sessions + try: + session_data = self.requesting.profile_get( + data["session_id"], client.profile + ) + except KeyError: + log.warning("session id doesn't exist, session has probably expired") + # TODO: send error dialog + return defer.succeed({}) + session_id = data["session_id"] + entity = session_data["jid"] + try: + session_data["node"] + # node has already been received + except KeyError: + # it's the first time we know the node, we save it in session data + session_data["node"] = data_form_values.pop("node") + + # remote_id is the XEP_0050 sessionid used by answering command + # while session_id is our own session id used with the frontend + remote_id = session_data.get("remote_id") + + # we request execute node's command + d = self.do(client, entity, session_data["node"], action=XEP_0050.ACTION.EXECUTE, + session_id=remote_id, form_values=data_form_values) + d.addCallback(self._commands_answer_2_xmlui, session_id, session_data) + d.addCallback(lambda xmlui: {"xmlui": xmlui} if xmlui is not None else {}) + + return d + + def _commands_menu(self, menu_data, profile): + """First XMLUI activated by menu: ask for target jid + + @param profile: %(doc_profile)s + """ + form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id) + form_ui.addText(_("Please enter target jid"), "instructions") + form_ui.change_container("pairs") + form_ui.addLabel("jid") + form_ui.addString("jid", value=self.host.get_client(profile).jid.host) + return {"xmlui": form_ui.toXml()} + + def _status_callback(self, client, command_elt, session_data, action, node): + """Ad-hoc command used to change the "show" part of status""" + actions = session_data.setdefault("actions", []) + actions.append(action) + + if len(actions) == 1: + # it's our first request, we ask the desired new status + status = XEP_0050.STATUS.EXECUTING + form = data_form.Form("form", title=_("status selection")) + show_options = [ + data_form.Option(name, label) for name, label in list(SHOWS.items()) + ] + field = data_form.Field( + "list-single", "show", options=show_options, required=True + ) + form.addField(field) + + payload = form.toElement() + note = None + + elif len(actions) == 2: + # we should have the answer here + try: + x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x")) + answer_form = data_form.Form.fromElement(x_elt) + show = answer_form["show"] + except (KeyError, StopIteration): + self.ad_hoc_error(XEP_0050.ERROR.BAD_PAYLOAD) + if show not in SHOWS: + self.ad_hoc_error(XEP_0050.ERROR.BAD_PAYLOAD) + if show == "disconnect": + self.host.disconnect(client.profile) + else: + self.host.presence_set(show=show, profile_key=client.profile) + + # job done, we can end the session + status = XEP_0050.STATUS.COMPLETED + payload = None + note = (self.NOTE.INFO, _("Status updated")) + else: + self.ad_hoc_error(XEP_0050.ERROR.INTERNAL) + + return (payload, status, None, note) + + def _run(self, service_jid_s="", node="", profile_key=C.PROF_KEY_NONE): + client = self.host.get_client(profile_key) + service_jid = jid.JID(service_jid_s) if service_jid_s else None + d = defer.ensureDeferred(self.run(client, service_jid, node or None)) + d.addCallback(lambda xmlui: xmlui.toXml()) + return d + + async def run(self, client, service_jid=None, node=None): + """Run an ad-hoc command + + @param service_jid(jid.JID, None): jid of the ad-hoc service + None to use profile's server + @param node(unicode, None): node of the ad-hoc commnad + None to get initial list + @return(unicode): command page XMLUI + """ + if service_jid is None: + service_jid = jid.JID(client.jid.host) + session_id, session_data = self.requesting.new_session(profile=client.profile) + session_data["jid"] = service_jid + if node is None: + xmlui = await self.list_ui(client, service_jid) + else: + session_data["node"] = node + cb_data = await self.requesting_entity( + {"session_id": session_id}, client.profile + ) + xmlui = cb_data["xmlui"] + + xmlui.session_id = session_id + return xmlui + + def list(self, client, to_jid): + """Request available commands + + @param to_jid(jid.JID, None): the entity answering the commands + None to use profile's server + @return D(disco.DiscoItems): found commands + """ + d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS) + return d + + def _list_ui(self, to_jid_s, profile_key): + client = self.host.get_client(profile_key) + to_jid = jid.JID(to_jid_s) if to_jid_s else None + d = self.list_ui(client, to_jid, no_instructions=True) + d.addCallback(lambda xmlui: xmlui.toXml()) + return d + + def list_ui(self, client, to_jid, no_instructions=False): + """Request available commands and generate XMLUI + + @param to_jid(jid.JID, None): the entity answering the commands + None to use profile's server + @param no_instructions(bool): if True, don't add instructions widget + @return D(xml_tools.XMLUI): UI with the commands + """ + d = self.list(client, to_jid) + d.addCallback(self._items_2_xmlui, no_instructions) + return d + + def _sequence(self, sequence, node, service_jid_s="", profile_key=C.PROF_KEY_NONE): + sequence = data_format.deserialise(sequence, type_check=list) + client = self.host.get_client(profile_key) + service_jid = jid.JID(service_jid_s) if service_jid_s else None + d = defer.ensureDeferred(self.sequence(client, sequence, node, service_jid)) + d.addCallback(lambda data: data_format.serialise(data)) + return d + + async def sequence( + self, + client: SatXMPPEntity, + sequence: List[dict], + node: str, + service_jid: Optional[jid.JID] = None, + ) -> dict: + """Send a series of data to an ad-hoc service + + @param sequence: list of values to send + value are specified by a dict mapping var name to value. + @param node: node of the ad-hoc commnad + @param service_jid: jid of the ad-hoc service + None to use profile's server + @return: data received in final answer + """ + if service_jid is None: + service_jid = jid.JID(client.jid.host) + + session_id = None + + for data_to_send in sequence: + iq_result_elt = await self.do( + client, + service_jid, + node, + session_id=session_id, + form_values=data_to_send, + ) + __, answer_data = self.parse_command_answer(iq_result_elt) + session_id = answer_data.pop("session_id") + + return answer_data + + def add_ad_hoc_command(self, client, callback, label, node=None, features=None, + timeout=600, allowed_jids=None, allowed_groups=None, + allowed_magics=None, forbidden_jids=None, forbidden_groups=None, + ): + """Add an ad-hoc command for the current profile + + @param callback: method associated with this ad-hoc command which return the + payload data (see AdHocCommand._sendAnswer), can return a + deferred + @param label: label associated with this command on the main menu + @param node: disco item node associated with this command. None to use + autogenerated node + @param features: features associated with the payload (list of strings), usualy + data form + @param timeout: delay between two requests before canceling the session (in + seconds) + @param allowed_jids: list of allowed entities + @param allowed_groups: list of allowed roster groups + @param allowed_magics: list of allowed magic keys, can be: + @ALL@: allow everybody + @PROFILE_BAREJID@: allow only the jid of the profile + @param forbidden_jids: black list of entities which can't access this command + @param forbidden_groups: black list of groups which can't access this command + @return: node of the added command, useful to remove the command later + """ + # FIXME: "@ALL@" for profile_key seems useless and dangerous + + if node is None: + node = "%s_%s" % ("COMMANDS", uuid4()) + + if features is None: + features = [data_form.NS_X_DATA] + + if allowed_jids is None: + allowed_jids = [] + if allowed_groups is None: + allowed_groups = [] + if allowed_magics is None: + allowed_magics = ["@PROFILE_BAREJID@"] + if forbidden_jids is None: + forbidden_jids = [] + if forbidden_groups is None: + forbidden_groups = [] + + # TODO: manage newly created/removed profiles + _allowed_jids = ( + (allowed_jids + [client.jid.userhostJID()]) + if "@PROFILE_BAREJID@" in allowed_magics + else allowed_jids + ) + ad_hoc_command = AdHocCommand( + callback, + label, + node, + features, + timeout, + _allowed_jids, + allowed_groups, + allowed_magics, + forbidden_jids, + forbidden_groups, + ) + ad_hoc_command.setHandlerParent(client) + commands = client._XEP_0050_commands + commands[node] = ad_hoc_command + + def on_cmd_request(self, request, client): + request.handled = True + requestor = jid.JID(request["from"]) + command_elt = next(request.elements(NS_COMMANDS, "command")) + action = command_elt.getAttribute("action", self.ACTION.EXECUTE) + node = command_elt.getAttribute("node") + if not node: + client.sendError(request, "bad-request") + return + sessionid = command_elt.getAttribute("sessionid") + commands = client._XEP_0050_commands + try: + command = commands[node] + except KeyError: + client.sendError(request, "item-not-found") + return + command.on_request(command_elt, requestor, action, sessionid) + + +@implementer(iwokkel.IDisco) +class XEP_0050_handler(XMPPHandler): + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + + @property + def client(self): + return self.parent + + def connectionInitialized(self): + self.xmlstream.addObserver( + CMD_REQUEST, self.plugin_parent.on_cmd_request, client=self.parent + ) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=""): + identities = [] + if nodeIdentifier == NS_COMMANDS and self.client._XEP_0050_commands: + # we only add the identity if we have registred commands + identities.append(ID_CMD_LIST) + return [disco.DiscoFeature(NS_COMMANDS)] + identities + + def getDiscoItems(self, requestor, target, nodeIdentifier=""): + ret = [] + if nodeIdentifier == NS_COMMANDS: + commands = self.client._XEP_0050_commands + for command in list(commands.values()): + if command.is_authorised(requestor): + ret.append( + disco.DiscoItem(self.parent.jid, command.node, command.getName()) + ) # TODO: manage name language + return ret