From 0dba2483701f7630490aed758fe4221afef7a8e9 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 23 Dec 2025 19:02:08 -0800 Subject: [PATCH] feat: add announcements on threads --- server/dev.env | 5 +- server/package.json | 3 +- server/prod.env | 5 +- server/src/config/announcements.ts | 20 + server/src/interfaces/announcementResponse.ts | 1 + server/src/modules/announceOnThreads.ts | 190 ++++++ server/src/routes/announcement.ts | 5 +- server/threadsAuth.js | 604 ++++++++++++++++++ 8 files changed, 829 insertions(+), 4 deletions(-) create mode 100644 server/src/modules/announceOnThreads.ts create mode 100644 server/threadsAuth.js diff --git a/server/dev.env b/server/dev.env index d3efc65..6c13cd4 100644 --- a/server/dev.env +++ b/server/dev.env @@ -22,4 +22,7 @@ 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 +MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token" +THREADS_APP_ID="op://Environment Variables - Naomi/Hikari/threads app id" +THREADS_APP_SECRET="op://Environment Variables - Naomi/Hikari/threads app secret" +THREADS_ACCESS_TOKEN= \ No newline at end of file diff --git a/server/package.json b/server/package.json index 0910e2b..a7424de 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "start": "op run --env-file=./prod.env -- node ./prod/index.js", "test": "echo 'No tests yet' && exit 0", "facebookAuth": "op run --env-file=./prod.env -- node facebookAuth.js", - "linkedinAuth": "op run --env-file=./prod.env -- node linkedinAuth.js" + "linkedinAuth": "op run --env-file=./prod.env -- node linkedinAuth.js", + "threadsAuth": "op run --env-file=./prod.env -- node threadsAuth.js" }, "keywords": [], "author": "", diff --git a/server/prod.env b/server/prod.env index d3efc65..6c13cd4 100644 --- a/server/prod.env +++ b/server/prod.env @@ -22,4 +22,7 @@ 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 +MASTODON_ACCESS_TOKEN="op://Environment Variables - Naomi/Hikari/mastodon token" +THREADS_APP_ID="op://Environment Variables - Naomi/Hikari/threads app id" +THREADS_APP_SECRET="op://Environment Variables - Naomi/Hikari/threads app secret" +THREADS_ACCESS_TOKEN= \ No newline at end of file diff --git a/server/src/config/announcements.ts b/server/src/config/announcements.ts index 9ecad51..5062239 100644 --- a/server/src/config/announcements.ts +++ b/server/src/config/announcements.ts @@ -48,6 +48,15 @@ Platform-specific requirements: - Do NOT include post numbers or thread indicators - Mastodon supports markdown, so you can use basic formatting like **bold** and *italic* +**Threads:** +- Break content into a thread of individual posts +- Each post should be under 500 characters (Threads' 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 +- Plain text format (no markdown) + **Facebook:** - Plain text format (no markdown) - Professional yet friendly tone @@ -134,6 +143,16 @@ const announcementJsonSchema = { required: [ "content", "title" ], type: "object", }, + threads: { + description: "Array of individual Threads posts that form a thread. Each post should be under 500 characters and flow naturally from one to the next.", + items: { + description: "A single Threads post in the thread (max 500 characters, no post numbers or thread indicators)", + maxLength: 500, + type: "string", + }, + minItems: 1, + type: "array", + }, twitter: { description: "Array of individual Twitter posts that form a thread. Each post should be under 280 characters and flow naturally from one to the next.", items: { @@ -152,6 +171,7 @@ const announcementJsonSchema = { "linkedin", "mastodon", "reddit", + "threads", "twitter", ], type: "object", diff --git a/server/src/interfaces/announcementResponse.ts b/server/src/interfaces/announcementResponse.ts index 0dcdb02..8ddbd8c 100644 --- a/server/src/interfaces/announcementResponse.ts +++ b/server/src/interfaces/announcementResponse.ts @@ -21,5 +21,6 @@ export interface AnnouncementResponse { content: string; title: string; }; + threads: Array; twitter: Array; } diff --git a/server/src/modules/announceOnThreads.ts b/server/src/modules/announceOnThreads.ts new file mode 100644 index 0000000..7a328a9 --- /dev/null +++ b/server/src/modules/announceOnThreads.ts @@ -0,0 +1,190 @@ + +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { isValidString } from "../utils/typeguards.js"; + +interface ThreadsErrorResponse { + error: { + message: string; + type: string; + code: number; + }; +} + +interface ThreadsSuccessResponse { + id: string; +} + +type ThreadsResponse = ThreadsErrorResponse | ThreadsSuccessResponse; + +/** + * Forwards an announcement to our Threads 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 announceOnThreads = async( + content: Array, +): Promise => { + if ( + process.env.THREADS_ACCESS_TOKEN === undefined + ) { + return "Threads credentials are not set."; + } + const [ firstPost, ...restOfPosts ] = content; + const failedReplies: Array = []; + if (firstPost === undefined) { + return "No posts to send to Threads."; + } + const accessToken = process.env.THREADS_ACCESS_TOKEN; + const apiUrl = `https://graph.threads.net/v1.0/me/threads`; + // Step 1: Create the first post + const firstPostParameters = new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + access_token: accessToken, + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + media_type: "TEXT", + text: firstPost, + }); + const firstPostResponse = await fetch( + `${apiUrl}?${firstPostParameters.toString()}`, + { + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }, + ).catch((error: unknown) => { + return error instanceof Error + ? error.message + : String(error); + }); + if (typeof firstPostResponse === "string") { + return `Failed to send initial post to Threads. ${firstPostResponse}`; + } + if (!firstPostResponse.ok) { + const errorText = await firstPostResponse.text().catch(() => { + return firstPostResponse.statusText; + }); + return `Failed to send initial post to Threads. 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 ThreadsResponse; + if ("error" in firstPostData) { + return `Failed to send initial post to Threads. ${firstPostData.error.message}`; + } + if (!isValidString(firstPostData.id)) { + return `Failed to parse initial post ID from Threads. ${JSON.stringify(firstPostData)}`; + } + // Step 2: Publish the first post + const publishUrl = `https://graph.threads.net/v1.0/me/threads_publish`; + const publishParameters = new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + access_token: accessToken, + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + creation_id: firstPostData.id, + }); + const publishResponse = await fetch( + `${publishUrl}?${publishParameters.toString()}`, + { + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "POST", + }, + ).catch((error: unknown) => { + return error instanceof Error + ? error.message + : String(error); + }); + if (typeof publishResponse === "string") { + return `Failed to publish initial post to Threads. ${publishResponse}`; + } + if (!publishResponse.ok) { + const errorText = await publishResponse.text().catch(() => { + return publishResponse.statusText; + }); + return `Failed to publish initial post to Threads. Status: ${publishResponse.status.toString()} ${errorText}`; + } + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Fetch does not accept generics. + const publishData = await publishResponse.json() as ThreadsSuccessResponse; + let parentThreadId = publishData.id; + // Step 3: Create replies for the rest of the posts + for (const post of restOfPosts) { + const replyParameters = new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + access_token: accessToken, + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + media_type: "TEXT", + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + reply_to_id: parentThreadId, + text: post, + }); + // eslint-disable-next-line no-await-in-loop -- We need to do this sequentially. + const replyResponse = await fetch( + `${apiUrl}?${replyParameters.toString()}`, + { + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name. + "Content-Type": "application/x-www-form-urlencoded", + }, + }, + ).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 ThreadsResponse; + if ("error" in replyData) { + failedReplies.push(post); + continue; + } + if (!isValidString(replyData.id)) { + failedReplies.push(post); + continue; + } + // Publish the reply + const replyPublishUrl = `https://graph.threads.net/v1.0/me/threads_publish`; + const replyPublishParameters = new URLSearchParams({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + access_token: accessToken, + // eslint-disable-next-line @typescript-eslint/naming-convention -- API requirement. + creation_id: replyData.id, + }); + // eslint-disable-next-line no-await-in-loop -- We need to do this sequentially. + const replyPublishResponse = await fetch( + `${replyPublishUrl}?${replyPublishParameters.toString()}`, + { + method: "POST", + }, + ).catch(() => { + return null; + }); + if (replyPublishResponse?.ok !== true) { + failedReplies.push(post); + continue; + } + const replyPublishData + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, no-await-in-loop -- Fetch does not accept generics. + = await replyPublishResponse.json() as ThreadsSuccessResponse; + parentThreadId = replyPublishData.id; + } + return `Successfully sent initial post to Threads. ${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 41ce4e7..5dee9b6 100644 --- a/server/src/routes/announcement.ts +++ b/server/src/routes/announcement.ts @@ -11,6 +11,7 @@ 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 { announceOnThreads } from "../modules/announceOnThreads.js"; import { announceOnTwitter } from "../modules/announceOnTwitter.js"; import { generateAnnouncements } from "../modules/generateAnnouncements.js"; import { getIpFromRequest } from "../modules/getIpFromRequest.js"; @@ -94,6 +95,7 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { facebook, mastodon, reddit, + threads, twitter, } = announcement.response; const { title: discordTitle, content: discordContent } = discord; @@ -121,9 +123,10 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => { const twitterPost = await announceOnTwitter(twitter); const facebookPost = await announceOnFacebook(facebook); const mastodonPost = await announceOnMastodon(mastodon); + const threadsPost = await announceOnThreads(threads); return await reply.status(201).send({ cost: announcement.cost, - message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}, Mastodon: ${mastodonPost}`, + message: `Announcement processed. Discord: ${discordPost}, Reddit: ${redditPost}, Bluesky: ${blueskyPost}, Twitter: ${twitterPost}, Facebook: ${facebookPost}, Mastodon: ${mastodonPost}, Threads: ${threadsPost}`, rawPost: announcement.response, }); }, diff --git a/server/threadsAuth.js b/server/threadsAuth.js new file mode 100644 index 0000000..660b6b1 --- /dev/null +++ b/server/threadsAuth.js @@ -0,0 +1,604 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + * + * Simple local server to authenticate with Threads (via Meta/Facebook) and obtain an Access Token. + * Run with: node threadsAuth.js + * Make sure to set THREADS_APP_ID and THREADS_APP_SECRET environment variables. + * + * Note: You need an Instagram Business Account linked to your Threads profile. + * The OAuth flow goes through Facebook's endpoints (Meta's unified platform) but uses + * Threads-specific app credentials. + */ + +import http from "http"; +import { URL } from "url"; + +const PORT = 3001; // Different port from Facebook auth +// Threads API requires HTTPS for OAuth redirects +// For local development, use ngrok: ngrok http 3001 +// Then set THREADS_REDIRECT_URI to your ngrok HTTPS URL +const REDIRECT_URI =`https://local3001.nhcarrigan.com/callback`; + +/** + * Creates the Threads OAuth authorization URL. + * Threads uses its own OAuth endpoint: threads.net/oauth/authorize + * @param {string} appId - The Threads App ID. + * @returns {string} The authorization URL. + */ +const getAuthUrl = (appId) => { + const params = new URLSearchParams({ + client_id: appId, + redirect_uri: REDIRECT_URI, + scope: "threads_basic,threads_content_publish", + response_type: "code", + }); + return `https://threads.net/oauth/authorize?${params.toString()}`; +}; + +/** + * Exchanges an authorization code for an access token. + * Threads uses its own token endpoint: graph.threads.net/oauth/access_token + * @param {string} code - The authorization code from Threads. + * @param {string} appId - The Threads App ID. + * @param {string} appSecret - The Threads App Secret. + * @returns {Promise<{access_token: string, user_id?: 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, + grant_type: "authorization_code", + }); + + const response = await fetch( + `https://graph.threads.net/oauth/access_token`, + { + body: params, + method: "POST", + }, + ); + 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 Threads App ID. + * @param {string} appSecret - The Threads 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 Instagram Business Accounts. + * @param {string} accessToken - The user access token. + * @returns {Promise} Array of Instagram Business Accounts. + */ +const getInstagramAccounts = async (accessToken) => { + const response = await fetch( + `https://graph.facebook.com/v21.0/me/accounts?fields=instagram_business_account&access_token=${accessToken}`, + ); + const data = await response.json(); + const accounts = []; + + if (data.data) { + for (const page of data.data) { + if (page.instagram_business_account) { + const igAccountResponse = await fetch( + `https://graph.facebook.com/v21.0/${page.instagram_business_account.id}?fields=id,username,threads_profile&access_token=${accessToken}`, + ); + const igAccount = await igAccountResponse.json(); + if (igAccount.threads_profile) { + accounts.push({ + instagramAccountId: igAccount.id, + username: igAccount.username, + threadsProfileId: igAccount.threads_profile.id, + }); + } + } + } + } + + return accounts; +}; + +/** + * 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.THREADS_APP_ID?.trim(); +const appSecret = process.env.THREADS_APP_SECRET?.trim(); + +if (!appId || !appSecret) { + console.error( + "Error: THREADS_APP_ID and THREADS_APP_SECRET environment variables must be set.", + ); + console.error( + "Example: THREADS_APP_ID=your_app_id THREADS_APP_SECRET=your_secret node threadsAuth.js", + ); + process.exit(1); +} + +// Validate App ID format (should be numeric) +if (!/^\d+$/.test(appId)) { + console.error( + `Error: THREADS_APP_ID does not appear to be valid. Got: "${appId}"`, + ); + console.error( + "App ID should be a numeric string. Make sure you're using 'op run' to resolve 1Password references.", + ); + console.error( + "Run: pnpm threadsAuth (or: op run --env-file=./prod.env -- node threadsAuth.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 = ` + + + + Threads Token Generator + + + +
+

