3904
+ − 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