Two years ago, we wrote about building a Slack bot to automatically tweet new JDriven blog posts, and inform us about this using our dedicated internal Slack channel. Life was simple then. If it was on Twitter, it was "online".

But the social media landscape has fragmented and augmented. The tech community has spread, at least across X, Mastodon, and Bluesky. So to reach our audience today, we have to tweet, toot and who knows what to reach them.

We needed to upgrade our trusty Slack bot from a single-channel poster to an omnichannel syndication engine. In this post, I’ll walk you through how I refactored our Deno-based Slack app to handle multiple platforms, manage distinct authentication protocols, and ensure we don’t spam our followers with duplicate posts.

The Source: smarter data with custom namespaces

The first challenge was identity, as we had the desire to also tag the author of the blog post on the social platforms. If the bot simply grabbed the author’s name, we’d lose the ability to tag them properly on each platform. Moreover, my handle on X is not necessarily the same as my handle on Mastodon or Bluesky.

Instead of hardcoding a mapping table in the bot, I decided to fix this at the source. I extended our blog’s Atom feed using a custom XML namespace (xmlns:social). This allows us to embed platform-specific metadata directly into the feed without breaking standard feed readers.

Here is what the raw XML looks like now:

Listing 1. A snippet from an <entry> with social-tags
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:social="https://jdriven.com/ns/social">
  <entry>
    <title>Automating with Deno</title>
    <id>https://jdriven.com/blog/2025/12/automating-with-deno</id>
    <author>
      <name>A caring developer</name>
      <social:twitter>jdriven_nl</social:twitter>
      <social:mastodon>jdriven@mastodon.social</social:mastodon>
      <social:bluesky>jdriven.com</social:bluesky>
    </author>
    <social:hashtags>#slack #deno #typescript</social:hashtags>
  </entry>
</feed>

By parsing and using these custom tags, the bot knows exactly what hashtags to use, and who to mention on which platform.

The setup

To be able to correctly use the xml tags in my Slack application, I created a custom FeedEntry type so I know what data I can expect.

Listing 2. The FeedEntry type (file: ./functions/types.ts)
export interface FeedEntry {
  id: any; // id can be a string or object
  title: string;
  "social:hashtags"?: string;
  published: string;
  updated?: string;
  author: {
    name: string;
    "social:twitter"?: string;
    "social:mastodon"?: string;
    "social:bluesky"?: string;
    [key: string]: any; // other author info
  };
  summary?: string | { type?: string; "#text": string };
  [key: string]: any; // other entry info
}

export interface Feed {
  entry: FeedEntry[];
}

export interface Blog {
  feed: Feed;
}

export type Env = Record<string, string>;

I already had a utils-file where several utility functions are stored:

  • checking if a blog post is not too old, to prevent posting everything we ever posted whenever our datastore is empty

  • posting a message to our dedicated Slack channel

  • fetching and parsing our atom feed from our blog site

I updated and improved the existing methods, and extended that file with a simple method to strip and clean html entities from a given piece of text. Something I will need when I want to post a summary or an introduction of a blog post to my social media post as well.

Listing 3. Utility functions (file: ./functions/utils.ts)
import { SlackAPIClient } from "deno-slack-api/types.ts";
import { parse } from "https://deno.land/x/xml/mod.ts";
import { decode } from "npm:html-entities";
import { Blog, Env, FeedEntry } from "./types.ts";

export async function isTooOld(feedItem: FeedEntry) {
  const DAY_IN_MS = 24 * 60 * 60 * 1000;
  const twoDaysAgo = new Date(Date.now() - 2 * DAY_IN_MS);
  const publishedDate = new Date(feedItem.published);
  const now = new Date();
  return publishedDate < twoDaysAgo || (publishedDate > now);
}

export async function getFeed(client: SlackAPIClient, env: Env): Promise<Blog> {
  const EMPTY_BLOG: Blog = { feed: { entry: [] } };
  try {
    let resultFeedXml = await fetch(env["FEED_URL"]).then((response) => {
      if (!response.ok) {
        throw new Error(`HTTP error ${response.status}: ${response.statusText}`);
      }
      return response;
    }).then((response) => response.text());

    return parse(resultFeedXml) as unknown as Blog;
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : String(error);
    console.error(`Failed to retrieve or parse feed from ${env["FEED_URL"]}:`, errorMessage);
    await postMessageToChannel(client, env, `⚠️ Error with blog feed: ${errorMessage}`);
    return EMPTY_BLOG;
  }
}

