bluesky graph

This commit is contained in:
Your Name 2024-07-05 19:05:50 -04:00 committed by Henri Dickson
parent e29b921d34
commit 196ac2a616
6 changed files with 235 additions and 173 deletions

View file

@ -4,12 +4,14 @@ from functools import cached_property
from atproto import Client, SessionEvent, client_utils from atproto import Client, SessionEvent, client_utils
from atproto_client import models from atproto_client import models
from atproto_client.exceptions import AtProtocolError
from atproto_identity.did.resolver import DidResolver from atproto_identity.did.resolver import DidResolver
from atproto_identity.handle.resolver import HandleResolver from atproto_identity.handle.resolver import HandleResolver
from django.utils import timezone from django.utils import timezone
from loguru import logger from loguru import logger
from catalog.common import jsondata from catalog.common import jsondata
from takahe.utils import Takahe
from .common import SocialAccount from .common import SocialAccount
@ -172,6 +174,52 @@ class BlueskyAccount(SocialAccount):
) )
return True return True
def refresh_graph(self, save=True) -> bool:
try:
r = self._client.get_followers(self.uid)
self.followers = [p.did for p in r.followers]
r = self._client.get_follows(self.uid)
self.following = [p.did for p in r.follows]
r = self._client.app.bsky.graph.get_mutes(
models.AppBskyGraphGetMutes.Params(cursor=None, limit=None)
)
self.mutes = [p.did for p in r.mutes]
except AtProtocolError as e:
logger.warning(f"{self} refresh_graph error: {e}")
return False
if save:
self.save(
update_fields=[
"followers",
"following",
"mutes",
]
)
return True
def sync_graph(self):
c = 0
def get_identity_ids(accts: list):
return set(
BlueskyAccount.objects.filter(
domain=Bluesky._DOMAIN, uid__in=accts
).values_list("user__identity", flat=True)
)
me = self.user.identity.pk
for target_identity in get_identity_ids(self.following):
if not Takahe.get_is_following(me, target_identity):
Takahe.follow(me, target_identity, True)
c += 1
for target_identity in get_identity_ids(self.mutes):
if not Takahe.get_is_muting(me, target_identity):
Takahe.mute(me, target_identity)
c += 1
return c
def post( def post(
self, self,
content, content,

View file

@ -44,13 +44,13 @@ class SocialAccount(TypedModel):
last_refresh = models.DateTimeField(default=None, null=True) last_refresh = models.DateTimeField(default=None, null=True)
last_reachable = models.DateTimeField(default=None, null=True) last_reachable = models.DateTimeField(default=None, null=True)
sync_profile = jsondata.BooleanField( # sync_profile = jsondata.BooleanField(
json_field_name="preference_data", default=True # json_field_name="preference_data", default=True
) # )
sync_graph = jsondata.BooleanField(json_field_name="preference_data", default=True) # sync_graph = jsondata.BooleanField(json_field_name="preference_data", default=True)
sync_timeline = jsondata.BooleanField( # sync_timeline = jsondata.BooleanField(
json_field_name="preference_data", default=True # json_field_name="preference_data", default=True
) # )
class Meta: class Meta:
indexes = [ indexes = [
@ -124,3 +124,6 @@ class SocialAccount(TypedModel):
self.refresh_graph() self.refresh_graph()
logger.debug(f"{self} refreshed") logger.debug(f"{self} refreshed")
return True return True
def sync_graph(self) -> int:
return 0

View file

@ -778,6 +778,46 @@ class MastodonAccount(SocialAccount):
) )
return True return True
def sync_graph(self):
c = 0
def get_identity_ids(accts: list):
return set(
MastodonAccount.objects.filter(handle__in=accts).values_list(
"user__identity", flat=True
)
)
def get_identity_ids_in_domains(domains: list):
return set(
MastodonAccount.objects.filter(domain__in=domains).values_list(
"user__identity", flat=True
)
)
me = self.user.identity.pk
for target_identity in get_identity_ids(self.following):
if not Takahe.get_is_following(me, target_identity):
Takahe.follow(me, target_identity, True)
c += 1
for target_identity in get_identity_ids(self.blocks):
if not Takahe.get_is_blocking(me, target_identity):
Takahe.block(me, target_identity)
c += 1
for target_identity in get_identity_ids_in_domains(self.domain_blocks):
if not Takahe.get_is_blocking(me, target_identity):
Takahe.block(me, target_identity)
c += 1
for target_identity in get_identity_ids(self.mutes):
if not Takahe.get_is_muting(me, target_identity):
Takahe.mute(me, target_identity)
c += 1
return c
def boost(self, post_url: str): def boost(self, post_url: str):
boost_toot(self._api_domain, self.access_token, post_url) boost_toot(self._api_domain, self.access_token, post_url)

View file

@ -254,40 +254,6 @@ class User(AbstractUser):
self.identity.save() self.identity.save()
self.social_accounts.all().delete() self.social_accounts.all().delete()
def sync_relationship(self):
def get_identity_ids(accts: list):
return set(
MastodonAccount.objects.filter(handle__in=accts).values_list(
"user__identity", flat=True
)
)
def get_identity_ids_in_domains(domains: list):
return set(
MastodonAccount.objects.filter(domain__in=domains).values_list(
"user__identity", flat=True
)
)
me = self.identity.pk
if not self.mastodon:
return
for target_identity in get_identity_ids(self.mastodon.following):
if not Takahe.get_is_following(me, target_identity):
Takahe.follow(me, target_identity, True)
for target_identity in get_identity_ids(self.mastodon.blocks):
if not Takahe.get_is_blocking(me, target_identity):
Takahe.block(me, target_identity)
for target_identity in get_identity_ids_in_domains(self.mastodon.domain_blocks):
if not Takahe.get_is_blocking(me, target_identity):
Takahe.block(me, target_identity)
for target_identity in get_identity_ids(self.mastodon.mutes):
if not Takahe.get_is_muting(me, target_identity):
Takahe.mute(me, target_identity)
def sync_identity(self): def sync_identity(self):
"""sync display name, bio, and avatar from available sources""" """sync display name, bio, and avatar from available sources"""
identity = self.identity.takahe_identity identity = self.identity.takahe_identity
@ -356,17 +322,18 @@ class User(AbstractUser):
if skip_graph: if skip_graph:
return return
if not self.preference.mastodon_skip_relationship: if not self.preference.mastodon_skip_relationship:
self.sync_relationship() c = 0
return for account in self.social_accounts.all():
c += account.sync_graph()
if c:
logger.debug(f"{self} graph updated with {c} new relationship.")
@staticmethod @staticmethod
def sync_accounts_task(user_id): def sync_accounts_task(user_id):
user = User.objects.get(pk=user_id) user = User.objects.get(pk=user_id)
logger.info(f"{user} accounts sync start") logger.info(f"{user} accounts sync start")
if user.sync_accounts(): user.sync_accounts()
logger.info(f"{user} accounts sync done") logger.info(f"{user} accounts sync end")
else:
logger.warning(f"{user} accounts sync failed")
def sync_accounts_later(self): def sync_accounts_later(self):
django_rq.get_queue("mastodon").enqueue(User.sync_accounts_task, self.pk) django_rq.get_queue("mastodon").enqueue(User.sync_accounts_task, self.pk)

View file

@ -129,143 +129,144 @@
{% endif %} {% endif %}
</details> </details>
</article> </article>
{% if enable_threads %}
<article>
<details>
<summary>{% trans "Threads.net" %}</summary>
<form action="{% url 'mastodon:threads_reconnect' %}" method="post">
{% csrf_token %}
<fieldset>
{% if request.user.threads %}
<label>
<i class="fa-brands fa-threads"></i> {% trans "Verified threads.net account" %}
<input type="input"
aria-invalid="false"
value="{{ request.user.threads.handle }}"
readonly>
<small>
{% if request.user.threads.last_refresh %}
{% trans "Last updated" %} {{ request.user.threads.last_refresh }}
{% endif %}
</small>
</label>
{% endif %}
<input type="submit"
value="{% if request.user.threads %} {% trans 'Link with a different threads.net account' %} {% else %} {% trans "Link with a threads.net account" %} {% endif %} " />
</fieldset>
</form>
{% if request.user.threads %}
<form action="{% url 'mastodon:threads_disconnect' %}"
method="post"
onsubmit="return confirm('{% trans "Once disconnected, you will no longer be able login with this identity. Are you sure to continue?" %}')">
{% csrf_token %}
<input type="submit"
value="{% trans 'Disconnect with Threads' %}"
class="secondary" />
</form>
{% endif %}
</details>
</article>
{% endif %}
{% if enable_bluesky %}
<article>
<details>
<summary>{% trans "Bluesky (ATProto)" %}</summary>
<form action="{% url 'mastodon:bluesky_reconnect' %}" method="post">
{% csrf_token %}
<fieldset>
{% if request.user.bluesky %}
<label>
<i class="fa-brands fa-bluesky"></i> {% trans "Verified ATProto identity" %}
<input type="input"
aria-invalid="false"
value="@{{ request.user.bluesky.handle }} {{ request.user.bluesky.uid }}"
readonly>
<small>
{% if request.user.bluesky.last_refresh %}
{% trans "Last updated" %} {{ request.user.bluesky.last_refresh }}
{% endif %}
</small>
</label>
{% endif %}
<input required
name="username"
autofocus
placeholder="{% trans 'Bluesky Login ID' %}"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck="false" />
<input required
type="password"
name="password"
placeholder="{% trans 'Bluesky app password' %}"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck="false" />
<input type="submit"
value="{% if request.user.bluesky %} {% trans 'Link with a different ATProto identity' %} {% else %} {% trans "Link with an ATProto identity" %} {% endif %} " />
<small>{% blocktrans %}App password can be created on <a href="https://bsky.app/settings/app-passwords" target="_blank">bsky.app</a>.{% endblocktrans %}</small>
</fieldset>
</form>
{% if request.user.bluesky %}
<form action="{% url 'mastodon:bluesky_disconnect' %}"
method="post"
onsubmit="return confirm('{% trans "Once disconnected, you will no longer be able login with this identity. Are you sure to continue?" %}')">
{% csrf_token %}
<input type="submit"
value="{% trans 'Disconnect with ATProto identity' %}"
class="secondary" />
</form>
{% endif %}
</details>
</article>
{% endif %}
{% endif %}
{% if request.user.social_accounts.all %}
<article> <article>
<details> <details>
<summary>{% trans "Threads.net" %}</summary> <summary>{% trans 'Sync and import social account' %}</summary>
<form action="{% url 'mastodon:threads_reconnect' %}" method="post"> <form action="{% url 'users:sync_mastodon_preference' %}"
method="post"
enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>
{% if request.user.threads %} <label>
<label> <input type="checkbox"
<i class="fa-brands fa-threads"></i> {% trans "Verified threads.net account" %} name="mastodon_sync_userinfo"
<input type="input" {% if not request.user.preference.mastodon_skip_userinfo %}checked{% endif %}>
aria-invalid="false" {% trans 'Sync display name, bio and avatar' %}
value="{{ request.user.threads.handle }}" </label>
readonly>
<small>
{% if request.user.threads.last_refresh %}
{% trans "Last updated" %} {{ request.user.threads.last_refresh }}
{% endif %}
</small>
</label>
{% endif %}
<input type="submit"
value="{% if request.user.threads %} {% trans 'Link with a different threads.net account' %} {% else %} {% trans "Link with a threads.net account" %} {% endif %} " />
</fieldset> </fieldset>
</form>
{% if request.user.threads %}
<form action="{% url 'mastodon:threads_disconnect' %}"
method="post"
onsubmit="return confirm('{% trans "Once disconnected, you will no longer be able login with this identity. Are you sure to continue?" %}')">
{% csrf_token %}
<input type="submit"
value="{% trans 'Disconnect with Threads' %}"
class="secondary" />
</form>
{% endif %}
</details>
</article>
<article>
<details>
<summary>{% trans "Bluesky (ATProto)" %}</summary>
<form action="{% url 'mastodon:bluesky_reconnect' %}" method="post">
{% csrf_token %}
<fieldset> <fieldset>
{% if request.user.bluesky %} <label>
<label> <input type="checkbox"
<i class="fa-brands fa-bluesky"></i> {% trans "Verified ATProto identity" %} name="mastodon_sync_relationship"
<input type="input" {% if not request.user.preference.mastodon_skip_relationship %}checked{% endif %}>
aria-invalid="false" {% trans 'Sync follow, mute and block' %}
value="@{{ request.user.bluesky.handle }} {{ request.user.bluesky.uid }}" </label>
readonly>
<small>
{% if request.user.bluesky.last_refresh %}
{% trans "Last updated" %} {{ request.user.bluesky.last_refresh }}
{% endif %}
</small>
</label>
{% endif %}
<input required
type="email"
name="username"
autofocus
placeholder="{% trans 'Bluesky Login ID' %}"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck="false" />
<input required
type="password"
name="password"
placeholder="{% trans 'Bluesky app password' %}"
autocorrect="off"
autocapitalize="off"
autocomplete="off"
spellcheck="false" />
<input type="submit"
value="{% if request.user.bluesky %} {% trans 'Link with a different ATProto identity' %} {% else %} {% trans "Link with an ATProto identity" %} {% endif %} " />
<small>{% blocktrans %}App password can be created on <a href="https://bsky.app/settings/app-passwords" target="_blank">bsky.app</a>.{% endblocktrans %}</small>
</fieldset> </fieldset>
<input type="submit" value="{% trans 'Save sync settings' %}" />
<small>
{% trans "New follow, mute and blocks in the associated identity may be automatically imported; removal has to be done manually." %}
</small>
</form>
<form action="{% url 'users:sync_mastodon' %}"
method="post"
enctype="multipart/form-data">
{% csrf_token %}
<small>{% trans "Click button below to start sync now." %}</small>
<input type="submit" value="{% trans 'Sync now' %}" />
<small>
{% if request.user.mastodon.last_refresh %}
{% trans "Last updated" %} {{ request.user.mastodon.last_refresh }}
{% endif %}
</small>
</form> </form>
{% if request.user.bluesky %}
<form action="{% url 'mastodon:bluesky_disconnect' %}"
method="post"
onsubmit="return confirm('{% trans "Once disconnected, you will no longer be able login with this identity. Are you sure to continue?" %}')">
{% csrf_token %}
<input type="submit"
value="{% trans 'Disconnect with ATProto identity' %}"
class="secondary" />
</form>
{% endif %}
</details> </details>
</article> </article>
{% endif %} {% endif %}
<article>
<details>
<summary>{% trans 'Sync and import social account' %}</summary>
<form action="{% url 'users:sync_mastodon_preference' %}"
method="post"
enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<label>
<input type="checkbox"
name="mastodon_sync_userinfo"
{% if not request.user.preference.mastodon_skip_userinfo %}checked{% endif %}>
{% trans 'Sync display name, bio and avatar' %}
</label>
</fieldset>
<fieldset>
<label>
<input type="checkbox"
name="mastodon_sync_relationship"
{% if not request.user.preference.mastodon_skip_relationship %}checked{% endif %}>
{% trans 'Sync follow, mute and block' %}
</label>
</fieldset>
<input type="submit"
value="{% trans 'Save sync settings' %}"
{% if not request.user.mastodon_username %}disabled{% endif %} />
<small>
{% trans "New follow, mute and blocks in the associated identity may be automatically imported; removal has to be done manually." %}
</small>
</form>
<form action="{% url 'users:sync_mastodon' %}"
method="post"
enctype="multipart/form-data">
{% csrf_token %}
<small>{% trans "Click button below to start sync now." %}</small>
<input type="submit"
value="{% trans 'Sync now' %}"
{% if not request.user.mastodon_username %}disabled{% endif %} />
<small>
{% if request.user.mastodon.last_refresh %}
{% trans "Last updated" %} {{ request.user.mastodon.last_refresh }}
{% endif %}
</small>
</form>
</details>
</article>
<article> <article>
<details> <details>
<summary>{% trans 'Users you are following' %}</summary> <summary>{% trans 'Users you are following' %}</summary>

View file

@ -37,6 +37,9 @@ def account_info(request):
"users/account.html", "users/account.html",
{ {
"allow_any_site": settings.MASTODON_ALLOW_ANY_SITE, "allow_any_site": settings.MASTODON_ALLOW_ANY_SITE,
"enable_email": settings.ENABLE_LOGIN_EMAIL,
"enable_threads": settings.ENABLE_LOGIN_THREADS,
"enable_bluesky": settings.ENABLE_LOGIN_BLUESKY,
"profile_form": profile_form, "profile_form": profile_form,
}, },
) )