šŸ” Threads Token Generator

+

Click the button below to authenticate with Meta/Facebook and get your Threads Access Token.

+ Authenticate with Meta +
+ Note: You need: +
    +
  • An Instagram Business Account
  • +
  • A Threads profile linked to that Instagram account
  • +
  • Admin access to a Facebook Page connected to your Instagram Business Account
  • +
+
+
+ āš ļø Important: Your Threads app must have: +
    +
  • Threads API product added
  • +
  • threads_basic and threads_content_publish permissions approved
  • +
  • Valid OAuth Redirect URI: ${REDIRECT_URI}
  • +
+
+ ${REDIRECT_URI.startsWith("http://") ? ` +
+ šŸ”’ HTTPS Required: Threads API requires HTTPS for OAuth redirects! +
    +
  • Install cloudflared: brew install cloudflared or download from cloudflare.com
  • +
  • Run: cloudflared tunnel --url http://localhost:${PORT}
  • +
  • Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)
  • +
  • Set environment variable: THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback
  • +
  • Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs
  • +
  • Restart this server
  • +
+
+ ` : ""} +
+ + + `; + return sendHtml(res, 200, html); + } + + // Callback route - handle OAuth callback + if (url.pathname === "/callback") { + // Threads appends #_ to the redirect URI - strip it from the URL + let code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + const errorReason = url.searchParams.get("error_reason"); + const errorDescription = url.searchParams.get("error_description"); + + // Debug: Log the full callback URL to see what Threads is sending + console.log(`\nšŸ” Callback received:`); + console.log(` Full URL: ${url.href}`); + console.log(` Expected redirect URI: ${REDIRECT_URI}`); + console.log(` Error: ${error || "none"}`); + console.log(` Error reason: ${errorReason || "none"}`); + console.log(` Error description: ${errorDescription || "none"}\n`); + + // If code is in the hash (after #_), extract it + if (!code && url.hash) { + const hashParams = new URLSearchParams(url.hash.substring(1)); + code = hashParams.get("code"); + } + + if (error) { + const html = ` + + + + Authentication Error + + + +
+

