Mercurial > libervia-backend
diff libervia/backend/plugins/plugin_xep_0402.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 | |
children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/plugins/plugin_xep_0402.py Wed Nov 20 11:43:27 2024 +0100 @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 + +# Libervia plugin to handle chat room bookmarks via PEP +# 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 Iterator, Self, cast + +from pydantic import BaseModel, Field, RootModel +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.words.xish import domish +from wokkel import pubsub + +from libervia.backend.core import exceptions +from libervia.backend.core.constants import Const as C +from libervia.backend.core.core_types import SatXMPPEntity +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.models.types import DomishElementType, JIDType +from libervia.backend.plugins.plugin_xep_0045 import XEP_0045 +from libervia.backend.plugins.plugin_xep_0048 import XEP_0048 +from libervia.backend.plugins.plugin_xep_0060 import XEP_0060 +from libervia.backend.tools import utils +from libervia.backend.tools.common import data_format + +log = getLogger(__name__) + + +PLUGIN_INFO = { + C.PI_NAME: "PEP Native Bookmarks", + C.PI_IMPORT_NAME: "XEP-0402", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: ["XEP-0048", "XEP-0060", "XEP-0163", "XEP-0045"], + C.PI_RECOMMENDATIONS: [], + C.PI_MAIN: "XEP_0402", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Bookmark chat rooms, and handle their joined state."""), +} + +NS_BOOKMARKS2 = "urn:xmpp:bookmarks:1" +NS_BOOKMARKS2_COMPAT = f"{NS_BOOKMARKS2}#compat" + + +class Conference(BaseModel): + """ + Model for conference data. + """ + + autojoin: bool = Field( + False, + description="Whether the client should automatically join the conference room on " + "login.", + ) + name: str | None = Field( + None, description="A friendly name for the bookmark, specified by the user." + ) + nick: str | None = Field( + None, description="The user's preferred roomnick for the chatroom." + ) + password: str | None = Field( + None, description="A password used to access the chatroom." + ) + extensions: list[DomishElementType] = Field( + default_factory=list, + description="A set of child elements (of potentially any namespace).", + ) + + def set_attributes(self, conference_elt: domish.Element) -> None: + """Set <conference> element attributes from this instance's data.""" + if self.autojoin: + conference_elt["autojoin"] = "true" if self.autojoin else "false" + if self.name: + conference_elt["name"] = self.name + + def set_children(self, conference_elt: domish.Element) -> None: + """Set <conference> element children from this instance's data.""" + if self.nick: + nick_elt = conference_elt.addElement((NS_BOOKMARKS2, "nick")) + nick_elt.addContent(self.nick) + if self.password: + password_elt = conference_elt.addElement((NS_BOOKMARKS2, "password")) + password_elt.addContent(self.password) + for extension in self.extensions: + conference_elt.addChild(extension) + + @classmethod + def from_element(cls, conference_elt: domish.Element) -> Self: + """ + Create a Conference instance from a <conference> element or its parent. + + @param conference_elt: The <conference> element or a parent element. + @return: Conference instance. + @raise exceptions.NotFound: If the <conference> element is not found. + """ + if conference_elt.uri != NS_BOOKMARKS2 or conference_elt.name != "conference": + child_conference_elt = next( + conference_elt.elements(NS_BOOKMARKS2, "conference"), None + ) + if child_conference_elt is None: + raise exceptions.NotFound("<conference> element not found") + else: + conference_elt = child_conference_elt + kwargs = {} + if conference_elt.hasAttribute("autojoin"): + kwargs["autojoin"] = conference_elt["autojoin"] == "true" + if conference_elt.hasAttribute("name"): + kwargs["name"] = conference_elt["name"] + nick_elt = next(conference_elt.elements(NS_BOOKMARKS2, "nick"), None) + if nick_elt: + kwargs["nick"] = str(nick_elt) + password_elt = next(conference_elt.elements(NS_BOOKMARKS2, "password"), None) + if password_elt: + kwargs["password"] = str(password_elt) + kwargs["extensions"] = [ + child for child in conference_elt.elements() if child.uri != NS_BOOKMARKS2 + ] + return cls(**kwargs) + + def to_element(self) -> domish.Element: + """Build the <conference> element from this instance's data. + + @return: <conference> element. + """ + conference_elt = domish.Element((NS_BOOKMARKS2, "conference")) + self.set_attributes(conference_elt) + self.set_children(conference_elt) + return conference_elt + + +class Bookmarks(RootModel): + root: dict[JIDType, Conference] + + def items(self): + return self.root.items() + + def __dict__(self) -> dict[JIDType, Conference]: # type: ignore + return self.root + + def __iter__(self) -> Iterator[JIDType]: # type: ignore + return iter(self.root) + + def __getitem__(self, item): + return self.root[item] + + @classmethod + def from_elements(cls, items_elt: list[domish.Element]) -> Self: + """Convert list of items to instance of Bookmarks. + + @param items_elt: list of <item> elements from boorkmarks node. + """ + bookmarks = {} + + for item_elt in items_elt: + try: + bookmark_jid = jid.JID(item_elt["id"]) + except RuntimeError as e: + log.warning(f"Can't parse bookmark jid {e}: {item_elt.toXml()}") + continue + try: + conference = Conference.from_element(item_elt) + except exceptions.NotFound: + log.warning(f"Can't find conference data in bookmark: {item_elt}") + else: + bookmarks[bookmark_jid] = conference + + return cls(bookmarks) + + +class XEP_0402: + namespace = NS_BOOKMARKS2 + + def __init__(self, host): + log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization") + self.host = host + self._legacy = cast(XEP_0048, host.plugins["XEP-0048"]) + self._p = cast(XEP_0060, host.plugins["XEP-0060"]) + self._muc = cast(XEP_0045, host.plugins["XEP-0045"]) + host.bridge.add_method( + "bookmark_get", + ".plugin", + in_sign="ss", + out_sign="s", + method=self._bookmark_get, + async_=True, + ) + host.bridge.add_method( + "bookmarks_list", + ".plugin", + in_sign="ss", + out_sign="s", + method=self._bookmarks_list, + async_=True, + ) + host.bridge.add_method( + "bookmark_remove", + ".plugin", + in_sign="ss", + out_sign="", + method=self._bookmark_remove, + async_=True, + ) + host.bridge.add_method( + "bookmarks_set", + ".plugin", + in_sign="ss", + out_sign="", + method=self._bookmarks_set, + async_=True, + ) + host.plugins["XEP-0163"].add_pep_event( + None, NS_BOOKMARKS2, self._on_bookmark_event + ) + host.register_namespace("bookmarks2", NS_BOOKMARKS2) + + async def _on_bookmark_event( + self, items_events: pubsub.ItemsEvent, profile: str + ) -> None: + client = self.host.get_client(profile) + if items_events.sender != client.jid.userhostJID(): + log.warning( + "Bookmark event Unexpectedly send for another account " + f"({items_events.sender})." + ) + else: + for item_elt in items_events.items: + try: + room_jid = jid.JID(item_elt["id"]) + except Exception as e: + log.warning( + f'Ignoring bookmark due to invalid JID in "id" ({e}): ' + f"{item_elt.toXml()}" + ) + continue + try: + conference = Conference.from_element(item_elt) + except exceptions.NotFound: + log.warning("Ignoring invalid bookmark element: {item_elt.toXml()}") + except Exception: + log.exception("Can't parse bookmark item: {item_elt.toXml()}") + else: + if conference.autojoin: + await self._muc.join( + client, + room_jid, + conference.nick, + {"password": conference.password}, + ) + else: + await self._muc.leave(client, room_jid) + + @utils.ensure_deferred + async def _bookmark_get(self, bookmark_jid: str, profile: str) -> str: + """List current boorkmarks. + + @param extra_s: Serialised extra. + Reserved for future use. + @param profile: Profile to use. + """ + client = self.host.get_client(profile) + conference = await self.get(client, jid.JID(bookmark_jid)) + return conference.model_dump_json(exclude_none=True) + + async def get(self, client: SatXMPPEntity, bookmark_jid: jid.JID) -> Conference: + """Helper method to get a single bookmark. + + @param client: profile session. + @bookmark_jid: JID of the boorkmark to get. + @return: Conference instance. + """ + pep_jid = client.jid.userhostJID() + if await self.host.memory.disco.has_feature( + client, NS_BOOKMARKS2_COMPAT, pep_jid + ): + items, __ = await self._p.get_items( + client, + client.jid.userhostJID(), + NS_BOOKMARKS2, + item_ids=[bookmark_jid.full()], + ) + return Conference.from_element(items[0]) + else: + # No compatibility layer, we use legacy bookmarks. + bookmarks = await self.list(client) + return bookmarks[bookmark_jid] + + def _bookmark_remove(self, bookmark_jid: str, profile: str) -> defer.Deferred[None]: + d = defer.ensureDeferred( + self.remove(self.host.get_client(profile), jid.JID(bookmark_jid)) + ) + return d + + async def remove(self, client: SatXMPPEntity, bookmark_jid: jid.JID) -> None: + """Helper method to delete an existing bookmark. + + @param client: Profile session. + @param bookmark_jid: Bookmark to delete. + """ + pep_jid = client.jid.userhostJID() + + if await self.host.memory.disco.has_feature( + client, NS_BOOKMARKS2_COMPAT, pep_jid + ): + await self._p.retract_items( + client, client.jid.userhostJID(), NS_BOOKMARKS2, [bookmark_jid.full()] + ) + else: + log.debug( + f"[{client.profile}] No compatibility layer found, we use legacy " + "bookmarks." + ) + await self._legacy.remove_bookmark( + self._legacy.MUC_TYPE, bookmark_jid, "private", client.profile + ) + + @utils.ensure_deferred + async def _bookmarks_list(self, extra_s: str, profile: str) -> str: + """List current boorkmarks. + + @param extra_s: Serialised extra. + Reserved for future use. + @param profile: Profile to use. + """ + client = self.host.get_client(profile) + extra = data_format.deserialise(extra_s) + bookmarks = await self.list(client, extra) + return bookmarks.model_dump_json(exclude_none=True) + + async def list(self, client: SatXMPPEntity, extra: dict | None = None) -> Bookmarks: + """List bookmarks. + + If there is a compatibility layer announced, Bookmarks2 will be used, otherwise + legacy bookmarks will be used. + @param client: Client session. + @param extra: extra dat) + @return: bookmarks. + """ + pep_jid = client.jid.userhostJID() + + if await self.host.memory.disco.has_feature( + client, NS_BOOKMARKS2_COMPAT, pep_jid + ): + items, __ = await self._p.get_items( + client, client.jid.userhostJID(), NS_BOOKMARKS2 + ) + return Bookmarks.from_elements(items) + else: + # There is no compatibility layer on the PEP server, so we use legacy + # bookmarks as recommended at + # https://docs.modernxmpp.org/client/groupchat/#bookmarks + log.debug( + f"[{client.profile}] No compatibility layer found, we use legacy " + "bookmarks." + ) + legacy_data = await self._legacy.bookmarks_list( + self._legacy.MUC_TYPE, "private", client.profile + ) + private_bookmarks = legacy_data["private"] + bookmarks_dict = {} + for jid, bookmark_data in private_bookmarks.items(): + autojoin = C.bool(bookmark_data.get("autojoin", C.BOOL_FALSE)) + name = bookmark_data.get("name") + nick = bookmark_data.get("nick") + password = bookmark_data.get("password") + conference = Conference( + autojoin=autojoin, name=name, nick=nick, password=password + ) + bookmarks_dict[jid] = conference + return Bookmarks(bookmarks_dict) + + @utils.ensure_deferred + async def _bookmarks_set(self, bookmarks_raw: str, profile: str) -> None: + """Add or update one or more bookmarks. + + @param bookmarks_raw: serialised bookmark. + It must deserialise to a dict mapping from bookmark JID to Conference data. + @param profile: Profile to use. + """ + client = self.host.get_client(profile) + bookmarks = Bookmarks.model_validate_json(bookmarks_raw) + pep_jid = client.jid.userhostJID() + + if await self.host.memory.disco.has_feature( + client, NS_BOOKMARKS2_COMPAT, pep_jid + ): + bookmark_items = [] + for bookmark_jid, conference in bookmarks.items(): + item_elt = domish.Element((pubsub.NS_PUBSUB, "item")) + item_elt["id"] = bookmark_jid.full() + item_elt.addChild(conference.to_element()) + bookmark_items.append(item_elt) + + await self._p.send_items( + client, + None, + NS_BOOKMARKS2, + bookmark_items, + extra={ + self._p.EXTRA_PUBLISH_OPTIONS: { + "pubsub#access_model": self._p.ACCESS_WHITELIST + }, + self._p.EXTRA_AUTOCREATE: True, + }, + ) + else: + log.debug( + f"[{client.profile}] No compatibility layer found, we use legacy " + "bookmarks." + ) + # XXX: We add every bookmark one by one, which is inefficient. The legacy + # plugin likely implemented this way because end-users typically add bookmarks + # individually. Nowadays, very few servers, if any, still implement XEP-0048 + # without the XEP-0402 compatibility layer, so it's not worth spending time to + # improve the legacy XEP-0048 plugin. + for bookmark_jid, conference in bookmarks.items(): + bookmark_data = {} + if conference.autojoin: + bookmark_data["autojoin"] = C.BOOL_TRUE + for attribute in ("name", "nick", "password"): + value = getattr(conference, attribute) + if value: + bookmark_data[attribute] = value + await self._legacy.add_bookmark( + self._legacy.MUC_TYPE, + bookmark_jid, + bookmark_data, + storage_type="private", + profile_key=client.profile, + )