export async function postMessageToChannel(client: SlackAPIClient, env: Env, text: string) {
  await client.chat.postMessage({
    channel: env["CHANNEL_ID"],
    text: text,
  });
}

export function cleanSummary(rawSummary: any): string {
  if (!rawSummary) return "";

  let text = typeof rawSummary === "object" && rawSummary["#text"]
    ? rawSummary["#text"]
    : String(rawSummary);

  // Decode HTML entities
  text = decode(text);

  // Strip HTML tags
  text = text.replace(/<[^>]+>/g, "");

  // Collapse whitespace
  text = text.replace(/\s+/g, " ").trim();

  return text;
}

The datastore: granular state management

In the old version, our datastore was simple: it stored the ID of the blog post. If the ID existed, we skipped it as it was already posted in a previous run.

However, posting to three distinct APIs introduces partial failure scenarios. What if X accepts the post, but the Bluesky API times out? If we simply marked the blog as "processed," we would never retry Bluesky. If we marked it "failed," we might double-post to X on the next run.

I updated the Slack datastore schema to track the state of each platform granularly:

Listing 4. The message datastore for our blog posts and social platforms (file: ./datastores/messages.ts)
import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts";

export const MessageDatastore = DefineDatastore({
  name: "messages",
  primary_key: "id",
  attributes: {
    id: { type: Schema.types.string },
    posted_to_twitter: { type: Schema.types.boolean, default: false },
    posted_to_mastodon: { type: Schema.types.boolean, default: false },
    posted_to_bluesky: { type: Schema.types.boolean, default: false },
  },
});

Now, the logic in our handler is idempotent. Even if the bot runs every hour, it only attempts the "missing" social posts for that specific article.

To make sure I use the correct types in our bot, I also added the PostStatusItem-type to our types-file.

Listing 5. The PostStatusItem type (file: ./functions/types.ts)
export interface PostStatusItem {
  id: string;
  posted_to_twitter: boolean;
  posted_to_mastodon: boolean;
  posted_to_bluesky: boolean;
}

Tailoring the Content

I only want to tag an author if it actually has a handle on a specific social platform. If the social::PLATFORM attribute is set for an author for any of the platform, then I want to mention that author in the social media post. Otherwise, I just include the author’s name. Our feed provides the handle for a platform without the @-sign, so I add that to the post.

However, one size does not fit all. On X (posting a Tweet), I might want to use the author’s handle. URL’s are always shortened to 23 characters and a tweet is limited to 280 characters. On Mastodon (posting a Toot), I need the full instance address (@user@server) but only the user’s handle is printed. Here, URL’s are also shortened to 23 characters and the limit for a mastodon post is 500 characters. And on Bluesky, all links and handles are counted fully in the post limit of 300 characters.

To make sure I have all this logic reusable, and keep platform-specific restrictions and handling, I created an abstract SocialPlatform class to hold all this logic.

  • store the platform-specific maximum length of a post

  • calculate the length of a post given a body

  • perform the actual post on the platform

I want to create the "same" post content for each platform, so I also created a createPostContent method that constructs the message dynamically based on the target platform. It grabs the specific user-handle from the feed data I parsed earlier.

To be able to handle platform-specific responses and report back to Slack, I also want to implement two more methods:

  1. a method getPublicUrl that will construct the public url to the post on that platform, so I can report that back to our dedicated Slack channel.

  2. a method getErrorDetail that handles errors and composes a nicely formatted String out of that, so I can both log it and report it back to our dedicated Slack channel.

Listing 6. The SocialPlatform abstract class (file: ./functions/platforms.ts)
import { Env, FeedEntry, PostStatusItem } from "./types.ts";
import { cleanSummary } from "./utils.ts";

export abstract class SocialPlatform {

  abstract readonly name: string; // Platform name
  abstract readonly postTypeName: string; // Common name of a post on this platform
  abstract readonly statusKey: keyof PostStatusItem; // Status key for persistence
  abstract readonly maxPostLength: number; // Maximum number of allowed chars or glyphs

