Support follow requests (#625)

This commit is contained in:
Henri Dickson 2023-08-18 02:19:45 -04:00 committed by GitHub
parent faa181807c
commit 70b9e3b900
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 283 additions and 99 deletions

View file

@ -324,6 +324,7 @@ class Migration(migrations.Migration):
("mentioned", "Mentioned"), ("mentioned", "Mentioned"),
("liked", "Liked"), ("liked", "Liked"),
("followed", "Followed"), ("followed", "Followed"),
("follow_requested", "Follow Requested"),
("boosted", "Boosted"), ("boosted", "Boosted"),
("announcement", "Announcement"), ("announcement", "Announcement"),
("identity_created", "Identity Created"), ("identity_created", "Identity Created"),

View file

@ -16,6 +16,7 @@ class TimelineEvent(models.Model):
mentioned = "mentioned" mentioned = "mentioned"
liked = "liked" # Someone liking one of our posts liked = "liked" # Someone liking one of our posts
followed = "followed" followed = "followed"
follow_requested = "follow_requested"
boosted = "boosted" # Someone boosting one of our posts boosted = "boosted" # Someone boosting one of our posts
announcement = "announcement" # Server announcement announcement = "announcement" # Server announcement
identity_created = "identity_created" # New identity created identity_created = "identity_created" # New identity created
@ -74,14 +75,30 @@ class TimelineEvent(models.Model):
@classmethod @classmethod
def add_follow(cls, identity, source_identity): def add_follow(cls, identity, source_identity):
""" """
Adds a follow to the timeline if it's not there already Adds a follow to the timeline if it's not there already, remove follow request if any
""" """
cls.objects.filter(
type=cls.Types.follow_requested,
identity=identity,
subject_identity=source_identity,
).delete()
return cls.objects.get_or_create( return cls.objects.get_or_create(
identity=identity, identity=identity,
type=cls.Types.followed, type=cls.Types.followed,
subject_identity=source_identity, subject_identity=source_identity,
)[0] )[0]
@classmethod
def add_follow_request(cls, identity, source_identity):
"""
Adds a follow request to the timeline if it's not there already
"""
return cls.objects.get_or_create(
identity=identity,
type=cls.Types.follow_requested,
subject_identity=source_identity,
)[0]
@classmethod @classmethod
def add_post(cls, identity, post): def add_post(cls, identity, post):
""" """
@ -169,6 +186,14 @@ class TimelineEvent(models.Model):
subject_identity_id=interaction.identity_id, subject_identity_id=interaction.identity_id,
).delete() ).delete()
@classmethod
def delete_follow(cls, target, source):
TimelineEvent.objects.filter(
type__in=[cls.Types.followed, cls.Types.follow_requested],
identity=target,
subject_identity=source,
).delete()
### Background tasks ### ### Background tasks ###
@classmethod @classmethod
@ -218,6 +243,8 @@ class TimelineEvent(models.Model):
) )
elif self.type == self.Types.followed: elif self.type == self.Types.followed:
result["type"] = "follow" result["type"] = "follow"
elif self.type == self.Types.follow_requested:
result["type"] = "follow_request"
elif self.type == self.Types.identity_created: elif self.type == self.Types.identity_created:
result["type"] = "admin.sign_up" result["type"] = "admin.sign_up"
else: else:

View file

