diff --git a/libervia/backend/plugins/plugin_xep_0050.py b/libervia/backend/plugins/plugin_xep_0050.py index 0821077d..f6669cc9 100644 --- a/libervia/backend/plugins/plugin_xep_0050.py +++ b/libervia/backend/plugins/plugin_xep_0050.py @@ -19,7 +19,7 @@ from collections.abc import Callable, Coroutine from enum import Enum, StrEnum, auto -from typing import Any, Self, cast +from typing import Any, NamedTuple, Self, cast import importlib from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator @@ -153,6 +153,34 @@ class AdHocCallbackData(BaseModel): ) +class PageData(NamedTuple): + """Information about the current page of an ad-hoc command. + + @param iq_elt: The original IQ request element containing the command element and its + context. + @param form: The parsed form which has been submitted by the user. The form is parsed + with its sources specified, meaning that field types should be set correctly. + In the rare case when there is no form (may happen for first page if form is + specified in ``AdHocCommand``), an empty form will be used. + @param requestor: The JID of the entity requesting the command execution. + @param session_data: A dictionary persisting for the whole command session that can be + used by the callback function to store any kind of data. + @param action: The action being performed as defined in the Action enum. + @param node: The node identifier of the command being executed. + @param idx: The index of the current page in the command sequence. + The index is automatically adjusted accorded to the action, it can be used by the + callback to determine which page to show. + """ + + iq_elt: domish.Element + form: DataForm + requestor: jid.JID + session_data: SessionData + action: Action + node: str + idx: int + + class AdHocCommand(BaseModel): """Pydantic model for ad-hoc command parameters. @@ -160,7 +188,7 @@ class AdHocCommand(BaseModel): """ callback: Callable[ - [SatXMPPEntity, domish.Element, SessionData, Action, str], + [SatXMPPEntity, PageData], Coroutine[None, None, AdHocCallbackData] | AdHocCallbackData, ] = Field( description=( @@ -168,10 +196,7 @@ class AdHocCommand(BaseModel): '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" + "- page_data (PageData): Data on current page of the command.\n" "The callback must return an AdHocCallbackData instance." ) ) @@ -416,7 +441,8 @@ class AdHocCommandHandler(XMPPHandler): self, callback_data: AdHocCallbackData, session_id: str, - request: domish.Element, + session_data: dict, + iq_elt: domish.Element, ) -> None: """Send result of the command. @@ -427,8 +453,8 @@ class AdHocCommandHandler(XMPPHandler): status = callback_data.status result = domish.Element((None, "iq")) result["type"] = "result" - result["id"] = request["id"] - result["to"] = request["from"] + result["id"] = iq_elt["id"] + result["to"] = iq_elt["from"] command_elt = result.addElement("command", NS_COMMANDS) command_elt["sessionid"] = session_id command_elt["node"] = self.command.node @@ -453,6 +479,9 @@ class AdHocCommandHandler(XMPPHandler): await self.client.a_send(result) if status in (Status.COMPLETED, Status.CANCELED): del self.sessions[session_id] + else: + # We store form used in pages so we can get submitted forms fields types. + session_data["forms"][session_data["page_idx"]] = callback_data.form async def _send_error( self, @@ -492,30 +521,46 @@ class AdHocCommandHandler(XMPPHandler): @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 + iq_elt = command_elt.parent + assert iq_elt is not None if not self.is_authorised(requestor): - return await self._send_error(Error.FORBIDDEN, session_id, request_elt) + return await self._send_error(Error.FORBIDDEN, session_id, iq_elt) if session_id: try: - session_data = self.sessions[session_id] + session_data_global = 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) + return await self._send_error(Error.SESSION_EXPIRED, session_id, iq_elt) + else: + session_data_private = session_data_global["private"] + session_data_public = session_data_global["public"] + if session_data_private["requestor"] != requestor: + return await self._send_error(Error.FORBIDDEN, session_id, iq_elt) + forms = session_data_private["forms"] + if action == Action.PREV: + if session_data_private["page_idx"] > 0: + session_data_private["page_idx"] -= 1 + else: + session_data_private["page_idx"] += 1 + page_idx = session_data_private["page_idx"] else: - session_id, session_data = self.sessions.new_session() - session_data["requestor"] = requestor + session_id, session_data_global = self.sessions.new_session() + # We use 2 dict: one for private date use to keep track of internal things + # such as page index, that we expose to callback through ``PageData``, and the + # public one which is exposed to callback through ``PageData.session_data``. + session_data_private = session_data_global["private"] = {} + session_data_public = session_data_global["public"] = {} + session_data_private["requestor"] = requestor + page_idx = session_data_private["page_idx"] = 0 + # Maps page_idx to corresponding form. + forms = session_data_private["forms"] 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) + await self._send_error(Error.BAD_ACTION, session_id, iq_elt) return # This is the first request, if a initial form is specified, we return it # directly. @@ -523,34 +568,47 @@ class AdHocCommandHandler(XMPPHandler): await self._send_answer( AdHocCallbackData(form=self.command.form, status=Status.EXECUTING), session_id, - request_elt, + session_data_private, + iq_elt, ) return if action == Action.CANCEL: ret_data = AdHocCallbackData(form=None, status=Status.CANCELED) else: + if page_idx == 0: + # We use an empty DataForm instead of None here because we want PageData + # to always have a form, to avoid having to test its existence each time. + # The case when there is no form should be rare: where we are on the first + # page and the form is not already specified with ``AdHocCommand``. + form = DataForm() + else: + last_form = forms[page_idx - 1] + form = DataForm.from_element(command_elt, source_form=last_form) + page_data = PageData( + iq_elt=iq_elt, + form=form, + requestor=requestor, + session_data=session_data_public, + action=action, + node=self.command.node, + idx=page_idx, + ) try: ret_data = await utils.maybe_async( - self.command.callback( - self.client, - command_elt, - session_data, - action, - self.command.node, - ) + self.command.callback(self.client, page_data) ) except AdHocError as e: await self._send_error( - e.callback_error, session_id, request_elt, text=e.text, lang=e.lang + e.callback_error, session_id, iq_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) + await self._send_error(Error.INTERNAL, session_id, iq_elt) return - await self._send_answer(ret_data, session_id, request_elt) + await self._send_answer(ret_data, session_id, session_data_private, iq_elt) class XEP_0050: @@ -653,8 +711,18 @@ class XEP_0050: """ raise AdHocError(error_type) - def _items_2_xmlui(self, items, no_instructions): - """Convert discovery items to XMLUI dialog""" + def _items_2_xmlui( + self, items: list[disco.DiscoItem], no_instructions: bool + ) -> xml_tools.XMLUI: + """Convert discovery items to XMLUI dialog + + This method transforms a list of discovery items into an XMLUI form + that can be presented to the user for selecting a command. + + @param items: List of discovery items to convert + @param no_instructions: If True, don't add instructions widget + @return: XMLUI form representing the command selection interface + """ # TODO: manage items on different jids form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id) @@ -929,14 +997,20 @@ class XEP_0050: d.addCallback(lambda xmlui: xmlui.toXml()) return d - async def run(self, client, service_jid=None, node=None): + async def run( + self, + client: SatXMPPEntity, + service_jid: jid.JID | None = None, + node: str | None = None, + ) -> xml_tools.XMLUI: """Run an ad-hoc command - @param service_jid(jid.JID, None): jid of the ad-hoc service + @param client: XMPP entity client + @param service_jid: jid of the ad-hoc service None to use profile's server - @param node(unicode, None): node of the ad-hoc commnad + @param node: node of the ad-hoc commnad None to get initial list - @return(unicode): command page XMLUI + @return: command page XMLUI """ if service_jid is None: service_jid = jid.JID(client.jid.host) @@ -954,12 +1028,14 @@ class XEP_0050: xmlui.session_id = session_id return xmlui - def list_commands(self, client, to_jid): + def list_commands( + self, client: SatXMPPEntity, to_jid: jid.JID | None + ) -> defer.Deferred[disco.DiscoItems]: """Request available commands - @param to_jid(jid.JID, None): the entity answering the commands + @param to_jid: the entity answering the commands None to use profile's server - @return D(disco.DiscoItems): found commands + @return: found commands """ d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS) return d @@ -971,13 +1047,15 @@ class XEP_0050: d.addCallback(lambda xmlui: xmlui.toXml()) return d - def list_ui(self, client, to_jid, no_instructions=False): + def list_ui( + self, client: SatXMPPEntity, to_jid: jid.JID | None, no_instructions: bool = False + ) -> defer.Deferred[xml_tools.XMLUI]: """Request available commands and generate XMLUI - @param to_jid(jid.JID, None): the entity answering the commands + @param to_jid: 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 + @param no_instructions: if True, don't add instructions widget + @return: UI with the commands """ d = self.list_commands(client, to_jid) d.addCallback(self._items_2_xmlui, no_instructions) @@ -1108,21 +1186,21 @@ class XEP_0050: 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")) + def on_cmd_request(self, iq_elt, client): + iq_elt.handled = True + requestor = jid.JID(iq_elt["from"]) + command_elt = next(iq_elt.elements(NS_COMMANDS, "command")) action = command_elt.getAttribute("action", Action.EXECUTE) node = command_elt.getAttribute("node") if not node: - client.sendError(request, "bad-request") + client.sendError(iq_elt, "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") + client.sendError(iq_elt, "item-not-found") return defer.ensureDeferred( command.on_request(command_elt, requestor, action, sessionid)