Skip to content

Commit

Permalink
Implement Bluesky changes needed for backfeed
Browse files Browse the repository at this point in the history
  • Loading branch information
JoelOtter committed Oct 7, 2023
1 parent 07b6cca commit b45d1f9
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 14 deletions.
174 changes: 162 additions & 12 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 @@ -584,7 +583,6 @@ def to_as1(obj, type=None, repo_did=None, repo_handle=None, pds=DEFAULT_PDS):

kwargs = {'repo_did': repo_did, 'repo_handle': repo_handle, 'pds': pds}

# TODO: once we're on Python 3.10, switch this to a match statement!
if type in ('app.bsky.actor.profile',
'app.bsky.actor.defs#profileView',
'app.bsky.actor.defs#profileViewBasic',
Expand Down Expand Up @@ -773,6 +771,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 @@ -974,15 +973,166 @@ 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 user_to_actor(self, user):
"""Converts a dict user to an actor.
Args:
user: Bluesky user app.bsky.actor.defs#profileViewDetailed
Returns:
an ActivityStreams actor dict, ready to be JSON-encoded
"""
return to_as1(user)

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)
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',
'context': {'inReplyTo': obj.get('inReplyTo')},
}

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):
"""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):
"""
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
6 changes: 4 additions & 2 deletions granary/tests/test_bluesky.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@

POST_AS = {
'objectType': 'activity',
'id': 'at://did/app.bsky.feed.post/tid',
'verb': 'post',
'actor': ACTOR_AS,
'object': {
'objectType': 'note',
'id': 'at://did/app.bsky.feed.post/tid',
Expand Down Expand Up @@ -759,7 +761,7 @@ def test_get_activities_activity_id(self, mock_get):
'replies': [REPLY_BSKY],
})

self.assert_equals([POST_AUTHOR_PROFILE_AS['object']],
self.assert_equals([POST_AUTHOR_PROFILE_AS],
self.bs.get_activities(activity_id='at://id'))
mock_get.assert_called_once_with(
'https://bsky.social/xrpc/app.bsky.feed.getPostThread?uri=at%3A%2F%2Fid&depth=1',
Expand All @@ -782,7 +784,7 @@ def test_get_activities_self_user_id(self, mock_get):
'feed': [POST_AUTHOR_BSKY],
})

self.assert_equals([POST_AUTHOR_PROFILE_AS['object']],
self.assert_equals([POST_AUTHOR_PROFILE_AS],
self.bs.get_activities(group_id=SELF, user_id='alice.com'))
mock_get.assert_called_once_with(
'https://bsky.social/xrpc/app.bsky.feed.getAuthorFeed?actor=alice.com',
Expand Down

0 comments on commit b45d1f9

Please sign in to comment.