Mercurial > libervia-backend
annotate libervia/backend/plugins/plugin_comp_ap_gateway/events.py @ 4140:13dd5660c28f
tests (unit/frontends): tests for webrtc implementation:
rel 426
| author | Goffi <goffi@goffi.org> |
|---|---|
| date | Wed, 01 Nov 2023 14:04:25 +0100 |
| 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 |
