- Replaces namedtuple with enums which are more appropriate here. - Add docstrings and type hints to `AdHocCommand` rel 465
971 lines
35 KiB
Python
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
|