view libervia/backend/plugins/plugin_xep_0471.py @ 4210:9218d4331bb2

cli (call): `tui` output implementation: - Moved original UI to a separated class, and use if with the `simple` output - By default, best output is automatically selected. For now `gui` is selected if possible, and `simple` is used as fallback. - The new `tui` output can be used to have the videos directly embedded in the terminal, either with real videos for compatible terminal emulators, or with Unicode blocks. - Text contrôls are used for both `simple` and `tui` outputs - several options can be used with `--oo` (will be documented in next commit). rel 428
author Goffi <goffi@goffi.org>
date Fri, 16 Feb 2024 18:46:06 +0100
parents bc898879af34
children 0d7bb4df2343
line wrap: on
line source

#!/usr/bin/env python3


# Libervia plugin to handle events
# Copyright (C) 2009-2022 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 random import seed
from typing import Optional, Final, Dict, List, Union, Any, Optional
from attr import attr

import shortuuid
from sqlalchemy.orm.events import event
from libervia.backend.core.xmpp import SatXMPPClient
from libervia.backend.core.i18n import _
from libervia.backend.core import exceptions
from libervia.backend.core.constants import Const as C
from libervia.backend.core.log import getLogger
from libervia.backend.core.xmpp import SatXMPPEntity
from libervia.backend.core.core_types import SatXMPPEntity
from libervia.backend.tools import utils
from libervia.backend.tools import xml_tools
from libervia.backend.tools.common import uri as xmpp_uri
from libervia.backend.tools.common import date_utils
from libervia.backend.tools.common import data_format
from twisted.internet import defer
from twisted.words.protocols.jabber import jid, error
from twisted.words.xish import domish
from wokkel import disco, iwokkel
from zope.interface import implementer
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
from wokkel import pubsub, data_form

log = getLogger(__name__)


PLUGIN_INFO = {
    C.PI_NAME: "Events",
    C.PI_IMPORT_NAME: "XEP-0471",
    C.PI_TYPE: "XEP",
    C.PI_MODES: C.PLUG_MODE_BOTH,
    C.PI_PROTOCOLS: [],
    C.PI_DEPENDENCIES: [
        "XEP-0060", "XEP-0080", "XEP-0447", "XEP-0470", # "INVITATION", "PUBSUB_INVITATION",
        # "LIST_INTEREST"
    ],
    C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"],
    C.PI_MAIN: "XEP_0471",
    C.PI_HANDLER: "yes",
    C.PI_DESCRIPTION: _("""Calendar Events"""),
}

NS_EVENT = "org.salut-a-toi.event:0"
NS_EVENTS: Final = "urn:xmpp:events:0"
NS_RSVP: Final = "urn:xmpp:events:rsvp:0"
NS_EXTRA: Final = "urn:xmpp:events:extra:0"


