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 []