view libervia/backend/plugins/plugin_xep_0297.py @ 4382:b897d98b2c51 default tip

plugin XEP-0297: Reworked `forward` method and add bridge method: `Forward` method has been reworked and now includes a fallback. XEP-0297 ask to not use fallback, but following a discussion on xsf@, we agreed that this is a legacy thing and a fallback should nowadays be used, I'll propose a patch to the specification. A `message_forward` has been added to bridge. rel 461
author Goffi <goffi@goffi.org>
date Fri, 04 Jul 2025 12:33:42 +0200
parents 4b842c1fb686
children
line wrap: on
line source

#!/usr/bin/env python3


# SAT plugin for Stanza Forwarding (XEP-0297)
# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 datetime import datetime
from typing import cast

from dateutil import tz
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from twisted.words.xish import domish
from wokkel import disco, iwokkel
from zope.interface import implementer

from libervia.backend import G
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.models.core import MessageData
from libervia.backend.core.i18n import D_, _
from libervia.backend.core.log import getLogger
from libervia.backend.memory.sqla_mapping import History
from libervia.backend.plugins.plugin_xep_0428 import XEP_0428
from libervia.backend.tools.common.date_utils import date_fmt



log = getLogger(__name__)



PLUGIN_INFO = {
    C.PI_NAME: "Stanza Forwarding",
    C.PI_IMPORT_NAME: "XEP-0297",
    C.PI_TYPE: "XEP",
    C.PI_PROTOCOLS: ["XEP-0297"],
    C.PI_DEPENDENCIES: ["XEP-0428"],
    C.PI_MAIN: "XEP_0297",
    C.PI_HANDLER: "yes",
    C.PI_DESCRIPTION: D_("""Implementation of Stanza Forwarding"""),
}

NS_FORWARD = "urn:xmpp:forward:0"


class XEP_0297:
    # TODO: Handle forwarded message reception, for now the fallback will be used.
    # TODO: Add a method to forward Pubsub Item, and notably blog content.

    def __init__(self, host):
        log.info(_("Stanza Forwarding plugin initialization"))
        self.host = host
        self._fallback = cast(XEP_0428, host.plugins["XEP-0428"])
        host.register_namespace("forward", NS_FORWARD)
        host.bridge.add_method(
            "message_forward",
            ".plugin",
            in_sign="sss",
            out_sign="",
            method=self._forward,
            async_=True,
        )

    def get_handler(self, client):
        return XEP_0297_handler(self, client.profile)

    @classmethod
    def update_uri(cls, element, uri):
        """Update recursively the element URI.

        @param element (domish.Element): element to update
        @param uri (unicode): new URI
        """
        # XXX: we need this because changing the URI of an existing element
        # containing children doesn't update the children's blank URI.
        element.uri = uri
        element.defaultUri = uri
        for child in element.children:
            if isinstance(child, domish.Element) and not child.uri:
                XEP_0297.update_uri(child, uri)

    def _forward(
        self,
        message_id: str,
        recipient_jid_s: str,
        profile_key: str
    ) -> defer.Deferred[None]:
        client = self.host.get_client(profile_key)
        recipient_jid = jid.JID(recipient_jid_s)
        return defer.ensureDeferred(self.forward_by_id(client, message_id, recipient_jid))

    async def forward_by_id(
        self,
        client: SatXMPPEntity,
        message_id: str,
        recipient_jid: jid.JID
    ) -> None:
        history = cast(History, await G.storage.get(
            client,
            History,
            History.uid,
            message_id,
            joined_loads=[History.messages, History.subjects, History.thread],
        ))
        if not history:
            raise exceptions.NotFound(
                f"No history found with message {message_id!r}."
            )
        # FIXME: Q&D way to get MessageData from History. History should have a proper
        #     way to do that.
        serialised_history = history.serialise()
        serialised_history["from"] = jid.JID(serialised_history["from"])
        serialised_history["to"] = jid.JID(serialised_history["to"])
        mess_data = MessageData(serialised_history)
        client.generate_message_xml(mess_data)
        message_elt = cast(domish.Element, mess_data["xml"])
        timestamp_float = float(history.timestamp or history.received_timestamp)
        timestamp = datetime.fromtimestamp(timestamp_float, tz.tzutc())

        fallback_lines = [
            f"{client.jid.userhost()} is forwarding this message to you:",
            f"[{date_fmt(timestamp_float)}]",
            f"From: {history.source_jid.full()}",
            f"To: {history.dest_jid.full()}"
        ]

        for subject in history.subjects:
            if subject.language:
                fallback_lines.append(f"Subject [{subject.language}]: {subject.subject}")
            else:
                fallback_lines.append(f"Subject: {subject.subject}")

        for message in history.messages:
            if message.language:
                fallback_lines.append(f"Message [{message.language}]: {message.message}")
            else:
                fallback_lines.append(f"Message: {message.message}")

        fallback_msg = "\n".join(fallback_lines)

        await self.forward(client, message_elt, recipient_jid, timestamp, fallback_msg)

    async def forward(
        self,
        client: SatXMPPEntity,
        stanza: domish.Element,
        to_jid: jid.JID,
        timestamp: datetime|None,
        fallback_msg: str | None = None
    ):
        """Forward a message to the given JID.

        @param client: client instance.
        @param stanza: original stanza to be forwarded.
        @param to_jid: recipient JID.
        @param timestamp: offset-aware timestamp of the original reception.
        @param body: optional description.
        @return: a Deferred when the message has been sent
        """
        message_elt = domish.Element((None, "message"))
        message_elt["to"] = to_jid.full()
        message_elt["type"] = stanza["type"]

        forwarded_elt = domish.Element((C.NS_FORWARD, "forwarded"))
        if timestamp:
            delay_elt = self.host.plugins["XEP-0203"].delay(timestamp)
            forwarded_elt.addChild(delay_elt)
        if not stanza.uri:
            XEP_0297.update_uri(stanza, "jabber:client")
        forwarded_elt.addChild(stanza)

        message_elt.addChild(domish.Element((None, "body")))
        message_elt.addChild(forwarded_elt)
        self._fallback.add_fallback_elt(message_elt, NS_FORWARD, fallback_msg)
        return await client.send_message_data(
            MessageData({"xml": message_elt, "extra": {}})
        )


@implementer(iwokkel.IDisco)
class XEP_0297_handler(XMPPHandler):

    def __init__(self, plugin_parent, profile):
        self.plugin_parent = plugin_parent
        self.host = plugin_parent.host
        self.profile = profile

    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
        return [disco.DiscoFeature(C.NS_FORWARD)]

    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
        return []