comparison libervia/backend/plugins/plugin_xep_0308.py @ 4163:3b3cd9453d9b

plugin XEP-0308: implement Last Message Correction
author Goffi <goffi@goffi.org>
date Tue, 28 Nov 2023 17:38:31 +0100
parents
children a1f7040b5a15
comparison
equal deleted inserted replaced
4162:98687eaa6a09 4163:3b3cd9453d9b
1 #!/usr/bin/env python3
2
3 # Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
4
5 # This program is free software: you can redistribute it and/or modify
6 # it under the terms of the GNU Affero General Public License as published by
7 # the Free Software Foundation, either version 3 of the License, or
8 # (at your option) any later version.
9
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU Affero General Public License for more details.
14
15 # You should have received a copy of the GNU Affero General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
18 import time
19
20 from sqlalchemy.orm.attributes import flag_modified
21 from twisted.internet import defer
22 from twisted.words.protocols.jabber import xmlstream
23 from twisted.words.protocols.jabber import jid
24 from twisted.words.xish import domish
25 from wokkel import disco
26 from zope.interface import implementer
27
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 History, Message, Subject, joinedload, select
34 from libervia.backend.models.core import MessageEditData, MessageEdition
35 from libervia.backend.tools.common import data_format
36 from libervia.backend.tools.utils import aio
37 log = getLogger(__name__)
38
39
40 PLUGIN_INFO = {
41 C.PI_NAME: "Last Message Correction",
42 C.PI_IMPORT_NAME: "XEP-0308",
43 C.PI_TYPE: "XEP",
44 C.PI_PROTOCOLS: ["XEP-0308"],
45 C.PI_DEPENDENCIES: ["XEP-0334"],
46 C.PI_MAIN: "XEP_0308",
47 C.PI_HANDLER: "yes",
48 C.PI_DESCRIPTION: _("""Implementation of XEP-0308 (Last Message Correction)"""),
49 }
50
51 NS_MESSAGE_CORRECT = "urn:xmpp:message-correct:0"
52
53
54 class XEP_0308:
55 def __init__(self, host):
56 log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
57 self.host = host
58 host.register_namespace("message_correct", NS_MESSAGE_CORRECT)
59 host.trigger.add("message_received", self._message_received_trigger)
60 host.bridge.add_method(
61 "message_edit",
62 ".plugin",
63 in_sign="sss",
64 out_sign="",
65 method=self._message_edit,
66 async_=True,
67 )
68 self._h = host.plugins["XEP-0334"]
69
70 def get_handler(self, client):
71 return XEP_0308_handler()
72
73 @aio
74 async def get_last_history(
75 self, client: SatXMPPEntity, message_elt: domish.Element
76 ) -> History | None:
77 profile_id = self.host.memory.storage.profiles[client.profile]
78 from_jid = jid.JID(message_elt["from"])
79 message_type = message_elt.getAttribute("type", C.MESS_TYPE_NORMAL)
80 async with self.host.memory.storage.session() as session:
81 stmt = (
82 select(History)
83 .where(
84 History.profile_id == profile_id,
85 History.source == from_jid.userhost(),
86 History.type == message_type,
87 )
88 .options(joinedload(History.messages))
89 .options(joinedload(History.subjects))
90 .options(joinedload(History.thread))
91 )
92 if message_elt.type == C.MESS_TYPE_GROUPCHAT:
93 stmt = stmt.where(History.source_res == from_jid.resource)
94
95 # we want last message
96 stmt = stmt.order_by(History.timestamp.desc()).limit(1)
97 result = await session.execute(stmt)
98 history = result.unique().scalar_one_or_none()
99 return history
100
101 async def update_history(
102 self,
103 client: SatXMPPEntity,
104 edited_history: History,
105 edit_timestamp: float,
106 new_message: dict[str, str],
107 new_subject: dict[str, str],
108 new_extra: dict,
109 previous_message: dict[str, str],
110 previous_subject: dict[str, str],
111 previous_extra: dict | None,
112 store: bool = True,
113 ) -> None:
114 # FIXME: new_extra is not handled by now
115 edited_history.messages = [
116 Message(message=mess, language=lang) for lang, mess in new_message.items()
117 ]
118 edited_history.subjects = [
119 Subject(subject=mess, language=lang) for lang, mess in new_subject.items()
120 ]
121 previous_version = {
122 # this is the timestamp when this version was published
123 "timestamp": edited_history.extra.get("updated", edited_history.timestamp),
124 "message": previous_message,
125 "subject": previous_subject,
126 }
127 edited_history.extra["updated"] = edit_timestamp
128 if previous_extra:
129 previous_extra = previous_extra.copy()
130 # we must not have editions in each edition
131 try:
132 del previous_extra[C.MESS_EXTRA_EDITIONS]
133 except KeyError:
134 pass
135 # extra may be important for rich content
136 previous_version["extra"] = previous_extra
137
138 if store:
139 flag_modified(edited_history, "extra")
140 edited_history.extra.setdefault(C.MESS_EXTRA_EDITIONS, []).append(previous_version)
141 await self.host.memory.storage.add(edited_history)
142
143 edit_data = MessageEditData(edited_history.serialise())
144 self.host.bridge.message_update(
145 edited_history.uid,
146 C.MESS_UPDATE_EDIT,
147 data_format.serialise(edit_data),
148 client.profile,
149 )
150
151 async def _message_received_trigger(
152 self,
153 client: SatXMPPEntity,
154 message_elt: domish.Element,
155 post_treat: defer.Deferred,
156 ) -> bool:
157 replace_elt = next(message_elt.elements(NS_MESSAGE_CORRECT, "replace"), None)
158 if not replace_elt:
159 return True
160 try:
161 replace_id = replace_elt["id"].strip()
162 if not replace_id:
163 raise KeyError
164 except KeyError:
165 log.warning(f"Invalid message correction: {message_elt.toXml()}")
166 else:
167 edited_history = await self.get_last_history(client, message_elt)
168 if edited_history is None:
169 log.warning(
170 f"No message found from {message_elt['from']}, can't correct "
171 f"anything: {message_elt.toXml()}"
172 )
173 return False
174 if edited_history.extra.get("message_id") != replace_id:
175 log.warning(
176 "Can't apply correction: it doesn't reference the last one: "
177 f"{message_elt.toXml}"
178 )
179 return False
180 previous_message_data = edited_history.serialise()
181 message_data = client.messageProt.parse_message(message_elt)
182 if not message_data["message"] and not message_data["subject"]:
183 log.warning(
184 "Message correction doesn't have body not subject, we can't edit "
185 "anything"
186 )
187 return False
188
189 await self.update_history(
190 client,
191 edited_history,
192 message_data.get("received_timestamp") or message_data["timestamp"],
193 message_data["message"],
194 message_data["subject"],
195 message_data["extra"],
196 previous_message_data["message"],
197 previous_message_data["subject"],
198 previous_message_data.get("extra"),
199 )
200
201 return False
202
203 async def message_edit(
204 self,
205 client: SatXMPPEntity,
206 message_id: str,
207 edit_data: MessageEdition,
208 ) -> None:
209 """Edit a message
210
211 The message can only be edited if it's the last one of the discussion.
212 @param client: client instance
213 @param message_id: UID of the message to edit
214 @param edit_data: data to update in the message
215 """
216 timestamp = time.time()
217 edited_history = await self.host.memory.storage.get(
218 client,
219 History,
220 History.uid,
221 message_id,
222 joined_loads=[History.messages, History.subjects, History.thread],
223 )
224 if edited_history is None:
225 raise exceptions.NotFound(
226 f"message to edit not found in database ({message_id})"
227 )
228 if edited_history.type == C.MESS_TYPE_GROUPCHAT:
229 is_group_chat = True
230 peer_jid = edited_history.dest_jid
231 else:
232 is_group_chat = False
233 peer_jid = jid.JID(edited_history.dest)
234 history_data = await self.host.memory.history_get(
235 client.jid, peer_jid, limit=1, profile=client.profile
236 )
237 if not history_data:
238 raise exceptions.NotFound(
239 "No message found in conversation with {peer_jid.full()}"
240 )
241 last_mess = history_data[0]
242 if last_mess[0] != message_id:
243 raise ValueError(
244 f"{message_id} is not the last message of the discussion, we can't edit "
245 "it"
246 )
247
248 await self.update_history(
249 client,
250 edited_history,
251 timestamp,
252 edit_data.message,
253 edit_data.subject,
254 edit_data.extra,
255 last_mess[4],
256 last_mess[5],
257 last_mess[-1],
258 # message will be updated and signal sent on reception in group chat
259 store = not is_group_chat
260 )
261
262 serialised = edited_history.serialise()
263 serialised["from"] = jid.JID(serialised["from"])
264 serialised["to"] = jid.JID(serialised["to"])
265
266 message_elt = client.generate_message_xml(serialised)["xml"]
267 replace_elt = message_elt.addElement((NS_MESSAGE_CORRECT, "replace"))
268 replace_elt["id"] = message_id
269 self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
270 client.send(message_elt)
271
272 def _message_edit(self, message_id: str, edit_data_s: str, profile: str) -> None:
273 client = self.host.get_client(profile)
274 edit_data = MessageEdition.model_validate_json(edit_data_s)
275 defer.ensureDeferred(self.message_edit(client, message_id, edit_data))
276
277
278 @implementer(disco.IDisco)
279 class XEP_0308_handler(xmlstream.XMPPHandler):
280 def getDiscoInfo(self, __, target, nodeIdentifier=""):
281 return [disco.DiscoFeature(NS_MESSAGE_CORRECT)]
282
283 def getDiscoItems(self, requestor, target, nodeIdentifier=""):
284 return []