diff --git a/KEYWORDS b/KEYWORDS
index 9dcb2f91..f27c3422 100644
--- a/KEYWORDS
+++ b/KEYWORDS
@@ -3,6 +3,8 @@ Alerts
Apprise API
Automated Packet Reporting System
AWS
+Bark
+BlueSky
BulkSMS
BulkVS
Burst SMS
diff --git a/README.md b/README.md
index efd8af9d..a641bba6 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,7 @@ The table below identifies the services this tool supports and some example serv
| [Apprise API](https://github.com/caronc/apprise/wiki/Notify_apprise_api) | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token
| [AWS SES](https://github.com/caronc/apprise/wiki/Notify_ses) | ses:// | (TCP) 443 | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName
ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN
| [Bark](https://github.com/caronc/apprise/wiki/Notify_bark) | bark:// | (TCP) 80 or 443 | bark://hostname
bark://hostname/device_key
bark://hostname/device_key1/device_key2/device_keyN
barks://hostname
barks://hostname/device_key
barks://hostname/device_key1/device_key2/device_keyN
+| [BlueSky](https://github.com/caronc/apprise/wiki/Notify_bluesky) | bluesky:// | (TCP) 443 | bluesky://Handle:AppPw
bluesky://Handle:AppPw/TargetHandle
bluesky://Handle:AppPw/TargetHandle1/TargetHandle2/TargetHandleN
| [Chanify](https://github.com/caronc/apprise/wiki/Notify_chanify) | chantify:// | (TCP) 443 | chantify://token
| [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token
| [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname
diff --git a/apprise/plugins/bluesky.py b/apprise/plugins/bluesky.py
new file mode 100644
index 00000000..cc1105c7
--- /dev/null
+++ b/apprise/plugins/bluesky.py
@@ -0,0 +1,579 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2025, Chris Caron
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+# 1. Create a BlueSky account
+# 2. Access Settings -> Privacy and Security
+# 3. Generate an App Password. Optionally grant yourself access to Direct
+# Messages if you want to be able to send them
+# 4. Assemble your Apprise URL like:
+# bluesky://handle@you-token-here
+#
+import re
+import requests
+import json
+from datetime import (datetime, timezone, timedelta)
+from ..attachment.base import AttachBase
+from .base import NotifyBase
+from ..url import PrivacyMode
+from ..common import NotifyType
+from ..locale import gettext_lazy as _
+
+# For parsing handles
+HANDLE_HOST_PARSE_RE = re.compile(r'(?P[^.]+)\.+(?P.+)$')
+
+IS_USER = re.compile(r'^\s*@?(?P[A-Z0-9_]+)(\.+(?P.+))?$', re.I)
+
+
+class NotifyBlueSky(NotifyBase):
+ """
+ A wrapper for BlueSky Notifications
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'BlueSky'
+
+ # The services URL
+ service_url = 'https://bluesky.us/'
+
+ # Protocol
+ secure_protocol = ('bsky', 'bluesky')
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_bluesky'
+
+ # Support attachments
+ attachment_support = True
+
+ # XRPC Suffix URLs; Structured as:
+ # https://host/{suffix}
+
+ # Taken right from google.auth.helpers:
+ clock_skew = timedelta(seconds=10)
+
+ # 1 hour in seconds (the lifetime of our token)
+ access_token_lifetime_sec = timedelta(seconds=3600)
+
+ # Detect your Decentralized Identitifer (DID), then you can get your Auth
+ # Token.
+ xrpc_suffix_did = "/xrpc/com.atproto.identity.resolveHandle"
+ xrpc_suffix_session = "/xrpc/com.atproto.server.createSession"
+ xrpc_suffix_record = "/xrpc/com.atproto.repo.createRecord"
+ xrpc_suffix_blob = "/xrpc/com.atproto.repo.uploadBlob"
+
+ # BlueSky is kind enough to return how many more requests we're allowed to
+ # continue to make within it's header response as:
+ # RateLimit-Reset: The epoc time (in seconds) we can expect our
+ # rate-limit to be reset.
+ # RateLimit-Remaining: an integer identifying how many requests we're
+ # still allow to make.
+ request_rate_per_sec = 0
+
+ # For Tracking Purposes
+ ratelimit_reset = datetime.now(timezone.utc).replace(tzinfo=None)
+
+ # Remaining messages
+ ratelimit_remaining = 1
+
+ # The default BlueSky host to use if one isn't specified
+ bluesky_default_host = 'bsky.social'
+
+ # Our message body size
+ body_maxlen = 280
+
+ # BlueSky does not support a title
+ title_maxlen = 0
+
+ # Define object templates
+ templates = (
+ '{schema}://{user}@{password}',
+ )
+
+ # Define our template tokens
+ template_tokens = dict(NotifyBase.template_tokens, **{
+ 'user': {
+ 'name': _('Username'),
+ 'type': 'string',
+ 'required': True,
+ },
+ 'password': {
+ 'name': _('Password'),
+ 'type': 'string',
+ 'private': True,
+ 'required': True,
+ },
+ })
+
+ def __init__(self, **kwargs):
+ """
+ Initialize BlueSky Object
+ """
+ super().__init__(**kwargs)
+
+ # Our access token
+ self.__access_token = self.store.get('access_token')
+ self.__refresh_token = None
+ self.__access_token_expiry = datetime.now(timezone.utc)
+
+ if not self.user:
+ msg = 'A BlueSky UserID/Handle must be specified.'
+ self.logger.warning(msg)
+ raise TypeError(msg)
+
+ # Set our default host
+ self.host = self.bluesky_default_host
+
+ # Identify our Handle (if define)
+ results = HANDLE_HOST_PARSE_RE.match(self.user)
+ if results:
+ self.user = results.group('handle').strip()
+ self.host = results.group('host').strip()
+
+ return
+
+ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
+ **kwargs):
+ """
+ Perform BlueSky Notification
+ """
+
+ if not self.__access_token and not self.login():
+ # We failed to authenticate - we're done
+ return False
+
+ # Track our returning blob IDs as they're stored on the BlueSky server
+ blobs = []
+
+ if attach and self.attachment_support:
+ url = f'https://{self.host}{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):
+
+ # Perform some simple error checking
+ if not attachment:
+ # We could not access the attachment
+ self.logger.error(
+ 'Could not access attachment {}.'.format(
+ attachment.url(privacy=True)))
+ return False
+
+ if not re.match(r'^image/.*', attachment.mimetype, re.I):
+ # Only support images at this time
+ self.logger.warning(
+ 'Ignoring unsupported BlueSky attachment {}.'.format(
+ attachment.url(privacy=True)))
+ continue
+
+ self.logger.debug(
+ 'Preparing BlueSky attachment {}'.format(
+ attachment.url(privacy=True)))
+
+ # Upload our image and get our blob associated with it
+ postokay, response = self._fetch(
+ url,
+ payload=attachment,
+ )
+
+ if not postokay:
+ # We can't post our attachment
+ return False
+
+ # Prepare our filename
+ filename = attachment.name \
+ if attachment.name else f'file{no:03}.dat'
+
+ if not (isinstance(response, dict)
+ and response.get('blob')):
+ self.logger.debug(
+ 'Could not attach the file to BlueSky: %s (mime=%s)',
+ filename, attachment.mimetype)
+ continue
+
+ blobs.append((response.get('blob'), filename))
+
+ # Prepare our URL
+ url = f'https://{self.host}{self.xrpc_suffix_record}'
+
+ # prepare our batch of payloads to create
+ payloads = []
+
+ payload = {
+ "collection": "app.bsky.feed.post",
+ "repo": self.get_identifier(),
+ "record": {
+ "text": body,
+ # 'YYYY-mm-ddTHH:MM:SSZ'
+ "createdAt": datetime.now(
+ tz=timezone.utc).strftime('%FT%XZ'),
+ "$type": "app.bsky.feed.post"
+ }
+ }
+
+ if blobs:
+ for no, blob in enumerate(blobs, start=1):
+ _payload = payload.copy()
+ if no > 1:
+ #
+ # multiple instances
+ #
+ # 1. update createdAt time
+ # 2. Change text to identify image no
+ _payload['record']['createdAt'] = \
+ datetime.now(tz=timezone.utc).strftime('%FT%XZ')
+ _payload['record']['text'] = \
+ '{:02d}/{:02d}'.format(no, len(blobs))
+
+ _payload['record']['embed'] = {
+ "images": [
+ {
+ "image": blob[0],
+ "alt": blob[1],
+ }
+ ],
+ "$type": "app.bsky.embed.images"
+ }
+ payloads.append(_payload)
+ else:
+ payloads.append(payload)
+
+ for payload in payloads:
+ # Send Login Information
+ postokay, response = self._fetch(
+ url,
+ payload=json.dumps(payload),
+ )
+ if not postokay:
+ # We failed
+ # Bad responses look like:
+ # {
+ # 'error': 'InvalidRequest',
+ # 'message': 'reason'
+ # }
+ return False
+ return True
+
+ def get_identifier(self, user=None, login=False):
+ """
+ Performs a Decentralized User Lookup and returns the identifier
+ """
+
+ if user is None:
+ 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
+
+ url = f'https://{self.host}{self.xrpc_suffix_did}'
+ params = {'handle': user}
+
+ # Send Login Information
+ postokay, response = self._fetch(
+ url,
+ params=params,
+ method='GET',
+ # We set this boolean so internal recursion doesn't take place.
+ login=login,
+ )
+
+ if not postokay or not response or 'did' not in response:
+ # We failed
+ return False
+
+ # Acquire our Decentralized Identitifer
+ did = response.get('did')
+ self.store.set(key, did)
+ return did
+
+ def login(self):
+ """
+ A simple wrapper to authenticate with the BlueSky Server
+ """
+
+ # Acquire our Decentralized Identitifer
+ did = self.get_identifier(self.user, login=True)
+ if not did:
+ return False
+
+ url = f'https://{self.host}{self.xrpc_suffix_session}'
+
+ payload = {
+ "identifier": did,
+ "password": self.password,
+ }
+
+ # Send Login Information
+ postokay, response = self._fetch(
+ url,
+ payload=json.dumps(payload),
+ # We set this boolean so internal recursion doesn't take place.
+ login=True,
+ )
+
+ # Our response object looks like this (content has been altered for
+ # presentation purposes):
+ # {
+ # 'did': 'did:plc:ruk414jakghak402j1jqekj2',
+ # 'didDoc': {
+ # '@context': [
+ # 'https://www.w3.org/ns/did/v1',
+ # 'https://w3id.org/security/multikey/v1',
+ # 'https://w3id.org/security/suites/secp256k1-2019/v1'
+ # ],
+ # 'id': 'did:plc:ruk414jakghak402j1jqekj2',
+ # 'alsoKnownAs': ['at://apprise.bsky.social'],
+ # 'verificationMethod': [
+ # {
+ # 'id': 'did:plc:ruk414jakghak402j1jqekj2#atproto',
+ # 'type': 'Multikey',
+ # 'controller': 'did:plc:ruk414jakghak402j1jqekj2',
+ # 'publicKeyMultibase' 'redacted'
+ # }
+ # ],
+ # 'service': [
+ # {
+ # 'id': '#atproto_pds',
+ # 'type': 'AtprotoPersonalDataServer',
+ # 'serviceEndpoint':
+ # 'https://woodtuft.us-west.host.bsky.network'
+ # }
+ # ]
+ # },
+ # 'handle': 'apprise.bsky.social',
+ # 'email': 'whoami@gmail.com',
+ # 'emailConfirmed': True,
+ # 'emailAuthFactor': False,
+ # 'accessJwt': 'redacted',
+ # 'refreshJwt': 'redacted',
+ # 'active': True,
+ # }
+
+ if not postokay or not response:
+ # We failed
+ return False
+
+ # Acquire our Token
+ self.__access_token = response.get('accessJwt')
+
+ # Handle other optional arguments we can use
+ self.__access_token_expiry = self.access_token_lifetime_sec + \
+ datetime.now(timezone.utc) - self.clock_skew
+
+ # The Refresh Token
+ self.__refresh_token = response.get('refreshJwt', self.__refresh_token)
+ self.store.set(
+ 'access_token', self.__access_token, self.__access_token_expiry)
+ self.store.set(
+ 'refresh_token', self.__refresh_token, self.__access_token_expiry)
+
+ self.logger.info('Authenticated to BlueSky as {}.{}'.format(
+ self.user, self.host))
+ return True
+
+ def _fetch(self, url, payload=None, params=None, method='POST',
+ content_type=None, login=False):
+ """
+ Wrapper to BlueSky API requests object
+ """
+
+ # use what was specified, otherwise build headers dynamically
+ headers = {
+ 'User-Agent': self.app_id,
+ 'Content-Type':
+ payload.mimetype if isinstance(payload, AttachBase) else (
+ 'application/x-www-form-urlencoded; charset=utf-8'
+ if method == 'GET' else 'application/json')
+ }
+
+ if self.__access_token:
+ # Set our token
+ headers['Authorization'] = 'Bearer {}'.format(self.__access_token)
+
+ # Some Debug Logging
+ self.logger.debug('BlueSky {} URL: {} (cert_verify={})'.format(
+ method, url, self.verify_certificate))
+ self.logger.debug(
+ 'BlueSky Payload: %s', str(payload)
+ if not isinstance(payload, AttachBase)
+ else 'attach: ' + payload.name)
+
+ # By default set wait to None
+ wait = None
+
+ if self.ratelimit_remaining == 0:
+ # Determine how long we should wait for or if we should wait at
+ # all. This isn't fool-proof because we can't be sure the client
+ # time (calling this script) is completely synced up with the
+ # Twitter server. One would hope we're on NTP and our clocks are
+ # the same allowing this to role smoothly:
+
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
+ if now < self.ratelimit_reset:
+ # We need to throttle for the difference in seconds
+ # We add 0.3 seconds to the end just to allow a grace
+ # period.
+ wait = (self.ratelimit_reset - now).total_seconds() + 0.3
+
+ # Always call throttle before any remote server i/o is made;
+ self.throttle(wait=wait)
+
+ # Initialize a default value for our content value
+ content = {}
+
+ # acquire our request mode
+ fn = requests.post if method == 'POST' else requests.get
+ try:
+ r = fn(
+ url,
+ data=payload if not isinstance(payload, AttachBase)
+ else payload.open(),
+ params=params,
+ headers=headers,
+ verify=self.verify_certificate,
+ timeout=self.request_timeout,
+ )
+
+ # Get our JSON content if it's possible
+ try:
+ content = json.loads(r.content)
+
+ except (TypeError, ValueError, AttributeError):
+ # TypeError = r.content is not a String
+ # ValueError = r.content is Unparsable
+ # AttributeError = r.content is None
+ content = {}
+
+ # Rate limit handling... our header objects at this point are:
+ # 'RateLimit-Limit': '10', # Total # of requests per hour
+ # 'RateLimit-Remaining': '9', # Requests remaining
+ # 'RateLimit-Reset': '1741631362', # Epoch Time
+ # 'RateLimit-Policy': '10;w=86400' # NoEntries;w=
+ try:
+ # Capture rate limiting if possible
+ self.ratelimit_remaining = \
+ int(r.headers.get('ratelimit-remaining'))
+ self.ratelimit_reset = datetime.fromtimestamp(
+ int(r.headers.get('ratelimit-reset')), timezone.utc
+ ).replace(tzinfo=None)
+
+ except (TypeError, ValueError):
+ # This is returned if we could not retrieve this information
+ # gracefully accept this state and move on
+ pass
+
+ if r.status_code != requests.codes.ok:
+ # We had a problem
+ status_str = \
+ NotifyBlueSky.http_response_code_lookup(r.status_code)
+
+ self.logger.warning(
+ 'Failed to send BlueSky {} to {}: '
+ '{}error={}.'.format(
+ method,
+ url,
+ ', ' if status_str else '',
+ r.status_code))
+
+ self.logger.debug(
+ 'Response Details:\r\n{}'.format(r.content))
+
+ # Mark our failure
+ return (False, content)
+
+ except requests.RequestException as e:
+ self.logger.warning(
+ 'Exception received when sending BlueSky {} to {}: '.
+ format(method, url))
+ self.logger.debug('Socket Exception: %s' % str(e))
+
+ # Mark our failure
+ return (False, content)
+
+ except (OSError, IOError) as e:
+ self.logger.warning(
+ 'An I/O error occurred while handling {}.'.format(
+ payload.name if isinstance(payload, AttachBase)
+ else payload))
+ self.logger.debug('I/O Exception: %s' % str(e))
+ return (False, content)
+
+ return (True, content)
+
+ @property
+ def url_identifier(self):
+ """
+ Returns all of the identifiers that make this URL unique from
+ another simliar one. Targets or end points should never be identified
+ here.
+ """
+ return (
+ self.secure_protocol[0],
+ self.user, self.password,
+ )
+
+ def url(self, privacy=False, *args, **kwargs):
+ """
+ Returns the URL built dynamically based on specified arguments.
+ """
+
+ # Apply our other parameters
+ params = self.url_parameters(privacy=privacy, *args, **kwargs)
+
+ user = self.user
+ if self.host != self.bluesky_default_host:
+ user += f'.{self.host}'
+
+ # our URL
+ return '{schema}://{user}@{password}?{params}'.format(
+ schema=self.secure_protocol[0],
+ user=NotifyBlueSky.quote(user, safe=''),
+ password=self.pprint(
+ self.password, privacy, mode=PrivacyMode.Secret, safe=''),
+ params=NotifyBlueSky.urlencode(params),
+ )
+
+ @staticmethod
+ def parse_url(url):
+ """
+ Parses the URL and returns enough arguments that can allow
+ us to re-instantiate this object.
+
+ """
+ results = NotifyBase.parse_url(url, verify_host=False)
+ if not results:
+ # We're done early as we couldn't load the results
+ return results
+
+ if not results.get('password') and results['host']:
+ results['password'] = NotifyBlueSky.unquote(results['host'])
+
+ # Do not use host field
+ results['host'] = None
+ return results
diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec
index f835e9f6..ca37112a 100644
--- a/packaging/redhat/python-apprise.spec
+++ b/packaging/redhat/python-apprise.spec
@@ -39,7 +39,7 @@ Apprise is a Python package for simplifying access to all of the different
notification services that are out there. Apprise opens the door and makes
it easy to access:
-Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, Burst SMS,
+Africas Talking, Apprise API, APRS, AWS SES, AWS SNS, Bark, BlueSky, Burst SMS,
BulkSMS, BulkVS, Chanify, ClickSend, DAPNET, DingTalk, Discord, E-Mail, Emby,
FCM, Feishu, Flock, Free Mobile, Google Chat, Gotify, Growl, Guilded, Home
Assistant, httpSMS, IFTTT, Join, Kavenegar, KODI, Kumulos, LaMetric, Line,
diff --git a/test/test_plugin_bluesky.py b/test/test_plugin_bluesky.py
new file mode 100644
index 00000000..0810423e
--- /dev/null
+++ b/test/test_plugin_bluesky.py
@@ -0,0 +1,630 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2025, Chris Caron
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import json
+import logging
+import os
+from datetime import datetime
+from datetime import timezone
+from unittest.mock import Mock, patch
+
+import pytest
+import requests
+
+from apprise import Apprise
+from apprise import NotifyType
+from apprise import AppriseAttachment
+from apprise.plugins.bluesky import NotifyBlueSky
+from helpers import AppriseURLTester
+
+# Disable logging for a cleaner testing output
+logging.disable(logging.CRITICAL)
+
+# Attachment Directory
+TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var')
+
+TWITTER_SCREEN_NAME = 'apprise'
+
+
+# Our Testing URLs
+apprise_url_tests = (
+ ##################################
+ # NotifyBlueSky
+ ##################################
+ ('bluesky://', {
+ # Missing user and app_pass
+ 'instance': TypeError,
+ }),
+ ('bluesky://:@/', {
+ 'instance': TypeError,
+ }),
+ ('bluesky://app-pw', {
+ # Missing User
+ 'instance': TypeError,
+ }),
+ ('bluesky://user@app-pw', {
+ 'instance': NotifyBlueSky,
+ # Expected notify() response False (because we won't be able
+ # to detect our user)
+ 'notify_response': False,
+ # Our expected url(privacy=True) startswith() response:
+ 'privacy_url': 'bsky://user@****',
+ }),
+ ('bluesky://user@app-pw1?cache=no', {
+ 'instance': NotifyBlueSky,
+ # At minimum we need an access token and did; below has no did
+ 'requests_response_text': {
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ },
+ 'notify_response': False,
+ }),
+ ('bluesky://user@app-pw2?cache=no', {
+ 'instance': NotifyBlueSky,
+ # valid payload
+ 'requests_response_text': {
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ 'did': 'did:1234',
+ },
+ }),
+ ('bluesky://user@app-pw3', {
+ # no cache; so we store our results
+ 'instance': NotifyBlueSky,
+ # valid payload
+ 'requests_response_text': {
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ 'did': 'did:1234',
+ # For handling attachments
+ 'blob': 'content',
+ },
+ }),
+ ('bluesky://user.example.ca@app-pw3', {
+ # no cache; so we store our results
+ 'instance': NotifyBlueSky,
+ # valid payload
+ 'requests_response_text': {
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ 'did': 'did:1234',
+ # For handling attachments
+ 'blob': 'content',
+ },
+ }),
+ # A duplicate of the entry above, this will cause cache to be referenced
+ ('bluesky://user@app-pw3', {
+ # no cache; so we store our results
+ 'instance': NotifyBlueSky,
+ # valid payload
+ 'requests_response_text': {
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ 'did': 'did:1234',
+ # For handling attachments
+ 'blob': 'content',
+ },
+ }),
+ ('bluesky://user@app-pw', {
+ 'instance': NotifyBlueSky,
+ # throw a bizzare code forcing us to fail to look it up
+ 'response': False,
+ 'requests_response_code': 999,
+ 'requests_response_text': {
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ 'did': 'did:1234',
+ },
+ }),
+ ('bluesky://user@app-pw', {
+ 'instance': NotifyBlueSky,
+ # Throws a series of connection and transfer exceptions when this flag
+ # is set and tests that we gracfully handle them
+ 'test_requests_exceptions': True,
+ 'requests_response_text': {
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ 'did': 'did:1234',
+ },
+ }),
+)
+
+
+def good_response(data=None):
+ """
+ Prepare a good response.
+ """
+ response = Mock()
+ response.content = json.dumps({
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ 'did': 'did:1234',
+ } if data is None else data)
+
+ response.status_code = requests.codes.ok
+
+ # Epoch time:
+ epoch = datetime.fromtimestamp(0, timezone.utc)
+
+ # Generate a very large rate-limit header window
+ response.headers = {
+ 'ratelimit-reset': (
+ datetime.now(timezone.utc) - epoch).total_seconds() + 86400,
+ 'ratelimit-remaining': '1000',
+ }
+
+ return response
+
+
+def bad_response(data=None):
+ """
+ Prepare a bad response.
+ """
+ response = Mock()
+ response.content = json.dumps({
+ "error": 'InvalidRequest',
+ "message": "Something failed",
+ } if data is None else data)
+ response.headers = {}
+ response.status_code = requests.codes.internal_server_error
+ return response
+
+
+@pytest.fixture
+def bluesky_url():
+ url = 'bluesky://user@app-key'
+ return url
+
+
+@pytest.fixture
+def good_message_response():
+ """
+ Prepare a good response.
+ """
+ response = good_response()
+ return response
+
+
+@pytest.fixture
+def bad_message_response():
+ """
+ Prepare a bad message response.
+ """
+ response = bad_response()
+ return response
+
+
+@pytest.fixture
+def good_media_response():
+ """
+ Prepare a good media response.
+ """
+ response = Mock()
+ response.content = json.dumps({
+ 'blob': {
+ '$type': 'blob',
+ 'mimeType': 'image/jpeg',
+ 'ref': {
+ '$link': 'baf124idksduabcjkaa3iey4bfyq'},
+ 'size': 73667,
+ }
+ })
+ response.headers = {}
+ response.status_code = requests.codes.ok
+ return response
+
+
+def test_plugin_bluesky_urls():
+ """
+ NotifyBlueSky() Apprise URLs
+ """
+
+ # Run our general tests
+ AppriseURLTester(tests=apprise_url_tests).run_all()
+
+
+def test_plugin_bluesky_general(mocker):
+ """
+ NotifyBlueSky() General Tests
+ """
+
+ mock_get = mocker.patch("requests.get")
+ mock_post = mocker.patch("requests.post")
+
+ # Epoch time:
+ epoch = datetime.fromtimestamp(0, timezone.utc)
+
+ request = good_response()
+ request.headers = {
+ 'ratelimit-reset': (
+ datetime.now(timezone.utc) - epoch).total_seconds(),
+ 'ratelimit-remaining': '1',
+ }
+
+ # Prepare Mock
+ mock_get.return_value = request
+ mock_post.return_value = request
+
+ # Variation Initializations
+ obj = NotifyBlueSky(user='handle', password='app-password')
+
+ assert isinstance(obj, NotifyBlueSky) is True
+ assert isinstance(obj.url(), str) is True
+
+ # apprise room was found
+ assert obj.send(body="test") is True
+
+ # Change our status code and try again
+ request.status_code = 403
+ assert obj.send(body="test") is False
+ assert obj.ratelimit_remaining == 1
+
+ # Return the status
+ request.status_code = requests.codes.ok
+ # Force a reset
+ request.headers['ratelimit-remaining'] = 0
+ # behind the scenes, it should cause us to update our rate limit
+ assert obj.send(body="test") is True
+ assert obj.ratelimit_remaining == 0
+
+ # This should cause us to block
+ request.headers['ratelimit-remaining'] = 10
+ assert obj.send(body="test") is True
+ assert obj.ratelimit_remaining == 10
+
+ # Handle cases where we simply couldn't get this field
+ del request.headers['ratelimit-remaining']
+ assert obj.send(body="test") is True
+ # It remains set to the last value
+ assert obj.ratelimit_remaining == 10
+
+ # Reset our variable back to 1
+ request.headers['ratelimit-remaining'] = 1
+
+ # Handle cases where our epoch time is wrong
+ del request.headers['ratelimit-reset']
+ assert obj.send(body="test") is True
+
+ # Return our object, but place it in the future forcing us to block
+ request.headers['ratelimit-reset'] = \
+ (datetime.now(timezone.utc) - epoch).total_seconds() + 1
+ request.headers['ratelimit-remaining'] = 0
+ obj.ratelimit_remaining = 0
+ assert obj.send(body="test") is True
+
+ # Return our object, but place it in the future forcing us to block
+ request.headers['ratelimit-reset'] = \
+ (datetime.now(timezone.utc) - epoch).total_seconds() - 1
+ request.headers['ratelimit-remaining'] = 0
+ obj.ratelimit_remaining = 0
+ assert obj.send(body="test") is True
+
+ # Return our limits to always work
+ request.headers['ratelimit-reset'] = \
+ (datetime.now(timezone.utc) - epoch).total_seconds()
+ request.headers['ratelimit-remaining'] = 1
+ obj.ratelimit_remaining = 1
+
+ assert obj.send(body="test") is True
+
+ # Flush our cache forcing it is re-creating
+ NotifyBlueSky._user_cache = {}
+ assert obj.send(body="test") is True
+
+ # Cause content response to be None
+ request.content = None
+ assert obj.send(body="test") is True
+
+ # Invalid JSON
+ request.content = '{'
+ assert obj.send(body="test") is True
+
+ # Return it to a parseable string
+ request.content = '{}'
+
+ results = NotifyBlueSky.parse_url('bluesky://handle@app-pass-word')
+ assert isinstance(results, dict) is True
+
+ # cause a json parsing issue now
+ response_obj = None
+ assert obj.send(body="test") is True
+
+ response_obj = '{'
+ assert obj.send(body="test") is True
+
+ # Flush out our cache
+ NotifyBlueSky._user_cache = {}
+
+ response_obj = {
+ 'accessJwt': 'abcd',
+ 'refreshJwt': 'abcd',
+ 'did': 'did:1234'
+ }
+ request.content = json.dumps(response_obj)
+
+ obj = NotifyBlueSky(user='handle', password='app-pass-word')
+ assert obj.send(body="test") is True
+
+ # Alter the key forcing us to look up a new value of ourselves again
+ NotifyBlueSky._user_cache = {}
+ NotifyBlueSky._whoami_cache = None
+ obj.ckey = 'different.then.it.was'
+ assert obj.send(body="test") is True
+
+ NotifyBlueSky._whoami_cache = None
+ obj.ckey = 'different.again'
+ assert obj.send(body="test") is True
+
+
+def test_plugin_bluesky_edge_cases():
+ """
+ NotifyBlueSky() Edge Cases
+ """
+
+ with pytest.raises(TypeError):
+ NotifyBlueSky()
+
+
+@patch('requests.post')
+@patch('requests.get')
+def test_plugin_bluesky_attachments_basic(
+ mock_get, mock_post, bluesky_url, good_message_response,
+ good_media_response):
+ """
+ NotifyBlueSky() Attachment Checks - Basic
+ """
+
+ mock_get.return_value = good_message_response
+ mock_post.side_effect = [
+ good_message_response, good_media_response, good_message_response]
+
+ # Create application objects.
+ obj = Apprise.instantiate(bluesky_url)
+ attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif'))
+
+ # Send our notification
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is True
+
+ # Verify API calls.
+ assert mock_get.call_count == 1
+ assert mock_get.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle'
+ assert mock_post.call_count == 3
+ assert mock_post.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.server.createSession'
+ assert mock_post.call_args_list[1][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+ assert mock_post.call_args_list[2][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
+
+
+@patch('requests.post')
+@patch('requests.get')
+def test_plugin_bluesky_attachments_bad_message_response(
+ mock_get, mock_post, bluesky_url, good_media_response,
+ good_message_response, bad_message_response):
+
+ mock_get.return_value = good_message_response
+ mock_post.side_effect = [
+ good_message_response, bad_message_response, good_message_response]
+
+ # Create application objects.
+ obj = Apprise.instantiate(bluesky_url)
+ attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif'))
+
+ # Our notification will fail now since our message will error out.
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is False
+
+ # Verify API calls.
+ assert mock_get.call_count == 1
+ assert mock_get.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle'
+ assert mock_post.call_count == 2
+ assert mock_post.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.server.createSession'
+ assert mock_post.call_args_list[1][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+
+
+@patch('requests.post')
+@patch('requests.get')
+def test_plugin_bluesky_attachments_upload_fails(
+ mock_get, mock_post, bluesky_url, good_media_response,
+ good_message_response):
+
+ # Test case where upload fails.
+ mock_get.return_value = good_message_response
+ mock_post.side_effect = [good_message_response, OSError]
+
+ # Create application objects.
+ obj = Apprise.instantiate(bluesky_url)
+ attach = AppriseAttachment(os.path.join(TEST_VAR_DIR, 'apprise-test.gif'))
+
+ # Send our notification; it will fail because of the message response.
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is False
+
+ # Verify API calls.
+ assert mock_get.call_count == 1
+ assert mock_get.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle'
+ assert mock_post.call_count == 2
+ assert mock_post.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.server.createSession'
+ assert mock_post.call_args_list[1][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+
+
+@patch('requests.post')
+@patch('requests.get')
+def test_plugin_bluesky_attachments_invalid_attachment(
+ mock_get, mock_post, bluesky_url, good_message_response,
+ good_media_response):
+
+ mock_get.return_value = good_message_response
+ mock_post.side_effect = [
+ good_message_response, good_media_response]
+
+ # Create application objects.
+ obj = Apprise.instantiate(bluesky_url)
+ attach = AppriseAttachment(
+ os.path.join(TEST_VAR_DIR, '/invalid/path/to/an/invalid/file.jpg'))
+
+ # An invalid attachment will cause a failure.
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is False
+
+ # Verify API calls.
+ assert mock_get.call_count == 1
+ assert mock_get.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle'
+
+ # 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'
+
+
+@patch('requests.post')
+@patch('requests.get')
+def test_plugin_bluesky_attachments_multiple_batch(
+ mock_get, mock_post, bluesky_url, good_message_response,
+ good_media_response):
+
+ mock_get.return_value = good_message_response
+ mock_post.side_effect = [
+ good_message_response, good_media_response, good_media_response,
+ good_media_response, good_media_response, good_message_response,
+ good_message_response, good_message_response, good_message_response]
+
+ # instantiate our object
+ obj = Apprise.instantiate(bluesky_url)
+
+ # 4 images are produced
+ attach = [
+ os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+ os.path.join(TEST_VAR_DIR, 'apprise-test.gif'),
+ os.path.join(TEST_VAR_DIR, 'apprise-test.jpeg'),
+ os.path.join(TEST_VAR_DIR, 'apprise-test.png'),
+ # This one is not supported, so it's ignored gracefully
+ os.path.join(TEST_VAR_DIR, 'apprise-test.mp4'),
+ ]
+
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is True
+
+ # Verify API calls.
+ assert mock_get.call_count == 1
+ assert mock_get.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle'
+ assert mock_post.call_count == 9
+ assert mock_post.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.server.createSession'
+ assert mock_post.call_args_list[1][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+ assert mock_post.call_args_list[2][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+ assert mock_post.call_args_list[3][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+ assert mock_post.call_args_list[4][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+ assert mock_post.call_args_list[5][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
+ assert mock_post.call_args_list[6][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
+ assert mock_post.call_args_list[7][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
+ assert mock_post.call_args_list[8][0][0] == \
+ 'https://bsky.social/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
+ # as the previous one is fine.
+ mock_get.reset_mock()
+ mock_post.reset_mock()
+
+ mock_get.return_value = good_message_response
+ mock_post.side_effect = [
+ good_media_response, good_media_response, good_media_response,
+ good_media_response, good_message_response, good_message_response,
+ good_message_response, good_message_response]
+
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO,
+ attach=attach) is True
+
+ # Verify API calls.
+ 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'
+ assert mock_post.call_args_list[1][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+ assert mock_post.call_args_list[2][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+ assert mock_post.call_args_list[3][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob'
+ assert mock_post.call_args_list[4][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
+ assert mock_post.call_args_list[5][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
+ assert mock_post.call_args_list[6][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
+ assert mock_post.call_args_list[7][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.repo.createRecord'
+
+
+@patch('requests.post')
+@patch('requests.get')
+def test_plugin_bluesky_auth_failure(
+ mock_get, mock_post, bluesky_url, good_message_response,
+ bad_message_response):
+
+ mock_get.return_value = good_message_response
+ mock_post.return_value = bad_message_response
+
+ # instantiate our object
+ obj = Apprise.instantiate(bluesky_url)
+
+ assert obj.notify(
+ body='body', title='title', notify_type=NotifyType.INFO) is False
+
+ # Verify API calls.
+ assert mock_get.call_count == 1
+ assert mock_get.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.identity.resolveHandle'
+ assert mock_post.call_count == 1
+ assert mock_post.call_args_list[0][0][0] == \
+ 'https://bsky.social/xrpc/com.atproto.server.createSession'