  // Calculate the char expense of a post
  abstract calculateLength(text: string): number;

  // Performing the actual post on this platform
  abstract post(env: Env, feedItem: FeedEntry): Promise<Response>;

  // Fetch the URL from the platform response
  abstract getPublicUrl(body: any, env: Env): string;

  // Handle error messages given a platform-specific error response
  abstract getErrorDetail(response: Response): Promise<string>;

  // Creating a common post for a platform
  protected createPostContent(feedItem: FeedEntry, feedSocialKey: string): string {
    const blogUrl = feedItem.id;
    const blogTitle = feedItem.title;

    const rawHandle = feedItem.author[feedSocialKey];
    const authorDisplay = rawHandle ? `@${rawHandle}` : feedItem.author.name;

    const dynamicHashtags = feedItem["social:hashtags"] || "";
    const hashtags = `#dev #softwaredevelopment ${dynamicHashtags}`.trim();

    const skeleton = `${blogTitle}\nA blog by ${authorDisplay}\n\n\n\n${hashtags}\n\n${blogUrl}`;
    const skeletonCost = this.calculateLength(skeleton);
    const availableSpace = this.maxPostLength - skeletonCost;
    let summary = cleanSummary(feedItem.summary);

    // If there is space, add the summary (shortened when required)
    if (availableSpace > 10) {
      while (this.calculateLength(summary + "...") > availableSpace && summary.length > 0) {
        // Chop of a word until it fits
        const lastSpace = summary.lastIndexOf(" ");
        if (lastSpace === -1) break;
        summary = summary.substring(0, lastSpace);
      }

      // Add dots if new summary is not identical to original cleaned summary
      if (summary.length < cleanSummary(feedItem.summary).length) {
        summary += "...";
      }
    } else {
      summary = "";
    }

    if (summary) {
      return `${blogTitle}\nA blog by ${authorDisplay}\n\n${summary}\n\n${hashtags}\n\n${blogUrl}`;
    }

    return `${blogTitle}\nA blog by ${authorDisplay}\n\n${hashtags}\n\n${blogUrl}`;
  }
}

With this abstract class, I will be able to have consistent implementations for each platform and I can reuse logic.

My three platform-specific classes are below:

Listing 7. The TwitterPlatform class (file: ./functions/platforms.ts)
import * as oauth from "https://raw.githubusercontent.com/snsinfu/deno-oauth-1.0a/main/mod.ts";

export class TwitterPlatform extends SocialPlatform {
  readonly name = "Twitter";
  readonly postTypeName = "Tweet";
  readonly statusKey = "posted_to_twitter";
  readonly maxPostLength = 280;

  calculateLength(text: string): number {
    let calculatedText = text;

    // Replace all URLs with a string of 23 chars (t.co length)
    const urlRegex = /https?:\/\/[^\s]+/g;
    calculatedText = calculatedText.replace(urlRegex, "x".repeat(23));

    return calculatedText.length;
  }

  async post(env: Env, feedItem: FeedEntry): Promise<Response> {
    // ... see below
  }

  async getErrorDetail(response: Response): Promise<string> {
    // ... see below
  }

  getPublicUrl(body: any, _env: Env) {
    return `https://twitter.com/jdriven_nl/status/${body.data.id}`;
  }
}
Listing 8. The MastodonPlatform class (file: ./functions/platforms.ts)
export class MastodonPlatform extends SocialPlatform {
  readonly name = "Mastodon";
  readonly postTypeName = "Toot";
  readonly statusKey = "posted_to_mastodon";
  readonly maxPostLength = 500;

  calculateLength(text: string): number {
    let calculatedText = text;

    // Replace all URLs with a string of 23 chars (mastodon length)
    const urlRegex = /https?:\/\/[^\s]+/g;
    calculatedText = calculatedText.replace(urlRegex, "x".repeat(23));

    // Mentions: @user@domain.com counts as @user
    const mentionRegex = /@([a-zA-Z0-9_]+)@[a-zA-Z0-9_.-]+/g;
    calculatedText = calculatedText.replace(mentionRegex, "@$1");

    return calculatedText.length;
  }

