Mercurial > libervia-backend
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_comp_ap_gateway/events.py Thu Sep 22 00:01:41 2022 +0200 @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 + +# Libervia ActivityPub Gateway +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from typing import Tuple + +import mimetypes +import html + +import shortuuid +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid + +from sat.core.i18n import _ +from sat.core.log import getLogger +from sat.core import exceptions +from sat.tools.common import date_utils, uri + +from .constants import NS_AP_PUBLIC, TYPE_ACTOR, TYPE_EVENT, TYPE_ITEM + + +log = getLogger(__name__) + +# direct copy of what Mobilizon uses +AP_EVENTS_CONTEXT = { + "@language": "und", + "Hashtag": "as:Hashtag", + "PostalAddress": "sc:PostalAddress", + "PropertyValue": "sc:PropertyValue", + "address": {"@id": "sc:address", "@type": "sc:PostalAddress"}, + "addressCountry": "sc:addressCountry", + "addressLocality": "sc:addressLocality", + "addressRegion": "sc:addressRegion", + "anonymousParticipationEnabled": {"@id": "mz:anonymousParticipationEnabled", + "@type": "sc:Boolean"}, + "category": "sc:category", + "commentsEnabled": {"@id": "pt:commentsEnabled", + "@type": "sc:Boolean"}, + "discoverable": "toot:discoverable", + "discussions": {"@id": "mz:discussions", "@type": "@id"}, + "events": {"@id": "mz:events", "@type": "@id"}, + "ical": "http://www.w3.org/2002/12/cal/ical#", + "inLanguage": "sc:inLanguage", + "isOnline": {"@id": "mz:isOnline", "@type": "sc:Boolean"}, + "joinMode": {"@id": "mz:joinMode", "@type": "mz:joinModeType"}, + "joinModeType": {"@id": "mz:joinModeType", + "@type": "rdfs:Class"}, + "location": {"@id": "sc:location", "@type": "sc:Place"}, + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "maximumAttendeeCapacity": "sc:maximumAttendeeCapacity", + "memberCount": {"@id": "mz:memberCount", "@type": "sc:Integer"}, + "members": {"@id": "mz:members", "@type": "@id"}, + "mz": "https://joinmobilizon.org/ns#", + "openness": {"@id": "mz:openness", "@type": "@id"}, + "participantCount": {"@id": "mz:participantCount", + "@type": "sc:Integer"}, + "participationMessage": {"@id": "mz:participationMessage", + "@type": "sc:Text"}, + "postalCode": "sc:postalCode", + "posts": {"@id": "mz:posts", "@type": "@id"}, + "propertyID": "sc:propertyID", + "pt": "https://joinpeertube.org/ns#", + "remainingAttendeeCapacity": "sc:remainingAttendeeCapacity", + "repliesModerationOption": {"@id": "mz:repliesModerationOption", + "@type": "mz:repliesModerationOptionType"}, + "repliesModerationOptionType": {"@id": "mz:repliesModerationOptionType", + "@type": "rdfs:Class"}, + "resources": {"@id": "mz:resources", "@type": "@id"}, + "sc": "http://schema.org#", + "streetAddress": "sc:streetAddress", + "timezone": {"@id": "mz:timezone", "@type": "sc:Text"}, + "todos": {"@id": "mz:todos", "@type": "@id"}, + "toot": "http://joinmastodon.org/ns#", + "uuid": "sc:identifier", + "value": "sc:value" +} + + +class APEvents: + """XMPP Events <=> AP Events conversion""" + + def __init__(self, apg): + self.host = apg.host + self.apg = apg + self._events = self.host.plugins["EVENTS"] + + async def event_data_2_ap_item( + self, event_data: dict, author_jid: jid.JID, is_new: bool=True + ) -> dict: + """Convert event data to AP activity + + @param event_data: event data as used in [plugin_exp_events] + @param author_jid: jid of the published of the event + @param is_new: if True, the item is a new one (no instance has been found in + cache). + If True, a "Create" activity will be generated, otherwise an "Update" one will + be + @return: AP activity wrapping an Event object + """ + if not event_data.get("id"): + event_data["id"] = shortuuid.uuid() + ap_account = await self.apg.getAPAccountFromJidAndNode( + author_jid, + self._events.namespace + ) + url_actor = self.apg.buildAPURL(TYPE_ACTOR, ap_account) + url_item = self.apg.buildAPURL(TYPE_ITEM, ap_account, event_data["id"]) + ap_object = { + "actor": url_actor, + "attributedTo": url_actor, + "to": [NS_AP_PUBLIC], + "id": url_item, + "type": TYPE_EVENT, + "name": next(iter(event_data["name"].values())), + "startTime": date_utils.date_fmt(event_data["start"], "iso"), + "endTime": date_utils.date_fmt(event_data["end"], "iso"), + "url": url_item, + } + + attachment = ap_object["attachment"] = [] + + # FIXME: we only handle URL head-picture for now + # TODO: handle jingle and use file metadata + try: + head_picture_url = event_data["head-picture"]["sources"][0]["url"] + except (KeyError, IndexError, TypeError): + pass + else: + media_type = mimetypes.guess_type(head_picture_url, False)[0] or "image/jpeg" + attachment.append({ + "name": "Banner", + "type": "Document", + "mediaType": media_type, + "url": head_picture_url, + }) + + descriptions = event_data.get("descriptions") + if descriptions: + for description in descriptions: + content = description["description"] + if description["type"] == "xhtml": + break + else: + content = f"<p>{html.escape(content)}</p>" # type: ignore + ap_object["content"] = content + + categories = event_data.get("categories") + if categories: + tag = ap_object["tag"] = [] + for category in categories: + tag.append({ + "name": f"#{category['term']}", + "type": "Hashtag", + }) + + locations = event_data.get("locations") + if locations: + ap_loc = ap_object["location"] = {} + # we only use the first found location + location = locations[0] + for source, dest in ( + ("description", "name"), + ("lat", "latitude"), + ("lon", "longitude"), + ): + value = location.get(source) + if value is not None: + ap_loc[dest] = value + for source, dest in ( + ("country", "addressCountry"), + ("locality", "addressLocality"), + ("region", "addressRegion"), + ("postalcode", "postalCode"), + ("street", "streetAddress"), + ): + value = location.get(source) + if value is not None: + ap_loc.setdefault("address", {})[dest] = value + + if event_data.get("comments"): + ap_object["commentsEnabled"] = True + + extra = event_data.get("extra") + + if extra: + status = extra.get("status") + if status: + ap_object["ical:status"] = status.upper() + + website = extra.get("website") + if website: + attachment.append({ + "href": website, + "mediaType": "text/html", + "name": "Website", + "type": "Link" + }) + + accessibility = extra.get("accessibility") + if accessibility: + wheelchair = accessibility.get("wheelchair") + if wheelchair: + if wheelchair == "full": + ap_wc_value = "fully" + elif wheelchair == "partial": + ap_wc_value = "partially" + elif wheelchair == "no": + ap_wc_value = "no" + else: + log.error(f"unexpected wheelchair value: {wheelchair}") + ap_wc_value = None + if ap_wc_value is not None: + attachment.append({ + "propertyID": "mz:accessibility:wheelchairAccessible", + "type": "PropertyValue", + "value": ap_wc_value + }) + + activity = self.apg.createActivity( + "Create" if is_new else "Update", url_actor, ap_object, activity_id=url_item + ) + activity["@context"].append(AP_EVENTS_CONTEXT) + return activity + + async def ap_item_2_event_data(self, ap_item: dict) -> dict: + """Convert AP activity or object to event data + + @param ap_item: ActivityPub item to convert + Can be either an activity of an object + @return: AP Item's Object and event data + @raise exceptions.DataError: something is invalid in the AP item + """ + is_activity = self.apg.is_activity(ap_item) + if is_activity: + ap_object = await self.apg.apGetObject(ap_item, "object") + if not ap_object: + log.warning(f'No "object" found in AP item {ap_item!r}') + raise exceptions.DataError + else: + ap_object = ap_item + + # id + if "_repeated" in ap_item: + # if the event is repeated, we use the original one ID + repeated_uri = ap_item["_repeated"]["uri"] + parsed_uri = uri.parseXMPPUri(repeated_uri) + object_id = parsed_uri["item"] + else: + object_id = ap_object.get("id") + if not object_id: + raise exceptions.DataError('"id" is missing in AP object') + + if ap_item["type"] != TYPE_EVENT: + raise exceptions.DataError("AP Object is not an event") + + # author + actor = await self.apg.apGetSenderActor(ap_object) + + account = await self.apg.getAPAccountFromId(actor) + author_jid = self.apg.getLocalJIDFromAccount(account).full() + + # name, start, end + event_data = { + "id": object_id, + "name": {"": ap_object.get("name") or "unnamed"}, + "start": date_utils.date_parse(ap_object["startTime"]), + "end": date_utils.date_parse(ap_object["endTime"]), + } + + # attachments/extra + event_data["extra"] = extra = {} + attachments = ap_object.get("attachment") or [] + for attachment in attachments: + name = attachment.get("name") + if name == "Banner": + try: + url = attachment["url"] + except KeyError: + log.warning(f"invalid attachment: {attachment}") + continue + event_data["head-picture"] = {"sources": [{"url": url}]} + elif name == "Website": + try: + url = attachment["href"] + except KeyError: + log.warning(f"invalid attachment: {attachment}") + continue + extra["website"] = url + else: + log.debug(f"unmanaged attachment: {attachment}") + + # description + content = ap_object.get("content") + if content: + event_data["descriptions"] = [{ + "type": "xhtml", + "description": content + }] + + # categories + tags = ap_object.get("tag") + if tags: + categories = event_data["categories"] = [] + for tag in tags: + if tag.get("type") == "Hashtag": + try: + term = tag["name"][1:] + except KeyError: + log.warning(f"invalid tag: {tag}") + continue + categories.append({"term": term}) + + #location + ap_location = ap_object.get("location") + if ap_location: + location = {} + for source, dest in ( + ("name", "description"), + ("latitude", "lat"), + ("longitude", "lon"), + ): + value = ap_location.get(source) + if value is not None: + location[dest] = value + address = ap_location.get("address") + if address: + for source, dest in ( + ("addressCountry", "country"), + ("addressLocality", "locality"), + ("addressRegion", "region"), + ("postalCode", "postalcode"), + ("streetAddress", "street"), + ): + value = address.get(source) + if value is not None: + location[dest] = value + if location: + event_data["locations"] = [location] + + # rsvp + # So far Mobilizon seems to only handle participate/don't participate, thus we use + # a simple "yes"/"no" form. + rsvp_data = {"fields": []} + event_data["rsvp"] = [rsvp_data] + rsvp_data["fields"].append({ + "type": "list-single", + "name": "attending", + "label": "Attending", + "options": [ + {"label": "yes", "value": "yes"}, + {"label": "no", "value": "no"} + ], + "required": True + }) + + # comments + + if ap_object.get("commentsEnabled"): + __, comments_node = await self.apg.getCommentsNodes(object_id, None) + event_data["comments"] = { + "service": author_jid, + "node": comments_node, + } + + # extra + # part of extra come from "attachment" above + + status = ap_object.get("ical:status") + if status is None: + pass + elif status in ("CONFIRMED", "CANCELLED", "TENTATIVE"): + extra["status"] = status.lower() + else: + log.warning(f"unknown event status: {status}") + + return event_data + + async def ap_item_2_event_data_and_elt( + self, + ap_item: dict + ) -> Tuple[dict, domish.Element]: + """Convert AP item to parsed event data and corresponding item element""" + event_data = await self.ap_item_2_event_data(ap_item) + event_elt = self._events.event_data_2_event_elt(event_data) + item_elt = domish.Element((None, "item")) + item_elt["id"] = event_data["id"] + item_elt.addChild(event_elt) + return event_data, item_elt + + async def ap_item_2_event_elt(self, ap_item: dict) -> domish.Element: + """Convert AP item to XMPP item element""" + __, item_elt = await self.ap_item_2_event_data_and_elt(ap_item) + return item_elt