class XEP_0471:
    namespace = NS_EVENTS

    def __init__(self, host):
        log.info(_("Events plugin initialization"))
        self.host = host
        self._p = host.plugins["XEP-0060"]
        self._g = host.plugins["XEP-0080"]
        self._b = host.plugins.get("XEP-0277")
        self._sfs = host.plugins["XEP-0447"]
        self._a = host.plugins["XEP-0470"]
        # self._i = host.plugins.get("EMAIL_INVITATION")
        host.register_namespace("events", NS_EVENTS)
        self._a.register_attachment_handler("rsvp", NS_EVENTS, self.rsvp_get, self.rsvp_set)
        # host.plugins["PUBSUB_INVITATION"].register(NS_EVENTS, self)
        host.bridge.add_method(
            "events_get",
            ".plugin",
            in_sign="ssasss",
            out_sign="s",
            method=self._events_get,
            async_=True,
        )
        host.bridge.add_method(
            "event_create",
            ".plugin",
            in_sign="sssss",
            out_sign="",
            method=self._event_create,
            async_=True,
        )
        host.bridge.add_method(
            "event_modify",
            ".plugin",
            in_sign="sssss",
            out_sign="",
            method=self._event_modify,
            async_=True,
        )
        host.bridge.add_method(
            "event_invitee_get",
            ".plugin",
            in_sign="sssasss",
            out_sign="s",
            method=self._event_invitee_get,
            async_=True,
        )
        host.bridge.add_method(
            "event_invitee_set",
            ".plugin",
            in_sign="sssss",
            out_sign="",
            method=self._event_invitee_set,
            async_=True,
        )
        host.bridge.add_method(
            "event_invitees_list",
            ".plugin",
            in_sign="sss",
            out_sign="a{sa{ss}}",
            method=self._event_invitees_list,
            async_=True,
        ),
        host.bridge.add_method(
            "event_invite",
            ".plugin",
            in_sign="sssss",
            out_sign="",
            method=self._invite,
            async_=True,
        )
        host.bridge.add_method(
            "event_invite_by_email",
            ".plugin",
            in_sign="ssssassssssss",
            out_sign="",
            method=self._invite_by_email,
            async_=True,
        )

    def get_handler(self, client):
        return EventsHandler(self)

    def _parse_event_elt(self, event_elt):
        """Helper method to parse event element

        @param (domish.Element): event_elt
        @return (tuple[int, dict[unicode, unicode]): timestamp, event_data
        """
        try:
            timestamp = date_utils.date_parse(next(event_elt.elements(NS_EVENT, "date")))
        except StopIteration:
            timestamp = -1

        data = {}

        for key in ("name",):
            try:
                data[key] = event_elt[key]
            except KeyError:
                continue

        for elt_name in ("description",):
            try:
                elt = next(event_elt.elements(NS_EVENT, elt_name))
            except StopIteration:
                continue
            else:
                data[elt_name] = str(elt)

        for elt_name in ("image", "background-image"):
            try:
                image_elt = next(event_elt.elements(NS_EVENT, elt_name))
                data[elt_name] = image_elt["src"]
            except StopIteration:
                continue
            except KeyError:
                log.warning(_("no src found for image"))

        for uri_type in ("invitees", "blog"):
            try:
                elt = next(event_elt.elements(NS_EVENT, uri_type))
                uri = data[uri_type + "_uri"] = elt["uri"]
                uri_data = xmpp_uri.parse_xmpp_uri(uri)
                if uri_data["type"] != "pubsub":
                    raise ValueError
            except StopIteration:
                log.warning(_("no {uri_type} element found!").format(uri_type=uri_type))
            except KeyError:
                log.warning(_("incomplete {uri_type} element").format(uri_type=uri_type))
            except ValueError:
                log.warning(_("bad {uri_type} element").format(uri_type=uri_type))
            else:
                data[uri_type + "_service"] = uri_data["path"]
                data[uri_type + "_node"] = uri_data["node"]

        for meta_elt in event_elt.elements(NS_EVENT, "meta"):
            key = meta_elt["name"]
            if key in data:
                log.warning(
                    "Ignoring conflicting meta element: {xml}".format(
                        xml=meta_elt.toXml()
                    )
                )
                continue
            data[key] = str(meta_elt)
        if event_elt.link:
            link_elt = event_elt.link
            data["service"] = link_elt["service"]
            data["node"] = link_elt["node"]
            data["item"] = link_elt["item"]
        if event_elt.getAttribute("creator") == "true":
            data["creator"] = True
        return timestamp, data

    def event_elt_2_event_data(self, event_elt: domish.Element) -> Dict[str, Any]:
        """Convert <event/> element to event data

        @param event_elt: <event/> element
            parent <item/> element can also be used
        @raise exceptions.NotFound: can't find event payload
        @raise ValueError: something is missing or badly formed
        """
        if event_elt.name == "item":
            try:
                event_elt = next(event_elt.elements(NS_EVENTS, "event"))
            except StopIteration:
                raise exceptions.NotFound("<event/> payload is missing")

        event_data: Dict[str, Any] = {}

        # id

        parent_elt = event_elt.parent
        if parent_elt is not None and parent_elt.hasAttribute("id"):
            event_data["id"] = parent_elt["id"]

        # name

        name_data: Dict[str, str] = {}
        event_data["name"] = name_data
        for name_elt in event_elt.elements(NS_EVENTS, "name"):
            lang = name_elt.getAttribute("xml:lang", "")
            if lang in name_data:
                raise ValueError("<name/> elements don't have distinct xml:lang")
            name_data[lang] = str(name_elt)

        if not name_data:
            raise exceptions.NotFound("<name/> element is missing")

        # start

        try:
            start_elt = next(event_elt.elements(NS_EVENTS, "start"))
        except StopIteration:
            raise exceptions.NotFound("<start/> element is missing")
        event_data["start"] = utils.parse_xmpp_date(str(start_elt))

        # end

        try:
            end_elt = next(event_elt.elements(NS_EVENTS, "end"))
        except StopIteration:
            raise exceptions.NotFound("<end/> element is missing")
        event_data["end"] = utils.parse_xmpp_date(str(end_elt))

        # head-picture

        head_pic_elt = next(event_elt.elements(NS_EVENTS, "head-picture"), None)
        if head_pic_elt is not None:
            event_data["head-picture"] = self._sfs.parse_file_sharing_elt(head_pic_elt)

        # description

        seen_desc = set()
        for description_elt in event_elt.elements(NS_EVENTS, "description"):
            lang = description_elt.getAttribute("xml:lang", "")
            desc_type = description_elt.getAttribute("type", "text")
            lang_type = (lang, desc_type)
            if lang_type in seen_desc:
                raise ValueError(
                    "<description/> elements don't have distinct xml:lang/type"
                )
            seen_desc.add(lang_type)
            descriptions = event_data.setdefault("descriptions", [])
            description_data = {"description": str(description_elt)}
            if lang:
                description_data["language"] = lang
            if desc_type:
                description_data["type"] = desc_type
            descriptions.append(description_data)

        # categories

        for category_elt in event_elt.elements(NS_EVENTS, "category"):
            try:
                category_data = {
                    "term": category_elt["term"]
                }
            except KeyError:
                log.warning(
                    "<category/> element is missing mandatory term: "
                    f"{category_elt.toXml()}"
                )
                continue
            wd = category_elt.getAttribute("wd")
            if wd:
                category_data["wikidata_id"] = wd
            lang = category_elt.getAttribute("xml:lang")
            if lang:
                category_data["language"] = lang
            event_data.setdefault("categories", []).append(category_data)

        # locations

        seen_location_ids = set()
        for location_elt in event_elt.elements(NS_EVENTS, "location"):
            location_id = location_elt.getAttribute("id", "")
            if location_id in seen_location_ids:
                raise ValueError("<location/> elements don't have distinct IDs")
            seen_location_ids.add(location_id)
            location_data = self._g.parse_geoloc_elt(location_elt)
            if location_id:
                location_data["id"] = location_id
            lang = location_elt.getAttribute("xml:lang", "")
            if lang:
                location_data["language"] = lang
            event_data.setdefault("locations", []).append(location_data)

        # RSVPs

        seen_rsvp_lang = set()
        for rsvp_elt in event_elt.elements(NS_EVENTS, "rsvp"):
            rsvp_lang = rsvp_elt.getAttribute("xml:lang", "")
            if rsvp_lang in seen_rsvp_lang:
                raise ValueError("<rsvp/> elements don't have distinct xml:lang")
            seen_rsvp_lang.add(rsvp_lang)
            rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP)
            if rsvp_form is None:
                log.warning(f"RSVP form is missing: {rsvp_elt.toXml()}")
                continue
            rsvp_data = xml_tools.data_form_2_data_dict(rsvp_form)
            if rsvp_lang:
                rsvp_data["language"] = rsvp_lang
            event_data.setdefault("rsvp", []).append(rsvp_data)

        # linked pubsub nodes

        for name in ("invitees", "comments", "blog", "schedule"):
            elt = next(event_elt.elements(NS_EVENTS, name), None)
            if elt is not None:
                try:
                    event_data[name] = {
                        "service": elt["jid"],
                        "node": elt["node"]
                    }
                except KeyError:
                    log.warning(f"invalid {name} element: {elt.toXml()}")

        # attachments

        attachments_elt = next(event_elt.elements(NS_EVENTS, "attachments"), None)
        if attachments_elt:
            attachments = event_data["attachments"] = []
            for file_sharing_elt in attachments_elt.elements(
                    self._sfs.namespace, "file-sharing"):
                try:
                    file_sharing_data = self._sfs.parse_file_sharing_elt(file_sharing_elt)
                except Exception as e:
                    log.warning(f"invalid attachment: {e}\n{file_sharing_elt.toXml()}")
                    continue
                attachments.append(file_sharing_data)

        # extra

        extra_elt = next(event_elt.elements(NS_EVENTS, "extra"), None)
        if extra_elt is not None:
            extra_form = data_form.findForm(extra_elt, NS_EXTRA)
            if extra_form is None:
                log.warning(f"extra form is missing: {extra_elt.toXml()}")
            else:
                extra_data = event_data["extra"] = {}
                for name, value in extra_form.items():
                    if name.startswith("accessibility:"):
                        extra_data.setdefault("accessibility", {})[name[14:]] = value
                    elif name == "accessibility":
                        log.warning(
                            'ignoring "accessibility" key which is not standard: '
                            f"{extra_form.toElement().toXml()}"
                        )
                    else:
                        extra_data[name] = value

        # external

        external_elt = next(event_elt.elements(NS_EVENTS, "external"), None)
        if external_elt:
            try:
                event_data["external"] = {
                    "jid": external_elt["jid"],
                    "node": external_elt["node"],
                    "item": external_elt["item"]
                }
            except KeyError:
                log.warning(f"invalid <external/> element: {external_elt.toXml()}")

        return event_data

    def _events_get(
            self, service: str, node: str, event_ids: List[str], extra: str, profile_key: str
    ):
        client = self.host.get_client(profile_key)
        d = defer.ensureDeferred(
            self.events_get(
                client,
                jid.JID(service) if service else None,
                node if node else NS_EVENTS,
                event_ids,
                data_format.deserialise(extra)
            )
        )
        d.addCallback(data_format.serialise)
        return d

    async def events_get(
        self,
        client: SatXMPPEntity,
        service: Optional[jid.JID],
        node: str = NS_EVENTS,
        events_ids: Optional[List[str]] = None,
        extra: Optional[dict] = None,
    ) -> List[Dict[str, Any]]:
        """Retrieve event data

        @param service: pubsub service
        @param node: pubsub node
        @param event_id: pubsub item ID
        @return: event data:
        """
        if service is None:
            service = client.jid.userhostJID()
        items, __ = await self._p.get_items(
            client, service, node, item_ids=events_ids, extra=extra
        )
        events = []
        for item in items:
            try:
                events.append(self.event_elt_2_event_data((item)))
            except (ValueError, exceptions.NotFound):
                log.warning(
                    f"Can't parse event for item {item['id']}: {item.toXml()}"
                )

        return events

    def _event_create(
        self,
        data_s: str,
        service: str,
        node: str,
        event_id: str = "",
        profile_key: str = C.PROF_KEY_NONE
    ):
        client = self.host.get_client(profile_key)
        return defer.ensureDeferred(
            self.event_create(
                client,
                data_format.deserialise(data_s),
                jid.JID(service) if service else None,
                node or None,
                event_id or None
            )
        )

    def event_data_2_event_elt(self, event_data: Dict[str, Any]) -> domish.Element:
        """Convert Event Data to corresponding Element

        @param event_data: data of the event with keys as follow:
            name (dict)
                map of language to name
                empty string can be used as key if no language is specified
                this key is mandatory
            start (int|float)
                starting time of the event
                this key is mandatory
            end (int|float)
                ending time of the event
                this key is mandatory
            head-picture(dict)
                file sharing data for the main picture to use to represent the event
            description(list[dict])
                list of descriptions. If there are several descriptions, they must have
                distinct (language, type).
                Description data is dict which following keys:
                    description(str)
                        the description itself, either in plain text or xhtml
                        this key is mandatory
                    language(str)
                        ISO-639 language code
                    type(str)
                        type of the description, either "text" (default) or "xhtml"
            categories(list[dict])
                each category is a dict with following keys:
                    term(str)
                        human readable short text of the category
                        this key is mandatory
                    wikidata_id(str)
                        Entity ID from WikiData
                    language(str)
                        ISO-639 language code
            locations(list[dict])
                list of location dict as used in plugin XEP-0080 [get_geoloc_elt].
                If several locations are used, they must have distinct IDs
            rsvp(list[dict])
                RSVP data. The dict is a data dict as used in
                sat.tools.xml_tools.data_dict_2_data_form with some extra keys.
                The "attending" key is automatically added if it's not already present,
                except if the "no_default" key is present. Thus, an empty dict can be used
                to use default RSVP.
                If several dict are present in the list, they must have different "lang"
                keys.
                Following extra key can be used:
                    language(str)
                        ISO-639 code for language used in the form
                    no_default(bool)
                        if True, the "attending" field won't be automatically added
            invitees(dict)
                link to pubsub node holding invitees list.
                Following keys are mandatory:
                    service(str)
                        pubsub service where the node is
                    node (str)
                        pubsub node to use
            comments(dict)
                link to pubsub node holding XEP-0277 comments on the event itself.
                Following keys are mandatory:
                    service(str)
                        pubsub service where the node is
                    node (str)
                        pubsub node to use
            blog(dict)
                link to pubsub node holding a blog about the event.
                Following keys are mandatory:
                    service(str)
                        pubsub service where the node is
                    node (str)
                        pubsub node to use
            schedule(dict)
                link to pubsub node holding an events node describing the schedule of this
                event.
                Following keys are mandatory:
                    service(str)
                        pubsub service where the node is
                    node (str)
                        pubsub node to use
            attachments[list[dict]]
                list of file sharing data about all kind of attachments of interest for
                the event.
            extra(dict)
                extra information about the event.
                Keys can be:
                    website(str)
                        main website about the event
                    status(str)
                        status of the event.
                        Can be one of "confirmed", "tentative" or "cancelled"
                    languages(list[str])
                        ISO-639 codes for languages which will be mainly spoken at the
                        event
                    accessibility(dict)
                        accessibility informations.
                        Keys can be:
                            wheelchair
                                tell if the event is accessible to wheelchair.
                                Value can be "full", "partial" or "no"
            external(dict):
                if present, this event is a link to an external one.
                Keys (all mandatory) are:
                    jid: pubsub service
                    node: pubsub node
                    item: event id
        @return: Event element
        @raise ValueError: some expected data were missing or incorrect
        """
        event_elt = domish.Element((NS_EVENTS, "event"))
        try:
            for lang, name in event_data["name"].items():
                name_elt = event_elt.addElement("name", content=name)
                if lang:
                    name_elt["xml:lang"] = lang
        except (KeyError, TypeError):
            raise ValueError('"name" field is not a dict mapping language to event name')
        try:
            event_elt.addElement("start", content=utils.xmpp_date(event_data["start"]))
            event_elt.addElement("end", content=utils.xmpp_date(event_data["end"]))
        except (KeyError, TypeError, ValueError):
            raise ValueError('"start" and "end" fields are mandatory')

        if "head-picture" in event_data:
            head_pic_data = event_data["head-picture"]
            head_picture_elt = event_elt.addElement("head-picture")
            head_picture_elt.addChild(self._sfs.get_file_sharing_elt(**head_pic_data))

        seen_desc = set()
        if "descriptions" in event_data:
            for desc_data in event_data["descriptions"]:
                desc_type = desc_data.get("type", "text")
                lang = desc_data.get("language") or ""
                lang_type = (lang, desc_type)
                if lang_type in seen_desc:
                    raise ValueError(
                        '"xml:lang" and "type" is not unique among descriptions: '
                        f"{desc_data}"
                    )
                seen_desc.add(lang_type)
                try:
                    description = desc_data["description"]
                except KeyError:
                    log.warning(f"description is missing in {desc_data!r}")
                    continue

                if desc_type == "text":
                    description_elt = event_elt.addElement(
                        "description", content=description
                    )
                elif desc_type == "xhtml":
                    description_elt = event_elt.addElement("description")
                    div_elt = xml_tools.parse(description, namespace=C.NS_XHTML)
                    description_elt.addChild(div_elt)
                else:
                    log.warning(f"unknown description type {desc_type!r}")
                    continue
                if lang:
                    description_elt["xml:lang"] = lang
        for category_data in event_data.get("categories", []):
            try:
                category_term = category_data["term"]
            except KeyError:
                log.warning(f'"term" is missing categories data: {category_data}')
                continue
            category_elt = event_elt.addElement("category")
            category_elt["term"] = category_term
            category_wd = category_data.get("wikidata_id")
            if category_wd:
                category_elt["wd"] = category_wd
            category_lang = category_data.get("language")
            if category_lang:
                category_elt["xml:lang"] = category_lang

        seen_location_ids = set()
        for location_data in event_data.get("locations", []):
            location_id = location_data.get("id", "")
            if location_id in seen_location_ids:
                raise ValueError("locations must have distinct IDs")
            seen_location_ids.add(location_id)
            location_elt = event_elt.addElement("location")
            location_elt.addChild(self._g.get_geoloc_elt(location_data))
            if location_id:
                location_elt["id"] = location_id

        rsvp_data_list: Optional[List[dict]] = event_data.get("rsvp")
        if rsvp_data_list is not None:
            seen_lang = set()
            for rsvp_data in rsvp_data_list:
                if not rsvp_data:
                    # we use a minimum data if an empty dict is received. It will be later
                    # filled with defaut "attending" field.
                    rsvp_data = {"fields": []}
                rsvp_elt = event_elt.addElement("rsvp")
                lang = rsvp_data.get("language", "")
                if lang in seen_lang:
                    raise ValueError(
                        "If several RSVP are specified, they must have distinct "
                        f"languages. {lang!r} language has been used several times."
                    )
                seen_lang.add(lang)
                if lang:
                    rsvp_elt["xml:lang"] = lang
                if not rsvp_data.get("no_default", False):
                    try:
                        next(f for f in rsvp_data["fields"] if f["name"] == "attending")
                    except StopIteration:
                        rsvp_data["fields"].append({
                            "type": "list-single",
                            "name": "attending",
                            "label": "Attending",
                            "options": [
                                {"label": "maybe", "value": "maybe"},
                                {"label": "yes", "value": "yes"},
                                {"label": "no", "value": "no"}
                            ],
                            "required": True
                        })
                rsvp_data["namespace"] = NS_RSVP
                rsvp_form = xml_tools.data_dict_2_data_form(rsvp_data)
                rsvp_elt.addChild(rsvp_form.toElement())

        for node_type in ("invitees", "comments", "blog", "schedule"):
            node_data = event_data.get(node_type)
            if not node_data:
                continue
            try:
                service, node = node_data["service"], node_data["node"]
            except KeyError:
                log.warning(f"invalid node data for {node_type}: {node_data}")
            else:
                pub_node_elt = event_elt.addElement(node_type)
                pub_node_elt["jid"] = service
                pub_node_elt["node"] = node

        attachments = event_data.get("attachments")
        if attachments:
            attachments_elt = event_elt.addElement("attachments")
            for attachment_data in attachments:
                attachments_elt.addChild(
                    self._sfs.get_file_sharing_elt(**attachment_data)
                )

        extra = event_data.get("extra")
        if extra:
            extra_form = data_form.Form(
                "result",
                formNamespace=NS_EXTRA
            )
            for node_type in ("website", "status"):
                if node_type in extra:
                    extra_form.addField(
                        data_form.Field(var=node_type, value=extra[node_type])
                    )
            if "languages" in extra:
                extra_form.addField(
                    data_form.Field(
                        "list-multi", var="languages", values=extra["languages"]
                    )
                )
            for node_type, value in extra.get("accessibility", {}).items():
                extra_form.addField(
                    data_form.Field(var=f"accessibility:{node_type}", value=value)
                )

            extra_elt = event_elt.addElement("extra")
            extra_elt.addChild(extra_form.toElement())

        if "external" in event_data:
            external_data = event_data["external"]
            external_elt = event_elt.addElement("external")
            for node_type in ("jid", "node", "item"):
                try:
                    value = external_data[node_type]
                except KeyError:
                    raise ValueError(f"Invalid external data: {external_data}")
                external_elt[node_type] = value

        return event_elt

    async def event_create(
        self,
        client: SatXMPPEntity,
        event_data: Dict[str, Any],
        service: Optional[jid.JID] = None,
        node: Optional[str] = None,
        event_id: Optional[str] = None,
    ) -> None:
        """Create or replace an event

        @param event_data: data of the event (cf. [event_data_2_event_elt])
        @param node: PubSub node of the event
            None to use default node (default namespace for personal agenda)
        @param service: PubSub service
            None to use profile's PEP
        @param event_id: ID of the item to create.
        """
        if not service:
            service = client.jid.userhostJID()
        if not node:
            node = NS_EVENTS
        if event_id is None:
            event_id = shortuuid.uuid()
        event_elt = self.event_data_2_event_elt(event_data)

        item_elt = pubsub.Item(id=event_id, payload=event_elt)
        options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}
        await self._p.create_if_new_node(
            client, service, nodeIdentifier=node, options=options
        )
        await self._p.publish(client, service, node, items=[item_elt])
        if event_data.get("rsvp"):
            await self._a.create_attachments_node(client, service, node, event_id)

    def _event_modify(
        self,
        data_s: str,
        event_id: str,
        service: str,
        node: str,
        profile_key: str = C.PROF_KEY_NONE
    ) -> None:
        client = self.host.get_client(profile_key)
        defer.ensureDeferred(
            self.event_modify(
                client,
                data_format.deserialise(data_s),
                event_id,
                jid.JID(service) if service else None,
                node or None,
            )
        )

    async def event_modify(
        self,
        client: SatXMPPEntity,
        event_data: Dict[str, Any],
        event_id: str,
        service: Optional[jid.JID] = None,
        node: Optional[str] = None,
    ) -> None:
        """Update an event

        Similar as create instead that it update existing item instead of
        creating or replacing it. Params are the same as for [event_create].
        """
        if not service:
            service = client.jid.userhostJID()
        if not node:
            node = NS_EVENTS
        old_event = (await self.events_get(client, service, node, [event_id]))[0]
        old_event.update(event_data)
        event_data = old_event
        await self.event_create(client, event_data, service, node, event_id)

    def rsvp_get(
        self,
        client: SatXMPPEntity,
        attachments_elt: domish.Element,
        data: Dict[str, Any],
    ) -> None:
        """Get RSVP answers from attachments"""
        try:
            rsvp_elt = next(
                attachments_elt.elements(NS_EVENTS, "rsvp")
            )
        except StopIteration:
            pass
        else:
            rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP)
            if rsvp_form is not None:
                data["rsvp"] = rsvp_data = dict(rsvp_form)
                self._a.set_timestamp(rsvp_elt, rsvp_data)

    def rsvp_set(
        self,
        client: SatXMPPEntity,
        data: Dict[str, Any],
        former_elt: Optional[domish.Element]
    ) -> Optional[domish.Element]:
        """update the <reaction> attachment"""
        rsvp_data = data["extra"].get("rsvp")
        if rsvp_data is None:
            return former_elt
        elif rsvp_data:
            rsvp_elt = domish.Element(
                (NS_EVENTS, "rsvp"),
                attribs = {
                    "timestamp": utils.xmpp_date()
                }
            )
            rsvp_form = data_form.Form("submit", formNamespace=NS_RSVP)
            rsvp_form.makeFields(rsvp_data)
            rsvp_elt.addChild(rsvp_form.toElement())
            return rsvp_elt
        else:
            return None

    def _event_invitee_get(
        self,
        service: str,
        node: str,
        item: str,
        invitees: List[str],
        extra: str,
        profile_key: str
    ) -> defer.Deferred:
        client = self.host.get_client(profile_key)
        if invitees:
            invitees_jid = [jid.JID(i) for i in invitees]
        else:
            invitees_jid = None
        d = defer.ensureDeferred(
            self.event_invitee_get(
                client,
                jid.JID(service) if service else None,
                node or None,
                item,
                invitees_jid,
                data_format.deserialise(extra)
            )
        )
        d.addCallback(lambda ret: data_format.serialise(ret))
        return d

    async def event_invitee_get(
        self,
        client: SatXMPPEntity,
        service: Optional[jid.JID],
        node: Optional[str],
        item: str,
        invitees: Optional[List[jid.JID]] = None,
        extra: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Dict[str, Any]]:
        """Retrieve attendance from event node

        @param service: PubSub service
        @param node: PubSub node of the event
        @param item: PubSub item of the event
        @param invitees: if set, only retrieve RSVPs from those guests
        @param extra: extra data used to retrieve items as for [get_attachments]
        @return: mapping of invitee bare JID to their RSVP
            an empty dict is returned if nothing has been answered yed
        """
        if service is None:
            service = client.jid.userhostJID()
        if node is None:
            node = NS_EVENTS
        attachments, metadata = await self._a.get_attachments(
            client, service, node, item, invitees, extra
        )
        ret = {}
        for attachment in attachments:
            try:
                rsvp = attachment["rsvp"]
            except KeyError:
                continue
            ret[attachment["from"]] = rsvp

        return ret

    def _event_invitee_set(
        self,
        service: str,
        node: str,
        item: str,
        rsvp_s: str,
        profile_key: str
    ):
        client = self.host.get_client(profile_key)
        return defer.ensureDeferred(
            self.event_invitee_set(
                client,
                jid.JID(service) if service else None,
                node or None,
                item,
                data_format.deserialise(rsvp_s)
            )
        )

    async def event_invitee_set(
        self,
        client: SatXMPPEntity,
        service: Optional[jid.JID],
        node: Optional[str],
        item: str,
        rsvp: Dict[str, Any],
    ) -> None:
        """Set or update attendance data in event node

        @param service: PubSub service
        @param node: PubSub node of the event
        @param item: PubSub item of the event
        @param rsvp: RSVP data (values to submit to the form)
        """
        if service is None:
            service = client.jid.userhostJID()
        if node is None:
            node = NS_EVENTS
        await self._a.set_attachements(client, {
            "service": service.full(),
            "node": node,
            "id": item,
            "extra": {"rsvp": rsvp}
        })

    def _event_invitees_list(self, service, node, profile_key):
        service = jid.JID(service) if service else None
        node = node if node else NS_EVENT
        client = self.host.get_client(profile_key)
        return defer.ensureDeferred(
            self.event_invitees_list(client, service, node)
        )

    async def event_invitees_list(self, client, service, node):
        """Retrieve attendance from event node

        @param service(unicode, None): PubSub service
        @param node(unicode): PubSub node of the event
        @return (dict): a dict with current attendance status,
            an empty dict is returned if nothing has been answered yed
        """
        items, metadata = await self._p.get_items(client, service, node)
        invitees = {}
        for item in items:
            try:
                event_elt = next(item.elements(NS_EVENT, "invitee"))
            except StopIteration:
                # no item found, event data are not set yet
                log.warning(_(
                    "no data found for {item_id} (service: {service}, node: {node})"
                    .format(item_id=item["id"], service=service, node=node)))
            else:
                data = {}
                for key in ("attend", "guests"):
                    try:
                        data[key] = event_elt[key]
                    except KeyError:
                        continue
                invitees[item["id"]] = data
        return invitees

    async def invite_preflight(
        self,
        client: SatXMPPEntity,
        invitee_jid: jid.JID,
        service: jid.JID,
        node: str,
        item_id: Optional[str] = None,
        name: str = '',
        extra: Optional[dict] = None,
    ) -> None:
        if self._b is None:
            raise exceptions.FeatureNotFound(
                _('"XEP-0277" (blog) plugin is needed for this feature')
            )
        if item_id is None:
            item_id = extra["default_item_id"] = NS_EVENT

        __, event_data = await self.events_get(client, service, node, item_id)
        log.debug(_("got event data"))
        invitees_service = jid.JID(event_data["invitees_service"])
        invitees_node = event_data["invitees_node"]
        blog_service = jid.JID(event_data["blog_service"])
        blog_node = event_data["blog_node"]
        await self._p.set_node_affiliations(
            client, invitees_service, invitees_node, {invitee_jid: "publisher"}
        )
        log.debug(
            f"affiliation set on invitee node (jid: {invitees_service}, "
            f"node: {invitees_node!r})"
        )
        await self._p.set_node_affiliations(
            client, blog_service, blog_node, {invitee_jid: "member"}
        )
        blog_items, __ = await self._b.mb_get(client, blog_service, blog_node, None)

        for item in blog_items:
            try:
                comments_service = jid.JID(item["comments_service"])
                comments_node = item["comments_node"]
            except KeyError:
                log.debug(
                    "no comment service set for item {item_id}".format(
                        item_id=item["id"]
                    )
                )
            else:
                await self._p.set_node_affiliations(
                    client, comments_service, comments_node, {invitee_jid: "publisher"}
                )
        log.debug(_("affiliation set on blog and comments nodes"))

    def _invite(self, invitee_jid, service, node, item_id, profile):
        return self.host.plugins["PUBSUB_INVITATION"]._send_pubsub_invitation(
            invitee_jid, service, node, item_id or NS_EVENT, profile_key=profile
        )

    def _invite_by_email(self, service, node, id_=NS_EVENT, email="", emails_extra=None,
                       name="", host_name="", language="", url_template="",
                       message_subject="", message_body="",
                       profile_key=C.PROF_KEY_NONE):
        client = self.host.get_client(profile_key)
        kwargs = {
            "profile": client.profile,
            "emails_extra": [str(e) for e in emails_extra],
        }
        for key in (
            "email",
            "name",
            "host_name",
            "language",
            "url_template",
            "message_subject",
            "message_body",
        ):
            value = locals()[key]
            kwargs[key] = str(value)
        return defer.ensureDeferred(self.invite_by_email(
            client, jid.JID(service) if service else None, node, id_ or NS_EVENT, **kwargs
        ))

    async def invite_by_email(self, client, service, node, id_=NS_EVENT, **kwargs):
        """High level method to create an email invitation to an event

        @param service(unicode, None): PubSub service
        @param node(unicode): PubSub node of the event
        @param id_(unicode): id_ with even data
        """
        if self._i is None:
            raise exceptions.FeatureNotFound(
                _('"Invitations" plugin is needed for this feature')
            )
        if self._b is None:
            raise exceptions.FeatureNotFound(
                _('"XEP-0277" (blog) plugin is needed for this feature')
            )
        service = service or client.jid.userhostJID()
        event_uri = xmpp_uri.build_xmpp_uri(
            "pubsub", path=service.full(), node=node, item=id_
        )
        kwargs["extra"] = {"event_uri": event_uri}
        invitation_data = await self._i.create(**kwargs)
        invitee_jid = invitation_data["jid"]
        log.debug(_("invitation created"))
        # now that we have a jid, we can send normal invitation
        await self.invite(client, invitee_jid, service, node, id_)

    def on_invitation_preflight(
        self,
        client: SatXMPPEntity,
        name: str,
        extra: dict,
        service: jid.JID,
        node: str,
        item_id: Optional[str],
        item_elt: domish.Element
    ) -> None:
        event_elt = item_elt.event
        link_elt = event_elt.addElement("link")
        link_elt["service"] = service.full()
        link_elt["node"] = node
        link_elt["item"] = item_id
        __, event_data = self._parse_event_elt(event_elt)
        try:
            name = event_data["name"]
        except KeyError:
            pass
        else:
            extra["name"] = name
        if 'image' in event_data:
            extra["thumb_url"] = event_data['image']
        extra["element"] = event_elt


@implementer(iwokkel.IDisco)
class EventsHandler(XMPPHandler):

    def __init__(self, plugin_parent):
        self.plugin_parent = plugin_parent

    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
        return [
            disco.DiscoFeature(NS_EVENTS),
        ]

    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
        return []