Files
prosody-podman/tests/venv2/lib/python3.11/site-packages/aioxmpp/adhoc/service.py
T
2026-08-02 18:57:40 +02:00

602 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
########################################################################
# File name: service.py
# This file is part of: aioxmpp
#
# LICENSE
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program. If not, see
# <http://www.gnu.org/licenses/>.
#
########################################################################
import asyncio
# import base64
import collections
import logging
import random
# from datetime import timedelta
import aioxmpp.disco
import aioxmpp.errors
import aioxmpp.disco.xso as disco_xso
import aioxmpp.service
import aioxmpp.structs
from aioxmpp.utils import namespaces
from . import xso as adhoc_xso
_logger = logging.getLogger(__name__)
_rng = random.SystemRandom()
class SessionError(RuntimeError):
pass
class ClientCancelledError(SessionError):
pass
class AdHocClient(aioxmpp.service.Service):
"""
Access other entities :xep:`50` Ad-Hoc commands.
This service provides helpers to conveniently access and execute :xep:`50`
Ad-Hoc commands.
.. automethod:: supports_commands
.. automethod:: get_commands
.. automethod:: get_command_info
.. automethod:: execute
"""
ORDER_AFTER = [aioxmpp.disco.DiscoClient]
async def get_commands(self, peer_jid):
"""
Return the list of commands offered by the peer.
:param peer_jid: JID of the peer to query
:type peer_jid: :class:`~aioxmpp.JID`
:rtype: :class:`list` of :class:`~.disco.xso.Item`
:return: List of command items
In the returned list, each :class:`~.disco.xso.Item` represents one
command supported by the peer. The :attr:`~.disco.xso.Item.node`
attribute is the identifier of the command which can be used with
:meth:`get_command_info` and :meth:`execute`.
"""
disco = self.dependencies[aioxmpp.disco.DiscoClient]
response = await disco.query_items(
peer_jid,
node=namespaces.xep0050_commands,
)
return response.items
async def get_command_info(self, peer_jid, command_name):
"""
Obtain information about a command.
:param peer_jid: JID of the peer to query
:type peer_jid: :class:`~aioxmpp.JID`
:param command_name: Node name of the command
:type command_name: :class:`str`
:rtype: :class:`~.disco.xso.InfoQuery`
:return: Service discovery information about the command
Sends a service discovery query to the service discovery node of the
command. The returned object contains information about the command,
such as the namespaces used by its implementation (generally the
:xep:`4` data forms namespace) and possibly localisations of the
commands name.
The `command_name` can be obtained by inspecting the listing from
:meth:`get_commands` or from well-known command names as defined for
example in :xep:`133`.
"""
disco = self.dependencies[aioxmpp.disco.DiscoClient]
response = await disco.query_info(
peer_jid,
node=command_name,
)
return response
async def supports_commands(self, peer_jid):
"""
Detect whether a peer supports :xep:`50` Ad-Hoc commands.
:param peer_jid: JID of the peer to query
:type peer_jid: :class:`aioxmpp.JID`
:rtype: :class:`bool`
:return: True if the peer supports the Ad-Hoc commands protocol, false
otherwise.
Note that the fact that a peer supports the protocol does not imply
that it offers any commands.
"""
disco = self.dependencies[aioxmpp.disco.DiscoClient]
response = await disco.query_info(
peer_jid,
)
return namespaces.xep0050_commands in response.features
async def execute(self, peer_jid, command_name):
"""
Start execution of a command with a peer.
:param peer_jid: JID of the peer to start the command at.
:type peer_jid: :class:`~aioxmpp.JID`
:param command_name: Node name of the command to execute.
:type command_name: :class:`str`
:rtype: :class:`~.adhoc.service.ClientSession`
:return: A started command execution session.
Initialises a client session and starts execution of the command. The
session is returned.
This may raise any exception which may be raised by
:meth:`~.adhoc.service.ClientSession.start`.
"""
session = ClientSession(
self.client.stream,
peer_jid,
command_name,
)
await session.start()
return session
CommandEntry = collections.namedtuple(
"CommandEntry",
[
"name",
"is_allowed",
"handler",
"features",
]
)
class CommandEntry(aioxmpp.disco.StaticNode):
def __init__(self, name, handler, features=set(), is_allowed=None):
super().__init__()
if isinstance(name, str):
self.__name = aioxmpp.structs.LanguageMap({None: name})
else:
self.__name = aioxmpp.structs.LanguageMap(name)
self.__handler = handler
features = set(features) | {namespaces.xep0050_commands}
for feature in features:
self.register_feature(feature)
self.__is_allowed = is_allowed
self.register_identity(
"automation",
"command-node",
names=self.__name
)
@property
def name(self):
return self.__name
@property
def handler(self):
return self.__handler
@property
def is_allowed(self):
return self.__is_allowed
def is_allowed_for(self, *args, **kwargs):
if self.__is_allowed is None:
return True
return self.__is_allowed(*args, **kwargs)
def iter_identities(self, stanza):
if not self.is_allowed_for(stanza.from_):
return iter([])
return super().iter_identities(stanza)
class AdHocServer(aioxmpp.service.Service, aioxmpp.disco.Node):
"""
Support for serving Ad-Hoc commands.
.. .. automethod:: register_stateful_command
.. automethod:: register_stateless_command
.. automethod:: unregister_command
"""
ORDER_AFTER = [aioxmpp.disco.DiscoServer]
disco_node = aioxmpp.disco.mount_as_node(
"http://jabber.org/protocol/commands"
)
disco_feature = aioxmpp.disco.register_feature(
"http://jabber.org/protocol/commands"
)
def __init__(self, client, **kwargs):
super().__init__(client, **kwargs)
self.register_identity(
"automation", "command-list",
)
self._commands = {}
self._disco = self.dependencies[aioxmpp.disco.DiscoServer]
@aioxmpp.service.iq_handler(aioxmpp.IQType.SET,
adhoc_xso.Command)
async def _handle_command(self, stanza):
try:
info = self._commands[stanza.payload.node]
except KeyError:
raise aioxmpp.errors.XMPPCancelError(
aioxmpp.errors.ErrorCondition.ITEM_NOT_FOUND,
text="no such command: {!r}".format(
stanza.payload.node
)
)
if not info.is_allowed_for(stanza.from_):
raise aioxmpp.errors.XMPPCancelError(
aioxmpp.errors.ErrorCondition.FORBIDDEN,
)
return await info.handler(stanza)
def iter_items(self, stanza):
local_jid = self.client.local_jid
languages = [
aioxmpp.structs.LanguageRange.fromstr("en"),
]
if stanza.lang is not None:
languages.insert(0, aioxmpp.structs.LanguageRange.fromstr(
str(stanza.lang)
))
for node, info in self._commands.items():
if not info.is_allowed_for(stanza.from_):
continue
yield disco_xso.Item(
local_jid,
name=info.name.lookup(languages),
node=node,
)
def register_stateless_command(self, node, name, handler, *,
is_allowed=None,
features={namespaces.xep0004_data}):
"""
Register a handler for a stateless command.
:param node: Name of the command (``node`` in the service discovery
list).
:type node: :class:`str`
:param name: Human-readable name of the command
:type name: :class:`str` or :class:`~.LanguageMap`
:param handler: Coroutine function to run to get the response for a
request.
:param is_allowed: A predicate which determines whether the command is
shown and allowed for a given peer.
:type is_allowed: function or :data:`None`
:param features: Set of features to announce for the command
:type features: :class:`set` of :class:`str`
When a request for the command is received, `handler` is invoked. The
semantics of `handler` are the same as for
:meth:`~.StanzaStream.register_iq_request_handler`. It must produce a
valid :class:`~.adhoc.xso.Command` response payload.
If `is_allowed` is not :data:`None`, it is invoked whenever a command
listing is generated and whenever a command request is received. The
:class:`aioxmpp.JID` of the requester is passed as positional argument
to `is_allowed`. If `is_allowed` returns false, the command is not
included in the list and attempts to execute it are rejected with
``<forbidden/>`` without calling `handler`.
If `is_allowed` is :data:`None`, the command is always visible and
allowed.
The `features` are returned on a service discovery info request for the
command node. By default, the :xep:`4` (Data Forms) namespace is
included, but this can be overridden by passing a different set without
that feature to `features`.
"""
info = CommandEntry(
name,
handler,
is_allowed=is_allowed,
features=features,
)
self._commands[node] = info
self._disco.mount_node(
node,
info,
)
def unregister_command(self, node):
"""
Unregister a command previously registered.
:param node: Name of the command (``node`` in the service discovery
list).
:type node: :class:`str`
"""
class ClientSession:
"""
Represent an Ad-Hoc command session on the client side.
:param stream: The stanza stream over which the session is established.
:type stream: :class:`~.StanzaStream`
:param peer_jid: The full JID of the peer to communicate with
:type peer_jid: :class:`~aioxmpp.JID`
:param command_name: The command to run
:type command_name: :class:`str`
The constructor does not send any stanza, it merely prepares the internal
state. To start the command itself, use the :class:`ClientSession` object
as context manager or call :meth:`start`.
.. note::
The client session returned by :meth:`.AdHocClient.execute` is already
started.
The `command_name` must be one of the :attr:`~.disco.xso.Item.node` values
as returned by :meth:`.AdHocClient.get_commands`.
.. automethod:: start
.. automethod:: proceed
.. automethod:: close
The following attributes change depending on the stage of execution of the
command:
.. autoattribute:: allowed_actions
.. autoattribute:: first_payload
.. autoattribute:: response
.. autoattribute:: status
"""
def __init__(self, stream, peer_jid, command_name, *, logger=None):
super().__init__()
self._stream = stream
self._peer_jid = peer_jid
self._command_name = command_name
self._logger = logger or _logger
self._status = None
self._response = None
@property
def status(self):
"""
The current status of command execution. This is either :data:`None` or
one of the :class:`~.adhoc.CommandStatus` enumeration values.
Initially, this attribute is :data:`None`. After calls to
:meth:`start`, :meth:`proceed` or :meth:`close`, it takes the value of
the :attr:`~.xso.Command.status` attribute of the response.
"""
if self._response is not None:
return self._response.status
return None
@property
def response(self):
"""
The last :class:`~.xso.Command` received from the peer.
This is initially (and after :meth:`close`) :data:`None`.
"""
return self._response
@property
def first_payload(self):
"""
Shorthand to access :attr:`~.xso.Command.first_payload` of the
:attr:`response`.
This is initially (and after :meth:`close`) :data:`None`.
"""
if self._response is not None:
return self._response.first_payload
return None
@property
def sessionid(self):
"""
Shorthand to access :attr:`~.xso.Command.sessionid` of the
:attr:`response`.
This is initially (and after :meth:`close`) :data:`None`.
"""
if self._response is not None:
return self._response.sessionid
return None
@property
def allowed_actions(self):
"""
Shorthand to access :attr:`~.xso.Actions.allowed_actions` of the
:attr:`response`.
If no response has been received yet or if the response specifies no
set of valid actions, this is the minimal set of allowed actions (
:attr:`~.ActionType.EXECUTE` and :attr:`~.ActionType.CANCEL`).
"""
if self._response is not None and self._response.actions is not None:
return self._response.actions.allowed_actions
return {adhoc_xso.ActionType.EXECUTE,
adhoc_xso.ActionType.CANCEL}
async def start(self):
"""
Initiate the session by starting to execute the command with the peer.
:return: The :attr:`~.xso.Command.first_payload` of the response
This sends an empty command IQ request with the
:attr:`~.ActionType.EXECUTE` action.
The :attr:`status`, :attr:`response` and related attributes get updated
with the newly received values.
"""
if self._response is not None:
raise RuntimeError("command execution already started")
request = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
to=self._peer_jid,
payload=adhoc_xso.Command(self._command_name),
)
self._response = await self._stream.send_iq_and_wait_for_reply(
request,
)
return self._response.first_payload
async def proceed(self, *,
action=adhoc_xso.ActionType.EXECUTE,
payload=None):
"""
Proceed command execution to the next stage.
:param action: Action type for proceeding
:type action: :class:`~.ActionTyp`
:param payload: Payload for the request, or :data:`None`
:return: The :attr:`~.xso.Command.first_payload` of the response
`action` must be one of the actions returned by
:attr:`allowed_actions`. It defaults to :attr:`~.ActionType.EXECUTE`,
which is (alongside with :attr:`~.ActionType.CANCEL`) always allowed.
`payload` may be a sequence of XSOs, a single XSO or :data:`None`. If
it is :data:`None`, the XSOs from the request are re-used. This is
useful if you modify the payload in-place (e.g. via
:attr:`first_payload`). Otherwise, the payload on the request is set to
the `payload` argument; if it is a single XSO, it is wrapped in a
sequence.
The :attr:`status`, :attr:`response` and related attributes get updated
with the newly received values.
"""
if self._response is None:
raise RuntimeError("command execution not started yet")
if action not in self.allowed_actions:
raise ValueError("action {} not allowed in this stage".format(
action
))
cmd = adhoc_xso.Command(
self._command_name,
action=action,
payload=self._response.payload if payload is None else payload,
sessionid=self.sessionid,
)
request = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
to=self._peer_jid,
payload=cmd,
)
try:
self._response = await self._stream.send_iq_and_wait_for_reply(
request,
)
except (aioxmpp.errors.XMPPModifyError,
aioxmpp.errors.XMPPCancelError) as exc:
if isinstance(exc.application_defined_condition,
(adhoc_xso.BadSessionID,
adhoc_xso.SessionExpired)):
await self.close()
raise SessionError(exc.text)
if isinstance(exc, aioxmpp.errors.XMPPCancelError):
await self.close()
raise
return self._response.first_payload
async def close(self):
if self._response is None:
return
if self.status != adhoc_xso.CommandStatus.COMPLETED:
request = aioxmpp.IQ(
type_=aioxmpp.IQType.SET,
to=self._peer_jid,
payload=adhoc_xso.Command(
self._command_name,
sessionid=self.sessionid,
action=adhoc_xso.ActionType.CANCEL,
)
)
try:
await self._stream.send_iq_and_wait_for_reply(
request,
)
except aioxmpp.errors.StanzaError as exc:
# we are cancelling only out of courtesy.
# if something goes wrong here, its barely worth logging
self._logger.debug(
"ignored stanza error during close(): %r",
exc,
)
self._response = None
async def __aenter__(self):
if self._response is None:
await self.start()
return self
async def __aexit__(self, exc_type, exc_value, exc_traceback):
await self.close()