Mercurial > libervia-backend
annotate libervia/backend/plugins/plugin_comp_ap_gateway/events.py @ 4262:d366d90a71aa
component AP Gateway: log invalid account in case of error.
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 12 Jun 2024 22:34:19 +0200 |
parents | 49019947cc76 |
children | 0d7bb4df2343 |
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] | |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
107 @param author_jid: jid of the publisher of the event |
3904 | 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 | |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
239 async def ap_item_2_event_data( |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
240 self, |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
241 requestor_actor_id: str, |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
242 ap_item: dict |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
243 ) -> dict: |
3904 | 244 """Convert AP activity or object to event data |
245 | |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
246 @param requestor_actor_id: ID of the actor doing the request. |
3904 | 247 @param ap_item: ActivityPub item to convert |
248 Can be either an activity of an object | |
249 @return: AP Item's Object and event data | |
250 @raise exceptions.DataError: something is invalid in the AP item | |
251 """ | |
252 is_activity = self.apg.is_activity(ap_item) | |
253 if is_activity: | |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
254 ap_object = await self.apg.ap_get_object(requestor_actor_id, ap_item, "object") |
3904 | 255 if not ap_object: |
256 log.warning(f'No "object" found in AP item {ap_item!r}') | |
257 raise exceptions.DataError | |
258 else: | |
259 ap_object = ap_item | |
260 | |
261 # id | |
262 if "_repeated" in ap_item: | |
263 # if the event is repeated, we use the original one ID | |
264 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
|
265 parsed_uri = uri.parse_xmpp_uri(repeated_uri) |
3904 | 266 object_id = parsed_uri["item"] |
267 else: | |
268 object_id = ap_object.get("id") | |
269 if not object_id: | |
270 raise exceptions.DataError('"id" is missing in AP object') | |
271 | |
272 if ap_item["type"] != TYPE_EVENT: | |
273 raise exceptions.DataError("AP Object is not an event") | |
274 | |
275 # author | |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
276 actor = await self.apg.ap_get_sender_actor(requestor_actor_id, ap_object) |
3904 | 277 |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
278 account = await self.apg.get_ap_account_from_id(requestor_actor_id, actor) |
4037
524856bd7b19
massive refactoring to switch from camelCase to snake_case:
Goffi <goffi@goffi.org>
parents:
4027
diff
changeset
|
279 author_jid = self.apg.get_local_jid_from_account(account).full() |
3904 | 280 |
281 # name, start, end | |
282 event_data = { | |
283 "id": object_id, | |
284 "name": {"": ap_object.get("name") or "unnamed"}, | |
285 "start": date_utils.date_parse(ap_object["startTime"]), | |
286 "end": date_utils.date_parse(ap_object["endTime"]), | |
287 } | |
288 | |
289 # attachments/extra | |
290 event_data["extra"] = extra = {} | |
291 attachments = ap_object.get("attachment") or [] | |
292 for attachment in attachments: | |
293 name = attachment.get("name") | |
294 if name == "Banner": | |
295 try: | |
296 url = attachment["url"] | |
297 except KeyError: | |
298 log.warning(f"invalid attachment: {attachment}") | |
299 continue | |
300 event_data["head-picture"] = {"sources": [{"url": url}]} | |
301 elif name == "Website": | |
302 try: | |
303 url = attachment["href"] | |
304 except KeyError: | |
305 log.warning(f"invalid attachment: {attachment}") | |
306 continue | |
307 extra["website"] = url | |
308 else: | |
309 log.debug(f"unmanaged attachment: {attachment}") | |
310 | |
311 # description | |
312 content = ap_object.get("content") | |
313 if content: | |
314 event_data["descriptions"] = [{ | |
315 "type": "xhtml", | |
316 "description": content | |
317 }] | |
318 | |
319 # categories | |
320 tags = ap_object.get("tag") | |
321 if tags: | |
322 categories = event_data["categories"] = [] | |
323 for tag in tags: | |
324 if tag.get("type") == "Hashtag": | |
325 try: | |
326 term = tag["name"][1:] | |
327 except KeyError: | |
328 log.warning(f"invalid tag: {tag}") | |
329 continue | |
330 categories.append({"term": term}) | |
331 | |
332 #location | |
333 ap_location = ap_object.get("location") | |
334 if ap_location: | |
335 location = {} | |
336 for source, dest in ( | |
337 ("name", "description"), | |
338 ("latitude", "lat"), | |
339 ("longitude", "lon"), | |
340 ): | |
341 value = ap_location.get(source) | |
342 if value is not None: | |
343 location[dest] = value | |
344 address = ap_location.get("address") | |
345 if address: | |
346 for source, dest in ( | |
347 ("addressCountry", "country"), | |
348 ("addressLocality", "locality"), | |
349 ("addressRegion", "region"), | |
350 ("postalCode", "postalcode"), | |
351 ("streetAddress", "street"), | |
352 ): | |
353 value = address.get(source) | |
354 if value is not None: | |
355 location[dest] = value | |
356 if location: | |
357 event_data["locations"] = [location] | |
358 | |
359 # rsvp | |
360 # So far Mobilizon seems to only handle participate/don't participate, thus we use | |
361 # a simple "yes"/"no" form. | |
362 rsvp_data = {"fields": []} | |
363 event_data["rsvp"] = [rsvp_data] | |
364 rsvp_data["fields"].append({ | |
365 "type": "list-single", | |
366 "name": "attending", | |
367 "label": "Attending", | |
368 "options": [ | |
369 {"label": "yes", "value": "yes"}, | |
370 {"label": "no", "value": "no"} | |
371 ], | |
372 "required": True | |
373 }) | |
374 | |
375 # comments | |
376 | |
377 if ap_object.get("commentsEnabled"): | |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
378 __, comments_node = await self.apg.get_comments_nodes( |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
379 requestor_actor_id, |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
380 object_id, |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
381 None |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
382 ) |
3904 | 383 event_data["comments"] = { |
384 "service": author_jid, | |
385 "node": comments_node, | |
386 } | |
387 | |
388 # extra | |
389 # part of extra come from "attachment" above | |
390 | |
391 status = ap_object.get("ical:status") | |
392 if status is None: | |
393 pass | |
394 elif status in ("CONFIRMED", "CANCELLED", "TENTATIVE"): | |
395 extra["status"] = status.lower() | |
396 else: | |
397 log.warning(f"unknown event status: {status}") | |
398 | |
399 return event_data | |
400 | |
401 async def ap_item_2_event_data_and_elt( | |
402 self, | |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
403 requestor_actor_id: str, |
3904 | 404 ap_item: dict |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
405 ) -> tuple[dict, domish.Element]: |
3904 | 406 """Convert AP item to parsed event data and corresponding item element""" |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
407 event_data = await self.ap_item_2_event_data( |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
408 requestor_actor_id, |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
409 ap_item |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
410 ) |
3904 | 411 event_elt = self._events.event_data_2_event_elt(event_data) |
412 item_elt = domish.Element((None, "item")) | |
413 item_elt["id"] = event_data["id"] | |
414 item_elt.addChild(event_elt) | |
415 return event_data, item_elt | |
416 | |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
417 async def ap_item_2_event_elt( |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
418 self, |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
419 requestor_actor_id: str, |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
420 ap_item: dict |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
421 ) -> domish.Element: |
3904 | 422 """Convert AP item to XMPP item element""" |
4259
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
423 __, item_elt = await self.ap_item_2_event_data_and_elt( |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
424 requestor_actor_id, |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
425 ap_item |
49019947cc76
component AP Gateway: implement HTTP GET signature.
Goffi <goffi@goffi.org>
parents:
4071
diff
changeset
|
426 ) |
3904 | 427 return item_elt |