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