changeset 3462:12dc234f698c

plugin invitation: pubsub invitations: - new Pubsub invitation plugin, to have a generic way to manage invitation on Pubsub based features - `invitePreflight` and `onInvitationPreflight` method can be implemented to customise invitation for a namespace - refactored events invitations to use the new plugin - a Pubsub invitation can now be for a whole node instead of a specific item - if invitation is for a node, a namespace can be specified to indicate what this node is about. It is then added in `extra` data - an element (domish.Element) can be added in `extra` data, it will then be added in the invitation - some code modernisation
author Goffi <goffi@goffi.org>
date Fri, 19 Feb 2021 15:50:22 +0100
parents 02a8d227d5bb
children 483bcfeb11c9
files sat/plugins/plugin_exp_events.py sat/plugins/plugin_exp_invitation.py sat/plugins/plugin_exp_invitation_file.py sat/plugins/plugin_exp_invitation_pubsub.py
diffstat 4 files changed, 353 insertions(+), 138 deletions(-) [+]
line wrap: on
line diff
--- a/sat/plugins/plugin_exp_events.py	Fri Feb 19 15:49:59 2021 +0100
+++ b/sat/plugins/plugin_exp_events.py	Fri Feb 19 15:50:22 2021 +0100
@@ -17,11 +17,13 @@
 # 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 typing import Optional
 import shortuuid
 from sat.core.i18n import _
 from sat.core import exceptions
 from sat.core.constants import Const as C
 from sat.core.log import getLogger
+from sat.core.xmpp import SatXMPPEntity
 from sat.tools import utils
 from sat.tools.common import uri as xmpp_uri
 from sat.tools.common import date_utils
@@ -41,7 +43,7 @@
     C.PI_IMPORT_NAME: "EVENTS",
     C.PI_TYPE: "EXP",
     C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION", "LIST_INTEREST"],
+    C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION", "PUBSUB_INVITATION", "LIST_INTEREST"],
     C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"],
     C.PI_MAIN: "Events",
     C.PI_HANDLER: "yes",
@@ -61,8 +63,7 @@
         self._i = self.host.plugins.get("EMAIL_INVITATION")
         self._b = self.host.plugins.get("XEP-0277")
         self.host.registerNamespace("event", NS_EVENT)
