From 0fce8324832e3da122088b52e2e5512be2edafd2 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 6 Jul 2025 16:21:01 -0400 Subject: [PATCH] Bluesky DID Lookup bugfix --- apprise/plugins/bluesky.py | 97 ++++++++++++--- test/test_plugin_bluesky.py | 234 +++++++++++++++++++++++++++++------- 2 files changed, 271 insertions(+), 60 deletions(-) diff --git a/apprise/plugins/bluesky.py b/apprise/plugins/bluesky.py index cc1105c7..ae33b952 100644 --- a/apprise/plugins/bluesky.py +++ b/apprise/plugins/bluesky.py @@ -84,6 +84,7 @@ class NotifyBlueSky(NotifyBase): xrpc_suffix_session = "/xrpc/com.atproto.server.createSession" xrpc_suffix_record = "/xrpc/com.atproto.repo.createRecord" xrpc_suffix_blob = "/xrpc/com.atproto.repo.uploadBlob" + plc_directory = 'https://plc.directory/{did}' # BlueSky is kind enough to return how many more requests we're allowed to # continue to make within it's header response as: @@ -138,6 +139,7 @@ class NotifyBlueSky(NotifyBase): self.__access_token = self.store.get('access_token') self.__refresh_token = None self.__access_token_expiry = datetime.now(timezone.utc) + self.__endpoint = self.store.get('endpoint') if not self.user: msg = 'A BlueSky UserID/Handle must be specified.' @@ -146,6 +148,8 @@ class NotifyBlueSky(NotifyBase): # Set our default host self.host = self.bluesky_default_host + self.__endpoint = f'https://{self.host}' \ + if not self.host else self.__endpoint # Identify our Handle (if define) results = HANDLE_HOST_PARSE_RE.match(self.user) @@ -169,7 +173,7 @@ class NotifyBlueSky(NotifyBase): blobs = [] if attach and self.attachment_support: - url = f'https://{self.host}{self.xrpc_suffix_blob}' + url = f'{self.__endpoint}{self.xrpc_suffix_blob}' # We need to upload our payload first so that we can source it # in remaining messages for no, attachment in enumerate(attach, start=1): @@ -217,14 +221,15 @@ class NotifyBlueSky(NotifyBase): blobs.append((response.get('blob'), filename)) # Prepare our URL - url = f'https://{self.host}{self.xrpc_suffix_record}' + did, endpoint = self.get_identifier() + url = f'{endpoint}{self.xrpc_suffix_record}' # prepare our batch of payloads to create payloads = [] payload = { "collection": "app.bsky.feed.post", - "repo": self.get_identifier(), + "repo": did, "record": { "text": body, # 'YYYY-mm-ddTHH:MM:SSZ' @@ -286,12 +291,17 @@ class NotifyBlueSky(NotifyBase): user = self.user user = f'{user}.{self.host}' if '.' not in user else f'{user}' - key = f'did.{user}' - did = self.store.get(key) - if did: - return did + did_key = f'did.{user}' + endpoint_key = f'endpoint.{user}' - url = f'https://{self.host}{self.xrpc_suffix_did}' + did = self.store.get(did_key) + endpoint = self.store.get(endpoint_key) + if did and endpoint: + # Early return + return did, endpoint + + # Step 1: Acquire DID from bsky.app + url = f'https://public.api.bsky.app{self.xrpc_suffix_did}' params = {'handle': user} # Send Login Information @@ -305,12 +315,70 @@ class NotifyBlueSky(NotifyBase): if not postokay or not response or 'did' not in response: # We failed - return False + return (False, False) - # Acquire our Decentralized Identitifer + # Store our DID did = response.get('did') - self.store.set(key, did) - return did + + # Step 2: Use DID to find the PDS + if did.startswith("did:plc:"): + pds_url = self.plc_directory.format(did=did) + + # PDS Query + postokay, service_response = self._fetch( + pds_url, + method='GET', + # We set this boolean so internal recursion doesn't take place. + login=login, + ) + if not postokay or not service_response or \ + 'service' not in service_response: + # We failed + return (False, False) + + endpoint = next( + (s["serviceEndpoint"] + for s in service_response.get("service", []) + if s["type"] == "AtprotoPersonalDataServer"), + None, + ) + + elif did.startswith("did:web:"): + # Convert to domain + domain = did[8:] + web_did_url = f"https://{domain}/.well-known/did.json" + postokay, service_response = self._fetch( + web_did_url, + method='GET', + # We set this boolean so internal recursion doesn't take place. + login=login, + ) + if not postokay or not service_response or \ + 'service' not in service_response: + # We failed + self.logger.warning( + 'Could not fetch DID document for did:web identity ' + '{}; ensure {} is available.'.format(did, web_did_url) + ) + return (False, False) + + endpoint = next( + (s["serviceEndpoint"] + for s in service_response.get("service", []) + if s["type"] == "AtprotoPersonalDataServer"), + None, + ) + + else: + raise RuntimeError("Unknown BlueSky DID scheme") + + # Step 3: Send to correct endpoint + if not endpoint: + raise RuntimeError("Failed to resolve BlueSky PDS endpoint") + + self.store.set(did_key, did) + self.store.set(endpoint_key, endpoint) + return (did, endpoint) def login(self): """ @@ -318,11 +386,11 @@ class NotifyBlueSky(NotifyBase): """ # Acquire our Decentralized Identitifer - did = self.get_identifier(self.user, login=True) + did, self.__endpoint = self.get_identifier(self.user, login=True) if not did: return False - url = f'https://{self.host}{self.xrpc_suffix_session}' + url = f'{self.__endpoint}{self.xrpc_suffix_session}' payload = { "identifier": did, @@ -392,6 +460,7 @@ class NotifyBlueSky(NotifyBase): 'access_token', self.__access_token, self.__access_token_expiry) self.store.set( 'refresh_token', self.__refresh_token, self.__access_token_expiry) + self.store.set('endpoint', self.__endpoint) self.logger.info('Authenticated to BlueSky as {}.{}'.format( self.user, self.host)) diff --git a/test/test_plugin_bluesky.py b/test/test_plugin_bluesky.py index 0810423e..a3b05c3e 100644 --- a/test/test_plugin_bluesky.py +++ b/test/test_plugin_bluesky.py @@ -90,7 +90,12 @@ apprise_url_tests = ( 'requests_response_text': { 'accessJwt': 'abcd', 'refreshJwt': 'abcd', - 'did': 'did:1234', + 'did': 'did:plc:1234', + # Support plc response + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] }, }), ('bluesky://user@app-pw3', { @@ -100,9 +105,14 @@ apprise_url_tests = ( 'requests_response_text': { 'accessJwt': 'abcd', 'refreshJwt': 'abcd', - 'did': 'did:1234', + 'did': 'did:plc:1234', # For handling attachments 'blob': 'content', + # Support plc response + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] }, }), ('bluesky://user.example.ca@app-pw3', { @@ -112,9 +122,14 @@ apprise_url_tests = ( 'requests_response_text': { 'accessJwt': 'abcd', 'refreshJwt': 'abcd', - 'did': 'did:1234', + 'did': 'did:plc:1234', # For handling attachments 'blob': 'content', + # Support plc response + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] }, }), # A duplicate of the entry above, this will cause cache to be referenced @@ -125,9 +140,14 @@ apprise_url_tests = ( 'requests_response_text': { 'accessJwt': 'abcd', 'refreshJwt': 'abcd', - 'did': 'did:1234', + 'did': 'did:plc:1234', # For handling attachments 'blob': 'content', + # Support plc response + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] }, }), ('bluesky://user@app-pw', { @@ -138,7 +158,12 @@ apprise_url_tests = ( 'requests_response_text': { 'accessJwt': 'abcd', 'refreshJwt': 'abcd', - 'did': 'did:1234', + 'did': 'did:plc:1234', + # Support plc response + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] }, }), ('bluesky://user@app-pw', { @@ -149,7 +174,12 @@ apprise_url_tests = ( 'requests_response_text': { 'accessJwt': 'abcd', 'refreshJwt': 'abcd', - 'did': 'did:1234', + 'did': 'did:plc:1234', + # Support plc response + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] }, }), ) @@ -163,7 +193,12 @@ def good_response(data=None): response.content = json.dumps({ 'accessJwt': 'abcd', 'refreshJwt': 'abcd', - 'did': 'did:1234', + 'did': 'did:plc:1234', + # Support plc response + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] } if data is None else data) response.status_code = requests.codes.ok @@ -363,7 +398,12 @@ def test_plugin_bluesky_general(mocker): response_obj = { 'accessJwt': 'abcd', 'refreshJwt': 'abcd', - 'did': 'did:1234' + 'did': 'did:plc:1234', + # Support plc response + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] } request.content = json.dumps(response_obj) @@ -413,16 +453,19 @@ def test_plugin_bluesky_attachments_basic( attach=attach) is True # Verify API calls. - assert mock_get.call_count == 1 + assert mock_get.call_count == 2 assert mock_get.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle' + 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle' + assert mock_get.call_args_list[1][0][0] == \ + 'https://plc.directory/did:plc:1234' + assert mock_post.call_count == 3 assert mock_post.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.server.createSession' + 'https://example.pds.io/xrpc/com.atproto.server.createSession' assert mock_post.call_args_list[1][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[2][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' @patch('requests.post') @@ -445,14 +488,17 @@ def test_plugin_bluesky_attachments_bad_message_response( attach=attach) is False # Verify API calls. - assert mock_get.call_count == 1 + assert mock_get.call_count == 2 assert mock_get.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle' + 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle' + assert mock_get.call_args_list[1][0][0] == \ + 'https://plc.directory/did:plc:1234' + assert mock_post.call_count == 2 assert mock_post.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.server.createSession' + 'https://example.pds.io/xrpc/com.atproto.server.createSession' assert mock_post.call_args_list[1][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' @patch('requests.post') @@ -475,14 +521,17 @@ def test_plugin_bluesky_attachments_upload_fails( attach=attach) is False # Verify API calls. - assert mock_get.call_count == 1 + assert mock_get.call_count == 2 assert mock_get.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle' + 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle' + assert mock_get.call_args_list[1][0][0] == \ + 'https://plc.directory/did:plc:1234' + assert mock_post.call_count == 2 assert mock_post.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.server.createSession' + 'https://example.pds.io/xrpc/com.atproto.server.createSession' assert mock_post.call_args_list[1][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' @patch('requests.post') @@ -506,14 +555,16 @@ def test_plugin_bluesky_attachments_invalid_attachment( attach=attach) is False # Verify API calls. - assert mock_get.call_count == 1 + assert mock_get.call_count == 2 assert mock_get.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle' + 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle' + assert mock_get.call_args_list[1][0][0] == \ + 'https://plc.directory/did:plc:1234' # No post request as attachment is not good. assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.server.createSession' + 'https://example.pds.io/xrpc/com.atproto.server.createSession' @patch('requests.post') @@ -546,28 +597,30 @@ def test_plugin_bluesky_attachments_multiple_batch( attach=attach) is True # Verify API calls. - assert mock_get.call_count == 1 + assert mock_get.call_count == 2 assert mock_get.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle' + 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle' + assert mock_get.call_args_list[1][0][0] == \ + 'https://plc.directory/did:plc:1234' assert mock_post.call_count == 9 assert mock_post.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.server.createSession' + 'https://example.pds.io/xrpc/com.atproto.server.createSession' assert mock_post.call_args_list[1][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[2][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[3][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[4][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[5][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' assert mock_post.call_args_list[6][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' assert mock_post.call_args_list[7][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' assert mock_post.call_args_list[8][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' # If we call the functions again, the only difference is # we no longer need to resolve the handle or create a session @@ -589,21 +642,21 @@ def test_plugin_bluesky_attachments_multiple_batch( assert mock_get.call_count == 0 assert mock_post.call_count == 8 assert mock_post.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[1][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[2][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[3][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob' + 'https://example.pds.io/xrpc/com.atproto.repo.uploadBlob' assert mock_post.call_args_list[4][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' assert mock_post.call_args_list[5][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' assert mock_post.call_args_list[6][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' assert mock_post.call_args_list[7][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.repo.createRecord' + 'https://example.pds.io/xrpc/com.atproto.repo.createRecord' @patch('requests.post') @@ -622,9 +675,98 @@ def test_plugin_bluesky_auth_failure( body='body', title='title', notify_type=NotifyType.INFO) is False # Verify API calls. - assert mock_get.call_count == 1 + assert mock_get.call_count == 2 assert mock_get.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle' + 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle' + assert mock_get.call_args_list[1][0][0] == \ + 'https://plc.directory/did:plc:1234' assert mock_post.call_count == 1 assert mock_post.call_args_list[0][0][0] == \ - 'https://bsky.social/xrpc/com.atproto.server.createSession' + 'https://example.pds.io/xrpc/com.atproto.server.createSession' + + +@patch('requests.post') +@patch('requests.get') +def test_plugin_bluesky_did_web_and_plc_resolution( + mock_get, mock_post, bluesky_url, good_message_response): + """ + NotifyBlueSky() - Full coverage of did:web and did:plc path + """ + + # Step 1: Identity resolution response (public.api.bsky.app) + identity_response = good_response({ + 'did': 'did:plc:abcdefg1234567' + }) + + # Step 2: PLC Directory lookup + plc_response = good_response({ + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.pds.io' + }] + }) + + # Step 3: Auth session + session_response = good_response() + + # Step 4: Create post + post_response = good_response() + + mock_get.side_effect = [identity_response, plc_response] + mock_post.side_effect = [session_response, post_response] + + obj = Apprise.instantiate(bluesky_url) + assert obj.notify( + body='Resolved PLC Flow') is True + + # Reset for did:web test + identity_response = good_response({ + 'did': 'did:web:example.com' + }) + + web_did_response = good_response({ + 'service': [{ + 'type': 'AtprotoPersonalDataServer', + 'serviceEndpoint': 'https://example.com' + }] + }) + + mock_get.side_effect = [identity_response, web_did_response] + mock_post.side_effect = [session_response, post_response] + + obj = Apprise.instantiate(bluesky_url) + assert obj.notify( + body='Resolved WEB Flow') is True + + # Invalid DID scheme + bad_did_response = good_response({ + 'did': 'did:unsupported:scheme' + }) + + mock_get.side_effect = [bad_did_response] + obj = Apprise.instantiate(bluesky_url) + + with pytest.raises(RuntimeError): + obj.notify(body='fail due to bad scheme') + + +@patch('requests.get') +def test_plugin_bluesky_pds_resolution_failures(mock_get): + """ + NotifyBlueSky() - Missing service field or invalid service endpoint + """ + identity_response = good_response({'did': 'did:plc:missing-service'}) + plc_no_service = good_response({'foo': 'bar'}) + + mock_get.side_effect = [identity_response, plc_no_service] + obj = NotifyBlueSky(user='handle', password='pass') + did, endpoint = obj.get_identifier() + assert (did, endpoint) == (False, False) + + identity_response = good_response({'did': 'did:web:example.com'}) + web_did_no_service = good_response({'foo': 'bar'}) + + mock_get.side_effect = [identity_response, web_did_no_service] + obj = NotifyBlueSky(user='handle', password='pass') + did, endpoint = obj.get_identifier() + assert (did, endpoint) == (False, False)