#!/usr/bin/env python3 # Libervia plugin for Ad-Hoc Commands (XEP-0050) # Copyright (C) 2009-2023 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 . from collections.abc import Callable, Coroutine from enum import Enum, StrEnum, auto from typing import Any, Self, cast import importlib from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator from pydantic import field_serializer from twisted.internet import defer from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber.error import StanzaError from twisted.words.protocols.jabber.xmlstream import XMPPHandler from twisted.words.xish import domish from wokkel import data_form, disco, iwokkel from zope.interface import implementer from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C from libervia.backend.core.core_types import SatXMPPClient, SatXMPPEntity from libervia.backend.core.i18n import D_, _ from libervia.backend.core.log import getLogger from libervia.backend.memory.memory import SessionData, Sessions from libervia.backend.models.types import DomishElementType, JIDType from libervia.backend.plugins.plugin_xep_0004 import DataForm from libervia.backend.tools import utils, xml_tools 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"""), } ALLOWED_MAGICS = {C.ENTITY_ALL, C.ENTITY_PROFILE_BARE, C.ENTITY_ADMINS} class Status(StrEnum): EXECUTING = auto() COMPLETED = auto() CANCELED = auto() class Action(StrEnum): EXECUTE = auto() CANCEL = auto() NEXT = auto() PREV = auto() class NoteType(StrEnum): INFO = auto() WARN = auto() ERROR = auto() class Error(tuple[str, str | None], Enum): """Errors from XEP-0050 §4.4 Table 5""" MALFORMED_ACTION = ("bad-request", "malformed-action") BAD_ACTION = ("bad-request", "bad-action") BAD_LOCALE = ("bad-request", "bad-locale") BAD_PAYLOAD = ("bad-request", "bad-payload") BAD_SESSIONID = ("bad-request", "bad-sessionid") SESSION_EXPIRED = ("not-allowed", "session-expired") FORBIDDEN = ("forbidden", None) ITEM_NOT_FOUND = ("item-not-found", None) FEATURE_NOT_IMPLEMENTED = ("feature-not-implemented", None) INTERNAL = ("internal-server-error", None) class AdHocError(Exception): def __init__(self, error_const, text=None, lang=None): """Error to be used from callback @param error_const: one of Error """ assert error_const in Error self.callback_error = error_const self.text = text self.lang = lang class Note(BaseModel): type: NoteType = NoteType.INFO text: str class AdHocCallbackData(BaseModel): """Pydantic model for ad-hoc command callback data. This model represents the data structure used in callback functions to return information about command execution results. """ # Note: While XEP-0050 theoretically allows using something other than a form as # payload (XEP-0050 §1.3 #prerequisites), forms are always used in practice. # Therefore, only forms are currently supported. However, in the future, a "payload: # DomishElementType|None" field could be added, which would be mutually exclusive # with "form". form: DataForm | None = Field( default=None, description="The form element which will be used as payload." ) status: Status = Field(description="Current status of the command execution") actions: list[Action] = Field( default_factory=lambda: list([Action.EXECUTE]), description=( "List of allowed actions (see Action enum). First action is the default one. " "Default to EXECUTE" ), ) notes: list[Note] = Field( default_factory=list, description="Command status notes (cf. XEP-0050 §4.3).", ) class AdHocCommand(BaseModel): """Pydantic model for ad-hoc command parameters. This model represents the parameters used to create an AdHocCommand instance. """ callback: Callable[ [SatXMPPEntity, domish.Element, SessionData, Action, str], Coroutine[None, None, AdHocCallbackData] | AdHocCallbackData, ] = Field( description=( "The callback function to execute when command is invoked. The " 'import string "path.to.module.function_to_call" can be used.\n\n' "The callback takes the following parameters:\n" "- client (SatXMPPEntity): The XMPP client instance\n" "- command_elt (domish.Element): The command element from the request\n" "- session_data (SessionData): Session data for this command execution\n" "- action (Action): The action being performed (EXECUTE, CANCEL, etc.)\n" "- node (str): The command node identifier\n" "The callback must return an AdHocCallbackData instance." ) ) label: str = Field(description="Label for the command displayed in UI") node: str = Field( default="", # We need the validator to set a value based on label if "node" is not specified. validate_default=True, description="Unique identifier for this command", ) form: DataForm | None = Field( default=None, description=( "Form used for the first page of the command. If not set, the callback must " "handle the initial page, which may include a form, or return results " "directly." ), ) features: list[str] = Field( default_factory=lambda: list([data_form.NS_X_DATA]), description="List of features supported by this command", ) timeout: int = Field(default=30, description="Session timeout in seconds") allowed_jids: set[JIDType] = Field( default_factory=set, description="List of JIDs that are allowed to use this command", ) allowed_groups: list[str] = Field( default_factory=list, description="List of roster groups that are allowed to use this command", ) allowed_magics: list[str] = Field( default_factory=lambda: list([C.ENTITY_PROFILE_BARE]), description=( "List of magic identifiers for special access rules. Possible values include:" " C.ENTITY_ALL (allow everybody), C.ENTITY_PROFILE_BARE (allow only the jid " "of the profile), C.ENTITY_ADMINS (any administrator user)" ), ) forbidden_jids: list[JIDType] = Field( default_factory=list, description="List of JIDs that are forbidden from using this command", ) forbidden_groups: list[str] = Field( default_factory=list, description="List of roster groups that are forbidden from using this command", ) @staticmethod def get_node_from_label(label: str) -> str: """Generate a node from the label.""" label = label.strip() assert label node = label.lower().replace(" ", "_") return node @field_validator("label", mode="after") @classmethod def check_label_not_empty(cls, value: str) -> str: """Check that "label" is not empty. @param value: value to check. """ if not value.strip(): raise ValueError('"label" can\'t be empty.') return value @field_validator("node", mode="after") @classmethod def set_default_node(cls, value: str, info: ValidationInfo) -> str: """Set a default node if it's empty. @param value: value to check. """ if not value.strip(): return cls.get_node_from_label(info.data["label"]) return value @field_validator("form", mode="after") @classmethod def ensure_form_type(cls, form: DataForm | None) -> DataForm | None: if form is not None and form.type != "form": raise ValueError( 'Only "form" type is allowed for the data form at "form" field.' ) return form @field_validator("allowed_magics", mode="after") @classmethod def check_allowed_magics(cls, value: str) -> str: """Check that "allowed_magics" is a valid value. @param value: value to check. """ if not ALLOWED_MAGICS.issuperset(value): raise ValueError( f'"allowed_magics" must be one of {", ".join(ALLOWED_MAGICS)}.' ) return value # Both following methods are inspired from # https://github.com/pydantic/pydantic/discussions/3911 # Thanks! @field_serializer("callback") def serialize_callback(self, callback: Callable) -> str: try: module_path = callback.__module__ function_name = callback.__name__ except AttributeError: raise ValueError( f"{callback} doesn't have a __module__ or __name__ attribute." ) return module_path + "." + function_name @field_validator("callback", mode="before", json_schema_input_type=str) @classmethod def import_callback(cls, value: Any) -> Callable: """Accept import string or callable. @param value: May be a callable or a string representation of the callable (e.g., 'module.function') @return: The imported callable. """ if isinstance(value, str): module_name, function_name = value.rsplit(".", 1) module = importlib.import_module(module_name) return getattr(module, function_name) elif isinstance(value, Callable): return value else: raise ValueError('"str" or "Callable are expected for "callback".') @implementer(iwokkel.IDisco) class AdHocCommandHandler(XMPPHandler): """Implementation of an Ad-Hoc Command handler for XMPP. This class handles the execution of ad-hoc commands as defined in XEP-0050. It manages command sessions, authorization checks, and communication with clients. """ def __init__(self, command: AdHocCommand) -> None: """Initialize the AdHocCommand handler. @param command: The AdHocCommand instance containing all command configuration """ XMPPHandler.__init__(self) self.command = command self.sessions = Sessions(timeout=command.timeout) @property def client(self) -> SatXMPPEntity: """Get the client associated with this command handler. @return: The XMPP client instance """ assert self.parent is not None return self.parent def get_name(self, xml_lang: str | None = None) -> str: """Get the name of this command for display purposes. @param xml_lang: Language code for localization (unused) @return: The label of this command """ return self.command.label def is_authorised(self, requestor: jid.JID) -> bool: """Check if a requestor is authorized to use this command. @param requestor: JID of the entity requesting access @return: True if authorized, False otherwise @raise exceptions.InternalError: A permission with groups is used with a component. """ if C.ENTITY_ALL in self.command.allowed_magics: return True forbidden = set(self.command.forbidden_jids) if self.command.forbidden_groups: if not isinstance(self.client, SatXMPPClient): raise exceptions.InternalError( "\"forbidden_groups\" can't be used which components, as they don't " "have roster." ) for group in self.command.forbidden_groups: forbidden.update(self.client.roster.get_jids_from_group(group)) if requestor.userhostJID() in forbidden: return False allowed = self.command.allowed_jids if self.command.allowed_groups: if not isinstance(self.client, SatXMPPClient): raise exceptions.InternalError( "\"allowed_groups\" can't be used which components, as they don't " "have roster." ) for group in self.command.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: jid.JID, target: jid.JID, nodeIdentifier: str = "" ) -> list[disco.DiscoFeature]: """Get disco info for this command. @param requestor: JID of the entity requesting information @param target: Target JID @param nodeIdentifier: Node identifier (unused) @return: List of disco features supported by this command """ 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)] + [ disco.DiscoFeature(feature) for feature in self.command.features ] def getDiscoItems( self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = "" ) -> list[disco.DiscoItem]: """Get disco items for this command. @param requestor: JID of the entity requesting information @param target: Target JID @param nodeIdentifier: Node identifier (unused) @return: Empty list as no items are supported """ return [] async def _send_answer( self, callback_data: AdHocCallbackData, session_id: str, request: domish.Element, ) -> None: """Send result of the command. @param callback_data: Data returned by the callback. @param session_id: current session id @param request: original request (domish.Element) """ status = callback_data.status 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.command.node command_elt["status"] = status if status != Status.CANCELED: if status != Status.COMPLETED: actions = callback_data.actions assert actions actions_elt = command_elt.addElement("actions") actions_elt["execute"] = actions[0] for action in actions: actions_elt.addElement(action) for note in callback_data.notes: note_elt = command_elt.addElement("note", content=note.text) note_elt["type"] = note.type if callback_data.form is not None: command_elt.addChild(callback_data.form.to_element()) await self.client.a_send(result) if status in (Status.COMPLETED, Status.CANCELED): del self.sessions[session_id] async def _send_error( self, error_constant: Error, session_id: str | None, request: domish.Element, text: str | None = None, lang: str | None = None, ) -> None: """Send error stanza. @param error_constant: Error tuple. @param session_id: Session ID. @param request: Original request. """ xmpp_condition, cmd_condition = error_constant iq_elt = StanzaError(xmpp_condition, text=text, textLang=lang).toResponse(request) if cmd_condition: error_elt = next(iq_elt.elements(None, "error")) error_elt.addElement(cmd_condition, NS_COMMANDS) await self.client.a_send(iq_elt) if session_id is not None: del self.sessions[session_id] async def on_request( self, command_elt: domish.Element, requestor: jid.JID, action: Action, session_id: str | None, ) -> None: """Handle an incoming command request. @param command_elt: The command element from the request @param requestor: JID of the entity making the request @param action: The action to perform (execute, cancel, etc.) @param session_id: Session ID if continuing an existing session @return: Deferred for handling the response """ request_elt = command_elt.parent assert request_elt is not None if not self.is_authorised(requestor): return await self._send_error(Error.FORBIDDEN, session_id, request_elt) if session_id: try: session_data = self.sessions[session_id] except KeyError: return await self._send_error( Error.SESSION_EXPIRED, session_id, request_elt ) if session_data["requestor"] != requestor: return await self._send_error(Error.FORBIDDEN, session_id, request_elt) else: session_id, session_data = self.sessions.new_session() session_data["requestor"] = requestor if action != Action.EXECUTE: log.warning( "Unexpected action on first page request of ad-hoc command: " f"{action!r}." ) await self._send_error(Error.BAD_ACTION, session_id, request_elt) return # This is the first request, if a initial form is specified, we return it # directly. if self.command.form is not None: await self._send_answer( AdHocCallbackData(form=self.command.form, status=Status.EXECUTING), session_id, request_elt, ) return if action == Action.CANCEL: ret_data = AdHocCallbackData(form=None, status=Status.CANCELED) else: try: ret_data = await utils.maybe_async( self.command.callback( self.client, command_elt, session_data, action, self.command.node, ) ) except AdHocError as e: await self._send_error( e.callback_error, session_id, request_elt, text=e.text, lang=e.lang ) return except Exception: log.exception(f"Unexpected error while handling request.") await self._send_error(Error.INTERNAL, session_id, request_elt) return await self._send_answer(ret_data, session_id, request_elt) class XEP_0050: 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 AdHocCommandHandler instance client._XEP_0050_commands = {} if not client.is_component: self.add_ad_hoc_command(client, self._status_callback, _("Status")) def do( self, client: SatXMPPEntity, entity: jid.JID, node: str, action: Action = Action.EXECUTE, session_id: str | None = None, form_values: dict[str, Any] | None = None, timeout: int = 30, ) -> defer.Deferred[domish.Element]: """Do an Ad-Hoc Command. @param entity: Entity which will execute the command. @param node: Node of the command. @param action: One of Action. @param session_id: ID of the ad-hoc session. None if no session is involved. @param form_values: 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: Error): """Shortcut to raise an AdHocError @param error_type(unicode): one of 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 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", 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 [Status.COMPLETED, Status.CANCELED]: # the command session is finished, we purge our session del self.requesting[session_id] if status == 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 != 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 = defer.ensureDeferred(self.requesting_entity(data, profile)) d.addCallback(serialise) return d async 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 {} 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 xmlui = await self.list_ui(client, entity) # we need to keep track of the session xmlui.session_id = session_id return {"xmlui": xmlui} 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 {} 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 iq_result_elt = await self.do( client, entity, session_data["node"], action=Action.EXECUTE, session_id=remote_id, form_values=data_form_values, ) xmlui = self._commands_answer_2_xmlui(iq_result_elt, session_id, session_data) return {"xmlui": xmlui} if xmlui is not None else {} 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: SatXMPPEntity, command_elt: domish.Element, session_data: SessionData, action: Action, node: str, ) -> AdHocCallbackData: """Ad-hoc command used to change the "show" part of status. @param client: Libervia client instance. @param command_elt: The command element from the request. @param session_data: Session data for this command execution. @param action: The action being performed (EXECUTE, CANCEL, etc.) @param node: The command node identifier. @return: Command results. @raise AdHocError: Something went wrong. """ actions = session_data.setdefault("actions", []) actions.append(action) if len(actions) == 1: # it's our first request, we ask the desired new status wokkel_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 ) wokkel_form.addField(field) return AdHocCallbackData( form=DataForm.from_wokkel_form(wokkel_form), status=Status.EXECUTING ) 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(Error.BAD_PAYLOAD) if show not in SHOWS: self.ad_hoc_error(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 return AdHocCallbackData( status=Status.COMPLETED, notes=[Note(text=_("Status updated"))] ) else: raise AdHocError(Error.INTERNAL) 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_commands(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_commands(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: jid.JID | None = 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 """ assert sequence answer_data = None 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") assert answer_data is not None return answer_data def register_ad_hoc_command( self, client: SatXMPPEntity, command: AdHocCommand ) -> str: """Register an ad-hoc command using an AdHocCommand instance. @param client: The XMPP client to register the command for @param command: The AdHocCommand instance containing all command configuration @return: node of the added command, useful to remove the command later """ command_handlers = client._XEP_0050_commands if command.node in command_handlers: raise exceptions.ConflictError( f"There is already a known node with the name {command.node!r}." ) # TODO: manage newly created/removed profiles if C.ENTITY_PROFILE_BARE in command.allowed_magics: command.allowed_jids.add(client.jid.userhostJID()) # TODO: manage dynamic addition/removal of admin status once possible if C.ENTITY_ADMINS in command.allowed_magics: command.allowed_jids.update(self.host.memory.admin_jids) # Create the handler using the command parameters command_handler = AdHocCommandHandler(command) command_handler.setHandlerParent(client) command_handlers[command.node] = command_handler return command.node def add_ad_hoc_command( self, client: SatXMPPEntity, callback: Callable, label: str, node: str | None = None, features: list[str] | None = None, timeout: int = 600, allowed_jids: list[jid.JID] | None = None, allowed_groups: list[str] | None = None, allowed_magics: list[str] | None = None, forbidden_jids: list[jid.JID] | None = None, forbidden_groups: list[str] | None = None, ) -> str: """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: C.ENTITY_ALL: allow everybody C.ENTITY_PROFILE_BARE: allow only the jid of the profile C.ENTITY_ADMINS: any administrator user @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 """ kwargs = { "callback": callback, "label": label, "node": node, "features": features, "timeout": timeout, "allowed_jids": allowed_jids, "allowed_groups": allowed_groups, "allowed_magics": allowed_magics, "forbidden_jids": forbidden_jids, "forbidden_groups": forbidden_groups, } kwargs = {k: v for k, v in kwargs.items() if v is not None} return self.register_ad_hoc_command(client, AdHocCommand(**kwargs)) 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", Action.EXECUTE) node = command_elt.getAttribute("node") if not node: client.sendError(request, "bad-request") return sessionid = command_elt.getAttribute("sessionid") command_handlers = client._XEP_0050_commands try: command = cast(AdHocCommandHandler, command_handlers[node]) except KeyError: client.sendError(request, "item-not-found") return defer.ensureDeferred( 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) -> SatXMPPEntity: assert self.parent is not None 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: command_handlers: dict[str, AdHocCommandHandler] = ( self.client._XEP_0050_commands ) for command_handler in list(command_handlers.values()): if command_handler.is_authorised(requestor): ret.append( disco.DiscoItem( self.client.jid, command_handler.command.node, command_handler.get_name(), ) ) # TODO: manage name language return ret