Mercurial > libervia-backend
comparison sat/plugins/plugin_comp_ap_gateway/events.py @ 3904:0aa7023dcd08
component AP gateway: events:
- XMPP Events <=> AP Events conversion
- `Join`/`Leave` activities are converted to RSVP attachments and vice versa
- fix caching/notification on item published on a virtual pubsub node
- add Ad-Hoc command to convert XMPP Jid/Node to virtual AP Account
- handle `Update` activity
- on `convertAndPostItems`, `Update` activity is used instead of `Create` if a version of
the item is already present in cache
- `events` field is added to actor data (and to `endpoints`), it links the `outbox` of the
actor mapping the same JID with the Events node (i.e. it links to the Events node of the
entity)
- fix subscription to nodes which are not the microblog one
rel 372
author | Goffi <goffi@goffi.org> |
---|---|
date | Thu, 22 Sep 2022 00:01:41 +0200 |
parents | |
children | 78b5f356900c |
comparison
equal
deleted
inserted
replaced
3903:384b7e6c2dbf | 3904:0aa7023dcd08 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Libervia ActivityPub Gateway | |
4 # Copyright (C) 2009-2021 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 Tuple | |
20 | |
21 import mimetypes | |
22 import html | |
23 | |
24 import shortuuid | |
25 from twisted.words.xish import domish | |
26 from twisted.words.protocols.jabber import jid | |
27 | |
28 from sat.core.i18n import _ | |
29 from sat.core.log import getLogger | |
30 from sat.core import exceptions | |
31 from sat.tools.common import date_utils, uri | |
32 | |
33 from .constants import NS_AP_PUBLIC, TYPE_ACTOR, TYPE_EVENT, TYPE_ITEM | |
34 | |
35 | |
36 log = getLogger(__name__) | |
37 | |
38 # direct copy of what Mobilizon uses | |
39 AP_EVENTS_CONTEXT = { | |
40 "@language": "und", | |
41 "Hashtag": "as:Hashtag", | |
42 "PostalAddress": "sc:PostalAddress", | |
43 "PropertyValue": "sc:PropertyValue", | |
44 "address": {"@id": "sc:address", "@type": "sc:PostalAddress"}, | |
45 "addressCountry": "sc:addressCountry", | |
46 "addressLocality": "sc:addressLocality", | |
47 "addressRegion": "sc:addressRegion", | |
48 "anonymousParticipationEnabled": {"@id": "mz:anonymousParticipationEnabled", | |
49 "@type": "sc:Boolean"}, | |
50 "category": "sc:category", | |
51 "commentsEnabled": {"@id": "pt:commentsEnabled", | |
52 "@type": "sc:Boolean"}, | |
53 "discoverable": "toot:discoverable", | |
54 "discussions": {"@id": "mz:discussions", "@type": "@id"}, | |
55 "events": {"@id": "mz:events", "@type": "@id"}, | |
56 "ical": "http://www.w3.org/2002/12/cal/ical#", | |
57 "inLanguage": "sc:inLanguage", | |
58 "isOnline": {"@id": "mz:isOnline", "@type": "sc:Boolean"}, | |
59 "joinMode": {"@id": "mz:joinMode", "@type": "mz:joinModeType"}, | |
60 "joinModeType": {"@id": "mz:joinModeType", | |
61 "@type": "rdfs:Class"}, | |
62 "location": {"@id": "sc:location", "@type": "sc:Place"}, | |
63 "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", | |
64 "maximumAttendeeCapacity": "sc:maximumAttendeeCapacity", | |
65 "memberCount": {"@id": "mz:memberCount", "@type": "sc:Integer"}, | |
66 "members": {"@id": "mz:members", "@type": "@id"}, | |
67 "mz": "https://joinmobilizon.org/ns#", | |
68 "openness": {"@id": "mz:openness", "@type": "@id"}, | |
69 "participantCount": {"@id": "mz:participantCount", | |
70 "@type": "sc:Integer"}, | |
71 "participationMessage": {"@id": "mz:participationMessage", | |
72 "@type": "sc:Text"}, | |
73 "postalCode": "sc:postalCode", | |
74 "posts": {"@id": "mz:posts", "@type": "@id"}, | |
75 "propertyID": "sc:propertyID", | |
76 "pt": "https://joinpeertube.org/ns#", | |
77 "remainingAttendeeCapacity": "sc:remainingAttendeeCapacity", | |
78 "repliesModerationOption": {"@id": "mz:repliesModerationOption", | |
79 "@type": "mz:repliesModerationOptionType"}, | |
80 "repliesModerationOptionType": {"@id": "mz:repliesModerationOptionType", | |
81 "@type": "rdfs:Class"}, | |
82 "resources": {"@id": "mz:resources", "@type": "@id"}, | |
83 "sc": "http://schema.org#", | |
84 "streetAddress": "sc:streetAddress", | |
85 "timezone": {"@id": "mz:timezone", "@type": "sc:Text"}, | |
86 "todos": {"@id": "mz:todos", "@type": "@id"}, | |
87 "toot": "http://joinmastodon.org/ns#", | |
88 "uuid": "sc:identifier", | |
89 "value": "sc:value" | |
90 } | |
91 | |
92 | |
93 class APEvents: | |
94 """XMPP Events <=> AP Events conversion""" | |
95 | |
96 def __init__(self, apg): | |
97 self.host = apg.host | |
98 self.apg = apg | |
99 self._events = self.host.plugins["EVENTS"] | |
100 | |
101 async def event_data_2_ap_item( | |
102 self, event_data: dict, author_jid: jid.JID, is_new: bool=True | |
103 ) -> dict: | |
104 """Convert event data to AP activity | |
105 | |
106 @param event_data: event data as used in [plugin_exp_events] | |
107 @param author_jid: jid of the published of the event | |
108 @param is_new: if True, the item is a new one (no instance has been found in | |
109 cache). | |
110 If True, a "Create" activity will be generated, otherwise an "Update" one will | |
111 be | |
112 @return: AP activity wrapping an Event object | |
113 """ | |
114 if not event_data.get("id"): | |
115 event_data["id"] = shortuuid.uuid() | |
116 ap_account = await self.apg.getAPAccountFromJidAndNode( | |
117 author_jid, | |
118 self._events.namespace | |
119 ) | |
120 url_actor = self.apg.buildAPURL(TYPE_ACTOR, ap_account) | |
121 url_item = self.apg.buildAPURL(TYPE_ITEM, ap_account, event_data["id"]) | |
122 ap_object = { | |
123 "actor": url_actor, | |
124 "attributedTo": url_actor, | |
125 "to": [NS_AP_PUBLIC], | |
126 "id": url_item, | |
127 "type": TYPE_EVENT, | |
128 "name": next(iter(event_data["name"].values())), | |
129 "startTime": date_utils.date_fmt(event_data["start"], "iso"), | |
130 "endTime": date_utils.date_fmt(event_data["end"], "iso"), | |
131 "url": url_item, | |
132 } | |
133 | |
134 attachment = ap_object["attachment"] = [] | |
135 | |
136 # FIXME: we only handle URL head-picture for now | |
137 # TODO: handle jingle and use file metadata | |
138 try: | |
139 head_picture_url = event_data["head-picture"]["sources"][0]["url"] | |
140 except (KeyError, IndexError, TypeError): | |
141 pass | |
142 else: | |
143 media_type = mimetypes.guess_type(head_picture_url, False)[0] or "image/jpeg" | |
144 attachment.append({ | |
145 "name": "Banner", | |
146 "type": "Document", | |
147 "mediaType": media_type, | |
148 "url": head_picture_url, | |
149 }) | |
150 | |
151 descriptions = event_data.get("descriptions") | |
152 if descriptions: | |
153 for description in descriptions: | |
154 content = description["description"] | |
155 if description["type"] == "xhtml": | |
156 break | |
157 else: | |
158 content = f"<p>{html.escape(content)}</p>" # type: ignore | |
159 ap_object["content"] = content | |
160 | |
161 categories = event_data.get("categories") | |
162 if categories: | |
163 tag = ap_object["tag"] = [] | |
164 for category in categories: | |
165 tag.append({ | |
166 "name": f"#{category['term']}", | |
167 "type": "Hashtag", | |
168 }) | |
169 | |
170 locations = event_data.get("locations") | |
171 if locations: | |
172 ap_loc = ap_object["location"] = {} | |
173 # we only use the first found location | |
174 location = locations[0] | |
175 for source, dest in ( | |
176 ("description", "name"), | |
177 ("lat", "latitude"), | |
178 ("lon", "longitude"), | |
179 ): | |
180 value = location.get(source) | |
181 if value is not None: | |
182 ap_loc[dest] = value | |
183 for source, dest in ( | |
184 ("country", "addressCountry"), | |
185 ("locality", "addressLocality"), | |
186 ("region", "addressRegion"), | |
187 ("postalcode", "postalCode"), | |
188 ("street", "streetAddress"), | |
189 ): | |
190 value = location.get(source) | |
191 if value is not None: | |
192 ap_loc.setdefault("address", {})[dest] = value | |
193 | |
194 if event_data.get("comments"): | |
195 ap_object["commentsEnabled"] = True | |
196 | |
197 extra = event_data.get("extra") | |
198 | |
199 if extra: | |
200 status = extra.get("status") | |
201 if status: | |
202 ap_object["ical:status"] = status.upper() | |
203 | |
204 website = extra.get("website") | |
205 if website: | |
206 attachment.append({ | |
207 "href": website, | |
208 "mediaType": "text/html", | |
209 "name": "Website", | |
210 "type": "Link" | |
211 }) | |
212 | |
213 accessibility = extra.get("accessibility") | |
214 if accessibility: | |
215 wheelchair = accessibility.get("wheelchair") | |
216 if wheelchair: | |
217 if wheelchair == "full": | |
218 ap_wc_value = "fully" | |
219 elif wheelchair == "partial": | |
220 ap_wc_value = "partially" | |
221 elif wheelchair == "no": | |
222 ap_wc_value = "no" | |
223 else: | |
224 log.error(f"unexpected wheelchair value: {wheelchair}") | |
225 ap_wc_value = None | |
226 if ap_wc_value is not None: | |
227 attachment.append({ | |
228 "propertyID": "mz:accessibility:wheelchairAccessible", | |
229 "type": "PropertyValue", | |
230 "value": ap_wc_value | |
231 }) | |
232 | |
233 activity = self.apg.createActivity( | |
234 "Create" if is_new else "Update", url_actor, ap_object, activity_id=url_item | |
235 ) | |
236 activity["@context"].append(AP_EVENTS_CONTEXT) | |
237 return activity | |
238 | |
239 async def ap_item_2_event_data(self, ap_item: dict) -> dict: | |
240 """Convert AP activity or object to event data | |
241 | |
242 @param ap_item: ActivityPub item to convert | |
243 Can be either an activity of an object | |
244 @return: AP Item's Object and event data | |
245 @raise exceptions.DataError: something is invalid in the AP item | |
246 """ | |
247 is_activity = self.apg.is_activity(ap_item) | |
248 if is_activity: | |
249 ap_object = await self.apg.apGetObject(ap_item, "object") | |
250 if not ap_object: | |
251 log.warning(f'No "object" found in AP item {ap_item!r}') | |
252 raise exceptions.DataError | |
253 else: | |
254 ap_object = ap_item | |
255 | |
256 # id | |
257 if "_repeated" in ap_item: | |
258 # if the event is repeated, we use the original one ID | |
259 repeated_uri = ap_item["_repeated"]["uri"] | |
260 parsed_uri = uri.parseXMPPUri(repeated_uri) | |
261 object_id = parsed_uri["item"] | |
262 else: | |
263 object_id = ap_object.get("id") | |
264 if not object_id: | |
265 raise exceptions.DataError('"id" is missing in AP object') | |
266 | |
267 if ap_item["type"] != TYPE_EVENT: | |
268 raise exceptions.DataError("AP Object is not an event") | |
269 | |
270 # author | |
271 actor = await self.apg.apGetSenderActor(ap_object) | |
272 | |
273 account = await self.apg.getAPAccountFromId(actor) | |
274 author_jid = self.apg.getLocalJIDFromAccount(account).full() | |
275 | |
276 # name, start, end | |
277 event_data = { | |
278 "id": object_id, | |
279 "name": {"": ap_object.get("name") or "unnamed"}, | |
280 "start": date_utils.date_parse(ap_object["startTime"]), | |
281 "end": date_utils.date_parse(ap_object["endTime"]), | |
282 } | |
283 | |
284 # attachments/extra | |
285 event_data["extra"] = extra = {} | |
286 attachments = ap_object.get("attachment") or [] | |
287 for attachment in attachments: | |
288 name = attachment.get("name") | |
289 if name == "Banner": | |
290 try: | |
291 url = attachment["url"] | |
292 except KeyError: | |
293 log.warning(f"invalid attachment: {attachment}") | |
294 continue | |
295 event_data["head-picture"] = {"sources": [{"url": url}]} | |
296 elif name == "Website": | |
297 try: | |
298 url = attachment["href"] | |
299 except KeyError: | |
300 log.warning(f"invalid attachment: {attachment}") | |
301 continue | |
302 extra["website"] = url | |
303 else: | |
304 log.debug(f"unmanaged attachment: {attachment}") | |
305 | |
306 # description | |
307 content = ap_object.get("content") | |
308 if content: | |
309 event_data["descriptions"] = [{ | |
310 "type": "xhtml", | |
311 "description": content | |
312 }] | |
313 | |
314 # categories | |
315 tags = ap_object.get("tag") | |
316 if tags: | |
317 categories = event_data["categories"] = [] | |
318 for tag in tags: | |
319 if tag.get("type") == "Hashtag": | |
320 try: | |
321 term = tag["name"][1:] | |
322 except KeyError: | |
323 log.warning(f"invalid tag: {tag}") | |
324 continue | |
325 categories.append({"term": term}) | |
326 | |
327 #location | |
328 ap_location = ap_object.get("location") | |
329 if ap_location: | |
330 location = {} | |
331 for source, dest in ( | |
332 ("name", "description"), | |
333 ("latitude", "lat"), | |
334 ("longitude", "lon"), | |
335 ): | |
336 value = ap_location.get(source) | |
337 if value is not None: | |
338 location[dest] = value | |
339 address = ap_location.get("address") | |
340 if address: | |
341 for source, dest in ( | |
342 ("addressCountry", "country"), | |
343 ("addressLocality", "locality"), | |
344 ("addressRegion", "region"), | |
345 ("postalCode", "postalcode"), | |
346 ("streetAddress", "street"), | |
347 ): | |
348 value = address.get(source) | |
349 if value is not None: | |
350 location[dest] = value | |
351 if location: | |
352 event_data["locations"] = [location] | |
353 | |
354 # rsvp | |
355 # So far Mobilizon seems to only handle participate/don't participate, thus we use | |
356 # a simple "yes"/"no" form. | |
357 rsvp_data = {"fields": []} | |
358 event_data["rsvp"] = [rsvp_data] | |
359 rsvp_data["fields"].append({ | |
360 "type": "list-single", | |
361 "name": "attending", | |
362 "label": "Attending", | |
363 "options": [ | |
364 {"label": "yes", "value": "yes"}, | |
365 {"label": "no", "value": "no"} | |
366 ], | |
367 "required": True | |
368 }) | |
369 | |
370 # comments | |
371 | |
372 if ap_object.get("commentsEnabled"): | |
373 __, comments_node = await self.apg.getCommentsNodes(object_id, None) | |
374 event_data["comments"] = { | |
375 "service": author_jid, | |
376 "node": comments_node, | |
377 } | |
378 | |
379 # extra | |
380 # part of extra come from "attachment" above | |
381 | |
382 status = ap_object.get("ical:status") | |
383 if status is None: | |
384 pass | |
385 elif status in ("CONFIRMED", "CANCELLED", "TENTATIVE"): | |
386 extra["status"] = status.lower() | |
387 else: | |
388 log.warning(f"unknown event status: {status}") | |
389 | |
390 return event_data | |
391 | |
392 async def ap_item_2_event_data_and_elt( | |
393 self, | |
394 ap_item: dict | |
395 ) -> Tuple[dict, domish.Element]: | |
396 """Convert AP item to parsed event data and corresponding item element""" | |
397 event_data = await self.ap_item_2_event_data(ap_item) | |
398 event_elt = self._events.event_data_2_event_elt(event_data) | |
399 item_elt = domish.Element((None, "item")) | |
400 item_elt["id"] = event_data["id"] | |
401 item_elt.addChild(event_elt) | |
402 return event_data, item_elt | |
403 | |
404 async def ap_item_2_event_elt(self, ap_item: dict) -> domish.Element: | |
405 """Convert AP item to XMPP item element""" | |
406 __, item_elt = await self.ap_item_2_event_data_and_elt(ap_item) | |
407 return item_elt |