Back to the blog
Blog

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

Mar 30, 20257 min read
  • Steam Deck
  • Linux
  • Automation
  • AI
  • Bluesky
  • Mastodon

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…

via GIPHY

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

[image or embed]

— Deckronomicon (@deckronomicon.bsky.social) March 30, 2025 at 12:45 PM

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.