generated from nhcarrigan/template
feat: post to mastodon
This commit is contained in:
+5
-1
@@ -18,4 +18,8 @@ SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook"
|
||||
FACEBOOK_PAGE_TOKEN="op://Environment Variables - Naomi/Hikari/facebook page token"
|
||||
FACEBOOK_APP_ID="op://Environment Variables - Naomi/Hikari/facebook app id"
|
||||
FACEBOOK_APP_SECRET="op://Environment Variables - Naomi/Hikari/facebook app secret"
|
||||
FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id"
|
||||
FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id"
|
||||
LINKEDIN_CLIENT_ID="op://Environment Variables - Naomi/Hikari/linkedin client id"
|
||||
LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret"
|
||||
MASTODON_INSTANCE_URL="op://Environment Variables - Naomi/Hikari/mastodon url"
|
||||
MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token"
|
||||
+3
-1
@@ -20,4 +20,6 @@ FACEBOOK_APP_ID="op://Environment Variables - Naomi/Hikari/facebook app id"
|
||||
FACEBOOK_APP_SECRET="op://Environment Variables - Naomi/Hikari/facebook app secret"
|
||||
FACEBOOK_PAGE_ID="op://Environment Variables - Naomi/Hikari/facebook page id"
|
||||
LINKEDIN_CLIENT_ID="op://Environment Variables - Naomi/Hikari/linkedin client id"
|
||||
LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret"
|
||||
LINKEDIN_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/linkedin client secret"
|
||||
MASTODON_INSTANCE_URL="op://Environment Variables - Naomi/Hikari/mastodon url"
|
||||
MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token"
|
||||
@@ -39,6 +39,15 @@ Platform-specific requirements:
|
||||
- Make the first post compelling to encourage thread reading
|
||||
- Do NOT include post numbers or thread indicators
|
||||
|
||||
**Mastodon:**
|
||||
- Break content into a thread of individual posts
|
||||
- Each post should be under 500 characters (Mastodon's limit)
|
||||
- Posts should flow naturally from one to the next
|
||||
- Use relevant hashtags (2-3 per post)
|
||||
- Make the first post compelling to encourage thread reading
|
||||
- Do NOT include post numbers or thread indicators
|
||||
- Mastodon supports markdown, so you can use basic formatting like **bold** and *italic*
|
||||
|
||||
**Facebook:**
|
||||
- Plain text format (no markdown)
|
||||
- Professional yet friendly tone
|
||||
@@ -98,6 +107,16 @@ const announcementJsonSchema = {
|
||||
description: "Plain text announcement for LinkedIn with professional hashtags. Should maintain Hikari's personality while being slightly more formal. Focus on value proposition and impact. Include calls to action for donating and joining Discord.",
|
||||
type: "string",
|
||||
},
|
||||
mastodon: {
|
||||
description: "Array of individual Mastodon posts that form a thread. Each post should be under 500 characters and flow naturally from one to the next. Mastodon supports markdown formatting.",
|
||||
items: {
|
||||
description: "A single Mastodon post in the thread (max 500 characters, no post numbers or thread indicators)",
|
||||
maxLength: 500,
|
||||
type: "string",
|
||||
},
|
||||
minItems: 1,
|
||||
type: "array",
|
||||
},
|
||||
reddit: {
|
||||
additionalProperties: false,
|
||||
description: "Reddit announcement with title and markdown-formatted content",
|
||||
@@ -131,6 +150,7 @@ const announcementJsonSchema = {
|
||||
"discord",
|
||||
"facebook",
|
||||
"linkedin",
|
||||
"mastodon",
|
||||
"reddit",
|
||||
"twitter",
|
||||
],
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AnnouncementResponse {
|
||||
};
|
||||
facebook: string;
|
||||
linkedin: string;
|
||||
mastodon: Array<string>;
|
||||
reddit: {
|
||||
content: string;
|
||||
title: string;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { isValidString } from "../utils/typeguards.js";
|
||||
|
||||
/**
|
||||
* Forwards an announcement to our Mastodon account.
|
||||
* @param content - The main body of the announcement.
|
||||
* @returns A message indicating the success or failure of the operation.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function, max-statements, complexity -- This is a big function.
|
||||
export const announceOnMastodon = async(
|
||||
content: Array<string>,
|
||||
): Promise<string> => {
|
||||
if (
|
||||
process.env.MASTODON_INSTANCE_URL === undefined
|
||||
|| process.env.MASTODON_ACCESS_TOKEN === undefined
|
||||
) {
|
||||
return "Mastodon credentials are not set.";
|
||||
}
|
||||
const [ firstPost, ...restOfPosts ] = content;
|
||||
const failedReplies: Array<string> = [];
|
||||
if (firstPost === undefined) {
|
||||
return "No posts to send to Mastodon.";
|
||||
}
|
||||
const instanceUrl = process.env.MASTODON_INSTANCE_URL.replace(/\/$/, "");
|
||||
const accessToken = process.env.MASTODON_ACCESS_TOKEN;
|
||||
const apiUrl = `${instanceUrl}/api/v1/statuses`;
|
||||
const headers = {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name.
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
const firstPostResponse = await fetch(apiUrl, {
|
||||
body: JSON.stringify({ status: firstPost }),
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
}).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof firstPostResponse === "string") {
|
||||
return `Failed to send initial post to Mastodon. ${firstPostResponse}`;
|
||||
}
|
||||
if (!firstPostResponse.ok) {
|
||||
const errorText = await firstPostResponse.text().catch(() => {
|
||||
return firstPostResponse.statusText;
|
||||
});
|
||||
return `Failed to send initial post to Mastodon. Status: ${firstPostResponse.status.toString()} ${errorText}`;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generics.
|
||||
const firstPostData = await firstPostResponse.json() as { id?: string };
|
||||
if (firstPostData.id === undefined) {
|
||||
return `Failed to parse initial post ID from Mastodon. ${JSON.stringify(firstPostData)}`;
|
||||
}
|
||||
let inReplyToId = firstPostData.id;
|
||||
for (const post of restOfPosts) {
|
||||
// eslint-disable-next-line no-await-in-loop -- We need to do this sequentially.
|
||||
const replyResponse = await fetch(apiUrl, {
|
||||
body: JSON.stringify({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement.
|
||||
in_reply_to_id: inReplyToId,
|
||||
status: post,
|
||||
}),
|
||||
headers: headers,
|
||||
method: "POST",
|
||||
}).catch((error: unknown) => {
|
||||
return error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
});
|
||||
if (typeof replyResponse === "string") {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
if (!replyResponse.ok) {
|
||||
failedReplies.push(post);
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-await-in-loop -- Fetch does not accept generics.
|
||||
const replyData = await replyResponse.json() as { id?: string };
|
||||
if (isValidString(replyData.id)) {
|
||||
inReplyToId = replyData.id;
|
||||
continue;
|
||||
}
|
||||
failedReplies.push(post);
|
||||
}
|
||||
return `Successfully sent initial post to Mastodon. ${failedReplies.length > 0
|
||||
? `Failed to send ${failedReplies.length.toString()} replies: ${failedReplies.join(", ")}`
|
||||
: `All ${(content.length - 1).toString()} replies were sent successfully.`}`;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { database } from "../db/database.js";
|
||||
import { announceOnBluesky } from "../modules/announceOnBluesky.js";
|
||||
import { announceOnDiscord } from "../modules/announceOnDiscord.js";
|
||||
import { announceOnFacebook } from "../modules/announceOnFacebook.js";
|
||||
import { announceOnMastodon } from "../modules/announceOnMastodon.js";
|
||||
import { announceOnReddit } from "../modules/announceOnReddit.js";
|
||||
import { announceOnTwitter } from "../modules/announceOnTwitter.js";
|
||||
import { generateAnnouncements } from "../modules/generateAnnouncements.js";
|
||||
@@ -91,6 +92,7 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => {
|
||||
bluesky,
|
||||
discord,
|
||||
facebook,
|
||||
mastodon,
|
||||
reddit,
|
||||
twitter,
|
||||
} = announcement.response;
|
||||
@@ -118,9 +120,10 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => {
|
||||
const blueskyPost = await announceOnBluesky(bluesky);
|
||||
const twitterPost = await announceOnTwitter(twitter);
|
||||
const facebookPost = await announceOnFacebook(facebook);
|
||||
const mastodonPost = await announceOnMastodon(mastodon);
|
||||
return await reply.status(201).send({
|
||||
cost: announcement.cost,
|
||||
message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}`,
|
||||
message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}, Mastodon: ${mastodonPost}`,
|
||||
rawPost: announcement.response,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user