āŒ Authentication Error

+
+

Error: ${error}

+

Error Reason: ${errorReason || "N/A"}

+

Error Description: ${errorDescription || "N/A"}

+

Full Callback URL: ${url.href}

+

Expected Redirect URI: ${REDIRECT_URI}

+
+

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 access token + const tokenResponse = await exchangeCodeForToken(code, appId, appSecret); + + if (tokenResponse.error_type || tokenResponse.error_message) { + throw new Error( + tokenResponse.error_message || "Failed to exchange code for token", + ); + } + + if (!tokenResponse.access_token) { + throw new Error( + "No access token received. Response: " + JSON.stringify(tokenResponse), + ); + } + + const accessToken = tokenResponse.access_token; + const userId = tokenResponse.user_id; + + // Step 2: Get Instagram Business Account ID + // The user_id from Threads token exchange is the Instagram Business Account ID + // We can also verify this by calling the Threads API + const accounts = []; + if (userId) { + // Try to get account info from Threads API + try { + const accountInfoResponse = await fetch( + `https://graph.threads.net/v1.0/${userId}?fields=id,username&access_token=${accessToken}`, + ); + if (accountInfoResponse.ok) { + const accountInfo = await accountInfoResponse.json(); + accounts.push({ + instagramAccountId: userId.toString(), + username: accountInfo.username || "unknown", + threadsProfileId: userId.toString(), // Threads Profile ID is same as Instagram Business Account ID + }); + } else { + // Fallback: use the user_id as Instagram Business Account ID + accounts.push({ + instagramAccountId: userId.toString(), + username: "unknown", + threadsProfileId: userId.toString(), + }); + } + } catch (err) { + // Fallback: use the user_id as Instagram Business Account ID + accounts.push({ + instagramAccountId: userId.toString(), + username: "unknown", + threadsProfileId: userId.toString(), + }); + } + } + + if (accounts.length === 0) { + return sendHtml( + res, + 200, + ` + + + + No Threads Accounts Found + + + +
+

