Many social media automation platforms now charge for automatically posting an RSS feed to X.com (Formerly Twitter). One alternative to paid social media automation platforms is to use Slack. If you are already paying for Slack Pro this comes at no additional cost. In this post we will have a look at setting up a Slack platform app to post our JDriven blog RSS feed to X.com.

Introduction

This post is not an introduction to Slack platform apps, but rather a guide for those who are considering using Slack to doing something similar. If you are new to Slack platform apps, I encourage you to review Slack’s comprehensive tutorials and documentation here

Manifest

All Slack platform apps start out with a manifest file that stipulates its requirements. This includes permissions, outgoing domains, workflows, datastore’s.

import { Manifest } from "deno-slack-sdk/mod.ts";
import { MessageDatastore } from "./datastores/messages.ts";
import { PostBlogJobWorkflow } from "./workflows/post_blog_job.ts";

export default Manifest({
  name: "jdriven-blog-poster",
  description: "An automatic bot that posts content to twitter",
  icon: "assets/jdriven_logo.png",
  // Our workflow
  workflows: [PostBlogJobWorkflow],
  // List of external domains our function calls
  outgoingDomains: ["blog.jdriven.com", "api.twitter.com", "raw.githubusercontent.com"],
  // Our datastore
  datastores: [MessageDatastore],
  // Permissions required
  botScopes: [
    "commands",
    "chat:write",
    "chat:write.public",
    "datastore:read",
    "datastore:write",
    "channels:read",
    "triggers:write",
    "triggers:read",
  ],
});

Trigger

The trigger stipulates what would cause this workflow to start. In this case we would like it to be scheduled and run once an hour.

import { Trigger } from "deno-slack-sdk/types.ts";
import { TriggerTypes } from "deno-slack-api/mod.ts";
import PostBlogJobWorkflow from "../workflows/post_blog_job.ts";

const postBlogJobTrigger: Trigger<typeof PostBlogJobWorkflow.definition> =
{
  type: TriggerTypes.Scheduled,
  name: "Post blog job trigger",
  description: "Trigger a job to check if any new posts have been made and tweet them",
  workflow: `#/workflows/${PostBlogJobWorkflow.definition.callback_id}`,
  inputs: {},
  schedule: {
    start_time: new Date(new Date().getTime() + 10000).toISOString(),
    end_time: "2037-12-31T23:59:59Z",
    // How often should we trigger our workflow
    frequency: { type: "hourly", repeats_every: 1 },
  },
};

export default postBlogJobTrigger;

Workflow

Our workflow consists of a single step, which is the PostBlogFunctionDefinition described below.

import { DefineWorkflow } from "deno-slack-sdk/mod.ts";
import { PostBlogFunctionDefinition } from "../functions/post_blog.ts";

export const PostBlogJobWorkflow = DefineWorkflow({
  callback_id: "post_blog_job",
  title: "Post blog job",
  description: "Post new blogs to twitter",
  input_parameters: { properties: {}, required: [] },
});

// Point our workflow to our function
PostBlogJobWorkflow.addStep(PostBlogFunctionDefinition, {});

export default PostBlogJobWorkflow;

Datastore

Our datastore is relatively simple and will include only one field, which is the id of the posted blog.

import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts";

export const MessageDatastore = DefineDatastore({
  name: "messages",
  primary_key: "id",
  attributes: {
    id: {
      type: Schema.types.string,
    }
  },
});

Utils

Let’s create a few utility functions that we can use in our Slack function. The CONSUMER_KEY, CONSUMER_SECRET, ACCESS_TOKEN, and ACCESS_SECRET can all be retrieved from your X.com developer portal. The CHANNEL_ID is the id of the channel you would like to send the message to.

import { parse } from "https://deno.land/x/xml/mod.ts"

import * as oauth from "https://raw.githubusercontent.com/snsinfu/deno-oauth-1.0a/main/mod.ts";

// Only check posts that are not older than 2 days
// Only if the post is not in the datastore will we consider it else it has already been posted
export async function isIneligibleToPost(client: any, feedItem: any) {
  var twoDaysAgo = new Date();
  twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
  var publishedDate = new Date(feedItem.published);
  if (publishedDate < twoDaysAgo) {
    console.info("Blog is too old to consider " + feedItem.id)
    return true;
  }

  const prePostedMessage = await client.apps.datastore.get({
    datastore: "messages",
    id: feedItem.id,
  });
  if (prePostedMessage.item.id!!) {
    console.info("Blog has already been posted " + feedItem.id)
    return true;
  }
  return false;
}

