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

539 lines
20 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 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
):
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)
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}))