ENOSUCHBLOG

Programming, philosophy, pedaling.


Going write-only on Twitter (and introducing autopost)

Dec 14, 2022     Tags: lifestyle, meta    

This post is at least a year old.

TL;DR: I’ve decided to wean myself off of Twitter by going “write-only”: my posts there will just be updates to this blog, bridged from Mastodon via Moa Bridge. I’ll be making those posts automatically, using a tool I made for exactly this purpose. Every post I make automatically will also therefore be available on Mastodon, and I encourage you (dear reader) to follow me there if you’d like.

Going forwards, you can find me (as in, actually talk to me) on Mastodon at @yossarian@infosec.exchange1. I’m unlikely to respond to Twitter DMs anymore2.

Update: As I regularly forget to mention: this blog also has an IRC room at #enosuchblog on libera.chat.


Bye, Twitter

It looks like the “TL;DR” to this post might end up being almost as long as the actual post.

I don’t think I need to do much explaining here: Twitter is not the right place for me anymore. Others have said it just fine already, so I’ll keep it brief: I don’t have confidence in the site’s leadership, and I’ve been concerned by a visible upwelling of reactionary content on the site.

I’m also aware that I’m part of multiple communities that provide value to the site, beyond just the value in advertising to me. Most of those communities have left or are in the process of leaving, and the value I get out of the fragments that remain do not balance out against the continued value that Twitter gets from my active account.

Still, I can’t just delete my account and be done with it: deleting my account means that the username gets recycled, and I’d prefer if that didn’t happen. I also still have friends who would like to stay (which I don’t blame them for) and people who I know read my blog by discovering my posts on Twitter.

The best solution for both of these groups (and myself) is to keep the account, but in a “write-only” state: updates to this blog will be Tweeted out automatically, but the account will otherwise cease to be active.

Introducing autopost

As mentioned above: my only remaining presence on Twitter will be automated posts, which will happen whenever a new post on this blog comes out.

These posts will look a lot like previous ones, which I’ve been doing manually:

An example Twitter post.

…except that they’ll be coming from a program called autopost.

Here’s the entirety of how I use autopost to automate publishing my blog posts to various sites, including Twitter:

1
autopost atom https://blog.yossarian.net/feed.xml

Internally, that:

  1. Fetches the title and URL of the latest blog entry, via Atom;
  2. Fans it across a bunch of different “backends,” including Reddit (for this blog’s subreddit), Mastodon (any instance that allows OAuth access is supported), and Twitter.

It’s all Python, so here’s what the Twitter backend looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Twitter(Backend):
    def __init__(self, config: TwitterConfig):
        self._config = config
        self._client = twitter.Api(
            consumer_key=self._config.api_key.get_secret_value(),
            consumer_secret=self._config.api_key_secret.get_secret_value(),
            access_token_key=self._config.access_token.get_secret_value(),
            access_token_secret=self._config.access_token_secret.get_secret_value(),
        )

    def health_check(self) -> Result[None, str]:
        return Err("unimplemented")

    def post(self, content: str, url: str, *, tags: list[str] = []) -> Result[Url, str]:
        tags = [f"#{tag}" for tag in tags]
        status = dedent(
            f"""
            {content}

            {url}

            {' '.join(tags)}
            """
        )

        try:
            resp = self._client.PostUpdate(status)
            url = f"https://twitter.com/{resp.user.screen_name}/status/{resp.id_str}"
            return Ok(Url(url))
        except Exception as e:
            return Err(str(e))

…except that I can’t currently use it, because Twitter has neglected to give me a developer token for over a month3.

Instead, I’m using the Mastodon backend:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Mastodon(Backend):
    def __init__(self, config: MastodonConfig):
        self._config = config
        self._client = MastodonClient(
            api_base_url=self._config.server,
            client_id=self._config.client_key.get_secret_value(),
            client_secret=self._config.client_secret.get_secret_value(),
            access_token=self._config.access_token.get_secret_value(),
        )

    def health_check(self) -> Result[None, str]:
        try:
            ok = self._client.instance_health()
            if ok:
                return Ok()
            return Err(f"{self._config.server} failed instance health check")
        except Exception as e:
            return Err(str(e))

    def post(self, content: str, url: str, *, tags: list[str] = []) -> Result[Url, str]:
        tags = [f"#{tag}" for tag in tags]
        status = dedent(
            f"""
            {content}

            {url}

            {' '.join(tags)}
            """
        )

        try:
            resp = self._client.status_post(status)
            return Ok(Url(resp["url"]))
        except Exception as e:
            return Err(str(e))

