diff --git a/libervia/backend/plugins/plugin_adhoc_dbus.py b/libervia/backend/plugins/plugin_adhoc_dbus.py
index 05aa14fd..3d9dffd6 100644
--- a/libervia/backend/plugins/plugin_adhoc_dbus.py
+++ b/libervia/backend/plugins/plugin_adhoc_dbus.py
@@ -18,6 +18,7 @@
# along with this program. If not, see .
from collections import OrderedDict
+from functools import partial
import os.path
from typing import cast
import uuid
@@ -25,12 +26,25 @@ import uuid
from twisted.internet import defer
from twisted.internet import reactor
from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
from wokkel import data_form
from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import D_, _
from libervia.backend.core.log import getLogger
-from libervia.backend.plugins.plugin_xep_0050 import XEP_0050, Action, Error, Note, Status
+from libervia.backend.memory.memory import SessionData
+from libervia.backend.plugins.plugin_xep_0004 import DataForm
+from libervia.backend.plugins.plugin_xep_0050 import (
+ XEP_0050,
+ Action,
+ AdHocCallbackData,
+ AdHocError,
+ Error,
+ Note,
+ NoteType,
+ Status,
+)
log = getLogger(__name__)
@@ -49,6 +63,7 @@ try:
from txdbus import client as dbus_client
except ImportError:
txdbus = None
+ dbus_client = None
log.warning(
"Missing module txdbus, please download/install it, auto D-Bus discovery will be "
"disabled"
@@ -57,7 +72,7 @@ except ImportError:
NS_MEDIA_PLAYER = "org.libervia.mediaplayer"
FD_NAME = "org.freedesktop.DBus"
-FD_PATH = "/org/freedekstop/DBus"
+FD_PATH = "/org/freedesktop/DBus"
INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable"
MPRIS_PREFIX = "org.mpris.MediaPlayer2"
CMD_GO_BACK = "GoBack"
@@ -132,6 +147,7 @@ class AdHocDBus:
async def profile_connected(self, client):
if txdbus is not None:
+ assert dbus_client is not None
if self.session_con is None:
self.session_con = await dbus_client.connect(reactor, "session")
self.fd_object = await self.session_con.getRemoteObject(FD_NAME, FD_PATH)
@@ -152,8 +168,8 @@ class AdHocDBus:
@param method: name of the method to call
@param args: will be transmitted to the method
@param kwargs: will be transmetted to the method, except for the following poped
- values:
- - interface: name of the interface to use
+ values:
+ - interface: name of the interface to use
@return: a deferred
"""
@@ -274,6 +290,95 @@ class AdHocDBus:
return (str(bus_name), methods)
+ async def dbus_callback(
+ self,
+ methods: set[tuple[str, str, str]],
+ bus_name: str,
+ flags: set[str],
+ client: SatXMPPEntity,
+ command_elt: domish.Element,
+ session_data: SessionData,
+ action: Action,
+ node: str,
+ ) -> AdHocCallbackData:
+ """Ad-hoc command for executing D-Bus methods.
+
+ @param methods: List of available D-Bus methods.
+ @param bus_name: Name of the D-Bus service.
+ @param flags: Command execution flags.
+ @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: When bad payload or internal errors occur.
+ """
+ actions = session_data.setdefault("actions", [])
+ names_map = session_data.setdefault("names_map", {})
+ actions.append(action)
+
+ if len(actions) == 1:
+ # it's our first request, we ask the desired new status
+ status = Status.EXECUTING
+ wokkel_form = data_form.Form("form", title=_("Command selection"))
+ options = []
+ for path, iface, command in methods:
+ label = command.rsplit(".", 1)[-1]
+ name = str(uuid.uuid4())
+ names_map[name] = (path, iface, command)
+ options.append(data_form.Option(name, label))
+
+ field = data_form.Field(
+ "list-single", "command", options=options, required=True
+ )
+ wokkel_form.addField(field)
+
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[]
+ )
+
+ 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)
+ command = answer_form["command"]
+ except (KeyError, StopIteration):
+ raise self._c.ad_hoc_error(Error.BAD_PAYLOAD)
+
+ if command not in names_map:
+ raise self._c.ad_hoc_error(Error.BAD_PAYLOAD)
+
+ path, iface, command = names_map[command]
+ assert self.session_con is not None
+ proxy = await self.session_con.getRemoteObject(bus_name, path)
+
+ await self._dbus_async_call(proxy, command, interface=iface)
+
+ # job done, we can end the session, except if we have FLAG_LOOP
+ if FLAG_LOOP in flags:
+ # We have a loop, so we clear everything and we execute again the command
+ # as we had a first call (command_elt is not used).
+ del actions[:]
+ names_map.clear()
+ return await self.dbus_callback(
+ methods,
+ bus_name,
+ flags,
+ client,
+ command_elt,
+ session_data,
+ Action.EXECUTE,
+ node,
+ )
+ return AdHocCallbackData(
+ status=Status.COMPLETED, notes=[Note(text=_("Command sent"))]
+ )
+ else:
+ raise AdHocError(Error.INTERNAL)
+
def _add_command(
self,
client,
@@ -290,70 +395,9 @@ class AdHocDBus:
if flags is None:
flags = set()
- async def d_bus_callback(client, command_elt, session_data, action, node):
- actions = session_data.setdefault("actions", [])
- names_map = session_data.setdefault("names_map", {})
- actions.append(action)
-
- if len(actions) == 1:
- # it's our first request, we ask the desired new status
- status = Status.EXECUTING
- form = data_form.Form("form", title=_("Command selection"))
- options = []
- for path, iface, command in methods:
- label = command.rsplit(".", 1)[-1]
- name = str(uuid.uuid4())
- names_map[name] = (path, iface, command)
- options.append(data_form.Option(name, label))
-
- field = data_form.Field(
- "list-single", "command", options=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)
- command = answer_form["command"]
- except (KeyError, StopIteration):
- raise self._c.ad_hoc_error(Error.BAD_PAYLOAD)
-
- if command not in names_map:
- raise self._c.ad_hoc_error(Error.BAD_PAYLOAD)
-
- path, iface, command = names_map[command]
- proxy = await self.session_con.getRemoteObject(bus_name, path)
-
- await self._dbus_async_call(proxy, command, interface=iface)
-
- # job done, we can end the session, except if we have FLAG_LOOP
- if FLAG_LOOP in flags:
- # We have a loop, so we clear everything and we execute again the
- # command as we had a first call (command_elt is not used, so None
- # is OK)
- del actions[:]
- names_map.clear()
- return await d_bus_callback(
- client, None, session_data, Action.EXECUTE, node
- )
- form = data_form.Form("form", title=_("Updated"))
- form.addField(data_form.Field("fixed", "Command sent"))
- status = Status.COMPLETED
- payload = None
- note = (Note.INFO, _("Command sent"))
- else:
- raise self._c.ad_hoc_error(Error.INTERNAL)
-
- return (payload, status, None, note)
-
self._c.add_ad_hoc_command(
client,
- d_bus_callback,
+ partial(self.dbus_callback, methods, bus_name, flags),
adhoc_name,
allowed_jids=allowed_jids,
allowed_groups=allowed_groups,
@@ -447,7 +491,25 @@ class AdHocDBus:
value = str(metadata[mpris_key])
form.addField(data_form.Field(fieldType="fixed", var=name, value=value))
- async def local_media_cb(self, client, command_elt, session_data, action, node):
+ async def local_media_cb(
+ self,
+ client: SatXMPPEntity,
+ command_elt: domish.Element,
+ session_data: SessionData,
+ action: Action,
+ node: str,
+ ) -> AdHocCallbackData:
+ """Ad-hoc command for controlling media players via D-Bus.
+
+ @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 ValueError: When media_player value is missing.
+ @raise AdHocError: When invalid player name or other internal errors occur.
+ """
assert txdbus is not None
try:
x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
@@ -460,11 +522,13 @@ class AdHocDBus:
bus_names = await self._dbus_list_names()
bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)]
if len(bus_names) == 0:
- note = (Note.INFO, D_("No media player found."))
- return (None, Status.COMPLETED, None, note)
+ return AdHocCallbackData(
+ status=Status.COMPLETED,
+ notes=[Note(type=NoteType.INFO, text=D_("No media player found."))],
+ )
options = []
status = Status.EXECUTING
- form = data_form.Form(
+ wokkel_form = data_form.Form(
"form", title=D_("Media Player Selection"), formNamespace=NS_MEDIA_PLAYER
)
for bus in bus_names:
@@ -476,9 +540,10 @@ class AdHocDBus:
field = data_form.Field(
"list-single", "media_player", options=options, required=True
)
- form.addField(field)
- payload = form.toElement()
- return (payload, status, None, None)
+ wokkel_form.addField(field)
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[]
+ )
else:
# player request
try:
@@ -493,8 +558,8 @@ class AdHocDBus:
"Hack attempt? Refused bus: {bus_name}"
).format(bus_name=bus_name)
)
- note = (Note.ERROR, D_("Invalid player name."))
- return (None, Status.COMPLETED, None, note)
+ note = Note(type=NoteType.ERROR, text=D_("Invalid player name."))
+ return AdHocCallbackData(status=Status.COMPLETED, notes=[note])
try:
proxy = await self.session_con.getRemoteObject(
@@ -502,8 +567,10 @@ class AdHocDBus:
)
except Exception as e:
log.warning(_("Can't get D-Bus proxy: {reason}").format(reason=e))
- note = (Note.ERROR, D_("Media player is not available anymore"))
- return (None, Status.COMPLETED, None, note)
+ note = Note(
+ type=NoteType.ERROR, text=D_("Media player is not available anymore")
+ )
+ return AdHocCallbackData(status=Status.COMPLETED, notes=[note])
try:
command = command_form["command"]
except KeyError:
@@ -512,8 +579,8 @@ class AdHocDBus:
await self.do_mpris_command(proxy, command)
# we construct the remote control form
- form = data_form.Form("form", title=D_("Media Player Selection"))
- form.addField(
+ wokkel_form = data_form.Form("form", title=D_("Media Player Selection"))
+ wokkel_form.addField(
data_form.Field(fieldType="hidden", var="media_player", value=bus_name)
)
for iface, properties_names in MPRIS_PROPERTIES.items():
@@ -528,14 +595,14 @@ class AdHocDBus:
)
continue
if name == MPRIS_METADATA_KEY:
- self.add_mpris_metadata(form, value)
+ self.add_mpris_metadata(wokkel_form, value)
else:
- form.addField(
+ wokkel_form.addField(
data_form.Field(fieldType="fixed", var=name, value=str(value))
)
commands = [data_form.Option(c, c.rsplit(".", 1)[1]) for c in MPRIS_COMMANDS]
- form.addField(
+ wokkel_form.addField(
data_form.Field(
fieldType="list-single",
var="command",
@@ -544,6 +611,7 @@ class AdHocDBus:
)
)
- payload = form.toElement()
status = Status.EXECUTING
- return (payload, status, None, None)
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[]
+ )
diff --git a/libervia/backend/plugins/plugin_adhoc_registration.py b/libervia/backend/plugins/plugin_adhoc_registration.py
index bb6d54ea..a04ea8f2 100644
--- a/libervia/backend/plugins/plugin_adhoc_registration.py
+++ b/libervia/backend/plugins/plugin_adhoc_registration.py
@@ -23,7 +23,7 @@ from uuid import uuid4
from twisted.internet import defer
from twisted.words.xish import domish
-from wokkel import data_form
+from wokkel import data_form as wokkel_data_form
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
@@ -31,7 +31,17 @@ from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import D_, _
from libervia.backend.core.log import getLogger
from libervia.backend.memory import persistent
-from libervia.backend.plugins.plugin_xep_0050 import XEP_0050, Error, Note, Status
+from libervia.backend.memory.memory import SessionData
+from libervia.backend.plugins.plugin_xep_0004 import DataForm
+from libervia.backend.plugins.plugin_xep_0050 import (
+ XEP_0050,
+ AdHocCallbackData,
+ AdHocError,
+ Error,
+ Note,
+ NoteType,
+ Status,
+)
from libervia.backend.tools.common import data_format, date_utils
log = getLogger(__name__)
@@ -113,10 +123,10 @@ class AdHocRegistration:
self,
client: SatXMPPEntity,
command_elt: domish.Element,
- session_data: dict,
+ session_data: SessionData,
action: str,
node: str,
- ) -> tuple[domish.Element | None, str, None, tuple[str, str] | None]:
+ ) -> AdHocCallbackData:
"""Ad-hoc command used to create a registration link.
This method presents a form to the user for creating a registration link,
@@ -128,8 +138,8 @@ class AdHocRegistration:
@param session_data: Data associated with the current session.
@param action: The action being performed.
@param node: The node identifier.
-
- @return: A tuple containing the payload if any,
+ @return: Command results.
+ @raise AdHocError: If the payload is invalid or internal error occurs.
"""
actions = session_data.setdefault("actions", [])
actions.append(action)
@@ -137,13 +147,15 @@ class AdHocRegistration:
if len(actions) == 1:
# First request, presenting the form to the user
status = Status.EXECUTING
- form = data_form.Form("form", title=D_("Create Registration Link"))
+ form = wokkel_data_form.Form("form", title=D_("Create Registration Link"))
form.addField(
- data_form.Field("text-single", "recipient", label=D_("Recipient Name"))
+ wokkel_data_form.Field(
+ "text-single", "recipient", label=D_("Recipient Name")
+ )
)
form.addField(
- data_form.Field(
+ wokkel_data_form.Field(
"text-single",
"expiration_time_pattern",
label=D_("Expiration Date"),
@@ -157,7 +169,7 @@ class AdHocRegistration:
)
)
form.addField(
- data_form.Field(
+ wokkel_data_form.Field(
"text-single",
"registration_limit",
label=D_("Maximum registrations limit"),
@@ -170,12 +182,12 @@ class AdHocRegistration:
)
)
form.addField(
- data_form.Field(
+ wokkel_data_form.Field(
"text-multi", "welcome_message", label=D_("Welcome Message")
)
)
form.addField(
- data_form.Field(
+ wokkel_data_form.Field(
"text-multi",
"extra_details",
label=D_("Additional Details"),
@@ -183,12 +195,12 @@ class AdHocRegistration:
)
payload = form.toElement()
- note = None
+ notes = []
elif len(actions) == 2:
try:
- x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
- answer_form = data_form.Form.fromElement(x_elt)
+ x_elt = next(command_elt.elements(wokkel_data_form.NS_X_DATA, "x"))
+ answer_form = wokkel_data_form.Form.fromElement(x_elt)
recipient = answer_form.get("recipient", None)
expiration_time_pattern = answer_form["expiration_time_pattern"]
@@ -220,27 +232,29 @@ class AdHocRegistration:
status = Status.COMPLETED
payload = None
- note = (
- Note.INFO,
- D_("Registration link created successfully: {link_id}").format(
- link_id=link_id
- ),
- )
+ notes = [
+ Note(
+ type=NoteType.INFO,
+ text=D_(
+ "Registration link created successfully: {link_id}"
+ ).format(link_id=link_id),
+ )
+ ]
except (KeyError, StopIteration, ValueError):
- raise self._c.ad_hoc_error(Error.BAD_PAYLOAD)
+ raise AdHocError(Error.BAD_PAYLOAD)
else:
- raise self._c.ad_hoc_error(Error.INTERNAL)
+ raise AdHocError(Error.INTERNAL)
- return (payload, status, None, note)
+ return AdHocCallbackData(payload=payload, status=status, notes=notes)
async def list_registration_links(
self,
client: SatXMPPEntity,
command_elt: domish.Element,
- session_data: dict,
+ session_data: SessionData,
action: str,
node: str,
- ) -> tuple[domish.Element | None, str, None, tuple[str, str] | None]:
+ ) -> AdHocCallbackData:
"""Ad-hoc command used to list all registration links.
This method retrieves all the registration links and presents them to the user.
@@ -250,8 +264,8 @@ class AdHocRegistration:
@param session_data: Data associated with the current session.
@param action: The action being performed.
@param node: The node identifier.
-
- @return: A tuple containing the payload if any,
+ @return: Command results.
+ @raise AdHocError: If internal error occurs.
"""
actions = session_data.setdefault("actions", [])
actions.append(action)
@@ -260,10 +274,10 @@ class AdHocRegistration:
all_links = await self.ad_hoc_registration_data.all()
status = Status.EXECUTING
- form = data_form.Form("form", title=D_("Registered Links"))
+ wokkel_form = wokkel_data_form.Form("form", title=D_("Registered Links"))
for link_id, link_data in all_links.items():
- form.addField(
- data_form.Field(
+ wokkel_form.addField(
+ wokkel_data_form.Field(
"text-multi",
var=f"link_{link_id}",
label=D_("Link ID: {link_id}").format(link_id=link_id),
@@ -271,24 +285,25 @@ class AdHocRegistration:
)
)
- payload = form.toElement()
- note = None
+ notes = []
status = Status.COMPLETED
else:
- raise self._c.ad_hoc_error(Error.INTERNAL)
+ raise AdHocError(Error.INTERNAL)
- return (payload, status, None, note)
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=notes
+ )
async def delete_registration_link(
self,
client: SatXMPPEntity,
command_elt: domish.Element,
- session_data: dict,
+ session_data: SessionData,
action: str,
node: str,
- ) -> tuple[domish.Element | None, str, None, tuple[str, str] | None]:
+ ) -> AdHocCallbackData:
"""Ad-hoc command used to delete a registration link.
This method presents a form to the user for selecting a registration link to
@@ -299,8 +314,8 @@ class AdHocRegistration:
@param session_data: Data associated with the current session.
@param action: The action being performed.
@param node: The node identifier.
-
- @return: A tuple containing the payload if any,
+ @return: Command results.
+ @raise AdHocError: If the payload is invalid or internal error occurs.
"""
actions = session_data.setdefault("actions", [])
actions.append(action)
@@ -308,11 +323,15 @@ class AdHocRegistration:
if len(actions) == 1:
all_links = await self.ad_hoc_registration_data.all()
status = Status.EXECUTING
- form = data_form.Form("form", title=D_("Delete Registration Link"))
+ wokkel_form = wokkel_data_form.Form(
+ "form", title=D_("Delete Registration Link")
+ )
- link_options = [data_form.Option(link_id, link_id) for link_id in all_links]
- form.addField(
- data_form.Field(
+ link_options = [
+ wokkel_data_form.Option(link_id, link_id) for link_id in all_links
+ ]
+ wokkel_form.addField(
+ wokkel_data_form.Field(
"list-single",
"link_to_delete",
label=D_("Select Link to Delete"),
@@ -321,28 +340,30 @@ class AdHocRegistration:
)
)
- payload = form.toElement()
- note = None
+ form = DataForm.from_wokkel_form(wokkel_form)
+ notes = []
elif len(actions) == 2:
try:
- x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
- answer_form = data_form.Form.fromElement(x_elt)
+ x_elt = next(command_elt.elements(wokkel_data_form.NS_X_DATA, "x"))
+ answer_form = wokkel_data_form.Form.fromElement(x_elt)
link_to_delete = answer_form["link_to_delete"]
await self.ad_hoc_registration_data.adel(link_to_delete)
status = Status.COMPLETED
- payload = None
- note = (
- Note.INFO,
- D_("Registration link {link_id} deleted successfully.").format(
- link_id=link_to_delete
- ),
- )
+ form = None
+ notes = [
+ Note(
+ type=NoteType.INFO,
+ text=D_(
+ "Registration link {link_id} deleted successfully."
+ ).format(link_id=link_to_delete),
+ )
+ ]
except (KeyError, StopIteration, ValueError):
- raise self._c.ad_hoc_error(Error.BAD_PAYLOAD)
+ raise AdHocError(Error.BAD_PAYLOAD)
else:
- raise self._c.ad_hoc_error(Error.INTERNAL)
+ raise AdHocError(Error.INTERNAL)
- return (payload, status, None, note)
+ return AdHocCallbackData(form=form, status=status, notes=notes)
diff --git a/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py b/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py
index 3a9df8c2..59020f3d 100644
--- a/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py
@@ -25,7 +25,15 @@ from libervia.backend.core.constants import Const as C
from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import _
from libervia.backend.core.log import getLogger
-from libervia.backend.plugins.plugin_xep_0050 import XEP_0050, Note, Status
+from libervia.backend.memory.memory import SessionData
+from libervia.backend.plugins.plugin_xep_0004 import DataForm
+from libervia.backend.plugins.plugin_xep_0050 import (
+ XEP_0050,
+ AdHocCallbackData,
+ Note,
+ NoteType,
+ Status,
+)
log = getLogger(__name__)
@@ -53,10 +61,20 @@ class APAdHocService:
self,
client: SatXMPPEntity,
command_elt: domish.Element,
- session_data: dict,
+ session_data: SessionData,
action: str,
node: str,
- ):
+ ) -> AdHocCallbackData:
+ """Convert XMPP JID/Node to AP actor.
+
+ @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: When the JID is invalid or when no actor is found.
+ """
try:
x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
command_form = data_form.Form.fromElement(x_elt)
@@ -65,25 +83,25 @@ class APAdHocService:
if command_form is None or len(command_form.fields) == 0:
# root request
status = Status.EXECUTING
- form = data_form.Form(
+ wokkel_form = data_form.Form(
"form",
title="XMPP JID/node to AP actor conversion",
formNamespace=NS_XMPP_JID_NODE_2_AP,
)
field = data_form.Field("text-single", "jid", required=True)
- form.addField(field)
+ wokkel_form.addField(field)
field = data_form.Field("text-single", "node", required=False)
- form.addField(field)
+ wokkel_form.addField(field)
- payload = form.toElement()
- return payload, status, None, None
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[]
+ )
else:
xmpp_jid = jid.JID(command_form["jid"])
xmpp_node = command_form.get("node")
actor = await self.apg.get_ap_account_from_jid_and_node(xmpp_jid, xmpp_node)
- note = (Note.INFO, actor)
- status = Status.COMPLETED
- payload = None
- return (payload, status, None, note)
+ return AdHocCallbackData(
+ status=Status.COMPLETED, notes=[Note(type=NoteType.INFO, text=actor)]
+ )
diff --git a/libervia/backend/plugins/plugin_comp_file_sharing_management.py b/libervia/backend/plugins/plugin_comp_file_sharing_management.py
index ab464afc..b43eaaf9 100644
--- a/libervia/backend/plugins/plugin_comp_file_sharing_management.py
+++ b/libervia/backend/plugins/plugin_comp_file_sharing_management.py
@@ -19,14 +19,27 @@
import os.path
from functools import partial
from typing import cast
+from twisted.words.xish import domish
from wokkel import data_form
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
+from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.core.i18n import _, D_
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.log import getLogger
-from libervia.backend.plugins.plugin_xep_0050 import XEP_0050, Note, Status, Error
+from libervia.backend.memory.memory import SessionData
+from libervia.backend.plugins.plugin_xep_0004 import DataForm, Text
+from libervia.backend.plugins.plugin_xep_0050 import (
+ XEP_0050,
+ Action,
+ AdHocCallbackData,
+ AdHocError,
+ Note,
+ NoteType,
+ Status,
+ Error,
+)
from libervia.backend.plugins.plugin_xep_0264 import XEP_0264
from libervia.backend.tools.common import utils
@@ -136,28 +149,27 @@ class FileSharingManagement(object):
@return (tuple): value to return
"""
status = Status.COMPLETED
- payload = None
- note = (Note.ERROR, reason)
- return payload, status, None, note
+ form = None
+ note = (NoteType.ERROR, reason)
+ return form, status, None, note
- def _get_root_args(self):
+ def _get_root_args(self) -> tuple[DataForm, Status, None, None]:
"""Create the form to select the file to use
@return (tuple): value to return
"""
status = Status.EXECUTING
- form = data_form.Form(
- "form", title="File Management", formNamespace=NS_FILE_MANAGEMENT
+ form = DataForm(
+ type="form",
+ title="File Management",
+ form_namespace=NS_FILE_MANAGEMENT,
+ fields=[
+ Text(var="path", required=True),
+ Text(var="namespace", required=True),
+ ],
)
- field = data_form.Field("text-single", "path", required=True)
- form.addField(field)
-
- field = data_form.Field("text-single", "namespace", required=False)
- form.addField(field)
-
- payload = form.toElement()
- return payload, status, None, None
+ return form, status, None, None
async def _get_file_data(self, client, session_data, command_form):
"""Retrieve field requested in root form
@@ -246,7 +258,24 @@ class FileSharingManagement(object):
if file_data["type"] == C.FILE_TYPE_DIRECTORY:
await self._update_dir(client, requestor, namespace, file_data, "PUBLIC")
- async def _on_change_file(self, client, command_elt, session_data, action, node):
+ async def _on_change_file(
+ self,
+ client: SatXMPPEntity,
+ command_elt: domish.Element,
+ session_data: SessionData,
+ action: Action,
+ node: str,
+ ) -> AdHocCallbackData:
+ """Change permissions of a file or directory.
+
+ @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 during the workflow.
+ """
try:
x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
command_form = data_form.Form.fromElement(x_elt)
@@ -259,14 +288,17 @@ class FileSharingManagement(object):
if command_form is None or len(command_form.fields) == 0:
# root request
- return self._get_root_args()
+ form, status, __, __ = self._get_root_args()
+ return AdHocCallbackData(form=form, status=status, notes=[])
elif found_file is None:
# file selected, we retrieve it and ask for permissions
try:
found_file = await self._get_file_data(client, session_data, command_form)
except WorkflowError as e:
- return e.err_args
+ form, status, __, note = e.err_args
+ notes = [Note(type=note[0], text=note[1])] if note else []
+ return AdHocCallbackData(form=form, status=status, notes=notes)
# management request
if found_file["type"] == C.FILE_TYPE_DIRECTORY:
@@ -306,15 +338,16 @@ class FileSharingManagement(object):
form.addField(field)
status = Status.EXECUTING
- payload = form.toElement()
- return (payload, status, None, None)
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(form), status=status, notes=[]
+ )
else:
# final phase, we'll do permission change here
try:
read_allowed = command_form.fields["read_allowed"]
except KeyError:
- self._c.ad_hoc_error(Error.BAD_PAYLOAD)
+ raise AdHocError(Error.BAD_PAYLOAD)
if read_allowed.value == "PUBLIC":
allowed_jids = "PUBLIC"
@@ -329,7 +362,7 @@ class FileSharingManagement(object):
log.warning(
_("Can't use read_allowed values: {reason}").format(reason=e)
)
- self._c.ad_hoc_error(Error.BAD_PAYLOAD)
+ raise AdHocError(Error.BAD_PAYLOAD)
if found_file["type"] == C.FILE_TYPE_FILE:
await self.host.memory.file_update(
@@ -341,7 +374,7 @@ class FileSharingManagement(object):
try:
recursive = command_form.fields["recursive"]
except KeyError:
- self._c.ad_hoc_error(Error.BAD_PAYLOAD)
+ raise AdHocError(Error.BAD_PAYLOAD)
await self.host.memory.file_update(
found_file["id"],
"access",
@@ -357,12 +390,29 @@ class FileSharingManagement(object):
)
# job done, we can end the session
- status = Status.COMPLETED
- payload = None
- note = (Note.INFO, _("management session done"))
- return (payload, status, None, note)
+ return AdHocCallbackData(
+ status=Status.COMPLETED,
+ notes=[Note(type=NoteType.INFO, text=_("management session done"))],
+ )
- async def _on_delete_file(self, client, command_elt, session_data, action, node):
+ async def _on_delete_file(
+ self,
+ client: SatXMPPEntity,
+ command_elt: domish.Element,
+ session_data: SessionData,
+ action: Action,
+ node: str,
+ ) -> AdHocCallbackData:
+ """Delete a file or directory.
+
+ @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 during the workflow.
+ """
try:
x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
command_form = data_form.Form.fromElement(x_elt)
@@ -375,14 +425,17 @@ class FileSharingManagement(object):
if command_form is None or len(command_form.fields) == 0:
# root request
- return self._get_root_args()
+ form, status, __, __ = self._get_root_args()
+ return AdHocCallbackData(form=form, status=status, notes=[])
elif found_file is None:
# file selected, we need confirmation before actually deleting
try:
found_file = await self._get_file_data(client, session_data, command_form)
except WorkflowError as e:
- return e.err_args
+ form, status, __, note = e.err_args
+ notes = [Note(type=note[0], text=note[1])] if note else []
+ return AdHocCallbackData(form=form, status=status, notes=notes)
if found_file["type"] == C.FILE_TYPE_DIRECTORY:
msg = D_(
"Are you sure to delete directory {name} and all files and "
@@ -406,16 +459,16 @@ class FileSharingManagement(object):
desc="check this box to confirm",
)
form.addField(field)
- status = Status.EXECUTING
- payload = form.toElement()
- return (payload, status, None, None)
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(form), status=Status.EXECUTING
+ )
else:
# final phase, we'll do deletion here
try:
confirmed = C.bool(command_form.fields["confirm"].value)
except KeyError:
- self._c.ad_hoc_error(Error.BAD_PAYLOAD)
+ raise AdHocError(Error.BAD_PAYLOAD)
if not confirmed:
note = None
else:
@@ -423,10 +476,11 @@ class FileSharingManagement(object):
await self.host.memory.file_delete(
client, requestor_bare, found_file["id"], recursive
)
- note = (Note.INFO, _("file deleted"))
+ note = Note(type=NoteType.INFO, text=_("file deleted"))
status = Status.COMPLETED
- payload = None
- return (payload, status, None, note)
+ form = None
+ notes = [note] if note else []
+ return AdHocCallbackData(status=Status.COMPLETED, notes=notes)
def _update_thumbs(self, extra, thumbnails):
extra[C.KEY_THUMBNAILS] = thumbnails
@@ -479,7 +533,24 @@ class FileSharingManagement(object):
else:
log.warning("unmanaged file type: {type_}".format(type_=file_data["type"]))
- async def _on_gen_thumbnails(self, client, command_elt, session_data, action, node):
+ async def _on_gen_thumbnails(
+ self,
+ client: SatXMPPEntity,
+ command_elt: domish.Element,
+ session_data: SessionData,
+ action: Action,
+ node: str,
+ ) -> AdHocCallbackData:
+ """Generate thumbnails for a file or directory.
+
+ @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 during the workflow.
+ """
try:
x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
command_form = data_form.Form.fromElement(x_elt)
@@ -491,42 +562,65 @@ class FileSharingManagement(object):
if command_form is None or len(command_form.fields) == 0:
# root request
- return self._get_root_args()
+ form, status, __, __ = self._get_root_args()
+ return AdHocCallbackData(form=form, status=status, notes=[])
elif found_file is None:
# file selected, we retrieve it and ask for permissions
try:
found_file = await self._get_file_data(client, session_data, command_form)
except WorkflowError as e:
- return e.err_args
+ form, status, __, note = e.err_args
+ notes = [Note(type=note[0], text=note[1])] if note else []
+ return AdHocCallbackData(form=form, status=status, notes=notes)
log.info("Generating thumbnails as requested")
await self._gen_thumbs(client, requestor, found_file["namespace"], found_file)
# job done, we can end the session
- status = Status.COMPLETED
- payload = None
- note = (Note.INFO, _("thumbnails generated"))
- return (payload, status, None, note)
+ return AdHocCallbackData(
+ status=Status.COMPLETED,
+ notes=[Note(type=NoteType.INFO, text=_("thumbnails generated"))],
+ )
+ else:
+ raise exceptions.InternalError("This code should not be reached.")
- async def _on_quota(self, client, command_elt, session_data, action, node):
+ async def _on_quota(
+ self,
+ client: SatXMPPEntity,
+ command_elt: domish.Element,
+ session_data: SessionData,
+ action: Action,
+ node: str,
+ ) -> AdHocCallbackData:
+ """Get current quota information.
+
+ @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 during the workflow.
+ """
requestor = session_data["requestor"]
quota = self.host.plugins["file_sharing"].get_quota(client, requestor)
try:
size_used = await self.host.memory.file_get_used_space(client, requestor)
except exceptions.PermissionError:
- raise WorkflowError(self._err(_("forbidden")))
+ raise AdHocError(Error.FORBIDDEN)
status = Status.COMPLETED
- form = data_form.Form("result")
- form.makeFields({"quota": quota, "user": size_used})
- payload = form.toElement()
- note = (
- Note.INFO,
- _("You are currently using {size_used} on {size_quota}").format(
+ wokkel_form = data_form.Form("result")
+ wokkel_form.makeFields({"quota": quota, "user": size_used})
+ note = Note(
+ type=NoteType.INFO,
+ text=_("You are currently using {size_used} on {size_quota}").format(
size_used=utils.get_human_size(size_used),
size_quota=(
_("unlimited quota") if quota is None else utils.get_human_size(quota)
),
),
)
- return (payload, status, None, note)
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(wokkel_form), status=status, notes=[note]
+ )
diff --git a/libervia/backend/plugins/plugin_xep_0050.py b/libervia/backend/plugins/plugin_xep_0050.py
index c767df6a..0821077d 100644
--- a/libervia/backend/plugins/plugin_xep_0050.py
+++ b/libervia/backend/plugins/plugin_xep_0050.py
@@ -17,10 +17,13 @@
# along with this program. If not, see .
+from collections.abc import Callable, Coroutine
from enum import Enum, StrEnum, auto
-from typing import Callable
-from uuid import uuid4
+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
@@ -31,17 +34,17 @@ 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 SatXMPPEntity
+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 Sessions
+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")
@@ -68,6 +71,8 @@ PLUGIN_INFO = {
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()
@@ -82,7 +87,7 @@ class Action(StrEnum):
PREV = auto()
-class Note(StrEnum):
+class NoteType(StrEnum):
INFO = auto()
WARN = auto()
ERROR = auto()
@@ -90,6 +95,7 @@ class Note(StrEnum):
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")
@@ -103,60 +109,219 @@ class Error(tuple[str, str | None], Enum):
class AdHocError(Exception):
- def __init__(self, error_const):
+ 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 AdHocCommand(XMPPHandler):
+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,
- callback: Callable,
- label: str,
- node: str,
- features: list[str],
- timeout: int,
- allowed_jids: list[jid.JID],
- allowed_groups: list[str],
- allowed_magics: list[str],
- forbidden_jids: list[jid.JID],
- forbidden_groups: list[str],
- ) -> None:
+ def __init__(self, command: AdHocCommand) -> None:
"""Initialize the AdHocCommand handler.
- @param callback: The callback function to execute when command is invoked
- @param label: Label for the command displayed in UI
- @param node: Unique identifier for this command
- @param features: List of features supported by this command
- @param timeout: Session timeout in seconds
- @param allowed_jids: List of JIDs that are allowed to use this command
- @param allowed_groups: List of roster groups that are allowed to use this command
- @param allowed_magics: List of magic identifiers for special access rules
- @param forbidden_jids: List of JIDs that are forbidden from using this command
- @param forbidden_groups: List of roster groups that are forbidden from using this
- command
+ @param command: The AdHocCommand instance containing all command configuration
"""
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)
+ self.command = command
+ self.sessions = Sessions(timeout=command.timeout)
@property
def client(self) -> SatXMPPEntity:
@@ -167,37 +332,51 @@ class AdHocCommand(XMPPHandler):
assert self.parent is not None
return self.parent
- def getName(self, xml_lang: str | None = None) -> str:
+ 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.label
+ 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.allowed_magics:
+ if C.ENTITY_ALL in self.command.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))
+ 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 = 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
- )
+ 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
@@ -217,7 +396,9 @@ class AdHocCommand(XMPPHandler):
): # 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
+ 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 = ""
@@ -231,104 +412,76 @@ class AdHocCommand(XMPPHandler):
"""
return []
- def _sendAnswer(
+ async def _send_answer(
self,
- callback_data: tuple[
- domish.Element | None,
- Status,
- list[str] | None,
- tuple[Note, str] | None,
- ],
+ callback_data: AdHocCallbackData,
session_id: str,
request: domish.Element,
) -> None:
"""Send result of the command.
- @param callback_data: tuple (payload, status, actions, note) with:
- - payload: usually containing data form
- - status: current status
- - actions: list of allowed actions (see Action).
- First action is the default one. Default to EXECUTE
- - note: optional additional note: either None or a tuple with (note type,
- human readable string)
+ @param callback_data: Data returned by the callback.
@param session_id: current session id
@param request: original request (domish.Element)
"""
- payload, status, actions, note = callback_data
- assert isinstance(payload, domish.Element) or payload is None
- assert status in Status
- if not actions:
- actions = [Action.EXECUTE]
+ 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.node
+ 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)
- if note is not None:
- note_type, note_mess = note
- note_elt = command_elt.addElement("note", content=note_mess)
- note_elt["type"] = note_type
+ for note in callback_data.notes:
+ note_elt = command_elt.addElement("note", content=note.text)
+ note_elt["type"] = note.type
- if payload is not None:
- command_elt.addChild(payload)
+ if callback_data.form is not None:
+ command_elt.addChild(callback_data.form.to_element())
- self.client.send(result)
+ await self.client.a_send(result)
if status in (Status.COMPLETED, Status.CANCELED):
del self.sessions[session_id]
- def _sendError(
+ async def _send_error(
self,
- error_constant: tuple[str, str | None],
- session_id: str,
+ 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: one of Error
- @param session_id: Session ID
- @param request: original request (domish.Element)
+ @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).toResponse(request)
+ 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)
- self.client.send(iq_elt)
- del self.sessions[session_id]
+ await self.client.a_send(iq_elt)
+ if session_id is not None:
+ del self.sessions[session_id]
- def _request_eb(
- self, failure_: defer.Failure, request: domish.Element, session_id: str
- ) -> None:
- """Error handler for command requests.
-
- @param failure_: The error that occurred
- @param request: Original request (domish.Element)
- @param session_id: Session ID
- """
- if failure_.check(AdHocError):
- error_constant = failure_.value.callback_error
- else:
- log.error(f"unexpected error while handling request: {failure_}")
- error_constant = Error.INTERNAL
-
- self._sendError(error_constant, session_id, request)
-
- def on_request(
+ async def on_request(
self,
command_elt: domish.Element,
requestor: jid.JID,
- action: str,
+ action: Action,
session_id: str | None,
) -> None:
"""Handle an incoming command request.
@@ -339,33 +492,65 @@ class AdHocCommand(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
+
if not self.is_authorised(requestor):
- return self._sendError(Error.FORBIDDEN, session_id, command_elt.parent)
+ return await self._send_error(Error.FORBIDDEN, session_id, request_elt)
+
if session_id:
try:
session_data = self.sessions[session_id]
except KeyError:
- return self._sendError(
- Error.SESSION_EXPIRED, session_id, command_elt.parent
+ return await self._send_error(
+ Error.SESSION_EXPIRED, session_id, request_elt
)
if session_data["requestor"] != requestor:
- return self._sendError(Error.FORBIDDEN, session_id, command_elt.parent)
+ 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:
- d = defer.succeed((None, Status.CANCELED, None, None))
+ ret_data = AdHocCallbackData(form=None, status=Status.CANCELED)
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)
+ 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:
@@ -413,31 +598,31 @@ class XEP_0050:
return XEP_0050_handler(self)
def profile_connected(self, client):
- # map from node to AdHocCommand instance
+ # 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,
- entity,
- node,
- action=ACTION.EXECUTE,
- session_id=None,
- form_values=None,
- timeout=30,
+ 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
+ """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 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
+ @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()
@@ -601,11 +786,11 @@ class XEP_0050:
ret_data["xmlui"] = ret_data["xmlui"].toXml()
return ret_data
- d = self.requesting_entity(data, profile)
+ d = defer.ensureDeferred(self.requesting_entity(data, profile))
d.addCallback(serialise)
return d
- def requesting_entity(self, data, profile):
+ 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
@@ -615,7 +800,7 @@ class XEP_0050:
dialog, or empty if it's finished without error)
"""
if C.bool(data.get("cancelled", C.BOOL_FALSE)):
- return defer.succeed({})
+ 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
@@ -626,13 +811,11 @@ class XEP_0050:
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)
+ xmlui = await self.list_ui(client, entity)
+ # we need to keep track of the session
+ xmlui.session_id = session_id
+ return {"xmlui": xmlui}
- 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:
@@ -642,7 +825,7 @@ class XEP_0050:
except KeyError:
log.warning("session id doesn't exist, session has probably expired")
# TODO: send error dialog
- return defer.succeed({})
+ return {}
session_id = data["session_id"]
entity = session_data["jid"]
try:
@@ -657,7 +840,7 @@ class XEP_0050:
remote_id = session_data.get("remote_id")
# we request execute node's command
- d = self.do(
+ iq_result_elt = await self.do(
client,
entity,
session_data["node"],
@@ -665,10 +848,8 @@ class XEP_0050:
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
+ 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
@@ -682,25 +863,42 @@ class XEP_0050:
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"""
+ 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
- status = Status.EXECUTING
- form = data_form.Form("form", title=_("status selection"))
+ 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
)
- form.addField(field)
+ wokkel_form.addField(field)
- payload = form.toElement()
- note = None
+ return AdHocCallbackData(
+ form=DataForm.from_wokkel_form(wokkel_form), status=Status.EXECUTING
+ )
elif len(actions) == 2:
# we should have the answer here
@@ -718,13 +916,11 @@ class XEP_0050:
self.host.presence_set(show=show, profile_key=client.profile)
# job done, we can end the session
- status = Status.COMPLETED
- payload = None
- note = (Note.INFO, _("Status updated"))
+ return AdHocCallbackData(
+ status=Status.COMPLETED, notes=[Note(text=_("Status updated"))]
+ )
else:
- self.ad_hoc_error(Error.INTERNAL)
-
- return (payload, status, None, note)
+ raise AdHocError(Error.INTERNAL)
def _run(self, service_jid_s="", node="", profile_key=C.PROF_KEY_NONE):
client = self.host.get_client(profile_key)
@@ -833,6 +1029,35 @@ class XEP_0050:
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,
@@ -868,74 +1093,40 @@ class XEP_0050:
@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
"""
- commands = client._XEP_0050_commands
-
- if node is None:
- node = label.lower().replace(" ", "_")
- if not node:
- node = f"COMMANDS_{uuid4()}"
- elif node in commands:
- node = f"{node}_{uuid4()}"
-
- if features is None:
- features = [data_form.NS_X_DATA]
-
- if allowed_jids is None:
- allowed_jids = []
- else:
- # we don't want to modify the initial list
- allowed_jids = allowed_jids.copy()
- if allowed_groups is None:
- allowed_groups = []
- if allowed_magics is None:
- allowed_magics = [C.ENTITY_PROFILE_BARE]
- elif isinstance(allowed_magics, str):
- raise exceptions.InternalError("Allowed magic must be a list of strings!")
- if forbidden_jids is None:
- forbidden_jids = []
- if forbidden_groups is None:
- forbidden_groups = []
-
- # TODO: manage newly created/removed profiles
- if C.ENTITY_PROFILE_BARE in allowed_magics:
- allowed_jids += [client.jid.userhostJID()]
- # TODO: manage dynamic addition/removal of admin status once possible
- if C.ENTITY_ADMINS in allowed_magics:
- allowed_jids += list(self.host.memory.admin_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[node] = ad_hoc_command
- return node
+ 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", self.ACTION.EXECUTE)
+ 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")
- commands = client._XEP_0050_commands
+ command_handlers = client._XEP_0050_commands
try:
- command = commands[node]
+ command = cast(AdHocCommandHandler, command_handlers[node])
except KeyError:
client.sendError(request, "item-not-found")
return
- command.on_request(command_elt, requestor, action, sessionid)
+ defer.ensureDeferred(
+ command.on_request(command_elt, requestor, action, sessionid)
+ )
@implementer(iwokkel.IDisco)
@@ -944,7 +1135,8 @@ class XEP_0050_handler(XMPPHandler):
self.plugin_parent = plugin_parent
@property
- def client(self):
+ def client(self) -> SatXMPPEntity:
+ assert self.parent is not None
return self.parent
def connectionInitialized(self):
@@ -962,10 +1154,16 @@ class XEP_0050_handler(XMPPHandler):
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):
+ 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.parent.jid, command.node, command.getName())
+ disco.DiscoItem(
+ self.client.jid,
+ command_handler.command.node,
+ command_handler.get_name(),
+ )
) # TODO: manage name language
return ret