#!/usr/bin/env python3 # Libervia plugin for Stanza Forwarding (XEP-0297) # Copyright (C) 2009-2026 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 . 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 tx_xmpp import disco, itx_xmpp 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_MODES: [C.PLUG_MODE_COMPONENT], 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: domish.Element, uri: str) -> None: """Update recursively the element URI. @param element: element to update @param uri: 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) @staticmethod def build_forwarded( stanza: domish.Element, timestamp: datetime | None = None ) -> domish.Element: """Build a element containing a stanza. @param stanza: The stanza to wrap in forwarded element. @param timestamp: Timestamp for delay element (XEP-0203). @return: The element with the stanza as child. """ forwarded_elt = domish.Element((C.NS_FORWARD, "forwarded")) if timestamp: delay_elt = G.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) return forwarded_elt 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, ) -> defer.Deferred: """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 = XEP_0297.build_forwarded(stanza, timestamp) 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(itx_xmpp.IDisco) class XEP_0297_handler(XMPPHandler): def __init__(self, plugin_parent: XEP_0297, profile: str) -> None: self.plugin_parent = plugin_parent self.host = plugin_parent.host self.profile = profile def getDiscoInfo( self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = "" ) -> list[disco.DiscoFeature]: """Get disco info for pubsub extended discovery @param requestor: JID of the requesting entity @param target: JID of the target entity @param nodeIdentifier: optional node identifier @return: list of disco features """ return [disco.DiscoFeature(C.NS_FORWARD)] def getDiscoItems( self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = "" ) -> list[disco.DiscoItem]: """Get disco items with extended discovery support @param requestor: JID of the requesting entity @param target: JID of the target entity @param nodeIdentifier: optional node identifier @return: list of disco items """ return []