changeset 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 5fd6a4dc2122
children f72d6b86f8dc
files libervia/backend/memory/disco.py libervia/backend/plugins/plugin_xep_0048.py libervia/backend/plugins/plugin_xep_0060.py libervia/backend/plugins/plugin_xep_0163.py libervia/backend/plugins/plugin_xep_0376.py libervia/backend/plugins/plugin_xep_0402.py libervia/cli/base.py libervia/cli/bookmarks_legacy.py libervia/cli/cmd_bookmarks.py libervia/cli/cmd_gateway.py libervia/cli/cmd_message.py
diffstat 11 files changed, 858 insertions(+), 165 deletions(-) [+]
line wrap: on
line diff
--- 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)
--- 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:
--- 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],
--- 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 <http://www.gnu.org/licenses/>.
 
 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)
 
--- 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
--- /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,
+                )
--- 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 <http://www.gnu.org/licenses/>.
 
+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:
     """
--- /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 <http://www.gnu.org/licenses/>.
+
+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")
+        )
--- 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 <http://www.gnu.org/licenses/>.
 
-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")
         )
--- 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)
--- 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"))