634 lines
23 KiB
Python
634 lines
23 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Libervia plugin for Privileged Entity (XEP-0356).
|
|
# Copyright (C) 2009-2026 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 dataclasses import dataclass, field
|
|
from textwrap import dedent
|
|
from typing import TYPE_CHECKING, Final, Literal, cast
|
|
import uuid
|
|
|
|
from twisted.internet import defer, reactor
|
|
from twisted.words.protocols.jabber import error, jid
|
|
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
|
|
from twisted.words.xish import domish
|
|
from tx_xmpp import disco, itx_xmpp
|
|
from tx_xmpp.xmppim import RosterItem
|
|
from zope.interface import implementer
|
|
|
|
from libervia.backend import G
|
|
from libervia.backend.core import exceptions
|
|
from libervia.backend.core.constants import Const as C
|
|
from libervia.backend.core.core_types import SatXMPPComponent, SatXMPPEntity
|
|
from libervia.backend.core.i18n import _
|
|
from libervia.backend.core.log import getLogger
|
|
from libervia.backend.plugins.plugin_xep_0115 import XEP_0115
|
|
from libervia.backend.plugins.plugin_xep_0297 import NS_FORWARD, XEP_0297
|
|
|
|
if TYPE_CHECKING:
|
|
from libervia.backend.core.main import LiberviaBackend
|
|
|
|
log = getLogger(__name__)
|
|
|
|
PLUGIN_INFO = {
|
|
C.PI_NAME: "Privileged Entity",
|
|
C.PI_IMPORT_NAME: "XEP-0356",
|
|
C.PI_TYPE: "XEP",
|
|
C.PI_MODES: [C.PLUG_MODE_COMPONENT],
|
|
C.PI_PROTOCOLS: ["XEP-0356"],
|
|
C.PI_DEPENDENCIES: ["XEP-0115", "XEP-0297"],
|
|
C.PI_RECOMMENDATIONS: [],
|
|
C.PI_MAIN: "XEP_0356",
|
|
C.PI_HANDLER: "yes",
|
|
C.PI_DESCRIPTION: dedent(_("""\
|
|
Privileged entity implementation for components.
|
|
|
|
This plugin provides privileged entity capabilities to components, allowing them to:
|
|
- Access and modify user rosters (with roster push support)
|
|
- Send messages on behalf of server
|
|
- Send IQ stanzas on behalf of server
|
|
- Receive presence information for managed entities and roster contacts
|
|
""")),
|
|
}
|
|
|
|
NS_PRIVILEGE: Final = "urn:xmpp:privilege:2"
|
|
NS_ROSTER = "jabber:iq:roster"
|
|
ROSTER_TTL = 3600
|
|
IMPORT_NAME = "XEP-0356"
|
|
|
|
RosterPrivilegeType = Literal["none", "get", "set", "both"]
|
|
MessagePrivilegeType = Literal["none", "outgoing"]
|
|
PresencePrivilegeType = Literal["none", "managed_entity", "roster"]
|
|
|
|
|
|
@dataclass
|
|
class IQPermission:
|
|
"""IQ permission for a namespace."""
|
|
|
|
get: bool
|
|
set: bool
|
|
|
|
|
|
@dataclass
|
|
class RosterCacheEntry:
|
|
"""Cached roster data for a managed entity."""
|
|
|
|
roster: dict[jid.JID, RosterItem]
|
|
version: str | None
|
|
timestamp: float
|
|
|
|
|
|
@dataclass
|
|
class PrivilegeData:
|
|
"""Data structure for privilege tracking."""
|
|
|
|
roster_access: RosterPrivilegeType = "none"
|
|
roster_push: bool = False
|
|
message_privilege: MessagePrivilegeType = "none"
|
|
presence_privilege: PresencePrivilegeType = "none"
|
|
iq_permissions: dict[str, IQPermission] = field(default_factory=dict)
|
|
|
|
|
|
class PrivilegeError(exceptions.PermissionError):
|
|
"""A privileged operation failed"""
|
|
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class ClientPresenceData:
|
|
"""Presence tracking data for a client."""
|
|
|
|
# bare_jid -> set of online resource strings
|
|
online_resources: dict[jid.JID, set[str]] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class ClientPrivilegeData:
|
|
privileges: PrivilegeData = field(default_factory=lambda: PrivilegeData())
|
|
roster_cache: dict[jid.JID, RosterCacheEntry] = field(default_factory=dict)
|
|
presence_data: ClientPresenceData = field(
|
|
default_factory=lambda: ClientPresenceData()
|
|
)
|
|
|
|
|
|
class XEP_0356:
|
|
"""XEP-0356: Privileged Entity handler."""
|
|
|
|
namespace = NS_PRIVILEGE
|
|
|
|
def __init__(self, host: "LiberviaBackend") -> None:
|
|
log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
|
|
self._xep_0115 = cast(XEP_0115, host.plugins["XEP-0115"])
|
|
self._forward = cast(XEP_0297, host.plugins["XEP-0297"])
|
|
host.register_namespace("privilege", NS_PRIVILEGE)
|
|
|
|
def get_handler(self, client: SatXMPPEntity) -> "XEP0356Handler":
|
|
"""Return the handler for this plugin.
|
|
|
|
@param client: The client entity.
|
|
@return: The XEP0356Handler instance.
|
|
"""
|
|
return XEP0356Handler(self)
|
|
|
|
def profile_connected(self, client: SatXMPPEntity) -> None:
|
|
"""Called when a client is connected.
|
|
|
|
Observers are now registered in XEP0356Handler.connectionInitialized()
|
|
to avoid race conditions with privilege advertisements.
|
|
|
|
@param client: The client connected.
|
|
"""
|
|
if not client.is_component:
|
|
return
|
|
log.debug(f"XEP-0356 profile_connected called for {client.profile}")
|
|
|
|
def can_access_roster(self, client: SatXMPPComponent) -> bool:
|
|
"""Check if this entity can access user rosters."""
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
return data.privileges.roster_access in ("get", "both")
|
|
|
|
def can_modify_roster(self, client: SatXMPPComponent) -> bool:
|
|
"""Check if this entity can modify user rosters."""
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
return data.privileges.roster_access in ("set", "both")
|
|
|
|
def can_receive_roster_pushes(self, client: SatXMPPComponent) -> bool:
|
|
"""Check if this entity receives roster pushes."""
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
return data.privileges.roster_push
|
|
|
|
def can_send_messages(self, client: SatXMPPComponent) -> bool:
|
|
"""Check if this entity can send privileged messages."""
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
return data.privileges.message_privilege == "outgoing"
|
|
|
|
def can_receive_managed_presence(self, client: SatXMPPComponent) -> bool:
|
|
"""Check if this entity can receive presence for managed entities."""
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
return data.privileges.presence_privilege in ("managed_entity", "roster")
|
|
|
|
def can_receive_roster_presence(self, client: SatXMPPComponent) -> bool:
|
|
"""Check if this entity can receive presence for roster contacts."""
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
return data.privileges.presence_privilege == "roster"
|
|
|
|
def can_send_iq(self, client: SatXMPPComponent, namespace: str, iq_type: str) -> bool:
|
|
"""Check if this entity can send privileged IQ for a namespace/type.
|
|
|
|
@param client: The client to check.
|
|
@param namespace: The namespace of the IQ payload.
|
|
@param iq_type: The type of IQ ("get" or "set").
|
|
@return: True if the entity can send this IQ.
|
|
"""
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
perms = data.privileges.iq_permissions.get(namespace)
|
|
if perms is None:
|
|
return False
|
|
return perms.get if iq_type == "get" else perms.set
|
|
|
|
def on_advertise(self, msg: domish.Element, client: SatXMPPComponent) -> None:
|
|
"""Handle privilege advertisement from the server.
|
|
|
|
@param msg: The message stanza containing privilege information.
|
|
@param client: The client receiving the advertisement.
|
|
"""
|
|
try:
|
|
privilege_elt = next(msg.elements(NS_PRIVILEGE, "privilege"))
|
|
except StopIteration:
|
|
log.warning("Privilege advertisement without privilege element")
|
|
return
|
|
|
|
server_jid = client.server_jid
|
|
if jid.JID(msg["from"]) != server_jid:
|
|
log.warning(
|
|
f"SECURITY: privilege advertisement from unexpected server: {msg['from']}"
|
|
)
|
|
raise error.StanzaError("not-allowed")
|
|
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
data.privileges = PrivilegeData()
|
|
|
|
for perm in privilege_elt.elements(NS_PRIVILEGE, "perm"):
|
|
try:
|
|
access = perm["access"]
|
|
except KeyError:
|
|
log.warning(f"perm element without access attribute: {perm.toXml()}")
|
|
continue
|
|
|
|
if access == "roster":
|
|
self._handle_roster_perm(data, perm)
|
|
elif access == "message":
|
|
self._handle_message_perm(data, perm)
|
|
elif access == "presence":
|
|
self._handle_presence_perm(data, perm)
|
|
elif access == "iq":
|
|
self._handle_iq_perm(data, perm)
|
|
else:
|
|
log.warning(f"Unknown privilege access type: {access}")
|
|
|
|
log.info(
|
|
f"Privileges updated for {client.profile}:\n"
|
|
f" roster: {data.privileges.roster_access} "
|
|
f"(push={data.privileges.roster_push})\n"
|
|
f" message: {data.privileges.message_privilege}\n"
|
|
f" presence: {data.privileges.presence_privilege}\n"
|
|
f" iq: {list(data.privileges.iq_permissions.keys())}"
|
|
)
|
|
|
|
def _handle_roster_perm(
|
|
self, data: ClientPrivilegeData, perm: domish.Element
|
|
) -> None:
|
|
"""Handle roster permission element."""
|
|
try:
|
|
perm_type = perm["type"]
|
|
except KeyError:
|
|
log.warning(f"roster perm without type: {perm.toXml()}")
|
|
return
|
|
|
|
if perm_type not in ("none", "get", "set", "both"):
|
|
log.warning(f"Invalid roster permission type: {perm_type}")
|
|
return
|
|
|
|
data.privileges.roster_access = cast(RosterPrivilegeType, perm_type)
|
|
|
|
push_attr = perm.getAttribute("push")
|
|
if push_attr is not None:
|
|
data.privileges.roster_push = push_attr.lower() == "true"
|
|
else:
|
|
data.privileges.roster_push = perm_type in ("get", "both")
|
|
|
|
def _handle_message_perm(
|
|
self, data: ClientPrivilegeData, perm: domish.Element
|
|
) -> None:
|
|
"""Handle message permission element."""
|
|
try:
|
|
perm_type = perm["type"]
|
|
except KeyError:
|
|
log.warning(f"message perm without type: {perm.toXml()}")
|
|
return
|
|
|
|
if perm_type not in ("none", "outgoing"):
|
|
log.warning(f"Invalid message permission type: {perm_type}")
|
|
return
|
|
|
|
data.privileges.message_privilege = cast(MessagePrivilegeType, perm_type)
|
|
|
|
def _handle_presence_perm(
|
|
self, data: ClientPrivilegeData, perm: domish.Element
|
|
) -> None:
|
|
"""Handle presence permission element."""
|
|
try:
|
|
perm_type = perm["type"]
|
|
except KeyError:
|
|
log.warning(f"presence perm without type: {perm.toXml()}")
|
|
return
|
|
|
|
if perm_type not in ("none", "managed_entity", "roster"):
|
|
log.warning(f"Invalid presence permission type: {perm_type}")
|
|
return
|
|
|
|
data.privileges.presence_privilege = cast(PresencePrivilegeType, perm_type)
|
|
|
|
def _handle_iq_perm(self, data: ClientPrivilegeData, perm: domish.Element) -> None:
|
|
"""Handle IQ permission element."""
|
|
for ns_elt in perm.elements(NS_PRIVILEGE, "namespace"):
|
|
try:
|
|
ns = ns_elt["ns"]
|
|
perm_type = ns_elt["type"]
|
|
except KeyError:
|
|
log.warning(
|
|
f"namespace element without required attributes: {ns_elt.toXml()}"
|
|
)
|
|
continue
|
|
|
|
if perm_type not in ("get", "set", "both"):
|
|
log.warning(f"Invalid IQ permission type: {perm_type}")
|
|
continue
|
|
|
|
data.privileges.iq_permissions[ns] = IQPermission(
|
|
get=perm_type in ("get", "both"),
|
|
set=perm_type in ("set", "both"),
|
|
)
|
|
|
|
def on_roster_push(self, client: SatXMPPComponent, iq_elt: domish.Element) -> None:
|
|
"""Handle roster push from server.
|
|
|
|
@param client: The client receiving the roster push.
|
|
@param iq_elt: The roster push IQ stanza.
|
|
"""
|
|
if not self.can_receive_roster_pushes(client):
|
|
log.debug("Roster push received but not configured")
|
|
return
|
|
|
|
from_jid_str = iq_elt.getAttribute("from")
|
|
if not from_jid_str:
|
|
log.warning(f"Roster push without 'from' attribute: {iq_elt.toXml()}")
|
|
return
|
|
|
|
from_jid = jid.JID(from_jid_str)
|
|
if from_jid.resource:
|
|
log.warning(
|
|
f"Roster push from full JID instead of bare JID: {iq_elt.toXml()}"
|
|
)
|
|
return
|
|
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
cache = data.roster_cache
|
|
|
|
# Parse roster item using tx-xmpp
|
|
query = next(iq_elt.elements(NS_ROSTER, "query"))
|
|
item_elt = next(query.elements(NS_ROSTER, "item"))
|
|
roster_item = RosterItem.fromElement(item_elt)
|
|
item_jid = roster_item.entity
|
|
|
|
cached_entry = cache.get(from_jid)
|
|
roster = cached_entry.roster if cached_entry else {}
|
|
if roster_item.remove:
|
|
roster.pop(item_jid, None)
|
|
else:
|
|
roster[item_jid] = roster_item
|
|
|
|
version = query.getAttribute("ver")
|
|
cache[from_jid] = RosterCacheEntry(
|
|
roster=roster,
|
|
version=version,
|
|
timestamp=reactor.seconds(),
|
|
)
|
|
|
|
log.debug(f"Roster push for {from_jid}: {item_jid}")
|
|
|
|
async def get_roster(
|
|
self, client: SatXMPPComponent, user_jid: jid.JID
|
|
) -> dict[jid.JID, RosterItem]:
|
|
"""Get the roster of a managed entity.
|
|
|
|
@param client: The client making the request.
|
|
@param user_jid: The bare JID of the user whose roster to retrieve.
|
|
@return: Dict mapping JIDs to RosterItem objects.
|
|
@raise PrivilegeError: If roster access is not permitted.
|
|
"""
|
|
if not self.can_access_roster(client):
|
|
raise PrivilegeError(f"Roster access not permitted for {user_jid}")
|
|
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
cache = data.roster_cache
|
|
cached = cache.get(user_jid)
|
|
if cached and (reactor.seconds() - cached.timestamp) < ROSTER_TTL:
|
|
return cached.roster
|
|
|
|
iq = client.IQ("get")
|
|
iq["to"] = user_jid.full()
|
|
query = domish.Element((NS_ROSTER, "query"))
|
|
iq.addChild(query)
|
|
|
|
result = await iq.send()
|
|
|
|
roster = {}
|
|
for item_elt in result.elements(NS_ROSTER, "item"):
|
|
roster_item = RosterItem.fromElement(item_elt)
|
|
roster[roster_item.entity] = roster_item
|
|
|
|
version = query.getAttribute("ver")
|
|
cache[user_jid] = RosterCacheEntry(
|
|
roster=roster,
|
|
version=version,
|
|
timestamp=reactor.seconds(),
|
|
)
|
|
|
|
return roster
|
|
|
|
async def send_privileged_message(
|
|
self, client: SatXMPPComponent, message_elt: domish.Element
|
|
) -> None:
|
|
"""Send a message on behalf of a user.
|
|
|
|
@param client: The client sending the message.
|
|
@param message_elt: The message stanza to send (will be wrapped in forwarded).
|
|
@raise PrivilegeError: If message privilege is not permitted.
|
|
"""
|
|
if not self.can_send_messages(client):
|
|
raise PrivilegeError("Message privilege not permitted")
|
|
|
|
try:
|
|
from_attr = message_elt["from"]
|
|
except KeyError:
|
|
raise PrivilegeError('Message must have a "from" attribute')
|
|
|
|
from_jid = jid.JID(from_attr)
|
|
if from_jid.resource or jid.JID(from_jid.host) != client.server_jid:
|
|
raise PrivilegeError(
|
|
f"from_jid must be a bare JID from our server: {from_jid}"
|
|
)
|
|
|
|
wrapper = domish.Element((None, "message"))
|
|
wrapper["from"] = client.jid.full()
|
|
wrapper["to"] = client.server_jid.full()
|
|
wrapper["id"] = str(uuid.uuid4())
|
|
|
|
privilege = domish.Element((NS_PRIVILEGE, "privilege"))
|
|
forwarded = self._forward.build_forwarded(message_elt)
|
|
privilege.addChild(forwarded)
|
|
wrapper.addChild(privilege)
|
|
|
|
client.xmlstream.send(wrapper)
|
|
|
|
async def send_privileged_iq(
|
|
self, client: SatXMPPComponent, iq_elt: domish.Element
|
|
) -> domish.Element:
|
|
"""Send an IQ stanza on behalf of a user.
|
|
|
|
@param client: The client sending the IQ.
|
|
@param iq_elt: The IQ stanza to send (will be wrapped).
|
|
@return: The IQ response.
|
|
@raise PrivilegeError: If IQ privilege is not permitted.
|
|
"""
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
if not data.privileges.iq_permissions:
|
|
raise PrivilegeError("IQ privilege not permitted")
|
|
|
|
to_attr = iq_elt.getAttribute("to")
|
|
if not to_attr:
|
|
raise PrivilegeError("IQ must have a 'to' attribute")
|
|
|
|
user_jid = jid.JID(to_attr)
|
|
if user_jid.resource or jid.JID(user_jid.host) != client.server_jid:
|
|
raise PrivilegeError(
|
|
f"user_jid must be a bare JID from our server: {user_jid}"
|
|
)
|
|
|
|
iq_type = cast(str, iq_elt.getAttribute("type", ""))
|
|
first_child = iq_elt.firstChildElement()
|
|
if first_child is None:
|
|
raise PrivilegeError("IQ has no payload")
|
|
|
|
namespace = first_child.uri
|
|
if not self.can_send_iq(client, namespace, iq_type):
|
|
raise PrivilegeError(
|
|
f"IQ privilege not permitted for namespace {namespace} type {iq_type}"
|
|
)
|
|
|
|
wrapper = client.IQ(iq_type)
|
|
wrapper["to"] = user_jid.full()
|
|
|
|
priv_iq = domish.Element((NS_PRIVILEGE, "privileged_iq"))
|
|
iq_copy = domish.Element(("jabber:client", "iq"))
|
|
iq_copy["type"] = iq_type
|
|
iq_copy["id"] = iq_elt.getAttribute("id", str(uuid.uuid4()))
|
|
if iq_elt.getAttribute("to"):
|
|
iq_copy["to"] = iq_elt["to"]
|
|
iq_copy.addChild(first_child)
|
|
priv_iq.addChild(iq_copy)
|
|
wrapper.addChild(priv_iq)
|
|
|
|
result = await wrapper.send()
|
|
return self._unwrap_privileged_iq_result(result)
|
|
|
|
def _unwrap_privileged_iq_result(
|
|
self, iq_result_elt: domish.Element
|
|
) -> domish.Element:
|
|
"""Unwrap a privileged IQ response.
|
|
|
|
@param iq_result_elt: The IQ result stanza.
|
|
@return: The unwrapped inner IQ.
|
|
@raise PrivilegeError: If the response format is invalid.
|
|
"""
|
|
try:
|
|
privilege = next(iq_result_elt.elements(NS_PRIVILEGE, "privilege"))
|
|
forwarded = next(privilege.elements(NS_FORWARD, "forwarded"))
|
|
return next(forwarded.elements("jabber:client", "iq"))
|
|
except StopIteration:
|
|
log.error(f"Invalid privileged IQ response: {iq_result_elt.toXml()}")
|
|
raise PrivilegeError("Invalid response format")
|
|
|
|
def _on_presence(
|
|
self, presence_elt: domish.Element, client: SatXMPPComponent
|
|
) -> None:
|
|
defer.ensureDeferred(self.on_presence(presence_elt, client))
|
|
|
|
async def on_presence(
|
|
self, presence_elt: domish.Element, client: SatXMPPComponent
|
|
) -> None:
|
|
"""Handle incoming presence stanza.
|
|
|
|
Capabilities are parsed with XEP-0115.
|
|
|
|
@param presence_elt: The presence stanza.
|
|
@param client: The client instance.
|
|
"""
|
|
try:
|
|
from_jid = jid.JID(presence_elt["from"])
|
|
except (KeyError, RuntimeError):
|
|
log.exception("Can't parse 'from' attribute.")
|
|
raise
|
|
|
|
self._track_presence(from_jid, presence_elt, client)
|
|
|
|
try:
|
|
await self._xep_0115.update(client, presence_elt)
|
|
except Exception:
|
|
log.exception("Failed to parse capabilities in XEP-0115")
|
|
|
|
await G.host.trigger.async_point(
|
|
"XEP-0356_presence_received", client, from_jid, presence_elt
|
|
)
|
|
|
|
def _track_presence(
|
|
self, from_jid: jid.JID, presence_elt: domish.Element, client: SatXMPPComponent
|
|
) -> None:
|
|
"""Track online resources per bare JID.
|
|
|
|
@param from_jid: The JID sending presence.
|
|
@param presence_elt: The presence stanza.
|
|
@param client: The client instance.
|
|
"""
|
|
resource = from_jid.resource
|
|
if resource is None:
|
|
# Bare JID presence, ignore
|
|
return
|
|
|
|
bare_jid = from_jid.userhostJID()
|
|
data = client.get_plugin_data(IMPORT_NAME, ClientPrivilegeData)
|
|
online_resources = data.presence_data.online_resources
|
|
|
|
presence_type = presence_elt.getAttribute("type")
|
|
if presence_type == "unavailable":
|
|
online_resources.setdefault(bare_jid, set()).discard(resource)
|
|
else:
|
|
online_resources.setdefault(bare_jid, set()).add(resource)
|
|
|
|
|
|
@implementer(itx_xmpp.IDisco)
|
|
class XEP0356Handler(XMPPHandler):
|
|
|
|
def __init__(self, plugin_parent: XEP_0356) -> None:
|
|
self.plugin_parent = plugin_parent
|
|
|
|
@property
|
|
def client(self) -> SatXMPPComponent:
|
|
"""Get the client (component) this handler is attached to."""
|
|
assert self.parent is not None
|
|
return cast(SatXMPPComponent, self.parent)
|
|
|
|
def connectionInitialized(self) -> None:
|
|
"""Register observers after stream initialization.
|
|
|
|
This is called immediately after authentication, ensuring we don't miss
|
|
privilege advertisements that may be sent by the server right after
|
|
the component authenticates.
|
|
"""
|
|
if not self.client.is_component:
|
|
return
|
|
|
|
self.client.xmlstream.addObserver(
|
|
f"/message/privilege[@xmlns='{NS_PRIVILEGE}']",
|
|
self.plugin_parent.on_advertise,
|
|
client=self.client,
|
|
)
|
|
self.client.xmlstream.addObserver(
|
|
"/presence", self.plugin_parent._on_presence, client=self.client
|
|
)
|
|
log.debug(f"XEP-0356 observers registered for {self.client.profile}")
|
|
|
|
def getDiscoInfo(
|
|
self,
|
|
requestor: jid.JID,
|
|
target: jid.JID,
|
|
nodeIdentifier: str = "",
|
|
) -> list[disco.DiscoFeature | disco.DiscoIdentity]:
|
|
"""Get disco info.
|
|
|
|
@param requestor: JID of the requesting entity
|
|
@param target: JID of the target entity
|
|
@param nodeIdentifier: Node identifier
|
|
@return: Disco features
|
|
"""
|
|
return [disco.DiscoFeature(NS_PRIVILEGE)]
|
|
|
|
def getDiscoItems(
|
|
self,
|
|
requestor: jid.JID,
|
|
target: jid.JID,
|
|
nodeIdentifier: str = "",
|
|
) -> list[disco.DiscoItem]:
|
|
"""Get disco items.
|
|
|
|
@param requestor: JID of the requesting entity
|
|
@param target: JID of the target entity
|
|
@param nodeIdentifier: Node identifier
|
|
@return: Disco items
|
|
"""
|
|
return []
|