# HG changeset patch # User Goffi # Date 1680187661 -7200 # Node ID 26c3e1bc7fb72f160b808f0fa5c8761b73c3b0b4 # Parent fe4725bf42fb3f9b47ef8831b907a27dfdc1fb80 plugin XEP-0471: renamed "events" plugin to XEP-0471 now that there is a XEP diff -r fe4725bf42fb -r 26c3e1bc7fb7 sat/plugins/plugin_comp_ap_gateway/__init__.py --- a/sat/plugins/plugin_comp_ap_gateway/__init__.py Fri Mar 24 10:29:48 2023 +0100 +++ b/sat/plugins/plugin_comp_ap_gateway/__init__.py Thu Mar 30 16:47:41 2023 +0200 @@ -109,7 +109,7 @@ C.PI_DEPENDENCIES: [ "XEP-0050", "XEP-0054", "XEP-0060", "XEP-0084", "XEP-0106", "XEP-0277", "XEP-0292", "XEP-0329", "XEP-0372", "XEP-0424", "XEP-0465", "XEP-0470", - "XEP-0447", "PUBSUB_CACHE", "TEXT_SYNTAXES", "IDENTITY", "EVENTS" + "XEP-0447", "XEP-0471", "PUBSUB_CACHE", "TEXT_SYNTAXES", "IDENTITY" ], C.PI_RECOMMENDATIONS: [], C.PI_MAIN: "APGateway", @@ -150,7 +150,7 @@ self._c = host.plugins["PUBSUB_CACHE"] self._t = host.plugins["TEXT_SYNTAXES"] self._i = host.plugins["IDENTITY"] - self._events = host.plugins["EVENTS"] + self._events = host.plugins["XEP-0471"] self._p.addManagedNode( "", items_cb=self._itemsReceived, diff -r fe4725bf42fb -r 26c3e1bc7fb7 sat/plugins/plugin_comp_ap_gateway/events.py --- a/sat/plugins/plugin_comp_ap_gateway/events.py Fri Mar 24 10:29:48 2023 +0100 +++ b/sat/plugins/plugin_comp_ap_gateway/events.py Thu Mar 30 16:47:41 2023 +0200 @@ -96,7 +96,7 @@ def __init__(self, apg): self.host = apg.host self.apg = apg - self._events = self.host.plugins["EVENTS"] + 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 diff -r fe4725bf42fb -r 26c3e1bc7fb7 sat/plugins/plugin_exp_events.py --- a/sat/plugins/plugin_exp_events.py Fri Mar 24 10:29:48 2023 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1173 +0,0 @@ -#!/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 . - -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 sat.core.xmpp import SatXMPPClient -from sat.core.i18n import _ -from sat.core import exceptions -from sat.core.constants import Const as C -from sat.core.log import getLogger -from sat.core.xmpp import SatXMPPEntity -from sat.core.core_types import SatXMPPEntity -from sat.tools import utils -from sat.tools import xml_tools -from sat.tools.common import uri as xmpp_uri -from sat.tools.common import date_utils -from sat.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: "EVENTS", - C.PI_TYPE: "EXP", - 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: "Events", - C.PI_HANDLER: "yes", - C.PI_DESCRIPTION: _("""XMPP Events Management"""), -} - -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 Events: - 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.registerNamespace("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.addMethod( - "eventsGet", - ".plugin", - in_sign="ssasss", - out_sign="s", - method=self._events_get, - async_=True, - ) - host.bridge.addMethod( - "eventCreate", - ".plugin", - in_sign="sssss", - out_sign="", - method=self._event_create, - async_=True, - ) - host.bridge.addMethod( - "eventModify", - ".plugin", - in_sign="sssss", - out_sign="", - method=self._event_modify, - async_=True, - ) - host.bridge.addMethod( - "eventInviteeGet", - ".plugin", - in_sign="sssasss", - out_sign="s", - method=self._event_invitee_get, - async_=True, - ) - host.bridge.addMethod( - "eventInviteeSet", - ".plugin", - in_sign="sssss", - out_sign="", - method=self._event_invitee_set, - async_=True, - ) - host.bridge.addMethod( - "eventInviteesList", - ".plugin", - in_sign="sss", - out_sign="a{sa{ss}}", - method=self._eventInviteesList, - async_=True, - ), - host.bridge.addMethod( - "eventInvite", - ".plugin", - in_sign="sssss", - out_sign="", - method=self._invite, - async_=True, - ) - host.bridge.addMethod( - "eventInviteByEmail", - ".plugin", - in_sign="ssssassssssss", - out_sign="", - method=self._inviteByEmail, - async_=True, - ) - - def getHandler(self, client): - return EventsHandler(self) - - def _parseEventElt(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.parseXMPPUri(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 element to event data - - @param event_elt: element - parent 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(" 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(" elements don't have distinct xml:lang") - name_data[lang] = str(name_elt) - - if not name_data: - raise exceptions.NotFound(" element is missing") - - # start - - try: - start_elt = next(event_elt.elements(NS_EVENTS, "start")) - except StopIteration: - raise exceptions.NotFound(" 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(" 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( - " 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( - " 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(" 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(" 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.dataForm2dataDict(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 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.getClient(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.getItems( - 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.getClient(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.dataDict2dataForm 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.dataDict2dataForm(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.createIfNewNode( - 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.getClient(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.setTimestamp(rsvp_elt, rsvp_data) - - def rsvp_set( - self, - client: SatXMPPEntity, - data: Dict[str, Any], - former_elt: Optional[domish.Element] - ) -> Optional[domish.Element]: - """update the 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_s: List[str], - extra: str, - profile_key: str - ) -> defer.Deferred: - client = self.host.getClient(profile_key) - if invitees_s: - invitees = [jid.JID(i) for i in invitees_s] - else: - invitees = None - d = defer.ensureDeferred( - self.event_invitee_get( - client, - jid.JID(service) if service else None, - node or None, - item, - invitees, - 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 [getAttachments] - @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.getAttachments( - 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.getClient(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 _eventInviteesList(self, service, node, profile_key): - service = jid.JID(service) if service else None - node = node if node else NS_EVENT - client = self.host.getClient(profile_key) - return defer.ensureDeferred( - self.eventInviteesList(client, service, node) - ) - - async def eventInviteesList(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.getItems(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 invitePreflight( - 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.setNodeAffiliations( - 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.setNodeAffiliations( - client, blog_service, blog_node, {invitee_jid: "member"} - ) - blog_items, __ = await self._b.mbGet(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.setNodeAffiliations( - 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"]._sendPubsubInvitation( - invitee_jid, service, node, item_id or NS_EVENT, profile_key=profile - ) - - def _inviteByEmail(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.getClient(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.inviteByEmail( - client, jid.JID(service) if service else None, node, id_ or NS_EVENT, **kwargs - )) - - async def inviteByEmail(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.buildXMPPUri( - "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 onInvitationPreflight( - 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._parseEventElt(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 [] diff -r fe4725bf42fb -r 26c3e1bc7fb7 sat/plugins/plugin_exp_invitation_pubsub.py --- a/sat/plugins/plugin_exp_invitation_pubsub.py Fri Mar 24 10:29:48 2023 +0100 +++ b/sat/plugins/plugin_exp_invitation_pubsub.py Thu Mar 30 16:47:41 2023 +0200 @@ -147,11 +147,11 @@ extra = {} try: handler = self._ns_handler[namespace] - preflight = handler.onInvitationPreflight + preflight = handler.on_invitation_preflight except KeyError: pass except AttributeError: - log.debug(f"no onInvitationPreflight method found for {namespace!r}") + log.debug(f"no on_invitation_preflight method found for {namespace!r}") else: await utils.asDeferred( preflight, diff -r fe4725bf42fb -r 26c3e1bc7fb7 sat/plugins/plugin_misc_lists.py --- a/sat/plugins/plugin_misc_lists.py Fri Mar 24 10:29:48 2023 +0100 +++ b/sat/plugins/plugin_misc_lists.py Thu Mar 30 16:47:41 2023 +0200 @@ -297,7 +297,7 @@ async_=True, ) - async def onInvitationPreflight( + async def on_invitation_preflight( self, client: SatXMPPEntity, namespace: str, diff -r fe4725bf42fb -r 26c3e1bc7fb7 sat/plugins/plugin_xep_0471.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0471.py Thu Mar 30 16:47:41 2023 +0200 @@ -0,0 +1,1173 @@ +#!/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 . + +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 sat.core.xmpp import SatXMPPClient +from sat.core.i18n import _ +from sat.core import exceptions +from sat.core.constants import Const as C +from sat.core.log import getLogger +from sat.core.xmpp import SatXMPPEntity +from sat.core.core_types import SatXMPPEntity +from sat.tools import utils +from sat.tools import xml_tools +from sat.tools.common import uri as xmpp_uri +from sat.tools.common import date_utils +from sat.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.registerNamespace("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.addMethod( + "eventsGet", + ".plugin", + in_sign="ssasss", + out_sign="s", + method=self._events_get, + async_=True, + ) + host.bridge.addMethod( + "eventCreate", + ".plugin", + in_sign="sssss", + out_sign="", + method=self._event_create, + async_=True, + ) + host.bridge.addMethod( + "eventModify", + ".plugin", + in_sign="sssss", + out_sign="", + method=self._event_modify, + async_=True, + ) + host.bridge.addMethod( + "eventInviteeGet", + ".plugin", + in_sign="sssasss", + out_sign="s", + method=self._event_invitee_get, + async_=True, + ) + host.bridge.addMethod( + "eventInviteeSet", + ".plugin", + in_sign="sssss", + out_sign="", + method=self._event_invitee_set, + async_=True, + ) + host.bridge.addMethod( + "eventInviteesList", + ".plugin", + in_sign="sss", + out_sign="a{sa{ss}}", + method=self._eventInviteesList, + async_=True, + ), + host.bridge.addMethod( + "eventInvite", + ".plugin", + in_sign="sssss", + out_sign="", + method=self._invite, + async_=True, + ) + host.bridge.addMethod( + "eventInviteByEmail", + ".plugin", + in_sign="ssssassssssss", + out_sign="", + method=self._invite_by_email, + async_=True, + ) + + def getHandler(self, client): + return EventsHandler(self) + + def _parseEventElt(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.parseXMPPUri(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 element to event data + + @param event_elt: element + parent 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(" 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(" elements don't have distinct xml:lang") + name_data[lang] = str(name_elt) + + if not name_data: + raise exceptions.NotFound(" element is missing") + + # start + + try: + start_elt = next(event_elt.elements(NS_EVENTS, "start")) + except StopIteration: + raise exceptions.NotFound(" 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(" 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( + " 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( + " 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(" 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(" 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.dataForm2dataDict(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 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.getClient(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.getItems( + 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.getClient(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.dataDict2dataForm 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.dataDict2dataForm(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.createIfNewNode( + 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.getClient(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.setTimestamp(rsvp_elt, rsvp_data) + + def rsvp_set( + self, + client: SatXMPPEntity, + data: Dict[str, Any], + former_elt: Optional[domish.Element] + ) -> Optional[domish.Element]: + """update the 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_s: List[str], + extra: str, + profile_key: str + ) -> defer.Deferred: + client = self.host.getClient(profile_key) + if invitees_s: + invitees = [jid.JID(i) for i in invitees_s] + else: + invitees = None + d = defer.ensureDeferred( + self.event_invitee_get( + client, + jid.JID(service) if service else None, + node or None, + item, + invitees, + 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 [getAttachments] + @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.getAttachments( + 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.getClient(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 _eventInviteesList(self, service, node, profile_key): + service = jid.JID(service) if service else None + node = node if node else NS_EVENT + client = self.host.getClient(profile_key) + return defer.ensureDeferred( + self.eventInviteesList(client, service, node) + ) + + async def eventInviteesList(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.getItems(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 invitePreflight( + 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.setNodeAffiliations( + 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.setNodeAffiliations( + client, blog_service, blog_node, {invitee_jid: "member"} + ) + blog_items, __ = await self._b.mbGet(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.setNodeAffiliations( + 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"]._sendPubsubInvitation( + 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.getClient(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.buildXMPPUri( + "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._parseEventElt(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 []