-        self.host.plugins["INVITATION"].registerNamespace(NS_EVENT,
-                                                           self.register)
+        self.host.plugins["PUBSUB_INVITATION"].register(NS_EVENT, self)
         host.bridge.addMethod(
             "eventGet",
             ".plugin",
@@ -232,32 +233,6 @@
             raise exceptions.NotFound(_("No event with this id has been found"))
         defer.returnValue(event_elt)
 
-    def register(self, client, name, extra, service, node, event_id, item_elt,
-                 creator=False):
-        """Register evenement in personal events list
-
-        @param service(jid.JID): pubsub service of the event
-        @param node(unicode): event node
-        @param event_id(unicode): event id
-        @param event_elt(domish.Element): event element
-            note that this element will be modified in place
-        @param creator(bool): True if client's profile is the creator of the node
-        """
-        event_elt = item_elt.event
-        link_elt = event_elt.addElement("link")
-        link_elt["service"] = service.full()
-        link_elt["node"] = node
-        link_elt["item"] = event_id
-        __, event_data = self._parseEventElt(event_elt)
-        name = event_data.get('name')
-        if 'image' in event_data:
-            extra = {'thumb_url': event_data['image']}
-        else:
-            extra = None
-        return self.host.plugins['LIST_INTEREST'].registerPubsub(
-            client, NS_EVENT, service, node, event_id, creator,
-            name=name, element=event_elt, extra=extra)
-
     def _eventGet(self, service, node, id_="", profile_key=C.PROF_KEY_NONE):
         service = jid.JID(service) if service else None
         node = node if node else NS_EVENT
@@ -289,10 +264,11 @@
         node = node or None
         client = self.host.getClient(profile_key)
         data["register"] = C.bool(data.get("register", C.BOOL_FALSE))
-        return self.eventCreate(client, timestamp, data, service, node, id_ or NS_EVENT)
+        return defer.ensureDeferred(
+            self.eventCreate(client, timestamp, data, service, node, id_ or NS_EVENT)
+        )
 
-    @defer.inlineCallbacks
-    def eventCreate(self, client, timestamp, data, service, node=None, event_id=NS_EVENT):
+    async def eventCreate(self, client, timestamp, data, service, node=None, event_id=NS_EVENT):
         """Create or replace an event
 
         @param service(jid.JID, None): PubSub service
@@ -341,8 +317,8 @@
                     del data[k]
             if key not in data:
                 # FIXME: affiliate invitees
-                uri_node = yield self._p.createNode(client, service)
-                yield self._p.setConfiguration(
+                uri_node = await self._p.createNode(client, service)
+                await self._p.setConfiguration(
                     client,
                     service,
                     uri_node,
@@ -372,17 +348,23 @@
         item_elt = pubsub.Item(id=event_id, payload=event_elt)
         try:
             # TODO: check auto-create, no need to create node first if available
-            node = yield self._p.createNode(client, service, nodeIdentifier=node)
+            node = await self._p.createNode(client, service, nodeIdentifier=node)
         except error.StanzaError as e:
             if e.condition == "conflict":
                 log.debug(_("requested node already exists"))
 
-        yield self._p.publish(client, service, node, items=[item_elt])
+        await self._p.publish(client, service, node, items=[item_elt])
 
         if register:
-            yield self.register(
-                client, None, {}, service, node, event_id, item_elt, creator=True)
-        defer.returnValue(node)
+            extra = {}
+            self.onInvitationPreflight(
+                client, "", extra, service, node, event_id, item_elt
+            )
+            await self.host.plugins['LIST_INTEREST'].registerPubsub(
+                client, NS_EVENT, service, node, event_id, True,
+                extra.pop("name", ""), extra.pop("element"), extra
+            )
+        return node
 
     def _eventModify(self, service, node, id_, timestamp_update, data_update,
                      profile_key=C.PROF_KEY_NONE):
@@ -390,12 +372,14 @@
         if not node:
             raise ValueError(_("missing node"))
         client = self.host.getClient(profile_key)
-        return self.eventModify(
-            client, service, node, id_ or NS_EVENT, timestamp_update or None, data_update
+        return defer.ensureDeferred(
+            self.eventModify(
+                client, service, node, id_ or NS_EVENT, timestamp_update or None,
+                data_update
+            )
         )
 
-    @defer.inlineCallbacks
-    def eventModify(
+    async def eventModify(
         self, client, service, node, id_=NS_EVENT, timestamp_update=None, data_update=None
     ):
         """Update an event
@@ -403,13 +387,13 @@
         Similar as create instead that it update existing item instead of
         creating or replacing it. Params are the same as for [eventCreate].
         """
-        event_timestamp, event_metadata = yield self.eventGet(client, service, node, id_)
+        event_timestamp, event_metadata = await self.eventGet(client, service, node, id_)
         new_timestamp = event_timestamp if timestamp_update is None else timestamp_update
         new_data = event_metadata
         if data_update:
             for k, v in data_update.items():
                 new_data[k] = v
-        yield self.eventCreate(client, new_timestamp, new_data, service, node, id_)
+        await self.eventCreate(client, new_timestamp, new_data, service, node, id_)
 
     def _eventsListSerialise(self, events):
         for timestamp, data in events:
@@ -543,52 +527,40 @@
                 invitees[item["id"]] = data
         defer.returnValue(invitees)
 
-    def _invite(self, invitee_jid, service, node, item_id, profile):
-        client = self.host.getClient(profile)
-        service = jid.JID(service) if service else None
-        node = node or None
-        item_id = item_id or None
-        invitee_jid = jid.JID(invitee_jid)
-        return self.invite(client, invitee_jid, service, node, item_id)
-
-    @defer.inlineCallbacks
-    def invite(self, client, invitee_jid, service, node, item_id=NS_EVENT):
-        """Invite an entity to the event
-
-        This will set permission to let the entity access everything needed
-        @pararm invitee_jid(jid.JID): entity to invite
-        @param service(jid.JID, None): pubsub service
-            None to use client's PEP
-        @param node(unicode): event node
-        @param item_id(unicode): event id
-        """
-        # FIXME: handle name and extra
-        name = ''
-        extra = {}
+    async def invitePreflight(
+        self,
+        client: SatXMPPEntity,
+        invitee_jid: jid.JID,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str] = None,
+        name: str = '',
+        extra: Optional[dict] = None,
+    ) -> None:
         if self._b is None:
             raise exceptions.FeatureNotFound(
                 _('"XEP-0277" (blog) plugin is needed for this feature')
             )
         if item_id is None:
-            item_id = NS_EVENT
+            item_id = extra["default_item_id"] = NS_EVENT
 
-        # first we authorize our invitee to see the nodes of interest
-        yield self._p.setNodeAffiliations(client, service, node, {invitee_jid: "member"})
-        log.debug(_("affiliation set on event node"))
-        __, event_data = yield self.eventGet(client, service, node, item_id)
+        __, event_data = await self.eventGet(client, service, node, item_id)
         log.debug(_("got event data"))
         invitees_service = jid.JID(event_data["invitees_service"])
         invitees_node = event_data["invitees_node"]
         blog_service = jid.JID(event_data["blog_service"])
         blog_node = event_data["blog_node"]
-        yield self._p.setNodeAffiliations(
+        await self._p.setNodeAffiliations(
             client, invitees_service, invitees_node, {invitee_jid: "publisher"}
         )
-        log.debug(_("affiliation set on invitee node"))
-        yield self._p.setNodeAffiliations(
+        log.debug(
+            f"affiliation set on invitee node (jid: {invitees_service}, "
+            f"node: {invitees_node!r})"
+        )
+        await self._p.setNodeAffiliations(
             client, blog_service, blog_node, {invitee_jid: "member"}
         )
-        blog_items, __ = yield self._b.mbGet(client, blog_service, blog_node, None)
+        blog_items, __ = await self._b.mbGet(client, blog_service, blog_node, None)
 
         for item in blog_items:
             try:
@@ -601,15 +573,15 @@
                     )
                 )
             else:
-                yield self._p.setNodeAffiliations(
+                await self._p.setNodeAffiliations(
                     client, comments_service, comments_node, {invitee_jid: "publisher"}
                 )
         log.debug(_("affiliation set on blog and comments nodes"))
 
-        # now we send the invitation
-        pubsub_invitation = self.host.plugins['INVITATION']
-        pubsub_invitation.sendPubsubInvitation(client, invitee_jid, service, node,
-                                               item_id, name, extra)
+    def _invite(self, invitee_jid, service, node, item_id, profile):
+        return self.host.plugins["PUBSUB_INVITATION"]._sendPubsubInvitation(
+            invitee_jid, service, node, item_id or NS_EVENT, profile_key=profile
+        )
 
     def _inviteByEmail(self, service, node, id_=NS_EVENT, email="", emails_extra=None,
                        name="", host_name="", language="", url_template="",
@@ -631,12 +603,11 @@
         ):
             value = locals()[key]
             kwargs[key] = str(value)
-        return self.inviteByEmail(
+        return defer.ensureDeferred(self.inviteByEmail(
             client, jid.JID(service) if service else None, node, id_ or NS_EVENT, **kwargs
-        )
+        ))
 
-    @defer.inlineCallbacks
-    def inviteByEmail(self, client, service, node, id_=NS_EVENT, **kwargs):
+    async def inviteByEmail(self, client, service, node, id_=NS_EVENT, **kwargs):
         """High level method to create an email invitation to an event
 
         @param service(unicode, None): PubSub service
@@ -656,11 +627,37 @@
             "pubsub", path=service.full(), node=node, item=id_
         )
         kwargs["extra"] = {"event_uri": event_uri}
