diff --git a/libervia/backend/core/core_types.py b/libervia/backend/core/core_types.py index 7d6c147a..ca9cc3a7 100644 --- a/libervia/backend/core/core_types.py +++ b/libervia/backend/core/core_types.py @@ -101,6 +101,10 @@ class SatXMPPEntity(ABC): discoHandler: disco.DiscoHandler messageProt: "SatMessageProtocol" identities: list[disco.DiscoIdentity] + # Map from (from_jid, message_id) to internal uid. + # This is updated when message are received during message parsing, and this is + # available only for message received during this session. + mess_id2uid: dict[tuple[str, str], str] # Fire when entity is connected conn_deferred: defer.Deferred xmlstream: mod_xmlstream.XmlStream diff --git a/libervia/backend/plugins/plugin_xep_0367.py b/libervia/backend/plugins/plugin_xep_0367.py new file mode 100644 index 00000000..da1ebc1e --- /dev/null +++ b/libervia/backend/plugins/plugin_xep_0367.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +# 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 . + +from typing import Any, Final, Self + +from pydantic import BaseModel, Field +from twisted.words.protocols.jabber import xmlstream +from twisted.words.xish import domish +from tx_xmpp import disco, itx_xmpp +from zope.interface import implementer + +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 + +log = getLogger(__name__) + +EXTRA_KEY: Final = "attached_to" + +NS_MESSAGE_ATTACHING = "urn:xmpp:message-attaching:1" + + +PLUGIN_INFO = { + C.PI_NAME: "Message Attaching", + C.PI_IMPORT_NAME: "XEP-0367", + C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0367"], + C.PI_MAIN: "XEP_0367", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of XEP-0367 (Message Attaching)"""), +} + + +class AttachedTo(BaseModel): + """Attach-to resolution data.""" + + uid: str | None = Field( + default=None, + description="Libervia internal UID of the message to attach to, if resolved.", + ) + stanza_id: str | None = Field( + description="XMPP stanza id from .", + ) + + def to_element(self) -> domish.Element: + """Build the element from this instance's data. + + @return: element. + @raise exceptions.DataError: If stanza_id is missing. + """ + if self.stanza_id is None: + raise exceptions.DataError("stanza_id is missing") + return domish.Element( + (NS_MESSAGE_ATTACHING, "attach-to"), + attribs={"id": self.uid or self.stanza_id}, + ) + + @classmethod + def from_element(cls, element: domish.Element) -> Self: + """Create an AttachedTo instance from an element. + + @param element: element. + @return: AttachedTo instance with raw_value set, uid to be resolved later. + """ + return cls(stanza_id=str(element["id"])) + + +class XEP_0367: + + def __init__(self, host) -> None: + log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.") + self.host = host + host.register_namespace("message-attaching", NS_MESSAGE_ATTACHING) + # priority=10 so "attached_to" is parsed before plugins which need it (e.g. + # XEP-0447) + host.trigger.add("message_parse", self._message_parse_trigger, priority=10) + + async def _message_parse_trigger( + self, + client: SatXMPPEntity, + message_elt: domish.Element, + data: dict[str, Any], + ) -> bool: + """Parse element and resolve UID. + + @param client: Client instance. + @param message_elt: The element. + @param data: Parsed message data. + @return: True to continue message processing. + """ + attach_to_elt = next( + message_elt.elements(NS_MESSAGE_ATTACHING, "attach-to"), None + ) + if attach_to_elt is not None: + attached_to = AttachedTo.from_element(attach_to_elt) + from_jid = data["from"] + uid = ( + client.mess_id2uid.get((from_jid, attached_to.stanza_id)) + if attached_to.stanza_id is not None + else None + ) + if uid is None: + log.warning( + f"No matching UID found for attach-to id {attached_to.stanza_id!r} " + f"from {from_jid!r}" + ) + else: + attached_to.uid = uid + data["extra"][EXTRA_KEY] = attached_to.model_dump() + return True + + def get_handler(self, client: SatXMPPEntity) -> "XEP0367Handler": + return XEP0367Handler() + + +@implementer(itx_xmpp.IDisco) +class XEP0367Handler(xmlstream.XMPPHandler): + + def getDiscoInfo(self, requestor: str, target: str, nodeIdentifier: str = "") -> list: + return [disco.DiscoFeature(NS_MESSAGE_ATTACHING)] + + def getDiscoItems( + self, requestor: str, target: str, nodeIdentifier: str = "" + ) -> list: + return []