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