2024-05-07 23:58:52 +00:00
|
|
|
"""Bridgy Fed firehose client. Enqueues receive tasks for events for bridged users.
|
|
|
|
"""
|
2024-05-08 22:48:39 +00:00
|
|
|
from collections import namedtuple
|
2024-05-09 03:43:10 +00:00
|
|
|
from datetime import timedelta
|
2024-05-07 23:17:44 +00:00
|
|
|
import itertools
|
|
|
|
import logging
|
2024-05-07 21:15:51 +00:00
|
|
|
import os
|
2024-05-08 17:39:03 +00:00
|
|
|
from queue import SimpleQueue
|
2024-05-09 03:43:10 +00:00
|
|
|
import time
|
2024-05-07 21:15:51 +00:00
|
|
|
|
|
|
|
from carbox import read_car
|
|
|
|
import dag_json
|
|
|
|
from granary.bluesky import AT_URI_PATTERN
|
|
|
|
from lexrpc.client import Client
|
2024-05-08 17:39:03 +00:00
|
|
|
from oauth_dropins.webutil import util
|
2024-05-09 04:35:03 +00:00
|
|
|
from oauth_dropins.webutil.appengine_info import DEBUG
|
2024-05-07 21:15:51 +00:00
|
|
|
|
2024-05-09 04:35:03 +00:00
|
|
|
from atproto import ATProto, Cursor
|
2024-05-09 20:26:24 +00:00
|
|
|
from common import add, create_task, report_error
|
2024-05-07 23:17:44 +00:00
|
|
|
import models
|
2024-05-08 17:39:03 +00:00
|
|
|
from models import Object
|
2024-05-07 23:17:44 +00:00
|
|
|
|
2024-05-09 03:43:10 +00:00
|
|
|
RECONNECT_DELAY = timedelta(seconds=30)
|
2024-05-09 06:11:27 +00:00
|
|
|
STORE_CURSOR_FREQ = timedelta(seconds=20)
|
2024-05-09 03:43:10 +00:00
|
|
|
|
2024-05-07 23:17:44 +00:00
|
|
|
logger = logging.getLogger(__name__)
|
2024-05-07 21:15:51 +00:00
|
|
|
|
2024-05-08 22:48:39 +00:00
|
|
|
# a commit operation. similar to arroba.repo.Write. record is None for deletes.
|
2024-05-09 06:11:27 +00:00
|
|
|
Op = namedtuple('Op', ['action', 'repo', 'path', 'seq', 'record'],
|
2024-05-08 22:48:39 +00:00
|
|
|
# record is optional
|
|
|
|
defaults=[None])
|
|
|
|
|
|
|
|
# contains Ops
|
2024-05-08 17:39:03 +00:00
|
|
|
new_commits = SimpleQueue()
|
|
|
|
|
2024-05-07 21:15:51 +00:00
|
|
|
|
2024-05-09 03:43:10 +00:00
|
|
|
def subscribe(reconnect=True):
|
|
|
|
"""Subscribes to the relay's firehose.
|
|
|
|
|
|
|
|
Relay hostname comes from the ``BGS_HOST`` environment variable.
|
|
|
|
|
|
|
|
Args:
|
|
|
|
reconnect (bool): whether to always reconnect after we get disconnected
|
|
|
|
"""
|
2024-05-08 17:39:03 +00:00
|
|
|
logger.info(f'starting thread to consume firehose and detect our commits')
|
|
|
|
|
2024-05-07 23:17:44 +00:00
|
|
|
query = ATProto.query(ATProto.enabled_protocols != None)
|
2024-05-09 03:43:10 +00:00
|
|
|
atproto_dids = frozenset(key.id() for key in query.iter(keys_only=True))
|
2024-05-07 23:17:44 +00:00
|
|
|
|
|
|
|
other_queries = itertools.chain(*(
|
|
|
|
cls.query(cls.copies.protocol == 'atproto').iter()
|
|
|
|
for cls in set(models.PROTOCOLS.values())
|
|
|
|
if cls and cls != ATProto))
|
2024-05-09 03:43:10 +00:00
|
|
|
bridged_dids = frozenset(user.get_copy(ATProto) for user in other_queries)
|
2024-05-07 21:15:51 +00:00
|
|
|
|
2024-05-09 03:43:10 +00:00
|
|
|
logger.info(f'Loaded {len(atproto_dids)} ATProto, {len(bridged_dids)} bridged dids')
|
2024-05-08 00:36:34 +00:00
|
|
|
logger.info(f'Subscribing to {os.environ["BGS_HOST"]} firehose')
|
2024-05-07 21:15:51 +00:00
|
|
|
|
2024-05-09 03:43:10 +00:00
|
|
|
while True:
|
|
|
|
try:
|
|
|
|
_subscribe(atproto_dids=atproto_dids, bridged_dids=bridged_dids)
|
|
|
|
except BaseException as err:
|
2024-05-09 06:11:27 +00:00
|
|
|
if DEBUG:
|
|
|
|
raise
|
2024-05-09 20:26:24 +00:00
|
|
|
report_error(err)
|
2024-05-09 03:43:10 +00:00
|
|
|
|
|
|
|
if not reconnect:
|
|
|
|
return
|
|
|
|
logger.info(f'disconnected! waiting {RECONNECT_DELAY} and then reconnecting')
|
|
|
|
time.sleep(RECONNECT_DELAY.total_seconds())
|
|
|
|
|
|
|
|
|
|
|
|
def _subscribe(atproto_dids=None, bridged_dids=None):
|
|
|
|
assert atproto_dids is not None and bridged_dids is not None, \
|
|
|
|
(atproto_dids, bridged_dids)
|
|
|
|
|
2024-05-09 04:35:03 +00:00
|
|
|
cursor = Cursor.get_by_id(
|
|
|
|
f'{os.environ["BGS_HOST"]} com.atproto.sync.subscribeRepos')
|
|
|
|
assert cursor
|
2024-05-09 06:11:27 +00:00
|
|
|
|
2024-05-07 23:58:52 +00:00
|
|
|
client = Client(f'https://{os.environ["BGS_HOST"]}')
|
|
|
|
|
2024-05-09 15:44:30 +00:00
|
|
|
sub_cursor = cursor.cursor + 1 if cursor.cursor else None
|
|
|
|
for header, payload in client.com.atproto.sync.subscribeRepos(cursor=sub_cursor):
|
2024-05-09 06:11:27 +00:00
|
|
|
# parse header
|
2024-05-09 20:26:24 +00:00
|
|
|
if header.get('op') == -1:
|
2024-05-08 00:36:34 +00:00
|
|
|
logger.warning(f'Got error from relay! {payload}')
|
|
|
|
continue
|
2024-05-09 20:26:24 +00:00
|
|
|
elif header.get('t') == '#info':
|
2024-05-08 00:36:34 +00:00
|
|
|
logger.info(f'Got info from relay: {payload}')
|
|
|
|
continue
|
2024-05-09 20:26:24 +00:00
|
|
|
elif header.get('t') != '#commit':
|
2024-05-07 21:15:51 +00:00
|
|
|
continue
|
|
|
|
|
2024-05-09 06:11:27 +00:00
|
|
|
# parse payload
|
2024-05-07 23:58:52 +00:00
|
|
|
repo = payload.get('repo')
|
2024-05-08 22:48:39 +00:00
|
|
|
assert repo
|
2024-05-09 03:43:10 +00:00
|
|
|
if repo in bridged_dids: # from a Bridgy Fed non-Bluesky user; ignore
|
2024-05-07 23:58:52 +00:00
|
|
|
# logger.info(f'Ignoring record from our non-ATProto bridged user {repo}')
|
2024-05-07 21:15:51 +00:00
|
|
|
continue
|
|
|
|
|
2024-05-08 20:26:36 +00:00
|
|
|
blocks = {}
|
2024-05-09 20:26:24 +00:00
|
|
|
if block_bytes := payload.get('blocks'):
|
|
|
|
_, blocks = read_car(block_bytes)
|
2024-05-08 20:26:36 +00:00
|
|
|
blocks = {block.cid: block for block in blocks}
|
|
|
|
|
2024-05-07 23:58:52 +00:00
|
|
|
# detect records that reference an ATProto user, eg replies, likes,
|
|
|
|
# reposts, mentions
|
2024-05-09 20:26:24 +00:00
|
|
|
for p_op in payload.get('ops', []):
|
|
|
|
op = Op(repo=repo, action=p_op.get('action'), path=p_op.get('path'),
|
|
|
|
seq=payload.get('seq'))
|
|
|
|
if not op.action or not op.path:
|
|
|
|
logger.info(
|
|
|
|
f'bad payload! seq {op.seq} has action {op.action} path {op.path}!')
|
|
|
|
continue
|
2024-05-08 20:26:36 +00:00
|
|
|
|
2024-05-09 03:43:10 +00:00
|
|
|
is_ours = repo in atproto_dids
|
2024-05-08 22:48:39 +00:00
|
|
|
if is_ours and op.action == 'delete':
|
|
|
|
logger.info(f'Got delete from our ATProto user: {op}')
|
2024-05-08 20:31:08 +00:00
|
|
|
# TODO: also detect deletes of records that *reference* our bridged
|
|
|
|
# users, eg a delete of a follow or like or repost of them.
|
|
|
|
# not easy because we need to getRecord the record to check
|
2024-05-08 22:48:39 +00:00
|
|
|
new_commits.put(op)
|
2024-05-08 20:26:36 +00:00
|
|
|
continue
|
2024-05-07 23:58:52 +00:00
|
|
|
|
2024-05-09 20:26:24 +00:00
|
|
|
cid = p_op.get('cid')
|
2024-05-08 22:48:39 +00:00
|
|
|
block = blocks.get(cid)
|
2024-05-08 20:26:36 +00:00
|
|
|
# our own commits are sometimes missing the record
|
|
|
|
# https://github.com/snarfed/bridgy-fed/issues/1016
|
2024-05-09 20:26:24 +00:00
|
|
|
if not cid or not block:
|
2024-05-07 23:58:52 +00:00
|
|
|
continue
|
|
|
|
|
2024-05-08 22:48:39 +00:00
|
|
|
op = Op(*op[:-1], record=block.decoded)
|
|
|
|
type = op.record.get('$type')
|
2024-05-07 23:58:52 +00:00
|
|
|
if not type:
|
2024-05-08 22:48:39 +00:00
|
|
|
logger.warning('commit record missing $type! {op.action} {op.repo} {op.path} {cid}')
|
|
|
|
logger.warning(dag_json.encode(op.record).decode())
|
2024-05-07 23:58:52 +00:00
|
|
|
continue
|
|
|
|
|
2024-05-08 22:48:39 +00:00
|
|
|
if is_ours:
|
|
|
|
logger.info(f'Got one from our ATProto user: {op.action} {op.repo} {op.path}')
|
|
|
|
new_commits.put(op)
|
2024-05-08 17:39:03 +00:00
|
|
|
continue
|
|
|
|
|
2024-05-07 23:58:52 +00:00
|
|
|
subjects = []
|
2024-05-08 18:18:50 +00:00
|
|
|
|
|
|
|
def maybe_add(did_or_ref):
|
|
|
|
if isinstance(did_or_ref, dict):
|
|
|
|
match = AT_URI_PATTERN.match(did_or_ref['uri'])
|
|
|
|
if match:
|
|
|
|
did = match.group('repo')
|
2024-05-08 18:31:31 +00:00
|
|
|
else:
|
|
|
|
return
|
2024-05-08 18:18:50 +00:00
|
|
|
else:
|
|
|
|
did = did_or_ref
|
|
|
|
|
2024-05-09 03:43:10 +00:00
|
|
|
if did and did in bridged_dids:
|
2024-05-07 23:58:52 +00:00
|
|
|
add(subjects, did)
|
|
|
|
|
|
|
|
if type in ('app.bsky.feed.like', 'app.bsky.feed.repost'):
|
2024-05-08 22:48:39 +00:00
|
|
|
maybe_add(op.record['subject'])
|
2024-05-07 23:58:52 +00:00
|
|
|
|
|
|
|
elif type in ('app.bsky.graph.block', 'app.bsky.graph.follow'):
|
2024-05-08 22:48:39 +00:00
|
|
|
maybe_add(op.record['subject'])
|
2024-05-07 23:58:52 +00:00
|
|
|
|
|
|
|
elif type == 'app.bsky.feed.post':
|
|
|
|
# replies
|
2024-05-08 22:48:39 +00:00
|
|
|
if reply := op.record.get('reply'):
|
2024-05-07 23:58:52 +00:00
|
|
|
for ref in 'parent', 'root':
|
2024-05-08 18:18:50 +00:00
|
|
|
maybe_add(reply[ref])
|
2024-05-07 23:58:52 +00:00
|
|
|
|
|
|
|
# mentions
|
2024-05-08 22:48:39 +00:00
|
|
|
for facet in op.record.get('facets', []):
|
2024-05-07 23:58:52 +00:00
|
|
|
for feature in facet['features']:
|
2024-05-08 18:31:31 +00:00
|
|
|
if feature['$type'] == 'app.bsky.richtext.facet#mention':
|
2024-05-07 23:58:52 +00:00
|
|
|
maybe_add(feature['did'])
|
|
|
|
|
2024-05-08 18:39:45 +00:00
|
|
|
# quote posts
|
2024-05-08 22:48:39 +00:00
|
|
|
if embed := op.record.get('embed'):
|
2024-05-08 19:13:26 +00:00
|
|
|
if embed['$type'] == 'app.bsky.embed.record':
|
2024-05-08 18:39:45 +00:00
|
|
|
maybe_add(embed['record'])
|
2024-05-08 19:13:26 +00:00
|
|
|
elif embed['$type'] == 'app.bsky.embed.recordWithMedia':
|
|
|
|
maybe_add(embed['record']['record'])
|
2024-05-07 23:58:52 +00:00
|
|
|
|
2024-05-08 17:39:03 +00:00
|
|
|
if subjects:
|
2024-05-08 22:48:39 +00:00
|
|
|
logger.info(f'Got one re our ATProto users {subjects}: {op.action} {op.repo} {op.path}')
|
|
|
|
new_commits.put(op)
|
2024-05-07 23:58:52 +00:00
|
|
|
|
|
|
|
|
2024-05-09 03:19:18 +00:00
|
|
|
def handle(limit=None):
|
2024-05-08 17:39:03 +00:00
|
|
|
"""Store Objects and create receive tasks for commits as they arrive.
|
|
|
|
|
|
|
|
:meth:`Object.get_or_create` makes network calls, via eg :meth:`Object.as1`
|
|
|
|
=> :meth:`ATProto.pds_for` and :meth:`ATProto.handle`, so we don't want to
|
|
|
|
do those in the critical path in :func:`subscribe`.
|
2024-05-09 03:19:18 +00:00
|
|
|
|
|
|
|
Args:
|
|
|
|
limit (int): return after handling this many commits
|
2024-05-08 17:39:03 +00:00
|
|
|
"""
|
|
|
|
logger.info(f'started thread to store objects and enqueue receive tasks')
|
|
|
|
|
2024-05-09 04:35:03 +00:00
|
|
|
cursor = Cursor.get_by_id(
|
|
|
|
f'{os.environ["BGS_HOST"]} com.atproto.sync.subscribeRepos')
|
|
|
|
assert cursor
|
|
|
|
|
2024-05-09 03:19:18 +00:00
|
|
|
count = 0
|
|
|
|
while op := new_commits.get():
|
|
|
|
at_uri = f'at://{op.repo}/{op.path}'
|
2024-05-08 17:39:03 +00:00
|
|
|
|
|
|
|
# store object, enqueue receive task
|
|
|
|
# TODO: for Object.bsky, does record have CIDs etc? how do we store?
|
|
|
|
# dag-json? how are polls doing this?
|
2024-05-09 03:19:18 +00:00
|
|
|
if op.action in ('create', 'update'):
|
|
|
|
record_kwarg = {'bsky': op.record}
|
|
|
|
obj_id = at_uri
|
|
|
|
elif op.action == 'delete':
|
|
|
|
obj_id = f'{at_uri}#delete'
|
|
|
|
record_kwarg = {'our_as1': {
|
|
|
|
'objectType': 'activity',
|
|
|
|
'verb': 'delete',
|
|
|
|
'id': obj_id,
|
|
|
|
'actor': op.repo,
|
|
|
|
'object': at_uri,
|
|
|
|
}}
|
|
|
|
else:
|
|
|
|
logger.error(f'Unknown action {action} for {op.repo} {op.path}')
|
|
|
|
continue
|
|
|
|
|
2024-05-09 03:43:10 +00:00
|
|
|
try:
|
|
|
|
obj = Object.get_or_create(id=obj_id, actor=op.repo, status='new',
|
|
|
|
users=[ATProto(id=op.repo).key],
|
|
|
|
source_protocol=ATProto.LABEL, **record_kwarg)
|
|
|
|
create_task(queue='receive', obj=obj.key.urlsafe(), authed_as=op.repo)
|
|
|
|
except BaseException as err:
|
2024-05-09 06:11:27 +00:00
|
|
|
if DEBUG:
|
|
|
|
raise
|
2024-05-09 20:26:24 +00:00
|
|
|
report_error(err)
|
2024-05-09 06:11:27 +00:00
|
|
|
|
|
|
|
if util.now().replace(tzinfo=None) - cursor.updated > STORE_CURSOR_FREQ:
|
|
|
|
# it's been long enough, update our stored cursor
|
|
|
|
cursor.cursor = op.seq
|
|
|
|
cursor.put()
|
2024-05-09 03:19:18 +00:00
|
|
|
|
|
|
|
count += 1
|
|
|
|
if limit is not None and count >= limit:
|
|
|
|
return
|
2024-05-08 17:39:03 +00:00
|
|
|
|
2024-05-09 13:43:51 +00:00
|
|
|
assert False, "handle thread shouldn't reach here!"
|
2024-05-08 00:36:34 +00:00
|
|
|
|
2024-05-07 23:58:52 +00:00
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
from oauth_dropins.webutil import appengine_config
|
|
|
|
import activitypub, web
|
|
|
|
|
|
|
|
with appengine_config.ndb_client.context():
|
|
|
|
subscribe()
|