Adding Bluesky To My Screenshot Automation Bot
I was kind of curious as to how it might be done, so I spent a Sunday morning doing it
If you’ve not read my previous post about Automatically Uploading My Steam Deck Screenshots To Pixelfed, you may want to start there as it gives you the background on how we’ve gotten to this point.
I’ve been feeling the draw lately for being creative, and while I haven’t quite put my finger on what it is I want to actually create, I did figure I could at least extend something that’s already in the wild. I’ve been tweaking and pulling on the various pieces of Deckronomicon for the last month now and have gotten it into a really good spot. Before, I kind of kept an eye on when the post was going to go out and then hopped online to clean things up that the LLM invariably got wrong. It still gets things wrong pretty regularly, but I have a much better success rate with the new way I’m generating the prompts. Overall, I’m pretty pleased with where it’s landed. Here’s the most recent example as of this writing.
I recently posted on Mastodon that I was considering extending this to also post to Bluesky. Not because I’m super enthused with the platform or anything, but mostly because it’s a different protocol, different API, etc… and might introduce some new challenges that I’d not faced with Pixelfed thus far. So, I had a few hours to kill this morning and decided to start cracking on it. First things first, let’s check out the docs. Bluesky’s documentation is far more robust than Pixelfed’s is and there’s even nice SDKs already built out for Python which made things a lot easier to just pull together. I was able to pretty quickly understand how to instantiate the client and get things posting from a high level as it’s really only a couple of lines of code.
from atproto import Client
client = Client()
client.login('username', 'password')
Easy peasy, let’s fire up an account for Deckronomicon and, voila, we’re in business.
I’d already had all of the code for posting to Pixelfed abstracted out into its own class, so I was able to spin up a pretty quick class for Bluesky following a similar class
from atproto import Client, models, exceptions
class BlueskyService:
def __init__(self, username: str, password: str, profile: str):
"""Initialize the Bluesky service.
Args:
username: Bluesky username
password: Bluesky password
profile: The Bluesky profile to post this to
"""
self.username = username
self.password = password
self.profile = profile
self.client = Client()
self.client.login(self.username, self.password)
def post_image(
self,
image_path: Path,
caption: str,
alt_text: str,
height: int,
width: int,
) -> bool:
"""Post an image to Bluesky.
Args:
image_path: Path to the image.
caption: Caption for the image.
alt_text: Alt text for the image.
height: The height for the image to avoid a 1:1 crop
width: The width for the image to avoid a 1:1 crop
Returns:
True if post was successful, False otherwise.
"""
logger.info("Starting post process on Bluesky")
try:
with open(image_path, "rb") as image:
aspect_ratio = models.AppBskyEmbedDefs.AspectRatio(
height=height, width=width
)
logger.info("Sending post request to Bluesky")
response = self.client.send_image(
text=caption,
image=image,
image_alt=alt_text,
profile_identify=self.profile,
image_aspect_ratio=aspect_ratio,
)
logger.info(f"Bluesky response cid: {response.cid}")
logger.info(f"Bluesky response uri: {response.uri}")
return True
except exceptions.AtProtocolError as e:
logger.error(f"Error during Bluesky upload: {str(e)}")
return False
I commented out the code to post to Pixelfed and move the file since I just wanted to test this out and it actually worked almost perfectly right out of the gate. The one thing I noticed is that the hashtag wasn’t clickable. Having used Bluesky a bit, I knew that was definitely something it supported so back into the docs I went. Turns out, I needed facets. So I made a few adjustments to the code
def post_image(...)
... the bulk of my post method
facets = self.convert_hashtags_to_facets(caption)
response = self.client.send_image(
text=caption,
image=image,
image_alt=alt_text,
profile_identify=self.profile,
image_aspect_ratio=aspect_ratio,
facets=facets,
)
... the rest of my post method
def convert_hashtags_to_facets(self, caption: str) -> List[models.AppBskyRichtextFacet.Main]:
"""Convert text to hashtag facet response
Args:
caption: The caption from our LLM
Returns:
List of facets that can be used to create the post
"""
facets = []
hashtag_pattern = r"#(\w+)"
for match in re.finditer(hashtag_pattern, caption):
tag = match.group(1)
start_bytes = len(caption[: match.start()].encode("utf-8"))
end_bytes = len(caption[: match.end()].encode("utf-8"))
index = models.AppBskyRichtextFacet.ByteSlice(
byteStart=start_bytes, byteEnd=end_bytes
)
tag_feature = models.AppBskyRichtextFacet.Tag(tag=tag)
facet = models.AppBskyRichtextFacet.Main(
index=index, features=[tag_feature]
)
facets.append(facet)
logger.debug(
f"Detected hashtag: #{tag} at positions {start_bytes}-{end_bytes}"
)
return facets
Cool, everything looks good, let’s run it again and… error. Invalid app.bsky.feed.post record: Record/text must not be longer than 300 graphemes… time to find out WTF a grapheme is.
Long story long (and several web searches later) it essentially boils down to character length. That’s definitely the way oversimplified version of it as it takes into effect things like URLs, hashtags, etc… in various ways, but I knew I could get away with some character limits and likely hit what I wanted. A quick optional length limit to the prompt and we’re back on our way until… another error. This time we got hit with This file is too large. It is 1.42MB but the maximum size is 976.56KB.
I didn’t want to get into the hassle of converting the images and saving them to /tmp and honestly I was pretty close to just calling it and stashing the code for later, but I decided to spend a little time digging into what I could do in memory with Python. I was already pulling in pillow to get exif data and things like width/height, so I figured I might be able to just leverage that. Turns out, I can with a little handy helper in my file_manager class which will try to automatically figure out what the scale is that I need to use. I could probably clean this up further by checking to see if we even need to calculate this (eg: is the file under 975kb already) but it works for now.
def get_reduced_screenshot(self, file_path: Path) -> bytes:
with Image.open(file_path) as image:
width, height = image.size
scale_factor = 1.0
while True:
new_width = int(width * scale_factor)
new_height = int(height * scale_factor)
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
image_bytes = io.BytesIO()
resized_image.save(image_bytes, format="JPEG", quality=85)
size_kb = len(image_bytes.getvalue()) / 1024
if size_kb <= 975:
break
scale_factor *= 0.9
return image_bytes.getvalue()
Time to run it again and, another error. Error during processing: cannot write mode RGBA as JPEG. One quick little edit to add a quick conversion.
if image.mode in ("RGBA"):
image = image.convert("RGB")
How about this time? Nope… forgot that I’m now converting to raw image bytes so I need to make one last change to my posting method to use the bytes instead of a Path object. Alright, fingers crossed this is everything I need, let’s run it one more time and…
Brace yourselves as our heroes face off against the vicious beasts of #FinalFantasyVI! Can they withstand the onslaught and triumph in this epic encounter? #RPG #RetroGaming
— Deckronomicon (@deckronomicon.bsky.social) March 30, 2025 at 12:45 PM
[image or embed]
We’ve done it! Well, at least for this first go around. I’m sure over the coming days I’ll check the logs and find some other issue that I’m not aware of, but now you can follow along with Deckronomicon on either Pixelfed or Bluesky.