class ActivitypubFollow(ActivitypubEntityMixin, Follow):
    _type = ActivityType.FOLLOW.value

    def post_receive(self) -> None:
        """
        Post receive hook - send back follow ack.
        """
        super().post_receive()
        if not self.following:
            return

        from federation.utils.activitypub import retrieve_and_parse_profile  # Circulars
        try:
            from federation.utils.django import get_function_from_config
        except ImportError:
            logger.warning("ActivitypubFollow.post_receive - Unable to send automatic Accept back, only supported on "
                           "Django currently")
            return
        get_private_key_function = get_function_from_config("get_private_key_function")
        key = get_private_key_function(self.target_id)
        if not key:
            logger.warning("ActivitypubFollow.post_receive - Failed to send automatic Accept back: could not find "
                           "profile to sign it with")
            return
        accept = ActivitypubAccept(
            activity_id=f"{self.target_id}#accept-{uuid.uuid4()}",
            actor_id=self.target_id,
            target_id=self.activity_id,
            object=self.to_as2(),
        )
        # noinspection PyBroadException
        try:
            profile = retrieve_and_parse_profile(self.actor_id)
        except Exception:
            profile = None
        if not profile:
            logger.warning("ActivitypubFollow.post_receive - Failed to fetch remote profile for sending back Accept")
            return
        # noinspection PyBroadException
        try:
            handle_send(
                accept,
                UserType(id=self.target_id, private_key=key),
                recipients=[{
                    "endpoint": profile.inboxes["private"],
                    "fid": self.actor_id,
                    "protocol": "activitypub",
                    "public": False,
                }],
            )
        except Exception:
            logger.exception("ActivitypubFollow.post_receive - Failed to send Accept back")

    def to_as2(self) -> Dict:
        if self.following:
            as2 = {
                "@context": CONTEXTS_DEFAULT,
                "id": self.activity_id,
                "type": self._type,
                "actor": self.actor_id,
                "object": self.target_id,
            }
        else:
            as2 = {
                "@context": CONTEXTS_DEFAULT,
                "id": self.activity_id,
                "type": ActivityType.UNDO.value,
                "actor": self.actor_id,
                "object": {
                    "id": f"{self.actor_id}#follow-{uuid.uuid4()}",
                    "type": self._type,
                    "actor": self.actor_id,
                    "object": self.target_id,
                },
            }
        return as2 class ActivitypubAccept(ActivitypubEntityMixin, Accept):
    _type = ActivityType.ACCEPT.value
    object: Dict = None

    def to_as2(self) -> Dict:
        as2 = {
            "@context": CONTEXTS_DEFAULT,
            "id": self.activity_id,
            "type": self._type,
            "actor": self.actor_id,
            "object": self.object,
        }
        return as2 class ActivitypubProfile(ActivitypubEntityMixin, Profile):
    _type = ActorType.PERSON.value
    public = True

    def to_as2(self) -> Dict:
        as2 = {
            "@context": CONTEXTS_DEFAULT + [
                CONTEXT_LD_SIGNATURES,
                CONTEXT_MANUALLY_APPROVES_FOLLOWERS,
            ],
            "endpoints": {
                "sharedInbox": self.inboxes["public"],
            },
            "followers": f"{with_slash(self.id)}followers/",
            "following": f"{with_slash(self.id)}following/",
            "id": self.id,
            "inbox": self.inboxes["private"],
            "manuallyApprovesFollowers": False,
            "name": self.name,
            "outbox": f"{with_slash(self.id)}outbox/",
            "publicKey": {
                "id": f"{self.id}#main-key",
                "owner": self.id,
                "publicKeyPem": self.public_key,
            },
            "type": self._type,
            "url": self.url,
        }
        if self.username:
            as2['preferredUsername'] = self.username
        if self.raw_content:
            as2['summary'] = self.raw_content
        if self.image_urls.get('large'):
            try:
                profile_icon = ActivitypubImage(url=self.image_urls.get('large'))
                if profile_icon.media_type:
                    as2['icon'] = profile_icon.to_as2()
            except Exception as ex:
                logger.warning("ActivitypubProfile.to_as2 - failed to set profile icon: %s", ex)

        if self.guid or self.handle:
            as2["@context"].append(CONTEXT_DIASPORA)
            if self.guid:
                as2["diaspora:guid"] = self.guid
            if self.handle:
                as2["diaspora:handle"] = self.handle

        return as2 class ActivitypubPost(ActivitypubNoteMixin, Post):
    pass class ActivitypubComment(ActivitypubNoteMixin, Comment):
    def to_as2(self) -> Dict:
        as2 = super().to_as2()
        as2["object"]["inReplyTo"] = self.target_id
        return as2 class ActivitypubRetraction(ActivitypubEntityMixin, Retraction):
    def resolve_object_type(self):
        return {
            "Comment": ObjectType.TOMBSTONE.value,
            "Post": ObjectType.TOMBSTONE.value,
            "Share": ActivityType.ANNOUNCE.value,
        }.get(self.entity_type)

    def resolve_type(self):
        return {
            "Comment": ActivityType.DELETE.value,
            "Post": ActivityType.DELETE.value,
            "Share": ActivityType.UNDO.value,
        }.get(self.entity_type)

    def to_as2(self) -> Dict:
        as2 = {
            "@context": CONTEXTS_DEFAULT,
            "id": self.activity_id,
            "type": self.resolve_type(),
            "actor": self.actor_id,
            "object": {
                "id": self.target_id,
                "type": self.resolve_object_type(),
            },
            "published": self.created_at.isoformat(),
        }
        return as2 class ActivitypubShare(ActivitypubEntityMixin, Share):
    _type = ActivityType.ANNOUNCE.value

    def to_as2(self) -> Dict:
        as2 = {
            "@context": CONTEXTS_DEFAULT,
            "id": self.activity_id,
            "type": self._type,
            "actor": self.actor_id,
            "object": self.target_id,
            "published": self.created_at.isoformat(),
        }
        return as2 def message_to_objects(
        message: Dict, sender: str = "", sender_key_fetcher: Callable[[str], str] = None, user: UserType = None,
) -> List:
    """
    Takes in a message extracted by a protocol and maps it to entities.
    """
    # We only really expect one element here for ActivityPub.
    return element_to_objects(message) def get_outbound_entity(entity: BaseEntity, private_key):
    """Get the correct outbound entity for this protocol.

    We might have to look at entity values to decide the correct outbound entity.
    If we cannot find one, we should raise as conversion cannot be guaranteed to the given protocol.

    Private key of author is needed to be passed for signing the outbound entity.

    :arg entity: An entity instance which can be of a base or protocol entity class.
    :arg private_key: Private key of sender in str format
    :returns: Protocol specific entity class instance.
    :raises ValueError: If conversion cannot be done.
    """
    if getattr(entity, "outbound_doc", None):
        # If the entity already has an outbound doc, just return the entity as is
        return entity
    outbound = None
    cls = entity.__class__
    if cls in [
        ActivitypubAccept, ActivitypubFollow, ActivitypubProfile, ActivitypubPost, ActivitypubComment,
        ActivitypubRetraction, ActivitypubShare,
    ]:
        # Already fine
        outbound = entity
    elif cls == Accept:
        outbound = ActivitypubAccept.from_base(entity)
    elif cls == Follow:
        outbound = ActivitypubFollow.from_base(entity)
    elif cls == Post:
        outbound = ActivitypubPost.from_base(entity)
    elif cls == Profile:
        outbound = ActivitypubProfile.from_base(entity)
    elif cls == Retraction:
        outbound = ActivitypubRetraction.from_base(entity)
    elif cls == Comment:
        outbound = ActivitypubComment.from_base(entity)
    elif cls == Share:
        outbound = ActivitypubShare.from_base(entity)
    if not outbound:
        raise ValueError("Don't know how to convert this base entity to ActivityPub protocol entities.")
    # TODO LDS signing
    # if isinstance(outbound, DiasporaRelayableMixin) and not outbound.signature:
    #     # Sign by author if not signed yet. We don't want to overwrite any existing signature in the case
    #     # that this is being sent by the parent author
    #     outbound.sign(private_key)
    #     # If missing, also add same signature to `parent_author_signature`. This is required at the moment
    #     # in all situations but is apparently being removed.
    #     # TODO: remove this once Diaspora removes the extra signature
    #     outbound.parent_signature = outbound.signature
    if hasattr(outbound, "pre_send"):
        outbound.pre_send()
    # Validate the entity
    outbound.validate(direction="outbound")
    return outbound class Accept(CreatedAtMixin, TargetIDMixin, BaseEntity):
    """An acceptance message for some target."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # ID not required for accept
        self._required.remove('id') class Follow(CreatedAtMixin, TargetIDMixin, BaseEntity):
    """Represents a handle following or unfollowing another handle."""
    following = True

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._required += ["following"]
        # ID not required for follow
        self._required.remove('id') class Profile(CreatedAtMixin, OptionalRawContentMixin, PublicMixin, BaseEntity):
    """Represents a profile for a user."""
    atom_url = ""
    email = ""
    gender = ""
    image_urls = None
    location = ""
    name = ""
    nsfw = False
    public_key = ""
    tag_list = None
    url = ""
    username = ""
    inboxes: Dict = None

    _allowed_children = (Image,)

    def __init__(self, *args, **kwargs):
        self.image_urls = {
            "small": "", "medium": "", "large": ""
        }
        self.inboxes = {
            "private": None,
            "public": None,
        }
        self.tag_list = []
        super().__init__(*args, **kwargs)
        # As an exception, a Profile does not require to have an `actor_id`
        self._required.remove('actor_id')

    def validate_email(self):
        if self.email:
            validator = Email()
            if not validator.is_valid(self.email):
                raise ValueError("Email is not valid") class Post(RawContentMixin, PublicMixin, CreatedAtMixin, ProviderDisplayNameMixin, BaseEntity):
    """Reflects a post, status message, etc, which will be composed from the message or to the message."""
    location = ""
    url = ""

    _allowed_children = (Image,)
    _default_activity = ActivityType.CREATE class Comment(RawContentMixin, ParticipationMixin, CreatedAtMixin, RootTargetIDMixin, BaseEntity):
    """Represents a comment, linked to another object."""
    participation = "comment"
    url = ""

    _allowed_children = (Image,)
    _default_activity = ActivityType.CREATE class Image(OptionalRawContentMixin, CreatedAtMixin, BaseEntity):
    """Reflects a single image, possibly linked to another object."""
    url: str = ""
    name: str = ""
    height: int = 0
    width: int = 0
    inline: bool = False
    media_type: str = ""

    _default_activity = ActivityType.CREATE
    _valid_media_types: Tuple[str] = (
        "image/jpeg",
        "image/png",
        "image/gif",
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._required += ["url"]
        self._required.remove("id")
        self._required.remove("actor_id")
        if self.url and not self.media_type:
            self.media_type = self.get_media_type()

    def get_media_type(self) -> str:
        media_type = fetch_content_type(self.url)
        if media_type in self._valid_media_types:
            return media_type
        return "" class Share(CreatedAtMixin, TargetIDMixin, EntityTypeMixin, OptionalRawContentMixin, PublicMixin,
            ProviderDisplayNameMixin, BaseEntity):
    """Represents a share of another entity.

    ``entity_type`` defaults to "Post" but can be any base entity class name. It should be the class name of the
    entity that was shared.

    The optional ``raw_content`` can be used for a "quoted share" case where the sharer adds their own note to the
    share.
    """
    entity_type = "Post" class UserType:
    id: str = attr.ib()
    private_key: Optional[Union[RsaKey, str]] = attr.ib(default=None)
    receiver_variant: Optional[ReceiverVariant] = attr.ib(default=None)

    # Required only if sending to Diaspora protocol platforms
    handle: Optional[str] = attr.ib(default=None)
    guid: Optional[str] = attr.ib(default=None)

    # Required only if sending to Matrix protocol
    mxid: Optional[str] = attr.ib(default=None)
    # TODO needed?
    variant: Optional[UserVariant] = attr.ib(default=None)

    @property
    def rsa_private_key(self) -> RsaKey:
        if isinstance(self.private_key, str):
            return RSA.importKey(self.private_key)
        return self.private_key class ReceiverVariant(Enum):
    # Indicates this receiver is a single actor
    ACTOR = "actor"
    # Indicates this receiver is the followers of this actor
    FOLLOWERS = "followers"
from datetime import datetime
from unittest.mock import patch, Mock
from federation.entities.activitypub.entities import (
    ActivitypubFollow, ActivitypubAccept, ActivitypubProfile, ActivitypubPost, ActivitypubComment,
    ActivitypubRetraction, ActivitypubShare)
from federation.entities.activitypub.mappers import message_to_objects, get_outbound_entity
from federation.entities.base import Accept, Follow, Profile, Post, Comment, Image, Share
from federation.tests.fixtures.payloads import (
    ACTIVITYPUB_FOLLOW, ACTIVITYPUB_PROFILE, ACTIVITYPUB_PROFILE_INVALID, ACTIVITYPUB_UNDO_FOLLOW, ACTIVITYPUB_POST,
    ACTIVITYPUB_COMMENT, ACTIVITYPUB_RETRACTION, ACTIVITYPUB_SHARE, ACTIVITYPUB_RETRACTION_SHARE,
    ACTIVITYPUB_POST_IMAGES, ACTIVITYPUB_POST_WITH_SOURCE_MARKDOWN, ACTIVITYPUB_POST_WITH_TAGS,
    ACTIVITYPUB_POST_WITH_SOURCE_BBCODE, ACTIVITYPUB_POST_WITH_MENTIONS, ACTIVITYPUB_PROFILE_WITH_DIASPORA_GUID)
from federation.types import UserType, ReceiverVariant
import pytest
        entities = message_to_objects(ACTIVITYPUB_SHARE, "https://mastodon.social/users/jaywink")
        assert len(entities) == 1
        entity = entities[0]
        assert isinstance(entity, ActivitypubShare)
        assert entity.actor_id == "https://mastodon.social/users/jaywink"
        assert entity.target_id == "https://mastodon.social/users/Gargron/statuses/102559779793316012"
        assert entity.id == "https://mastodon.social/users/jaywink/statuses/102560701449465612/activity"
        assert entity.public is True
        assert entity.entity_type == "Post"
        assert entity.raw_content == ""

    def test_message_to_objects__follow(self):
        entities = message_to_objects(ACTIVITYPUB_FOLLOW, "https://example.com/actor")
        assert len(entities) == 1
        entity = entities[0]
        assert isinstance(entity, ActivitypubFollow)
        assert entity.actor_id == "https://example.com/actor"
        assert entity.target_id == "https://example.org/actor"
        assert entity.following is True

    def test_message_to_objects__unfollow(self):
        entities = message_to_objects(ACTIVITYPUB_UNDO_FOLLOW, "https://example.com/actor")
        assert len(entities) == 1
        entity = entities[0]
        assert isinstance(entity, ActivitypubFollow)
        assert entity.actor_id == "https://example.com/actor"
        assert entity.target_id == "https://example.org/actor"
        assert entity.following is False

    @pytest.mark.skip
    def test_message_to_objects_mentions_are_extracted(self):
        entities = message_to_objects(
            DIASPORA_POST_SIMPLE_WITH_MENTION, "alice@alice.diaspora.example.org"
        )
        assert len(entities) == 1
        post = entities[0]
        assert post._mentions == {'jaywink@jasonrobinson.me'}

    def test_message_to_objects_simple_post(self):
        entities = message_to_objects(ACTIVITYPUB_POST, "https://diaspodon.fr/users/jaywink")
        assert len(entities) == 1
        post = entities[0]
        assert isinstance(post, ActivitypubPost)
        assert isinstance(post, Post)
        assert post.raw_content == '<p><span class="h-card"><a class="u-url mention" ' \
                                   'href="https://dev.jasonrobinson.me/u/jaywink/">' \
                                   '@<span>jaywink</span></a></span> boom</p>'
        assert post.rendered_content == '<p><span class="h-card"><a href="https://dev.jasonrobinson.me/u/jaywink/" ' \
                                        'class="u-url mention">@<span>jaywink</span></a></span> boom</p>'
        assert post.id == "https://diaspodon.fr/users/jaywink/statuses/102356911717767237"
        assert post.actor_id == "https://diaspodon.fr/users/jaywink"
        assert post.public is True
        assert post._media_type == "text/html"
        assert getattr(post, "target_id", None) is None

    def test_message_to_objects_simple_post__with_tags(self):
        entities = message_to_objects(ACTIVITYPUB_POST_WITH_TAGS, "https://diaspodon.fr/users/jaywink")
        assert len(entities) == 1
        post = entities[0]
        assert isinstance(post, ActivitypubPost)
        assert isinstance(post, Post)
        assert post.raw_content == '<p>boom #test</p>'

    def test_message_to_objects_simple_post__with_mentions(self):
        entities = message_to_objects(ACTIVITYPUB_POST_WITH_MENTIONS, "https://mastodon.social/users/jaywink")
        assert len(entities) == 1
        post = entities[0]
        assert isinstance(post, ActivitypubPost)
        assert isinstance(post, Post)
        assert len(post._mentions) == 1
        assert list(post._mentions)[0] == "https://dev3.jasonrobinson.me/u/jaywink/"

    def test_message_to_objects_simple_post__with_source__bbcode(self):
        entities = message_to_objects(ACTIVITYPUB_POST_WITH_SOURCE_BBCODE, "https://diaspodon.fr/users/jaywink")
        assert len(entities) == 1
        post = entities[0]
        assert isinstance(post, ActivitypubPost)
        assert isinstance(post, Post)
        assert post.rendered_content == '<p><span class="h-card"><a href="https://dev.jasonrobinson.me/u/jaywink/" ' \
                                        'class="u-url mention">@<span>jaywink</span></a></span> boom</p>'
        assert post.raw_content == '<p><span class="h-card"><a class="u-url mention" ' \
                                   'href="https://dev.jasonrobinson.me/u/jaywink/">' \
                                   '@<span>jaywink</span></a></span> boom</p>'

    def test_message_to_objects_simple_post__with_source__markdown(self):
        entities = message_to_objects(ACTIVITYPUB_POST_WITH_SOURCE_MARKDOWN, "https://diaspodon.fr/users/jaywink")
        assert len(entities) == 1
        post = entities[0]
        assert isinstance(post, ActivitypubPost)
        assert isinstance(post, Post)
        assert post.rendered_content == '<p><span class="h-card"><a href="https://dev.jasonrobinson.me/u/jaywink/" ' \
                                        'class="u-url mention">@<span>jaywink</span></a></span> boom</p>'
        assert post.raw_content == "@jaywink boom"
        assert post.id == "https://diaspodon.fr/users/jaywink/statuses/102356911717767237"
        assert post.actor_id == "https://diaspodon.fr/users/jaywink"
        assert post.public is True
        assert post._media_type == "text/markdown"
        assert getattr(post, "target_id", None) is None

    def test_message_to_objects_post_with_photos(self):
        entities = message_to_objects(ACTIVITYPUB_POST_IMAGES, "https://mastodon.social/users/jaywink")
        assert len(entities) == 1
        post = entities[0]
        assert isinstance(post, ActivitypubPost)
        assert len(post._children) == 1
        photo = post._children[0]
        assert isinstance(photo, Image)
        assert photo.url == "https://files.mastodon.social/media_attachments/files/017/642/079/original/" \
                            "f51b0aee0ee1f2e1.jpg"
        assert photo.name == ""
        assert photo.raw_content == ""
        assert photo.height == 0
        assert photo.width == 0
        assert photo.guid == ""
        assert photo.handle == ""

    def test_message_to_objects_comment(self):
        entities = message_to_objects(ACTIVITYPUB_COMMENT, "https://diaspodon.fr/users/jaywink")
        assert len(entities) == 1
        comment = entities[0]

        assert isinstance(comment, ActivitypubComment)