comparison libervia/backend/plugins/plugin_xep_0461.py @ 4370:0eaa50f21efb

plugin XEP-0461: Message Replies implementation: Implement message replies. Thread ID are always added when a reply is initiated from Libervia, so a thread can continue the reply. rel 457
author Goffi <goffi@goffi.org>
date Tue, 06 May 2025 00:34:01 +0200
parents
children
comparison
equal deleted inserted replaced
4369:b74a76a8e168 4370:0eaa50f21efb
1 #!/usr/bin/env python3
2
3 # Libervia plugin for Extended Channel Search (XEP-0461)
4 # Copyright (C) 2009-2025 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from typing import TYPE_CHECKING, Any, Final, Self, cast
20 from pydantic import BaseModel, Field
21 from twisted.internet import defer
22 from twisted.words.protocols.jabber import jid
23 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
24 from twisted.words.xish import domish
25 from wokkel import disco, iwokkel
26 from zope.interface import implementer
27 from libervia.backend import G
28 from libervia.backend.core import exceptions
29 from libervia.backend.core.constants import Const as C
30 from libervia.backend.core.core_types import SatXMPPEntity
31 from libervia.backend.core.i18n import _
32 from libervia.backend.core.log import getLogger
33 from libervia.backend.memory.sqla import Storage
34 from libervia.backend.memory.sqla_mapping import History
35 from libervia.backend.models.core import MessageData
36 from libervia.backend.models.types import JIDType
37 from libervia.backend.tools.utils import ensure_deferred
38
39 if TYPE_CHECKING:
40 from libervia.backend.core.main import LiberviaBackend
41
42 log = getLogger(__name__)
43
44
45 PLUGIN_INFO = {
46 C.PI_NAME: "Message Replies",
47 C.PI_IMPORT_NAME: "XEP-0461",
48 C.PI_TYPE: "XEP",
49 C.PI_MODES: C.PLUG_MODE_BOTH,
50 C.PI_DEPENDENCIES: [],
51 C.PI_RECOMMENDATIONS: [],
52 C.PI_MAIN: "XEP_0461",
53 C.PI_HANDLER: "yes",
54 C.PI_DESCRIPTION: _("Message replies support."),
55 }
56
57 NS_REPLY = "urn:xmpp:reply:0"
58 host: "LiberviaBackend | None" = None
59
60
61 class ReplyTo(BaseModel):
62 to: JIDType | None = None
63 id: str
64 internal_uid: bool = Field(
65 default=True,
66 description="True if the id is a message UID, i.e. Libervia internal ID of "
67 "message, otherwise it's an XMPP stanza ID or origin ID as specified in the XEP"
68 )
69
70 async def ensure_xmpp_id(self, client: SatXMPPEntity) -> None:
71 if not self.internal_uid:
72 return
73 history = await G.storage.get(
74 client,
75 History,
76 History.uid,
77 self.id,
78 )
79
80 if history.uid != self.id:
81 raise exceptions.InternalError(
82 f"Inconsistency between given history UID {history.uid!r} and ReplyTo "
83 f"id ({self.id!r})."
84 )
85 if history.type == C.MESS_TYPE_GROUPCHAT:
86 self.id = history.stanza_id
87 else:
88 self.id = history.origin_id
89 assert self.id
90 self.internal_uid = False
91
92 @classmethod
93 def from_element(cls, reply_elt: domish.Element) -> Self:
94 """Create a ReplyTo instance from a <reply> element or its parent.
95
96 @param reply_elt: The <reply> element or a parent element.
97 @return: ReplyTo instance.
98 @raise exceptions.NotFound: If the <reply> element is not found.
99 """
100 if reply_elt.uri != NS_REPLY or reply_elt.name != "reply":
101 child_file_metadata_elt = next(reply_elt.elements(NS_REPLY, "reply"), None)
102 if child_file_metadata_elt is None:
103 raise exceptions.NotFound("<reply> element not found")
104 else:
105 reply_elt = child_file_metadata_elt
106
107 try:
108 message_id = reply_elt["id"]
109 except KeyError:
110 raise exceptions.DataError('Missing "id" attribute.')
111
112 return cls(to=reply_elt.getAttribute("to"), id=message_id, internal_uid=False)
113
114 def to_element(self) -> domish.Element:
115 """Build the <reply> element from this instance's data.
116
117 @return: <reply> element.
118 """
119 if self.internal_uid:
120 raise exceptions.DataError(
121 '"id" must be converted to XMPP id before calling "to_element". Please '
122 '"use ensure_xmpp_id" first.')
123 reply_elt = domish.Element((NS_REPLY, "reply"), attribs={"id": self.id})
124 if self.to is not None:
125 reply_elt["to"] = self.to.full()
126 return reply_elt
127
128
129 class XEP_0461:
130 """Implementation of XEP-0461 Message Replies."""
131
132 namespace: Final[str] = NS_REPLY
133
134 def __init__(self, host: Any):
135 log.info(f"Plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization.")
136 self.host = host
137 host.register_namespace("reply", NS_REPLY)
138 host.trigger.add("message_received", self._message_received_trigger)
139 host.trigger.add("sendMessage", self._sendMessage_trigger)
140
141 def get_handler(self, client):
142 return XEP_0461_handler()
143
144 async def _parse_reply(
145 self, client: SatXMPPEntity, message_elt: domish.Element, mess_data: MessageData
146 ) -> MessageData:
147 try:
148 reply_to = ReplyTo.from_element(message_elt)
149 except exceptions.NotFound:
150 pass
151 else:
152 try:
153 thread_id = mess_data["extra"]["thread"]
154 except KeyError:
155 pass
156 else:
157 message_type = message_elt.getAttribute("type")
158 storage = G.storage
159 history = await storage.get_history_from_xmpp_id(
160 client, reply_to.id, message_type
161 )
162 if history is None:
163 log.warning(f"Received a <reply> to an unknown message: {reply_to!r}")
164 else:
165 if not history.thread:
166 await storage.add_thread(
167 history.uid, thread_id, None, is_retroactive=True
168 )
169 # We can to use internal UID to make frontends life easier.
170 reply_to.id = history.uid
171 reply_to.internal_uid = True
172 # FIXME: We use mode=json to have a serialisable data in storage. When
173 # Pydantic models are fully integrated with MessageData and storage, the
174 # ReplyTo model should be used directly here instead.
175 mess_data["extra"]["reply"] = reply_to.model_dump(mode="json")
176 return mess_data
177
178 def _message_received_trigger(
179 self,
180 client: SatXMPPEntity,
181 message_elt: domish.Element,
182 post_treat: defer.Deferred,
183 ) -> bool:
184 post_treat.addCallback(
185 lambda mess_data: defer.ensureDeferred(
186 self._parse_reply(client, message_elt, mess_data)
187 )
188 )
189 return True
190
191 @ensure_deferred
192 async def _add_reply_to_elt(self, mess_data: MessageData, client: SatXMPPEntity) -> MessageData:
193 try:
194 # FIXME: Once MessageData is fully moved to Pydantic, we should have directly
195 # the ReplyTo instance here.
196 reply_to = ReplyTo(**mess_data["extra"]["reply"])
197 except KeyError:
198 log.error('"_add_reply_to_elt" should not be called when there is no reply.')
199 else:
200 await reply_to.ensure_xmpp_id(client)
201 message_elt: domish.Element = mess_data["xml"]
202 message_elt.addChild(reply_to.to_element())
203
204 return mess_data
205
206 def _sendMessage_trigger(
207 self,
208 client: SatXMPPEntity,
209 mess_data: MessageData,
210 pre_xml_treatments: defer.Deferred,
211 post_xml_treatments: defer.Deferred,
212 ) -> bool:
213 try:
214 extra = mess_data["extra"]
215 reply_to = ReplyTo(**extra["reply"])
216 except KeyError:
217 pass
218 else:
219 if "thread" not in extra:
220 # FIXME: once MessageData is fully moved to Pydantic, ReplyTo should be
221 # used directly here instead of a dump.
222 extra["reply"] = reply_to.model_dump(mode="json")
223 # We use parent message ID as thread ID.
224 extra["thread"] = reply_to.id
225
226 post_xml_treatments.addCallback(self._add_reply_to_elt, client)
227 return True
228
229
230 @implementer(iwokkel.IDisco)
231 class XEP_0461_handler(XMPPHandler):
232
233 def getDiscoInfo(
234 self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = ""
235 ) -> list[disco.DiscoFeature]:
236 return [disco.DiscoFeature(NS_REPLY)]
237
238 def getDiscoItems(
239 self, requestor: jid.JID, target: jid.JID, nodeIdentifier: str = ""
240 ) -> list[disco.DiscoItems]:
241 return []