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).
This commit is contained in:
Goffi 2024-11-20 11:43:27 +01:00
commit 06a1eef720
11 changed files with 858 additions and 165 deletions

View file

@ -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)

View file

@ -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:

View file

@ -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],

View file

@ -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 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)

View file

@ -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

View file

@ -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,
)

View file

@ -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
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:
"""

View file

@ -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")
)

View file

@ -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(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")
)

View file

@ -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)

View file

@ -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"))