Mercurial > libervia-backend
annotate libervia/backend/plugins/plugin_comp_ap_gateway/events.py @ 4095:684ba556a617
core (memory/sqla_mapping): fix legacy pickled values:
folloing packages refactoring, legacy pickled values could not be unpickled (due to use of
old classes). This temporary workaround fix it, but the right thing to do will be to move
from pickle to JSON at some point.
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 12 Jun 2023 14:57:27 +0200 |
parents | 4b842c1fb686 |
children | 49019947cc76 |
rev | line source |
---|---|
3904 | 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 | |
4071
4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
Goffi <goffi@goffi.org>
parents:
4037
diff
changeset
|
28 from libervia.backend.core.i18n import _ |
4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
Goffi <goffi@goffi.org>
parents:
4037
diff
changeset
|
29 from libervia.backend.core.log import getLogger |
4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
Goffi <goffi@goffi.org>
parents:
4037
diff
changeset
|
30 from libervia.backend.core import exceptions |
4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
Goffi <goffi@goffi.org>
parents:
4037
diff
changeset
|
31 from libervia.backend.tools.common import date_utils, uri |
3904 | 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 | |
4027
26c3e1bc7fb7
plugin XEP-0471: renamed "events" plugin to XEP-0471 now that there is a XEP
Goffi <goffi@goffi.org>
parents:
4023
diff
changeset
|
99 self._events = self.host.plugins["XEP-0471"] |
3904 | 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() | |
4037
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
116 ap_account = await self.apg.get_ap_account_from_jid_and_node( |
3904 | 117 author_jid, |
118 self._events.namespace | |
119 ) | |
4037
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
120 url_actor = self.apg.build_apurl(TYPE_ACTOR, ap_account) |
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
121 url_item = self.apg.build_apurl(TYPE_ITEM, ap_account, event_data["id"]) |
3904 | 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 | |
4023
78b5f356900c
component AP gateway: handle attachments
Goffi <goffi@goffi.org>
parents:
3904
diff
changeset
|
233 activity = self.apg.create_activity( |
3904 | 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: | |
4037
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
249 ap_object = await self.apg.ap_get_object(ap_item, "object") |
3904 | 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"] | |
4037
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
260 parsed_uri = uri.parse_xmpp_uri(repeated_uri) |
3904 | 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 | |
4037
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
271 actor = await self.apg.ap_get_sender_actor(ap_object) |
3904 | 272 |
4037
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
273 account = await self.apg.get_ap_account_from_id(actor) |
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
274 author_jid = self.apg.get_local_jid_from_account(account).full() |
3904 | 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"): | |
4037
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
373 __, comments_node = await self.apg.get_comments_nodes(object_id, None) |
3904 | 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 |