diff libervia/backend/plugins/plugin_xep_0048.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_xep_0048.py@524856bd7b19
children 5f2d496c633f
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0048.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,523 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Bookmarks (xep-0048)
+# 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 libervia.backend.core.i18n import _, D_
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.memory.persistent import PersistentBinaryDict
+from libervia.backend.tools import xml_tools
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.error import StanzaError
+
+from twisted.internet import defer
+
+NS_BOOKMARKS = "storage:bookmarks"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Bookmarks",
+    C.PI_IMPORT_NAME: "XEP-0048",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0048"],
+    C.PI_DEPENDENCIES: ["XEP-0045"],
+    C.PI_RECOMMENDATIONS: ["XEP-0049"],
+    C.PI_MAIN: "XEP_0048",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of bookmarks"""),
+}
+
+
+class XEP_0048(object):
+    MUC_TYPE = "muc"
+    URL_TYPE = "url"
+    MUC_KEY = "jid"
+    URL_KEY = "url"
+    MUC_ATTRS = ("autojoin", "name")
+    URL_ATTRS = ("name",)
+
+    def __init__(self, host):
+        log.info(_("Bookmarks plugin initialization"))
+        self.host = host
+        # self.__menu_id = host.register_callback(self._bookmarks_menu, with_data=True)
+        self.__bm_save_id = host.register_callback(self._bookmarks_save_cb, with_data=True)
+        host.import_menu(
+            (D_("Groups"), D_("Bookmarks")),
+            self._bookmarks_menu,
+            security_limit=0,
+            help_string=D_("Use and manage bookmarks"),
+        )
+        self.__selected_id = host.register_callback(
+            self._bookmark_selected_cb, with_data=True
+        )
+        host.bridge.add_method(
+            "bookmarks_list",
+            ".plugin",
+            in_sign="sss",
+            out_sign="a{sa{sa{ss}}}",
+            method=self._bookmarks_list,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "bookmarks_remove",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="",
+            method=self._bookmarks_remove,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "bookmarks_add",
+            ".plugin",
+            in_sign="ssa{ss}ss",
+            out_sign="",
+            method=self._bookmarks_add,
+            async_=True,
+        )
+        try:
+            self.private_plg = self.host.plugins["XEP-0049"]
+        except KeyError:
+            self.private_plg = None
+        try:
+            self.host.plugins[C.TEXT_CMDS].register_text_commands(self)
+        except KeyError:
+            log.info(_("Text commands not available"))
+
+    async def profile_connected(self, client):
+        local = client.bookmarks_local = PersistentBinaryDict(
+            NS_BOOKMARKS, client.profile
+        )
+        await local.load()
+        if not local:
+            local[XEP_0048.MUC_TYPE] = dict()
+            local[XEP_0048.URL_TYPE] = dict()
+        private = await self._get_server_bookmarks("private", client.profile)
+        pubsub = client.bookmarks_pubsub = None
+
+        for bookmarks in (local, private, pubsub):
+            if bookmarks is not None:
+                for (room_jid, data) in list(bookmarks[XEP_0048.MUC_TYPE].items()):
+                    if data.get("autojoin", "false") == "true":
+                        nick = data.get("nick", client.jid.user)
+                        defer.ensureDeferred(
+                            self.host.plugins["XEP-0045"].join(client, room_jid, nick, {})
+                        )
+
+        # we don't use a DeferredList to gather result here, as waiting for all room would
+        # slow down a lot the connection process, and result in a bad user experience.
+
+    @defer.inlineCallbacks
+    def _get_server_bookmarks(self, storage_type, profile):
+        """Get distants bookmarks
+
+        update also the client.bookmarks_[type] key, with None if service is not available
+        @param storage_type: storage type, can be:
+            - 'private': XEP-0049 storage
+            - 'pubsub': XEP-0223 storage
+        @param profile: %(doc_profile)s
+        @return: data dictionary, or None if feature is not available
+        """
+        client = self.host.get_client(profile)
+        if storage_type == "private":
+            try:
+                bookmarks_private_xml = yield self.private_plg.private_xml_get(
+                    "storage", NS_BOOKMARKS, profile
+                )
+                data = client.bookmarks_private = self._bookmark_elt_2_dict(
+                    bookmarks_private_xml
+                )
+            except (StanzaError, AttributeError):
+                log.info(_("Private XML storage not available"))
+                data = client.bookmarks_private = None
+        elif storage_type == "pubsub":
+            raise NotImplementedError
+        else:
+            raise ValueError("storage_type must be 'private' or 'pubsub'")
+        defer.returnValue(data)
+
+    @defer.inlineCallbacks
+    def _set_server_bookmarks(self, storage_type, bookmarks_elt, profile):
+        """Save bookmarks on server
+
+        @param storage_type: storage type, can be:
+            - 'private': XEP-0049 storage
+            - 'pubsub': XEP-0223 storage
+        @param bookmarks_elt (domish.Element): bookmarks XML
+        @param profile: %(doc_profile)s
+        """
+        if storage_type == "private":
+            yield self.private_plg.private_xml_store(bookmarks_elt, profile)
+        elif storage_type == "pubsub":
+            raise NotImplementedError
+        else:
+            raise ValueError("storage_type must be 'private' or 'pubsub'")
+
+    def _bookmark_elt_2_dict(self, storage_elt):
+        """Parse bookmarks to get dictionary
+        @param storage_elt (domish.Element): bookmarks storage
+        @return (dict): bookmark data (key: bookmark type, value: list) where key can be:
+            - XEP_0048.MUC_TYPE
+            - XEP_0048.URL_TYPE
+            - value (dict): data as for add_bookmark
+        """
+        conf_data = {}
+        url_data = {}
+
+        conference_elts = storage_elt.elements(NS_BOOKMARKS, "conference")
+        for conference_elt in conference_elts:
+            try:
+                room_jid = jid.JID(conference_elt[XEP_0048.MUC_KEY])
+            except KeyError:
+                log.warning(
+                    "invalid bookmark found, igoring it:\n%s" % conference_elt.toXml()
+                )
+                continue
+
+            data = conf_data[room_jid] = {}
+
+            for attr in XEP_0048.MUC_ATTRS:
+                if conference_elt.hasAttribute(attr):
+                    data[attr] = conference_elt[attr]
+            try:
+                data["nick"] = str(
+                    next(conference_elt.elements(NS_BOOKMARKS, "nick"))
+                )
+            except StopIteration:
+                pass
+            # TODO: manage password (need to be secured, see XEP-0049 §4)
+
+        url_elts = storage_elt.elements(NS_BOOKMARKS, "url")
+        for url_elt in url_elts:
+            try:
+                url = url_elt[XEP_0048.URL_KEY]
+            except KeyError:
+                log.warning("invalid bookmark found, igoring it:\n%s" % url_elt.toXml())
+                continue
+            data = url_data[url] = {}
+            for attr in XEP_0048.URL_ATTRS:
+                if url_elt.hasAttribute(attr):
+                    data[attr] = url_elt[attr]
+
+        return {XEP_0048.MUC_TYPE: conf_data, XEP_0048.URL_TYPE: url_data}
+
+    def _dict_2_bookmark_elt(self, type_, data):
+        """Construct a bookmark element from a data dict
+        @param data (dict): bookmark data (key: bookmark type, value: list) where key can be:
+            - XEP_0048.MUC_TYPE
+            - XEP_0048.URL_TYPE
+            - value (dict): data as for add_bookmark
+        @return (domish.Element): bookmark element
+        """
+        rooms_data = data.get(XEP_0048.MUC_TYPE, {})
+        urls_data = data.get(XEP_0048.URL_TYPE, {})
+        storage_elt = domish.Element((NS_BOOKMARKS, "storage"))
+        for room_jid in rooms_data:
+            conference_elt = storage_elt.addElement("conference")
+            conference_elt[XEP_0048.MUC_KEY] = room_jid.full()
+            for attr in XEP_0048.MUC_ATTRS:
+                try:
+                    conference_elt[attr] = rooms_data[room_jid][attr]
+                except KeyError:
+                    pass
+            try:
+                conference_elt.addElement("nick", content=rooms_data[room_jid]["nick"])
+            except KeyError:
+                pass
+
+        for url, url_data in urls_data.items():
+            url_elt = storage_elt.addElement("url")
+            url_elt[XEP_0048.URL_KEY] = url
+            for attr in XEP_0048.URL_ATTRS:
+                try:
+                    url_elt[attr] = url_data[attr]
+                except KeyError:
+                    pass
+
+        return storage_elt
+
+    def _bookmark_selected_cb(self, data, profile):
+        try:
+            room_jid_s, nick = data["index"].split(" ", 1)
+            room_jid = jid.JID(room_jid_s)
+        except (KeyError, RuntimeError):
+            log.warning(_("No room jid selected"))
+            return {}
+
+        client = self.host.get_client(profile)
+        d = self.host.plugins["XEP-0045"].join(client, room_jid, nick, {})
+
+        def join_eb(failure):
+            log.warning("Error while trying to join room: {}".format(failure))
+            # FIXME: failure are badly managed in plugin XEP-0045. Plugin XEP-0045 need to be fixed before managing errors correctly here
+            return {}
+
+        d.addCallbacks(lambda __: {}, join_eb)
+        return d
+
+    def _bookmarks_menu(self, data, profile):
+        """ XMLUI activated by menu: return Gateways UI
+        @param profile: %(doc_profile)s
+
+        """
+        client = self.host.get_client(profile)
+        xmlui = xml_tools.XMLUI(title=_("Bookmarks manager"))
+        adv_list = xmlui.change_container(
+            "advanced_list",
+            columns=3,
+            selectable="single",
+            callback_id=self.__selected_id,
+        )
+        for bookmarks in (
+            client.bookmarks_local,
+            client.bookmarks_private,
+            client.bookmarks_pubsub,
+        ):
+            if bookmarks is None:
+                continue
+            for (room_jid, data) in sorted(
+                list(bookmarks[XEP_0048.MUC_TYPE].items()),
+                key=lambda item: item[1].get("name", item[0].user),
+            ):
+                room_jid_s = room_jid.full()
+                adv_list.set_row_index(
+                    "%s %s" % (room_jid_s, data.get("nick") or client.jid.user)
+                )
+                xmlui.addText(data.get("name", ""))
+                xmlui.addJid(room_jid)
+                if C.bool(data.get("autojoin", C.BOOL_FALSE)):
+                    xmlui.addText("autojoin")
+                else:
+                    xmlui.addEmpty()
+        adv_list.end()
+        xmlui.addDivider("dash")
+        xmlui.addText(_("add a bookmark"))
+        xmlui.change_container("pairs")
+        xmlui.addLabel(_("Name"))
+        xmlui.addString("name")
+        xmlui.addLabel(_("jid"))
+        xmlui.addString("jid")
+        xmlui.addLabel(_("Nickname"))
+        xmlui.addString("nick", client.jid.user)
+        xmlui.addLabel(_("Autojoin"))
+        xmlui.addBool("autojoin")
+        xmlui.change_container("vertical")
+        xmlui.addButton(self.__bm_save_id, _("Save"), ("name", "jid", "nick", "autojoin"))
+        return {"xmlui": xmlui.toXml()}
+
+    def _bookmarks_save_cb(self, data, profile):
+        bm_data = xml_tools.xmlui_result_2_data_form_result(data)
+        try:
+            location = jid.JID(bm_data.pop("jid"))
+        except KeyError:
+            raise exceptions.InternalError("Can't find mandatory key")
+        d = self.add_bookmark(XEP_0048.MUC_TYPE, location, bm_data, profile_key=profile)
+        d.addCallback(lambda __: {})
+        return d
+
+    @defer.inlineCallbacks
+    def add_bookmark(
+        self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE
+    ):
+        """Store a new bookmark
+
+        @param type_: bookmark type, one of:
+            - XEP_0048.MUC_TYPE: Multi-User chat room
+            - XEP_0048.URL_TYPE: web page URL
+        @param location: dependeding on type_, can be a MUC room jid or an url
+        @param data (dict): depending on type_, can contains the following keys:
+            - name: human readable name of the bookmark
+            - nick: user preferred room nick (default to user part of profile's jid)
+            - autojoin: "true" if room must be automatically joined on connection
+            - password: unused yet TODO
+        @param storage_type: where the bookmark will be stored, can be:
+            - "auto": find best available option: pubsub, private, local in that order
+            - "pubsub": PubSub private storage (XEP-0223)
+            - "private": Private XML storage (XEP-0049)
+            - "local": Store in SàT database
+        @param profile_key: %(doc_profile_key)s
+        """
+        assert storage_type in ("auto", "pubsub", "private", "local")
+        if type_ == XEP_0048.URL_TYPE and {"autojoin", "nick"}.intersection(list(data.keys())):
+            raise ValueError("autojoin or nick can't be used with URLs")
+        client = self.host.get_client(profile_key)
+        if storage_type == "auto":
+            if client.bookmarks_pubsub is not None:
+                storage_type = "pubsub"
+            elif client.bookmarks_private is not None:
+                storage_type = "private"
+            else:
+                storage_type = "local"
+                log.warning(_("Bookmarks will be local only"))
+            log.info(_('Type selected for "auto" storage: %s') % storage_type)
+
+        if storage_type == "local":
+            client.bookmarks_local[type_][location] = data
+            yield client.bookmarks_local.force(type_)
+        else:
+            bookmarks = yield self._get_server_bookmarks(storage_type, client.profile)
+            bookmarks[type_][location] = data
+            bookmark_elt = self._dict_2_bookmark_elt(type_, bookmarks)
+            yield self._set_server_bookmarks(storage_type, bookmark_elt, client.profile)
+
+    @defer.inlineCallbacks
+    def remove_bookmark(
+        self, type_, location, storage_type="all", profile_key=C.PROF_KEY_NONE
+    ):
+        """Remove a stored bookmark
+
+        @param type_: bookmark type, one of:
+            - XEP_0048.MUC_TYPE: Multi-User chat room
+            - XEP_0048.URL_TYPE: web page URL
+        @param location: dependeding on type_, can be a MUC room jid or an url
+        @param storage_type: where the bookmark is stored, can be:
+            - "all": remove from everywhere
+            - "pubsub": PubSub private storage (XEP-0223)
+            - "private": Private XML storage (XEP-0049)
+            - "local": Store in SàT database
+        @param profile_key: %(doc_profile_key)s
+        """
+        assert storage_type in ("all", "pubsub", "private", "local")
+        client = self.host.get_client(profile_key)
+
+        if storage_type in ("all", "local"):
+            try:
+                del client.bookmarks_local[type_][location]
+                yield client.bookmarks_local.force(type_)
+            except KeyError:
+                log.debug("Bookmark is not present in local storage")
+
+        if storage_type in ("all", "private"):
+            bookmarks = yield self._get_server_bookmarks("private", client.profile)
+            try:
+                del bookmarks[type_][location]
+                bookmark_elt = self._dict_2_bookmark_elt(type_, bookmarks)
+                yield self._set_server_bookmarks("private", bookmark_elt, client.profile)
+            except KeyError:
+                log.debug("Bookmark is not present in private storage")
+
+        if storage_type == "pubsub":
+            raise NotImplementedError
+
+    def _bookmarks_list(self, type_, storage_location, profile_key=C.PROF_KEY_NONE):
+        """Return stored bookmarks
+
+        @param type_: bookmark type, one of:
+            - XEP_0048.MUC_TYPE: Multi-User chat room
+            - XEP_0048.URL_TYPE: web page URL
+        @param storage_location: can be:
+            - 'all'
+            - 'local'
+            - 'private'
+            - 'pubsub'
+        @param profile_key: %(doc_profile_key)s
+        @param return (dict): (key: storage_location, value dict) with:
+            - value (dict): (key: bookmark_location, value: bookmark data)
+        """
+        client = self.host.get_client(profile_key)
+        ret = {}
+        ret_d = defer.succeed(ret)
+
+        def fill_bookmarks(__, _storage_location):
+            bookmarks_ori = getattr(client, "bookmarks_" + _storage_location)
+            if bookmarks_ori is None:
+                return ret
+            data = bookmarks_ori[type_]
+            for bookmark in data:
+                if type_ == XEP_0048.MUC_TYPE:
+                    ret[_storage_location][bookmark.full()] = data[bookmark].copy()
+                else:
+                    ret[_storage_location][bookmark] = data[bookmark].copy()
+            return ret
+
+        for _storage_location in ("local", "private", "pubsub"):
+            if storage_location in ("all", _storage_location):
+                ret[_storage_location] = {}
+                if _storage_location in ("private",):
+                    # we update distant bookmarks, just in case an other client added something
+                    d = self._get_server_bookmarks(_storage_location, client.profile)
+                else:
+                    d = defer.succeed(None)
+                d.addCallback(fill_bookmarks, _storage_location)
+                ret_d.addCallback(lambda __: d)
+
+        return ret_d
+
+    def _bookmarks_remove(
+        self, type_, location, storage_location, profile_key=C.PROF_KEY_NONE
+    ):
+        """Return stored bookmarks
+
+        @param type_: bookmark type, one of:
+            - XEP_0048.MUC_TYPE: Multi-User chat room
+            - XEP_0048.URL_TYPE: web page URL
+        @param location: dependeding on type_, can be a MUC room jid or an url
+        @param storage_location: can be:
+            - "all": remove from everywhere
+            - "pubsub": PubSub private storage (XEP-0223)
+            - "private": Private XML storage (XEP-0049)
+            - "local": Store in SàT database
+        @param profile_key: %(doc_profile_key)s
+        """
+        if type_ == XEP_0048.MUC_TYPE:
+            location = jid.JID(location)
+        return self.remove_bookmark(type_, location, storage_location, profile_key)
+
+    def _bookmarks_add(
+        self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE
+    ):
+        if type_ == XEP_0048.MUC_TYPE:
+            location = jid.JID(location)
+        return self.add_bookmark(type_, location, data, storage_type, profile_key)
+
+    def cmd_bookmark(self, client, mess_data):
+        """(Un)bookmark a MUC room
+
+        @command (group): [autojoin | remove]
+            - autojoin: join room automatically on connection
+            - remove: remove bookmark(s) for this room
+        """
+        txt_cmd = self.host.plugins[C.TEXT_CMDS]
+
+        options = mess_data["unparsed"].strip().split()
+        if options and options[0] not in ("autojoin", "remove"):
+            txt_cmd.feed_back(client, _("Bad arguments"), mess_data)
+            return False
+
+        room_jid = mess_data["to"].userhostJID()
+
+        if "remove" in options:
+            self.remove_bookmark(XEP_0048.MUC_TYPE, room_jid, profile_key=client.profile)
+            txt_cmd.feed_back(
+                client,
+                _("All [%s] bookmarks are being removed") % room_jid.full(),
+                mess_data,
+            )
+            return False
+
+        data = {
+            "name": room_jid.user,
+            "nick": client.jid.user,
+            "autojoin": "true" if "autojoin" in options else "false",
+        }
+        self.add_bookmark(XEP_0048.MUC_TYPE, room_jid, data, profile_key=client.profile)
+        txt_cmd.feed_back(client, _("Bookmark added"), mess_data)
+
+        return False