Posting to social media from ghost using Kestra

Posting to social media from ghost using Kestra
The kestra logo

Previously, I have set up webhooks which trigger when my ghost blog posts go live. In order to show the world what I have written, I usually post on to social media. To be honest, I find posting a bit of a chore.

Recently, I have set up an account on Bluesky. I also have Mastodon too and both of these accounts can be accessed by API. I think you can see where this is going. My plan is to use the trigger from ghost to start a flow in Kestra which posts to social media for me. If the flow fails, I should get an alert, via slack.

Now we know what we are doing, and we have previously set up ghost to produce webhooks on key events in Kestra, we can use this to kick off the work flow. For reference, my webhook triggers on the ghost event "post published". Since I want to be able to post to social media from multiple flows and if I want to update them, I want one easy place to do that. I decided to create a separate flow to do just that. This also allows for testing. My last requirement is that I only really care about text and links, not images.

Taking the webhook from last time, we just need that to call a subflow. The new subflow will be in the namespace "posting" and be called "socialMediaPoster"

id: ghostPagePublishedTrigger
namespace: ansible
description: Start tasks on a webhook from Ghost

labels:
  env: prod
  project: trigger-wrapper

inputs:
  - id: blueuser
    type: STRING
    required: true
    defaults: "youruser.bsky.social"

tasks:
  - id: call_posting_socialMediaPoster
    type: io.kestra.plugin.core.flow.Subflow
    namespace: posting
    flowId: socialMediaPoster
    inputs:
      content: "{{ trigger.body.post.current.title }} {{ trigger.body.post.current.url }}"
      blueuser: "{{ inputs.blueuser }}"
    wait: true
    transmitFailed: false
       
triggers:
  - id: ghostPagePublishedTrigger
    type: io.kestra.plugin.core.trigger.Webhook
    description: Ghost webhook - Page published
    key: abcdefgh12345678

Next we need to define the flow that will actually do the posting.

id: socialMediaPoster
namespace: posting
description: Posts to social media for me

labels:
  env: prod
  project: social-wrapper

inputs:
  - id: content
    type: STRING
    required: true


tasks:

Essentially, I know this block will need an input of what I want to post called "content". The next is to decide what the tasks should be. Since I want to post to bluesky, I looked for scripts which allow you to post to bluesky and what their requirements are. Fortunately, bluesky actually publishes their own app in python to do just that.

In the Bluesky github cookbook, there is a script called create_bsky_post.py. It can be found here: https://github.com/bluesky-social/cookbook/blob/main/python-bsky-post/README.md. If you would like more detail on what this app is doing, bluesky have a tutorial on creating your own script to post to bluesky here: https://docs.bsky.app/docs/tutorials/creating-a-post

Looking at the scripts, the information we will need is the bluesky user, the password and the text to post. Of all of these, the password needs to be stored securely. There are ways of encoding secrets in to the environment of kestra which can be accessed via the secret function.

The kestra task for calling the script looks like this:

  - id: post_to_bluesky
    type: io.kestra.plugin.scripts.python.Commands
    namespaceFiles:
      enabled: true
      include:
        - create_bsky_post.py
    taskRunner:
      type: io.kestra.plugin.core.runner.Process
    beforeCommands:
    - pip install requests datetime bs4 > /dev/null
    commands:
    - python create_bsky_post.py --handle "{{ inputs.blueuser }}" --password "{{ secret('BLUESKY_PASSWORD')}}" "{{ inputs.content }}"   

The script itself is stored in the _files directory of my namespace, posting. This is found in your kestra data directory. Once set up and tested, everything appeared to be working fine. For the next challenge, I moved on to Mastodon.

While Mastodon doesn't have an example script that I could find, it is relatively straightfoward. I repurposed some of the create_bluesky_post.py to post to Mastodon instead. This file does depend on the Mastodon library, which does have examples of posting. For this script, you don't need your handle, but you do need an api key as well as your instance name.

#!/usr/bin/env python3

import re
import os
import sys
import json
import argparse
from typing import Dict, List
from datetime import datetime, timezone

import requests
from bs4 import BeautifulSoup
from mastodon import Mastodon

def main():
    parser = argparse.ArgumentParser(description="Mastodon post upload example script")
    parser.add_argument("--password", default=os.environ.get("ATP_AUTH_PASSWORD"))
    parser.add_argument("text", default="")
    args = parser.parse_args()
    if not (args.password):
        print("APIoken is required", file=sys.stderr)
        sys.exit(-1)

    # Create an instance of the Mastodon class
    mastodon = Mastodon(
      access_token=args.password,
      api_base_url='https://infosec.exchange'
    )

    # Post a new status update
    mastodon.status_post(args.text)

if __name__ == "__main__":
    main()

In this case, I have set the api base to infosec.exchange, which is where I am based. Your mileage may vary. Setting up the task is similar to the task for bluesky:

  - id: post_to_mastodon
    type: io.kestra.plugin.scripts.python.Commands
    namespaceFiles:
      enabled: true
      include:
        - create_mastodon_post.py
    taskRunner:
      type: io.kestra.plugin.core.runner.Process
    beforeCommands:
    - pip install requests datetime bs4 Mastodon.py > /dev/null
    commands:
    - python create_mastodon_post.py --password "{{ secret('MASTODON_ACCESS_TOKEN')}}" "{{ inputs.content }}"   

Some clean up on the script and the requirements can probably be done.

Putting everything together, the full flow looks like this:

id: socialMediaPoster
namespace: posting
description: Posts to social media for me

labels:
  env: prod
  project: social-wrapper

inputs:
  - id: blueuser
    type: STRING
    required: true
    defaults: "youruser.bsky.social"
  - id: content
    type: STRING
    required: true

tasks:
  - id: post_to_bluesky
    type: io.kestra.plugin.scripts.python.Commands
    namespaceFiles:
      enabled: true
      include:
        - create_bsky_post.py
    taskRunner:
      type: io.kestra.plugin.core.runner.Process
    beforeCommands:
    - pip install requests datetime bs4 > /dev/null
    commands:
    - python create_bsky_post.py --handle "{{ inputs.blueuser }}" --password "{{ secret('BLUESKY_PASSWORD')}}" "{{ inputs.content }}"   
  - id: post_to_mastodon
    type: io.kestra.plugin.scripts.python.Commands
    namespaceFiles:
      enabled: true
      include:
        - create_mastodon_post.py
    taskRunner:
      type: io.kestra.plugin.core.runner.Process
    beforeCommands:
    - pip install requests datetime bs4 Mastodon.py > /dev/null
    commands:
    - python create_mastodon_post.py --password "{{ secret('MASTODON_ACCESS_TOKEN')}}" "{{ inputs.content }}"  

Now, I have a way to automatically post to social media when my blog posts go live or when I think the output of a flow requires it. It is also able to add other tasks or call other flows as well.

As an Amazon Associate I earn from qualifying purchases.

If you have found this post useful, please consider donating.