  async post(env: Env, feedItem: FeedEntry): Promise<Response> {
    // ... see below
  }

  async getErrorDetail(response: Response): Promise<string> {
    // ... see below
  }

  getPublicUrl(body: any, _env: Env) {
    return body.url;
  }
}
Listing 9. The BlueskyPlatform class (file: ./functions/platforms.ts)
import { AtpAgent, RichText } from "npm:@atproto/api";

export class BlueskyPlatform extends SocialPlatform {
  readonly name = "BlueSky";
  readonly postTypeName = "Bluesky status";
  readonly statusKey = "posted_to_bluesky";
  readonly maxPostLength = 300;

  calculateLength(text: string): number {
    // Use Bluesky's RichText library
    const rt = new RichText({ text: text });
    return rt.graphemeLength;
  }

  async post(env: Env, feedItem: FeedEntry): Promise<Response> {
    // ... see below
  }

  async getErrorDetail(response: Response): Promise<string> {
    // ... see below
  }

  getPublicUrl(body: any, env: Env) {
    const bskyUri = body.uri; // at://[...]/app.bsky.feed.post/[UNIQUE_POST_ID]
    const bskyIdentifier = env["BLUESKY_IDENTIFIER"];
    const bskyPostId = bskyUri.split("/").pop();
    return `https://bsky.app/profile/${bskyIdentifier}/post/${bskyPostId}`;
  }
}

The three flavors of authentication

This was the most technically diverse part of the upgrade. Each platform requires a completely different approach to authentication.

X (Twitter): OAuth 1.0a

X requires a signed header using OAuth 1.0a (HMAC-SHA1). I kept the existing deno-oauth-1.0a library for this. It involves signing the method, URL, and parameters with consumer keys and access tokens.

Listing 10. Posting a Tweet (file: functions/platforms.ts, class TwitterPlatform)
  async post(env: Env, feedItem: FeedEntry): Promise<Response> {
    const tweetText = this.createPostContent(feedItem, "social:twitter")
    const tweetBody = { text: tweetText };

    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),
      },
    ));

    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,
      },
    });
  }

A Twitter response is documented at the X API docs, which I implemented as follows:

Listing 11. Handling a Twitter response (file: functions/platforms.ts, class TwitterPlatform)
  async getErrorDetail(response: Response): Promise<string> {
    try {
      const body = await response.json();

      if (body.errors && Array.isArray(body.errors)) {
        return body.errors.map((e: any) => {
          const baseError = `${e.title} (${e.type})`;
          return e.detail ? `${baseError}: ${e.detail}` : baseError;
        }).join(" | ");
      }

      if (body.title || body.detail) {
        return `${body.title}: ${body.detail}`;
      }

      return JSON.stringify(body);
    } catch (e) {
      return `HTTP ${response.status} ${response.statusText}`;
    }
  }

Mastodon: the gentle giant

Mastodon is a breath of fresh air. It uses a standard REST API with a simple Bearer token.

Listing 12. Posting a Toot (file: functions/platforms.ts, class MastodonPlatform)
  async post(env: Env, feedItem: FeedEntry): Promise<Response> {
    const tootText = this.createPostContent(feedItem, "social:mastodon")
    const tootBody = { status: tootText };

    const instance = env["MASTODON_INSTANCE_URL"];
    const token = env["MASTODON_ACCESS_TOKEN"];

    return await fetch(`${instance}/api/v1/statuses`, {
      method: "POST",
      body: JSON.stringify(tootBody),
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
        Authorization: `Bearer ${token}`,
      },
    });
  }

A Mastodon response is documented at Error entities page on joinmastodon.org. I implemented this as follows:

Listing 13. Handling a Mastodon response (file: functions/platforms.ts, class MastodonPlatform)
  async getErrorDetail(response: Response): Promise<string> {
    try {
      const body = await response.json();
      const err = body.error || "Unknown Error";
      const desc = body.error_description ? ` - ${body.error_description}` : "";
      return `${err}${desc}`;
    } catch (e) {
      return `HTTP ${response.status} ${response.statusText}`;
    }
  }

Bluesky: the AT protocol