-        invitation_data = yield self._i.create(**kwargs)
+        invitation_data = await self._i.create(**kwargs)
         invitee_jid = invitation_data["jid"]
         log.debug(_("invitation created"))
         # now that we have a jid, we can send normal invitation
-        yield self.invite(client, invitee_jid, service, node, id_)
+        await self.invite(client, invitee_jid, service, node, id_)
+
+    def onInvitationPreflight(
+        self,
+        client: SatXMPPEntity,
+        name: str,
+        extra: dict,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str],
+        item_elt: domish.Element
+    ) -> None:
+        event_elt = item_elt.event
+        link_elt = event_elt.addElement("link")
+        link_elt["service"] = service.full()
+        link_elt["node"] = node
+        link_elt["item"] = item_id
+        __, event_data = self._parseEventElt(event_elt)
+        try:
+            name = event_data["name"]
+        except KeyError:
+            pass
+        else:
+            extra["name"] = name
+        if 'image' in event_data:
+            extra["thumb_url"] = event_data['image']
+        extra["element"] = event_elt
 
 
 @implementer(iwokkel.IDisco)
--- a/sat/plugins/plugin_exp_invitation.py	Fri Feb 19 15:49:59 2021 +0100
+++ b/sat/plugins/plugin_exp_invitation.py	Fri Feb 19 15:50:22 2021 +0100
@@ -16,15 +16,18 @@
 # 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 typing import Optional
