- 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
407 lines
15 KiB
Python
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
|