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