Bluesky DID Lookup bugfix

pull/1363/head
Chris Caron 2025-07-06 16:21:01 -04:00
parent 19a201ff44
commit 0fce832483
2 changed files with 271 additions and 60 deletions

View File

@ -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))

View File

@ -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)