diff libervia/backend/plugins/plugin_exp_invitation.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_exp_invitation.py@524856bd7b19
children 0d7bb4df2343
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_invitation.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+
+# SàT plugin to manage invitations
+# 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 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 libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.tools import utils
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Invitation",
+    C.PI_IMPORT_NAME: "INVITATION",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0329", "XEP-0334", "LIST_INTEREST"],
+    C.PI_RECOMMENDATIONS: ["EMAIL_INVITATION"],
+    C.PI_MAIN: "Invitation",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("Experimental handling of invitations"),
+}
+
+NS_INVITATION = "https://salut-a-toi/protocol/invitation:0"
+INVITATION = '/message/invitation[@xmlns="{ns_invit}"]'.format(
+    ns_invit=NS_INVITATION
+)
+NS_INVITATION_LIST = NS_INVITATION + "#list"
+
+
+class Invitation(object):
+
+    def __init__(self, host):
+        log.info(_("Invitation plugin initialization"))
+        self.host = host
+        self._p = self.host.plugins["XEP-0060"]
+        self._h = self.host.plugins["XEP-0334"]
+        # map from namespace of the invitation to callback handling it
+        self._ns_cb = {}
+
+    def get_handler(self, client):
+        return PubsubInvitationHandler(self)
+
+    def register_namespace(self, namespace, callback):
+        """Register a callback for a namespace
+
+        @param namespace(unicode): namespace handled
+        @param callback(callbable): method handling the invitation
+            For pubsub invitation, it will be called with following arguments:
+                - client
+                - name(unicode, None): name of the event
+                - extra(dict): extra data
+                - service(jid.JID): pubsub service jid
+                - node(unicode): pubsub node
+                - item_id(unicode, None): pubsub item id
+                - item_elt(domish.Element): item of the invitation
+            For file sharing invitation, it will be called with following arguments:
+                - client
+                - name(unicode, None): name of the repository
+                - extra(dict): extra data
+                - service(jid.JID): service jid of the file repository
+                - repos_type(unicode): type of the repository, can be:
+                    - files: generic file sharing
+                    - photos: photos album
+                - namespace(unicode, None): namespace of the repository
+                - path(unicode, None): path of the repository
+        @raise exceptions.ConflictError: this namespace is already registered
+        """
+        if namespace in self._ns_cb:
+            raise exceptions.ConflictError(
+                "invitation namespace {namespace} is already register with {callback}"
+                .format(namespace=namespace, callback=self._ns_cb[namespace]))
+        self._ns_cb[namespace] = callback
+
+    def _generate_base_invitation(self, client, invitee_jid, name, extra):
+        """Generate common mess_data end invitation_elt
+
+        @param invitee_jid(jid.JID): entitee to send invitation to
+        @param name(unicode, None): name of the shared repository
+        @param extra(dict, None): extra data, where key can be:
+            - thumb_url: URL of a thumbnail
+        @return (tuple[dict, domish.Element): mess_data and invitation_elt
+        """
+        mess_data = {
+            "from": client.jid,
+            "to": invitee_jid,
+            "uid": "",
+            "message": {},
+            "type": C.MESS_TYPE_CHAT,
+            "subject": {},
+            "extra": {},
+        }
+        client.generate_message_xml(mess_data)
+        self._h.add_hint_elements(mess_data["xml"], [self._h.HINT_STORE])
+        invitation_elt = mess_data["xml"].addElement("invitation", NS_INVITATION)
+        if name is not None:
+            invitation_elt["name"] = name
+        thumb_url = extra.get('thumb_url')
+        if thumb_url:
+            if not thumb_url.startswith('http'):
+                log.warning(
+                    "only http URLs are allowed for thumbnails, got {url}, ignoring"
+                    .format(url=thumb_url))
+            else:
+                invitation_elt['thumb_url'] = thumb_url
+        return mess_data, invitation_elt
+
+    def send_pubsub_invitation(
+        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: 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 [_generate_base_invitation]
+        @param extra: see [_generate_base_invitation]
+        """
+        if extra is None:
+            extra = {}
+        mess_data, invitation_elt = self._generate_base_invitation(
+            client, invitee_jid, name, extra)
+        pubsub_elt = invitation_elt.addElement("pubsub")
+        pubsub_elt["service"] = service.full()
+        pubsub_elt["node"] = node
+        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 send_file_sharing_invitation(
+        self, client, invitee_jid, service, repos_type=None, namespace=None, path=None,
+        name=None, extra=None
+    ):
+        """Send a file sharing invitation in a <message> stanza
+
+        @param invitee_jid(jid.JID): entitee to send invitation to
+        @param service(jid.JID): file sharing service
+        @param repos_type(unicode, None): type of files repository, can be:
+            - None, "files": files sharing
+            - "photos": photos album
+        @param namespace(unicode, None): namespace of the shared repository
+        @param path(unicode, None): path of the shared repository
+        @param name(unicode, None): see [_generate_base_invitation]
+        @param extra(dict, None): see [_generate_base_invitation]
+        """
+        if extra is None:
+            extra = {}
+        li_plg = self.host.plugins["LIST_INTEREST"]
+        li_plg.normalise_file_sharing_service(client, service)
+
+        # FIXME: not the best place to adapt permission, but it's necessary to check them
+        #   for UX
+        try:
+            await self.host.plugins['XEP-0329'].affiliationsSet(
+                client, service, namespace, path, {invitee_jid: "member"}
+            )
+        except Exception as e:
+            log.warning(f"Can't set affiliation: {e}")
+
+        if "thumb_url" not in extra:
+            # we have no thumbnail, we check in our own list of interests if there is one
+            try:
+                item_id = li_plg.get_file_sharing_id(service, namespace, path)
+                own_interest = await li_plg.get(client, item_id)
+            except exceptions.NotFound:
+                log.debug(
+                    "no thumbnail found for file sharing interest at "
+                    f"[{service}/{namespace}]{path}"
+                )
+            else:
+                try:
+                    extra['thumb_url'] = own_interest['thumb_url']
+                except KeyError:
+                    pass
+
+        mess_data, invitation_elt = self._generate_base_invitation(
+            client, invitee_jid, name, extra)
+        file_sharing_elt = invitation_elt.addElement("file_sharing")
+        file_sharing_elt["service"] = service.full()
+        if repos_type is not None:
+            if repos_type not in ("files", "photos"):
+                msg = "unknown repository type: {repos_type}".format(
+                    repos_type=repos_type)
+                log.warning(msg)
+                raise exceptions.DateError(msg)
+            file_sharing_elt["type"] = repos_type
+        if namespace is not None:
+            file_sharing_elt["namespace"] = namespace
+        if path is not None:
+            file_sharing_elt["path"] = path
+        client.send(mess_data["xml"])
+
+    async def _parse_pubsub_elt(self, client, pubsub_elt):
+        try:
+            service = jid.JID(pubsub_elt["service"])
+            node = pubsub_elt["node"]
+        except (RuntimeError, KeyError):
+            raise exceptions.DataError("Bad invitation, ignoring")
+
+        item_id = pubsub_elt.getAttribute("item")
+
+        if item_id is not None:
+            try:
+                items, metadata = await self._p.get_items(
+                    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
+
+            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]
+
+        return namespace, args
+
+    async def _parse_file_sharing_elt(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")
+        sharing_ns = file_sharing_elt.getAttribute("namespace")
+        path = file_sharing_elt.getAttribute("path")
+        args = [service, repos_type, sharing_ns, path]
+        ns_fis = self.host.get_namespace("fis")
+        return ns_fis, args
+
+    async def on_invitation(self, message_elt, client):
+        log.debug("invitation received [{profile}]".format(profile=client.profile))
+        invitation_elt = message_elt.invitation
+
+        name = invitation_elt.getAttribute("name")
+        extra = {}
+        if invitation_elt.hasAttribute("thumb_url"):
+            extra['thumb_url'] = invitation_elt['thumb_url']
+
+        for elt in invitation_elt.elements():
+            if elt.uri != NS_INVITATION:
+                log.warning("unexpected element: {xml}".format(xml=elt.toXml()))
+                continue
+            if elt.name == "pubsub":
+                method = self._parse_pubsub_elt
+            elif elt.name == "file_sharing":
+                method = self._parse_file_sharing_elt
+            else:
+                log.warning("not implemented invitation element: {xml}".format(
+                    xml = elt.toXml()))
+                continue
+            try:
+                namespace, args = await method(client, elt)
+            except exceptions.DataError:
+                log.warning("Can't parse invitation element: {xml}".format(
+                            xml = elt.toXml()))
+                continue
+
+            try:
+                cb = self._ns_cb[namespace]
+            except KeyError:
+                log.warning(_(
+                    'No handler for namespace "{namespace}", invitation ignored')
+                    .format(namespace=namespace))
+            else:
+                await utils.as_deferred(cb, client, namespace, name, extra, *args)
+
+
+@implementer(iwokkel.IDisco)
+class PubsubInvitationHandler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            INVITATION,
+            lambda message_elt: defer.ensureDeferred(
+                self.plugin_parent.on_invitation(message_elt, client=self.parent)
+            ),
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [
+            disco.DiscoFeature(NS_INVITATION),
+        ]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []