#!/usr/bin/env python3 # Libervia plugin for XEP-0465 # 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 . from typing import Optional, List, Dict, Union from twisted.words.protocols.jabber.xmlstream import XMPPHandler from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber import error from twisted.words.xish import domish from zope.interface import implementer from tx_xmpp import disco, itx_xmpp from libervia.backend.core.constants import Const as C from libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger from libervia.backend.core import exceptions from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.tools import utils from libervia.backend.tools.common import data_format log = getLogger(__name__) PLUGIN_INFO = { C.PI_NAME: "Pubsub Public Subscriptions", C.PI_IMPORT_NAME: "XEP-0465", C.PI_TYPE: C.PLUG_TYPE_XEP, C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0465"], C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0376"], C.PI_MAIN: "XEP_0465", C.PI_HANDLER: "yes", C.PI_DESCRIPTION: _("""Pubsub Public Subscriptions implementation"""), } NS_PPS = "urn:xmpp:pps:0" NS_PPS_SUBSCRIPTIONS = "urn:xmpp:pps:subscriptions:0" NS_PPS_SUBSCRIBERS = "urn:xmpp:pps:subscribers:0" SUBSCRIBERS_NODE_PREFIX = f"{NS_PPS_SUBSCRIBERS}/" NOT_IMPLEMENTED_MSG = ( "The service at {service!s} doesn't seem to support Pubsub Public Subscriptions " "(XEP-0465), please request support from your service administrator." ) class XEP_0465: def __init__(self, host): log.info(_("Pubsub Public Subscriptions initialization")) host.register_namespace("pps", NS_PPS) self.host = host host.bridge.add_method( "ps_public_subscriptions_get", ".plugin", in_sign="sss", out_sign="s", method=self._subscriptions, async_=True, ) host.bridge.add_method( "ps_public_node_subscriptions_get", ".plugin", in_sign="sss", out_sign="a{ss}", method=self._get_public_node_subscriptions, async_=True, ) def get_handler(self, client): return XEP_0465_Handler() @property def subscriptions_node(self) -> str: return NS_PPS_SUBSCRIPTIONS @property def subscribers_node_prefix(self) -> str: return SUBSCRIBERS_NODE_PREFIX def build_subscription_elt(self, node: str, service: jid.JID) -> domish.Element: """Generate a element This is the element that a service returns on public subscriptions request """ subscription_elt = domish.Element((NS_PPS, "subscription")) subscription_elt["node"] = node subscription_elt["service"] = service.full() return subscription_elt def build_subscriber_elt(self, subscriber: jid.JID) -> domish.Element: """Generate a element This is the element that a service returns on node public subscriptions request """ subscriber_elt = domish.Element((NS_PPS, "subscriber")) subscriber_elt["jid"] = subscriber.full() return subscriber_elt @utils.ensure_deferred async def _subscriptions( self, service="", nodeIdentifier="", profile_key=C.PROF_KEY_NONE ) -> str: client = self.host.get_client(profile_key) service = None if not service else jid.JID(service) subs = await self.subscriptions(client, service, nodeIdentifier or None) return data_format.serialise(subs) async def subscriptions( self, client: SatXMPPEntity, service: Optional[jid.JID] = None, node: Optional[str] = None, ) -> List[Dict[str, Union[str, bool]]]: """Retrieve public subscriptions from a service @param service(jid.JID): PubSub service @param nodeIdentifier(unicode, None): node to filter None to get all subscriptions """ if service is None: service = client.jid.userhostJID() try: items, __ = await self.host.plugins["XEP-0060"].get_items( client, service, NS_PPS_SUBSCRIPTIONS ) except error.StanzaError as e: if e.condition == "forbidden": log.warning(NOT_IMPLEMENTED_MSG.format(service=service)) return [] else: raise e ret = [] for item in items: try: subscription_elt = next(item.elements(NS_PPS, "subscription")) except StopIteration: log.warning(f"no element found: {item.toXml()}") continue try: sub_dict = { "service": subscription_elt["service"], "node": subscription_elt["node"], "subscriber": service.full(), "state": subscription_elt.getAttribute("subscription", "subscribed"), } except KeyError: log.warning(f"invalid element: {subscription_elt.toXml()}") continue if node is not None and sub_dict["node"] != node: # if not is specified, we filter out any other node # FIXME: should node filtering be done by server? continue ret.append(sub_dict) return ret @utils.ensure_deferred async def _get_public_node_subscriptions( self, service: str, node: str, profile_key: str ) -> Dict[str, str]: client = self.host.get_client(profile_key) subs = await self.get_public_node_subscriptions( client, jid.JID(service) if service else None, node ) return {j.full(): a for j, a in subs.items()} def get_public_subscribers_node(self, node: str) -> str: """Return prefixed node to retrieve public subscribers""" return f"{NS_PPS_SUBSCRIBERS}/{node}" async def get_public_node_subscriptions( self, client: SatXMPPEntity, service: Optional[jid.JID], nodeIdentifier: str ) -> Dict[jid.JID, str]: """Retrieve public subscriptions to a node @param nodeIdentifier(unicode): node to get subscriptions from """ if not nodeIdentifier: raise exceptions.DataError("node identifier can't be empty") if service is None: service = client.jid.userhostJID() subscribers_node = self.get_public_subscribers_node(nodeIdentifier) try: items, __ = await self.host.plugins["XEP-0060"].get_items( client, service, subscribers_node ) except error.StanzaError as e: if e.condition == "forbidden": log.warning(NOT_IMPLEMENTED_MSG.format(service=service)) return {} else: raise e ret = {} for item in items: try: subscriber_elt = next(item.elements(NS_PPS, "subscriber")) except StopIteration: log.warning(f"no element found: {item.toXml()}") continue try: ret[jid.JID(subscriber_elt["jid"])] = "subscribed" except (KeyError, RuntimeError): log.warning(f"invalid element: {subscriber_elt.toXml()}") continue return ret def set_public_opt(self, options: Optional[dict] = None) -> dict: """Set option to make a subscription public @param options: dict where the option must be set if None, a new dict will be created @return: the options dict """ if options is None: options = {} options[f"{{{NS_PPS}}}public"] = True return options @implementer(itx_xmpp.IDisco) class XEP_0465_Handler(XMPPHandler): def getDiscoInfo(self, requestor, service, nodeIdentifier=""): return [disco.DiscoFeature(NS_PPS)] def getDiscoItems(self, requestor, service, nodeIdentifier=""): return []