little-boxes/little_boxes/activitypub.py

985 wiersze
30 KiB
Python

2018-06-11 20:50:02 +00:00
"""Core ActivityPub classes."""
2018-06-15 22:27:49 +00:00
import logging
2018-06-13 21:15:09 +00:00
import weakref
2018-06-11 20:50:02 +00:00
from datetime import datetime
2019-04-14 10:35:13 +00:00
from datetime import timezone
2018-06-11 20:50:02 +00:00
from enum import Enum
2018-06-15 22:27:49 +00:00
from typing import Any
from typing import Dict
2018-06-11 20:50:02 +00:00
from typing import List
from typing import Optional
from typing import Type
2018-06-15 22:27:49 +00:00
from typing import Union
2018-06-11 20:50:02 +00:00
2018-06-15 22:27:49 +00:00
from .backend import Backend
2018-07-09 22:23:55 +00:00
from .errors import ActivityGoneError
2018-07-08 21:02:15 +00:00
from .errors import ActivityNotFoundError
from .errors import ActivityUnavailableError
2018-06-15 22:27:49 +00:00
from .errors import BadActivityError
2019-04-08 19:18:43 +00:00
from .errors import NotAnActivityError
2018-06-15 22:27:49 +00:00
from .errors import Error
from .errors import UnexpectedActivityTypeError
2018-08-01 21:50:37 +00:00
from .key import Key
2018-06-11 20:50:02 +00:00
logger = logging.getLogger(__name__)
2018-06-13 18:09:58 +00:00
UninitializedBackendError = Error("a backend must be initialized")
2018-06-13 18:08:12 +00:00
2018-06-11 20:50:02 +00:00
# Helper/shortcut for typing
ObjectType = Dict[str, Any]
2018-07-21 11:12:27 +00:00
ActorType = Union["Person", "Application", "Group", "Organization", "Service"]
2018-06-11 20:50:02 +00:00
ObjectOrIDType = Union[str, ObjectType]
2018-06-12 17:57:40 +00:00
CTX_AS = "https://www.w3.org/ns/activitystreams"
CTX_SECURITY = "https://w3id.org/security/v1"
AS_PUBLIC = "https://www.w3.org/ns/activitystreams#Public"
2018-06-11 20:50:02 +00:00
2018-07-22 19:30:32 +00:00
DEFAULT_CTX = COLLECTION_CTX = [
2018-06-11 20:50:02 +00:00
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
2018-07-22 09:18:27 +00:00
{
2018-07-24 22:02:21 +00:00
# AS ext
2018-07-22 09:18:27 +00:00
"Hashtag": "as:Hashtag",
"sensitive": "as:sensitive",
2018-07-24 22:02:21 +00:00
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
# toot
2018-07-22 09:18:27 +00:00
"toot": "http://joinmastodon.org/ns#",
2018-07-22 19:30:32 +00:00
"featured": "toot:featured",
2018-07-24 22:02:21 +00:00
# schema
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
2018-07-22 09:18:27 +00:00
},
2018-06-11 20:50:02 +00:00
]
# Will be used to keep track of all the defined activities
2018-06-13 18:08:12 +00:00
_ACTIVITY_CLS: Dict["ActivityType", Type["BaseActivity"]] = {}
2018-06-11 20:50:02 +00:00
2018-06-13 18:08:12 +00:00
BACKEND: Optional[Backend] = None
2018-06-11 20:50:02 +00:00
2018-06-12 17:57:40 +00:00
2018-06-16 09:17:15 +00:00
def get_backend() -> Backend:
if BACKEND is None:
raise UninitializedBackendError
return BACKEND
2018-06-11 20:50:02 +00:00
def use_backend(backend_instance):
global BACKEND
BACKEND = backend_instance
2019-04-14 10:30:32 +00:00
def format_datetime(dt: datetime) -> str:
2019-04-14 10:35:13 +00:00
if dt.tzinfo is None:
raise ValueError("datetime must be tz aware")
2019-04-14 17:18:35 +00:00
return (
dt.astimezone(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
2019-04-14 10:30:32 +00:00
2018-06-11 20:50:02 +00:00
class ActivityType(Enum):
"""Supported activity `type`."""
2018-06-12 17:57:40 +00:00
ANNOUNCE = "Announce"
BLOCK = "Block"
LIKE = "Like"
CREATE = "Create"
UPDATE = "Update"
2018-09-02 13:33:46 +00:00
2018-06-12 17:57:40 +00:00
ORDERED_COLLECTION = "OrderedCollection"
ORDERED_COLLECTION_PAGE = "OrderedCollectionPage"
COLLECTION_PAGE = "CollectionPage"
COLLECTION = "Collection"
2018-09-02 13:33:46 +00:00
2018-06-12 17:57:40 +00:00
NOTE = "Note"
2018-07-14 11:25:54 +00:00
ARTICLE = "Article"
2018-09-02 13:33:46 +00:00
VIDEO = "Video"
2019-03-23 06:40:46 +00:00
AUDIO = "Audio"
DOCUMENT = "Document"
2018-09-02 13:33:46 +00:00
2018-06-12 17:57:40 +00:00
ACCEPT = "Accept"
REJECT = "Reject"
FOLLOW = "Follow"
2018-09-02 13:33:46 +00:00
2018-06-12 17:57:40 +00:00
DELETE = "Delete"
UNDO = "Undo"
2018-09-02 13:33:46 +00:00
2018-06-12 17:57:40 +00:00
IMAGE = "Image"
TOMBSTONE = "Tombstone"
2018-06-24 08:18:36 +00:00
# Actor types
PERSON = "Person"
APPLICATION = "Application"
GROUP = "Group"
ORGANIZATION = "Organization"
SERVICE = "Service"
2018-07-13 23:22:49 +00:00
# Others
MENTION = "Mention"
2019-04-09 18:54:28 +00:00
# Mastodon specific?
QUESTION = "Question"
2019-04-18 22:30:16 +00:00
# Used by Prismo
PAGE = "Page"
2019-04-23 22:25:45 +00:00
# Misskey uses standalone Key object
KEY = "Key"
2018-06-24 08:18:36 +00:00
ACTOR_TYPES = [
ActivityType.PERSON,
ActivityType.APPLICATION,
ActivityType.GROUP,
ActivityType.ORGANIZATION,
ActivityType.SERVICE,
ActivityType.QUESTION, # Mastodon notoft the end of a question with an update from that question
2018-06-24 08:18:36 +00:00
]
2019-04-09 18:54:48 +00:00
CREATE_TYPES = [
ActivityType.NOTE,
ActivityType.ARTICLE,
ActivityType.VIDEO,
2019-03-23 06:40:46 +00:00
ActivityType.AUDIO,
2019-04-09 18:54:48 +00:00
ActivityType.QUESTION,
2019-04-13 18:43:32 +00:00
ActivityType.DOCUMENT,
2019-04-18 22:30:16 +00:00
ActivityType.PAGE,
2019-04-09 18:54:48 +00:00
]
2018-07-20 22:43:34 +00:00
2018-06-24 08:18:36 +00:00
COLLECTION_TYPES = [ActivityType.COLLECTION, ActivityType.ORDERED_COLLECTION]
2018-06-12 17:57:40 +00:00
def parse_activity(
payload: ObjectType, expected: Optional[ActivityType] = None
) -> "BaseActivity":
2018-09-06 17:25:43 +00:00
if "type" not in payload:
raise BadActivityError(f"the payload has no type: {payload!r}")
t = ActivityType(_to_list(payload["type"])[0])
2018-06-11 20:50:02 +00:00
if expected and t != expected:
2018-06-12 17:57:40 +00:00
raise UnexpectedActivityTypeError(
2018-07-14 11:25:54 +00:00
f'expected a {expected.name} activity, got a {payload["type"]}: {payload}'
2018-06-12 17:57:40 +00:00
)
2018-06-11 20:50:02 +00:00
if t not in _ACTIVITY_CLS:
2018-07-14 13:11:49 +00:00
raise BadActivityError(
f'unsupported activity type {payload["type"]}: {payload}'
)
2018-06-11 20:50:02 +00:00
activity = _ACTIVITY_CLS[t](**payload)
return activity
def _to_list(data: Union[List[Any], Any]) -> List[Any]:
2018-06-12 19:47:58 +00:00
"""Helper to convert fields that can be either an object or a list of objects to a
list of object."""
2018-06-11 20:50:02 +00:00
if isinstance(data, list):
return data
return [data]
def clean_activity(activity: ObjectType) -> Dict[str, Any]:
"""Clean the activity before rendering it.
- Remove the hidden bco and bcc field
"""
2018-07-07 10:50:03 +00:00
for field in ["bto", "bcc", "source"]:
2018-06-11 20:50:02 +00:00
if field in activity:
2019-04-08 19:23:51 +00:00
del activity[field]
2018-06-12 17:57:40 +00:00
if activity["type"] == "Create" and field in activity["object"]:
2019-04-08 19:23:51 +00:00
del activity["object"][field]
2018-06-11 20:50:02 +00:00
return activity
def _get_actor_id(actor: ObjectOrIDType) -> str:
"""Helper for retrieving an actor `id`."""
if isinstance(actor, dict):
2018-06-12 17:57:40 +00:00
return actor["id"]
2018-06-11 20:50:02 +00:00
return actor
2019-04-16 20:46:17 +00:00
def _get_id(obj) -> Optional[str]:
if obj is None:
return None
elif isinstance(obj, str):
return obj
elif isinstance(obj, dict):
try:
return obj["id"]
except KeyError:
raise ValueError(f"object is missing ID: {obj!r}")
else:
raise ValueError(f"unexpected object: {obj!r}")
def _has_type(
obj_type: Union[str, List[str]],
_types: Union[ActivityType, str, List[Union[ActivityType, str]]],
):
"""Returns `True` if one of `obj_type` equals one of `_types`."""
types_str = [
_type.value if isinstance(_type, ActivityType) else _type
2018-07-11 20:50:55 +00:00
for _type in _to_list(_types)
]
for _type in _to_list(obj_type):
if _type in types_str:
return True
return False
2018-06-11 20:50:02 +00:00
class _ActivityMeta(type):
"""Metaclass for keeping track of subclass."""
2018-06-12 17:57:40 +00:00
2018-06-11 20:50:02 +00:00
def __new__(meta, name, bases, class_dict):
cls = type.__new__(meta, name, bases, class_dict)
# Ensure the class has an activity type defined
2018-06-12 17:57:40 +00:00
if name != "BaseActivity" and not cls.ACTIVITY_TYPE:
raise ValueError(f"class {name} has no ACTIVITY_TYPE")
2018-06-11 20:50:02 +00:00
# Register it
_ACTIVITY_CLS[cls.ACTIVITY_TYPE] = cls
return cls
class BaseActivity(object, metaclass=_ActivityMeta):
"""Base class for ActivityPub activities."""
2018-06-12 17:57:40 +00:00
ACTIVITY_TYPE: Optional[
ActivityType
] = None # the ActivityTypeEnum the class will represent
2018-06-11 20:50:02 +00:00
OBJECT_REQUIRED = False # Whether the object field is required or note
ALLOWED_OBJECT_TYPES: List[ActivityType] = []
2018-06-12 17:57:40 +00:00
ACTOR_REQUIRED = (
True
) # Most of the object requires an actor, so this flag in on by default
2018-06-11 20:50:02 +00:00
2018-06-13 07:38:12 +00:00
def __init__(self, **kwargs) -> None: # noqa: C901
2018-06-13 18:08:12 +00:00
if not self.ACTIVITY_TYPE:
raise Error("should never happen")
2018-06-11 20:50:02 +00:00
# Initialize the dict that will contains all the activity fields
self._data: Dict[str, Any] = {}
if not kwargs.get("type"):
self._data["type"] = self.ACTIVITY_TYPE.value
else:
atype = kwargs.pop("type")
if self.ACTIVITY_TYPE.value not in _to_list(atype):
raise UnexpectedActivityTypeError(
f"Expect the type to be {self.ACTIVITY_TYPE.value!r}"
)
self._data["type"] = atype
2018-06-12 17:57:40 +00:00
logger.debug(f"initializing a {self.ACTIVITY_TYPE.value} activity: {kwargs!r}")
2018-06-11 20:50:02 +00:00
# A place to set ephemeral data
2018-06-13 18:08:12 +00:00
self.__ctx: Any = {}
2018-06-11 20:50:02 +00:00
2018-06-18 19:42:32 +00:00
self.__obj: Optional["BaseActivity"] = None
2018-07-29 07:18:09 +00:00
self.__actor: Optional[List[ActorType]] = None
2018-06-18 19:36:54 +00:00
2018-06-11 20:50:02 +00:00
# The id may not be present for new activities
2018-06-12 17:57:40 +00:00
if "id" in kwargs:
self._data["id"] = kwargs.pop("id")
2018-06-11 20:50:02 +00:00
2018-06-24 08:18:36 +00:00
if self.ACTIVITY_TYPE not in ACTOR_TYPES and self.ACTOR_REQUIRED:
2018-06-12 17:57:40 +00:00
actor = kwargs.get("actor")
2018-06-11 20:50:02 +00:00
if actor:
2018-06-12 17:57:40 +00:00
kwargs.pop("actor")
2018-06-24 08:18:36 +00:00
actor = self._validate_actor(actor)
2018-06-12 17:57:40 +00:00
self._data["actor"] = actor
2018-07-20 22:43:34 +00:00
elif self.ACTIVITY_TYPE in CREATE_TYPES:
2018-06-13 07:49:23 +00:00
if "attributedTo" not in kwargs:
2018-06-13 07:38:12 +00:00
raise BadActivityError(f"Note is missing attributedTo")
2018-06-11 20:50:02 +00:00
else:
2018-06-12 17:57:40 +00:00
raise BadActivityError("missing actor")
2018-06-11 20:50:02 +00:00
2018-06-12 17:57:40 +00:00
if self.OBJECT_REQUIRED and "object" in kwargs:
obj = kwargs.pop("object")
2018-06-11 20:50:02 +00:00
if isinstance(obj, str):
# The object is a just a reference the its ID/IRI
# FIXME(tsileo): fetch the ref
2018-06-12 17:57:40 +00:00
self._data["object"] = obj
elif isinstance(obj, dict):
2018-06-11 20:50:02 +00:00
if not self.ALLOWED_OBJECT_TYPES:
2018-06-12 17:57:40 +00:00
raise UnexpectedActivityTypeError("unexpected object")
if "type" not in obj or (
self.ACTIVITY_TYPE != ActivityType.CREATE and "id" not in obj
):
raise BadActivityError("invalid object, missing type")
2018-06-27 21:30:49 +00:00
if not _has_type( # type: ignore # XXX too complicated
2018-06-27 21:26:19 +00:00
obj["type"], self.ALLOWED_OBJECT_TYPES
2018-06-27 21:30:49 +00:00
):
2018-06-11 20:50:02 +00:00
raise UnexpectedActivityTypeError(
f'unexpected object type {obj["type"]} (allowed={self.ALLOWED_OBJECT_TYPES!r})'
)
2018-06-12 17:57:40 +00:00
self._data["object"] = obj
else:
raise BadActivityError(
f"invalid object type ({type(obj).__qualname__}): {obj!r}"
)
2018-06-11 20:50:02 +00:00
2018-06-12 17:57:40 +00:00
if "@context" not in kwargs:
self._data["@context"] = CTX_AS
2018-06-11 20:50:02 +00:00
else:
2018-06-12 17:57:40 +00:00
self._data["@context"] = kwargs.pop("@context")
2018-06-11 20:50:02 +00:00
# @context check
2018-06-12 17:57:40 +00:00
if not isinstance(self._data["@context"], list):
self._data["@context"] = [self._data["@context"]]
if CTX_SECURITY not in self._data["@context"]:
self._data["@context"].append(CTX_SECURITY)
if isinstance(self._data["@context"][-1], dict):
self._data["@context"][-1]["Hashtag"] = "as:Hashtag"
self._data["@context"][-1]["sensitive"] = "as:sensitive"
2018-07-22 19:30:32 +00:00
self._data["@context"][-1]["toot"] = "http://joinmastodon.org/ns#"
self._data["@context"][-1]["featured"] = "toot:featured"
2018-06-11 20:50:02 +00:00
else:
2018-06-12 17:57:40 +00:00
self._data["@context"].append(
2018-07-22 19:30:32 +00:00
{
"Hashtag": "as:Hashtag",
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
"featured": "toot:featured",
}
2018-06-12 17:57:40 +00:00
)
2018-06-11 20:50:02 +00:00
2018-07-29 12:41:59 +00:00
# Remove keys with `None` value
valid_kwargs = {}
for k, v in kwargs.items():
if v is None:
continue
valid_kwargs[k] = v
self._data.update(**valid_kwargs)
2018-06-11 20:50:02 +00:00
try:
2018-07-29 12:41:59 +00:00
self._init()
2018-06-11 20:50:02 +00:00
except NotImplementedError:
pass
2018-07-29 12:41:59 +00:00
def _init(self) -> None:
"""Optional init callback."""
raise NotImplementedError
2018-06-11 20:50:02 +00:00
def has_type(
self, _types: Union[ActivityType, str, List[Union[ActivityType, str]]]
):
2018-07-15 19:03:14 +00:00
"""Return True if the activity has the given type."""
2018-07-11 17:20:36 +00:00
return _has_type(self._data["type"], _types)
2018-09-02 13:33:46 +00:00
def get_url(self, preferred_mimetype: str = "text/html") -> str:
2018-07-15 19:03:14 +00:00
"""Returns the url attributes as a str.
Returns the URL if it's a str, or the href of the first link.
"""
if isinstance(self.url, str):
return self.url
elif isinstance(self.url, dict):
if self.url.get("type") != "Link":
raise BadActivityError(f"invalid type {self.url}")
return str(self.url.get("href"))
2018-09-02 13:33:46 +00:00
elif isinstance(self.url, list):
last_link = None
for link in self.url:
last_link = link
if link.get("type") != "Link":
raise BadActivityError(f"invalid type {link}")
if link.get("mimeType").startswith(preferred_mimetype):
return link.get("href")
2018-09-02 13:37:34 +00:00
if not last_link:
raise BadActivityError(f"invalid type for {self.url}")
2018-09-02 13:33:46 +00:00
return last_link
2018-07-15 19:03:14 +00:00
else:
raise BadActivityError(f"invalid type for {self.url}")
2018-06-13 19:42:03 +00:00
def ctx(self) -> Any:
2018-07-29 12:41:59 +00:00
if self.__ctx:
return self.__ctx()
2018-06-11 20:50:02 +00:00
2018-06-13 19:42:03 +00:00
def set_ctx(self, ctx: Any) -> None:
2018-06-13 21:15:09 +00:00
# FIXME(tsileo): does not use the ctx to set the id to the "parent" when building delete
self.__ctx = weakref.ref(ctx)
2018-06-11 20:50:02 +00:00
def __repr__(self) -> str:
"""Pretty repr."""
2018-06-12 17:57:40 +00:00
return "{}({!r})".format(self.__class__.__qualname__, self._data.get("id"))
2018-06-11 20:50:02 +00:00
def __str__(self) -> str:
"""Returns the ID/IRI when castign to str."""
2018-06-12 17:57:40 +00:00
return str(self._data.get("id", f"[new {self.ACTIVITY_TYPE} activity]"))
2018-06-11 20:50:02 +00:00
def __getattr__(self, name: str) -> Any:
"""Allow to access the object field as regular attributes."""
if self._data.get(name):
return self._data.get(name)
2018-07-29 12:41:59 +00:00
def _set_id(self, uri: str, obj_id: str) -> None:
2018-06-11 20:50:02 +00:00
"""Optional callback for subclasses to so something with a newly generated ID (for outbox activities)."""
raise NotImplementedError
2018-07-29 12:41:59 +00:00
def set_id(self, uri: str, obj_id: str) -> None:
2018-06-11 20:50:02 +00:00
"""Set the ID for a new activity."""
2018-06-12 17:57:40 +00:00
logger.debug(f"setting ID {uri} / {obj_id}")
self._data["id"] = uri
2018-06-11 20:50:02 +00:00
try:
2018-07-29 12:41:59 +00:00
self._set_id(uri, obj_id)
2018-06-11 20:50:02 +00:00
except NotImplementedError:
pass
def _actor_id(self, obj: ObjectOrIDType) -> str:
2018-07-11 18:09:22 +00:00
if isinstance(obj, dict) and _has_type( # type: ignore
2018-07-11 17:39:48 +00:00
obj["type"], ACTOR_TYPES
2018-07-11 18:09:22 +00:00
):
2018-06-12 17:57:40 +00:00
obj_id = obj.get("id")
2018-06-11 20:50:02 +00:00
if not obj_id:
2018-06-12 17:57:40 +00:00
raise BadActivityError(f"missing object id: {obj!r}")
2018-06-11 20:50:02 +00:00
return obj_id
elif isinstance(obj, str):
return obj
else:
raise BadActivityError(f'invalid "actor" field: {obj!r}')
2018-06-24 08:18:36 +00:00
def _validate_actor(self, obj: ObjectOrIDType) -> str:
2018-06-13 18:08:12 +00:00
if BACKEND is None:
raise UninitializedBackendError
2018-06-11 20:50:02 +00:00
obj_id = self._actor_id(obj)
try:
actor = BACKEND.fetch_iri(obj_id)
2018-07-19 23:35:18 +00:00
except (ActivityGoneError, ActivityNotFoundError):
raise
2018-06-11 20:50:02 +00:00
except Exception:
2018-06-12 17:57:40 +00:00
raise BadActivityError(f"failed to validate actor {obj!r}")
2018-06-11 20:50:02 +00:00
2018-06-12 17:57:40 +00:00
if not actor or "id" not in actor:
raise BadActivityError(f"invalid actor {actor}")
2018-06-11 20:50:02 +00:00
2018-06-27 21:30:49 +00:00
if not _has_type( # type: ignore # XXX: too complicated
2018-06-27 21:26:19 +00:00
actor["type"], ACTOR_TYPES
2018-06-27 21:30:49 +00:00
):
2018-06-24 08:18:36 +00:00
raise UnexpectedActivityTypeError(f'actor has wrong type {actor["type"]!r}')
2018-06-12 17:57:40 +00:00
return actor["id"]
2018-06-11 20:50:02 +00:00
2018-08-05 11:19:06 +00:00
def get_object_id(self) -> str:
if BACKEND is None:
raise UninitializedBackendError
if self.__obj:
return self.__obj.id
if isinstance(self._data["object"], dict):
return self._data["object"]["id"]
elif isinstance(self._data["object"], str):
return self._data["object"]
else:
raise ValueError(f"invalid object {self._data['object']}")
2018-06-12 17:57:40 +00:00
def get_object(self) -> "BaseActivity":
2018-06-11 20:50:02 +00:00
"""Returns the object as a BaseActivity instance."""
2018-06-13 18:08:12 +00:00
if BACKEND is None:
raise UninitializedBackendError
2018-06-11 20:50:02 +00:00
if self.__obj:
return self.__obj
2018-06-12 17:57:40 +00:00
if isinstance(self._data["object"], dict):
p = parse_activity(self._data["object"])
2018-06-11 20:50:02 +00:00
else:
2018-06-12 17:57:40 +00:00
obj = BACKEND.fetch_iri(self._data["object"])
if ActivityType(obj.get("type")) not in self.ALLOWED_OBJECT_TYPES:
raise UnexpectedActivityTypeError(
f'invalid object type {obj.get("type")!r}'
)
2018-06-11 20:50:02 +00:00
p = parse_activity(obj)
2018-06-18 19:42:32 +00:00
self.__obj = p
2018-06-11 20:50:02 +00:00
return p
def reset_object_cache(self) -> None:
self.__obj = None
2018-06-12 17:57:40 +00:00
def to_dict(
self, embed: bool = False, embed_object_id_only: bool = False
) -> ObjectType:
2018-06-11 20:50:02 +00:00
"""Serializes the activity back to a dict, ready to be JSON serialized."""
data = dict(self._data)
if embed:
2018-06-12 17:57:40 +00:00
for k in ["@context", "signature"]:
2018-06-11 20:50:02 +00:00
if k in data:
2019-04-08 19:23:51 +00:00
del data[k]
2018-06-12 17:57:40 +00:00
if (
data.get("object")
and embed_object_id_only
and isinstance(data["object"], dict)
):
2018-06-11 20:50:02 +00:00
try:
2018-06-12 17:57:40 +00:00
data["object"] = data["object"]["id"]
2018-06-11 20:50:02 +00:00
except KeyError:
2018-06-12 17:57:40 +00:00
raise BadActivityError(
f'embedded object {data["object"]!r} should have an id'
)
2018-06-11 20:50:02 +00:00
return data
2018-07-21 11:09:31 +00:00
def get_actor(self) -> ActorType:
2018-06-13 18:08:12 +00:00
if BACKEND is None:
raise UninitializedBackendError
2018-06-18 19:36:54 +00:00
if self.__actor:
2018-07-29 07:18:09 +00:00
return self.__actor[0]
2018-06-18 19:36:54 +00:00
2018-06-12 17:57:40 +00:00
actor = self._data.get("actor")
2018-06-11 20:50:02 +00:00
if not actor and self.ACTOR_REQUIRED:
# Quick hack for Note objects
2018-07-20 22:43:34 +00:00
if self.ACTIVITY_TYPE in CREATE_TYPES:
2018-07-29 07:18:09 +00:00
actor = self._data.get("attributedTo")
if not actor:
raise BadActivityError(f"missing attributedTo")
2018-06-11 20:50:02 +00:00
else:
2018-06-12 17:57:40 +00:00
raise BadActivityError(f"failed to fetch actor: {self._data!r}")
2018-06-11 20:50:02 +00:00
2018-07-29 07:18:09 +00:00
self.__actor: List[ActorType] = []
for item in _to_list(actor):
if not isinstance(item, (str, dict)):
raise BadActivityError(f"invalid actor: {self._data!r}")
2018-06-13 18:08:12 +00:00
2018-07-29 07:18:09 +00:00
actor_id = self._actor_id(item)
2018-06-11 20:50:02 +00:00
2018-07-29 07:18:09 +00:00
p = parse_activity(BACKEND.fetch_iri(actor_id))
if not p.has_type(ACTOR_TYPES): # type: ignore
raise UnexpectedActivityTypeError(f"{p!r} is not an actor")
self.__actor.append(p) # type: ignore
return self.__actor[0]
2018-07-21 11:09:31 +00:00
2018-06-11 20:50:02 +00:00
def _recipients(self) -> List[str]:
return []
2018-06-13 07:38:12 +00:00
def recipients(self) -> List[str]: # noqa: C901
2018-06-13 18:08:12 +00:00
if BACKEND is None:
raise UninitializedBackendError
2018-06-11 20:50:02 +00:00
recipients = self._recipients()
actor_id = self.get_actor().id
2018-07-09 20:20:46 +00:00
out: List[str] = []
if self.type == ActivityType.CREATE.value:
2018-07-29 15:08:03 +00:00
out = BACKEND.extra_inboxes()
2018-07-09 20:20:46 +00:00
2018-06-11 20:50:02 +00:00
for recipient in recipients:
if recipient in [actor_id, AS_PUBLIC, None]:
continue
2018-06-22 21:38:25 +00:00
2018-06-24 08:18:36 +00:00
try:
actor = fetch_remote_activity(recipient)
2019-04-08 19:27:22 +00:00
except (ActivityGoneError, ActivityNotFoundError, NotAnActivityError):
2018-06-24 08:18:36 +00:00
logger.info(f"{recipient} is gone")
continue
2018-07-08 21:02:15 +00:00
except ActivityUnavailableError:
# TODO(tsileo): retry separately?
logger.info(f"failed {recipient} to fetch recipient")
continue
2018-06-24 08:18:36 +00:00
if actor.ACTIVITY_TYPE in ACTOR_TYPES:
if actor.endpoints:
shared_inbox = actor.endpoints.get("sharedInbox")
2018-07-09 06:38:46 +00:00
if shared_inbox:
if shared_inbox not in out:
out.append(shared_inbox)
2018-06-24 08:18:36 +00:00
continue
if actor.inbox and actor.inbox not in out:
out.append(actor.inbox)
2018-06-11 20:50:02 +00:00
2018-06-24 08:18:36 +00:00
# Is the activity a `Collection`/`OrderedCollection`?
elif actor.ACTIVITY_TYPE in COLLECTION_TYPES:
for item in BACKEND.parse_collection(actor.to_dict()):
2018-06-24 08:18:36 +00:00
# XXX(tsileo): is nested collection support needed here?
if item in [actor_id, AS_PUBLIC]:
continue
try:
2018-07-08 21:02:15 +00:00
col_actor = fetch_remote_activity(item)
except ActivityUnavailableError:
# TODO(tsileo): retry separately?
logger.info(f"failed {recipient} to fetch recipient")
2018-06-24 08:18:36 +00:00
continue
2019-04-08 19:19:13 +00:00
except (
ActivityGoneError,
ActivityNotFoundError,
NotAnActivityError,
):
2018-06-24 08:18:36 +00:00
logger.info(f"{item} is gone")
continue
if col_actor.endpoints:
shared_inbox = col_actor.endpoints.get("sharedInbox")
2018-07-09 06:38:46 +00:00
if shared_inbox:
if shared_inbox not in out:
out.append(shared_inbox)
2018-06-11 20:50:02 +00:00
continue
2018-07-09 06:38:46 +00:00
2018-06-24 08:18:36 +00:00
if col_actor.inbox and col_actor.inbox not in out:
out.append(col_actor.inbox)
else:
raise BadActivityError(f"failed to parse {recipient}")
2018-06-11 20:50:02 +00:00
return out
class Person(BaseActivity):
ACTIVITY_TYPE = ActivityType.PERSON
OBJECT_REQUIRED = False
ACTOR_REQUIRED = False
def get_key(self) -> Key:
return Key.from_dict(self.publicKey)
2018-06-11 20:50:02 +00:00
2018-07-03 06:22:39 +00:00
class Service(Person):
2018-06-24 08:18:36 +00:00
ACTIVITY_TYPE = ActivityType.SERVICE
2018-07-03 06:22:39 +00:00
class Application(Person):
2018-06-24 08:18:36 +00:00
ACTIVITY_TYPE = ActivityType.APPLICATION
2018-07-03 06:22:39 +00:00
class Group(Person):
2018-06-24 08:18:36 +00:00
ACTIVITY_TYPE = ActivityType.GROUP
2018-07-03 06:22:39 +00:00
class Organization(Person):
2018-06-24 08:18:36 +00:00
ACTIVITY_TYPE = ActivityType.ORGANIZATION
2018-06-11 20:50:02 +00:00
class Block(BaseActivity):
ACTIVITY_TYPE = ActivityType.BLOCK
OBJECT_REQUIRED = True
ACTOR_REQUIRED = True
class Collection(BaseActivity):
ACTIVITY_TYPE = ActivityType.COLLECTION
OBJECT_REQUIRED = False
ACTOR_REQUIRED = False
2018-06-24 08:18:36 +00:00
class OerderedCollection(BaseActivity):
ACTIVITY_TYPE = ActivityType.ORDERED_COLLECTION
OBJECT_REQUIRED = False
ACTOR_REQUIRED = False
2018-06-11 20:50:02 +00:00
class Image(BaseActivity):
ACTIVITY_TYPE = ActivityType.IMAGE
OBJECT_REQUIRED = False
ACTOR_REQUIRED = False
def __repr__(self):
2018-06-12 17:57:40 +00:00
return "Image({!r})".format(self._data.get("url"))
2018-06-11 20:50:02 +00:00
class Follow(BaseActivity):
ACTIVITY_TYPE = ActivityType.FOLLOW
2018-07-21 11:09:31 +00:00
ALLOWED_OBJECT_TYPES = ACTOR_TYPES
2018-06-11 20:50:02 +00:00
OBJECT_REQUIRED = True
ACTOR_REQUIRED = True
def _recipients(self) -> List[str]:
return [self.get_object().id]
def build_undo(self) -> BaseActivity:
2018-06-12 17:57:40 +00:00
return Undo(object=self.to_dict(embed=True), actor=self.get_actor().id)
2018-06-11 20:50:02 +00:00
class Accept(BaseActivity):
ACTIVITY_TYPE = ActivityType.ACCEPT
ALLOWED_OBJECT_TYPES = [ActivityType.FOLLOW]
OBJECT_REQUIRED = True
ACTOR_REQUIRED = True
def _recipients(self) -> List[str]:
return [self.get_object().get_actor().id]
class Undo(BaseActivity):
ACTIVITY_TYPE = ActivityType.UNDO
2018-06-12 17:57:40 +00:00
ALLOWED_OBJECT_TYPES = [
ActivityType.FOLLOW,
ActivityType.LIKE,
ActivityType.ANNOUNCE,
2018-08-28 20:16:28 +00:00
ActivityType.BLOCK,
2018-06-12 17:57:40 +00:00
]
2018-06-11 20:50:02 +00:00
OBJECT_REQUIRED = True
ACTOR_REQUIRED = True
def _recipients(self) -> List[str]:
obj = self.get_object()
2018-06-11 22:14:29 +00:00
if obj.ACTIVITY_TYPE == ActivityType.FOLLOW:
2018-06-11 20:50:02 +00:00
return [obj.get_object().id]
else:
return [obj.get_object().get_actor().id]
class Like(BaseActivity):
ACTIVITY_TYPE = ActivityType.LIKE
2018-07-20 22:43:34 +00:00
ALLOWED_OBJECT_TYPES = CREATE_TYPES
2018-06-11 20:50:02 +00:00
OBJECT_REQUIRED = True
ACTOR_REQUIRED = True
def _recipients(self) -> List[str]:
return [self.get_object().get_actor().id]
def build_undo(self) -> BaseActivity:
return Undo(
object=self.to_dict(embed=True, embed_object_id_only=True),
actor=self.get_actor().id,
)
class Announce(BaseActivity):
ACTIVITY_TYPE = ActivityType.ANNOUNCE
2018-07-20 22:43:34 +00:00
ALLOWED_OBJECT_TYPES = CREATE_TYPES
2018-06-11 20:50:02 +00:00
OBJECT_REQUIRED = True
ACTOR_REQUIRED = True
def _recipients(self) -> List[str]:
2018-06-14 21:39:05 +00:00
recipients = [self.get_object().get_actor().id]
2018-06-11 20:50:02 +00:00
2018-06-12 17:57:40 +00:00
for field in ["to", "cc"]:
2018-06-11 20:50:02 +00:00
if field in self._data:
recipients.extend(_to_list(self._data[field]))
2018-06-14 21:39:05 +00:00
return list(set(recipients))
2018-06-11 20:50:02 +00:00
def build_undo(self) -> BaseActivity:
2018-06-14 22:00:54 +00:00
return Undo(actor=self.get_actor().id, object=self.to_dict(embed=True))
2018-06-11 20:50:02 +00:00
class Delete(BaseActivity):
ACTIVITY_TYPE = ActivityType.DELETE
2018-08-28 20:15:31 +00:00
ALLOWED_OBJECT_TYPES = CREATE_TYPES + ACTOR_TYPES + [ActivityType.TOMBSTONE]
2018-06-11 20:50:02 +00:00
OBJECT_REQUIRED = True
def _get_actual_object(self) -> BaseActivity:
2018-06-13 18:08:12 +00:00
if BACKEND is None:
raise UninitializedBackendError
2018-07-29 12:41:59 +00:00
# XXX(tsileo): overrides get_object instead?
2018-06-11 20:50:02 +00:00
obj = self.get_object()
2018-06-18 18:47:41 +00:00
if (
obj.id.startswith(BACKEND.base_url())
and obj.ACTIVITY_TYPE == ActivityType.TOMBSTONE
):
2018-06-11 20:50:02 +00:00
obj = parse_activity(BACKEND.fetch_iri(obj.id))
2018-06-18 18:18:57 +00:00
if obj.ACTIVITY_TYPE == ActivityType.TOMBSTONE:
# If we already received it, we may be able to get a copy
better_obj = BACKEND.fetch_iri(obj.id)
if better_obj:
return parse_activity(better_obj)
2018-06-11 20:50:02 +00:00
return obj
def _recipients(self) -> List[str]:
obj = self._get_actual_object()
return obj._recipients()
class Update(BaseActivity):
ACTIVITY_TYPE = ActivityType.UPDATE
2018-07-21 11:09:31 +00:00
ALLOWED_OBJECT_TYPES = CREATE_TYPES + ACTOR_TYPES
2018-06-11 20:50:02 +00:00
OBJECT_REQUIRED = True
ACTOR_REQUIRED = True
2018-11-12 09:11:58 +00:00
def _recipients(self) -> List[str]:
# TODO(tsileo): audience support?
recipients = []
for field in ["to", "cc", "bto", "bcc"]:
if field in self._data:
recipients.extend(_to_list(self._data[field]))
recipients.extend(self.get_object()._recipients())
return recipients
2018-06-11 20:50:02 +00:00
class Create(BaseActivity):
ACTIVITY_TYPE = ActivityType.CREATE
2018-07-20 22:43:34 +00:00
ALLOWED_OBJECT_TYPES = CREATE_TYPES
2018-06-11 20:50:02 +00:00
OBJECT_REQUIRED = True
ACTOR_REQUIRED = True
2018-07-20 23:05:14 +00:00
def is_public(self) -> bool:
"""Returns True if the activity is addressed to the special "public" collection."""
for field in ["to", "cc", "bto", "bcc"]:
if field in self._data:
2019-04-14 10:19:21 +00:00
if AS_PUBLIC in _to_list(self._data[field]):
2018-07-20 23:05:14 +00:00
return True
2019-04-14 10:19:21 +00:00
2018-07-20 23:05:14 +00:00
return False
2018-07-29 12:41:59 +00:00
def _set_id(self, uri: str, obj_id: str) -> None:
2018-06-22 23:14:05 +00:00
if BACKEND is None:
raise UninitializedBackendError
# FIXME(tsileo): add a BACKEND.note_activity_url, and pass the actor to both
2018-06-12 17:57:40 +00:00
self._data["object"]["id"] = uri + "/activity"
2018-07-29 18:54:49 +00:00
if "url" not in self._data["object"]:
self._data["object"]["url"] = BACKEND.note_url(obj_id)
2018-06-13 21:15:09 +00:00
if isinstance(self.ctx(), Note):
try:
self.ctx().id = self._data["object"]["id"]
except NotImplementedError:
pass
2018-06-11 20:50:02 +00:00
self.reset_object_cache()
2018-07-29 12:41:59 +00:00
def _init(self) -> None:
2018-06-11 20:50:02 +00:00
obj = self.get_object()
if not obj.attributedTo:
2018-06-12 17:57:40 +00:00
self._data["object"]["attributedTo"] = self.get_actor().id
2018-06-11 20:50:02 +00:00
if not obj.published:
if self.published:
2018-06-12 17:57:40 +00:00
self._data["object"]["published"] = self.published
2018-06-11 20:50:02 +00:00
else:
2019-04-14 10:35:13 +00:00
now = format_datetime(datetime.now().astimezone())
2018-06-12 17:57:40 +00:00
self._data["published"] = now
self._data["object"]["published"] = now
2018-06-11 20:50:02 +00:00
def _recipients(self) -> List[str]:
# TODO(tsileo): audience support?
recipients = []
2018-06-12 17:57:40 +00:00
for field in ["to", "cc", "bto", "bcc"]:
2018-06-11 20:50:02 +00:00
if field in self._data:
recipients.extend(_to_list(self._data[field]))
recipients.extend(self.get_object()._recipients())
return recipients
2018-07-09 22:48:51 +00:00
def get_tombstone(self, deleted: Optional[str] = None) -> BaseActivity:
return Tombstone(
id=self.id,
published=self.get_object().published,
deleted=deleted,
updated=deleted,
)
2018-06-11 20:50:02 +00:00
class Tombstone(BaseActivity):
ACTIVITY_TYPE = ActivityType.TOMBSTONE
ACTOR_REQUIRED = False
OBJECT_REQUIRED = False
class Note(BaseActivity):
ACTIVITY_TYPE = ActivityType.NOTE
ACTOR_REQUIRED = True
OBJECT_REQURIED = False
2018-07-29 12:41:59 +00:00
def _init(self) -> None:
if "sensitive" not in self._data:
2018-06-12 17:57:40 +00:00
self._data["sensitive"] = False
2018-06-11 20:50:02 +00:00
def _recipients(self) -> List[str]:
# TODO(tsileo): audience support?
recipients: List[str] = []
2018-06-12 17:57:40 +00:00
for field in ["to", "cc", "bto", "bcc"]:
2018-06-11 20:50:02 +00:00
if field in self._data:
recipients.extend(_to_list(self._data[field]))
return recipients
def build_create(self) -> BaseActivity:
"""Wraps an activity in a Create activity."""
create_payload = {
2018-06-12 17:57:40 +00:00
"object": self.to_dict(embed=True),
"actor": self.attributedTo,
2018-06-11 20:50:02 +00:00
}
2018-06-12 17:57:40 +00:00
for field in ["published", "to", "bto", "cc", "bcc", "audience"]:
2018-06-11 20:50:02 +00:00
if field in self._data:
create_payload[field] = self._data[field]
2018-06-13 21:15:09 +00:00
create = Create(**create_payload)
create.set_ctx(self)
return create
2018-06-11 20:50:02 +00:00
2018-07-21 11:09:31 +00:00
def build_like(self, as_actor: ActorType) -> BaseActivity:
2018-06-18 06:46:24 +00:00
return Like(object=self.id, actor=as_actor.id)
2018-06-11 20:50:02 +00:00
2018-07-21 11:09:31 +00:00
def build_announce(self, as_actor: ActorType) -> BaseActivity:
2018-06-11 20:50:02 +00:00
return Announce(
2018-06-18 06:46:24 +00:00
actor=as_actor.id,
2018-06-11 20:50:02 +00:00
object=self.id,
to=[AS_PUBLIC],
2018-06-18 18:47:41 +00:00
cc=[as_actor.followers, self.attributedTo],
2019-04-14 10:35:13 +00:00
published=format_datetime(datetime.now().astimezone()),
2018-06-11 20:50:02 +00:00
)
2018-07-13 23:22:49 +00:00
def has_mention(self, actor_id: str) -> bool:
if self.tag is not None:
for tag in self.tag:
2018-09-03 06:54:30 +00:00
try:
if tag["type"] == ActivityType.MENTION.value:
if tag["href"] == actor_id:
return True
except Exception:
logger.exception(f"invalid tag {tag!r}")
2018-07-13 23:22:49 +00:00
return False
2019-04-16 20:40:45 +00:00
def get_in_reply_to(self) -> Optional[str]:
2019-04-16 20:46:17 +00:00
return _get_id(self.inReplyTo)
2019-04-16 20:40:45 +00:00
2018-06-11 20:50:02 +00:00
2019-04-09 18:54:28 +00:00
class Question(Note):
ACTIVITY_TYPE = ActivityType.QUESTION
ACTOR_REQUIRED = True
OBJECT_REQURIED = False
def one_of(self) -> List[Dict[str, Any]]:
return self._data.get("oneOf", [])
2018-07-20 22:43:34 +00:00
class Article(Note):
ACTIVITY_TYPE = ActivityType.ARTICLE
ACTOR_REQUIRED = True
OBJECT_REQURIED = False
2019-04-18 22:30:16 +00:00
class Page(Note):
ACTIVITY_TYPE = ActivityType.PAGE
ACTOR_REQUIRED = True
OBJECT_REQURIED = False
2018-09-02 13:33:46 +00:00
class Video(Note):
ACTIVITY_TYPE = ActivityType.VIDEO
ACTOR_REQUIRED = True
OBJECT_REQURIED = False
2019-03-23 13:28:37 +00:00
class Document(Note):
ACTIVITY_TYPE = ActivityType.DOCUMENT
ACTOR_REQUIRED = True
OBJECT_REQUIRED = False
2019-03-23 13:28:37 +00:00
class Audio(Note):
2019-03-23 06:40:46 +00:00
ACTIVITY_TYPE = ActivityType.AUDIO
ACTOR_REQUIRED = True
OBJECT_REQUIRED = False
2018-06-22 21:38:25 +00:00
def fetch_remote_activity(
iri: str, expected: Optional[ActivityType] = None
) -> BaseActivity:
return parse_activity(get_backend().fetch_iri(iri), expected=expected)