comparison sat/plugins/plugin_comp_ap_gateway/events.py @ 3904:0aa7023dcd08

component AP gateway: events: - XMPP Events <=> AP Events conversion - `Join`/`Leave` activities are converted to RSVP attachments and vice versa - fix caching/notification on item published on a virtual pubsub node - add Ad-Hoc command to convert XMPP Jid/Node to virtual AP Account - handle `Update` activity - on `convertAndPostItems`, `Update` activity is used instead of `Create` if a version of the item is already present in cache - `events` field is added to actor data (and to `endpoints`), it links the `outbox` of the actor mapping the same JID with the Events node (i.e. it links to the Events node of the entity) - fix subscription to nodes which are not the microblog one rel 372
author Goffi <goffi@goffi.org>
date Thu, 22 Sep 2022 00:01:41 +0200
parents
children 78b5f356900c
comparison
equal deleted inserted replaced
3903:384b7e6c2dbf 3904:0aa7023dcd08
1 #!/usr/bin/env python3
2
3 # Libervia ActivityPub Gateway
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from typing import Tuple
20
21 import mimetypes
22 import html
23
24 import shortuuid
25 from twisted.words.xish import domish
26 from twisted.words.protocols.jabber import jid
27
28 from sat.core.i18n import _
29 from sat.core.log import getLogger
30 from sat.core import exceptions
31 from sat.tools.common import date_utils, uri
32
33 from .constants import NS_AP_PUBLIC, TYPE_ACTOR, TYPE_EVENT, TYPE_ITEM
34
35
36 log = getLogger(__name__)
37
38 # direct copy of what Mobilizon uses
39 AP_EVENTS_CONTEXT = {
40 "@language": "und",
41 "Hashtag": "as:Hashtag",
42 "PostalAddress": "sc:PostalAddress",
43 "PropertyValue": "sc:PropertyValue",
44 "address": {"@id": "sc:address", "@type": "sc:PostalAddress"},
45 "addressCountry": "sc:addressCountry",
46 "addressLocality": "sc:addressLocality",
47 "addressRegion": "sc:addressRegion",
48 "anonymousParticipationEnabled": {"@id": "mz:anonymousParticipationEnabled",
49 "@type": "sc:Boolean"},
50 "category": "sc:category",
51 "commentsEnabled": {"@id": "pt:commentsEnabled",
52 "@type": "sc:Boolean"},
53 "discoverable": "toot:discoverable",
54 "discussions": {"@id": "mz:discussions", "@type": "@id"},
55 "events": {"@id": "mz:events", "@type": "@id"},
56 "ical": "http://www.w3.org/2002/12/cal/ical#",
57 "inLanguage": "sc:inLanguage",
58 "isOnline": {"@id": "mz:isOnline", "@type": "sc:Boolean"},
59 "joinMode": {"@id": "mz:joinMode", "@type": "mz:joinModeType"},
60 "joinModeType": {"@id": "mz:joinModeType",
61 "@type": "rdfs:Class"},
62 "location": {"@id": "sc:location", "@type": "sc:Place"},
63 "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
64 "maximumAttendeeCapacity": "sc:maximumAttendeeCapacity",
65 "memberCount": {"@id": "mz:memberCount", "@type": "sc:Integer"},
66 "members": {"@id": "mz:members", "@type": "@id"},
67 "mz": "https://joinmobilizon.org/ns#",
68 "openness": {"@id": "mz:openness", "@type": "@id"},
69 "participantCount": {"@id": "mz:participantCount",
70 "@type": "sc:Integer"},
71 "participationMessage": {"@id": "mz:participationMessage",
72 "@type": "sc:Text"},
73 "postalCode": "sc:postalCode",
74 "posts": {"@id": "mz:posts", "@type": "@id"},
75 "propertyID": "sc:propertyID",
76 "pt": "https://joinpeertube.org/ns#",
77 "remainingAttendeeCapacity": "sc:remainingAttendeeCapacity",
78 "repliesModerationOption": {"@id": "mz:repliesModerationOption",
79 "@type": "mz:repliesModerationOptionType"},
80 "repliesModerationOptionType": {"@id": "mz:repliesModerationOptionType",
81 "@type": "rdfs:Class"},
82 "resources": {"@id": "mz:resources", "@type": "@id"},
83 "sc": "http://schema.org#",
84 "streetAddress": "sc:streetAddress",
85 "timezone": {"@id": "mz:timezone", "@type": "sc:Text"},
86 "todos": {"@id": "mz:todos", "@type": "@id"},
87 "toot": "http://joinmastodon.org/ns#",
88 "uuid": "sc:identifier",
89 "value": "sc:value"
90 }
91
92
93 class APEvents:
94 """XMPP Events <=> AP Events conversion"""
95
96 def __init__(self, apg):
97 self.host = apg.host
98 self.apg = apg
99 self._events = self.host.plugins["EVENTS"]
100
101 async def event_data_2_ap_item(
102 self, event_data: dict, author_jid: jid.JID, is_new: bool=True
103 ) -> dict:
104 """Convert event data to AP activity
105
106 @param event_data: event data as used in [plugin_exp_events]
107 @param author_jid: jid of the published of the event
108 @param is_new: if True, the item is a new one (no instance has been found in
109 cache).
110 If True, a "Create" activity will be generated, otherwise an "Update" one will
111 be
112 @return: AP activity wrapping an Event object
113 """
114 if not event_data.get("id"):
115 event_data["id"] = shortuuid.uuid()
116 ap_account = await self.apg.getAPAccountFromJidAndNode(
117 author_jid,
118 self._events.namespace
119 )
120 url_actor = self.apg.buildAPURL(TYPE_ACTOR, ap_account)
121 url_item = self.apg.buildAPURL(TYPE_ITEM, ap_account, event_data["id"])
122 ap_object = {
123 "actor": url_actor,
124 "attributedTo": url_actor,
125 "to": [NS_AP_PUBLIC],
126 "id": url_item,
127 "type": TYPE_EVENT,
128 "name": next(iter(event_data["name"].values())),
129 "startTime": date_utils.date_fmt(event_data["start"], "iso"),
130 "endTime": date_utils.date_fmt(event_data["end"], "iso"),
131 "url": url_item,
132 }
133
134 attachment = ap_object["attachment"] = []
135
136 # FIXME: we only handle URL head-picture for now
137 # TODO: handle jingle and use file metadata
138 try:
139 head_picture_url = event_data["head-picture"]["sources"][0]["url"]
140 except (KeyError, IndexError, TypeError):
141 pass
142 else:
143 media_type = mimetypes.guess_type(head_picture_url, False)[0] or "image/jpeg"
144 attachment.append({
145 "name": "Banner",
146 "type": "Document",
147 "mediaType": media_type,
148 "url": head_picture_url,
149 })
150
151 descriptions = event_data.get("descriptions")
152 if descriptions:
153 for description in descriptions:
154 content = description["description"]
155 if description["type"] == "xhtml":
156 break
157 else:
158 content = f"<p>{html.escape(content)}</p>" # type: ignore
159 ap_object["content"] = content
160
161 categories = event_data.get("categories")
162 if categories:
163 tag = ap_object["tag"] = []
164 for category in categories:
165 tag.append({
166 "name": f"#{category['term']}",
167 "type": "Hashtag",
168 })
169
170 locations = event_data.get("locations")
171 if locations:
172 ap_loc = ap_object["location"] = {}
173 # we only use the first found location
174 location = locations[0]
175 for source, dest in (
176 ("description", "name"),
177 ("lat", "latitude"),
178 ("lon", "longitude"),
179 ):
180 value = location.get(source)
181 if value is not None:
182 ap_loc[dest] = value
183 for source, dest in (
184 ("country", "addressCountry"),
185 ("locality", "addressLocality"),
186 ("region", "addressRegion"),
187 ("postalcode", "postalCode"),
188 ("street", "streetAddress"),
189 ):
190 value = location.get(source)
191 if value is not None:
192 ap_loc.setdefault("address", {})[dest] = value
193
194 if event_data.get("comments"):
195 ap_object["commentsEnabled"] = True
196
197 extra = event_data.get("extra")
198
199 if extra:
200 status = extra.get("status")
201 if status:
202 ap_object["ical:status"] = status.upper()
203
204 website = extra.get("website")
205 if website:
206 attachment.append({
207 "href": website,
208 "mediaType": "text/html",
209 "name": "Website",
210 "type": "Link"
211 })
212
213 accessibility = extra.get("accessibility")
214 if accessibility:
215 wheelchair = accessibility.get("wheelchair")
216 if wheelchair:
217 if wheelchair == "full":
218 ap_wc_value = "fully"
219 elif wheelchair == "partial":
220 ap_wc_value = "partially"
221 elif wheelchair == "no":
222 ap_wc_value = "no"
223 else:
224 log.error(f"unexpected wheelchair value: {wheelchair}")
225 ap_wc_value = None
226 if ap_wc_value is not None:
227 attachment.append({
228 "propertyID": "mz:accessibility:wheelchairAccessible",
229 "type": "PropertyValue",
230 "value": ap_wc_value
231 })
232
233 activity = self.apg.createActivity(
234 "Create" if is_new else "Update", url_actor, ap_object, activity_id=url_item
235 )
236 activity["@context"].append(AP_EVENTS_CONTEXT)
237 return activity
238
239 async def ap_item_2_event_data(self, ap_item: dict) -> dict:
240 """Convert AP activity or object to event data
241
242 @param ap_item: ActivityPub item to convert
243 Can be either an activity of an object
244 @return: AP Item's Object and event data
245 @raise exceptions.DataError: something is invalid in the AP item
246 """
247 is_activity = self.apg.is_activity(ap_item)
248 if is_activity:
249 ap_object = await self.apg.apGetObject(ap_item, "object")
250 if not ap_object:
251 log.warning(f'No "object" found in AP item {ap_item!r}')
252 raise exceptions.DataError
253 else:
254 ap_object = ap_item
255
256 # id
257 if "_repeated" in ap_item:
258 # if the event is repeated, we use the original one ID
259 repeated_uri = ap_item["_repeated"]["uri"]
260 parsed_uri = uri.parseXMPPUri(repeated_uri)
261 object_id = parsed_uri["item"]
262 else:
263 object_id = ap_object.get("id")
264 if not object_id:
265 raise exceptions.DataError('"id" is missing in AP object')
266
267 if ap_item["type"] != TYPE_EVENT:
268 raise exceptions.DataError("AP Object is not an event")
269
270 # author
271 actor = await self.apg.apGetSenderActor(ap_object)
272
273 account = await self.apg.getAPAccountFromId(actor)
274 author_jid = self.apg.getLocalJIDFromAccount(account).full()
275
276 # name, start, end
277 event_data = {
278 "id": object_id,
279 "name": {"": ap_object.get("name") or "unnamed"},
280 "start": date_utils.date_parse(ap_object["startTime"]),
281 "end": date_utils.date_parse(ap_object["endTime"]),
282 }
283
284 # attachments/extra
285 event_data["extra"] = extra = {}
286 attachments = ap_object.get("attachment") or []
287 for attachment in attachments:
288 name = attachment.get("name")
289 if name == "Banner":
290 try:
291 url = attachment["url"]
292 except KeyError:
293 log.warning(f"invalid attachment: {attachment}")
294 continue
295 event_data["head-picture"] = {"sources": [{"url": url}]}
296 elif name == "Website":
297 try:
298 url = attachment["href"]
299 except KeyError:
300 log.warning(f"invalid attachment: {attachment}")
301 continue
302 extra["website"] = url
303 else:
304 log.debug(f"unmanaged attachment: {attachment}")
305
306 # description
307 content = ap_object.get("content")
308 if content:
309 event_data["descriptions"] = [{
310 "type": "xhtml",
311 "description": content
312 }]
313
314 # categories
315 tags = ap_object.get("tag")
316 if tags:
317 categories = event_data["categories"] = []
318 for tag in tags:
319 if tag.get("type") == "Hashtag":
320 try:
321 term = tag["name"][1:]
322 except KeyError:
323 log.warning(f"invalid tag: {tag}")
324 continue
325 categories.append({"term": term})
326
327 #location
328 ap_location = ap_object.get("location")
329 if ap_location:
330 location = {}
331 for source, dest in (
332 ("name", "description"),
333 ("latitude", "lat"),
334 ("longitude", "lon"),
335 ):
336 value = ap_location.get(source)
337 if value is not None:
338 location[dest] = value
339 address = ap_location.get("address")
340 if address:
341 for source, dest in (
342 ("addressCountry", "country"),
343 ("addressLocality", "locality"),
344 ("addressRegion", "region"),
345 ("postalCode", "postalcode"),
346 ("streetAddress", "street"),
347 ):
348 value = address.get(source)
349 if value is not None:
350 location[dest] = value
351 if location:
352 event_data["locations"] = [location]
353
354 # rsvp
355 # So far Mobilizon seems to only handle participate/don't participate, thus we use
356 # a simple "yes"/"no" form.
357 rsvp_data = {"fields": []}
358 event_data["rsvp"] = [rsvp_data]
359 rsvp_data["fields"].append({
360 "type": "list-single",
361 "name": "attending",
362 "label": "Attending",
363 "options": [
364 {"label": "yes", "value": "yes"},
365 {"label": "no", "value": "no"}
366 ],
367 "required": True
368 })
369
370 # comments
371
372 if ap_object.get("commentsEnabled"):
373 __, comments_node = await self.apg.getCommentsNodes(object_id, None)
374 event_data["comments"] = {
375 "service": author_jid,
376 "node": comments_node,
377 }
378
379 # extra
380 # part of extra come from "attachment" above
381
382 status = ap_object.get("ical:status")
383 if status is None:
384 pass
385 elif status in ("CONFIRMED", "CANCELLED", "TENTATIVE"):
386 extra["status"] = status.lower()
387 else:
388 log.warning(f"unknown event status: {status}")
389
390 return event_data
391
392 async def ap_item_2_event_data_and_elt(
393 self,
394 ap_item: dict
395 ) -> Tuple[dict, domish.Element]:
396 """Convert AP item to parsed event data and corresponding item element"""
397 event_data = await self.ap_item_2_event_data(ap_item)
398 event_elt = self._events.event_data_2_event_elt(event_data)
399 item_elt = domish.Element((None, "item"))
400 item_elt["id"] = event_data["id"]
401 item_elt.addChild(event_elt)
402 return event_data, item_elt
403
404 async def ap_item_2_event_elt(self, ap_item: dict) -> domish.Element:
405 """Convert AP item to XMPP item element"""
406 __, item_elt = await self.ap_item_2_event_data_and_elt(ap_item)
407 return item_elt