From 0cef2f3429f61dc68b4c007bddfc483a6bc01984 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 15:12:27 -0800 Subject: [PATCH] feat: add facebook posting --- server/dev.env | 17 +- server/facebookAuth.js | 514 +++++++++++++++++++++++ server/package.json | 3 +- server/prod.env | 6 +- server/src/modules/announceOnFacebook.ts | 81 ++++ server/src/routes/announcement.ts | 12 +- 6 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 server/facebookAuth.js create mode 100644 server/src/modules/announceOnFacebook.ts diff --git a/server/dev.env b/server/dev.env index 11e08c7..7ff4411 100644 --- a/server/dev.env +++ b/server/dev.env @@ -3,4 +3,19 @@ MONGO_URI="op://Environment Variables - Naomi/Hikari/mongo_uri" DISCORD_TOKEN="op://Environment Variables - Naomi/Hikari/discord_token" FORUM_API_KEY="op://Environment Variables - Naomi/Hikari/discourse_key" ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token" -ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key" \ No newline at end of file +REDDIT_CLIENT_ID="op://Environment Variables - Naomi/Hikari/reddit_client_id" +REDDIT_CLIENT_SECRET="op://Environment Variables - Naomi/Hikari/reddit_client_secret" +REDDIT_PASSWORD="op://Environment Variables - Naomi/Hikari/reddit_password" +REDDIT_USERNAME="op://Environment Variables - Naomi/Hikari/reddit_username" +BSKY_APP_PASSWORD="op://Environment Variables - Naomi/Hikari/bsky_password" +ANTHROPIC_KEY="op://Environment Variables - Naomi/Hikari/anthropic_key" +TWITTER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_access_token" +TWITTER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_access_secret" +TWITTER_CONSUMER_KEY="op://Environment Variables - Naomi/Hikari/twitter_consumer_key" +TWITTER_CONSUMER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_consumer_secret" +TWITTER_BEARER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_bearer_token" +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 diff --git a/server/facebookAuth.js b/server/facebookAuth.js new file mode 100644 index 0000000..1a55ea0 --- /dev/null +++ b/server/facebookAuth.js @@ -0,0 +1,514 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + * + * Simple local server to authenticate with Facebook and obtain a Page Access Token. + * Run with: node facebookAuth.js + * Make sure to set FACEBOOK_APP_ID and FACEBOOK_APP_SECRET environment variables. + */ + +import http from "http"; +import { URL } from "url"; + +const PORT = 3000; +const REDIRECT_URI = `http://localhost:${PORT}/callback`; + +/** + * Creates the Facebook OAuth authorization URL. + * @param {string} appId - The Facebook App ID. + * @returns {string} The authorization URL. + */ +const getAuthUrl = (appId) => { + const params = new URLSearchParams({ + client_id: appId, + redirect_uri: REDIRECT_URI, + scope: "pages_manage_posts,pages_show_list", + response_type: "code", + }); + return `https://www.facebook.com/v21.0/dialog/oauth?${params.toString()}`; +}; + +/** + * Exchanges an authorization code for an access token. + * @param {string} code - The authorization code from Facebook. + * @param {string} appId - The Facebook App ID. + * @param {string} appSecret - The Facebook App Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The access token response. + */ +const exchangeCodeForToken = async (code, appId, appSecret) => { + const params = new URLSearchParams({ + client_id: appId, + client_secret: appSecret, + redirect_uri: REDIRECT_URI, + code: code, + }); + + const response = await fetch( + `https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`, + ); + return await response.json(); +}; + +/** + * Exchanges a short-lived token for a long-lived token. + * @param {string} shortLivedToken - The short-lived access token. + * @param {string} appId - The Facebook App ID. + * @param {string} appSecret - The Facebook App Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The long-lived token response. + */ +const exchangeForLongLivedToken = async (shortLivedToken, appId, appSecret) => { + const params = new URLSearchParams({ + grant_type: "fb_exchange_token", + client_id: appId, + client_secret: appSecret, + fb_exchange_token: shortLivedToken, + }); + + const response = await fetch( + `https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`, + ); + return await response.json(); +}; + +/** + * Gets the user's pages. + * @param {string} accessToken - The user access token. + * @returns {Promise} Array of pages the user manages. + */ +const getUserPages = async (accessToken) => { + const response = await fetch( + `https://graph.facebook.com/v21.0/me/accounts?access_token=${accessToken}`, + ); + const data = await response.json(); + return data.data || []; +}; + +/** + * Gets a Page Access Token for a specific page. + * @param {string} pageId - The page ID. + * @param {string} userAccessToken - The user access token. + * @returns {Promise} The Page Access Token. + */ +const getPageAccessToken = async (pageId, userAccessToken) => { + const response = await fetch( + `https://graph.facebook.com/v21.0/${pageId}?fields=access_token&access_token=${userAccessToken}`, + ); + const data = await response.json(); + return data.access_token; +}; + +/** + * Exchanges a short-lived Page Access Token for a long-lived one. + * @param {string} pageAccessToken - The short-lived Page Access Token. + * @param {string} appId - The Facebook App ID. + * @param {string} appSecret - The Facebook App Secret. + * @returns {Promise<{access_token: string, expires_in?: number}>} The long-lived Page Access Token. + */ +const exchangePageTokenForLongLived = async ( + pageAccessToken, + appId, + appSecret, +) => { + const params = new URLSearchParams({ + grant_type: "fb_exchange_token", + client_id: appId, + client_secret: appSecret, + fb_exchange_token: pageAccessToken, + }); + + const response = await fetch( + `https://graph.facebook.com/v21.0/oauth/access_token?${params.toString()}`, + ); + return await response.json(); +}; + +/** + * Sends an HTML response. + * @param {http.ServerResponse} res - The HTTP response object. + * @param {number} statusCode - The HTTP status code. + * @param {string} html - The HTML content to send. + */ +const sendHtml = (res, statusCode, html) => { + res.writeHead(statusCode, { "Content-Type": "text/html" }); + res.end(html); +}; + +/** + * Sends a JSON response. + * @param {http.ServerResponse} res - The HTTP response object. + * @param {number} statusCode - The HTTP status code. + * @param {object} data - The JSON data to send. + */ +const sendJson = (res, statusCode, data) => { + res.writeHead(statusCode, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data, null, 2)); +}; + +const appId = process.env.FACEBOOK_APP_ID; +const appSecret = process.env.FACEBOOK_APP_SECRET; + +if (!appId || !appSecret) { + console.error( + "Error: FACEBOOK_APP_ID and FACEBOOK_APP_SECRET environment variables must be set.", + ); + console.error( + "Example: FACEBOOK_APP_ID=your_app_id FACEBOOK_APP_SECRET=your_secret node facebookAuth.js", + ); + process.exit(1); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + // Root route - show auth link + if (url.pathname === "/") { + const authUrl = getAuthUrl(appId); + const html = ` + + + + Facebook Page Token Generator + + + +
+

šŸ” Facebook Page Token Generator

+

Click the button below to authenticate with Facebook and get your Page Access Token.

+ Authenticate with Facebook +
+ Note: Make sure you're an admin of the Facebook Page you want to post to. +
+
+ + + `; + return sendHtml(res, 200, html); + } + + // Callback route - handle OAuth callback + if (url.pathname === "/callback") { + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + + if (error) { + const html = ` + + + + Authentication Error + + + +
+

āŒ Authentication Error

+
+

Error: ${error}

+

${url.searchParams.get("error_description") || ""}

+
+

Try again

+
+ + + `; + return sendHtml(res, 400, html); + } + + if (!code) { + return sendHtml( + res, + 400, + "

Error

No authorization code received.

Try again", + ); + } + + try { + // Step 1: Exchange code for short-lived user token + const tokenResponse = await exchangeCodeForToken(code, appId, appSecret); + + if (tokenResponse.error) { + throw new Error( + tokenResponse.error.message || "Failed to exchange code for token", + ); + } + + const shortLivedUserToken = tokenResponse.access_token; + + // Step 2: Exchange for long-lived user token + const longLivedUserTokenResponse = await exchangeForLongLivedToken( + shortLivedUserToken, + appId, + appSecret, + ); + + if (longLivedUserTokenResponse.error) { + throw new Error( + longLivedUserTokenResponse.error.message || + "Failed to exchange for long-lived token", + ); + } + + const longLivedUserToken = longLivedUserTokenResponse.access_token; + + // Step 3: Get user's pages + const pages = await getUserPages(longLivedUserToken); + + if (pages.length === 0) { + return sendHtml( + res, + 200, + ` + + + + No Pages Found + + + +
+

āš ļø No Pages Found

+

You don't have access to any Facebook Pages, or you're not an admin of any pages.

+

Try again

+
+ + + `, + ); + } + + // Step 4: Get Page Access Tokens and exchange for long-lived + const pageTokens = []; + for (const page of pages) { + const pageAccessToken = await getPageAccessToken( + page.id, + longLivedUserToken, + ); + const longLivedPageTokenResponse = await exchangePageTokenForLongLived( + pageAccessToken, + appId, + appSecret, + ); + + if (!longLivedPageTokenResponse.error) { + pageTokens.push({ + pageId: page.id, + pageName: page.name, + accessToken: longLivedPageTokenResponse.access_token, + expiresIn: longLivedPageTokenResponse.expires_in, + }); + } + } + + // Display results + const pagesHtml = pageTokens + .map( + (pt) => ` +
+

${pt.pageName}

+

Page ID: ${pt.pageId}

+

Access Token:

+ +

Expires in: ${pt.expiresIn ? `${Math.floor(pt.expiresIn / 86400)} days` : "Never (as long as admin access is maintained)"}

+
+ `, + ) + .join(""); + + const html = ` + + + + Success! Your Page Tokens + + + +
+

āœ… Success!

+
+

Your Page Access Tokens:

+

Copy these tokens and add them to your environment variables. Use the Page Access Token for the page you want to post to.

+
+ ${pagesHtml} +
+

āš ļø Important:

+
    +
  • Store these tokens securely (like your other API credentials)
  • +
  • Page Access Tokens don't expire as long as you remain an admin
  • +
  • Add the token to your environment variables as FACEBOOK_PAGE_ACCESS_TOKEN
  • +
  • You'll also need the Page ID as FACEBOOK_PAGE_ID
  • +
+
+

Start over

+
+ + + `; + + return sendHtml(res, 200, html); + } catch (error) { + const html = ` + + + + Error + + + +
+

āŒ Error

+
+

Error: ${error.message}

+
+

Try again

+
+ + + `; + return sendHtml(res, 500, html); + } + } + + // 404 + sendHtml(res, 404, "

Not Found

Go home

"); +}); + +server.listen(PORT, () => { + console.log(`\nšŸš€ Facebook Auth Server running at http://localhost:${PORT}`); + console.log(`\nšŸ“‹ Make sure you've set:`); + console.log(` - FACEBOOK_APP_ID`); + console.log(` - FACEBOOK_APP_SECRET`); + console.log(`\nšŸ”— Open http://localhost:${PORT} in your browser to start!\n`); +}); + diff --git a/server/package.json b/server/package.json index ff0ada0..71eb77a 100644 --- a/server/package.json +++ b/server/package.json @@ -9,7 +9,8 @@ "dev": "NODE_ENV=dev op run --env-file=./dev.env -- tsx watch ./src/index.ts", "build": "tsx ./getDocs.ts && tsc", "start": "op run --env-file=./prod.env -- node ./prod/index.js", - "test": "echo 'No tests yet' && exit 0" + "test": "echo 'No tests yet' && exit 0", + "facebookAuth": "op run --env-file=./prod.env -- node facebookAuth.js" }, "keywords": [], "author": "", diff --git a/server/prod.env b/server/prod.env index be724e5..7ff4411 100644 --- a/server/prod.env +++ b/server/prod.env @@ -14,4 +14,8 @@ TWITTER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_access_secret" TWITTER_CONSUMER_KEY="op://Environment Variables - Naomi/Hikari/twitter_consumer_key" TWITTER_CONSUMER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_consumer_secret" TWITTER_BEARER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_bearer_token" -SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook" \ No newline at end of file +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 diff --git a/server/src/modules/announceOnFacebook.ts b/server/src/modules/announceOnFacebook.ts new file mode 100644 index 0000000..5b79543 --- /dev/null +++ b/server/src/modules/announceOnFacebook.ts @@ -0,0 +1,81 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +interface FacebookErrorResponse { + error: { + code: number; + message: string; + type: string; + }; +} + +interface FacebookSuccessResponse { + id: string; +} + +type FacebookResponse = FacebookErrorResponse | FacebookSuccessResponse; + +/** + * Forwards an announcement to our Facebook Page. + * @param content - The main body of the announcement. + * @returns A message indicating the success or failure of the operation. + */ +export const announceOnFacebook = async(content: string): Promise => { + if ( + process.env.FACEBOOK_PAGE_TOKEN === undefined + || process.env.FACEBOOK_PAGE_ID === undefined + ) { + return "Facebook credentials are not set."; + } + + if (content.trim().length === 0) { + return "No content to send to Facebook."; + } + + const pageId = process.env.FACEBOOK_PAGE_ID; + const accessToken = process.env.FACEBOOK_PAGE_TOKEN; + + try { + const response = await fetch( + `https://graph.facebook.com/v21.0/${pageId}/feed`, + { + body: new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Facebook API requires snake_case. + access_token: accessToken, + message: content, + }), + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }, + ); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generic. + const result = (await response.json()) as FacebookResponse; + + if ("error" in result) { + const errorMessage = result.error.message === "" + ? JSON.stringify(result.error) + : result.error.message; + return `Failed to send message to Facebook. ${errorMessage}`; + } + + if ("id" in result) { + return `Successfully sent post to Facebook. Post ID: ${result.id}`; + } + + return `Failed to send message to Facebook. Unexpected response: ${JSON.stringify(result)}`; + } catch (error: unknown) { + return `Failed to send message to Facebook. ${ + error instanceof Error + ? error.message + : String(error) + }`; + } +}; + diff --git a/server/src/routes/announcement.ts b/server/src/routes/announcement.ts index 5a4fd0f..81febf5 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -8,6 +8,7 @@ import { blockedIps } from "../cache/blockedIps.js"; 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 { announceOnReddit } from "../modules/announceOnReddit.js"; import { announceOnTwitter } from "../modules/announceOnTwitter.js"; import { generateAnnouncements } from "../modules/generateAnnouncements.js"; @@ -86,7 +87,13 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { }); } - const { bluesky, discord, reddit, twitter } = announcement.response; + const { + bluesky, + discord, + facebook, + reddit, + twitter, + } = announcement.response; const { title: discordTitle, content: discordContent } = discord; const { title: redditTitle, content: redditContent } = reddit; @@ -110,9 +117,10 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { ); const blueskyPost = await announceOnBluesky(bluesky); const twitterPost = await announceOnTwitter(twitter); + const facebookPost = await announceOnFacebook(facebook); return await reply.status(201).send({ cost: announcement.cost, - message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}`, + message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}`, rawPost: announcement.response, }); },