libervia-backend/libervia/backend/plugins/plugin_xep_0402.py

547 lines
21 KiB
Python

#!/usr/bin/env python3
# Libervia plugin to handle chat room bookmarks via PEP
# Copyright (C) 2009-2026 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 error, jid
from twisted.words.protocols.jabber.xmlstream import TimeoutError
from twisted.words.xish import domish
from tx_xmpp 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 AlreadyJoined, 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.plugins.plugin_xep_0163 import XEP_0163
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"] = C.bool(conference_elt["autojoin"])
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"])
pep = cast(XEP_0163, host.plugins["XEP-0163"])
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,
)
pep.add_pep_event(None, NS_BOOKMARKS2, self._on_bookmark_event)
host.register_namespace("bookmarks2", NS_BOOKMARKS2)
host.trigger.add_with_check("muc_room_left", self, self._muc_room_left_trigger)
host.trigger.add_with_check(
"muc_room_joined", self, self._muc_room_joined_trigger
)
async def profile_connected(self, client: SatXMPPEntity) -> None:
"""Fetch bookmarks and autojoin in background."""
defer.ensureDeferred(self._fetch_and_process_bookmarks(client))
async def _fetch_and_process_bookmarks(self, client: SatXMPPEntity) -> None:
"""Fetch bookmarks and process join/leave for autojoin rooms.
@param client: Client session.
"""
try:
bookmarks = await self.list(client)
except exceptions.NotFound:
log.warning(f"[{client.profile}] No bookmarks found on server")
return
except Exception:
log.exception(f"[{client.profile}] Failed to retrieve bookmarks on startup")
return
for room_jid, conference in bookmarks.items():
try:
if conference.autojoin:
await self._muc.join(
client,
room_jid,
conference.nick,
{"password": conference.password},
)
except TimeoutError:
log.warning(f"[{client.profile}] Timeout while joining {room_jid}")
except Exception:
log.exception(
f"[{client.profile}] Failed to process autojoin for {room_jid}"
)
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:
try:
await self._muc.join(
client,
room_jid,
conference.nick,
{"password": conference.password},
)
except AlreadyJoined:
pass
else:
try:
await self._muc.leave(client, room_jid)
except exceptions.NotFound:
# Room not joined, nothing to do
pass
@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
):
try:
await self._p.retract_items(
client,
client.jid.userhostJID(),
NS_BOOKMARKS2,
[bookmark_jid.full()],
)
except error.StanzaError as e:
if e.condition != "item-not-found":
raise
# no bookmark to remove, nothing to do
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)
await self.set(client, bookmarks)
async def set(self, client: SatXMPPEntity, bookmarks: Bookmarks) -> None:
"""Add or update one or more bookmarks.
@param client: Client session.
@param bookmarks: Bookmarks to add or update, keyed by their JID.
"""
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,
)
async def _muc_room_joined_trigger(
self, client: SatXMPPEntity, room_jid: jid.JID
) -> None:
"""Update the autojoin flag of a room's bookmark on room join.
@param client: Client session.
@param room_jid: JID of the room that was joined.
"""
await self._set_autojoin(client, room_jid, True)
async def _muc_room_left_trigger(
self, client: SatXMPPEntity, room_jid: jid.JID
) -> None:
"""Update the autojoin flag of a room's bookmark on room leave.
@param client: Client session.
@param room_jid: JID of the room that was left.
"""
await self._set_autojoin(client, room_jid, False)
async def _set_autojoin(
self, client: SatXMPPEntity, room_jid: jid.JID, autojoin: bool
) -> None:
"""Set the autojoin flag of a room's bookmark, if it exists.
@param client: Client session.
@param room_jid: JID of the room.
@param autojoin: True if the room is joined, False otherwise.
"""
try:
conference = await self.get(client, room_jid)
except (IndexError, KeyError, exceptions.NotFound):
# No bookmark for this room, nothing to do
return
except Exception:
# Updating the autojoin flag must not break the join/leave
log.exception(f"Can't get bookmark for {room_jid}")
return
if conference.autojoin == autojoin:
return
conference.autojoin = autojoin
await self.set(client, Bookmarks({room_jid: conference}))