+from zope.interface import implementer
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from wokkel import disco, iwokkel
 from sat.core.i18n import _
 from sat.core import exceptions
 from sat.core.constants import Const as C
 from sat.core.log import getLogger
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from sat.core.xmpp import SatXMPPEntity
+from sat.tools import utils
 
 log = getLogger(__name__)
 
@@ -123,16 +126,25 @@
                 invitation_elt['thumb_url'] = thumb_url
         return mess_data, invitation_elt
 
-    def sendPubsubInvitation(self, client, invitee_jid, service, node,
-                             item_id, name, extra):
+    def sendPubsubInvitation(
+        self,
+        client: SatXMPPEntity,
+        invitee_jid: jid.JID,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str],
+        name: Optional[str],
+        extra: Optional[dict]
+    ) -> None:
         """Send an pubsub invitation in a <message> stanza
 
-        @param invitee_jid(jid.JID): entitee to send invitation to
-        @param service(jid.JID): pubsub service
-        @param node(unicode): pubsub node
-        @param item_id(unicode): pubsub id
-        @param name(unicode, None): see [_generateBaseInvitation]
-        @param extra(dict, None): see [_generateBaseInvitation]
+        @param invitee_jid: entitee to send invitation to
+        @param service: pubsub service
+        @param node: pubsub node
+        @param item_id: pubsub id
+            None when the invitation is for a whole node
+        @param name: see [_generateBaseInvitation]
+        @param extra: see [_generateBaseInvitation]
         """
         if extra is None:
             extra = {}
@@ -141,8 +153,22 @@
         pubsub_elt = invitation_elt.addElement("pubsub")
         pubsub_elt["service"] = service.full()
         pubsub_elt["node"] = node
-        pubsub_elt["item"] = item_id
-        return client.send(mess_data["xml"])
+        if item_id is None:
+            try:
+                namespace = extra.pop("namespace")
+            except KeyError:
+                raise exceptions.DataError('"namespace" key is missing in "extra" data')
+            node_data_elt = pubsub_elt.addElement("node_data")
+            node_data_elt["namespace"] = namespace
+            try:
+                node_data_elt.addChild(extra["element"])
+            except KeyError:
+                pass
+        else:
+            pubsub_elt["item"] = item_id
+        if "element" in extra:
+            invitation_elt.addChild(extra.pop("element"))
+        client.send(mess_data["xml"])
 
     async def sendFileSharingInvitation(
         self, client, invitee_jid, service, repos_type=None, namespace=None, path=None,
@@ -207,53 +233,61 @@
             file_sharing_elt["path"] = path
         client.send(mess_data["xml"])
 
-    @defer.inlineCallbacks
-    def _parsePubsubElt(self, client, pubsub_elt):
+    async def _parsePubsubElt(self, client, pubsub_elt):
         try:
             service = jid.JID(pubsub_elt["service"])
             node = pubsub_elt["node"]
-            item_id = pubsub_elt.getAttribute("item")
         except (RuntimeError, KeyError):
-            log.warning(_("Bad invitation, ignoring"))
-            raise exceptions.DataError
+            raise exceptions.DataError("Bad invitation, ignoring")
+
+        item_id = pubsub_elt.getAttribute("item")
+
+        if item_id is not None:
+            try:
+                items, metadata = await self._p.getItems(
+                    client, service, node, item_ids=[item_id]
+                )
+            except Exception as e:
+                log.warning(_("Can't get item linked with invitation: {reason}").format(
+                            reason=e))
+            try:
+                item_elt = items[0]
+            except IndexError:
+                log.warning(_("Invitation was linking to a non existing item"))
+                raise exceptions.DataError
 
-        try:
-            items, metadata = yield self._p.getItems(client, service, node,
-                                                     item_ids=[item_id])
-        except Exception as e:
-            log.warning(_("Can't get item linked with invitation: {reason}").format(
-                        reason=e))
-        try:
-            item_elt = items[0]
-        except IndexError:
-            log.warning(_("Invitation was linking to a non existing item"))
-            raise exceptions.DataError
+            try:
+                namespace = item_elt.firstChildElement().uri
+            except Exception as e:
+                log.warning(_("Can't retrieve namespace of invitation: {reason}").format(
+                    reason = e))
+                raise exceptions.DataError
 
-        try:
-            namespace = item_elt.firstChildElement().uri
-        except Exception as e:
-            log.warning(_("Can't retrieve namespace of invitation: {reason}").format(
-                reason = e))
-            raise exceptions.DataError
+            args = [service, node, item_id, item_elt]
+        else:
+            try:
+                node_data_elt = next(pubsub_elt.elements((NS_INVITATION, "node_data")))
+            except StopIteration:
+                raise exceptions.DataError("Bad invitation, ignoring")
+            namespace = node_data_elt['namespace']
+            args = [service, node, None, node_data_elt]
 
-        args = [service, node, item_id, item_elt]
-        defer.returnValue((namespace, args))
+        return namespace, args
 
-    def _parseFileSharingElt(self, client, file_sharing_elt):
+    async def _parseFileSharingElt(self, client, file_sharing_elt):
         try:
             service = jid.JID(file_sharing_elt["service"])
         except (RuntimeError, KeyError):
             log.warning(_("Bad invitation, ignoring"))
             raise exceptions.DataError
         repos_type = file_sharing_elt.getAttribute("type", "files")
-        namespace = file_sharing_elt.getAttribute("namespace")
+        sharing_ns = file_sharing_elt.getAttribute("namespace")
         path = file_sharing_elt.getAttribute("path")
-        args = [service, repos_type, namespace, path]
+        args = [service, repos_type, sharing_ns, path]
         ns_fis = self.host.getNamespace("fis")
         return ns_fis, args
 
-    @defer.inlineCallbacks
-    def onInvitation(self, message_elt, client):
+    async def onInvitation(self, message_elt, client):
         log.debug("invitation received [{profile}]".format(profile=client.profile))
         invitation_elt = message_elt.invitation
 
@@ -275,7 +309,7 @@
                     xml = elt.toXml()))
                 continue
             try:
