active-boxes 0.0.1.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,984 @@
1
+ """Core ActivityPub classes."""
2
+ import logging
3
+ import weakref
4
+ from datetime import datetime
5
+ from datetime import timezone
6
+ from enum import Enum
7
+ from typing import Any
8
+ from typing import Dict
9
+ from typing import List
10
+ from typing import Optional
11
+ from typing import Type
12
+ from typing import Union
13
+
14
+ from .backend import Backend
15
+ from .errors import ActivityGoneError
16
+ from .errors import ActivityNotFoundError
17
+ from .errors import ActivityUnavailableError
18
+ from .errors import BadActivityError
19
+ from .errors import NotAnActivityError
20
+ from .errors import Error
21
+ from .errors import UnexpectedActivityTypeError
22
+ from .key import Key
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ UninitializedBackendError = Error("a backend must be initialized")
27
+
28
+ # Helper/shortcut for typing
29
+ ObjectType = Dict[str, Any]
30
+ ActorType = Union["Person", "Application", "Group", "Organization", "Service"]
31
+ ObjectOrIDType = Union[str, ObjectType]
32
+
33
+ CTX_AS = "https://www.w3.org/ns/activitystreams"
34
+ CTX_SECURITY = "https://w3id.org/security/v1"
35
+ AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
36
+
37
+ DEFAULT_CTX = COLLECTION_CTX = [
38
+ "https://www.w3.org/ns/activitystreams",
39
+ "https://w3id.org/security/v1",
40
+ {
41
+ # AS ext
42
+ "Hashtag": "as:Hashtag",
43
+ "sensitive": "as:sensitive",
44
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
45
+ # toot
46
+ "toot": "http://joinmastodon.org/ns#",
47
+ "featured": "toot:featured",
48
+ # schema
49
+ "schema": "http://schema.org#",
50
+ "PropertyValue": "schema:PropertyValue",
51
+ "value": "schema:value",
52
+ },
53
+ ]
54
+
55
+ # Will be used to keep track of all the defined activities
56
+ _ACTIVITY_CLS: Dict["ActivityType", Type["BaseActivity"]] = {}
57
+
58
+ BACKEND: Optional[Backend] = None
59
+
60
+
61
+ def get_backend() -> Backend:
62
+ if BACKEND is None:
63
+ raise UninitializedBackendError
64
+ return BACKEND
65
+
66
+
67
+ def use_backend(backend_instance):
68
+ global BACKEND
69
+ BACKEND = backend_instance
70
+
71
+
72
+ def format_datetime(dt: datetime) -> str:
73
+ if dt.tzinfo is None:
74
+ raise ValueError("datetime must be tz aware")
75
+
76
+ return (
77
+ dt.astimezone(timezone.utc)
78
+ .replace(microsecond=0)
79
+ .isoformat()
80
+ .replace("+00:00", "Z")
81
+ )
82
+
83
+
84
+ class ActivityType(Enum):
85
+ """Supported activity `type`."""
86
+
87
+ ANNOUNCE = "Announce"
88
+ BLOCK = "Block"
89
+ LIKE = "Like"
90
+ CREATE = "Create"
91
+ UPDATE = "Update"
92
+
93
+ ORDERED_COLLECTION = "OrderedCollection"
94
+ ORDERED_COLLECTION_PAGE = "OrderedCollectionPage"
95
+ COLLECTION_PAGE = "CollectionPage"
96
+ COLLECTION = "Collection"
97
+
98
+ NOTE = "Note"
99
+ ARTICLE = "Article"
100
+ VIDEO = "Video"
101
+ AUDIO = "Audio"
102
+ DOCUMENT = "Document"
103
+
104
+ ACCEPT = "Accept"
105
+ REJECT = "Reject"
106
+ FOLLOW = "Follow"
107
+
108
+ DELETE = "Delete"
109
+ UNDO = "Undo"
110
+
111
+ IMAGE = "Image"
112
+ TOMBSTONE = "Tombstone"
113
+
114
+ # Actor types
115
+ PERSON = "Person"
116
+ APPLICATION = "Application"
117
+ GROUP = "Group"
118
+ ORGANIZATION = "Organization"
119
+ SERVICE = "Service"
120
+
121
+ # Others
122
+ MENTION = "Mention"
123
+
124
+ # Mastodon specific?
125
+ QUESTION = "Question"
126
+
127
+ # Used by Prismo
128
+ PAGE = "Page"
129
+
130
+ # Misskey uses standalone Key object
131
+ KEY = "Key"
132
+
133
+
134
+ ACTOR_TYPES = [
135
+ ActivityType.PERSON,
136
+ ActivityType.APPLICATION,
137
+ ActivityType.GROUP,
138
+ ActivityType.ORGANIZATION,
139
+ ActivityType.SERVICE,
140
+ ActivityType.QUESTION, # Mastodon notoft the end of a question with an update from that question
141
+ ]
142
+
143
+ CREATE_TYPES = [
144
+ ActivityType.NOTE,
145
+ ActivityType.ARTICLE,
146
+ ActivityType.VIDEO,
147
+ ActivityType.AUDIO,
148
+ ActivityType.QUESTION,
149
+ ActivityType.DOCUMENT,
150
+ ActivityType.PAGE,
151
+ ]
152
+
153
+ COLLECTION_TYPES = [ActivityType.COLLECTION, ActivityType.ORDERED_COLLECTION]
154
+
155
+
156
+ def parse_activity(
157
+ payload: ObjectType, expected: Optional[ActivityType] = None
158
+ ) -> "BaseActivity":
159
+ if "type" not in payload:
160
+ raise BadActivityError(f"the payload has no type: {payload!r}")
161
+
162
+ t = ActivityType(_to_list(payload["type"])[0])
163
+
164
+ if expected and t != expected:
165
+ raise UnexpectedActivityTypeError(
166
+ f'expected a {expected.name} activity, got a {payload["type"]}: {payload}'
167
+ )
168
+
169
+ if t not in _ACTIVITY_CLS:
170
+ raise BadActivityError(
171
+ f'unsupported activity type {payload["type"]}: {payload}'
172
+ )
173
+
174
+ activity = _ACTIVITY_CLS[t](**payload)
175
+
176
+ return activity
177
+
178
+
179
+ def _to_list(data: Union[List[Any], Any]) -> List[Any]:
180
+ """Helper to convert fields that can be either an object or a list of objects to a
181
+ list of object."""
182
+ if isinstance(data, list):
183
+ return data
184
+ return [data]
185
+
186
+
187
+ def clean_activity(activity: ObjectType) -> Dict[str, Any]:
188
+ """Clean the activity before rendering it.
189
+ - Remove the hidden bco and bcc field
190
+ """
191
+ for field in ["bto", "bcc", "source"]:
192
+ if field in activity:
193
+ del activity[field]
194
+ if activity["type"] == "Create" and field in activity["object"]:
195
+ del activity["object"][field]
196
+ return activity
197
+
198
+
199
+ def _get_actor_id(actor: ObjectOrIDType) -> str:
200
+ """Helper for retrieving an actor `id`."""
201
+ if isinstance(actor, dict):
202
+ return actor["id"]
203
+ return actor
204
+
205
+
206
+ def _get_id(obj) -> Optional[str]:
207
+ if obj is None:
208
+ return None
209
+ elif isinstance(obj, str):
210
+ return obj
211
+ elif isinstance(obj, dict):
212
+ try:
213
+ return obj["id"]
214
+ except KeyError:
215
+ raise ValueError(f"object is missing ID: {obj!r}")
216
+ else:
217
+ raise ValueError(f"unexpected object: {obj!r}")
218
+
219
+
220
+ def _has_type(
221
+ obj_type: Union[str, List[str]],
222
+ _types: Union[ActivityType, str, List[Union[ActivityType, str]]],
223
+ ):
224
+ """Returns `True` if one of `obj_type` equals one of `_types`."""
225
+ types_str = [
226
+ _type.value if isinstance(_type, ActivityType) else _type
227
+ for _type in _to_list(_types)
228
+ ]
229
+ for _type in _to_list(obj_type):
230
+ if _type in types_str:
231
+ return True
232
+ return False
233
+
234
+
235
+ class _ActivityMeta(type):
236
+ """Metaclass for keeping track of subclass."""
237
+
238
+ def __new__(meta, name, bases, class_dict):
239
+ cls = type.__new__(meta, name, bases, class_dict)
240
+
241
+ # Ensure the class has an activity type defined
242
+ if name != "BaseActivity" and not cls.ACTIVITY_TYPE:
243
+ raise ValueError(f"class {name} has no ACTIVITY_TYPE")
244
+
245
+ # Register it
246
+ _ACTIVITY_CLS[cls.ACTIVITY_TYPE] = cls
247
+ return cls
248
+
249
+
250
+ class BaseActivity(object, metaclass=_ActivityMeta):
251
+ """Base class for ActivityPub activities."""
252
+
253
+ ACTIVITY_TYPE: Optional[
254
+ ActivityType
255
+ ] = None # the ActivityTypeEnum the class will represent
256
+ OBJECT_REQUIRED = False # Whether the object field is required or note
257
+ ALLOWED_OBJECT_TYPES: List[ActivityType] = []
258
+ ACTOR_REQUIRED = (
259
+ True
260
+ ) # Most of the object requires an actor, so this flag in on by default
261
+
262
+ def __init__(self, **kwargs) -> None: # noqa: C901
263
+ if not self.ACTIVITY_TYPE:
264
+ raise Error("should never happen")
265
+
266
+ # Initialize the dict that will contains all the activity fields
267
+ self._data: Dict[str, Any] = {}
268
+
269
+ if not kwargs.get("type"):
270
+ self._data["type"] = self.ACTIVITY_TYPE.value
271
+ else:
272
+ atype = kwargs.pop("type")
273
+ if self.ACTIVITY_TYPE.value not in _to_list(atype):
274
+ raise UnexpectedActivityTypeError(
275
+ f"Expect the type to be {self.ACTIVITY_TYPE.value!r}"
276
+ )
277
+ self._data["type"] = atype
278
+
279
+ logger.debug(f"initializing a {self.ACTIVITY_TYPE.value} activity: {kwargs!r}")
280
+
281
+ # A place to set ephemeral data
282
+ self.__ctx: Any = {}
283
+
284
+ self.__obj: Optional["BaseActivity"] = None
285
+ self.__actor: Optional[List[ActorType]] = None
286
+
287
+ # The id may not be present for new activities
288
+ if "id" in kwargs:
289
+ self._data["id"] = kwargs.pop("id")
290
+
291
+ if self.ACTIVITY_TYPE not in ACTOR_TYPES and self.ACTOR_REQUIRED:
292
+ actor = kwargs.get("actor")
293
+ if actor:
294
+ kwargs.pop("actor")
295
+ actor = self._validate_actor(actor)
296
+ self._data["actor"] = actor
297
+ elif self.ACTIVITY_TYPE in CREATE_TYPES:
298
+ if "attributedTo" not in kwargs:
299
+ raise BadActivityError(f"Note is missing attributedTo")
300
+ else:
301
+ raise BadActivityError("missing actor")
302
+
303
+ if self.OBJECT_REQUIRED and "object" in kwargs:
304
+ obj = kwargs.pop("object")
305
+ if isinstance(obj, str):
306
+ # The object is a just a reference the its ID/IRI
307
+ # FIXME(tsileo): fetch the ref
308
+ self._data["object"] = obj
309
+ elif isinstance(obj, dict):
310
+ if not self.ALLOWED_OBJECT_TYPES:
311
+ raise UnexpectedActivityTypeError("unexpected object")
312
+ if "type" not in obj or (
313
+ self.ACTIVITY_TYPE != ActivityType.CREATE and "id" not in obj
314
+ ):
315
+ raise BadActivityError("invalid object, missing type")
316
+ if not _has_type( # type: ignore # XXX too complicated
317
+ obj["type"], self.ALLOWED_OBJECT_TYPES
318
+ ):
319
+ raise UnexpectedActivityTypeError(
320
+ f'unexpected object type {obj["type"]} (allowed={self.ALLOWED_OBJECT_TYPES!r})'
321
+ )
322
+ self._data["object"] = obj
323
+ else:
324
+ raise BadActivityError(
325
+ f"invalid object type ({type(obj).__qualname__}): {obj!r}"
326
+ )
327
+
328
+ if "@context" not in kwargs:
329
+ self._data["@context"] = CTX_AS
330
+ else:
331
+ self._data["@context"] = kwargs.pop("@context")
332
+
333
+ # @context check
334
+ if not isinstance(self._data["@context"], list):
335
+ self._data["@context"] = [self._data["@context"]]
336
+ if CTX_SECURITY not in self._data["@context"]:
337
+ self._data["@context"].append(CTX_SECURITY)
338
+ if isinstance(self._data["@context"][-1], dict):
339
+ self._data["@context"][-1]["Hashtag"] = "as:Hashtag"
340
+ self._data["@context"][-1]["sensitive"] = "as:sensitive"
341
+ self._data["@context"][-1]["toot"] = "http://joinmastodon.org/ns#"
342
+ self._data["@context"][-1]["featured"] = "toot:featured"
343
+ else:
344
+ self._data["@context"].append(
345
+ {
346
+ "Hashtag": "as:Hashtag",
347
+ "sensitive": "as:sensitive",
348
+ "toot": "http://joinmastodon.org/ns#",
349
+ "featured": "toot:featured",
350
+ }
351
+ )
352
+
353
+ # Remove keys with `None` value
354
+ valid_kwargs = {}
355
+ for k, v in kwargs.items():
356
+ if v is None:
357
+ continue
358
+ valid_kwargs[k] = v
359
+ self._data.update(**valid_kwargs)
360
+
361
+ try:
362
+ self._init()
363
+ except NotImplementedError:
364
+ pass
365
+
366
+ def _init(self) -> None:
367
+ """Optional init callback."""
368
+ raise NotImplementedError
369
+
370
+ def has_type(
371
+ self, _types: Union[ActivityType, str, List[Union[ActivityType, str]]]
372
+ ):
373
+ """Return True if the activity has the given type."""
374
+ return _has_type(self._data["type"], _types)
375
+
376
+ def get_url(self, preferred_mimetype: str = "text/html") -> str:
377
+ """Returns the url attributes as a str.
378
+
379
+ Returns the URL if it's a str, or the href of the first link.
380
+
381
+ """
382
+ if isinstance(self.url, str):
383
+ return self.url
384
+ elif isinstance(self.url, dict):
385
+ if self.url.get("type") != "Link":
386
+ raise BadActivityError(f"invalid type {self.url}")
387
+ return str(self.url.get("href"))
388
+ elif isinstance(self.url, list):
389
+ last_link = None
390
+ for link in self.url:
391
+ last_link = link
392
+ if link.get("type") != "Link":
393
+ raise BadActivityError(f"invalid type {link}")
394
+ if link.get("mimeType").startswith(preferred_mimetype):
395
+ return link.get("href")
396
+ if not last_link:
397
+ raise BadActivityError(f"invalid type for {self.url}")
398
+ return last_link
399
+ else:
400
+ raise BadActivityError(f"invalid type for {self.url}")
401
+
402
+ def ctx(self) -> Any:
403
+ if self.__ctx:
404
+ return self.__ctx()
405
+
406
+ def set_ctx(self, ctx: Any) -> None:
407
+ # FIXME(tsileo): does not use the ctx to set the id to the "parent" when building delete
408
+ self.__ctx = weakref.ref(ctx)
409
+
410
+ def __repr__(self) -> str:
411
+ """Pretty repr."""
412
+ return "{}({!r})".format(self.__class__.__qualname__, self._data.get("id"))
413
+
414
+ def __str__(self) -> str:
415
+ """Returns the ID/IRI when castign to str."""
416
+ return str(self._data.get("id", f"[new {self.ACTIVITY_TYPE} activity]"))
417
+
418
+ def __getattr__(self, name: str) -> Any:
419
+ """Allow to access the object field as regular attributes."""
420
+ if self._data.get(name):
421
+ return self._data.get(name)
422
+
423
+ def _set_id(self, uri: str, obj_id: str) -> None:
424
+ """Optional callback for subclasses to so something with a newly generated ID (for outbox activities)."""
425
+ raise NotImplementedError
426
+
427
+ def set_id(self, uri: str, obj_id: str) -> None:
428
+ """Set the ID for a new activity."""
429
+ logger.debug(f"setting ID {uri} / {obj_id}")
430
+ self._data["id"] = uri
431
+ try:
432
+ self._set_id(uri, obj_id)
433
+ except NotImplementedError:
434
+ pass
435
+
436
+ def _actor_id(self, obj: ObjectOrIDType) -> str:
437
+ if isinstance(obj, dict) and _has_type( # type: ignore
438
+ obj["type"], ACTOR_TYPES
439
+ ):
440
+ obj_id = obj.get("id")
441
+ if not obj_id:
442
+ raise BadActivityError(f"missing object id: {obj!r}")
443
+ return obj_id
444
+ elif isinstance(obj, str):
445
+ return obj
446
+ else:
447
+ raise BadActivityError(f'invalid "actor" field: {obj!r}')
448
+
449
+ def _validate_actor(self, obj: ObjectOrIDType) -> str:
450
+ if BACKEND is None:
451
+ raise UninitializedBackendError
452
+
453
+ obj_id = self._actor_id(obj)
454
+ try:
455
+ actor = BACKEND.fetch_iri(obj_id)
456
+ except (ActivityGoneError, ActivityNotFoundError):
457
+ raise
458
+ except Exception:
459
+ raise BadActivityError(f"failed to validate actor {obj!r}")
460
+
461
+ if not actor or "id" not in actor:
462
+ raise BadActivityError(f"invalid actor {actor}")
463
+
464
+ if not _has_type( # type: ignore # XXX: too complicated
465
+ actor["type"], ACTOR_TYPES
466
+ ):
467
+ raise UnexpectedActivityTypeError(f'actor has wrong type {actor["type"]!r}')
468
+
469
+ return actor["id"]
470
+
471
+ def get_object_id(self) -> str:
472
+ if BACKEND is None:
473
+ raise UninitializedBackendError
474
+
475
+ if self.__obj:
476
+ return self.__obj.id
477
+ if isinstance(self._data["object"], dict):
478
+ return self._data["object"]["id"]
479
+ elif isinstance(self._data["object"], str):
480
+ return self._data["object"]
481
+ else:
482
+ raise ValueError(f"invalid object {self._data['object']}")
483
+
484
+ def get_object(self) -> "BaseActivity":
485
+ """Returns the object as a BaseActivity instance."""
486
+ if BACKEND is None:
487
+ raise UninitializedBackendError
488
+
489
+ if self.__obj:
490
+ return self.__obj
491
+ if isinstance(self._data["object"], dict):
492
+ p = parse_activity(self._data["object"])
493
+ else:
494
+ obj = BACKEND.fetch_iri(self._data["object"])
495
+ if ActivityType(obj.get("type")) not in self.ALLOWED_OBJECT_TYPES:
496
+ raise UnexpectedActivityTypeError(
497
+ f'invalid object type {obj.get("type")!r}'
498
+ )
499
+ p = parse_activity(obj)
500
+
501
+ self.__obj = p
502
+ return p
503
+
504
+ def reset_object_cache(self) -> None:
505
+ self.__obj = None
506
+
507
+ def to_dict(
508
+ self, embed: bool = False, embed_object_id_only: bool = False
509
+ ) -> ObjectType:
510
+ """Serializes the activity back to a dict, ready to be JSON serialized."""
511
+ data = dict(self._data)
512
+ if embed:
513
+ for k in ["@context", "signature"]:
514
+ if k in data:
515
+ del data[k]
516
+ if (
517
+ data.get("object")
518
+ and embed_object_id_only
519
+ and isinstance(data["object"], dict)
520
+ ):
521
+ try:
522
+ data["object"] = data["object"]["id"]
523
+ except KeyError:
524
+ raise BadActivityError(
525
+ f'embedded object {data["object"]!r} should have an id'
526
+ )
527
+
528
+ return data
529
+
530
+ def get_actor(self) -> ActorType:
531
+ if BACKEND is None:
532
+ raise UninitializedBackendError
533
+
534
+ if self.__actor:
535
+ return self.__actor[0]
536
+
537
+ actor = self._data.get("actor")
538
+ if not actor and self.ACTOR_REQUIRED:
539
+ # Quick hack for Note objects
540
+ if self.ACTIVITY_TYPE in CREATE_TYPES:
541
+ actor = self._data.get("attributedTo")
542
+ if not actor:
543
+ raise BadActivityError(f"missing attributedTo")
544
+ else:
545
+ raise BadActivityError(f"failed to fetch actor: {self._data!r}")
546
+
547
+ self.__actor: List[ActorType] = []
548
+ for item in _to_list(actor):
549
+ if not isinstance(item, (str, dict)):
550
+ raise BadActivityError(f"invalid actor: {self._data!r}")
551
+
552
+ actor_id = self._actor_id(item)
553
+
554
+ p = parse_activity(BACKEND.fetch_iri(actor_id))
555
+ if not p.has_type(ACTOR_TYPES): # type: ignore
556
+ raise UnexpectedActivityTypeError(f"{p!r} is not an actor")
557
+ self.__actor.append(p) # type: ignore
558
+
559
+ return self.__actor[0]
560
+
561
+ def _recipients(self) -> List[str]:
562
+ return []
563
+
564
+ def recipients(self) -> List[str]: # noqa: C901
565
+ if BACKEND is None:
566
+ raise UninitializedBackendError
567
+
568
+ recipients = self._recipients()
569
+ actor_id = self.get_actor().id
570
+
571
+ out: List[str] = []
572
+ if self.type == ActivityType.CREATE.value:
573
+ out = BACKEND.extra_inboxes()
574
+
575
+ for recipient in recipients:
576
+ if recipient in [actor_id, AS_PUBLIC, None]:
577
+ continue
578
+
579
+ try:
580
+ actor = fetch_remote_activity(recipient)
581
+ except (ActivityGoneError, ActivityNotFoundError, NotAnActivityError):
582
+ logger.info(f"{recipient} is gone")
583
+ continue
584
+ except ActivityUnavailableError:
585
+ # TODO(tsileo): retry separately?
586
+ logger.info(f"failed {recipient} to fetch recipient")
587
+ continue
588
+
589
+ if actor.ACTIVITY_TYPE in ACTOR_TYPES:
590
+ if actor.endpoints:
591
+ shared_inbox = actor.endpoints.get("sharedInbox")
592
+ if shared_inbox:
593
+ if shared_inbox not in out:
594
+ out.append(shared_inbox)
595
+ continue
596
+
597
+ if actor.inbox and actor.inbox not in out:
598
+ out.append(actor.inbox)
599
+
600
+ # Is the activity a `Collection`/`OrderedCollection`?
601
+ elif actor.ACTIVITY_TYPE in COLLECTION_TYPES:
602
+ for item in BACKEND.parse_collection(actor.to_dict()):
603
+ # XXX(tsileo): is nested collection support needed here?
604
+
605
+ if item in [actor_id, AS_PUBLIC]:
606
+ continue
607
+
608
+ try:
609
+ col_actor = fetch_remote_activity(item)
610
+ except ActivityUnavailableError:
611
+ # TODO(tsileo): retry separately?
612
+ logger.info(f"failed {recipient} to fetch recipient")
613
+ continue
614
+ except (
615
+ ActivityGoneError,
616
+ ActivityNotFoundError,
617
+ NotAnActivityError,
618
+ ):
619
+ logger.info(f"{item} is gone")
620
+ continue
621
+
622
+ if col_actor.endpoints:
623
+ shared_inbox = col_actor.endpoints.get("sharedInbox")
624
+ if shared_inbox:
625
+ if shared_inbox not in out:
626
+ out.append(shared_inbox)
627
+ continue
628
+
629
+ if col_actor.inbox and col_actor.inbox not in out:
630
+ out.append(col_actor.inbox)
631
+ else:
632
+ raise BadActivityError(f"failed to parse {recipient}")
633
+
634
+ return out
635
+
636
+
637
+ class Person(BaseActivity):
638
+ ACTIVITY_TYPE = ActivityType.PERSON
639
+ OBJECT_REQUIRED = False
640
+ ACTOR_REQUIRED = False
641
+
642
+ def get_key(self) -> Key:
643
+ return Key.from_dict(self.publicKey)
644
+
645
+
646
+ class Service(Person):
647
+ ACTIVITY_TYPE = ActivityType.SERVICE
648
+
649
+
650
+ class Application(Person):
651
+ ACTIVITY_TYPE = ActivityType.APPLICATION
652
+
653
+
654
+ class Group(Person):
655
+ ACTIVITY_TYPE = ActivityType.GROUP
656
+
657
+
658
+ class Organization(Person):
659
+ ACTIVITY_TYPE = ActivityType.ORGANIZATION
660
+
661
+
662
+ class Block(BaseActivity):
663
+ ACTIVITY_TYPE = ActivityType.BLOCK
664
+ OBJECT_REQUIRED = True
665
+ ACTOR_REQUIRED = True
666
+
667
+
668
+ class Collection(BaseActivity):
669
+ ACTIVITY_TYPE = ActivityType.COLLECTION
670
+ OBJECT_REQUIRED = False
671
+ ACTOR_REQUIRED = False
672
+
673
+
674
+ class OerderedCollection(BaseActivity):
675
+ ACTIVITY_TYPE = ActivityType.ORDERED_COLLECTION
676
+ OBJECT_REQUIRED = False
677
+ ACTOR_REQUIRED = False
678
+
679
+
680
+ class Image(BaseActivity):
681
+ ACTIVITY_TYPE = ActivityType.IMAGE
682
+ OBJECT_REQUIRED = False
683
+ ACTOR_REQUIRED = False
684
+
685
+ def __repr__(self):
686
+ return "Image({!r})".format(self._data.get("url"))
687
+
688
+
689
+ class Follow(BaseActivity):
690
+ ACTIVITY_TYPE = ActivityType.FOLLOW
691
+ ALLOWED_OBJECT_TYPES = ACTOR_TYPES
692
+ OBJECT_REQUIRED = True
693
+ ACTOR_REQUIRED = True
694
+
695
+ def _recipients(self) -> List[str]:
696
+ return [self.get_object().id]
697
+
698
+ def build_undo(self) -> BaseActivity:
699
+ return Undo(object=self.to_dict(embed=True), actor=self.get_actor().id)
700
+
701
+
702
+ class Accept(BaseActivity):
703
+ ACTIVITY_TYPE = ActivityType.ACCEPT
704
+ ALLOWED_OBJECT_TYPES = [ActivityType.FOLLOW]
705
+ OBJECT_REQUIRED = True
706
+ ACTOR_REQUIRED = True
707
+
708
+ def _recipients(self) -> List[str]:
709
+ return [self.get_object().get_actor().id]
710
+
711
+
712
+ class Undo(BaseActivity):
713
+ ACTIVITY_TYPE = ActivityType.UNDO
714
+ ALLOWED_OBJECT_TYPES = [
715
+ ActivityType.FOLLOW,
716
+ ActivityType.LIKE,
717
+ ActivityType.ANNOUNCE,
718
+ ActivityType.BLOCK,
719
+ ]
720
+ OBJECT_REQUIRED = True
721
+ ACTOR_REQUIRED = True
722
+
723
+ def _recipients(self) -> List[str]:
724
+ obj = self.get_object()
725
+ if obj.ACTIVITY_TYPE == ActivityType.FOLLOW:
726
+ return [obj.get_object().id]
727
+ else:
728
+ return [obj.get_object().get_actor().id]
729
+
730
+
731
+ class Like(BaseActivity):
732
+ ACTIVITY_TYPE = ActivityType.LIKE
733
+ ALLOWED_OBJECT_TYPES = CREATE_TYPES
734
+ OBJECT_REQUIRED = True
735
+ ACTOR_REQUIRED = True
736
+
737
+ def _recipients(self) -> List[str]:
738
+ return [self.get_object().get_actor().id]
739
+
740
+ def build_undo(self) -> BaseActivity:
741
+ return Undo(
742
+ object=self.to_dict(embed=True, embed_object_id_only=True),
743
+ actor=self.get_actor().id,
744
+ )
745
+
746
+
747
+ class Announce(BaseActivity):
748
+ ACTIVITY_TYPE = ActivityType.ANNOUNCE
749
+ ALLOWED_OBJECT_TYPES = CREATE_TYPES
750
+ OBJECT_REQUIRED = True
751
+ ACTOR_REQUIRED = True
752
+
753
+ def _recipients(self) -> List[str]:
754
+ recipients = [self.get_object().get_actor().id]
755
+
756
+ for field in ["to", "cc"]:
757
+ if field in self._data:
758
+ recipients.extend(_to_list(self._data[field]))
759
+
760
+ return list(set(recipients))
761
+
762
+ def build_undo(self) -> BaseActivity:
763
+ return Undo(actor=self.get_actor().id, object=self.to_dict(embed=True))
764
+
765
+
766
+ class Delete(BaseActivity):
767
+ ACTIVITY_TYPE = ActivityType.DELETE
768
+ ALLOWED_OBJECT_TYPES = CREATE_TYPES + ACTOR_TYPES + [ActivityType.TOMBSTONE]
769
+ OBJECT_REQUIRED = True
770
+
771
+ def _get_actual_object(self) -> BaseActivity:
772
+ if BACKEND is None:
773
+ raise UninitializedBackendError
774
+
775
+ # XXX(tsileo): overrides get_object instead?
776
+ obj = self.get_object()
777
+ if (
778
+ obj.id.startswith(BACKEND.base_url())
779
+ and obj.ACTIVITY_TYPE == ActivityType.TOMBSTONE
780
+ ):
781
+ obj = parse_activity(BACKEND.fetch_iri(obj.id))
782
+ if obj.ACTIVITY_TYPE == ActivityType.TOMBSTONE:
783
+ # If we already received it, we may be able to get a copy
784
+ better_obj = BACKEND.fetch_iri(obj.id)
785
+ if better_obj:
786
+ return parse_activity(better_obj)
787
+ return obj
788
+
789
+ def _recipients(self) -> List[str]:
790
+ obj = self._get_actual_object()
791
+ return obj._recipients()
792
+
793
+
794
+ class Update(BaseActivity):
795
+ ACTIVITY_TYPE = ActivityType.UPDATE
796
+ ALLOWED_OBJECT_TYPES = CREATE_TYPES + ACTOR_TYPES
797
+ OBJECT_REQUIRED = True
798
+ ACTOR_REQUIRED = True
799
+
800
+ def _recipients(self) -> List[str]:
801
+ # TODO(tsileo): audience support?
802
+ recipients = []
803
+ for field in ["to", "cc", "bto", "bcc"]:
804
+ if field in self._data:
805
+ recipients.extend(_to_list(self._data[field]))
806
+
807
+ recipients.extend(self.get_object()._recipients())
808
+
809
+ return recipients
810
+
811
+
812
+ class Create(BaseActivity):
813
+ ACTIVITY_TYPE = ActivityType.CREATE
814
+ ALLOWED_OBJECT_TYPES = CREATE_TYPES
815
+ OBJECT_REQUIRED = True
816
+ ACTOR_REQUIRED = True
817
+
818
+ def is_public(self) -> bool:
819
+ """Returns True if the activity is addressed to the special "public" collection."""
820
+ for field in ["to", "cc", "bto", "bcc"]:
821
+ if field in self._data:
822
+ if AS_PUBLIC in _to_list(self._data[field]):
823
+ return True
824
+
825
+ return False
826
+
827
+ def _set_id(self, uri: str, obj_id: str) -> None:
828
+ if BACKEND is None:
829
+ raise UninitializedBackendError
830
+
831
+ # FIXME(tsileo): add a BACKEND.note_activity_url, and pass the actor to both
832
+ self._data["object"]["id"] = uri + "/activity"
833
+ if "url" not in self._data["object"]:
834
+ self._data["object"]["url"] = BACKEND.note_url(obj_id)
835
+ if isinstance(self.ctx(), Note):
836
+ try:
837
+ self.ctx().id = self._data["object"]["id"]
838
+ except NotImplementedError:
839
+ pass
840
+ self.reset_object_cache()
841
+
842
+ def _init(self) -> None:
843
+ obj = self.get_object()
844
+ if not obj.attributedTo:
845
+ self._data["object"]["attributedTo"] = self.get_actor().id
846
+ if not obj.published:
847
+ if self.published:
848
+ self._data["object"]["published"] = self.published
849
+ else:
850
+ now = format_datetime(datetime.now().astimezone())
851
+ self._data["published"] = now
852
+ self._data["object"]["published"] = now
853
+
854
+ def _recipients(self) -> List[str]:
855
+ # TODO(tsileo): audience support?
856
+ recipients = []
857
+ for field in ["to", "cc", "bto", "bcc"]:
858
+ if field in self._data:
859
+ recipients.extend(_to_list(self._data[field]))
860
+
861
+ recipients.extend(self.get_object()._recipients())
862
+
863
+ return recipients
864
+
865
+ def get_tombstone(self, deleted: Optional[str] = None) -> BaseActivity:
866
+ return Tombstone(
867
+ id=self.id,
868
+ published=self.get_object().published,
869
+ deleted=deleted,
870
+ updated=deleted,
871
+ )
872
+
873
+
874
+ class Tombstone(BaseActivity):
875
+ ACTIVITY_TYPE = ActivityType.TOMBSTONE
876
+ ACTOR_REQUIRED = False
877
+ OBJECT_REQUIRED = False
878
+
879
+
880
+ class Note(BaseActivity):
881
+ ACTIVITY_TYPE = ActivityType.NOTE
882
+ ACTOR_REQUIRED = True
883
+ OBJECT_REQURIED = False
884
+
885
+ def _init(self) -> None:
886
+ if "sensitive" not in self._data:
887
+ self._data["sensitive"] = False
888
+
889
+ def _recipients(self) -> List[str]:
890
+ # TODO(tsileo): audience support?
891
+ recipients: List[str] = []
892
+
893
+ for field in ["to", "cc", "bto", "bcc"]:
894
+ if field in self._data:
895
+ recipients.extend(_to_list(self._data[field]))
896
+
897
+ return recipients
898
+
899
+ def build_create(self) -> BaseActivity:
900
+ """Wraps an activity in a Create activity."""
901
+ create_payload = {
902
+ "object": self.to_dict(embed=True),
903
+ "actor": self.attributedTo,
904
+ }
905
+ for field in ["published", "to", "bto", "cc", "bcc", "audience"]:
906
+ if field in self._data:
907
+ create_payload[field] = self._data[field]
908
+
909
+ create = Create(**create_payload)
910
+ create.set_ctx(self)
911
+
912
+ return create
913
+
914
+ def build_like(self, as_actor: ActorType) -> BaseActivity:
915
+ return Like(object=self.id, actor=as_actor.id)
916
+
917
+ def build_announce(self, as_actor: ActorType) -> BaseActivity:
918
+ return Announce(
919
+ actor=as_actor.id,
920
+ object=self.id,
921
+ to=[AS_PUBLIC],
922
+ cc=[as_actor.followers, self.attributedTo],
923
+ published=format_datetime(datetime.now().astimezone()),
924
+ )
925
+
926
+ def has_mention(self, actor_id: str) -> bool:
927
+ if self.tag is not None:
928
+ for tag in self.tag:
929
+ try:
930
+ if tag["type"] == ActivityType.MENTION.value:
931
+ if tag["href"] == actor_id:
932
+ return True
933
+ except Exception:
934
+ logger.exception(f"invalid tag {tag!r}")
935
+
936
+ return False
937
+
938
+ def get_in_reply_to(self) -> Optional[str]:
939
+ return _get_id(self.inReplyTo)
940
+
941
+
942
+ class Question(Note):
943
+ ACTIVITY_TYPE = ActivityType.QUESTION
944
+ ACTOR_REQUIRED = True
945
+ OBJECT_REQURIED = False
946
+
947
+ def one_of(self) -> List[Dict[str, Any]]:
948
+ return self._data.get("oneOf", [])
949
+
950
+
951
+ class Article(Note):
952
+ ACTIVITY_TYPE = ActivityType.ARTICLE
953
+ ACTOR_REQUIRED = True
954
+ OBJECT_REQURIED = False
955
+
956
+
957
+ class Page(Note):
958
+ ACTIVITY_TYPE = ActivityType.PAGE
959
+ ACTOR_REQUIRED = True
960
+ OBJECT_REQURIED = False
961
+
962
+
963
+ class Video(Note):
964
+ ACTIVITY_TYPE = ActivityType.VIDEO
965
+ ACTOR_REQUIRED = True
966
+ OBJECT_REQURIED = False
967
+
968
+
969
+ class Document(Note):
970
+ ACTIVITY_TYPE = ActivityType.DOCUMENT
971
+ ACTOR_REQUIRED = True
972
+ OBJECT_REQUIRED = False
973
+
974
+
975
+ class Audio(Note):
976
+ ACTIVITY_TYPE = ActivityType.AUDIO
977
+ ACTOR_REQUIRED = True
978
+ OBJECT_REQUIRED = False
979
+
980
+
981
+ def fetch_remote_activity(
982
+ iri: str, expected: Optional[ActivityType] = None
983
+ ) -> BaseActivity:
984
+ return parse_activity(get_backend().fetch_iri(iri), expected=expected)