// A method to retrieve the RSS feed using a get request
export async function getFeed(client: any, env: any) {
  let resultFeedXml = await fetch("https://blog.jdriven.com/atom.xml").then(response => {
    if (response.ok) {
      return response;
    }
    console.error("Failed to retrieve blog feed");
    console.error(response);
    postMessageToChannel(client, env, "Failed to retrieve blog feed");
    return response;
  }).then(response => response.text());

  return parse(resultFeedXml);
}

// Method to add a message to a channel
export async function postMessageToChannel(client: any, env: any, text: string) {
  await client.chat.postMessage({
    channel: env["CHANNEL_ID"],
    text: text
  });
}

// Call the X.com API
export async function postToTwitter(env: any, tweet: string) {
  let tweetBody = {
    text: tweet
  }

  const oathClient = new oauth.OAuthClient({
    consumer: {
      key: env["CONSUMER_KEY"],
      secret: env["CONSUMER_SECRET"]
    },
    signature: oauth.HMAC_SHA1,
  });

  const auth = oauth.toAuthHeader(oathClient.sign(
    "POST",
    "https://api.twitter.com/2/tweets",
    {
      token: {
        key: env["ACCESS_TOKEN"],
        secret: env["ACCESS_SECRET"]
      },
      body: JSON.stringify(tweetBody),
    }
  ));

  console.info("Tweeting: " + tweetBody.text);

  return await fetch("https://api.twitter.com/2/tweets", {
    method: "POST",
    body: JSON.stringify(tweetBody),
    headers: {
      "Content-Type": "application/json",
      Accept: "application/json",
      Authorization: auth
    },
  });
}

Function

Our slack function is fairly simple and does the following:

  1. A GET request is made for the RSS feed XML.

  2. The list of feed blog posts is iterated through one by one.

  3. If a blog post has been posted before it is ignored (This is done by querying the datastore).

  4. If the blog post hasn’t been posted before, post it to X.com and add it to the datastore of posted blogs.

import { DefineFunction, SlackFunction } from "deno-slack-sdk/mod.ts";

import { getFeed, postToTwitter, isIneligibleToPost, postMessageToChannel } from "./utils.ts";

export const PostBlogFunctionDefinition = DefineFunction({
  callback_id: "post_blog",
  title: "Post new blog post to Twitter",
  source_file: "functions/post_blog.ts",
  input_parameters: { properties: {}, required: [] },
  output_parameters: { properties: {}, required: [] },
});

interface Blog {
  feed: Feed;
}

interface Feed {
  entry: any[]
}

export default SlackFunction(
  PostBlogFunctionDefinition,
  async ({ client, env }) => {

    let resultFeedJson: Blog = await getFeed(client, env) as unknown as Blog;

    let feed: Feed = resultFeedJson.feed;

    // Loop over all blog posts in RSS Feed
    for (const feedItem of feed.entry) {

      // If post is ineligible skip over it
      if (await isIneligibleToPost(client, feedItem)) {
        continue;
      }

      // Post is eligible so let's post it
      let twitterResponse = await postToTwitter(env, feedItem.title + " " + feedItem.id);
      if (twitterResponse.ok) {

        // If successfully posted add to datastore of posted blogs
        await client.apps.datastore.put({
          datastore: "messages",
          item: {
            id: feedItem.id
          },
        });

        let twitterBody = await twitterResponse.json()
        console.info("Tweet posted for blog post: https://twitter.com/jdriven_nl/status/" + twitterBody.data.id)
        await postMessageToChannel(client, env,
            "Tweet posted for blog post: https://twitter.com/jdriven_nl/status/" + twitterBody.data.id);
      } else {
        console.error("Failed to Tweet blog post: " + feedItem.id + " Response: " + twitterResponse.body)
        console.error(twitterResponse)
        await postMessageToChannel(client, env, "Failed to Tweet blog post: " + feedItem.id);
      }
    }

    return { outputs: {} };
  },
);

Conclusion

After deploying our app using the Slack cli, if a new blog post appears in the RSS feed and the trigger runs, it will automatically be posted to X.com. A message will also be sent on Slack.

X.com Post
Figure 1. X.com post
Slack Post Message
Figure 2. Slack message
shadow-left