/** * @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.`}`; };