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:
parent
286e931a7f
commit
b07179a871
5 changed files with 840 additions and 441 deletions
|
|
@ -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=[]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue