Mercurial > libervia-backend
view libervia/backend/plugins/plugin_comp_ap_gateway/events.py @ 4309:b56b1eae7994
component email gateway: add multicasting:
XEP-0033 multicasting is now supported both for incoming and outgoing messages. XEP-0033
metadata are converted to suitable Email headers and vice versa.
Email address and JID are both supported, and delivery is done by the gateway when
suitable on incoming messages.
rel 450
author | Goffi <goffi@goffi.org> |
---|---|
date | Thu, 26 Sep 2024 16:12:01 +0200 |
parents | 0d7bb4df2343 |
children |
line wrap: on
line source
#!/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 libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger from libervia.backend.core import exceptions from libervia.backend.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["XEP-0471"] 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 publisher 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.get_ap_account_from_jid_and_node( author_jid, self._events.namespace ) url_actor = self.apg.build_apurl(TYPE_ACTOR, ap_account) url_item = self.apg.build_apurl(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.create_activity( "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, requestor_actor_id: str, ap_item: dict) -> dict: """Convert AP activity or object to event data @param requestor_actor_id: ID of the actor doing the request. @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.ap_get_object( requestor_actor_id, 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.parse_xmpp_uri(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.ap_get_sender_actor(requestor_actor_id, ap_object) account = await self.apg.get_ap_account_from_id(requestor_actor_id, actor) author_jid = self.apg.get_local_jid_from_account(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.get_comments_nodes( requestor_actor_id, 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, requestor_actor_id: str, 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(requestor_actor_id, 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, requestor_actor_id: str, ap_item: dict ) -> domish.Element: """Convert AP item to XMPP item element""" __, item_elt = await self.ap_item_2_event_data_and_elt( requestor_actor_id, ap_item ) return item_elt