view 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 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 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