āš ļø No Threads Accounts Found

+

You don't have access to any Instagram Business Accounts with Threads profiles, or your Facebook Page isn't connected to an Instagram Business Account.

+

Try again

+
+ + + `, + ); + } + + // Display results + const accountsHtml = accounts + .map( + (account) => ` +
+

@${account.username}

+

Instagram Business Account ID: ${account.instagramAccountId}

+

Threads Profile ID: ${account.threadsProfileId}

+

Access Token:

+ +

Note: Threads access tokens are short-lived. You may need to refresh them periodically.

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

āœ… Success!

+
+

Your Threads Access Tokens:

+

Copy these values and add them to your environment variables.

+
+ ${accountsHtml} +
+

āš ļø Important:

+
    +
  • Store these tokens securely (like your other API credentials)
  • +
  • Add the access token to your environment variables as THREADS_ACCESS_TOKEN
  • +
  • Add the Instagram Business Account ID as THREADS_INSTAGRAM_ACCOUNT_ID
  • +
  • Add the Threads Profile ID as THREADS_PROFILE_ID (usually same as Instagram Account ID)
  • +
  • Threads tokens are short-lived and may need to be refreshed periodically
  • +
+
+

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šŸš€ Threads Auth Server running at http://localhost:${PORT}`); + console.log(`\nšŸ“‹ Make sure you've set:`); + console.log(` - THREADS_APP_ID`); + console.log(` - THREADS_APP_SECRET`); + + if (REDIRECT_URI.startsWith("http://")) { + console.log(`\nšŸ”’ HTTPS REQUIRED: Threads API requires HTTPS for OAuth redirects!`); + console.log(`\n Current redirect URI: ${REDIRECT_URI}`); + console.log(`\n To fix:`); + console.log(` 1. Install cloudflared: brew install cloudflared`); + console.log(` 2. Run: cloudflared tunnel --url http://localhost:${PORT}`); + console.log(` 3. Copy the HTTPS URL (e.g., https://abc123.trycloudflare.com)`); + console.log(` 4. Set: THREADS_REDIRECT_URI=https://abc123.trycloudflare.com/callback`); + console.log(` 5. Add the HTTPS URL to your Threads app's Valid OAuth Redirect URIs`); + console.log(` 6. Restart this server`); + } else { + console.log(`\nāœ… Using HTTPS redirect URI: ${REDIRECT_URI}`); + } + + console.log(`\nšŸ”— Open http://localhost:${PORT} in your browser to start!`); + console.log(`\nāš ļø Make sure your Threads app has:`); + console.log(` - Threads API product added`); + console.log(` - threads_basic and threads_content_publish permissions`); + console.log(` - OAuth Redirect URI: ${REDIRECT_URI}`); + console.log(` - Client OAuth Login: ON`); + console.log(` - Web OAuth Login: ON`); + console.log(`\nšŸ’” Note: OAuth flow uses Threads-specific endpoints`); + console.log(`\nšŸ” Debug info:`); + console.log(` - Redirect URI: ${REDIRECT_URI}`); + console.log(` - URL-encoded: ${encodeURIComponent(REDIRECT_URI)}`); + console.log(` - Make sure this EXACTLY matches what's in your Threads app settings\n`); +}); +