From 5214c77f6aa63361af5aa6d0335bef1cebffdd3a Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Tue, 19 Sep 2023 21:46:41 -0700 Subject: [PATCH] Protocol.targets: find and add originals for targets that are copies both Object and User originals --- docs/index.rst | 43 ++++++++++++++++++++++++++++++---- protocol.py | 45 +++++++++++++++++++++++------------- tests/test_protocol.py | 52 ++++++++++++++++++++++++++++++++++++++---- tests/testutil.py | 4 ++++ 4 files changed, 119 insertions(+), 25 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 03d819ad..ab798582 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,8 +9,9 @@ Bridgy Fed connects your web site to `microformats2 `__. Your site gets its own fediverse profile, posts and avatar and header and all. Bridgy Fed translates likes, reposts, mentions, follows, and more -back and forth. `See the user docs `__ for -more details. +back and forth. `See the user docs `__ and +`developer docs `__ for more +details. https://fed.brid.gy/ @@ -23,7 +24,9 @@ License: This project is placed in the public domain. Development ----------- -Pull requests are welcome! Feel free to `ping me in +Development reference docs are at +`bridgy-fed.readthedocs.io `__. Pull +requests are welcome! Feel free to `ping me in #indieweb-dev `__ with any questions. First, fork and clone this repo. Then, install the `Google Cloud @@ -80,6 +83,36 @@ added you as an owner - run: gcloud -q beta app deploy --no-cache --project bridgy-federated *.yaml +How to add a new protocol +------------------------- + +1. Determine `how you’ll map the new protocol to other existing Bridgy + Fed protocols `__, specifically + identity, protocol inference, events, and operations. `Add those to + the existing tables in the + docs `__ + in a PR. This is an important step before you start writing code. +2. If the new protocol uses a new data format - which is likely - add + that format to `granary `__ in a + new file with functions that convert to/from `ActivityStreams + 1 `__ and tests. See + `nostr.py `__ + and + `test_nostr.py `__ + for examples. +3. Implement the protocol in a new ``.py`` file as a subclass of both + `Protocol `__ + and + `User `__. + Implement the ``send``, ``fetch``, ``serve``, and ``target_for`` + methods from ``Protocol`` and ``readable_id``, ``web_url``, + ``ap_address``, and ``ap_actor`` from ``User`` . +4. TODO: add a new usage section to the docs for the new protocol. +5. TODO: does the new protocol need any new UI or signup functionality? + Unusual, but not impossible. Add that if necessary. +6. Add the new protocol’s logo to ``static/``, use it in + `templates/user.html `__. + Stats ----- @@ -94,7 +127,7 @@ Bridgy `__. Here’s how. :: - gcloud datastore export --async gs://bridgy-federated.appspot.com/stats/ --kinds Follower,Response + gcloud datastore export --async gs://bridgy-federated.appspot.com/stats/ --kinds Follower,Object Note that ``--kinds`` is required. `From the export docs `__: @@ -109,7 +142,7 @@ Bridgy `__. Here’s how. :: - for kind in Follower Response; do + for kind in Follower Object; do bq load --replace --nosync --source_format=DATASTORE_BACKUP datastore.$kind gs://bridgy-federated.appspot.com/stats/all_namespaces/kind_$kind/all_namespaces_kind_$kind.export_metadata done diff --git a/protocol.py b/protocol.py index 712618b1..f90687d2 100644 --- a/protocol.py +++ b/protocol.py @@ -418,7 +418,7 @@ class Protocol: obj.changed = orig.changed # if this is a post, ie not an activity, wrap it in a create or update - obj = from_cls._handle_bare_object(obj) + obj = from_cls.handle_bare_object(obj) if obj.type not in SUPPORTED_TYPES: error(f'Sorry, {obj.type} activities are not supported yet.', status=501) @@ -454,7 +454,7 @@ class Protocol: return 'OK' # noop elif obj.type == 'stop-following': - # TODO: unify with _handle_follow? + # TODO: unify with handle_follow? # TODO: handle multiple followees if not actor_id or not inner_obj_id: error(f'Undo of Follow requires actor id and object id. Got: {actor_id} {inner_obj_id} {obj.as1}') @@ -528,13 +528,13 @@ class Protocol: } if obj.type == 'follow': - from_cls._handle_follow(obj) + from_cls.handle_follow(obj) # deliver to targets - return from_cls._deliver(obj) + return from_cls.deliver(obj) @classmethod - def _handle_follow(from_cls, obj): + def handle_follow(from_cls, obj): """Handles an incoming follow activity. Args: @@ -626,7 +626,7 @@ class Protocol: accept.put() @classmethod - def _handle_bare_object(cls, obj): + def handle_bare_object(cls, obj): """If obj is a bare object, wraps it in a create or update activity. Checks if we've seen it before. @@ -691,7 +691,7 @@ class Protocol: error(f'{obj.key.id()} is unchanged, nothing to do', status=204) @classmethod - def _deliver(from_cls, obj): + def deliver(from_cls, obj): """Delivers an activity to its external recipients. Args: @@ -699,7 +699,7 @@ class Protocol: """ # find delivery targets # sort targets so order is deterministic for tests, debugging, etc - targets = from_cls._targets(obj) # maps Target to Object or None + targets = from_cls.targets(obj) # maps Target to Object or None if not targets: obj.status = 'ignored' @@ -759,24 +759,37 @@ class Protocol: return ret @classmethod - def _targets(cls, obj): + def targets(cls, obj): """Collects the targets to send an :class:`models.Object` to. Targets are both objects - original posts, events, etc - and actors. Args: - obj: :class:`models.Object` + obj (:class:`models.Object`) - Returns: dict: { - :class:`Target`: original (in response to) :class:`Object`, if any, - otherwise None - } + Returns: + dict: { + :class:`Target`: original (in response to) :class:`models.Object`, + if any, otherwise None + } """ logger.info('Finding recipients and their targets') + target_uris = set(as1.targets(obj.as1)) + logger.info(f'Raw targets: {target_uris}') + + if target_uris: + origs = {u.key.id() for u in User.get_for_copies(target_uris)} | \ + {o.key.id() for o in Object.query(Object.copies.uri.IN(target_uris))} + if origs: + target_uris |= origs + logger.info(f'Added originals: {origs}') + + orig_obj = None - targets = {} - for id in sorted(as1.targets(obj.as1)): + targets = {} # maps Target to Object or None + + for id in sorted(target_uris): protocol = Protocol.for_id(id) if not protocol: logger.info(f"Can't determine protocol for {id}") diff --git a/tests/test_protocol.py b/tests/test_protocol.py index f1f05841..751f6ebb 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -303,7 +303,49 @@ class ProtocolTest(TestCase): self.assertCountEqual([ Target(protocol='fake', uri='fake:post:target'), Target(protocol='atproto', uri='http://localhost/'), - ], Protocol._targets(obj).keys()) + ], Protocol.targets(obj).keys()) + + @patch('requests.get', return_value=requests_response({})) + def test_targets_converts_copies_to_originals(self, mock_get): + """targets should convert User/Object.copies to their originals.""" + alice = self.make_user('fake:alice', cls=Fake, + copies=[Target(uri='did:plc:alice', protocol='atproto')]) + bob = self.make_user( + 'fake:bob', cls=OtherFake, + copies=[Target(uri='other:bob', protocol='other')]) + obj = self.store_object( + id='fake:post', our_as1={'foo': 9}, + copies=[Target(uri='at://did:plc:eve/post/789', protocol='fake')]) + + Fake.fetchable = { + 'fake:alice': {'foo': 1}, + 'fake:bob': {'foo': 2}, + } + OtherFake.fetchable = { + 'other:bob': {'foo': 3}, + } + + obj = Object(our_as1={ + 'id': 'other:reply', + 'objectType': 'note', + 'inReplyTo': [ + 'at://did:web:unknown/post/123', + 'at://did:plc:eve/post/789', + ], + 'tags': [{ + 'objectType': 'mention', + 'url': 'did:plc:alice', + }, { + 'objectType': 'mention', + 'url': 'other:bob', + }], + }) + self.assertCountEqual([ + Target(uri='fake:post:target', protocol='fake'), + Target(uri='fake:alice:target', protocol='fake'), + Target(uri='fake:bob:target', protocol='fake'), + Target(uri='other:bob:target', protocol='otherfake'), + ], Protocol.targets(obj).keys()) class ProtocolReceiveTest(TestCase): @@ -1161,9 +1203,11 @@ class ProtocolReceiveTest(TestCase): self.assertEqual('OK', OtherFake.receive_as1(follow_as1)) - self.assertEqual(2, len(Fake.sent)) - self.assertEqual('accept', Fake.sent[0][0].type) - self.assertEqual('follow', Fake.sent[1][0].type) + self.assertEqual(1, len(OtherFake.sent)) + self.assertEqual('accept', OtherFake.sent[0][0].type) + + self.assertEqual(1, len(Fake.sent)) + self.assertEqual('follow', Fake.sent[0][0].type) followers = Follower.query().fetch() self.assertEqual(1, len(followers)) diff --git a/tests/testutil.py b/tests/testutil.py index 5c0b095b..da0b37ab 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -140,6 +140,10 @@ class OtherFake(Fake): """ ABBREV = 'other' + fetchable = {} + sent = [] + fetched = [] + @classmethod def owns_id(cls, id): return id.startswith('other:')