Bluesky requires a bit more legwork. It doesn’t use a static token; you need to manage a session, understand DID’s (Decentralized Identifiers), and calculate Facets (byte-indices for links and mentions) before sending the data.

Steps:

  1. Login: Send identifier and app-password to com.atproto.server.createSession.

  2. Get JWT: Receive an Access JWT and the user’s DID (Decentralized Identifier).

  3. Create Record: Use the JWT to post to the app.bsky.feed.post collection.

I used the @atproto/api package via Deno’s npm compatibility layer to handle the heavy lifting of detecting links and formatting the record correctly, while using standard fetch for the actual API calls to keep full control over the headers.

Listing 14. Creating a Bluesky post (file: ./functions/platforms.ts, class BlueskyPlatform)
  async post(env: Env, feedItem: FeedEntry): Promise<Response> {
    const identifier = env["BLUESKY_IDENTIFIER"];
    const password = env["BLUESKY_APP_PASSWORD"];

    const sessionResponse = await fetch(
      "https://bsky.social/xrpc/com.atproto.server.createSession",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ identifier, password }),
      },
    );

    if (!sessionResponse.ok) {
      console.error("Bluesky login failed.");
      return sessionResponse;
    }
    const session = await sessionResponse.json();

    const postContent = this.createPostContent(feedItem, "social:bluesky")
    const agent = new AtpAgent({ service: "https://bsky.social" });
    const rt = new RichText({ text: postContent });
    await rt.detectFacets(agent);

    return await fetch(
      "https://bsky.social/xrpc/com.atproto.repo.createRecord",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${session.accessJwt}`,
        },
        body: JSON.stringify({
          repo: session.did,
          collection: "app.bsky.feed.post",
          record: {
            $type: "app.bsky.feed.post",
            text: rt.text,
            facets: rt.facets,
            createdAt: new Date().toISOString(),
          },
        }),
      },
    );
  }

A Bluesky response is documented at the Bluesky docs, which I implemented as follows:

Listing 15. Handling a Bluesky response (file: ./functions/platforms.ts, class BlueskyPlatform)
  async getErrorDetail(response: Response): Promise<string> {
    try {
      const body = await response.json();
      const err = body.error || "Error";
      const msg = body.message ? ` - ${body.message}` : "";
      return `${err}${msg}`;
    } catch (e) {
      return `HTTP ${response.status} ${response.statusText}`;
    }
  }

Parallel execution and feedback

Our main custom function post_blog is defined following the standard Deno Slack SDK. Let me highlight some details and specifics below.

Listing 16. Slack function (file: ./functions/post_blog.ts)
import { SlackAPIClient } from "deno-slack-api/types.ts";
import { DefineFunction, SlackFunction } from "deno-slack-sdk/mod.ts";
import { MessageDatastore } from "../datastores/messages.ts";
import { BlueskyPlatform, MastodonPlatform, TwitterPlatform } from "./platforms.ts";
import { Blog, Feed, PostStatusItem } from "./types.ts";
import { getFeed, isTooOld, postMessageToChannel } from "./utils.ts";

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

export async function postBlogHandler(client: SlackAPIClient, env: Record<string, string>) {
  // method body, see below
  return { outputs: {} };
}

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

In this postBlogHandler I’ll first get the Atom feed from our blog site and loop over all the items:

Listing 17. postBlogHandler method body
let resultFeedJson: Blog = await getFeed(client, env) as unknown as Blog;
let feed: Feed = resultFeedJson.feed;
for (const feedItem of feed.entry) {
  // loop body
}

Within this loop, I execute the following actions for each entry:

  1. If an item is too old or not published yet, or if an item is already posted on all social platforms, skip it

  2. If needed, post item to X, Mastodon and/or BlueSky

  3. Report result to our dedicated Slack channel

  4. Store result in our datastore

Below is the implementation for this:

Listing 18. postBlogHandler loop body: check blog entry age and datastore
if (await isTooOld(feedItem)) {
  continue;
}

const datastoreResponse = await client.apps.datastore.get<typeof MessageDatastore.definition>({
  datastore: MessageDatastore.name,
  id: feedItem.id,
});

const postStatus: PostStatusItem = (datastoreResponse.item?.id) ? datastoreResponse.item : {
  id: feedItem.id,
  posted_to_twitter: false,
  posted_to_mastodon: false,
  posted_to_bluesky: false,
};

if (postStatus.posted_to_twitter && postStatus.posted_to_mastodon && postStatus.posted_to_bluesky) {
  continue;
}

Now I know that I need to post to at least one social platform. Instead of waiting for X, then Mastodon, then Bluesky, I will fire all requests in parallel using Promise.all. For that, I will use an array of postingPromises, where each entry is a function call to the platform-specific post(env, feedItem)-method. By always filling the postingPromises array with a Promise, I make sure the number of items is identical to the platforms array, which makes it easy for looping over the platforms later on.

Listing 19. postBlogHandler loop body: construct and execute posting promises
// Platforms to be posted to
const platforms = [
  new TwitterPlatform(),
  new MastodonPlatform(),
  new BlueskyPlatform(),
];

const postingPromises = platforms.map((platform) => {
  if (!postStatus[platform.statusKey]) return platform.post(env, feedItem);
  else return Promise.resolve(null);
});

// Parallel execution
const responses = await Promise.all(postingPromises);

After the attempts are made, I keep track if I have a changed state for a platform and will construct my feedback message so the bot reports back to our dedicated Slack channel. This provides immediate visibility into what happened. Did the blog post go out? Did one platform fail?

Listing 20. postBlogHandler loop body: handle response for each platform
let statusChanged = false;
const resultMessages: string[] = [];

// Handle responses
for (let i = 0; i < responses.length; i++) {
  const response = responses[i];
  if (!response) continue;

  const platform = platforms[i];
  if (response.ok) {
    postStatus[platform.statusKey] = true;
    statusChanged = true;
    const body = await response.json();
    const pubUrl = platform.getPublicUrl(body, env);
    resultMessages.push(`✅ ${platform.postTypeName} posted: ${pubUrl}`);
  } else {
    const errorDetail = await platform.getErrorDetail(response);
    resultMessages.push(`❌ Failed to post to ${platform.name}: ${errorDetail}`);
  }
}

If the status has changed, I have something to send to our Slack channel, and I want to persist the result of our actions in our datastore.

Listing 21. postBlogHandler loop body: post result to Slack channel and persist result
// If there is anything to tell, post a message to channel
if (resultMessages.length > 0) {
  const message = `Socials for: *${feedItem.title}*\n\n` + resultMessages.join("\n");
  await postMessageToChannel(client, env, message);
}

// Store status in datastore if it has changed
if (statusChanged || !datastoreResponse.item) {
  const putResp = await client.apps.datastore.put<typeof MessageDatastore.definition>({
    datastore: MessageDatastore.name,
    item: postStatus,
  });

  if (!putResp.ok) {
    console.error(`Failed to save to datastore. Summary: ${putResp.error}`);
    if (putResp.errors) {
      console.error("Full error details:", JSON.stringify(putResp.errors, null, 2));
    }
  }
}

I have now covered all platforms and the result for each platform is persisted.

Triggers: scheduled or manual

Finally, I improved the developer experience. The original bot only had a Scheduled Trigger (once per hour). This made testing annoying — you had to wait.

I added a manual trigger that listens for an @app_mention in Slack. Now, if I want to force-check the feed immediately, I just tag the bot in our channel.

Although the channel-id is defined in our environment, I can not use it here as the environment is not yet loaded. Therefore, I have hardcoded it here.

Listing 22. Trigger the blog poster manually (file: triggers/post_blog_manual.ts)
import { TriggerEventTypes, TriggerTypes } from "deno-slack-api/mod.ts";
import type { Trigger } from "deno-slack-sdk/types.ts";
import PostBlogJobWorkflow from "../workflows/post_blog_job.ts";

export const postBlogManualTrigger: Trigger<typeof PostBlogJobWorkflow.definition> = {
  type: TriggerTypes.Event,
  name: "Post blog manual trigger",
  description: "Start the PostBlogJobWorkflow after a @mention",
  workflow: `#/workflows/${PostBlogJobWorkflow.definition.callback_id}`,
  inputs: {},
  event: {
    event_type: TriggerEventTypes.AppMentioned,
    channel_ids: [
      // Channel id for our custom channel, should be the same as env["CHANNEL_ID"]
      // Hardcoded here as the environment variables are not loaded yet
      "C********",
    ],
  },
};

