Mercurial > libervia-backend
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 [] |