comparison libervia/backend/plugins/plugin_xep_0424.py @ 4166:a1f7040b5a15

plugin XEP-0424: message retraction update: - follow specification update (with namespace bump) - retract from history on message reception for group chat - send bridge message
author Goffi <goffi@goffi.org>
date Thu, 30 Nov 2023 13:23:53 +0100
parents 4b842c1fb686
children 7eda7cb8a15c
comparison
equal deleted inserted replaced
4165:81faa85c9cfa 4166:a1f7040b5a15
13 # GNU Affero General Public License for more details. 13 # GNU Affero General Public License for more details.
14 14
15 # You should have received a copy of the GNU Affero General Public License 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/>. 16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17 17
18 from typing import Dict, Any
19 import time 18 import time
20 from copy import deepcopy 19 from typing import Any, Dict
21 20
22 from twisted.words.protocols.jabber import xmlstream, jid 21 from sqlalchemy.orm.attributes import flag_modified
22 from twisted.internet import defer
23 from twisted.words.protocols.jabber import jid, xmlstream
23 from twisted.words.xish import domish 24 from twisted.words.xish import domish
24 from twisted.internet import defer
25 from wokkel import disco 25 from wokkel import disco
26 from zope.interface import implementer 26 from zope.interface import implementer
27 27
28 from libervia.backend.core import exceptions
28 from libervia.backend.core.constants import Const as C 29 from libervia.backend.core.constants import Const as C
29 from libervia.backend.core.i18n import _, D_ 30 from libervia.backend.core.core_types import MessageData, SatXMPPEntity
30 from libervia.backend.core import exceptions 31 from libervia.backend.core.i18n import D_, _
31 from libervia.backend.core.core_types import SatXMPPEntity
32 from libervia.backend.core.log import getLogger 32 from libervia.backend.core.log import getLogger
33 from libervia.backend.memory.sqla_mapping import History 33 from libervia.backend.memory.sqla_mapping import History
34 from libervia.backend.tools.common import data_format
34 35
35 log = getLogger(__name__) 36 log = getLogger(__name__)
36 37
37 38
38 PLUGIN_INFO = { 39 PLUGIN_INFO = {
39 C.PI_NAME: "Message Retraction", 40 C.PI_NAME: "Message Retraction",
40 C.PI_IMPORT_NAME: "XEP-0424", 41 C.PI_IMPORT_NAME: "XEP-0424",
41 C.PI_TYPE: "XEP", 42 C.PI_TYPE: "XEP",
42 C.PI_MODES: C.PLUG_MODE_BOTH, 43 C.PI_MODES: C.PLUG_MODE_BOTH,
43 C.PI_PROTOCOLS: ["XEP-0334", "XEP-0424", "XEP-0428"], 44 C.PI_PROTOCOLS: ["XEP-0424"],
44 C.PI_DEPENDENCIES: ["XEP-0422"], 45 C.PI_DEPENDENCIES: ["XEP-0334", "XEP-0428"],
45 C.PI_MAIN: "XEP_0424", 46 C.PI_MAIN: "XEP_0424",
46 C.PI_HANDLER: "yes", 47 C.PI_HANDLER: "yes",
47 C.PI_DESCRIPTION: _("""Implementation Message Retraction"""), 48 C.PI_DESCRIPTION: _("""Implementation Message Retraction"""),
48 } 49 }
49 50
50 NS_MESSAGE_RETRACT = "urn:xmpp:message-retract:0" 51 NS_MESSAGE_RETRACT = "urn:xmpp:message-retract:1"
51 52
52 CATEGORY = "Privacy" 53 CATEGORY = "Privacy"
53 NAME = "retract_history" 54 NAME = "retract_history"
54 LABEL = D_("Keep History of Retracted Messages") 55 LABEL = D_("Keep History of Retracted Messages")
55 PARAMS = """ 56 PARAMS = """
63 """.format( 64 """.format(
64 category_name=CATEGORY, name=NAME, label=_(LABEL) 65 category_name=CATEGORY, name=NAME, label=_(LABEL)
65 ) 66 )
66 67
67 68
68 class XEP_0424(object): 69 class XEP_0424:
69 70
70 def __init__(self, host): 71 def __init__(self, host):
71 log.info(_("XEP-0424 (Message Retraction) plugin initialization")) 72 log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
72 self.host = host 73 self.host = host
73 host.memory.update_params(PARAMS) 74 host.memory.update_params(PARAMS)
74 self._h = host.plugins["XEP-0334"] 75 self._h = host.plugins["XEP-0334"]
75 self._f = host.plugins["XEP-0422"]
76 host.register_namespace("message-retract", NS_MESSAGE_RETRACT) 76 host.register_namespace("message-retract", NS_MESSAGE_RETRACT)
77 host.trigger.add("message_received", self._message_received_trigger, 100) 77 host.trigger.add("message_received", self._message_received_trigger, 100)
78 host.bridge.add_method( 78 host.bridge.add_method(
79 "message_retract", 79 "message_retract",
80 ".plugin", 80 ".plugin",
94 ) 94 )
95 95
96 def retract_by_origin_id( 96 def retract_by_origin_id(
97 self, 97 self,
98 client: SatXMPPEntity, 98 client: SatXMPPEntity,
99 dest_jid: jid.JID, 99 peer_jid: jid.JID,
100 origin_id: str 100 origin_id: str
101 ) -> None: 101 ) -> None:
102 """Send a message retraction using origin-id 102 """Send a message retraction using origin-id
103 103
104 [retract] should be prefered: internal ID should be used as it is independant of 104 [retract] should be prefered: internal ID should be used as it is independant of
106 (notably for some components), and then this method can be used 106 (notably for some components), and then this method can be used
107 @param origin_id: origin-id as specified in XEP-0359 107 @param origin_id: origin-id as specified in XEP-0359
108 """ 108 """
109 message_elt = domish.Element((None, "message")) 109 message_elt = domish.Element((None, "message"))
110 message_elt["from"] = client.jid.full() 110 message_elt["from"] = client.jid.full()
111 message_elt["to"] = dest_jid.full() 111 message_elt["to"] = peer_jid.full()
112 apply_to_elt = self._f.apply_to_elt(message_elt, origin_id) 112 retract_elt = message_elt.addElement((NS_MESSAGE_RETRACT, "retract"))
113 apply_to_elt.addElement((NS_MESSAGE_RETRACT, "retract")) 113 retract_elt["id"] = origin_id
114 self.host.plugins["XEP-0428"].add_fallback_elt( 114 self.host.plugins["XEP-0428"].add_fallback_elt(
115 message_elt, 115 message_elt,
116 "[A message retraction has been requested, but your client doesn't support " 116 "[A message retraction has been requested, but your client doesn't support "
117 "it]" 117 "it]"
118 ) 118 )
136 raise exceptions.FeatureNotFound( 136 raise exceptions.FeatureNotFound(
137 f"message to retract doesn't have the necessary origin-id, the sending " 137 f"message to retract doesn't have the necessary origin-id, the sending "
138 "client is probably not supporting message retraction." 138 "client is probably not supporting message retraction."
139 ) 139 )
140 else: 140 else:
141 self.retract_by_origin_id(client, history.dest_jid, origin_id) 141 if history.type == C.MESS_TYPE_GROUPCHAT:
142 await self.retract_db_history(client, history) 142 is_group_chat = True
143 peer_jid = history.dest_jid
144 else:
145 is_group_chat = False
146 peer_jid = jid.JID(history.dest)
147 self.retract_by_origin_id(client, peer_jid, origin_id)
148 if not is_group_chat:
149 # retraction will happen when <retract> message will be received in the
150 # chat.
151 await self.retract_db_history(client, history)
143 152
144 async def retract( 153 async def retract(
145 self, 154 self,
146 client: SatXMPPEntity, 155 client: SatXMPPEntity,
147 message_id: str, 156 message_id: str,
155 """ 164 """
156 if not message_id: 165 if not message_id:
157 raise ValueError("message_id can't be empty") 166 raise ValueError("message_id can't be empty")
158 history = await self.host.memory.storage.get( 167 history = await self.host.memory.storage.get(
159 client, History, History.uid, message_id, 168 client, History, History.uid, message_id,
160 joined_loads=[History.messages, History.subjects] 169 joined_loads=[History.messages, History.subjects, History.thread]
161 ) 170 )
162 if history is None: 171 if history is None:
163 raise exceptions.NotFound( 172 raise exceptions.NotFound(
164 f"message to retract not found in database ({message_id})" 173 f"message to retract not found in database ({message_id})"
165 ) 174 )
169 """Mark an history instance in database as retracted 178 """Mark an history instance in database as retracted
170 179
171 @param history: history instance 180 @param history: history instance
172 "messages" and "subjects" must be loaded too 181 "messages" and "subjects" must be loaded too
173 """ 182 """
174 # FIXME: should be keep history? This is useful to check why a message has been 183 # FIXME: should we keep history? This is useful to check why a message has been
175 # retracted, but if may be bad if the user think it's really deleted 184 # retracted, but if may be bad if the user think it's really deleted
176 # we assign a new object to be sure to trigger an update 185 flag_modified(history, "extra")
177 history.extra = deepcopy(history.extra) if history.extra else {} 186 keep_history = await self.host.memory.param_get_a_async(
178 history.extra["retracted"] = True
179 keep_history = self.host.memory.param_get_a(
180 NAME, CATEGORY, profile_key=client.profile 187 NAME, CATEGORY, profile_key=client.profile
181 ) 188 )
182 old_version: Dict[str, Any] = { 189 old_version: Dict[str, Any] = {
183 "timestamp": time.time() 190 "timestamp": time.time()
184 } 191 }
185 if keep_history: 192 if keep_history:
186 old_version.update({ 193 old_version.update({
187 "messages": [m.serialise() for m in history.messages], 194 "messages": [m.serialise() for m in history.messages],
188 "subjects": [s.serialise() for s in history.subjects] 195 "subjects": [s.serialise() for s in history.subjects],
189 }) 196 })
190 197
191 history.extra.setdefault("old_versions", []).append(old_version) 198 history.extra.setdefault("old_versions", []).append(old_version)
192 await self.host.memory.storage.delete( 199
193 history.messages + history.subjects, 200 history.messages.clear()
194 session_add=[history] 201 history.subjects.clear()
202 history.extra["retracted"] = True
203 # we remove editions history to keep no trace of old messages
204 if "editions" in history.extra:
205 del history.extra["editions"]
206 if "attachments" in history.extra:
207 del history.extra["attachments"]
208 await self.host.memory.storage.add(history)
209
210 retract_data = MessageData(history.serialise())
211 self.host.bridge.message_update(
212 history.uid,
213 C.MESS_UPDATE_RETRACT,
214 data_format.serialise(retract_data),
215 client.profile,
195 ) 216 )
196 217
197 async def _message_received_trigger( 218 async def _message_received_trigger(
198 self, 219 self,
199 client: SatXMPPEntity, 220 client: SatXMPPEntity,
200 message_elt: domish.Element, 221 message_elt: domish.Element,
201 post_treat: defer.Deferred 222 post_treat: defer.Deferred
202 ) -> bool: 223 ) -> bool:
203 fastened_elts = await self._f.get_fastened_elts(client, message_elt) 224 retract_elt = next(message_elt.elements(NS_MESSAGE_RETRACT, "retract"), None)
204 if fastened_elts is None: 225 if not retract_elt:
205 return True 226 return True
206 for elt in fastened_elts.elements: 227 try:
207 if elt.name == "retract" and elt.uri == NS_MESSAGE_RETRACT: 228 if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
208 if fastened_elts.history is not None: 229 col_id = History.stanza_id
209 source_jid = fastened_elts.history.source_jid 230 else:
210 from_jid = jid.JID(message_elt["from"]) 231 col_id = History.origin_id
211 if source_jid.userhostJID() != from_jid.userhostJID(): 232 history = await self.host.memory.storage.get(
212 log.warning( 233 client, History, col_id, retract_elt["id"],
213 f"Received message retraction from {from_jid.full()}, but " 234 joined_loads=[History.messages, History.subjects, History.thread]
214 f"the message to retract is from {source_jid.full()}. This " 235 )
215 f"maybe a hack attempt.\n{message_elt.toXml()}" 236 except KeyError:
216 ) 237 log.warning(f"invalid retract element, missing id: {retract_elt.toXml()}")
217 return False 238 return False
218 break 239 from_jid = jid.JID(message_elt["from"])
219 else: 240
220 return True 241 if (
242 history is not None
243 and history.source_jid.userhostJID() != from_jid.userhostJID()
244 ):
245 log.warning(
246 f"Received message retraction from {from_jid.full()}, but the message to "
247 f"retract is from {history.source_jid.full()}. This maybe a hack "
248 f"attempt.\n{message_elt.toXml()}"
249 )
250 return False
251
221 if not await self.host.trigger.async_point( 252 if not await self.host.trigger.async_point(
222 "XEP-0424_retractReceived", client, message_elt, elt, fastened_elts 253 "XEP-0424_retract_received", client, message_elt, retract_elt, history
223 ): 254 ):
224 return False 255 return False
225 if fastened_elts.history is None: 256
257 if history is None:
226 # we check history after the trigger because we may be in a component which 258 # we check history after the trigger because we may be in a component which
227 # doesn't store messages in database. 259 # doesn't store messages in database.
228 log.warning( 260 log.warning(
229 f"No message found with given origin-id: {message_elt.toXml()}" 261 f"No message found with given origin-id: {message_elt.toXml()}"
230 ) 262 )
231 return False 263 return False
232 log.info(f"[{client.profile}] retracting message {fastened_elts.id!r}") 264 log.info(f"[{client.profile}] retracting message {history.uid!r}")
233 await self.retract_db_history(client, fastened_elts.history) 265 await self.retract_db_history(client, history)
234 # TODO: send bridge signal
235
236 return False 266 return False
237 267
238 268
239 @implementer(disco.IDisco) 269 @implementer(disco.IDisco)
240 class XEP_0424_handler(xmlstream.XMPPHandler): 270 class XEP_0424_handler(xmlstream.XMPPHandler):