Posting to social media from ghost using Kestra
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.