Mercurial > libervia-backend
comparison sat/plugins/plugin_xep_0470.py @ 3888:aa7197b67c26
component AP gateway: AP <=> XMPP reactions conversions:
- Pubsub Attachments plugin has been renamed to XEP-0470 following publication
- XEP-0470 has been updated to follow 0.2 changes
- AP reactions (as implemented in Pleroma) are converted to XEP-0470
- XEP-0470 events are converted to AP reactions (again, using "EmojiReact" from Pleroma)
- AP activities related to attachments (like/reactions) are cached in Libervia because
it's not possible to retrieve them from Pleroma instances once they have been emitted
(doing an HTTP get on their ID returns a 404). For now those cache are not flushed, this
should be improved in the future.
- `sharedInbox` is used when available. Pleroma returns a 500 HTTP error when ``to`` or
``cc`` are used in a direct inbox.
- reactions and like are not currently used for direct messages, because they can't be
emitted from Pleroma in this case, thus there is no point in implementing them for the
moment.
rel 371
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 31 Aug 2022 17:07:03 +0200 |
parents | sat/plugins/plugin_pubsub_attachments.py@c0bcbcf5b4b7 |
children | 43024e50b701 |
comparison
equal
deleted
inserted
replaced
3887:6090141b1b70 | 3888:aa7197b67c26 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia plugin for Pubsub Attachments | |
4 # Copyright (C) 2009-2022 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 List, Tuple, Dict, Any, Callable, Optional | |
20 | |
21 from twisted.words.protocols.jabber import jid, xmlstream, error | |
22 from twisted.words.xish import domish | |
23 from twisted.internet import defer | |
24 from zope.interface import implementer | |
25 from wokkel import pubsub, disco, iwokkel | |
26 | |
27 from sat.core.constants import Const as C | |
28 from sat.core.i18n import _ | |
29 from sat.core.log import getLogger | |
30 from sat.core.core_types import SatXMPPEntity | |
31 from sat.core import exceptions | |
32 from sat.tools.common import uri, data_format, date_utils | |
33 from sat.tools.utils import xmpp_date | |
34 | |
35 | |
36 log = getLogger(__name__) | |
37 | |
38 IMPORT_NAME = "XEP-0470" | |
39 | |
40 PLUGIN_INFO = { | |
41 C.PI_NAME: "Pubsub Attachments", | |
42 C.PI_IMPORT_NAME: IMPORT_NAME, | |
43 C.PI_TYPE: C.PLUG_TYPE_XEP, | |
44 C.PI_MODES: C.PLUG_MODE_BOTH, | |
45 C.PI_PROTOCOLS: [], | |
46 C.PI_DEPENDENCIES: ["XEP-0060"], | |
47 C.PI_MAIN: "PubsubAttachments", | |
48 C.PI_HANDLER: "yes", | |
49 C.PI_DESCRIPTION: _("""Pubsub Attachments implementation"""), | |
50 } | |
51 NS_PREFIX = "urn:xmpp:pubsub-attachments:" | |
52 NS_PUBSUB_ATTACHMENTS = f"{NS_PREFIX}1" | |
53 NS_PUBSUB_ATTACHMENTS_SUM = f"{NS_PREFIX}summary:1" | |
54 | |
55 | |
56 class PubsubAttachments: | |
57 namespace = NS_PUBSUB_ATTACHMENTS | |
58 | |
59 def __init__(self, host): | |
60 log.info(_("XEP-0470 (Pubsub Attachments) plugin initialization")) | |
61 host.registerNamespace("pubsub-attachments", NS_PUBSUB_ATTACHMENTS) | |
62 self.host = host | |
63 self._p = host.plugins["XEP-0060"] | |
64 self.handlers: Dict[Tuple[str, str], dict[str, Any]] = {} | |
65 host.trigger.add("XEP-0277_send", self.onMBSend) | |
66 self.registerAttachmentHandler( | |
67 "noticed", NS_PUBSUB_ATTACHMENTS, self.noticedGet, self.noticedSet | |
68 ) | |
69 self.registerAttachmentHandler( | |
70 "reactions", NS_PUBSUB_ATTACHMENTS, self.reactionsGet, self.reactionsSet | |
71 ) | |
72 host.bridge.addMethod( | |
73 "psAttachmentsGet", | |
74 ".plugin", | |
75 in_sign="sssasss", | |
76 out_sign="(ss)", | |
77 method=self._get, | |
78 async_=True, | |
79 ) | |
80 host.bridge.addMethod( | |
81 "psAttachmentsSet", | |
82 ".plugin", | |
83 in_sign="ss", | |
84 out_sign="", | |
85 method=self._set, | |
86 async_=True, | |
87 ) | |
88 | |
89 def getHandler(self, client): | |
90 return PubsubAttachments_Handler() | |
91 | |
92 def registerAttachmentHandler( | |
93 self, | |
94 name: str, | |
95 namespace: str, | |
96 get_cb: Callable[ | |
97 [SatXMPPEntity, domish.Element, Dict[str, Any]], | |
98 None], | |
99 set_cb: Callable[ | |
100 [SatXMPPEntity, Dict[str, Any], Optional[domish.Element]], | |
101 Optional[domish.Element]], | |
102 ) -> None: | |
103 """Register callbacks to handle an attachment | |
104 | |
105 @param name: name of the element | |
106 @param namespace: namespace of the element | |
107 (name, namespace) couple must be unique | |
108 @param get: method to call when attachments are retrieved | |
109 it will be called with (client, element, data) where element is the | |
110 <attachments> element to parse, and data must be updated in place with | |
111 parsed data | |
112 @param set: method to call when the attachment need to be set or udpated | |
113 it will be called with (client, data, former_elt of None if there was no | |
114 former element). When suitable, ``operation`` should be used to check if we | |
115 request an ``update`` or a ``replace``. | |
116 """ | |
117 key = (name, namespace) | |
118 if key in self.handlers: | |
119 raise exceptions.ConflictError( | |
120 f"({name}, {namespace}) attachment handlers are already registered" | |
121 ) | |
122 self.handlers[(name, namespace)] = { | |
123 "get": get_cb, | |
124 "set": set_cb | |
125 } | |
126 | |
127 def getAttachmentNodeName(self, service: jid.JID, node: str, item: str) -> str: | |
128 """Generate name to use for attachment node""" | |
129 target_item_uri = uri.buildXMPPUri( | |
130 "pubsub", | |
131 path=service.userhost(), | |
132 node=node, | |
133 item=item | |
134 ) | |
135 return f"{NS_PUBSUB_ATTACHMENTS}/{target_item_uri}" | |
136 | |
137 def isAttachmentNode(self, node: str) -> bool: | |
138 """Return True if node name is an attachment node""" | |
139 return node.startswith(f"{NS_PUBSUB_ATTACHMENTS}/") | |
140 | |
141 def attachmentNode2Item(self, node: str) -> Tuple[jid.JID, str, str]: | |
142 """Retrieve service, node and item from attachement node's name""" | |
143 if not self.isAttachmentNode(node): | |
144 raise ValueError("this is not an attachment node!") | |
145 prefix_len = len(f"{NS_PUBSUB_ATTACHMENTS}/") | |
146 item_uri = node[prefix_len:] | |
147 parsed_uri = uri.parseXMPPUri(item_uri) | |
148 if parsed_uri["type"] != "pubsub": | |
149 raise ValueError(f"unexpected URI type, it must be a pubsub URI: {item_uri}") | |
150 try: | |
151 service = jid.JID(parsed_uri["path"]) | |
152 except RuntimeError: | |
153 raise ValueError(f"invalid service in pubsub URI: {item_uri}") | |
154 node = parsed_uri["node"] | |
155 item = parsed_uri["item"] | |
156 return (service, node, item) | |
157 | |
158 async def onMBSend( | |
159 self, | |
160 client: SatXMPPEntity, | |
161 service: jid.JID, | |
162 node: str, | |
163 item: domish.Element, | |
164 data: dict | |
165 ) -> bool: | |
166 """trigger to create attachment node on each publication""" | |
167 node_config = await self._p.getConfiguration(client, service, node) | |
168 attachment_node = self.getAttachmentNodeName(service, node, item["id"]) | |
169 # we use the same options as target node | |
170 try: | |
171 await self._p.createIfNewNode( | |
172 client, service, attachment_node, options=dict(node_config) | |
173 ) | |
174 except Exception as e: | |
175 log.warning(f"Can't create attachment node {attachment_node}: {e}]") | |
176 return True | |
177 | |
178 def items2attachmentData( | |
179 self, | |
180 client: SatXMPPEntity, | |
181 items: List[domish.Element] | |
182 ) -> List[Dict[str, Any]]: | |
183 """Convert items from attachment node to attachment data""" | |
184 list_data = [] | |
185 for item in items: | |
186 try: | |
187 attachments_elt = next( | |
188 item.elements(NS_PUBSUB_ATTACHMENTS, "attachments") | |
189 ) | |
190 except StopIteration: | |
191 log.warning( | |
192 "item is missing <attachments> elements, ignoring it: {item.toXml()}" | |
193 ) | |
194 continue | |
195 item_id = item["id"] | |
196 publisher_s = item.getAttribute("publisher") | |
197 # publisher is not filled by all pubsub service, so we can't count on it | |
198 if publisher_s: | |
199 publisher = jid.JID(publisher_s) | |
200 if publisher.userhost() != item_id: | |
201 log.warning( | |
202 f"publisher {publisher.userhost()!r} doesn't correspond to item " | |
203 f"id {item['id']!r}, ignoring. This may be a hack attempt.\n" | |
204 f"{item.toXml()}" | |
205 ) | |
206 continue | |
207 try: | |
208 jid.JID(item_id) | |
209 except RuntimeError: | |
210 log.warning( | |
211 "item ID is not a JID, this is not compliant and is ignored: " | |
212 f"{item.toXml}" | |
213 ) | |
214 continue | |
215 data = { | |
216 "from": item_id | |
217 } | |
218 for handler in self.handlers.values(): | |
219 handler["get"](client, attachments_elt, data) | |
220 if len(data) > 1: | |
221 list_data.append(data) | |
222 return list_data | |
223 | |
224 def _get( | |
225 self, | |
226 service_s: str, | |
227 node: str, | |
228 item: str, | |
229 senders_s: List[str], | |
230 extra_s: str, | |
231 profile_key: str | |
232 ) -> defer.Deferred: | |
233 client = self.host.getClient(profile_key) | |
234 extra = data_format.deserialise(extra_s) | |
235 senders = [jid.JID(s) for s in senders_s] | |
236 d = defer.ensureDeferred( | |
237 self.getAttachments(client, jid.JID(service_s), node, item, senders) | |
238 ) | |
239 d.addCallback( | |
240 lambda ret: | |
241 (data_format.serialise(ret[0]), | |
242 data_format.serialise(ret[1])) | |
243 ) | |
244 return d | |
245 | |
246 async def getAttachments( | |
247 self, | |
248 client: SatXMPPEntity, | |
249 service: jid.JID, | |
250 node: str, | |
251 item: str, | |
252 senders: Optional[List[jid.JID]], | |
253 extra: Optional[dict] = None | |
254 ) -> Tuple[List[Dict[str, Any]], dict]: | |
255 """Retrieve data attached to a pubsub item | |
256 | |
257 @param service: pubsub service where the node is | |
258 @param node: pubsub node containing the item | |
259 @param item: ID of the item for which attachments will be retrieved | |
260 @param senders: bare JIDs of entities that are checked. Attachments from those | |
261 entities will be retrieved. | |
262 If None, attachments from all entities will be retrieved | |
263 @param extra: extra data, will be used as ``extra`` argument when doing | |
264 ``getItems`` call. | |
265 @return: A tuple with: | |
266 - the list of attachments data, one item per found sender. The attachments | |
267 data are dict containing attachment, no ``extra`` field is used here | |
268 (contrarily to attachments data used with ``setAttachments``). | |
269 - metadata returned by the call to ``getItems`` | |
270 """ | |
271 if extra is None: | |
272 extra = {} | |
273 attachment_node = self.getAttachmentNodeName(service, node, item) | |
274 item_ids = [e.userhost() for e in senders] if senders else None | |
275 items, metadata = await self._p.getItems( | |
276 client, service, attachment_node, item_ids=item_ids, extra=extra | |
277 ) | |
278 list_data = self.items2attachmentData(client, items) | |
279 | |
280 return list_data, metadata | |
281 | |
282 def _set( | |
283 self, | |
284 attachments_s: str, | |
285 profile_key: str | |
286 ) -> None: | |
287 client = self.host.getClient(profile_key) | |
288 attachments = data_format.deserialise(attachments_s) or {} | |
289 return defer.ensureDeferred(self.setAttachments(client, attachments)) | |
290 | |
291 def applySetHandler( | |
292 self, | |
293 client: SatXMPPEntity, | |
294 attachments_data: dict, | |
295 item_elt: Optional[domish.Element], | |
296 handlers: Optional[List[Tuple[str, str]]] = None, | |
297 from_jid: Optional[jid.JID] = None, | |
298 ) -> domish.Element: | |
299 """Apply all ``set`` callbacks to an attachments item | |
300 | |
301 @param attachments_data: data describing the attachments | |
302 ``extra`` key will be used, and created if not found | |
303 @param from_jid: jid of the author of the attachments | |
304 ``client.jid.userhostJID()`` will be used if not specified | |
305 @param item_elt: item containing an <attachments> element | |
306 will be modified in place | |
307 if None, a new element will be created | |
308 @param handlers: list of (name, namespace) of handlers to use. | |
309 if None, all registered handlers will be used. | |
310 @return: updated item_elt if given, otherwise a new item_elt | |
311 """ | |
312 attachments_data.setdefault("extra", {}) | |
313 if item_elt is None: | |
314 item_id = client.jid.userhost() if from_jid is None else from_jid.userhost() | |
315 item_elt = pubsub.Item(item_id) | |
316 item_elt.addElement((NS_PUBSUB_ATTACHMENTS, "attachments")) | |
317 | |
318 try: | |
319 attachments_elt = next( | |
320 item_elt.elements(NS_PUBSUB_ATTACHMENTS, "attachments") | |
321 ) | |
322 except StopIteration: | |
323 log.warning( | |
324 f"no <attachments> element found, creating a new one: {item_elt.toXml()}" | |
325 ) | |
326 attachments_elt = item_elt.addElement((NS_PUBSUB_ATTACHMENTS, "attachments")) | |
327 | |
328 if handlers is None: | |
329 handlers = list(self.handlers.keys()) | |
330 | |
331 for name, namespace in handlers: | |
332 try: | |
333 handler = self.handlers[(name, namespace)] | |
334 except KeyError: | |
335 log.error( | |
336 f"unregistered handler ({name!r}, {namespace!r}) is requested, " | |
337 "ignoring" | |
338 ) | |
339 continue | |
340 try: | |
341 former_elt = next(attachments_elt.elements(namespace, name)) | |
342 except StopIteration: | |
343 former_elt = None | |
344 new_elt = handler["set"](client, attachments_data, former_elt) | |
345 if new_elt != former_elt: | |
346 if former_elt is not None: | |
347 attachments_elt.children.remove(former_elt) | |
348 if new_elt is not None: | |
349 attachments_elt.addChild(new_elt) | |
350 return item_elt | |
351 | |
352 async def setAttachments( | |
353 self, | |
354 client: SatXMPPEntity, | |
355 attachments_data: Dict[str, Any] | |
356 ) -> None: | |
357 """Set or update attachments | |
358 | |
359 Former <attachments> element will be retrieved and updated. Individual | |
360 attachments replace or update their elements individually, according to the | |
361 "operation" key. | |
362 | |
363 "operation" key may be "update" or "replace", and defaults to update, it is only | |
364 used in attachments where "update" makes sense (e.g. it's used for "reactions" | |
365 but not for "noticed"). | |
366 | |
367 @param attachments_data: data describing attachments. Various keys (usually stored | |
368 in attachments_data["extra"]) may be used depending on the attachments | |
369 handlers registered. The keys "service", "node" and "id" MUST be set. | |
370 ``attachments_data`` is thought to be compatible with microblog data. | |
371 | |
372 """ | |
373 try: | |
374 service = jid.JID(attachments_data["service"]) | |
375 node = attachments_data["node"] | |
376 item = attachments_data["id"] | |
377 except (KeyError, RuntimeError): | |
378 raise ValueError( | |
379 'data must have "service", "node" and "id" set' | |
380 ) | |
381 attachment_node = self.getAttachmentNodeName(service, node, item) | |
382 try: | |
383 items, __ = await self._p.getItems( | |
384 client, service, attachment_node, item_ids=[client.jid.userhost()] | |
385 ) | |
386 except exceptions.NotFound: | |
387 item_elt = None | |
388 else: | |
389 if not items: | |
390 item_elt = None | |
391 else: | |
392 item_elt = items[0] | |
393 | |
394 item_elt = self.applySetHandler( | |
395 client, | |
396 attachments_data, | |
397 item_elt=item_elt, | |
398 ) | |
399 | |
400 try: | |
401 await self._p.sendItems(client, service, attachment_node, [item_elt]) | |
402 except error.StanzaError as e: | |
403 if e.condition == "item-not-found": | |
404 # the node doesn't exist, we can't publish attachments | |
405 log.warning( | |
406 f"no attachment node found at {service} on {node!r} for item " | |
407 f"{item!r}, we can't update attachments." | |
408 ) | |
409 raise exceptions.NotFound("No attachment node available") | |
410 else: | |
411 raise e | |
412 | |
413 async def subscribe( | |
414 self, | |
415 client: SatXMPPEntity, | |
416 service: jid.JID, | |
417 node: str, | |
418 item: str, | |
419 ) -> None: | |
420 """Subscribe to attachment node targeting the item | |
421 | |
422 @param service: service of target item (will also be used for attachment node) | |
423 @param node: node of target item (used to get attachment node's name) | |
424 @param item: name of target item (used to get attachment node's name) | |
425 """ | |
426 attachment_node = self.getAttachmentNodeName(service, node, item) | |
427 await self._p.subscribe(client, service, attachment_node) | |
428 | |
429 | |
430 def setTimestamp(self, attachment_elt: domish.Element, data: dict) -> None: | |
431 """Check if a ``timestamp`` attribute is set, parse it, and fill data | |
432 | |
433 @param attachments_elt: element where the ``timestamp`` attribute may be set | |
434 @param data: data specific to the attachment (i.e. not the whole microblog data) | |
435 ``timestamp`` field will be set there if timestamp exists and is parsable | |
436 """ | |
437 timestamp_raw = attachment_elt.getAttribute("timestamp") | |
438 if timestamp_raw: | |
439 try: | |
440 timestamp = date_utils.date_parse(timestamp_raw) | |
441 except date_utils.ParserError: | |
442 log.warning(f"can't parse timestamp: {timestamp_raw}") | |
443 else: | |
444 data["timestamp"] = timestamp | |
445 | |
446 def noticedGet( | |
447 self, | |
448 client: SatXMPPEntity, | |
449 attachments_elt: domish.Element, | |
450 data: Dict[str, Any], | |
451 ) -> None: | |
452 try: | |
453 noticed_elt = next( | |
454 attachments_elt.elements(NS_PUBSUB_ATTACHMENTS, "noticed") | |
455 ) | |
456 except StopIteration: | |
457 pass | |
458 else: | |
459 noticed_data = { | |
460 "noticed": True | |
461 } | |
462 self.setTimestamp(noticed_elt, noticed_data) | |
463 data["noticed"] = noticed_data | |
464 | |
465 def noticedSet( | |
466 self, | |
467 client: SatXMPPEntity, | |
468 data: Dict[str, Any], | |
469 former_elt: Optional[domish.Element] | |
470 ) -> Optional[domish.Element]: | |
471 """add or remove a <noticed> attachment | |
472 | |
473 if data["noticed"] is True, element is added, if it's False, it's removed, and | |
474 it's not present or None, the former element is kept. | |
475 """ | |
476 noticed = data["extra"].get("noticed") | |
477 if noticed is None: | |
478 return former_elt | |
479 elif noticed: | |
480 return domish.Element( | |
481 (NS_PUBSUB_ATTACHMENTS, "noticed"), | |
482 attribs = { | |
483 "timestamp": xmpp_date() | |
484 } | |
485 ) | |
486 else: | |
487 return None | |
488 | |
489 def reactionsGet( | |
490 self, | |
491 client: SatXMPPEntity, | |
492 attachments_elt: domish.Element, | |
493 data: Dict[str, Any], | |
494 ) -> None: | |
495 try: | |
496 reactions_elt = next( | |
497 attachments_elt.elements(NS_PUBSUB_ATTACHMENTS, "reactions") | |
498 ) | |
499 except StopIteration: | |
500 pass | |
501 else: | |
502 reactions_data = {"reactions": []} | |
503 reactions = reactions_data["reactions"] | |
504 for reaction_elt in reactions_elt.elements(NS_PUBSUB_ATTACHMENTS, "reaction"): | |
505 reactions.append(str(reaction_elt)) | |
506 self.setTimestamp(reactions_elt, reactions_data) | |
507 data["reactions"] = reactions_data | |
508 | |
509 def reactionsSet( | |
510 self, | |
511 client: SatXMPPEntity, | |
512 data: Dict[str, Any], | |
513 former_elt: Optional[domish.Element] | |
514 ) -> Optional[domish.Element]: | |
515 """update the <reaction> attachment""" | |
516 reactions_data = data["extra"].get("reactions") | |
517 if reactions_data is None: | |
518 return former_elt | |
519 operation_type = reactions_data.get("operation", "update") | |
520 if operation_type == "update": | |
521 former_reactions = { | |
522 str(r) for r in former_elt.elements(NS_PUBSUB_ATTACHMENTS, "reaction") | |
523 } if former_elt is not None else set() | |
524 added_reactions = set(reactions_data.get("add") or []) | |
525 removed_reactions = set(reactions_data.get("remove") or []) | |
526 reactions = list((former_reactions | added_reactions) - removed_reactions) | |
527 elif operation_type == "replace": | |
528 reactions = reactions_data.get("reactions") or [] | |
529 else: | |
530 raise exceptions.DataError(f"invalid reaction operation: {operation_type!r}") | |
531 if reactions: | |
532 reactions_elt = domish.Element( | |
533 (NS_PUBSUB_ATTACHMENTS, "reactions"), | |
534 attribs = { | |
535 "timestamp": xmpp_date() | |
536 } | |
537 ) | |
538 for reactions_data in reactions: | |
539 reactions_elt.addElement("reaction", content=reactions_data) | |
540 return reactions_elt | |
541 else: | |
542 return None | |
543 | |
544 | |
545 @implementer(iwokkel.IDisco) | |
546 class PubsubAttachments_Handler(xmlstream.XMPPHandler): | |
547 | |
548 def getDiscoInfo(self, requestor, service, nodeIdentifier=""): | |
549 return [disco.DiscoFeature(NS_PUBSUB_ATTACHMENTS)] | |
550 | |
551 def getDiscoItems(self, requestor, service, nodeIdentifier=""): | |
552 return [] |