@ -58,6 +58,8 @@ urlpatterns = [
path("v1/filters", filters.list_filters), path("v1/filters", filters.list_filters),
# Follow requests # Follow requests
path("v1/follow_requests", follow_requests.follow_requests), path("v1/follow_requests", follow_requests.follow_requests),
path("v1/follow_requests/<id>/authorize", follow_requests.accept_follow_request),
path("v1/follow_requests/<id>/reject", follow_requests.reject_follow_request),
# Instance # Instance
path("v1/instance", instance.instance_info_v1), path("v1/instance", instance.instance_info_v1),
path("v1/instance/activity", instance.activity), path("v1/instance/activity", instance.activity),

View file

@ -29,6 +29,7 @@ def update_credentials(
display_name: QueryOrBody[str | None] = None, display_name: QueryOrBody[str | None] = None,
note: QueryOrBody[str | None] = None, note: QueryOrBody[str | None] = None,
discoverable: QueryOrBody[bool | None] = None, discoverable: QueryOrBody[bool | None] = None,
locked: QueryOrBody[bool | None] = None,
source: QueryOrBody[dict[str, Any] | None] = None, source: QueryOrBody[dict[str, Any] | None] = None,
fields_attributes: QueryOrBody[dict[str, dict[str, str]] | None] = None, fields_attributes: QueryOrBody[dict[str, dict[str, str]] | None] = None,
avatar: File | None = None, avatar: File | None = None,
@ -42,6 +43,8 @@ def update_credentials(
service.set_summary(note) service.set_summary(note)
if discoverable is not None: if discoverable is not None:
identity.discoverable = discoverable identity.discoverable = discoverable
if locked is not None:
identity.manually_approves_followers = locked
if source: if source:
if "privacy" in source: if "privacy" in source:
privacy_map = { privacy_map = {

View file

@ -1,8 +1,12 @@
from django.http import HttpRequest from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from hatchway import api_view from hatchway import api_view
from api import schemas from api import schemas
from api.decorators import scope_required from api.decorators import scope_required
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
from users.models.identity import Identity
from users.services.identity import IdentityService
@scope_required("read:follows") @scope_required("read:follows")
@ -14,5 +18,43 @@ def follow_requests(
min_id: str | None = None, min_id: str | None = None,
limit: int = 40, limit: int = 40,
) -> list[schemas.Account]: ) -> list[schemas.Account]:
# We don't implement this yet service = IdentityService(request.identity)
return [] paginator = MastodonPaginator(max_limit=80)
pager: PaginationResult[Identity] = paginator.paginate(
service.follow_requests(),
min_id=min_id,
max_id=max_id,
since_id=since_id,
limit=limit,
)
return PaginatingApiResponse(
[schemas.Account.from_identity(i) for i in pager.results],
request=request,
include_params=["limit"],
)
@scope_required("write:follows")
@api_view.post
def accept_follow_request(
request: HttpRequest,
id: str | None = None,
) -> schemas.Relationship:
source_identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
IdentityService(request.identity).accept_follow_request(source_identity)
return IdentityService(source_identity).mastodon_json_relationship(request.identity)
@scope_required("write:follows")
@api_view.post
def reject_follow_request(
request: HttpRequest,
id: str | None = None,
) -> schemas.Relationship:
source_identity = get_object_or_404(
Identity.objects.exclude(restriction=Identity.Restriction.blocked), pk=id
)
IdentityService(request.identity).reject_follow_request(source_identity)
return IdentityService(source_identity).mastodon_json_relationship(request.identity)

View file

@ -251,7 +251,8 @@ def test_clear_timeline(
else: else:
service.unfollow(remote_identity) service.unfollow(remote_identity)
# Run stator once to process the timeline clear message # Run stator twice to process the timeline clear message
stator.run_single_cycle()
stator.run_single_cycle() stator.run_single_cycle()
# Verify that the right things vanished # Verify that the right things vanished

View file

@ -33,7 +33,7 @@ def test_follow(
assert outbound_data["actor"] == identity.actor_uri assert outbound_data["actor"] == identity.actor_uri
assert outbound_data["object"] == remote_identity.actor_uri assert outbound_data["object"] == remote_identity.actor_uri
assert outbound_data["id"] == f"{identity.actor_uri}follow/{follow.pk}/" assert outbound_data["id"] == f"{identity.actor_uri}follow/{follow.pk}/"
assert Follow.objects.get(pk=follow.pk).state == FollowStates.local_requested assert Follow.objects.get(pk=follow.pk).state == FollowStates.pending_approval
# Come in with an inbox message of either a reference type or an embedded type # Come in with an inbox message of either a reference type or an embedded type
if ref_only: if ref_only:
message = { message = {
@ -53,4 +53,5 @@ def test_follow(
InboxMessage.objects.create(message=message) InboxMessage.objects.create(message=message)
# Run stator and ensure that accepted our follow # Run stator and ensure that accepted our follow
stator.run_single_cycle() stator.run_single_cycle()
stator.run_single_cycle()
assert Follow.objects.get(pk=follow.pk).state == FollowStates.accepted assert Follow.objects.get(pk=follow.pk).state == FollowStates.accepted

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.3 on 2023-08-04 01:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("users", "0021_identity_aliases"),
]
operations = [
migrations.RunSQL(
"UPDATE users_follow SET state = 'pending_approval' WHERE state = 'local_requested';"
),
migrations.RunSQL(
"UPDATE users_follow SET state = 'accepting' WHERE state = 'remote_requested';"
),
migrations.RunSQL(
"DELETE FROM users_follow WHERE state not in ('accepted', 'accepting', 'pending_approval', 'unrequested');"
),
]

View file

@ -7,43 +7,65 @@ from core.exceptions import capture_message
from core.ld import canonicalise, get_str_or_id from core.ld import canonicalise, get_str_or_id
from core.snowflake import Snowflake from core.snowflake import Snowflake
from stator.models import State, StateField, StateGraph, StatorModel from stator.models import State, StateField, StateGraph, StatorModel
from users.models.block import Block
from users.models.identity import Identity from users.models.identity import Identity
from users.models.inbox_message import InboxMessage
class FollowStates(StateGraph): class FollowStates(StateGraph):
unrequested = State(try_interval=600) unrequested = State(try_interval=600)
local_requested = State(try_interval=24 * 60 * 60) pending_approval = State(externally_progressed=True)
remote_requested = State(try_interval=24 * 60 * 60) accepting = State(try_interval=24 * 60 * 60)
rejecting = State(try_interval=24 * 60 * 60)
accepted = State(externally_progressed=True) accepted = State(externally_progressed=True)
undone = State(try_interval=60 * 60) undone = State(try_interval=24 * 60 * 60)
undone_remotely = State(delete_after=24 * 60 * 60) pending_removal = State(try_interval=60 * 60)
failed = State() removed = State(delete_after=1)
rejected = State()
unrequested.transitions_to(local_requested) unrequested.transitions_to(pending_approval)
unrequested.transitions_to(remote_requested) unrequested.transitions_to(accepting)
unrequested.times_out_to(failed, seconds=86400 * 7) unrequested.transitions_to(rejecting)
local_requested.transitions_to(accepted) unrequested.times_out_to(removed, seconds=24 * 60 * 60)
local_requested.transitions_to(rejected) pending_approval.transitions_to(accepting)
remote_requested.transitions_to(accepted) pending_approval.transitions_to(rejecting)
pending_approval.transitions_to(pending_removal)
accepting.transitions_to(accepted)
accepting.times_out_to(accepted, seconds=7 * 24 * 60 * 60)
rejecting.transitions_to(pending_removal)
rejecting.times_out_to(pending_removal, seconds=24 * 60 * 60)
accepted.transitions_to(rejecting)
accepted.transitions_to(undone) accepted.transitions_to(undone)
undone.transitions_to(undone_remotely) undone.transitions_to(pending_removal)
pending_removal.transitions_to(removed)
@classmethod @classmethod
def group_active(cls): def group_active(cls):
return [cls.unrequested, cls.local_requested, cls.accepted] """
Follows that are active means they are being handled and no need to re-request
"""
return [cls.unrequested, cls.pending_approval, cls.accepting, cls.accepted]
@classmethod
def group_accepted(cls):
"""
Follows that are accepting/accepted means they should be consider accepted when deliver to followers
"""
return [cls.accepting, cls.accepted]
@classmethod @classmethod
def handle_unrequested(cls, instance: "Follow"): def handle_unrequested(cls, instance: "Follow"):
""" """
Follows that are unrequested need us to deliver the Follow object Follows start unrequested as their initial state regardless of local/remote
to the target server.
""" """
# Remote follows should not be here if Block.maybe_get(
source=instance.target, target=instance.source, require_active=True
):
return cls.rejecting
if not instance.target.local:
if not instance.source.local: if not instance.source.local:
return cls.remote_requested # remote follow remote, invalid case
if instance.target.local: return cls.removed
return cls.accepted # local follow remote, send Follow to target server
# Don't try if the other identity didn't fetch yet # Don't try if the other identity didn't fetch yet
if not instance.target.inbox_uri: if not instance.target.inbox_uri:
return return
@ -56,19 +78,19 @@ class FollowStates(StateGraph):
) )
except httpx.RequestError: except httpx.RequestError:
return return
return cls.local_requested return cls.pending_approval
# local/remote follow local, check manually_approve
if instance.target.manually_approves_followers:
from activities.models import TimelineEvent
TimelineEvent.add_follow_request(instance.target, instance.source)
return cls.pending_approval
return cls.accepting
@classmethod @classmethod
def handle_local_requested(cls, instance: "Follow"): def handle_accepting(cls, instance: "Follow"):
# TODO: Resend follow requests occasionally if not instance.source.local:
pass # send an Accept object to the source server
@classmethod
def handle_remote_requested(cls, instance: "Follow"):
"""
Items in remote_requested need us to send an Accept object to the
source server.
"""
try: try:
instance.target.signed_request( instance.target.signed_request(
method="post", method="post",
@ -77,14 +99,32 @@ class FollowStates(StateGraph):
) )
except httpx.RequestError: except httpx.RequestError:
return return
from activities.models import TimelineEvent
TimelineEvent.add_follow(instance.target, instance.source)
return cls.accepted return cls.accepted
@classmethod
def handle_rejecting(cls, instance: "Follow"):
if not instance.source.local:
# send a Reject object to the source server
try:
instance.target.signed_request(
method="post",
uri=instance.source.inbox_uri,
body=canonicalise(instance.to_reject_ap()),
)
except httpx.RequestError:
return
return cls.pending_removal
@classmethod @classmethod
def handle_undone(cls, instance: "Follow"): def handle_undone(cls, instance: "Follow"):
""" """
Delivers the Undo object to the target server Delivers the Undo object to the target server
""" """
try: try:
if not instance.target.local:
instance.source.signed_request( instance.source.signed_request(
method="post", method="post",
uri=instance.target.inbox_uri, uri=instance.target.inbox_uri,
@ -92,7 +132,15 @@ class FollowStates(StateGraph):
) )
except httpx.RequestError: except httpx.RequestError:
return return
return cls.undone_remotely return cls.pending_removal
@classmethod
def handle_pending_removal(cls, instance: "Follow"):
if instance.target.local:
from activities.models import TimelineEvent
TimelineEvent.delete_follow(instance.target, instance.source)
return cls.removed
class FollowQuerySet(models.QuerySet): class FollowQuerySet(models.QuerySet):
@ -100,6 +148,10 @@ class FollowQuerySet(models.QuerySet):
query = self.filter(state__in=FollowStates.group_active()) query = self.filter(state__in=FollowStates.group_active())
return query return query
def accepted(self):
query = self.filter(state__in=FollowStates.group_accepted())
return query
class FollowManager(models.Manager): class FollowManager(models.Manager):
def get_queryset(self): def get_queryset(self):
@ -108,6 +160,9 @@ class FollowManager(models.Manager):
def active(self): def active(self):
return self.get_queryset().active() return self.get_queryset().active()
def accepted(self):
return self.get_queryset().accepted()
class Follow(StatorModel): class Follow(StatorModel):
""" """
@ -169,16 +224,13 @@ class Follow(StatorModel):
Creates a Follow from a local Identity to the target Creates a Follow from a local Identity to the target
(which can be local or remote). (which can be local or remote).
""" """
from activities.models import TimelineEvent
if not source.local: if not source.local:
raise ValueError("You cannot initiate follows from a remote Identity") raise ValueError("You cannot initiate follows from a remote Identity")
try: try:
follow = Follow.objects.get(source=source, target=target) follow = Follow.objects.get(source=source, target=target)
if not follow.active: if not follow.active:
follow.state = ( follow.state = FollowStates.unrequested
FollowStates.accepted if target.local else FollowStates.unrequested
)
follow.boosts = boosts follow.boosts = boosts
follow.save() follow.save()
except Follow.DoesNotExist: except Follow.DoesNotExist:
@ -188,29 +240,22 @@ class Follow(StatorModel):
target=target, target=target,
boosts=boosts, boosts=boosts,
uri="", uri="",
state=( state=FollowStates.unrequested,
FollowStates.accepted
if target.local
else FollowStates.unrequested
),
) )
follow.uri = source.actor_uri + f"follow/{follow.pk}/" follow.uri = source.actor_uri + f"follow/{follow.pk}/"
# TODO: Local follow approvals
if target.local:
TimelineEvent.add_follow(follow.target, follow.source)
follow.save() follow.save()
return follow return follow
### Properties ### ### Properties ###
@property
def pending(self):
return self.state in [FollowStates.unrequested, FollowStates.local_requested]
@property @property
def active(self): def active(self):
return self.state in FollowStates.group_active() return self.state in FollowStates.group_active()
@property
def accepted(self):
return self.state in FollowStates.group_accepted()
### ActivityPub (outbound) ### ### ActivityPub (outbound) ###
def to_ap(self): def to_ap(self):
@ -235,6 +280,17 @@ class Follow(StatorModel):
"object": self.to_ap(), "object": self.to_ap(),
} }
def to_reject_ap(self):
"""
Returns the AP JSON for this objects' rejection.
"""
return {
"type": "Reject",
"id": self.uri + "#reject",
"actor": self.target.actor_uri,
"object": self.to_ap(),
}
def to_undo_ap(self): def to_undo_ap(self):
""" """
Returns the AP JSON for this objects' undo. Returns the AP JSON for this objects' undo.
@ -268,14 +324,14 @@ class Follow(StatorModel):
source = Identity.by_actor_uri(data["actor"], create=create) source = Identity.by_actor_uri(data["actor"], create=create)
target = Identity.by_actor_uri(get_str_or_id(data["object"])) target = Identity.by_actor_uri(get_str_or_id(data["object"]))
follow = cls.maybe_get(source=source, target=target) follow = cls.maybe_get(source=source, target=target)
# If it doesn't exist, create one in the remote_requested state # If it doesn't exist, create one in the unrequested state
if follow is None: if follow is None:
if create: if create:
return cls.objects.create( return cls.objects.create(
source=source, source=source,
target=target, target=target,
uri=data["id"], uri=data["id"],
state=FollowStates.remote_requested, state=FollowStates.unrequested,
) )
else: else:
raise cls.DoesNotExist( raise cls.DoesNotExist(
@ -289,7 +345,6 @@ class Follow(StatorModel):
""" """
Handles an incoming follow request Handles an incoming follow request
""" """
from activities.models import TimelineEvent
with transaction.atomic(): with transaction.atomic():
try: try:
@ -299,11 +354,9 @@ class Follow(StatorModel):
"Identity not found for incoming Follow", extras={"data": data} "Identity not found for incoming Follow", extras={"data": data}
) )
return return
if follow.state == FollowStates.accepted:
# Force it into remote_requested so we send an accept # Likely the source server missed the Accept, send it back again
follow.transition_perform(FollowStates.remote_requested) follow.transition_perform(FollowStates.accepting)
# Add a timeline event
TimelineEvent.add_follow(follow.target, follow.source)
@classmethod @classmethod
def handle_accept_ap(cls, data): def handle_accept_ap(cls, data):
@ -324,11 +377,8 @@ class Follow(StatorModel):
if data["actor"] != follow.target.actor_uri: if data["actor"] != follow.target.actor_uri:
raise ValueError("Accept actor does not match its Follow object", data) raise ValueError("Accept actor does not match its Follow object", data)
# If the follow was waiting to be accepted, transition it # If the follow was waiting to be accepted, transition it
if follow and follow.state in [ if follow and follow.state == FollowStates.pending_approval:
FollowStates.unrequested, follow.transition_perform(FollowStates.accepting)
FollowStates.local_requested,
]:
follow.transition_perform(FollowStates.accepted)
@classmethod @classmethod
def handle_reject_ap(cls, data): def handle_reject_ap(cls, data):
@ -348,8 +398,17 @@ class Follow(StatorModel):
# Ensure the Accept actor is the Follow's target # Ensure the Accept actor is the Follow's target
if data["actor"] != follow.target.actor_uri: if data["actor"] != follow.target.actor_uri:
raise ValueError("Reject actor does not match its Follow object", data) raise ValueError("Reject actor does not match its Follow object", data)
# Clear timeline if remote target remove local source from their previously accepted follows
if follow.accepted:
InboxMessage.create_internal(
{
"type": "ClearTimeline",
"object": follow.target.pk,
"actor": follow.source.pk,
}
)
# Mark the follow rejected # Mark the follow rejected
follow.transition_perform(FollowStates.rejected) follow.transition_perform(FollowStates.rejecting)
@classmethod @classmethod
def handle_undo_ap(cls, data): def handle_undo_ap(cls, data):
@ -369,4 +428,4 @@ class Follow(StatorModel):
if data["actor"] != follow.source.actor_uri: if data["actor"] != follow.source.actor_uri:
raise ValueError("Accept actor does not match its Follow object", data) raise ValueError("Accept actor does not match its Follow object", data)
# Delete the follow # Delete the follow
follow.delete() follow.transition_perform(FollowStates.pending_removal)

View file

@ -991,7 +991,7 @@ class Identity(StatorModel):
"avatar_static": self.local_icon_url().absolute, "avatar_static": self.local_icon_url().absolute,
"header": header_image.absolute if header_image else missing, "header": header_image.absolute if header_image else missing,
"header_static": header_image.absolute if header_image else missing, "header_static": header_image.absolute if header_image else missing,
"locked": False, "locked": bool(self.manually_approves_followers),
"fields": ( "fields": (
[ [
{ {

View file

@ -73,7 +73,7 @@ class IdentityService:
return ( return (
Identity.objects.filter( Identity.objects.filter(
outbound_follows__target=self.identity, outbound_follows__target=self.identity,
inbound_follows__state__in=FollowStates.group_active(), outbound_follows__state=FollowStates.accepted,
) )
.not_deleted() .not_deleted()
.distinct() .distinct()
@ -81,6 +81,28 @@ class IdentityService:
.select_related("domain") .select_related("domain")
) )
def follow_requests(self) -> models.QuerySet[Identity]:
return (
Identity.objects.filter(
outbound_follows__target=self.identity,
outbound_follows__state=FollowStates.pending_approval,
)
.not_deleted()
.distinct()
.order_by("username")
.select_related("domain")
)
def accept_follow_request(self, source_identity):
existing_follow = Follow.maybe_get(source_identity, self.identity)
if existing_follow:
existing_follow.transition_perform(FollowStates.accepting)
def reject_follow_request(self, source_identity):
existing_follow = Follow.maybe_get(source_identity, self.identity)
if existing_follow:
existing_follow.transition_perform(FollowStates.rejecting)
def follow(self, target_identity: Identity, boosts=True) -> Follow: def follow(self, target_identity: Identity, boosts=True) -> Follow:
""" """
Follows a user (or does nothing if already followed). Follows a user (or does nothing if already followed).
@ -114,6 +136,7 @@ class IdentityService:
if target_identity == self.identity: if target_identity == self.identity:
raise ValueError("You cannot block yourself") raise ValueError("You cannot block yourself")
self.unfollow(target_identity) self.unfollow(target_identity)
self.reject_follow_request(target_identity)
block = Block.create_local_block(self.identity, target_identity) block = Block.create_local_block(self.identity, target_identity)
InboxMessage.create_internal( InboxMessage.create_internal(
{ {
@ -221,8 +244,10 @@ class IdentityService:
relationships = self.relationships(from_identity) relationships = self.relationships(from_identity)
return { return {
"id": self.identity.pk, "id": self.identity.pk,
"following": relationships["outbound_follow"] is not None, "following": relationships["outbound_follow"] is not None
"followed_by": relationships["inbound_follow"] is not None, and relationships["outbound_follow"].accepted,
"followed_by": relationships["inbound_follow"] is not None
and relationships["inbound_follow"].accepted,
"showing_reblogs": ( "showing_reblogs": (
relationships["outbound_follow"] relationships["outbound_follow"]
and relationships["outbound_follow"].boosts and relationships["outbound_follow"].boosts
@ -233,7 +258,8 @@ class IdentityService:
"blocked_by": relationships["inbound_block"] is not None, "blocked_by": relationships["inbound_block"] is not None,
"muting": relationships["outbound_mute"] is not None, "muting": relationships["outbound_mute"] is not None,
"muting_notifications": False, "muting_notifications": False,
"requested": False, "requested": relationships["outbound_follow"] is not None
and relationships["outbound_follow"].state == FollowStates.pending_approval,
"domain_blocking": False, "domain_blocking": False,
"endorsed": False, "endorsed": False,
"note": ( "note": (