export default postBlogManualTrigger;

Finishing the complete app

To tie it all together, Slack also required a Workflow definition, which has not changed as compared to the previous version of our bot. A workflow is a set of steps that are executed in order. Each step in a workflow is a function.

Listing 23. Workflow definition (file: ./workflows/post_blog_job.ts)
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 Socials",
  input_parameters: { properties: {}, required: [] },
});

PostBlogJobWorkflow.addStep(PostBlogFunctionDefinition, {});

export default PostBlogJobWorkflow;

This gives everything that is required, and I can offer this to the Slack platform with the following manifest:

Listing 24. Slack bot manifest (file: ./manifest.ts)
import { Manifest } from "deno-slack-sdk/mod.ts";
import { MessageDatastore } from "./datastores/messages.ts";
import { postBlogJobTrigger } from "./triggers/post_blog_job.ts";
import { postBlogManualTrigger } from "./triggers/post_blog_manual.ts";
import { PostBlogJobWorkflow } from "./workflows/post_blog_job.ts";

export default Manifest({
  name: "jdriven-blog-poster",
  description: "An automatic bot that posts content to socials",
  icon: "assets/jdriven_logo.png",
  workflows: [PostBlogJobWorkflow],
  triggers: [postBlogJobTrigger, postBlogManualTrigger],
  outgoingDomains: [
    "api.twitter.com",
    "raw.githubusercontent.com",
    "jdriven.com",
    "mastodon.social",
    "bsky.social"
  ],
  datastores: [MessageDatastore],
  botScopes: [
    "commands",
    "chat:write",
    "chat:write.public",
    "datastore:read",
    "datastore:write",
    "channels:read",
    "triggers:write",
    "triggers:read",
    "app_mentions:read",
  ],
});