-                namespace, args = yield method(client, elt)
+                namespace, args = await method(client, elt)
             except exceptions.DataError:
                 log.warning("Can't parse invitation element: {xml}".format(
                             xml = elt.toXml()))
@@ -288,7 +322,7 @@
                     'No handler for namespace "{namespace}", invitation ignored')
                     .format(namespace=namespace))
             else:
-                cb(client, name, extra, *args)
+                await utils.asDeferred(cb, client, namespace, name, extra, *args)
 
 
 @implementer(iwokkel.IDisco)
@@ -299,7 +333,10 @@
 
     def connectionInitialized(self):
         self.xmlstream.addObserver(
-            INVITATION, self.plugin_parent.onInvitation, client=self.parent
+            INVITATION,
+            lambda message_elt: defer.ensureDeferred(
+                self.plugin_parent.onInvitation(message_elt, client=self.parent)
+            ),
         )
 
     def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
--- a/sat/plugins/plugin_exp_invitation_file.py	Fri Feb 19 15:49:59 2021 +0100
+++ b/sat/plugins/plugin_exp_invitation_file.py	Fri Feb 19 15:50:22 2021 +0100
@@ -19,6 +19,7 @@
 from sat.core.i18n import _
 from sat.core.constants import Const as C
 from sat.core.log import getLogger
+from sat.core.xmpp import SatXMPPEntity
 from sat.tools.common import data_format
 from twisted.internet import defer
 from twisted.words.protocols.jabber import jid
@@ -69,7 +70,17 @@
                 extra=extra)
         )
 
-    def onInvitation(self, client, name, extra, service, repos_type, namespace, path):
+    def onInvitation(
+        self,
+        client: SatXMPPEntity,
+        namespace: str,
+        name: str,
+        extra: dict,
+        service: jid.JID,
+        repos_type: str,
+        sharing_ns: str,
+        path: str
+    ):
         if repos_type == "files":
             type_human = _("file sharing")
         elif repos_type == "photos":
