libervia-backend/sat/plugins/plugin_comp_ap_gateway/events.py
Goffi adea3770de 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
2022-09-22 00:01:41 +02:00

407 lines
15 KiB
Python

#!/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