Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Bluesky changes needed for backfeed #571

Merged
merged 12 commits into from
Oct 23, 2023
161 changes: 150 additions & 11 deletions granary/bluesky.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* https://atproto.com/lexicons/app-bsky-actor
* https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky
"""
import copy
import json
import logging
import re
Expand Down Expand Up @@ -771,6 +770,7 @@ def to_as1(obj, type=None, repo_did=None, repo_handle=None, pds=DEFAULT_PDS):
reason = obj.get('reason')
if reason and reason.get('$type') == 'app.bsky.feed.defs#reasonRepost':
ret = {
'id': obj.get('post', {}).get('viewer', {}).get('repost'),
'objectType': 'activity',
'verb': 'share',
'object': ret,
Expand Down Expand Up @@ -992,15 +992,154 @@ def get_activities_response(self, user_id=None, group_id=None, app_id=None,
resp = self.client.app.bsky.feed.getAuthorFeed({}, actor=handle, **params)
posts = resp.get('feed', [])

# TODO: inReplyTo
ret = self.make_activities_base_response(
trim_nulls(to_as1(post, type='app.bsky.feed.defs#feedViewPost',
repo_did=self.did, repo_handle=handle))
for post in posts
)
ret['actor'] = {
'id': self.did,
'displayName': self.handle,
'url': self.user_url(self.handle),
if cache is None:
# for convenience, throwaway object just for this method
cache = {}

activities = []

for post in posts:
reason = post.get('reason')
is_repost = reason and reason.get('$type') == 'app.bsky.feed.defs#reasonRepost'
if is_repost and not include_shares:
continue

activity = self.postprocess_activity(self._post_to_activity(post))
activities.append(activity)
obj = activity['object']
id = obj.get('id')
tags = obj.setdefault('tags', [])

if is_repost:
# If it's a repost we're not interested in responses to it.
continue
bs_post = post.get('post')
if bs_post and id:
# Likes
like_count = bs_post.get('likeCount')
if fetch_likes and like_count and like_count != cache.get('ABL ' + id):
likers = self.client.app.bsky.feed.getLikes({}, uri=bs_post.get('uri'))
tags.extend(self._make_like(bs_post, l.get('actor')) for l in likers.get('likes'))
cache['ABL ' + id] = count

# Reposts
repost_count = bs_post.get('repostCount')
if fetch_shares and repost_count and repost_count != cache.get('ABRP ' + id):
reposters = self.client.app.bsky.feed.getRepostedBy({}, uri=bs_post.get('uri'))
tags.extend(self._make_share(bs_post, r) for r in reposters.get('repostedBy'))
cache['ABRP ' + id] = count

# Replies
reply_count = bs_post.get('replyCount')
if fetch_replies and reply_count and reply_count != cache.get('ABR ' + id):
replies = self._get_replies(bs_post.get('uri'))
replies = [to_as1(reply, 'app.bsky.feed.defs#threadViewPost') for reply in replies]
for r in replies:
r['id'] = self.tag_uri(r['id'])
obj['replies'] = {
'items': replies,
}
cache['ABR ' + id] = count

resp = self.make_activities_base_response(util.trim_nulls(activities))
return resp

def get_comment(self, comment_id, **kwargs):
"""Fetches and returns a comment.

Args:
comment_id: string status id
**kwargs: unused

Returns: dict, ActivityStreams object

Raises:
:class:`ValueError`: if comment_id is invalid
"""
post_thread = self.client.app.bsky.feed.getPostThread({}, uri=comment_id, depth=1)
obj = to_as1(post_thread.get('thread'), 'app.bsky.feed.defs#threadViewPost')
return obj

def _post_to_activity(self, post):
"""Converts a post to an activity.

Args:
post: Bluesky post app.bluesky.feed.defs#feedViewPost

Returns: AS1 activity
"""
obj = to_as1(post, type='app.bsky.feed.defs#feedViewPost')
if obj.get('objectType') == 'activity':
return obj
return {
'id': obj.get('id'),
'verb': 'post',
'actor': obj.get('author'),
'object': obj,
'objectType': 'activity',
}

def _make_like(self, post, actor):
return self._make_like_or_share(post, actor, 'like')

def _make_share(self, post, actor):
return self._make_like_or_share(post, actor, 'share')

def _make_like_or_share(self, post, actor, verb):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now have like/share support in to_as1, so we may want to use that eventually instead. Low priority though, definitely doesn't have to be in this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah I might leave this for now just until I untangle what we need to do with actors/IDs/tags and whether that stuff ought to live in to_as1 or out here?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely! Yeah I think to_as1 can do most of these, especially if we start using its existing did and handle kwargs for actor, but we can do that later.

"""Generates and returns a ActivityStreams like object.

Args:
post: dict, Bluesky app.bsky.feed.defs#feedViewPost
actor: dict, Bluesky app.bsky.actor.defs#profileView
verb: string, 'like' or 'share'

Returns: dict, AS1 like activity
"""
assert verb in ('like', 'share')
label = 'liked' if verb == 'like' else 'reposted'
url = at_uri_to_web_url(post.get('uri'), post.get('author').get('handle'))
actor_id = actor.get('did')
author = to_as1(actor, 'app.bsky.actor.defs#profileView')
author['id'] = self.tag_uri(author['id'])
return {
'id': self.tag_uri(f"{post.get('uri')}_{label}_by_{actor_id}"),
'url': url,
'objectType': 'activity',
'verb': verb,
'object': {'url': url},
'author': author,
}

def _get_replies(self, uri):
"""
Gets the replies to a specific post and returns them
in ascending order of creation. Does not include the original post.

Args:
uri: string, post uri

Returns: list, Bluesky app.bsky.feed.defs#threadViewPost
"""
ret = []
resp = self.client.app.bsky.feed.getPostThread({}, uri=uri)
thread = resp.get('thread')
if thread:
ret = self._recurse_replies(thread)
return sorted(ret, key = lambda thread: thread.get('post', {}).get('record', {}).get('createdAt'))

# TODO this ought to take a depth limit.
def _recurse_replies(self, thread):
JoelOtter marked this conversation as resolved.
Show resolved Hide resolved
"""
Recurses through a Bluesky app.bsky.feed.defs#threadViewPost
and returns its replies as a list.

Args:
thread: dict, Bluesky app.bsky.feed.defs#threadViewPost

Returns: list, Bluesky app.bsky.feed.defs#threadViewPost
"""
ret = []
for r in thread.get('replies', []):
ret += [r]
ret += self._recurse_replies(r)
return ret
Loading