from collections.abc import Iterable from django.db import models, transaction from django.utils import timezone from activities.models.fan_out import FanOut from activities.models.post import Post from activities.models.post_types import QuestionData from core.ld import format_ld_date, get_str_or_id, parse_ld_date from core.snowflake import Snowflake from stator.models import State, StateField, StateGraph, StatorModel from users.models.identity import Identity class PostInteractionStates(StateGraph): new = State(try_interval=300) fanned_out = State(externally_progressed=True) undone = State(try_interval=300) undone_fanned_out = State(delete_after=24 * 60 * 60) new.transitions_to(fanned_out) fanned_out.transitions_to(undone) undone.transitions_to(undone_fanned_out) @classmethod def group_active(cls): return [cls.new, cls.fanned_out] @classmethod def handle_new(cls, instance: "PostInteraction"): """ Creates all needed fan-out objects for a new PostInteraction. """ # Boost: send a copy to all people who follow this user (limiting # to just local follows if it's a remote boost) # Pin: send Add activity to all people who follow this user if instance.type == instance.Types.boost or instance.type == instance.Types.pin: for target in instance.get_targets(): FanOut.objects.create( type=FanOut.Types.interaction, identity=target, subject_post=instance.post, subject_post_interaction=instance, ) # Like: send a copy to the original post author only, # if the liker is local or they are elif instance.type == instance.Types.like: if instance.identity.local or instance.post.local: FanOut.objects.create( type=FanOut.Types.interaction, identity_id=instance.post.author_id, subject_post=instance.post, subject_post_interaction=instance, ) # Vote: send a copy of the vote to the original # post author only if it's a local interaction # to a non local post elif instance.type == instance.Types.vote: if instance.identity.local and not instance.post.local: FanOut.objects.create( type=FanOut.Types.interaction, identity_id=instance.post.author_id, subject_post=instance.post, subject_post_interaction=instance, ) else: raise ValueError("Cannot fan out unknown type") # And one for themselves if they're local and it's a boost if instance.type == PostInteraction.Types.boost and instance.identity.local: FanOut.objects.create( identity_id=instance.identity_id, type=FanOut.Types.interaction, subject_post=instance.post, subject_post_interaction=instance, ) return cls.fanned_out @classmethod def handle_undone(cls, instance: "PostInteraction"): """ Creates all needed fan-out objects to undo a PostInteraction. """ # Undo Boost: send a copy to all people who follow this user # Undo Pin: send a Remove activity to all people who follow this user if instance.type == instance.Types.boost or instance.type == instance.Types.pin: for follow in instance.identity.inbound_follows.select_related( "source", "target" ): if follow.source.local or follow.target.local: FanOut.objects.create( type=FanOut.Types.undo_interaction, identity_id=follow.source_id, subject_post=instance.post, subject_post_interaction=instance, ) # Undo Like: send a copy to the original post author only elif instance.type == instance.Types.like: FanOut.objects.create( type=FanOut.Types.undo_interaction, identity_id=instance.post.author_id, subject_post=instance.post, subject_post_interaction=instance, ) else: raise ValueError("Cannot fan out unknown type") # And one for themselves if they're local and it's a boost if instance.type == PostInteraction.Types.boost and instance.identity.local: FanOut.objects.create( identity_id=instance.identity_id, type=FanOut.Types.undo_interaction, subject_post=instance.post, subject_post_interaction=instance, ) return cls.undone_fanned_out class PostInteraction(StatorModel): """ Handles both boosts and likes """ class Types(models.TextChoices): like = "like" boost = "boost" vote = "vote" pin = "pin" id = models.BigIntegerField( primary_key=True, default=Snowflake.generate_post_interaction, ) # The state the boost is in state = StateField(PostInteractionStates) # The canonical object ID object_uri = models.CharField(max_length=500, blank=True, null=True, unique=True) # What type of interaction it is type = models.CharField(max_length=100, choices=Types.choices) # The user who boosted/liked/etc. identity = models.ForeignKey( "users.Identity", on_delete=models.CASCADE, related_name="interactions", ) # The post that was boosted/liked/etc post = models.ForeignKey( "activities.Post", on_delete=models.CASCADE, related_name="interactions", ) # Used to store any interaction extra text value like the vote # in the question/poll case value = models.CharField(max_length=50, blank=True, null=True) # When the activity was originally created (as opposed to when we received it) # Mastodon only seems to send this for boosts, not likes published = models.DateTimeField(default=timezone.now) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) class Meta: indexes = [models.Index(fields=["type", "identity", "post"])] ### Display helpers ### @classmethod def get_post_interactions(cls, posts, identity): """ Returns a dict of {interaction_type: set(post_ids)} for all the posts and the given identity, for use in templates. """ # Bulk-fetch any of our own interactions ids_with_interaction_type = cls.objects.filter( identity=identity, post_id__in=[post.pk for post in posts], type__in=[cls.Types.like, cls.Types.boost, cls.Types.pin], state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out], ).values_list("post_id", "type") # Make it into the return dict result = {} for post_id, interaction_type in ids_with_interaction_type: result.setdefault(interaction_type, set()).add(post_id) return result @classmethod def get_event_interactions(cls, events, identity) -> dict[str, set[str]]: """ Returns a dict of {interaction_type: set(post_ids)} for all the posts within the events and the given identity, for use in templates. """ return cls.get_post_interactions( [e.subject_post for e in events if e.subject_post], identity ) def get_targets(self) -> Iterable[Identity]: """ Returns an iterable with Identities of followers that have unique shared_inbox among each other to be used as target. When interaction is boost, only boost follows are considered, for pins all followers are considered. """ # Start including the post author targets = {self.post.author} query = self.identity.inbound_follows.active() # Include all followers that are following the boosts if self.type == self.Types.boost: query = query.filter(boosts=True) for follow in query.select_related("source"): targets.add(follow.source) # Fetch the full blocks and remove them as targets for block in ( self.identity.outbound_blocks.active() .filter(mute=False) .select_related("target") ): try: targets.remove(block.target) except KeyError: pass deduped_targets = set() shared_inboxes = set() for target in targets: if target.local: # Local targets always gets the boosts # despite its creator locality deduped_targets.add(target) elif self.identity.local: # Dedupe the targets based on shared inboxes # (we only keep one per shared inbox) if not target.shared_inbox_uri: deduped_targets.add(target) elif target.shared_inbox_uri not in shared_inboxes: shared_inboxes.add(target.shared_inbox_uri) deduped_targets.add(target) return deduped_targets ### Create helpers ### @classmethod def create_votes(cls, post, identity, choices) -> list["PostInteraction"]: question = post.type_data if question.end_time and timezone.now() > question.end_time: raise ValueError("Validation failed: The poll has already ended") if post.interactions.filter(identity=identity, type=cls.Types.vote).exists(): raise ValueError("Validation failed: You have already voted on this poll") votes = [] with transaction.atomic(): for choice in set(choices): vote = cls.objects.create( identity=identity, post=post, type=PostInteraction.Types.vote, value=question.options[choice].name, ) vote.object_uri = f"{identity.actor_uri}#votes/{vote.id}" vote.save() votes.append(vote) if not post.local: question.options[choice].votes += 1 if not post.local: question.voter_count += 1 post.calculate_type_data() return votes ### ActivityPub (outbound) ### def to_ap(self) -> dict: """ Returns the AP JSON for this object """ # Create an object URI if we don't have one if self.object_uri is None: self.object_uri = self.identity.actor_uri + f"#{self.type}/{self.id}" if self.type == self.Types.boost: value = { "type": "Announce", "id": self.object_uri, "published": format_ld_date(self.published), "actor": self.identity.actor_uri, "object": self.post.object_uri, "to": "as:Public", } elif self.type == self.Types.like: value = { "type": "Like", "id": self.object_uri, "published": format_ld_date(self.published), "actor": self.identity.actor_uri, "object": self.post.object_uri, } elif self.type == self.Types.vote: value = { "type": "Note", "id": self.object_uri, "to": self.post.author.actor_uri, "name": self.value, "inReplyTo": self.post.object_uri, "attributedTo": self.identity.actor_uri, } elif self.type == self.Types.pin: raise ValueError("Cannot turn into AP") return value def to_create_ap(self): """ Returns the AP JSON to create this object """ object = self.to_ap() return { "to": object.get("to", []), "cc": object.get("cc", []), "type": "Create", "id": self.object_uri, "actor": self.identity.actor_uri, "object": object, } def to_undo_ap(self) -> dict: """ Returns the AP JSON to undo this object """ object = self.to_ap() return { "id": object["id"] + "/undo", "type": "Undo", "actor": self.identity.actor_uri, "object": object, } def to_add_ap(self): """ Returns the AP JSON to add a pin interaction to the featured collection """ return { "type": "Add", "actor": self.identity.actor_uri, "object": self.post.object_uri, "target": self.identity.actor_uri + "collections/featured/", } def to_remove_ap(self): """ Returns the AP JSON to remove a pin interaction from the featured collection """ return { "type": "Remove", "actor": self.identity.actor_uri, "object": self.post.object_uri, "target": self.identity.actor_uri + "collections/featured/", } ### ActivityPub (inbound) ### @classmethod def by_ap(cls, data, create=False) -> "PostInteraction": """ Retrieves a PostInteraction instance by its ActivityPub JSON object. Optionally creates one if it's not present. Raises KeyError if it's not found and create is False. """ # Do we have one with the right ID? try: boost = cls.objects.get(object_uri=data["id"]) except cls.DoesNotExist: if create: # Resolve the author identity = Identity.by_actor_uri(data["actor"], create=True) # Resolve the post object = data["object"] target = get_str_or_id(object, "inReplyTo") or get_str_or_id(object) post = Post.by_object_uri(target, fetch=True) value = None # Get the right type if data["type"].lower() == "like": type = cls.Types.like elif data["type"].lower() == "announce": type = cls.Types.boost elif ( data["type"].lower() == "create" and object["type"].lower() == "note" and isinstance(post.type_data, QuestionData) ): type = cls.Types.vote question = post.type_data value = object["name"] if question.end_time and timezone.now() > question.end_time: # TODO: Maybe create an expecific expired exception? raise cls.DoesNotExist( f"Cannot create a vote to the expired question {post.id}" ) already_voted = ( post.type_data.mode == "oneOf" and post.interactions.filter( type=cls.Types.vote, identity=identity ).exists() ) if already_voted: raise cls.DoesNotExist( f"The identity {identity.handle} already voted in question {post.id}" ) else: raise ValueError(f"Cannot handle AP type {data['type']}") # Make the actual interaction boost = cls.objects.create( object_uri=data["id"], identity=identity, post=post, published=parse_ld_date(data.get("published", None)) or timezone.now(), type=type, value=value, ) else: raise cls.DoesNotExist(f"No interaction with ID {data['id']}", data) return boost @classmethod def handle_ap(cls, data): """ Handles an incoming announce/like """ with transaction.atomic(): # Create it try: interaction = cls.by_ap(data, create=True) except (cls.DoesNotExist, Post.DoesNotExist): # That post is gone, boss # TODO: Limited retry state? return if interaction and interaction.post: interaction.post.calculate_stats() interaction.post.calculate_type_data() @classmethod def handle_undo_ap(cls, data): """ Handles an incoming undo for a announce/like """ with transaction.atomic(): # Find it try: interaction = cls.by_ap(data["object"]) except (cls.DoesNotExist, Post.DoesNotExist): # Well I guess we don't need to undo it do we return # Verify the actor matches if data["actor"] != interaction.identity.actor_uri: raise ValueError("Actor mismatch on interaction undo") # Delete all events that reference it interaction.timeline_events.all().delete() # Force it into undone_fanned_out as it's not ours interaction.transition_perform(PostInteractionStates.undone_fanned_out) # Recalculate post stats interaction.post.calculate_stats() interaction.post.calculate_type_data() @classmethod def handle_add_ap(cls, data): """ Handles an incoming Add activity which is a pin """ target = data.get("target", None) if not target: return # we only care about pinned posts, not hashtags object = data.get("object", {}) if isinstance(object, dict) and object.get("type") == "Hashtag": return with transaction.atomic(): identity = Identity.by_actor_uri(data["actor"], create=True) # it's only a pin if the target is the identity's featured collection URI if identity.featured_collection_uri != target: return object_uri = get_str_or_id(object) if not object_uri: return post = Post.by_object_uri(object_uri, fetch=True) return PostInteraction.objects.get_or_create( type=cls.Types.pin, identity=identity, post=post, state__in=PostInteractionStates.group_active(), )[0] @classmethod def handle_remove_ap(cls, data): """ Handles an incoming Remove activity which is an unpin """ target = data.get("target", None) if not target: return # we only care about pinned posts, not hashtags object = data.get("object", {}) if isinstance(object, dict) and object.get("type") == "Hashtag": return with transaction.atomic(): identity = Identity.by_actor_uri(data["actor"], create=True) # it's only an unpin if the target is the identity's featured collection URI if identity.featured_collection_uri != target: return try: object_uri = get_str_or_id(object) if not object_uri: return post = Post.by_object_uri(object_uri, fetch=False) for interaction in cls.objects.filter( type=cls.Types.pin, identity=identity, post=post, state__in=PostInteractionStates.group_active(), ): # Force it into undone_fanned_out as it's not ours interaction.transition_perform( PostInteractionStates.undone_fanned_out ) except (cls.DoesNotExist, Post.DoesNotExist): return ### Mastodon API ### def to_mastodon_status_json(self, interactions=None, identity=None): """ This wraps Posts in a fake Status for boost interactions. """ if self.type != self.Types.boost: raise ValueError( f"Cannot make status JSON for interaction of type {self.type}" ) # Make a fake post for this boost (because mastodon treats boosts as posts) post_json = self.post.to_mastodon_json( interactions=interactions, identity=identity ) return { "id": f"{self.pk}", "uri": post_json["uri"], "created_at": format_ld_date(self.published), "account": self.identity.to_mastodon_json(include_counts=False), "content": "", "visibility": post_json["visibility"], "sensitive": post_json["sensitive"], "spoiler_text": post_json["spoiler_text"], "media_attachments": [], "mentions": [], "tags": [], "emojis": [], "reblogs_count": 0, "favourites_count": 0, "replies_count": 0, "url": post_json["url"], "in_reply_to_id": None, "in_reply_to_account_id": None, "poll": post_json["poll"], "card": None, "language": None, "text": "", "edited_at": None, "reblog": post_json, }