plugin XEP-0050: Improve page data handling:

- Ad-Hoc callbacks now receive only a `client` and a `page_data`.
- `page_data` keep all important data useful to process current page in a namedtuple.
- Submitted form is automatically parsed, so callback just has to check the data from there.
- Page index is automatically handled, so the handling by callback is no longed needed.
- Session data is now splitted in 2 dict: private for `AdHocCommandHandler`, and public
  for the callback.
This commit is contained in:
Goffi 2025-10-10 17:44:04 +02:00
commit eaf6f1f00b

View file

@ -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)