@@ -81,11 +92,12 @@
             type_human = _("file sharing")
         log.info(_(
             '{profile} has received an invitation for a files repository ({type_human}) '
-            'with namespace {namespace!r} at path [{path}]').format(
-            profile=client.profile, type_human=type_human, namespace=namespace, path=path)
+            'with namespace {sharing_ns!r} at path [{path}]').format(
+            profile=client.profile, type_human=type_human, sharing_ns=sharing_ns,
+                path=path)
             )
         return defer.ensureDeferred(
             self.host.plugins['LIST_INTEREST'].registerFileSharing(
-                client, service, repos_type, namespace, path, name, extra
+                client, service, repos_type, sharing_ns, path, name, extra
             )
         )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_exp_invitation_pubsub.py	Fri Feb 19 15:50:22 2021 +0100
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+
+# SàT plugin to send invitations for Pubsub
+# Copyright (C) 2009-2021 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 typing import Optional
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+from sat.core.i18n import _
+from sat.core.constants import Const as C
+from sat.core.log import getLogger
+from sat.core.xmpp import SatXMPPEntity
+from sat.tools import utils
+from sat.tools.common import data_format
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Invitation",
+    C.PI_IMPORT_NAME: "PUBSUB_INVITATION",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "PubsubInvitation",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("Invitations for pubsub based features"),
+}
+
+
+class PubsubInvitation:
+
+    def __init__(self, host):
+        log.info(_("Pubsub Invitation plugin initialization"))
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        # namespace to handler map
+        self._ns_handler = {}
+        host.bridge.addMethod(
+            "psInvite",
+            ".plugin",
+            in_sign="sssssss",
+            out_sign="",
+            method=self._sendPubsubInvitation,
+            async_=True
+        )
+
+    def register(
+        self,
+        namespace: str,
+        handler
+    ) -> None:
+        self._ns_handler[namespace] = handler
+        self.host.plugins["INVITATION"].registerNamespace(namespace, self.onInvitation)
+
+    def _sendPubsubInvitation(
+            self, invitee_jid_s, service_s, node, item_id=None,
+            name=None, extra_s='', profile_key=C.PROF_KEY_NONE):
+        client = self.host.getClient(profile_key)
+        invitee_jid = jid.JID(invitee_jid_s)
+        service = jid.JID(service_s)
+        extra = data_format.deserialise(extra_s)
+        return defer.ensureDeferred(
+            self.invite(
+                client,
+                invitee_jid,
+                service,
+                node,
+                item_id or None,
+                name=name or None,
+                extra=extra
+            )
+        )
+
+    async def invite(
+        self,
+        client: SatXMPPEntity,
+        invitee_jid: jid.JID,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str] = None,
+        name: str = '',
+        extra: Optional[dict] = None,
+    ) -> None:
+        if extra is None:
+            extra = {}
+        else:
+            namespace = extra.get("namespace")
+            if namespace:
+                try:
+                    handler = self._ns_handler[namespace]
+                    preflight = handler.invitePreflight
+                except KeyError:
+                    pass
+                except AttributeError:
+                    log.debug(f"no invitePreflight method found for {namespace!r}")
+                else:
+                    await utils.asDeferred(
+                        preflight,
+                        client, invitee_jid, service, node, item_id, name, extra
+                    )
+            if item_id is None:
+                item_id = extra.pop("default_item_id", None)
+
+        # we authorize our invitee to see the nodes of interest
+        await self._p.setNodeAffiliations(client, service, node, {invitee_jid: "member"})
+        log.debug(f"affiliation set on {service}'s {node!r} node")
+
+        # now we send the invitation
+        self.host.plugins["INVITATION"].sendPubsubInvitation(
+            client,
+            invitee_jid,
+            service,
+            node,
+            item_id,
+            name=name or None,
+            extra=extra
+        )
+
+    async def onInvitation(
+        self,
+        client: SatXMPPEntity,
+        namespace: str,
+        name: str,
+        extra: dict,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str],
+        item_elt: domish.Element
+    ) -> None:
+        if extra is None:
+            extra = {}
+        try:
+            handler = self._ns_handler[namespace]
+            preflight = handler.onInvitationPreflight
+        except KeyError:
+            pass
+        except AttributeError:
+            log.debug(f"no onInvitationPreflight method found for {namespace!r}")
+        else:
+            await utils.asDeferred(
+                preflight,
+                client, namespace, name, extra, service, node, item_id, item_elt
+            )
+            if item_id is None:
+                item_id = extra.pop("default_item_id", None)
+        creator = extra.pop("creator", False)
+        element = extra.pop("element", None)
+        if not name:
+            name = extra.pop("name", "")
+
+        return self.host.plugins['LIST_INTEREST'].registerPubsub(
+            client, namespace, service, node, item_id, creator,
+            name, element, extra)