Posting our blog feed to social networks using Slack
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:
<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.
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.
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:
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.
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:
-
a method
getPublicUrlthat will construct the public url to the post on that platform, so I can report that back to our dedicated Slack channel. -
a method
getErrorDetailthat 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.
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:
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}`;
}
}
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;
}
}
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.
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:
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.
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:
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:
-
Login: Send identifier and app-password to
com.atproto.server.createSession. -
Get JWT: Receive an Access JWT and the user’s DID (Decentralized Identifier).
-
Create Record: Use the JWT to post to the
app.bsky.feed.postcollection.
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.
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:
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.
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:
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:
-
If an item is too old or not published yet, or if an item is already posted on all social platforms, skip it
-
If needed, post item to X, Mastodon and/or BlueSky
-
Report result to our dedicated Slack channel
-
Store result in our datastore
Below is the implementation for this:
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.
// 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?
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.
// 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.
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.
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:
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.
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!


