diff --git a/libervia/backend/memory/disco.py b/libervia/backend/memory/disco.py
index d24f9713..8dae79f4 100755
--- a/libervia/backend/memory/disco.py
+++ b/libervia/backend/memory/disco.py
@@ -135,7 +135,7 @@ class Discovery(object):
@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 --git a/libervia/backend/plugins/plugin_xep_0048.py b/libervia/backend/plugins/plugin_xep_0048.py
index b7ca223b..0643828c 100644
--- a/libervia/backend/plugins/plugin_xep_0048.py
+++ b/libervia/backend/plugins/plugin_xep_0048.py
@@ -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 @@ class XEP_0048(object):
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 @@ class XEP_0048(object):
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 @@ class XEP_0048(object):
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 @@ class XEP_0048(object):
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 @@ class XEP_0048(object):
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 --git a/libervia/backend/plugins/plugin_xep_0060.py b/libervia/backend/plugins/plugin_xep_0060.py
index ad10118e..681c6fae 100644
--- a/libervia/backend/plugins/plugin_xep_0060.py
+++ b/libervia/backend/plugins/plugin_xep_0060.py
@@ -102,6 +102,7 @@ class XEP_0060(object):
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 @@ class XEP_0060(object):
* 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 @@ class XEP_0060(object):
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 @@ class XEP_0060(object):
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 @@ class XEP_0060(object):
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 @@ class XEP_0060(object):
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 @@ class XEP_0060(object):
def retract_items(
self,
- client: SatXMPPClient,
+ client: SatXMPPEntity,
service: jid.JID,
nodeIdentifier: str,
itemIdentifiers: Iterable[str],
diff --git a/libervia/backend/plugins/plugin_xep_0163.py b/libervia/backend/plugins/plugin_xep_0163.py
index c630fb78..56d5c46c 100644
--- a/libervia/backend/plugins/plugin_xep_0163.py
+++ b/libervia/backend/plugins/plugin_xep_0163.py
@@ -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 twisted.words.xish import domish
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 @@ class XEP_0163(object):
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 @@ class XEP_0163(object):
@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 @@ class XEP_0163(object):
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 --git a/libervia/backend/plugins/plugin_xep_0376.py b/libervia/backend/plugins/plugin_xep_0376.py
index 648970df..8686d29d 100644
--- a/libervia/backend/plugins/plugin_xep_0376.py
+++ b/libervia/backend/plugins/plugin_xep_0376.py
@@ -105,7 +105,10 @@ class XEP_0376:
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 @@ class XEP_0376:
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 --git a/libervia/backend/plugins/plugin_xep_0402.py b/libervia/backend/plugins/plugin_xep_0402.py
new file mode 100644
index 00000000..2d55edbf
--- /dev/null
+++ b/libervia/backend/plugins/plugin_xep_0402.py
@@ -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 --git a/libervia/cli/base.py b/libervia/cli/base.py
index 458e7639..3375977a 100644
--- a/libervia/cli/base.py
+++ b/libervia/cli/base.py
@@ -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
-import termios
from pathlib import Path
-from glob import iglob
+import sys
+import termios
+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 date_utils
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.loops import QuitException, get_libervia_cli_loop
from libervia.cli.constants import Const as C
+from libervia.cli.loops import QuitException, get_libervia_cli_loop
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 @@ PROGRESS_DELAY = 0.1 # the progression will be checked every PROGRESS_DELAY s
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 --git a/libervia/cli/bookmarks_legacy.py b/libervia/cli/bookmarks_legacy.py
new file mode 100644
index 00000000..a86f3ce5
--- /dev/null
+++ b/libervia/cli/bookmarks_legacy.py
@@ -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 --git a/libervia/cli/cmd_bookmarks.py b/libervia/cli/cmd_bookmarks.py
index c8b762ff..8e9c80b9 100644
--- a/libervia/cli/cmd_bookmarks.py
+++ b/libervia/cli/cmd_bookmarks.py
@@ -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(BookmarksCommon):
+class BookmarksList(base.CommandBase):
def __init__(self, host):
- super(BookmarksList, self).__init__(host, "list", help=_("list bookmarks"))
+ 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
+
+ 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 @@ class BookmarksRemove(BookmarksCommon):
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 @@ class BookmarksRemove(BookmarksCommon):
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 --git a/libervia/cli/cmd_gateway.py b/libervia/cli/cmd_gateway.py
index 825e24bc..f8d8eb37 100644
--- a/libervia/cli/cmd_gateway.py
+++ b/libervia/cli/cmd_gateway.py
@@ -154,16 +154,14 @@ class Unregister(base.CommandBase):
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 --git a/libervia/cli/cmd_message.py b/libervia/cli/cmd_message.py
index 0cacda93..57637b24 100644
--- a/libervia/cli/cmd_message.py
+++ b/libervia/cli/cmd_message.py
@@ -116,7 +116,7 @@ class Send(base.CommandBase):
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 @@ class Send(base.CommandBase):
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 @@ class Send(base.CommandBase):
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"))