# HG changeset patch # User Goffi # Date 1732099407 -3600 # Node ID 554a87ae17a6090c315c070019bd6ee2d470b2c0 # Parent 5fd6a4dc21221bf868bd1f11c25fcb98f547c812 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). diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/backend/memory/disco.py --- a/libervia/backend/memory/disco.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/backend/memory/disco.py Wed Nov 20 11:43:27 2024 +0100 @@ -135,7 +135,7 @@ @param feature: feature namespace @param jid_: jid of the target, or None for profile's server - @param node(unicode): optional node to use for disco request + @param node: optional node to use for disco request @return: a Deferred which fire a boolean (True if feature is available) """ disco_infos = await self.get_infos(client, jid_, node) diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/backend/plugins/plugin_xep_0048.py --- a/libervia/backend/plugins/plugin_xep_0048.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/backend/plugins/plugin_xep_0048.py Wed Nov 20 11:43:27 2024 +0100 @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -# SAT plugin for Bookmarks (xep-0048) -# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) +# 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 @@ -71,28 +71,30 @@ 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_list", + "bookmarks_legacy_list", ".plugin", in_sign="sss", out_sign="a{sa{sa{ss}}}", - method=self._bookmarks_list, + method=self.bookmarks_list, async_=True, ) host.bridge.add_method( - "bookmarks_remove", + "bookmarks_legacy_remove", ".plugin", in_sign="ssss", out_sign="", - method=self._bookmarks_remove, + method=self.bookmarks_remove, async_=True, ) host.bridge.add_method( - "bookmarks_add", + "bookmarks_legacy_add", ".plugin", in_sign="ssa{ss}ss", out_sign="", - method=self._bookmarks_add, + method=self.bookmarks_add, async_=True, ) try: @@ -419,7 +421,12 @@ if storage_type == "pubsub": raise NotImplementedError - def _bookmarks_list(self, type_, storage_location, profile_key=C.PROF_KEY_NONE): + 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: @@ -458,7 +465,8 @@ 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 + # 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) @@ -467,7 +475,7 @@ return ret_d - def _bookmarks_remove( + def bookmarks_remove( self, type_, location, storage_location, profile_key=C.PROF_KEY_NONE ): """Return stored bookmarks @@ -487,7 +495,7 @@ location = jid.JID(location) return self.remove_bookmark(type_, location, storage_location, profile_key) - def _bookmarks_add( + def bookmarks_add( self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE ): if type_ == XEP_0048.MUC_TYPE: diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/backend/plugins/plugin_xep_0060.py --- a/libervia/backend/plugins/plugin_xep_0060.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/backend/plugins/plugin_xep_0060.py Wed Nov 20 11:43:27 2024 +0100 @@ -102,6 +102,7 @@ ID_SINGLETON = "current" EXTRA_PUBLISH_OPTIONS = "publish_options" EXTRA_ON_PRECOND_NOT_MET = "on_precondition_not_met" + EXTRA_AUTOCREATE = "autocreate" # extra disco needed for RSM, cf. XEP-0060 § 6.5.4 DISCO_RSM = "http://jabber.org/protocol/pubsub#rsm" @@ -608,6 +609,8 @@ * publish_without_options: re-publish without the publish-options. A warning will be logged showing that the publish-options could not be used + - self.EXTRA_AUTOCREATE(bool): Create the node if it's not found, and the + service doesn't do autocreate itself. @return: ids of the created items """ if extra is None: @@ -631,7 +634,25 @@ sender=sender, ) except error.StanzaError as e: - if ( + if e.condition == "item-not-found": + if extra.get(self.EXTRA_AUTOCREATE, False): + # Autocreate is requested, we create the requested node. + await self.createNode( + client, service, nodeIdentifier, publish_options + ) + # And we try again. + iq_result = await self.publish( + client, + service, + nodeIdentifier, + parsed_items, + options=publish_options, + sender=sender, + ) + else: + raise e + + elif ( e.condition == "conflict" and e.appCondition and e.appCondition.name == "precondition-not-met" @@ -690,15 +711,15 @@ client: SatXMPPEntity, service: jid.JID, nodeIdentifier: str, - items: Optional[List[domish.Element]] = None, - options: Optional[dict] = None, - sender: Optional[jid.JID] = None, - extra: Optional[Dict[str, Any]] = None, + items: list[pubsub.Item]|None = None, + options: dict|None = None, + sender: jid.JID|None = None, + extra: dict[str, Any]|None = None, ) -> domish.Element: """Publish pubsub items @param sender: sender of the request, - client.jid will be used if nto set + client.jid will be used if not set @param extra: extra data not used directly by ``publish``, but may be used in triggers @return: IQ result stanza @@ -786,26 +807,26 @@ async def get_items( self, client: SatXMPPEntity, - service: Optional[jid.JID], + service: jid.JID|None, node: str, - max_items: Optional[int] = None, - item_ids: Optional[List[str]] = None, - sub_id: Optional[str] = None, - rsm_request: Optional[rsm.RSMRequest] = None, - extra: Optional[dict] = None, - ) -> Tuple[List[domish.Element], dict]: + max_items: int|None = None, + item_ids: list[str]|None = None, + sub_id: str|None = None, + rsm_request: rsm.RSMRequest|None = None, + extra: dict|None = None, + ) -> tuple[list[domish.Element], dict]: """Retrieve pubsub items from a node. - @param service (JID, None): pubsub service. - @param node (str): node id. - @param max_items (int): optional limit on the number of retrieved items. + @param service: pubsub service. + @param node: node id. + @param max_items: optional limit on the number of retrieved items. @param item_ids (list[str]): identifiers of the items to be retrieved (can't be used with rsm_request). If requested items don't exist, they won't be returned, meaning that we can have an empty list as result (NotFound exception is NOT raised). - @param sub_id (str): optional subscription identifier. - @param rsm_request (rsm.RSMRequest): RSM request data - @return: a deferred couple (list[dict], dict) containing: + @param sub_id : optional subscription identifier. + @param rsm_request: RSM request data + @return: a deferred tuple containing: - list of items - metadata with the following keys: - rsm_first, rsm_last, rsm_count, rsm_index: first, last, count and index @@ -980,11 +1001,11 @@ def createNode( self, - client: SatXMPPClient, + client: SatXMPPEntity, service: jid.JID, nodeIdentifier: Optional[str] = None, options: Optional[Dict[str, str]] = None, - ) -> str: + ) -> defer.Deferred[str]: """Create a new node @param service: PubSub service, @@ -1234,7 +1255,7 @@ def retract_items( self, - client: SatXMPPClient, + client: SatXMPPEntity, service: jid.JID, nodeIdentifier: str, itemIdentifiers: Iterable[str], diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/backend/plugins/plugin_xep_0163.py --- a/libervia/backend/plugins/plugin_xep_0163.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/backend/plugins/plugin_xep_0163.py Wed Nov 20 11:43:27 2024 +0100 @@ -18,6 +18,9 @@ # along with this program. If not, see . from typing import Optional, Callable + +from twisted.python.failure import Failure +from libervia.backend.core.core_types import SatXMPPEntity from libervia.backend.core.i18n import _ from libervia.backend.core import exceptions from libervia.backend.core.constants import Const as C @@ -28,6 +31,7 @@ from wokkel import disco, pubsub from wokkel.formats import Mood from libervia.backend.tools.common import data_format +from libervia.backend.tools import utils log = getLogger(__name__) @@ -74,12 +78,15 @@ disco_info.extend(list(map(disco.DiscoFeature, self.pep_events))) return True + def log_error(self, failure_: Failure) -> None: + log.error(f"Failed to call callback: {failure_}") + def add_pep_event( self, - event_type: Optional[str], + event_type: str | None, node: str, - in_callback: Callable, - out_callback: Optional[Callable] = None, + in_callback: Callable[[pubsub.ItemsEvent, str], None], + out_callback: Callable | None = None, notify: bool = True, ) -> None: """Add a Personal Eventing Protocol event manager @@ -90,7 +97,8 @@ @param node: namespace of the node (e.g. http://jabber.org/protocol/mood for User Mood) @param in_callback: method to call when this event occur - the callable will be called with (itemsEvent, profile) as arguments + the callable will be called with (itemsEvent, profile) as arguments. + Can be blocking or async. @param out_callback: method to call when we want to publish this event (must return a deferred) the callable will be called when send_pep_event is called @@ -107,19 +115,20 @@ if notify: self.pep_events.add(node + "+notify") - def filter_pep_event(client, itemsEvent): + def filter_pep_event(client: SatXMPPEntity, itemsEvent: pubsub.ItemsEvent): """Ignore messages which are not coming from PEP (i.e. a bare jid) - @param itemsEvent(pubsub.ItemsEvent): pubsub event + @param itemsEvent: pubsub event """ if not itemsEvent.sender.user or itemsEvent.sender.resource: - log.debug( + log.warning( "ignoring non PEP event from {} (profile={})".format( itemsEvent.sender.full(), client.profile ) ) return - in_callback(itemsEvent, client.profile) + d = utils.as_deferred(in_callback, itemsEvent, client.profile) + d.addErrback(self.log_error) self.host.plugins["XEP-0060"].add_managed_node(node, items_cb=filter_pep_event) diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/backend/plugins/plugin_xep_0376.py --- a/libervia/backend/plugins/plugin_xep_0376.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/backend/plugins/plugin_xep_0376.py Wed Nov 20 11:43:27 2024 +0100 @@ -105,7 +105,10 @@ sub_jid: Optional[jid.JID] = None, options: Optional[dict] = None, ) -> Tuple[bool, Optional[pubsub.Subscription]]: - if not await self.host.memory.disco.has_feature(client, NS_PAM) or client.is_component: + if ( + not await self.host.memory.disco.has_feature(client, NS_PAM) + or client.is_component + ): return True, None await self._sub_request(client, service, nodeIdentifier, sub_jid, options, True) @@ -127,7 +130,10 @@ subscriptionIdentifier: Optional[str], sender: Optional[jid.JID] = None, ) -> bool: - if not await self.host.memory.disco.has_feature(client, NS_PAM) or client.is_component: + if ( + not await self.host.memory.disco.has_feature(client, NS_PAM) + or client.is_component + ): return True await self._sub_request(client, service, nodeIdentifier, sub_jid, None, False) return False diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/backend/plugins/plugin_xep_0402.py --- /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 . + +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 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 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 element or its parent. + + @param conference_elt: The element or a parent element. + @return: Conference instance. + @raise exceptions.NotFound: If the 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(" 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 element from this instance's data. + + @return: 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 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, + ) diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/cli/base.py --- a/libervia/cli/base.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/cli/base.py Wed Nov 20 11:43:27 2024 +0100 @@ -16,27 +16,26 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import argparse import asyncio -from libervia.backend.core.i18n import _ - -### logging ### +from collections import OrderedDict +from glob import iglob +from importlib import import_module +import inspect import logging as log - -log.basicConfig(level=log.WARNING, format="[%(name)s] %(message)s") -### - -import sys import os import os.path -import argparse -import inspect -import tty +from pathlib import Path +import sys import termios -from pathlib import Path -from glob import iglob +import tty from typing import Optional, Set, Union -from importlib import import_module -from libervia.frontends.tools.jid import JID +import xml.etree.ElementTree as ET + +from rich import console + +from libervia.backend.core import exceptions +from libervia.backend.core.i18n import _ from libervia.backend.tools import config from libervia.backend.tools.common import dynamic_import from libervia.backend.tools.common import uri @@ -44,15 +43,17 @@ from libervia.backend.tools.common import utils from libervia.backend.tools.common import data_format from libervia.backend.tools.common.ansi import ANSI as A -from libervia.backend.core import exceptions import libervia.cli +from libervia.cli.constants import Const as C from libervia.cli.loops import QuitException, get_libervia_cli_loop -from libervia.cli.constants import Const as C from libervia.frontends.bridge.bridge_frontend import BridgeException from libervia.frontends.tools import aio, misc -import xml.etree.ElementTree as ET # FIXME: used temporarily to manage XMLUI -from collections import OrderedDict -from rich import console +from libervia.frontends.tools.jid import JID + +log.basicConfig(level=log.WARNING, format="[%(name)s] %(message)s") +### + + ## bridge handling # we get bridge name from conf and initialise the right class accordingly @@ -90,6 +91,21 @@ def date_decoder(arg): return date_utils.date_parse_ext(arg, default_tz=date_utils.TZ_LOCAL) +def optional_bool_decoder(arg: str) -> bool: + """Decode an optional string to a boolean value. + + @param value: The input string to decode. + @return: The decoded boolean value. + @raise ValueError: If the input string is not a valid boolean representation. + """ + lower_arg = arg.lower() + if lower_arg in ['true', '1', 't', 'y', 'yes']: + return True + elif lower_arg in ['false', '0', 'f', 'n', 'no']: + return False + else: + raise ValueError(f"Invalid boolean value: {arg}") + class LiberviaCli: """ diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/cli/bookmarks_legacy.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/cli/bookmarks_legacy.py Wed Nov 20 11:43:27 2024 +0100 @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + + +# Libervia CLI +# 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 . + +from . import base +from libervia.backend.core.i18n import _ +from libervia.cli.constants import Const as C + +STORAGE_LOCATIONS = ("local", "private", "pubsub") +TYPES = ("muc", "url") + + +class BookmarksCommon(base.CommandBase): + """Class used to group common options of bookmarks subcommands""" + + def add_parser_options(self, location_default="all"): + self.parser.add_argument( + "-l", + "--location", + type=str, + choices=(location_default,) + STORAGE_LOCATIONS, + default=location_default, + help=_("storage location (default: %(default)s)"), + ) + self.parser.add_argument( + "-t", + "--type", + type=str, + choices=TYPES, + default=TYPES[0], + help=_("bookmarks type (default: %(default)s)"), + ) + + +class BookmarksList(BookmarksCommon): + def __init__(self, host): + super(BookmarksList, self).__init__(host, "list", help=_("list bookmarks")) + + async def start(self): + try: + data = await self.host.bridge.bookmarks_legacy_list( + self.args.type, self.args.location, self.host.profile + ) + except Exception as e: + self.disp(f"can't get bookmarks list: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + + mess = [] + for location in STORAGE_LOCATIONS: + if not data[location]: + continue + loc_mess = [] + loc_mess.append(f"{location}:") + book_mess = [] + for book_link, book_data in list(data[location].items()): + name = book_data.get("name") + autojoin = book_data.get("autojoin", "false") == "true" + nick = book_data.get("nick") + book_mess.append( + "\t%s[%s%s]%s" + % ( + (name + " ") if name else "", + book_link, + " (%s)" % nick if nick else "", + " (*)" if autojoin else "", + ) + ) + loc_mess.append("\n".join(book_mess)) + mess.append("\n".join(loc_mess)) + + print("\n\n".join(mess)) + self.host.quit() + + +class BookmarksRemove(BookmarksCommon): + def __init__(self, host): + super(BookmarksRemove, self).__init__(host, "remove", help=_("remove a bookmark")) + + def add_parser_options(self): + super(BookmarksRemove, self).add_parser_options() + self.parser.add_argument( + "bookmark", help=_("jid (for muc bookmark) or url of to remove") + ) + self.parser.add_argument( + "-f", + "--force", + action="store_true", + help=_("delete bookmark without confirmation"), + ) + + async def start(self): + if not self.args.force: + await self.host.confirm_or_quit(_("Are you sure to delete this bookmark?")) + + try: + await self.host.bridge.bookmarks_legacy_remove( + self.args.type, self.args.bookmark, self.args.location, self.host.profile + ) + except Exception as e: + self.disp(_("can't delete bookmark: {e}").format(e=e), error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("bookmark deleted")) + self.host.quit() + + +class BookmarksAdd(BookmarksCommon): + def __init__(self, host): + super(BookmarksAdd, self).__init__(host, "add", help=_("add a bookmark")) + + def add_parser_options(self): + super(BookmarksAdd, self).add_parser_options(location_default="auto") + self.parser.add_argument( + "bookmark", help=_("jid (for muc bookmark) or url of to remove") + ) + self.parser.add_argument("-n", "--name", help=_("bookmark name")) + muc_group = self.parser.add_argument_group(_("MUC specific options")) + muc_group.add_argument("-N", "--nick", help=_("nickname")) + muc_group.add_argument( + "-a", + "--autojoin", + action="store_true", + help=_("join room on profile connection"), + ) + + async def start(self): + if self.args.type == "url" and (self.args.autojoin or self.args.nick is not None): + self.parser.error(_("You can't use --autojoin or --nick with --type url")) + data = {} + if self.args.autojoin: + data["autojoin"] = "true" + if self.args.nick is not None: + data["nick"] = self.args.nick + if self.args.name is not None: + data["name"] = self.args.name + try: + await self.host.bridge.bookmarks_legacy_add( + self.args.type, + self.args.bookmark, + data, + self.args.location, + self.host.profile, + ) + except Exception as e: + self.disp(f"can't add bookmark: {e}", error=True) + self.host.quit(C.EXIT_BRIDGE_ERRBACK) + else: + self.disp(_("bookmark successfully added")) + self.host.quit() + + +class BookmarksLegacy(base.CommandBase): + subcommands = (BookmarksList, BookmarksRemove, BookmarksAdd) + + def __init__(self, host): + super(BookmarksLegacy, self).__init__( + host, "legacy", use_profile=False, help=_("manage legacy bookmarks") + ) diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/cli/cmd_bookmarks.py --- a/libervia/cli/cmd_bookmarks.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/cli/cmd_bookmarks.py Wed Nov 20 11:43:27 2024 +0100 @@ -17,86 +17,70 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from . import base +from rich.table import Table + from libervia.backend.core.i18n import _ +from libervia.backend.tools.common import data_format from libervia.cli.constants import Const as C +from . import base +from .bookmarks_legacy import BookmarksLegacy + __commands__ = ["Bookmarks"] -STORAGE_LOCATIONS = ("local", "private", "pubsub") -TYPES = ("muc", "url") - -class BookmarksCommon(base.CommandBase): - """Class used to group common options of bookmarks subcommands""" - - def add_parser_options(self, location_default="all"): - self.parser.add_argument( - "-l", - "--location", - type=str, - choices=(location_default,) + STORAGE_LOCATIONS, - default=location_default, - help=_("storage location (default: %(default)s)"), - ) - self.parser.add_argument( - "-t", - "--type", - type=str, - choices=TYPES, - default=TYPES[0], - help=_("bookmarks type (default: %(default)s)"), +class BookmarksList(base.CommandBase): + def __init__(self, host): + extra_outputs = {"default": self.default_output} + super().__init__( + host, "list", help=_("list bookmarks"), + use_output=C.OUTPUT_COMPLEX, + extra_outputs=extra_outputs ) + def add_parser_options(self): + pass -class BookmarksList(BookmarksCommon): - def __init__(self, host): - super(BookmarksList, self).__init__(host, "list", help=_("list bookmarks")) + def default_output(self, data: dict) -> None: + table = Table(title="📚 " + _("Group Chat Bookmarks")) + table.add_column("🌐 JID") + table.add_column("📝 " + _("Name")) + table.add_column("👤 " + _("Nick")) + table.add_column("🔒 " + _("Password")) + table.add_column("🚪 " + _("Joined")) + + for jid, conference_data in data.items(): + table.add_row( + str(jid), + conference_data.get("name", ""), + conference_data.get("nick", ""), + conference_data.get("password", ""), + "✅" if conference_data.get("autojoin", False) else "❌" + ) + + self.console.print(table) async def start(self): try: - data = await self.host.bridge.bookmarks_list( - self.args.type, self.args.location, self.host.profile - ) + data = data_format.deserialise(await self.host.bridge.bookmarks_list( + "", self.host.profile + )) except Exception as e: self.disp(f"can't get bookmarks list: {e}", error=True) self.host.quit(C.EXIT_BRIDGE_ERRBACK) + return - mess = [] - for location in STORAGE_LOCATIONS: - if not data[location]: - continue - loc_mess = [] - loc_mess.append(f"{location}:") - book_mess = [] - for book_link, book_data in list(data[location].items()): - name = book_data.get("name") - autojoin = book_data.get("autojoin", "false") == "true" - nick = book_data.get("nick") - book_mess.append( - "\t%s[%s%s]%s" - % ( - (name + " ") if name else "", - book_link, - " (%s)" % nick if nick else "", - " (*)" if autojoin else "", - ) - ) - loc_mess.append("\n".join(book_mess)) - mess.append("\n".join(loc_mess)) - - print("\n\n".join(mess)) + await self.output(data) self.host.quit() -class BookmarksRemove(BookmarksCommon): +class BookmarksRemove(base.CommandBase): def __init__(self, host): - super(BookmarksRemove, self).__init__(host, "remove", help=_("remove a bookmark")) + super().__init__(host, "remove", help=_("remove a bookmark")) def add_parser_options(self): - super(BookmarksRemove, self).add_parser_options() self.parser.add_argument( - "bookmark", help=_("jid (for muc bookmark) or url of to remove") + "bookmark", help=_("jid of the bookmark to remove") ) self.parser.add_argument( "-f", @@ -107,11 +91,14 @@ async def start(self): if not self.args.force: - await self.host.confirm_or_quit(_("Are you sure to delete this bookmark?")) + await self.host.confirm_or_quit( + _("Are you sure to delete the bookmark {bookmark_id!r}?") + .format(bookmark_id=self.args.bookmark) + ) try: - await self.host.bridge.bookmarks_remove( - self.args.type, self.args.bookmark, self.args.location, self.host.profile + await self.host.bridge.bookmark_remove( + self.args.bookmark, self.host.profile ) except Exception as e: self.disp(_("can't delete bookmark: {e}").format(e=e), error=True) @@ -121,55 +108,88 @@ self.host.quit() -class BookmarksAdd(BookmarksCommon): +class BookmarksSet(base.CommandBase): def __init__(self, host): - super(BookmarksAdd, self).__init__(host, "add", help=_("add a bookmark")) + super().__init__( + host, "set", help=_("add or update a bookmark") + ) def add_parser_options(self): - super(BookmarksAdd, self).add_parser_options(location_default="auto") + self.parser.add_argument("bookmark", help=_("jid of the chat room")) + self.parser.add_argument("-n", "--name", help=_("bookmark name"), dest="name") self.parser.add_argument( - "bookmark", help=_("jid (for muc bookmark) or url of to remove") + "-j", + "--join", + nargs="?", + # Value use when option is missing. + default=None, + # Value use when option is used, but value is not specified. + const=True, + type=base.optional_bool_decoder, + # The bookmark attribute is called "autojoin" for historical reason, but it's + # now used a "join" flag, so we use ``join`` here for the option. + dest="autojoin", + metavar="BOOL", + help=_("join the conference room"), ) - self.parser.add_argument("-n", "--name", help=_("bookmark name")) - muc_group = self.parser.add_argument_group(_("MUC specific options")) - muc_group.add_argument("-N", "--nick", help=_("nickname")) - muc_group.add_argument( - "-a", - "--autojoin", + self.parser.add_argument( + "-N", + "--nick", help=_("preferred roomnick for the chatroom") + ) + self.parser.add_argument( + "-P", + "--password", help=_("password used to access the chatroom") + ) + self.parser.add_argument( + "-u", + "--update", action="store_true", - help=_("join room on profile connection"), + help=_("update bookmark data instead of replacing") ) async def start(self): - if self.args.type == "url" and (self.args.autojoin or self.args.nick is not None): - self.parser.error(_("You can't use --autojoin or --nick with --type url")) - data = {} - if self.args.autojoin: - data["autojoin"] = "true" - if self.args.nick is not None: - data["nick"] = self.args.nick - if self.args.name is not None: - data["name"] = self.args.name + conference_data = { + "autojoin": self.args.autojoin, + "name": self.args.name, + "nick": self.args.nick, + "password": self.args.password, + } + + conference_data = {k: v for k, v in conference_data.items() if v is not None} + if self.args.update: + try: + old_conference_data = data_format.deserialise( + await self.host.bridge.bookmark_get( + self.args.bookmark, self.host.profile + ) + ) + except Exception as e: + self.disp( + f"Can't find existing bookmark {self.args.bookmark!r}: {e}. We " + "create it.", + error=True + ) + else: + old_conference_data.update(conference_data) + conference_data = old_conference_data + try: - await self.host.bridge.bookmarks_add( - self.args.type, - self.args.bookmark, - data, - self.args.location, + await self.host.bridge.bookmarks_set( + data_format.serialise({self.args.bookmark: conference_data}), self.host.profile, ) except Exception as e: self.disp(f"can't add bookmark: {e}", error=True) self.host.quit(C.EXIT_BRIDGE_ERRBACK) else: - self.disp(_("bookmark successfully added")) + self.disp(_("bookmark successfully set")) self.host.quit() class Bookmarks(base.CommandBase): - subcommands = (BookmarksList, BookmarksRemove, BookmarksAdd) + subcommands = (BookmarksList, BookmarksSet, BookmarksRemove, BookmarksLegacy) def __init__(self, host): - super(Bookmarks, self).__init__( + super().__init__( host, "bookmarks", use_profile=False, help=_("manage bookmarks") ) diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/cli/cmd_gateway.py --- a/libervia/cli/cmd_gateway.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/cli/cmd_gateway.py Wed Nov 20 11:43:27 2024 +0100 @@ -154,16 +154,14 @@ async def start(self): if not self.args.force: await self.host.confirm_or_quit( - _( - "Are you sure that you want to unregister from {gateway_jid}?" - ).format(gateway_jid=self.args.gateway_jid), - _("Gateway unregistration cancelled.") + _("Are you sure that you want to unregister from {gateway_jid}?").format( + gateway_jid=self.args.gateway_jid + ), + _("Gateway unregistration cancelled."), ) try: - await self.host.bridge.in_band_unregister( - self.args.gateway_jid, self.profile - ) + await self.host.bridge.in_band_unregister(self.args.gateway_jid, self.profile) except Exception as e: self.disp(f"can't unregister from gateway: {e}", error=True) self.host.quit(C.EXIT_BRIDGE_ERRBACK) diff -r 5fd6a4dc2122 -r 554a87ae17a6 libervia/cli/cmd_message.py --- a/libervia/cli/cmd_message.py Wed Nov 20 11:38:44 2024 +0100 +++ b/libervia/cli/cmd_message.py Wed Nov 20 11:43:27 2024 +0100 @@ -116,7 +116,7 @@ addressing_group = self.parser.add_argument_group( "addressing commands", description="Commands to add addressing metadata, and/or to send message to " - "multiple recipients." + "multiple recipients.", ) for arg_name in RECIPIENTS_ARGS: addressing_group.add_argument( @@ -125,7 +125,7 @@ action="append", metavar=("JID", "DESCRIPTION"), help=f'extra "{arg_name.upper()}" recipient(s), may be used several ' - 'times', + "times", ) for arg_name in REPLY_ARGS: addressing_group.add_argument( @@ -133,13 +133,12 @@ nargs="+", action="append", metavar=("JID", "DESCRIPTION"), - help=f'ask to reply to this JID, may be used several ' - 'times', + help=f"ask to reply to this JID, may be used several times", ) addressing_group.add_argument( "--no-reply", action="store_true", - help="flag this message as not requiring replies" + help="flag this message as not requiring replies", ) syntax = self.parser.add_mutually_exclusive_group() syntax.add_argument("-x", "--xhtml", action="store_true", help=_("XHTML body"))