libervia-backend/libervia/backend/plugins/plugin_xep_0050.py
Goffi b07179a871 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
2025-10-01 17:57:47 +02:00

1169 lines
44 KiB
Python

#!/usr/bin/env python3
# Libervia plugin for Ad-Hoc Commands (XEP-0050)
# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# 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 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
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from twisted.words.xish import domish
from wokkel import data_form, disco, iwokkel
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 SatXMPPClient, SatXMPPEntity
from libervia.backend.core.i18n import D_, _
from libervia.backend.core.log import getLogger
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")
ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node")
CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]'
SHOWS = {
"default": _("Online"),
"away": _("Away"),
"chat": _("Free for chat"),
"dnd": _("Do not disturb"),
"xa": _("Left"),
"disconnect": _("Disconnect"),
}
PLUGIN_INFO = {
C.PI_NAME: "Ad-Hoc Commands",
C.PI_IMPORT_NAME: "XEP-0050",
C.PI_MODES: C.PLUG_MODE_BOTH,
C.PI_TYPE: "XEP",
C.PI_PROTOCOLS: ["XEP-0050"],
C.PI_MAIN: "XEP_0050",
C.PI_HANDLER: "yes",
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()
COMPLETED = auto()
CANCELED = auto()
class Action(StrEnum):
EXECUTE = auto()
CANCEL = auto()
NEXT = auto()
PREV = auto()
class NoteType(StrEnum):
INFO = auto()
WARN = auto()
ERROR = auto()
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")
BAD_PAYLOAD = ("bad-request", "bad-payload")
BAD_SESSIONID = ("bad-request", "bad-sessionid")
SESSION_EXPIRED = ("not-allowed", "session-expired")
FORBIDDEN = ("forbidden", None)
ITEM_NOT_FOUND = ("item-not-found", None)
FEATURE_NOT_IMPLEMENTED = ("feature-not-implemented", None)
INTERNAL = ("internal-server-error", None)
class AdHocError(Exception):
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 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, command: AdHocCommand) -> None:
"""Initialize the AdHocCommand handler.
@param command: The AdHocCommand instance containing all command configuration
"""
XMPPHandler.__init__(self)
self.command = command
self.sessions = Sessions(timeout=command.timeout)
@property
def client(self) -> SatXMPPEntity:
"""Get the client associated with this command handler.
@return: The XMPP client instance
"""
assert self.parent is not None
return self.parent
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.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.command.allowed_magics:
return True
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 = 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
def getDiscoInfo(
self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = ""
) -> list[disco.DiscoFeature]:
"""Get disco info for this command.
@param requestor: JID of the entity requesting information
@param target: Target JID
@param nodeIdentifier: Node identifier (unused)
@return: List of disco features supported by this command
"""
if (
nodeIdentifier != NS_COMMANDS
): # 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)] + [
disco.DiscoFeature(feature) for feature in self.command.features
]
def getDiscoItems(
self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = ""
) -> list[disco.DiscoItem]:
"""Get disco items for this command.
@param requestor: JID of the entity requesting information
@param target: Target JID
@param nodeIdentifier: Node identifier (unused)
@return: Empty list as no items are supported
"""
return []
async def _send_answer(
self,
callback_data: AdHocCallbackData,
session_id: str,
request: domish.Element,
) -> None:
"""Send result of the command.
@param callback_data: Data returned by the callback.
@param session_id: current session id
@param request: original request (domish.Element)
"""
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.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)
for note in callback_data.notes:
note_elt = command_elt.addElement("note", content=note.text)
note_elt["type"] = note.type
if callback_data.form is not None:
command_elt.addChild(callback_data.form.to_element())
await self.client.a_send(result)
if status in (Status.COMPLETED, Status.CANCELED):
del self.sessions[session_id]
async def _send_error(
self,
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: Error tuple.
@param session_id: Session ID.
@param request: Original request.
"""
xmpp_condition, cmd_condition = error_constant
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)
await self.client.a_send(iq_elt)
if session_id is not None:
del self.sessions[session_id]
async def on_request(
self,
command_elt: domish.Element,
requestor: jid.JID,
action: Action,
session_id: str | None,
) -> None:
"""Handle an incoming command request.
@param command_elt: The command element from the request
@param requestor: JID of the entity making the request
@param action: The action to perform (execute, cancel, etc.)
@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 await self._send_error(Error.FORBIDDEN, session_id, request_elt)
if session_id:
try:
session_data = self.sessions[session_id]
except KeyError:
return await self._send_error(
Error.SESSION_EXPIRED, session_id, request_elt
)
if session_data["requestor"] != requestor:
return await self._send_error(Error.FORBIDDEN, session_id, request_elt)
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:
ret_data = AdHocCallbackData(form=None, status=Status.CANCELED)
else:
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:
def __init__(self, host):
log.info(_("plugin XEP-0050 initialization"))
self.host = host
self.requesting = Sessions()
host.bridge.add_method(
"ad_hoc_run",
".plugin",
in_sign="sss",
out_sign="s",
method=self._run,
async_=True,
)
host.bridge.add_method(
"ad_hoc_list",
".plugin",
in_sign="ss",
out_sign="s",
method=self._list_ui,
async_=True,
)
host.bridge.add_method(
"ad_hoc_sequence",
".plugin",
in_sign="ssss",
out_sign="s",
method=self._sequence,
async_=True,
)
self.__requesting_id = host.register_callback(
self._requesting_entity, with_data=True
)
host.import_menu(
(D_("Service"), D_("Commands")),
self._commands_menu,
security_limit=2,
help_string=D_("Execute ad-hoc commands"),
)
host.register_namespace("commands", NS_COMMANDS)
def get_handler(self, client):
return XEP_0050_handler(self)
def profile_connected(self, client):
# 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: 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.
@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()
command_elt = iq_elt.addElement("command", NS_COMMANDS)
command_elt["node"] = node
command_elt["action"] = action
if session_id is not None:
command_elt["sessionid"] = session_id
if form_values:
# We add the XMLUI result to the command payload
form = data_form.Form("submit")
form.makeFields(form_values)
command_elt.addChild(form.toElement())
d = iq_elt.send()
return d
def get_command_elt(self, iq_elt):
try:
return next(iq_elt.elements(NS_COMMANDS, "command"))
except StopIteration:
raise exceptions.NotFound(_("Missing command element"))
def ad_hoc_error(self, error_type: Error):
"""Shortcut to raise an AdHocError
@param error_type(unicode): one of Error
"""
raise AdHocError(error_type)
def _items_2_xmlui(self, items, no_instructions):
"""Convert discovery items to XMLUI dialog"""
# TODO: manage items on different jids
form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
if not no_instructions:
form_ui.addText(_("Please select a command"), "instructions")
options = [(item.nodeIdentifier, item.name) for item in items]
form_ui.addList("node", options)
return form_ui
def _get_data_lvl(self, type_):
"""Return the constant corresponding to <note/> type attribute value
@param type_: note type (see XEP-0050 §4.3)
@return: a C.XMLUI_DATA_LVL_* constant
"""
if type_ == "error":
return C.XMLUI_DATA_LVL_ERROR
elif type_ == "warn":
return C.XMLUI_DATA_LVL_WARNING
else:
if type_ != "info":
log.warning(_("Invalid note type [%s], using info") % type_)
return C.XMLUI_DATA_LVL_INFO
def _merge_notes(self, notes):
"""Merge notes with level prefix (e.g. "ERROR: the message")
@param notes (list): list of tuple (level, message)
@return: list of messages
"""
lvl_map = {
C.XMLUI_DATA_LVL_INFO: "",
C.XMLUI_DATA_LVL_WARNING: "%s: " % _("WARNING"),
C.XMLUI_DATA_LVL_ERROR: "%s: " % _("ERROR"),
}
return ["%s%s" % (lvl_map[lvl], msg) for lvl, msg in notes]
def parse_command_answer(self, iq_elt):
command_elt = self.get_command_elt(iq_elt)
data = {}
data["status"] = command_elt.getAttribute("status", Status.EXECUTING)
data["session_id"] = command_elt.getAttribute("sessionid")
data["notes"] = notes = []
for note_elt in command_elt.elements(NS_COMMANDS, "note"):
notes.append(
(
self._get_data_lvl(note_elt.getAttribute("type", "info")),
str(note_elt),
)
)
return command_elt, data
def _commands_answer_2_xmlui(self, iq_elt, session_id, session_data):
"""Convert command answer to an ui for frontend
@param iq_elt: command result
@param session_id: id of the session used with the frontend
@param profile_key: %(doc_profile_key)s
"""
command_elt, answer_data = self.parse_command_answer(iq_elt)
status = answer_data["status"]
if status in [Status.COMPLETED, Status.CANCELED]:
# the command session is finished, we purge our session
del self.requesting[session_id]
if status == Status.COMPLETED:
session_id = None
else:
return None
remote_session_id = answer_data["session_id"]
if remote_session_id:
session_data["remote_id"] = remote_session_id
notes = answer_data["notes"]
for data_elt in command_elt.elements(data_form.NS_X_DATA, "x"):
if data_elt["type"] in ("form", "result"):
break
else:
# no matching data element found
if status != Status.COMPLETED:
log.warning(
_("No known payload found in ad-hoc command result, aborting")
)
del self.requesting[session_id]
return xml_tools.XMLUI(
C.XMLUI_DIALOG,
dialog_opt={
C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
C.XMLUI_DATA_MESS: _("No payload found"),
C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_ERROR,
},
)
if not notes:
# the status is completed, and we have no note to show
return None
# if we have only one note, we show a dialog with the level of the note
# if we have more, we show a dialog with "info" level, and all notes merged
dlg_level = notes[0][0] if len(notes) == 1 else C.XMLUI_DATA_LVL_INFO
return xml_tools.XMLUI(
C.XMLUI_DIALOG,
dialog_opt={
C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
C.XMLUI_DATA_MESS: "\n".join(self._merge_notes(notes)),
C.XMLUI_DATA_LVL: dlg_level,
},
session_id=session_id,
)
if session_id is None:
xmlui = xml_tools.data_form_elt_result_2_xmlui(data_elt)
if notes:
for level, note in notes:
if level != "info":
note = f"[{level}] {note}"
xmlui.add_widget("text", note)
return xmlui
form = data_form.Form.fromElement(data_elt)
# we add any present note to the instructions
form.instructions.extend(self._merge_notes(notes))
return xml_tools.data_form_2_xmlui(
form, self.__requesting_id, session_id=session_id
)
def _requesting_entity(self, data, profile):
def serialise(ret_data):
if "xmlui" in ret_data:
ret_data["xmlui"] = ret_data["xmlui"].toXml()
return ret_data
d = defer.ensureDeferred(self.requesting_entity(data, profile))
d.addCallback(serialise)
return d
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
self._commands_menu)
@param profile: %(doc_profile)s
@return: callback dict result (with "xmlui" corresponding to the answering
dialog, or empty if it's finished without error)
"""
if C.bool(data.get("cancelled", C.BOOL_FALSE)):
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
# TODO: managed answerer errors
# TODO: manage nodes with a non data form payload
if "session_id" not in data:
# we just had the jid, we now request it for the available commands
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
xmlui = await self.list_ui(client, entity)
# we need to keep track of the session
xmlui.session_id = session_id
return {"xmlui": xmlui}
else:
# we have started a several forms sessions
try:
session_data = self.requesting.profile_get(
data["session_id"], client.profile
)
except KeyError:
log.warning("session id doesn't exist, session has probably expired")
# TODO: send error dialog
return {}
session_id = data["session_id"]
entity = session_data["jid"]
try:
session_data["node"]
# node has already been received
except KeyError:
# it's the first time we know the node, we save it in session data
session_data["node"] = data_form_values.pop("node")
# remote_id is the XEP_0050 sessionid used by answering command
# while session_id is our own session id used with the frontend
remote_id = session_data.get("remote_id")
# we request execute node's command
iq_result_elt = await self.do(
client,
entity,
session_data["node"],
action=Action.EXECUTE,
session_id=remote_id,
form_values=data_form_values,
)
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
@param profile: %(doc_profile)s
"""
form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
form_ui.addText(_("Please enter target jid"), "instructions")
form_ui.change_container("pairs")
form_ui.addLabel("jid")
form_ui.addString("jid", value=self.host.get_client(profile).jid.host)
return {"xmlui": form_ui.toXml()}
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
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
)
wokkel_form.addField(field)
return AdHocCallbackData(
form=DataForm.from_wokkel_form(wokkel_form), status=Status.EXECUTING
)
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)
show = answer_form["show"]
except (KeyError, StopIteration):
self.ad_hoc_error(Error.BAD_PAYLOAD)
if show not in SHOWS:
self.ad_hoc_error(Error.BAD_PAYLOAD)
if show == "disconnect":
self.host.disconnect(client.profile)
else:
self.host.presence_set(show=show, profile_key=client.profile)
# job done, we can end the session
return AdHocCallbackData(
status=Status.COMPLETED, notes=[Note(text=_("Status updated"))]
)
else:
raise AdHocError(Error.INTERNAL)
def _run(self, service_jid_s="", node="", profile_key=C.PROF_KEY_NONE):
client = self.host.get_client(profile_key)
service_jid = jid.JID(service_jid_s) if service_jid_s else None
d = defer.ensureDeferred(self.run(client, service_jid, node or None))
d.addCallback(lambda xmlui: xmlui.toXml())
return d
async def run(self, client, service_jid=None, node=None):
"""Run an ad-hoc command
@param service_jid(jid.JID, None): jid of the ad-hoc service
None to use profile's server
@param node(unicode, None): node of the ad-hoc commnad
None to get initial list
@return(unicode): command page XMLUI
"""
if service_jid is None:
service_jid = jid.JID(client.jid.host)
session_id, session_data = self.requesting.new_session(profile=client.profile)
session_data["jid"] = service_jid
if node is None:
xmlui = await self.list_ui(client, service_jid)
else:
session_data["node"] = node
cb_data = await self.requesting_entity(
{"session_id": session_id}, client.profile
)
xmlui = cb_data["xmlui"]
xmlui.session_id = session_id
return xmlui
def list_commands(self, client, to_jid):
"""Request available commands
@param to_jid(jid.JID, None): the entity answering the commands
None to use profile's server
@return D(disco.DiscoItems): found commands
"""
d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS)
return d
def _list_ui(self, to_jid_s, profile_key):
client = self.host.get_client(profile_key)
to_jid = jid.JID(to_jid_s) if to_jid_s else None
d = self.list_ui(client, to_jid, no_instructions=True)
d.addCallback(lambda xmlui: xmlui.toXml())
return d
def list_ui(self, client, to_jid, no_instructions=False):
"""Request available commands and generate XMLUI
@param to_jid(jid.JID, None): the entity answering the commands
None to use profile's server
@param no_instructions(bool): if True, don't add instructions widget
@return D(xml_tools.XMLUI): UI with the commands
"""
d = self.list_commands(client, to_jid)
d.addCallback(self._items_2_xmlui, no_instructions)
return d
def _sequence(self, sequence, node, service_jid_s="", profile_key=C.PROF_KEY_NONE):
sequence = data_format.deserialise(sequence, type_check=list)
client = self.host.get_client(profile_key)
service_jid = jid.JID(service_jid_s) if service_jid_s else None
d = defer.ensureDeferred(self.sequence(client, sequence, node, service_jid))
d.addCallback(lambda data: data_format.serialise(data))
return d
async def sequence(
self,
client: SatXMPPEntity,
sequence: list[dict],
node: str,
service_jid: jid.JID | None = None,
) -> dict:
"""Send a series of data to an ad-hoc service
@param sequence: list of values to send
value are specified by a dict mapping var name to value.
@param node: node of the ad-hoc commnad
@param service_jid: jid of the ad-hoc service
None to use profile's server
@return: data received in final answer
"""
assert sequence
answer_data = None
if service_jid is None:
service_jid = jid.JID(client.jid.host)
session_id = None
for data_to_send in sequence:
iq_result_elt = await self.do(
client,
service_jid,
node,
session_id=session_id,
form_values=data_to_send,
)
__, answer_data = self.parse_command_answer(iq_result_elt)
session_id = answer_data.pop("session_id")
assert answer_data is not None
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,
callback: Callable,
label: str,
node: str | None = None,
features: list[str] | None = None,
timeout: int = 600,
allowed_jids: list[jid.JID] | None = None,
allowed_groups: list[str] | None = None,
allowed_magics: list[str] | None = None,
forbidden_jids: list[jid.JID] | None = None,
forbidden_groups: list[str] | None = None,
) -> str:
"""Add an ad-hoc command for the current profile
@param callback: method associated with this ad-hoc command which return the
payload data (see AdHocCommand._sendAnswer), can return a deferred
@param label: label associated with this command on the main menu
@param node: disco item node associated with this command. None to use
autogenerated node
@param features: features associated with the payload (list of strings), usualy
data form
@param timeout: delay between two requests before canceling the session (in
seconds)
@param allowed_jids: list of allowed entities
@param allowed_groups: list of allowed roster groups
@param allowed_magics: list of allowed magic keys, can be:
C.ENTITY_ALL: allow everybody
C.ENTITY_PROFILE_BARE: allow only the jid of the profile
C.ENTITY_ADMINS: any administrator user
@param forbidden_jids: black list of entities which can't access this command
@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
"""
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", Action.EXECUTE)
node = command_elt.getAttribute("node")
if not node:
client.sendError(request, "bad-request")
return
sessionid = command_elt.getAttribute("sessionid")
command_handlers = client._XEP_0050_commands
try:
command = cast(AdHocCommandHandler, command_handlers[node])
except KeyError:
client.sendError(request, "item-not-found")
return
defer.ensureDeferred(
command.on_request(command_elt, requestor, action, sessionid)
)
@implementer(iwokkel.IDisco)
class XEP_0050_handler(XMPPHandler):
def __init__(self, plugin_parent):
self.plugin_parent = plugin_parent
@property
def client(self) -> SatXMPPEntity:
assert self.parent is not None
return self.parent
def connectionInitialized(self):
self.xmlstream.addObserver(
CMD_REQUEST, self.plugin_parent.on_cmd_request, client=self.parent
)
def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
identities = []
if nodeIdentifier == NS_COMMANDS and self.client._XEP_0050_commands:
# we only add the identity if we have registred commands
identities.append(ID_CMD_LIST)
return [disco.DiscoFeature(NS_COMMANDS)] + identities
def getDiscoItems(self, requestor, target, nodeIdentifier=""):
ret = []
if nodeIdentifier == NS_COMMANDS:
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.client.jid,
command_handler.command.node,
command_handler.get_name(),
)
) # TODO: manage name language
return ret