As I have used a set of environment variables, I have to push them to the production platform of Slack separately. To list all existing environment variables, you can use:

slack env list

Pushing all required variables (as I have them locally defined in my .env-file):

slack env add FEED_URL "https://jdriven.com/blog/atom.xml"
slack env add CHANNEL_ID "<id of channel where the bot will interact with, e.g. C********>"
slack env add CONSUMER_KEY "****AbcD01"
slack env add CONSUMER_SECRET "****EfgH23"
slack env add ACCESS_TOKEN "****IjkL45"
slack env add ACCESS_SECRET "****MnoP67"
slack env add MASTODON_INSTANCE_URL "<Mastodon server instance, e.g. https://mastodon.social>"
slack env add MASTODON_ACCESS_TOKEN "****QrsT89"
slack env add BLUESKY_IDENTIFIER "<Bluesky identifier without the @-sign, e.g. jdriven.com>"
slack env add BLUESKY_APP_PASSWORD "****-uvwx"

Now I can finally deploy our Slack app:

slack deploy

I could just rest at ease, waiting until a new blog was posted, and follow activity in our channel. Luckily, Hubert gave me a heads-up. Drumroll…​ Yes. It worked.

Screenshot of our Slack bot

Conclusion

This project turned out to be much more than just a "Slack integration"; It was a rewarding TypeScript playground that touched on a large spectrum of backend development. By refactoring the bot to be state-aware and platform "agnostic", I’ve turned a simple script into a social syndication tool:

  • Parsing and extending raw Atom XML feeds with a custom XML namespace, to extract structured metadata and tag authors correctly on each platform

  • Authentication diversity, from the legacy complexity of OAuth 1.0a (X) to standard Bearer tokens (Mastodon) and modern session-based flows (Bluesky/AT Protocol)

  • Handling platform specific user-handles, posting-requirements, limits and responses

  • Idempotent persistence using a non-standard, serverless datastore: I only retry failed posts

  • Pushing real-time status updates back to the user via chat

Now, if you’ll excuse me, I have a blog post to publish — and I’m pretty sure I won’t have to tweet or toot or tell about it manually on social platforms.

Follow us!

@jdriven_nl on X (Twitter)
@jdriven on Mastodon
@jdriven.com on Bluesky
shadow-left