diff --git a/deno.json b/deno.json index 97cbf21..960ed02 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@codybrom/denim", - "version": "1.3.4", + "version": "1.3.5", "description": "A Deno function for posting to Threads.", "entry": "./mod.ts", "exports": { diff --git a/deno.lock b/deno.lock index a72a06f..8475c0f 100644 --- a/deno.lock +++ b/deno.lock @@ -17,10 +17,49 @@ } } }, + "redirects": { + "https://deno.land/std/testing/asserts.ts": "https://deno.land/std@0.224.0/testing/asserts.ts" + }, "remote": { "https://deno.land/std@0.153.0/fmt/colors.ts": "ff7dc9c9f33a72bd48bc24b21bbc1b4545d8494a431f17894dbc5fe92a938fc4", "https://deno.land/std@0.153.0/testing/_diff.ts": "141f978a283defc367eeee3ff7b58aa8763cf7c8e0c585132eae614468e9d7b8", "https://deno.land/std@0.153.0/testing/_format.ts": "cd11136e1797791045e639e9f0f4640d5b4166148796cad37e6ef75f7d7f3832", - "https://deno.land/std@0.153.0/testing/asserts.ts": "d6595cfc330b4233546a047a0d7d57940771aa9d97a172ceb91e84ae6200b3af" + "https://deno.land/std@0.153.0/testing/_test_suite.ts": "2d07073d5460a4e3ec50c55ae822cd9bd136926d7363091379947fef9c73c3e4", + "https://deno.land/std@0.153.0/testing/asserts.ts": "d6595cfc330b4233546a047a0d7d57940771aa9d97a172ceb91e84ae6200b3af", + "https://deno.land/std@0.153.0/testing/bdd.ts": "35060cefd9cc21b414f4d89453b3551a3d52ec50aeff25db432503c5485b2f72", + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", + "https://deno.land/std@0.224.0/testing/asserts.ts": "d0cdbabadc49cc4247a50732ee0df1403fdcd0f95360294ad448ae8c240f3f5c" } } diff --git a/examples/edge-function.ts b/examples/edge-function.ts index 9650312..67891af 100644 --- a/examples/edge-function.ts +++ b/examples/edge-function.ts @@ -6,9 +6,11 @@ import { publishThreadsContainer, createCarouselItem, getPublishingLimit, -} from "jsr:@codybrom/denim@1.3.0"; +} from "jsr:@codybrom/denim@1.3.5"; -async function postToThreads(request: ThreadsPostRequest): Promise { +async function postToThreads( + request: ThreadsPostRequest +): Promise<{ id: string; permalink: string }> { try { // Check rate limit const rateLimit = await getPublishingLimit( @@ -23,17 +25,40 @@ async function postToThreads(request: ThreadsPostRequest): Promise { delete request.imageUrl; } - const containerId = await createThreadsContainer(request); - console.log(`Container created with ID: ${containerId}`); + const containerResult = await createThreadsContainer(request); + console.log( + `Container created with ID: ${ + typeof containerResult === "string" + ? containerResult + : containerResult.id + }` + ); - const publishedId = await publishThreadsContainer( + const publishedResult = await publishThreadsContainer( request.userId, request.accessToken, - containerId + typeof containerResult === "string" + ? containerResult + : containerResult.id, + true // Get permalink + ); + + console.log( + `Post published with ID: ${ + typeof publishedResult === "string" + ? publishedResult + : publishedResult.id + }` ); - console.log(`Post published with ID: ${publishedId}`); - return publishedId; + if (typeof publishedResult === "string") { + return { id: publishedResult, permalink: "" }; + } + + return { + id: publishedResult.id, + permalink: publishedResult.permalink, + }; } catch (error) { console.error("Error posting to Threads:", error); throw error; @@ -102,7 +127,6 @@ Deno.serve(async (req: Request) => { videoUrl: body.videoUrl, altText: body.altText, linkAttachment: body.linkAttachment, - allowlistedCountryCodes: body.allowlistedCountryCodes, replyControl: body.replyControl, children: body.children, }; @@ -117,14 +141,23 @@ Deno.serve(async (req: Request) => { videoUrl: item.videoUrl, altText: item.altText, }); - postRequest.children.push(itemId); + postRequest.children.push( + typeof itemId === "string" ? itemId : itemId.id + ); } } - const publishedId = await postToThreads(postRequest); - return new Response(JSON.stringify({ success: true, publishedId }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + const publishedResult = await postToThreads(postRequest); + return new Response( + JSON.stringify({ + success: true, + id: publishedResult.id, + permalink: publishedResult.permalink, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } catch (error) { console.error("Error processing request:", error); return new Response( @@ -169,10 +202,11 @@ Deno.serve(async (req: Request) => { "userId": "YOUR_USER_ID", "accessToken": "YOUR_ACCESS_TOKEN", "mediaType": "TEXT", - "text": "Hello from Denim!" + "text": "Hello from Denim!", + "linkAttachment": "https://example.com" }' - # Post an image Thread + # Post an image Thread with alt text curl -X POST /post \ -H "Content-Type: application/json" \ -d '{ @@ -180,10 +214,11 @@ Deno.serve(async (req: Request) => { "accessToken": "YOUR_ACCESS_TOKEN", "mediaType": "IMAGE", "text": "Check out this image I posted with Denim!", - "imageUrl": "https://example.com/image.jpg" + "imageUrl": "https://example.com/image.jpg", + "altText": "A beautiful sunset over the ocean" }' - # Post a video Thread + # Post a video Thread with reply control curl -X POST /post \ -H "Content-Type: application/json" \ -d '{ @@ -191,7 +226,8 @@ Deno.serve(async (req: Request) => { "accessToken": "YOUR_ACCESS_TOKEN", "mediaType": "VIDEO", "text": "Watch this video I posted with Denim!", - "videoUrl": "https://example.com/video.mp4" + "videoUrl": "https://example.com/video.mp4", + "replyControl": "mentioned_only" }' # Post a carousel Thread diff --git a/mock_threads_api.ts b/mock_threads_api.ts new file mode 100644 index 0000000..9ef8fa1 --- /dev/null +++ b/mock_threads_api.ts @@ -0,0 +1,174 @@ +// mock_threads_api.ts + +import type { + ThreadsContainer, + ThreadsPost, + ThreadsProfile, + PublishingLimit, + ThreadsPostRequest, + ThreadsListResponse, +} from "./types.ts"; + +export class MockThreadsAPI implements MockThreadsAPI { + private containers: Map = new Map(); + private posts: Map = new Map(); + private users: Map = new Map(); + private publishingLimits: Map = new Map(); + private errorMode = false; + + constructor() { + // Initialize with some sample data + this.users.set("12345", { + id: "12345", + username: "testuser", + name: "Test User", + threadsProfilePictureUrl: "https://example.com/profile.jpg", + threadsBiography: "This is a test user", + }); + + this.publishingLimits.set("12345", { + quota_usage: 10, + config: { + quota_total: 250, + quota_duration: 86400, + }, + }); + } + + setErrorMode(mode: boolean) { + this.errorMode = mode; + } + + createThreadsContainer( + request: ThreadsPostRequest + ): Promise { + if (this.errorMode) { + return Promise.reject(new Error("Failed to create Threads container")); + } + const containerId = `container_${Math.random().toString(36).substring(7)}`; + const permalink = `https://www.threads.net/@${request.userId}/post/${containerId}`; + const container: ThreadsContainer = { + id: containerId, + permalink, + status: "FINISHED", + }; + this.containers.set(containerId, container); + + // Create a post immediately when creating a container + const postId = `post_${Math.random().toString(36).substring(7)}`; + const post: ThreadsPost = { + id: postId, + media_product_type: "THREADS", + media_type: request.mediaType, + permalink, + owner: { id: request.userId }, + username: "testuser", + text: request.text || "", + timestamp: new Date().toISOString(), + shortcode: postId, + is_quote_post: false, + hasReplies: false, + isReply: false, + isReplyOwnedByMe: false, + }; + this.posts.set(postId, post); + + // Always return an object with both id and permalink + return Promise.resolve({ id: containerId, permalink }); + } + + publishThreadsContainer( + _userId: string, + _accessToken: string, + containerId: string, + getPermalink: boolean = false + ): Promise { + if (this.errorMode) { + return Promise.reject(new Error("Failed to publish Threads container")); + } + const container = this.containers.get(containerId); + if (!container) { + return Promise.reject(new Error("Container not found")); + } + + // Find the post associated with this container + const existingPost = Array.from(this.posts.values()).find( + (post) => post.permalink === container.permalink + ); + + if (!existingPost) { + return Promise.reject( + new Error("Post not found for the given container") + ); + } + + return Promise.resolve( + getPermalink + ? { id: existingPost.id, permalink: existingPost.permalink || "" } + : existingPost.id + ); + } + + createCarouselItem( + request: Omit & { + mediaType: "IMAGE" | "VIDEO"; + } + ): Promise { + const itemId = `item_${Math.random().toString(36).substring(7)}`; + const container: ThreadsContainer = { + id: itemId, + permalink: `https://www.threads.net/@${request.userId}/post/${itemId}`, + status: "FINISHED", + }; + this.containers.set(itemId, container); + return Promise.resolve({ id: itemId }); + } + + getPublishingLimit( + userId: string, + _accessToken: string + ): Promise { + if (this.errorMode) { + return Promise.reject(new Error("Failed to get publishing limit")); + } + const limit = this.publishingLimits.get(userId); + if (!limit) { + return Promise.reject(new Error("Publishing limit not found")); + } + return Promise.resolve(limit); + } + + getThreadsList( + userId: string, + _accessToken: string, + options?: { + since?: string; + until?: string; + limit?: number; + after?: string; + before?: string; + } + ): Promise { + const threads = Array.from(this.posts.values()) + .filter((post) => post.owner.id === userId) + .slice(0, options?.limit || 25); + + return Promise.resolve({ + data: threads as ThreadsPost[], + paging: { + cursors: { + before: "BEFORE_CURSOR", + after: "AFTER_CURSOR", + }, + }, + }); + } + + getSingleThread(mediaId: string, _accessToken: string): Promise { + const post = this.posts.get(mediaId); + if (!post) { + return Promise.reject(new Error("Thread not found")); + } + return Promise.resolve(post as ThreadsPost); + } +} diff --git a/mod.ts b/mod.ts index b95132a..761f249 100644 --- a/mod.ts +++ b/mod.ts @@ -1,3 +1,27 @@ +// mod.ts +import type { + ThreadsPostRequest, + PublishingLimit, + ThreadsPost, + ThreadsListResponse, + MockThreadsAPI, +} from "./types.ts"; +export type { + ThreadsPostRequest, + PublishingLimit, + ThreadsPost, + ThreadsListResponse, +}; + +/** + * Retrieves the mock API instance if available. + * + * @returns The mock API instance or null if not available + */ +function getAPI(): MockThreadsAPI | null { + return (globalThis as { threadsAPI?: MockThreadsAPI }).threadsAPI || null; +} + /** * @module * @@ -8,34 +32,6 @@ /** The base URL for the Threads API */ export const THREADS_API_BASE_URL = "https://graph.threads.net/v1.0"; -/** - * Represents a request to post content on Threads. - */ -export interface ThreadsPostRequest { - /** The user ID of the Threads account */ - userId: string; - /** The access token for authentication */ - accessToken: string; - /** The type of media being posted */ - mediaType: "TEXT" | "IMAGE" | "VIDEO" | "CAROUSEL"; - /** The text content of the post (optional) */ - text?: string; - /** The URL of the image to be posted (optional, for IMAGE type) */ - imageUrl?: string; - /** The URL of the video to be posted (optional, for VIDEO type) */ - videoUrl?: string; - /** The accessibility text for the image or video (optional) */ - altText?: string; - /** The URL to be attached as a link to the post (optional, for text posts only) */ - linkAttachment?: string; - /** List of country codes where the post should be visible (optional - requires special API access) */ - allowlistedCountryCodes?: string[]; - /** Controls who can reply to the post (optional) */ - replyControl?: "everyone" | "accounts_you_follow" | "mentioned_only"; - /** Array of carousel item IDs (required for CAROUSEL type, not applicable for other types) */ - children?: string[]; -} - /** * Creates a Threads media container. * @@ -58,65 +54,82 @@ export interface ThreadsPostRequest { */ export async function createThreadsContainer( request: ThreadsPostRequest -): Promise { - // Input validation - validateRequest(request); - - const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`; - const body = new URLSearchParams({ - access_token: request.accessToken, - media_type: request.mediaType, - }); - - // Add common optional parameters - if (request.text) body.append("text", request.text); - if (request.altText) body.append("alt_text", request.altText); - if (request.replyControl) body.append("reply_control", request.replyControl); - if (request.allowlistedCountryCodes) { - body.append( - "allowlisted_country_codes", - request.allowlistedCountryCodes.join(",") - ); +): Promise { + const api = getAPI(); + if (api) { + // Use mock API + return api.createThreadsContainer(request); } + try { + // Input validation + validateRequest(request); + + const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`; + const body = new URLSearchParams({ + access_token: request.accessToken, + media_type: request.mediaType, + }); + + // Add common optional parameters + if (request.text) body.append("text", request.text); + if (request.altText) body.append("alt_text", request.altText); + if (request.replyControl) + body.append("reply_control", request.replyControl); + if (request.allowlistedCountryCodes) { + body.append( + "allowlisted_country_codes", + request.allowlistedCountryCodes.join(",") + ); + } - // Handle media type specific parameters - if (request.mediaType === "VIDEO") { - const videoItemId = await createVideoItemContainer(request); - body.set("media_type", "CAROUSEL"); - body.append("children", videoItemId); - } else if (request.mediaType === "IMAGE" && request.imageUrl) { - body.append("image_url", request.imageUrl); - } else if (request.mediaType === "TEXT" && request.linkAttachment) { - body.append("link_attachment", request.linkAttachment); - } else if (request.mediaType === "CAROUSEL" && request.children) { - body.append("children", request.children.join(",")); - } + // Handle media type specific parameters + if (request.mediaType === "VIDEO" && request.videoUrl) { + const videoItemId = await createVideoItemContainer(request); + body.set("media_type", "CAROUSEL"); + body.append("children", videoItemId); + } else if (request.mediaType === "IMAGE" && request.imageUrl) { + body.append("image_url", request.imageUrl); + } else if (request.mediaType === "TEXT" && request.linkAttachment) { + body.append("link_attachment", request.linkAttachment); + } else if (request.mediaType === "CAROUSEL" && request.children) { + body.append("children", request.children.join(",")); + } - console.log(`Sending request to: ${url}`); - console.log(`Request body: ${body.toString()}`); + console.log(`Sending request to: ${url}`); + console.log(`Request body: ${body.toString()}`); - const response = await fetch(url, { - method: "POST", - body: body, - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - }); + const response = await fetch(url, { + method: "POST", + body: body, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); - const responseText = await response.text(); - console.log(`Response status: ${response.status} ${response.statusText}`); - console.log(`Response body: ${responseText}`); + const responseText = await response.text(); - if (!response.ok) { - throw new Error( - `Failed to create Threads container: ${response.statusText}. Details: ${responseText}` - ); - } + console.log(`Response status: ${response.status} ${response.statusText}`); + console.log(`Response body: ${responseText}`); + + if (!response.ok) { + throw new Error(`Internal Server Error. Details: ${responseText}`); + } - try { const data = JSON.parse(responseText); - return data.id; + + // If getPermalink is true, fetch the permalink + if (request.getPermalink) { + const threadData = await getSingleThread(data.id, request.accessToken); + return { + id: data.id, + permalink: threadData.permalink || "", + }; + } else { + return data.id; + } } catch (error) { - console.error(`Failed to parse response JSON: ${error}`); - throw new Error(`Invalid response from Threads API: ${responseText}`); + // Access error message safely + const errorMessage = + error instanceof Error ? error.message : "Unknown Error"; + throw new Error(`Failed to create Threads container: ${errorMessage}`); } } @@ -168,6 +181,7 @@ function validateRequest(request: ThreadsPostRequest): void { /** * Creates a video item container for Threads. + * * @param request - The ThreadsPostRequest object containing video post details * @returns A Promise that resolves to the video item container ID * @throws Will throw an error if the API request fails @@ -242,7 +256,12 @@ export async function createCarouselItem( request: Omit & { mediaType: "IMAGE" | "VIDEO"; } -): Promise { +): Promise { + const api = getAPI(); + if (api) { + // Use mock API + return api.createCarouselItem(request); + } if (request.mediaType !== "IMAGE" && request.mediaType !== "VIDEO") { throw new Error("Carousel items must be either IMAGE or VIDEO type"); } @@ -292,6 +311,7 @@ export async function createCarouselItem( /** * Checks the status of a Threads container. + * * @param containerId - The ID of the container to check * @param accessToken - The access token for authentication * @returns A Promise that resolves to the container status @@ -329,56 +349,91 @@ async function checkContainerStatus( export async function publishThreadsContainer( userId: string, accessToken: string, - containerId: string -): Promise { - const publishUrl = `${THREADS_API_BASE_URL}/${userId}/threads_publish`; - const publishBody = new URLSearchParams({ - access_token: accessToken, - creation_id: containerId, - }); - - const publishResponse = await fetch(publishUrl, { - method: "POST", - body: publishBody, - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - - if (!publishResponse.ok) { - throw new Error( - `Failed to publish Threads container: ${publishResponse.statusText}` + containerId: string, + getPermalink: boolean = false +): Promise { + const api = getAPI(); + if (api) { + // Use mock API + return api.publishThreadsContainer( + userId, + accessToken, + containerId, + getPermalink ); } + try { + const publishUrl = `${THREADS_API_BASE_URL}/${userId}/threads_publish`; + const publishBody = new URLSearchParams({ + access_token: accessToken, + creation_id: containerId, + }); + + const publishResponse = await fetch(publishUrl, { + method: "POST", + body: publishBody, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (!publishResponse.ok) { + throw new Error( + `Failed to publish Threads container: ${publishResponse.statusText}` + ); + } - // Check container status - let status = await checkContainerStatus(containerId, accessToken); - let attempts = 0; - const maxAttempts = 5; + const publishData = await publishResponse.json(); + + if (getPermalink) { + const mediaId = publishData.id; + const permalinkUrl = `${THREADS_API_BASE_URL}/${mediaId}?fields=permalink&access_token=${accessToken}`; + const permalinkResponse = await fetch(permalinkUrl); + + if (permalinkResponse.ok) { + const permalinkData = await permalinkResponse.json(); + return { + id: mediaId, + permalink: permalinkData.permalink, + }; + } else { + throw new Error("Failed to fetch permalink"); + } + } - while ( - status !== "PUBLISHED" && - status !== "FINISHED" && - attempts < maxAttempts - ) { - await new Promise((resolve) => setTimeout(resolve, 60000)); // Wait for 1 minute - status = await checkContainerStatus(containerId, accessToken); - attempts++; - } + // Check container status + let status = await checkContainerStatus(containerId, accessToken); + let attempts = 0; + const maxAttempts = 5; + + while ( + status !== "PUBLISHED" && + status !== "FINISHED" && + attempts < maxAttempts + ) { + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second + status = await checkContainerStatus(containerId, accessToken); + attempts++; + } - if (status === "ERROR") { - throw new Error(`Failed to publish container. Error: ${status}`); - } + if (status === "ERROR") { + throw new Error(`Failed to publish container. Error: ${status}`); + } - if (status !== "PUBLISHED" && status !== "FINISHED") { - throw new Error( - `Container not published after ${maxAttempts} attempts. Current status: ${status}` - ); - } + if (status !== "PUBLISHED" && status !== "FINISHED") { + throw new Error( + `Container not published after ${maxAttempts} attempts. Current status: ${status}` + ); + } - return containerId; // Return the container ID as the published ID + return publishData.id; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to publish Threads container: ${error.message}`); + } + throw error; + } } - /** * Serves HTTP requests to create and publish Threads posts. * @@ -386,6 +441,8 @@ export async function publishThreadsContainer( * containing ThreadsPostRequest data. It creates a container and * immediately publishes it. * + * @throws Will throw an error if the request is invalid or if there's an error during processing + * * @example * ```typescript * // Start the server @@ -393,6 +450,8 @@ export async function publishThreadsContainer( * ``` */ export function serveRequests() { + const api = getAPI(); + Deno.serve(async (req) => { if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); @@ -416,17 +475,47 @@ export function serveRequests() { ); } - // Create the Threads container - const containerId = await createThreadsContainer(requestData); + let containerResult; + let publishResult; + + if (api) { + // Use mock API + containerResult = await api.createThreadsContainer(requestData); + publishResult = await api.publishThreadsContainer( + requestData.userId, + requestData.accessToken, + typeof containerResult === "string" + ? containerResult + : containerResult.id, + requestData.getPermalink + ); + } else { + // Use real API calls + containerResult = await createThreadsContainer(requestData); + if (typeof containerResult === "string") { + publishResult = await publishThreadsContainer( + requestData.userId, + requestData.accessToken, + containerResult, + requestData.getPermalink + ); + } else { + publishResult = containerResult; + } + } - // Immediately attempt to publish the Threads container - const publishedId = await publishThreadsContainer( - requestData.userId, - requestData.accessToken, - containerId - ); + let responseData; + if (typeof publishResult === "string") { + responseData = { success: true, publishedId: publishResult }; + } else { + responseData = { + success: true, + publishedId: publishResult.id, + permalink: publishResult.permalink, + }; + } - return new Response(JSON.stringify({ success: true, publishedId }), { + return new Response(JSON.stringify(responseData), { headers: { "Content-Type": "application/json" }, }); } catch (error) { @@ -462,13 +551,12 @@ export function serveRequests() { export async function getPublishingLimit( userId: string, accessToken: string -): Promise<{ - quota_usage: number; - config: { - quota_total: number; - quota_duration: number; - }; -}> { +): Promise { + const api = getAPI(); + if (api) { + // Use mock API + return api.getPublishingLimit(userId, accessToken); + } const url = `${THREADS_API_BASE_URL}/${userId}/threads_publishing_limit`; const params = new URLSearchParams({ access_token: accessToken, @@ -476,15 +564,94 @@ export async function getPublishingLimit( }); const response = await fetch(`${url}?${params}`); + if (!response.ok) { + throw new Error(`Failed to get publishing limit: ${response.statusText}`); + } + const data = await response.json(); + return data.data[0]; +} +/** + * Retrieves a list of all threads created by a user. + * + * @param userId - The user ID of the Threads account + * @param accessToken - The access token for authentication + * @param options - Optional parameters for the request + * @param options.since - Start date for fetching threads (ISO 8601 format) + * @param options.until - End date for fetching threads (ISO 8601 format) + * @param options.limit - Maximum number of threads to return + * @param options.after - Cursor for pagination (next page) + * @param options.before - Cursor for pagination (previous page) + * @returns A Promise that resolves to the ThreadsListResponse + * @throws Will throw an error if the API request fails + */ +export async function getThreadsList( + userId: string, + accessToken: string, + options?: { + since?: string; + until?: string; + limit?: number; + after?: string; + before?: string; + } +): Promise { + const api = getAPI(); + if (api) { + // Use mock API + return api.getThreadsList(userId, accessToken, options); + } + const fields = + "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post"; + const url = new URL(`${THREADS_API_BASE_URL}/${userId}/threads`); + url.searchParams.append("fields", fields); + url.searchParams.append("access_token", accessToken); + + if (options) { + if (options.since) url.searchParams.append("since", options.since); + if (options.until) url.searchParams.append("until", options.until); + if (options.limit) + url.searchParams.append("limit", options.limit.toString()); + if (options.after) url.searchParams.append("after", options.after); + if (options.before) url.searchParams.append("before", options.before); + } + + const response = await fetch(url.toString()); if (!response.ok) { - throw new Error( - `Failed to get publishing limit: ${ - data.error?.message || response.statusText - }` - ); + throw new Error(`Failed to retrieve threads list: ${response.statusText}`); } - return data.data[0]; + return await response.json(); +} + +/** + * Retrieves a single Threads media object. + * + * @param mediaId - The ID of the Threads media object + * @param accessToken - The access token for authentication + * @returns A Promise that resolves to the ThreadsPost object + * @throws Will throw an error if the API request fails + */ +export async function getSingleThread( + mediaId: string, + accessToken: string +): Promise { + const api = getAPI(); + if (api) { + // Use mock API + return api.getSingleThread(mediaId, accessToken); + } + const fields = + "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post"; + const url = new URL(`${THREADS_API_BASE_URL}/${mediaId}`); + url.searchParams.append("fields", fields); + url.searchParams.append("access_token", accessToken); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`Failed to retrieve thread: ${response.statusText}`); + } + + return await response.json(); } diff --git a/mod_test.ts b/mod_test.ts index 82993e0..990dcbb 100644 --- a/mod_test.ts +++ b/mod_test.ts @@ -1,4 +1,5 @@ // mod_test.ts +import type { ThreadsPostRequest } from "./types.ts"; import { assertEquals, assertRejects, @@ -8,494 +9,376 @@ import { publishThreadsContainer, createCarouselItem, getPublishingLimit, - type ThreadsPostRequest, + getThreadsList, + getSingleThread, } from "./mod.ts"; +import { MockThreadsAPI } from "./mock_threads_api.ts"; -// Mock fetch response -globalThis.fetch = ( - input: string | URL | Request, - init?: RequestInit -): Promise => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - - const body = - init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(); - - if (url.includes("threads")) { - if (url.includes("threads_publish")) { - return Promise.resolve({ - ok: true, - status: 200, - statusText: "OK", - text: () => Promise.resolve(JSON.stringify({ id: "published123" })), - } as Response); - } - - if (body.get("is_carousel_item") === "true") { - if (body.get("access_token") === "invalid_token") { - return Promise.resolve({ - ok: false, - status: 400, - statusText: "Bad Request", - text: () => - Promise.resolve(JSON.stringify({ error: "Invalid access token" })), - } as Response); - } - return Promise.resolve({ - ok: true, - status: 200, - statusText: "OK", - text: () => Promise.resolve(JSON.stringify({ id: "item123" })), - } as Response); - } +Deno.test("Threads API", async (t) => { + let mockAPI: MockThreadsAPI; - return Promise.resolve({ - ok: true, - status: 200, - statusText: "OK", - text: () => Promise.resolve(JSON.stringify({ id: "container123" })), - } as Response); + function setupMockAPI() { + mockAPI = new MockThreadsAPI(); + (globalThis as { threadsAPI?: MockThreadsAPI }).threadsAPI = mockAPI; } - return Promise.resolve({ - ok: false, - status: 500, - statusText: "Internal Server Error", - text: () => Promise.resolve("Error"), - } as Response); -}; - -Deno.test( - "createThreadsContainer should return container ID for basic text post", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "TEXT", - text: "Hello, Threads!", - }; - - const containerId = await createThreadsContainer(requestData); - assertEquals(containerId, "container123"); - } -); - -Deno.test( - "createThreadsContainer should return container ID with text post with link attachment, reply control, and allowlisted countries", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "TEXT", - text: "Hello, Threads!", - linkAttachment: "https://example.com", - replyControl: "everyone", - allowlistedCountryCodes: ["US", "CA"], - }; - - const containerId = await createThreadsContainer(requestData); - assertEquals(containerId, "container123"); + function teardownMockAPI() { + delete (globalThis as { threadsAPI?: MockThreadsAPI }).threadsAPI; } -); - -Deno.test( - "createThreadsContainer should handle image post with alt text", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "IMAGE", - text: "Check out this image!", - imageUrl: "https://example.com/image.jpg", - altText: "A beautiful sunset", - }; - - const containerId = await createThreadsContainer(requestData); - assertEquals(containerId, "container123"); - } -); - -Deno.test( - "createThreadsContainer should handle video post with all features", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "VIDEO", - text: "Watch this video!", - videoUrl: "https://example.com/video.mp4", - altText: "A tutorial video", - replyControl: "mentioned_only", - allowlistedCountryCodes: ["US", "GB"], - }; - - const containerId = await createThreadsContainer(requestData); - assertEquals(containerId, "container123"); - } -); - -Deno.test("createThreadsContainer should throw error on failure", async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "TEXT", - text: "Hello, Threads!", - linkAttachment: "https://example.com", - }; - - globalThis.fetch = (): Promise => - Promise.resolve({ - ok: false, - status: 500, - statusText: "Internal Server Error", - text: () => Promise.resolve("Error"), - } as Response); - - await assertRejects( - async () => { - await createThreadsContainer(requestData); - }, - Error, - "Failed to create Threads container" - ); -}); -Deno.test("createCarouselItem should return item ID", async () => { - const requestData = { - userId: "12345", - accessToken: "token", - mediaType: "IMAGE" as const, - imageUrl: "https://example.com/image.jpg", - altText: "Test image", - }; - - globalThis.fetch = ( - _input: string | URL | Request, - init?: RequestInit - ): Promise => { - const body = - init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(); - if (body.get("is_carousel_item") === "true") { - return Promise.resolve({ - ok: true, - status: 200, - statusText: "OK", - text: () => Promise.resolve(JSON.stringify({ id: "item123" })), - } as Response); - } - return Promise.resolve({ - ok: false, - status: 500, - statusText: "Internal Server Error", - text: () => Promise.resolve("Error"), - } as Response); - }; - - const itemId = await createCarouselItem(requestData); - assertEquals(itemId, "item123"); -}); - -Deno.test("createCarouselItem should handle video items", async () => { - const requestData = { - userId: "12345", - accessToken: "token", - mediaType: "VIDEO" as const, - videoUrl: "https://example.com/video.mp4", - altText: "Test video", - }; - - globalThis.fetch = ( - _input: string | URL | Request, - init?: RequestInit - ): Promise => { - const body = - init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(); - if (body.get("is_carousel_item") === "true") { - return Promise.resolve({ - ok: true, - status: 200, - statusText: "OK", - text: () => Promise.resolve(JSON.stringify({ id: "item123" })), - } as Response); - } - return Promise.resolve({ - ok: false, - status: 500, - statusText: "Internal Server Error", - text: () => Promise.resolve("Error"), - } as Response); - }; - - const itemId = await createCarouselItem(requestData); - assertEquals(itemId, "item123"); -}); - -Deno.test("createThreadsContainer should handle carousel post", async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "CAROUSEL", - text: "Check out this carousel!", - children: ["item123", "item456"], - replyControl: "everyone", - allowlistedCountryCodes: ["US", "CA"], - }; - - const containerId = await createThreadsContainer(requestData); - assertEquals(containerId, "container123"); -}); - -Deno.test("createCarouselItem should throw error on failure", async () => { - const requestData = { - userId: "12345", - accessToken: "invalid_token", - mediaType: "IMAGE" as const, - imageUrl: "https://example.com/image.jpg", - }; - - await assertRejects( - () => createCarouselItem(requestData), - Error, - "Failed to create carousel item" - ); -}); - -Deno.test("publishThreadsContainer should return published ID", async () => { - const userId = "12345"; - const accessToken = "token"; - const containerId = "container123"; - - const publishedId = await publishThreadsContainer( - userId, - accessToken, - containerId - ); - assertEquals(publishedId, "published123"); -}); -Deno.test("publishThreadsContainer should throw error on failure", async () => { - const userId = "12345"; - const accessToken = "token"; - const containerId = "container123"; - - globalThis.fetch = ( - _input: string | URL | Request, - _init?: RequestInit - ): Promise => - Promise.resolve({ - ok: false, - status: 500, - statusText: "Internal Server Error", - text: () => Promise.resolve("Error"), - } as Response); - - await assertRejects( - async () => { - await publishThreadsContainer(userId, accessToken, containerId); - }, - Error, - "Failed to publish Threads container" - ); -}); - -Deno.test( - "createThreadsContainer should throw error when imageUrl is provided for non-IMAGE type", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "TEXT", - text: "This shouldn't work", - imageUrl: "https://example.com/image.jpg", - }; - - await assertRejects( - () => createThreadsContainer(requestData), - Error, - "imageUrl can only be used with IMAGE media type" + await t.step("createThreadsContainer", async (t) => { + await t.step("should return container ID for basic text post", async () => { + setupMockAPI(); + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "token", + mediaType: "TEXT", + text: "Hello, Threads!", + }; + + const result = await createThreadsContainer(requestData); + if (typeof result === "string") { + assertEquals(result.length > 0, true); + } else { + assertEquals(typeof result.id, "string"); + assertEquals(result.id.length > 0, true); + } + teardownMockAPI(); + }); + + await t.step("should handle image post with alt text", async () => { + setupMockAPI(); + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "token", + mediaType: "IMAGE", + text: "Check out this image!", + imageUrl: "https://example.com/image.jpg", + altText: "A beautiful sunset", + }; + + const containerId = await createThreadsContainer(requestData); + if (typeof containerId === "string") { + assertEquals(containerId.length > 0, true); + } else { + assertEquals(typeof containerId.id, "string"); + assertEquals(containerId.id.length > 0, true); + } + teardownMockAPI(); + }); + + await t.step("should handle video post with all features", async () => { + setupMockAPI(); + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "token", + mediaType: "VIDEO", + text: "Watch this video!", + videoUrl: "https://example.com/video.mp4", + altText: "A tutorial video", + replyControl: "mentioned_only", + allowlistedCountryCodes: ["US", "GB"], + }; + + const containerId = await createThreadsContainer(requestData); + if (typeof containerId === "string") { + assertEquals(containerId.length > 0, true); + } else { + assertEquals(typeof containerId.id, "string"); + assertEquals(containerId.id.length > 0, true); + } + teardownMockAPI(); + }); + + await t.step("should throw error on failure", async () => { + setupMockAPI(); + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "invalid_token", + mediaType: "TEXT", + text: "Hello, Threads!", + linkAttachment: "https://example.com", + }; + + // Mock the error in the MockThreadsAPI + mockAPI.setErrorMode(true); + + await assertRejects( + () => createThreadsContainer(requestData), + Error, + "Failed to create Threads container" + ); + teardownMockAPI(); + }); + + await t.step( + "should throw error when CAROUSEL type is used without children", + async () => { + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "token", + mediaType: "CAROUSEL", + text: "This carousel has no items", + }; + + await assertRejects( + async () => await createThreadsContainer(requestData), + Error, + "CAROUSEL media type requires at least 2 children" + ); + } ); - } -); - -Deno.test( - "createThreadsContainer should throw error when videoUrl is provided for non-VIDEO type", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "IMAGE", - imageUrl: "https://example.com/image.jpg", - videoUrl: "https://example.com/video.mp4", - }; - - await assertRejects( - () => createThreadsContainer(requestData), - Error, - "videoUrl can only be used with VIDEO media type" + + await t.step( + "should throw error when imageUrl is provided for non-IMAGE type", + async () => { + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "token", + mediaType: "TEXT", + text: "This shouldn't work", + imageUrl: "https://example.com/image.jpg", + }; + + await assertRejects( + () => createThreadsContainer(requestData), + Error, + "imageUrl can only be used with IMAGE media type" + ); + } ); - } -); - -Deno.test( - "createThreadsContainer should throw error when linkAttachment is provided for non-TEXT type", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "IMAGE", - imageUrl: "https://example.com/image.jpg", - linkAttachment: "https://example.com", - }; - - await assertRejects( - () => createThreadsContainer(requestData), - Error, - "linkAttachment can only be used with TEXT media type" + + await t.step( + "should throw error when videoUrl is provided for non-VIDEO type", + async () => { + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "token", + mediaType: "IMAGE", + imageUrl: "https://example.com/image.jpg", + videoUrl: "https://example.com/video.mp4", + }; + + await assertRejects( + () => createThreadsContainer(requestData), + Error, + "videoUrl can only be used with VIDEO media type" + ); + } ); - } -); - -Deno.test( - "createThreadsContainer should throw error when children is provided for non-CAROUSEL type", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "IMAGE", - imageUrl: "https://example.com/image.jpg", - children: ["item1", "item2"], - }; - - await assertRejects( + + await t.step( + "should throw error when linkAttachment is provided for non-TEXT type", async () => { - await createThreadsContainer(requestData); - }, - Error, - "Failed to create Threads container" + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "token", + mediaType: "IMAGE", + imageUrl: "https://example.com/image.jpg", + linkAttachment: "https://example.com", + }; + + await assertRejects( + () => createThreadsContainer(requestData), + Error, + "linkAttachment can only be used with TEXT media type" + ); + } ); - } -); - -Deno.test( - "createThreadsContainer should throw error when CAROUSEL type is used without children", - async () => { - const requestData: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "CAROUSEL", - text: "This carousel has no items", - }; - - await assertRejects( + + await t.step( + "should throw error when children is provided for non-CAROUSEL type", async () => { - await createThreadsContainer(requestData); - }, - Error, - "Failed to create Threads container" + const requestData: ThreadsPostRequest = { + userId: "12345", + accessToken: "token", + mediaType: "IMAGE", + imageUrl: "https://example.com/image.jpg", + children: ["item1", "item2"], + }; + + await assertRejects( + async () => { + await createThreadsContainer(requestData); + }, + Error, + "Failed to create Threads container" + ); + } ); - } -); - -Deno.test( - "createThreadsContainer should not throw error when attributes are used correctly", - async () => { - const textRequest: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", + }); + + await t.step("publishThreadsContainer", async (t) => { + await t.step("should publish container successfully", async () => { + setupMockAPI(); + const userId = "12345"; + const accessToken = "token"; + const containerId = await createThreadsContainer({ + userId, + accessToken, + mediaType: "TEXT", + text: "Test post", + }); + + const result = await publishThreadsContainer( + userId, + accessToken, + typeof containerId === "string" ? containerId : containerId.id + ); + if (typeof result === "string") { + assertEquals(result.length > 0, true); + } else { + assertEquals(typeof result.id, "string"); + assertEquals(result.id.length > 0, true); + } + teardownMockAPI(); + }); + + await t.step("should throw error on failure", async () => { + setupMockAPI(); + const userId = "12345"; + const accessToken = "invalid_token"; + const containerId = "invalid_container"; + + // Mock the error in the MockThreadsAPI + mockAPI.setErrorMode(true); + + await assertRejects( + () => publishThreadsContainer(userId, accessToken, containerId), + Error, + "Failed to publish Threads container" + ); + teardownMockAPI(); + }); + }); + + await t.step("createCarouselItem", async (t) => { + await t.step("should return item ID", async () => { + setupMockAPI(); + const requestData = { + userId: "12345", + accessToken: "token", + mediaType: "IMAGE" as const, + imageUrl: "https://example.com/image.jpg", + altText: "Test image", + }; + + const itemId = await createCarouselItem(requestData); + if (typeof itemId === "string") { + assertEquals(itemId.length > 0, true); + } else { + assertEquals(typeof itemId.id, "string"); + assertEquals(itemId.id.length > 0, true); + } + teardownMockAPI(); + }); + + await t.step("should handle video items", async () => { + setupMockAPI(); + const requestData = { + userId: "12345", + accessToken: "token", + mediaType: "VIDEO" as const, + videoUrl: "https://example.com/video.mp4", + altText: "Test video", + }; + + const itemId = await createCarouselItem(requestData); + if (typeof itemId === "string") { + assertEquals(itemId.length > 0, true); + } else { + assertEquals(typeof itemId.id, "string"); + assertEquals(itemId.id.length > 0, true); + } + teardownMockAPI(); + }); + }); + + await t.step("getThreadsList", async () => { + setupMockAPI(); + const userId = "12345"; + const accessToken = "valid_token"; + + // Create some test posts + await createThreadsContainer({ + userId, + accessToken, mediaType: "TEXT", - text: "This is a text post", - linkAttachment: "https://example.com", - }; - - const imageRequest: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "IMAGE", - imageUrl: "https://example.com/image.jpg", - altText: "An example image", - }; - - const videoRequest: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "VIDEO", - videoUrl: "https://example.com/video.mp4", - altText: "An example video", - }; - - const carouselRequest: ThreadsPostRequest = { - userId: "12345", - accessToken: "token", - mediaType: "CAROUSEL", - text: "A carousel post", - children: ["item1", "item2"], - }; - - const textContainerId = await createThreadsContainer(textRequest); - const imageContainerId = await createThreadsContainer(imageRequest); - const videoContainerId = await createThreadsContainer(videoRequest); - const carouselContainerId = await createThreadsContainer(carouselRequest); - - assertEquals(textContainerId, "container123"); - assertEquals(imageContainerId, "container123"); - assertEquals(videoContainerId, "container123"); - assertEquals(carouselContainerId, "container123"); - } -); + text: "Test post 1", + }); + await createThreadsContainer({ + userId, + accessToken, + mediaType: "TEXT", + text: "Test post 2", + }); + + const result = await getThreadsList(userId, accessToken); + assertEquals(Array.isArray(result.data), true); + assertEquals(result.data.length > 0, true); + assertEquals(result.data[0].text, "Test post 1"); + assertEquals(result.data[1].text, "Test post 2"); + if (result.paging) { + assertEquals(typeof result.paging.cursors.before, "string"); + assertEquals(typeof result.paging.cursors.after, "string"); + } + teardownMockAPI(); + }); -Deno.test( - "getPublishingLimit should return rate limit information", - async () => { + await t.step("getSingleThread", async () => { + setupMockAPI(); const userId = "12345"; const accessToken = "valid_token"; + const testText = "Test post for getSingleThread"; - globalThis.fetch = (_input: string | URL | Request): Promise => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => - Promise.resolve({ - data: [ - { - quota_usage: 10, - config: { - quota_total: 250, - quota_duration: 86400, - }, - }, - ], - }), - } as Response); - }; - - const result = await getPublishingLimit(userId, accessToken); - assertEquals(result.quota_usage, 10); - assertEquals(result.config.quota_total, 250); - assertEquals(result.config.quota_duration, 86400); - } -); - -Deno.test("getPublishingLimit should throw error on failure", async () => { - const userId = "12345"; - const accessToken = "invalid_token"; - - globalThis.fetch = (_input: string | URL | Request): Promise => { - return Promise.resolve({ - ok: false, - status: 400, - statusText: "Bad Request", - json: () => - Promise.resolve({ error: { message: "Invalid access token" } }), - } as Response); - }; - - await assertRejects( - () => getPublishingLimit(userId, accessToken), - Error, - "Failed to get publishing limit" - ); + const containerId = await createThreadsContainer({ + userId, + accessToken, + mediaType: "TEXT", + text: testText, + }); + const mediaId = await publishThreadsContainer( + userId, + accessToken, + typeof containerId === "string" ? containerId : containerId.id + ); + + const result = await getSingleThread( + typeof mediaId === "string" ? mediaId : mediaId.id, + accessToken + ); + assertEquals(typeof result.id, "string"); + assertEquals(result.text, testText); + teardownMockAPI(); + }); + + await t.step("getPublishingLimit", async (t) => { + await t.step("should return rate limit information", async () => { + setupMockAPI(); + const userId = "12345"; + const accessToken = "valid_token"; + + const result = await getPublishingLimit(userId, accessToken); + assertEquals(typeof result.quota_usage, "number"); + assertEquals(typeof result.config.quota_total, "number"); + assertEquals(typeof result.config.quota_duration, "number"); + teardownMockAPI(); + }); + + await t.step("should throw error on failure", async () => { + setupMockAPI(); + const userId = "12345"; + const accessToken = "invalid_token"; + + // Mock the error in the MockThreadsAPI + mockAPI.setErrorMode(true); + + await assertRejects( + () => getPublishingLimit(userId, accessToken), + Error, + "Failed to get publishing limit" + ); + teardownMockAPI(); + }); + }); }); diff --git a/package.json b/package.json index 8ca2e96..9e471c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codybrom/denim", - "version": "1.3.4", + "version": "1.3.5", "description": "Typescript/Deno module to simplify posting to Threads", "main": "mod.ts", "directories": { diff --git a/types.ts b/types.ts new file mode 100644 index 0000000..75d73ef --- /dev/null +++ b/types.ts @@ -0,0 +1,235 @@ +// types.ts + +/** + * Represents the types of media that can be posted on Threads. + */ +export type MediaType = "TEXT" | "IMAGE" | "VIDEO" | "CAROUSEL"; + +/** + * Represents the options for controlling who can reply to a post. + */ +export type ReplyControl = + | "everyone" + | "accounts_you_follow" + | "mentioned_only"; + +/** + * Represents a request to post content on Threads. + */ +export interface ThreadsPostRequest { + /** The user ID of the Threads account */ + userId: string; + /** The access token for authentication */ + accessToken: string; + /** The type of media being posted */ + mediaType: MediaType; + /** The text content of the post (optional) */ + text?: string; + /** The URL of the image to be posted (optional, for IMAGE type) */ + imageUrl?: string; + /** The URL of the video to be posted (optional, for VIDEO type) */ + videoUrl?: string; + /** The accessibility text for the image or video (optional) */ + altText?: string; + /** The URL to be attached as a link to the post (optional, for text posts only) */ + linkAttachment?: string; + /** List of country codes where the post should be visible (optional - requires special API access) */ + allowlistedCountryCodes?: string[]; + /** Controls who can reply to the post (optional) */ + replyControl?: ReplyControl; + /** Array of carousel item IDs (required for CAROUSEL type, not applicable for other types) */ + children?: string[]; + /** Whether to return the permalink of the post (optional, default: false) */ + getPermalink?: boolean; +} + +/** + * Represents a single Threads media object. + */ +export interface ThreadsPost { + /** Unique identifier for the media object */ + id: string; + /** Type of product where the media is published (e.g., "THREADS") */ + media_product_type: string; + /** Type of media (e.g., "TEXT", "IMAGE", "VIDEO", "CAROUSEL") */ + media_type: MediaType; + /** URL of the media content (if applicable) */ + media_url?: string; + /** Permanent link to the post */ + permalink?: string; + /** Information about the owner of the post */ + owner: { id: string }; + /** Username of the account that created the post */ + username: string; + /** Text content of the post */ + text?: string; + /** Timestamp of when the post was created (ISO 8601 format) */ + timestamp: string; + /** Short code identifier for the media */ + shortcode: string; + /** URL of the thumbnail image (for video posts) */ + thumbnail_url?: string; + /** List of child posts (for carousel posts) */ + children?: ThreadsPost[]; + /** Indicates if the post is a quote of another post */ + is_quote_post: boolean; + /** Accessibility text for the image or video */ + altText?: string; + /** URL of the attached link */ + linkAttachmentUrl?: string; + /** Indicates if the post has replies */ + hasReplies: boolean; + /** Indicates if the post is a reply to another post */ + isReply: boolean; + /** Indicates if the reply is owned by the current user */ + isReplyOwnedByMe: boolean; + /** Information about the root post (for replies) */ + rootPost?: { id: string }; + /** Information about the post being replied to */ + repliedTo?: { id: string }; + /** Visibility status of the post */ + hideStatus?: "VISIBLE" | "HIDDEN"; + /** Controls who can reply to the post */ + replyAudience?: ReplyControl; +} + +/** + * Represents the response structure when retrieving a list of Threads. + */ +export interface ThreadsListResponse { + /** Array of ThreadsPost representing the retrieved posts */ + data: ThreadsPost[]; + /** Pagination information */ + paging?: { + /** Cursors for navigating through pages of results */ + cursors: { + /** Cursor for the previous page */ + before: string; + /** Cursor for the next page */ + after: string; + }; + }; +} + +/** + * Represents the publishing limit information for a user. + */ +export interface PublishingLimit { + /** Current usage count towards the quota */ + quota_usage: number; + /** Configuration for the publishing limit */ + config: { + /** Total allowed quota */ + quota_total: number; + /** Duration of the quota period in seconds */ + quota_duration: number; + }; +} + +/** + * Represents a Threads media container. + */ +export interface ThreadsContainer { + /** Unique identifier for the container */ + id: string; + /** Permanent link to the container */ + permalink: string; + /** Status of the container */ + status: "FINISHED" | "FAILED"; + /** Error message if the container failed */ + errorMessage?: string; +} + +/** + * Represents a Threads user profile. + */ +export interface ThreadsProfile { + /** Unique identifier for the user */ + id: string; + /** Username of the account */ + username: string; + /** Display name of the user */ + name: string; + /** URL of the user's profile picture */ + threadsProfilePictureUrl: string; + /** Biography text of the user */ + threadsBiography: string; +} + +/** + * Represents the mock API for Threads operations. + */ +export interface MockThreadsAPI { + /** + * Creates a Threads media container. + * @param request The request object containing post details + * @returns A promise that resolves to either a string ID or an object with ID and permalink + */ + createThreadsContainer( + request: ThreadsPostRequest + ): Promise; + + /** + * Publishes a Threads media container. + * @param userId The user ID + * @param accessToken The access token + * @param containerId The ID of the container to publish + * @param getPermalink Whether to return the permalink + * @returns A promise that resolves to either a string ID or an object with ID and permalink + */ + publishThreadsContainer( + userId: string, + accessToken: string, + containerId: string, + getPermalink?: boolean + ): Promise; + + /** + * Creates a carousel item for a Threads post. + * @param request The request object containing carousel item details + * @returns A promise that resolves to either a string ID or an object with ID + */ + createCarouselItem( + request: Omit & { + mediaType: "IMAGE" | "VIDEO"; + } + ): Promise; + + /** + * Retrieves the publishing limit for a user. + * @param userId The user ID + * @param accessToken The access token + * @returns A promise that resolves to the publishing limit information + */ + getPublishingLimit( + userId: string, + accessToken: string + ): Promise; + + /** + * Retrieves a list of Threads posts for a user. + * @param userId The user ID + * @param accessToken The access token + * @param options Optional parameters for pagination and date range + * @returns A promise that resolves to the Threads list response + */ + getThreadsList( + userId: string, + accessToken: string, + options?: { + since?: string; + until?: string; + limit?: number; + after?: string; + before?: string; + } + ): Promise; + + /** + * Retrieves a single Thread post. + * @param mediaId The ID of the media to retrieve + * @param accessToken The access token + * @returns A promise that resolves to the Thread post + */ + getSingleThread(mediaId: string, accessToken: string): Promise; +}