…and mirroring my posts via moa.party, which is a public instance of Moa Bridge.

Configuring autopost is also pretty easy, it’s just a TOML file, listing each “backend” to post to and how to get its credentials (in this case, they’re expected to be in the listed environment variables):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[[backend]]
type = "Reddit"
name = "reddit:/r/enosuchblog"
subreddit = "enosuchblog"
client_id = { type = "Environment", variable = "REDDIT_CLIENT_ID" }
client_secret = { type = "Environment", variable = "REDDIT_CLIENT_SECRET" }
username = "yossarian_flew_away"
password = { type = "Environment", variable = "REDDIT_PASSWORD" }

[[backend]]
type = "Mastodon"
name = "mastodon:@yossarian@infosec.exchange"
server = "https://infosec.exchange"
client_key = { type = "Environment", variable = "INFOSEC_EXCHANGE_CLIENT_KEY" }
client_secret = { type = "Environment", variable = "INFOSEC_EXCHANGE_CLIENT_SECRET" }
access_token = { type = "Environment", variable = "INFOSEC_EXCHANGE_ACCESS_TOKEN" }

(As implied by the format, there can be multiple backends of each type, e.g. multiple subreddits to post to. I’m not currently using that functionality, but it might be useful to myself or others in the future.)

Tying things together

On its own, all autopost does is make a post to a bunch of services.

It isn’t entirely sufficient for my purposes: there needs to be a little more state, to ensure that I don’t accidentally make duplicate posts each time I use GitHub Actions to deploy my blog.

To accomplish that, I have a little bit of CI glue:

  1. Before building my blog and deploying it, I check the RSS feed for the latest post’s title and store it;
  2. Immediately after deploying the blog, I fetch the latest post’s title from RSS again;
  3. If the two diverge, I know that the deployment was for a new blog post and that the CI should also run autopost.

Here’s what that actually looks like, in CI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
jobs:
  pre-deploy:
    runs-on: ubuntu-latest
    outputs:
      title: ${{ steps.latest-blog-title.outputs.title }}
    steps:
      - name: dependencies
        run: |
          # omitted for brevity
      - name: get the most recent blog post title
        id: latest-blog-title
        run: |
          title=$(curl https://blog.yossarian.net/feed.xml | \
            xq -r '.feed.entry[0].title."#text"')
          echo "title=${title}" | tee -a "${GITHUB_OUTPUT}"

  # deploy omitted for brevity

  autopost:
    needs: [pre-deploy, deploy]
    runs-on: ubuntu-latest
    steps:
      - name: dependencies
        run: |
          # omitted for brevity
      - name: check for a new post
        env:
          LAST_POST_TITLE: ${{ needs.pre-deploy.outputs.title }}
        run: |
          latest_title=$(curl https://blog.yossarian.net/feed.xml | \
            xq -r '.feed.entry[0].title."#text"')
          if [[ "${latest_title}" = "${LAST_POST_TITLE}" ]]; then
            echo "no new post, not autoposting"
            exit 0
          fi

          echo "autoposting for: ${latest_title}"
          autopost atom https://blog.yossarian.net/feed.xml

…and with that, updates only go out when the blog is updated with a new post (and not whenever I fix a typo or re-deploy for any other reason).

As I am wont, autopost is open source and available for anybody else to use via PyPI:

1
2
$ python -m pip install autopost
$ autopost --help

My hope is that it’ll help others make the move away from Twitter as well.


  1. Among many other places. 

  2. This is my opportunity to say “thanks” to those of you who did reach out to me via Twitter over the years. You might find it interesting to know that you were in the majority, followed by e-mail, followed by Keybase and Reddit. 

  3. I can’t really blame them for this, though. It’s hard to imagine that there’s anybody even looking at developer applications at the moment. 


Discussions: Reddit Twitter Mastodon