plugin XEP-0050: refactor to use Pydantic models:

Pydantic models are now used for Ad-Hoc commands callback data. The `AdHocCommand` model
can (de)serialise the callback by using its import string.
This change has several benefits:
- better data validation.
- the serialisation mechanism will make communication with frontends easier.
- open the door for automatic documentation.

rel 465
This commit is contained in:
Goffi 2025-10-01 17:57:47 +02:00
commit b07179a871
5 changed files with 840 additions and 441 deletions

View file

@ -18,6 +18,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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=[]
)

View file

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

View file

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

View file

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

View file

@ -17,10 +17,13 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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