Mercurial > libervia-backend
view libervia/backend/plugins/plugin_xep_0048.py @ 4327:554a87ae17a6
plugin XEP-0048, XEP-0402; CLI (bookmarks): implement XEP-0402 (PEP Native Bookmarks):
- Former bookmarks implementation is now labeled as "legacy".
- XEP-0402 is now used for bookmarks when relevant namespaces are found, and it fallbacks
to legacy XEP-0048/XEP-0049 bookmarks otherwise.
- CLI legacy bookmark commands have been moved to `bookmarks legacy`
- CLI bookmarks commands now use the new XEP-0402 (with fallback to legacy one
automatically used if necessary).
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 20 Nov 2024 11:43:27 +0100 |
parents | 0d7bb4df2343 |
children |
line wrap: on
line source
#!/usr/bin/env python3 # Libervia plugin for Bookmarks (xep-0048) # Copyright (C) 2009-2024 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 cast 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 ) # XXX: We're transitionning to XEP-0402, so we mark bridge method as "legacy" # here. host.bridge.add_method( "bookmarks_legacy_list", ".plugin", in_sign="sss", out_sign="a{sa{sa{ss}}}", method=self.bookmarks_list, async_=True, ) host.bridge.add_method( "bookmarks_legacy_remove", ".plugin", in_sign="ssss", out_sign="", method=self.bookmarks_remove, async_=True, ) host.bridge.add_method( "bookmarks_legacy_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() local = cast(dict[str, dict | None] | None, local) if not local: local = {XEP_0048.MUC_TYPE: {}, XEP_0048.URL_TYPE: {}} 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_: str, storage_location: str, profile_key: str = C.PROF_KEY_NONE ) -> defer.Deferred[dict]: """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 try: data = bookmarks_ori[type_] except KeyError: log.warning(f"{type_!r} missing in {storage_location} storage.") 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