libervia-backend/libervia/backend/plugins/plugin_xep_0050.py
Goffi a32b8bf9e8 plugin XEP-0050: use Enums and add type hints:
- Replaces namedtuple with enums which are more appropriate here.
- Add docstrings and type hints to `AdHocCommand`

rel 465
2025-10-01 17:57:47 +02:00

971 lines
35 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 enum import Enum, StrEnum, auto
from typing import Callable
from uuid import uuid4
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 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.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"""),
}
class Status(StrEnum):
EXECUTING = auto()
COMPLETED = auto()
CANCELED = auto()
class Action(StrEnum):
EXECUTE = auto()
CANCEL = auto()
NEXT = auto()
PREV = auto()
class Note(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):
"""Error to be used from callback
@param error_const: one of Error
"""
assert error_const in Error
self.callback_error = error_const
@implementer(iwokkel.IDisco)
class AdHocCommand(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:
"""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
"""
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)
@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 getName(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
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
"""
if C.ENTITY_ALL in self.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))
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
)
)
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)] + self.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 []
def _sendAnswer(
self,
callback_data: tuple[
domish.Element | None,
Status,
list[str] | None,
tuple[Note, str] | None,
],
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 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]
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["status"] = status
if status != Status.CANCELED:
if status != Status.COMPLETED:
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
if payload is not None:
command_elt.addChild(payload)
self.client.send(result)
if status in (Status.COMPLETED, Status.CANCELED):
del self.sessions[session_id]
def _sendError(
self,
error_constant: tuple[str, str | None],
session_id: str,
request: domish.Element,
) -> None:
"""Send error stanza.
@param error_constant: one of Error
@param session_id: Session ID
@param request: original request (domish.Element)
"""
xmpp_condition, cmd_condition = error_constant
iq_elt = StanzaError(xmpp_condition).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]
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(
self,
command_elt: domish.Element,
requestor: jid.JID,
action: str,
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
"""
if not self.is_authorised(requestor):
return self._sendError(Error.FORBIDDEN, session_id, command_elt.parent)
if session_id:
try:
session_data = self.sessions[session_id]
except KeyError:
return self._sendError(
Error.SESSION_EXPIRED, session_id, command_elt.parent
)
if session_data["requestor"] != requestor:
return self._sendError(Error.FORBIDDEN, session_id, command_elt.parent)
else:
session_id, session_data = self.sessions.new_session()
session_data["requestor"] = requestor
if action == Action.CANCEL:
d = defer.succeed((None, Status.CANCELED, None, None))
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)
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 AdHocCommand 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,
) -> defer.Deferred[domish.Element]:
"""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
"""
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 = self.requesting_entity(data, profile)
d.addCallback(serialise)
return d
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 defer.succeed({})
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
d = self.list_ui(client, entity)
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:
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 defer.succeed({})
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
d = self.do(
client,
entity,
session_data["node"],
action=Action.EXECUTE,
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
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, command_elt, session_data, action, node):
"""Ad-hoc command used to change the "show" part of status"""
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"))
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)
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)
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
status = Status.COMPLETED
payload = None
note = (Note.INFO, _("Status updated"))
else:
self.ad_hoc_error(Error.INTERNAL)
return (payload, status, None, note)
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 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
"""
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
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)
node = command_elt.getAttribute("node")
if not node:
client.sendError(request, "bad-request")
return
sessionid = command_elt.getAttribute("sessionid")
commands = client._XEP_0050_commands
try:
command = commands[node]
except KeyError:
client.sendError(request, "item-not-found")
return
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):
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:
commands = self.client._XEP_0050_commands
for command in list(commands.values()):
if command.is_authorised(requestor):
ret.append(
disco.DiscoItem(self.parent.jid, command.node, command.getName())
) # TODO: manage name language
return ret