diff --git a/server/dev.env b/server/dev.env index 7ff4411..d3efc65 100644 --- a/server/dev.env +++ b/server/dev.env @@ -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" \ No newline at end of file +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" \ No newline at end of file diff --git a/server/prod.env b/server/prod.env index 4e46978..d3efc65 100644 --- a/server/prod.env +++ b/server/prod.env @@ -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" \ No newline at end of file +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" \ No newline at end of file diff --git a/server/src/config/announcements.ts b/server/src/config/announcements.ts index d9b501f..9ecad51 100644 --- a/server/src/config/announcements.ts +++ b/server/src/config/announcements.ts @@ -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", ], diff --git a/server/src/interfaces/announcementResponse.ts b/server/src/interfaces/announcementResponse.ts index c0d1738..0dcdb02 100644 --- a/server/src/interfaces/announcementResponse.ts +++ b/server/src/interfaces/announcementResponse.ts @@ -16,6 +16,7 @@ export interface AnnouncementResponse { }; facebook: string; linkedin: string; + mastodon: Array; reddit: { content: string; title: string; diff --git a/server/src/modules/announceOnMastodon.ts b/server/src/modules/announceOnMastodon.ts new file mode 100644 index 0000000..c5db593 --- /dev/null +++ b/server/src/modules/announceOnMastodon.ts @@ -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, +): Promise => { + 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 = []; + 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.`}`; +}; diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 81febf5..41ce4e7 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -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, }); },