little-boxes/little_boxes/activitypub.py

985 wiersze
30 KiB
Python

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