lib.itmens/mastodon/models/bluesky.py

272 lines
9.6 KiB
Python
Raw Normal View History

2024-07-05 10:53:43 -04:00
import re
2024-07-05 16:26:26 -04:00
import typing
2024-07-04 00:17:12 -04:00
from functools import cached_property
from atproto import Client, SessionEvent, client_utils
2024-07-04 12:43:45 -04:00
from atproto_client import models
2024-07-05 19:05:50 -04:00
from atproto_client.exceptions import AtProtocolError
2024-07-05 10:53:43 -04:00
from atproto_identity.did.resolver import DidResolver
from atproto_identity.handle.resolver import HandleResolver
2024-07-04 00:17:12 -04:00
from django.utils import timezone
from loguru import logger
2024-07-01 17:29:38 -04:00
from catalog.common import jsondata
2024-07-05 19:05:50 -04:00
from takahe.utils import Takahe
2024-07-01 17:29:38 -04:00
from .common import SocialAccount
2024-07-05 16:26:26 -04:00
if typing.TYPE_CHECKING:
from catalog.common.models import Item
from journal.models.common import Content
2024-07-01 17:29:38 -04:00
class Bluesky:
2024-07-05 10:53:43 -04:00
_DOMAIN = "-"
_RE_HANDLE = re.compile(
2024-07-05 16:26:26 -04:00
r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"
2024-07-05 10:53:43 -04:00
)
# for BlueskyAccount
# uid is did and the only unique identifier
# domain is not useful and will always be _DOMAIN
# handle and base_url may change in BlueskyAccount.refresh()
2024-07-04 00:17:12 -04:00
@staticmethod
2024-07-05 10:53:43 -04:00
def authenticate(handle: str, password: str) -> "BlueskyAccount | None":
if not Bluesky._RE_HANDLE.match(handle) or len(handle) > 500:
logger.warning(f"ATProto login failed: handle {handle} is invalid")
return None
2024-07-04 00:17:12 -04:00
try:
2024-07-05 10:53:43 -04:00
handle_r = HandleResolver(timeout=5)
did = handle_r.resolve(handle)
if not did:
logger.warning(
f"ATProto login failed: handle {handle} -> <missing did>"
)
return
did_r = DidResolver()
did_doc = did_r.resolve(did)
if not did_doc:
logger.warning(
f"ATProto login failed: handle {handle} -> did {did} -> <missing doc>"
)
return
resolved_handle = did_doc.get_handle()
if resolved_handle != handle:
logger.warning(
f"ATProto login failed: handle {handle} -> did {did} -> handle {resolved_handle}"
)
return
base_url = did_doc.get_pds_endpoint()
client = Client(base_url)
profile = client.login(handle, password)
2024-07-04 00:17:12 -04:00
session_string = client.export_session_string()
except Exception as e:
2024-07-05 10:53:43 -04:00
logger.debug(f"Bluesky login {handle} exception {e}")
return
account = BlueskyAccount.objects.filter(
uid=profile.did, domain=Bluesky._DOMAIN
2024-07-04 00:17:12 -04:00
).first()
2024-07-05 10:53:43 -04:00
if not account:
account = BlueskyAccount(uid=profile.did, domain=Bluesky._DOMAIN)
account._client = client
2024-07-04 00:17:12 -04:00
account.session_string = session_string
2024-07-05 10:53:43 -04:00
account.base_url = base_url
if account.pk:
2024-07-05 18:15:10 -04:00
account.refresh(save=True, did_check=False)
2024-07-05 10:53:43 -04:00
else:
2024-07-05 18:15:10 -04:00
account.refresh(save=False, did_check=False)
2024-07-04 00:17:12 -04:00
return account
2024-07-01 17:29:38 -04:00
class BlueskyAccount(SocialAccount):
2024-07-04 00:17:12 -04:00
# app_username = jsondata.CharField(json_field_name="access_data", default="")
# app_password = jsondata.EncryptedTextField(
# json_field_name="access_data", default=""
# )
2024-07-05 10:53:43 -04:00
base_url = jsondata.CharField(json_field_name="access_data", default=None)
2024-07-04 00:17:12 -04:00
session_string = jsondata.EncryptedTextField(
2024-07-01 17:29:38 -04:00
json_field_name="access_data", default=""
)
2024-07-05 10:53:43 -04:00
display_name = jsondata.CharField(json_field_name="account_data", default="")
description = jsondata.CharField(json_field_name="account_data", default="")
avatar = jsondata.CharField(json_field_name="account_data", default="")
2024-07-04 00:17:12 -04:00
def on_session_change(self, event, session) -> None:
if event in (SessionEvent.CREATE, SessionEvent.REFRESH):
session_string = session.export()
if session_string != self.session_string:
self.session_string = session_string
if self.pk:
self.save(update_fields=["access_data"])
@cached_property
def _client(self):
client = Client()
client.on_session_change(self.on_session_change)
self._profile = client.login(session_string=self.session_string)
return client
@property
def url(self):
2024-07-05 16:26:26 -04:00
return f"https://{self.handle}"
2024-07-04 00:17:12 -04:00
2024-07-05 18:15:10 -04:00
def check_alive(self, save=True):
did = self.uid
did_r = DidResolver()
handle_r = HandleResolver(timeout=5)
did_doc = did_r.resolve(did)
if not did_doc:
logger.warning(f"ATProto refresh failed: did {did} -> <missing doc>")
return False
resolved_handle = did_doc.get_handle()
if not resolved_handle:
logger.warning(f"ATProto refresh failed: did {did} -> <missing handle>")
return False
resolved_did = handle_r.resolve(resolved_handle)
resolved_pds = did_doc.get_pds_endpoint()
if did != resolved_did:
logger.warning(
f"ATProto refresh failed: did {did} -> handle {resolved_handle} -> did {resolved_did}"
)
return False
if resolved_handle != self.handle:
logger.debug(
f"ATProto refresh: handle changed for did {did}: handle {self.handle} -> {resolved_handle}"
)
self.handle = resolved_handle
if resolved_pds != self.base_url:
logger.debug(
f"ATProto refresh: pds changed for did {did}: handle {self.base_url} -> {resolved_pds}"
)
self.base_url = resolved_pds
self.last_reachable = timezone.now()
if save:
self.save(
update_fields=[
"access_data",
"handle",
"last_reachable",
]
)
return True
def refresh(self, save=True, did_check=True):
if did_check:
self.check_alive(save=save)
2024-07-05 10:53:43 -04:00
profile = self._client.me
2024-07-04 00:17:12 -04:00
if not profile:
2024-07-05 10:53:43 -04:00
logger.warning("Bluesky: client not logged in.") # this should not happen
2024-07-05 18:15:10 -04:00
return False
2024-07-05 10:53:43 -04:00
if self.handle != profile.handle:
2024-07-05 22:01:04 -04:00
if self.handle:
logger.warning(
f"ATProto refresh: handle mismatch {self.handle} from did doc -> {profile.handle} from PDS"
)
self.handle = profile.handle
2024-07-04 00:17:12 -04:00
self.account_data = {
k: v for k, v in profile.__dict__.items() if isinstance(v, (int, str))
}
self.last_refresh = timezone.now()
if save:
self.save(
update_fields=[
"account_data",
"last_reachable",
]
)
2024-07-05 18:15:10 -04:00
return True
2024-07-04 00:17:12 -04:00
2024-07-05 19:05:50 -04:00
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
2024-07-05 16:26:26 -04:00
def post(
self,
content,
reply_to_id=None,
obj: "Item | Content | None" = None,
rating=None,
**kwargs,
):
from journal.models.renderers import render_rating
2024-07-04 12:43:45 -04:00
reply_to = None
if reply_to_id:
posts = self._client.get_posts([reply_to_id]).posts
if posts:
root_post_ref = models.create_strong_ref(posts[0])
reply_to = models.AppBskyFeedPost.ReplyRef(
parent=root_post_ref, root=root_post_ref
)
2024-07-05 16:26:26 -04:00
text = (
content.replace("##rating##", render_rating(rating))
.replace("##obj_link_if_plain##", "")
.split("##obj##")
)
richtext = client_utils.TextBuilder()
first = True
for t in text:
if not first and obj:
richtext.link(obj.display_title, obj.absolute_url)
else:
first = False
richtext.text(t)
if obj:
embed = models.AppBskyEmbedExternal.Main(
external=models.AppBskyEmbedExternal.External(
title=obj.display_title,
2024-07-13 00:16:47 -04:00
description=obj.brief_description,
2024-07-05 16:26:26 -04:00
uri=obj.absolute_url,
)
)
else:
embed = None
post = self._client.send_post(richtext, reply_to=reply_to, embed=embed)
2024-07-04 12:43:45 -04:00
# return AT uri as id since it's used as so.
return {"cid": post.cid, "id": post.uri}
def delete_post(self, post_uri):
self._client.delete_post(post_uri)