view libervia/backend/plugins/plugin_xep_0402.py @ 4327:554a87ae17a6

plugin XEP-0048, XEP-0402; CLI (bookmarks): implement XEP-0402 (PEP Native Bookmarks): - Former bookmarks implementation is now labeled as "legacy". - XEP-0402 is now used for bookmarks when relevant namespaces are found, and it fallbacks to legacy XEP-0048/XEP-0049 bookmarks otherwise. - CLI legacy bookmark commands have been moved to `bookmarks legacy` - CLI bookmarks commands now use the new XEP-0402 (with fallback to legacy one automatically used if necessary).
author Goffi <goffi@goffi.org>
date Wed, 20 Nov 2024 11:43:27 +0100
parents
children
line wrap: on
line source

#!/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,
                )