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:
parent
697c7dbdbe
commit
eaf6f1f00b
1 changed files with 129 additions and 51 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue