@codybrom/denim 1.3.6 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.github/workflows/publish.yml +17 -7
  2. package/.vscode/settings.json +34 -9
  3. package/CHANGELOG.md +137 -0
  4. package/deno.json +22 -8
  5. package/deno.lock +17 -59
  6. package/examples/edge-function.ts +171 -177
  7. package/mod.ts +138 -635
  8. package/mod_test.ts +1287 -431
  9. package/package.json +22 -22
  10. package/readme.md +155 -191
  11. package/src/api/createCarouselItem.ts +86 -0
  12. package/src/api/createThreadsContainer.ts +122 -0
  13. package/src/api/debugToken.ts +35 -0
  14. package/src/api/deleteThread.ts +36 -0
  15. package/src/api/exchangeCodeForToken.ts +50 -0
  16. package/src/api/exchangeToken.ts +36 -0
  17. package/src/api/getAppAccessToken.ts +35 -0
  18. package/src/api/getConversation.ts +51 -0
  19. package/src/api/getGhostPosts.ts +50 -0
  20. package/src/api/getLocation.ts +38 -0
  21. package/src/api/getMediaInsights.ts +39 -0
  22. package/src/api/getMentions.ts +57 -0
  23. package/src/api/getOEmbed.ts +41 -0
  24. package/src/api/getProfile.ts +46 -0
  25. package/src/api/getProfilePosts.ts +53 -0
  26. package/src/api/getPublishingLimit.ts +59 -0
  27. package/src/api/getReplies.ts +51 -0
  28. package/src/api/getSingleThread.ts +37 -0
  29. package/src/api/getThreadsList.ts +49 -0
  30. package/src/api/getUserInsights.ts +54 -0
  31. package/src/api/getUserReplies.ts +54 -0
  32. package/src/api/lookupProfile.ts +53 -0
  33. package/src/api/manageReply.ts +41 -0
  34. package/src/api/publishThreadsContainer.ts +107 -0
  35. package/src/api/refreshToken.ts +33 -0
  36. package/src/api/repost.ts +38 -0
  37. package/src/api/searchKeyword.ts +86 -0
  38. package/src/api/searchLocations.ts +46 -0
  39. package/src/constants.ts +80 -0
  40. package/src/types.ts +925 -0
  41. package/src/utils/checkContainerStatus.ts +39 -0
  42. package/src/utils/getAPI.ts +13 -0
  43. package/src/utils/mock_threads_api.ts +582 -0
  44. package/src/utils/validateRequest.ts +166 -0
  45. package/mock_threads_api.ts +0 -174
  46. package/types.ts +0 -235
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
- "name": "@codybrom/denim",
3
- "version": "1.3.6",
4
- "description": "Typescript/Deno module to simplify posting to Threads",
5
- "main": "mod.ts",
6
- "directories": {
7
- "example": "examples"
8
- },
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/codybrom/denim.git"
12
- },
13
- "keywords": [
14
- "threads",
15
- "threads-api"
16
- ],
17
- "author": "Cody Bromley",
18
- "license": "MIT",
19
- "bugs": {
20
- "url": "https://github.com/codybrom/denim/issues"
21
- },
22
- "homepage": "https://github.com/codybrom/denim#readme"
23
- }
2
+ "name": "@codybrom/denim",
3
+ "version": "2.0.1",
4
+ "description": "A Deno/TypeScript library for the Threads API",
5
+ "main": "mod.ts",
6
+ "directories": {
7
+ "example": "examples"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/codybrom/denim.git"
12
+ },
13
+ "keywords": [
14
+ "threads",
15
+ "threads-api"
16
+ ],
17
+ "author": "Cody Bromley",
18
+ "license": "MIT",
19
+ "bugs": {
20
+ "url": "https://github.com/codybrom/denim/issues"
21
+ },
22
+ "homepage": "https://github.com/codybrom/denim#readme"
23
+ }
package/readme.md CHANGED
@@ -1,251 +1,215 @@
1
1
  # Denim
2
2
 
3
- [![JSR](https://jsr.io/badges/@codybrom/denim)](https://jsr.io/@codybrom/denim) [![JSR Score](https://jsr.io/badges/@codybrom/denim/score)](https://jsr.io/@codybrom/denim)
3
+ [![JSR](https://jsr.io/badges/@codybrom/denim)](https://jsr.io/@codybrom/denim)
4
+ [![JSR Score](https://jsr.io/badges/@codybrom/denim/score)](https://jsr.io/@codybrom/denim)
4
5
 
5
- **Denim** is a Deno module that provides a simple interface for posting single Threads posts using text, images, or videos.
6
+ A Deno/TypeScript wrapper for the
7
+ [Threads API](https://developers.facebook.com/docs/threads). Covers posting,
8
+ retrieval, replies, profiles, insights, search, locations, tokens, and oEmbed.
6
9
 
7
- ## Features
10
+ You'll need a Threads app with an access token from
11
+ [Meta's developer portal](https://developers.facebook.com/apps/). See the
12
+ [Threads API docs](https://developers.facebook.com/docs/threads/get-started) for
13
+ setup.
8
14
 
9
- - Create and publish posts on Threads with an easy-use-API
10
- - Supports text-only, image, video, and carousel posts
11
- - Add alt text to image and video posts
12
- - Attach links to text posts
13
- - Geo-gate content to specific countries
14
- - Control who can reply to posts
15
- - Retrieve publishing rate limit information
16
- - Ready to deploy as an edge function
15
+ ```bash
16
+ deno add @codybrom/denim
17
+ ```
17
18
 
18
- ## Installation
19
+ ## Publishing
19
20
 
20
- ### Using with Deno
21
+ Threads publishing is two steps: create a container, then publish it.
21
22
 
22
- To add Denim to your Deno project, you can use the following command:
23
+ ```typescript
24
+ import {
25
+ createThreadsContainer,
26
+ publishThreadsContainer,
27
+ } from "@codybrom/denim";
28
+
29
+ const containerId = await createThreadsContainer({
30
+ userId: "YOUR_USER_ID",
31
+ accessToken: "YOUR_ACCESS_TOKEN",
32
+ mediaType: "TEXT",
33
+ text: "Hello from Denim!",
34
+ });
23
35
 
24
- ```bash
25
- deno add @codybrom/denim
36
+ await publishThreadsContainer("YOUR_USER_ID", "YOUR_ACCESS_TOKEN", containerId);
26
37
  ```
27
38
 
28
- This will add the latest version of Denim to your project's dependencies.
39
+ `createThreadsContainer(request)` takes a `ThreadsPostRequest` and returns the
40
+ container ID string. The request requires `userId`, `accessToken`, `mediaType`,
41
+ and usually `text`. Optional fields control the post type:
29
42
 
30
- ## Usage
43
+ | Field | Type | Purpose |
44
+ | ------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------- |
45
+ | `imageUrl` | `string` | Image URL (for `IMAGE` or `CAROUSEL` items) |
46
+ | `videoUrl` | `string` | Video URL (for `VIDEO` or `CAROUSEL` items) |
47
+ | `altText` | `string` | Alt text for images and videos |
48
+ | `linkAttachment` | `string` | URL to attach to a `TEXT` post |
49
+ | `replyControl` | `string` | `"everyone"`, `"accounts_you_follow"`, `"mentioned_only"`, `"parent_post_author_only"`, or `"followers_only"` |
50
+ | `allowlistedCountryCodes` | `string[]` | ISO country codes to restrict post visibility |
51
+ | `replyToId` | `string` | Post ID to reply to |
52
+ | `quotePostId` | `string` | Post ID to quote |
53
+ | `pollAttachment` | `object` | `{ option_a, option_b, option_c?, option_d? }` |
54
+ | `topicTag` | `string` | Topic tag for the post |
55
+ | `isGhostPost` | `boolean` | Make a ghost post (text only, expires in 24h) |
56
+ | `isSpoilerMedia` | `boolean` | Hide media behind a spoiler overlay |
57
+ | `textEntities` | `array` | Text spoiler ranges: `[{ entity_type, offset, length }]` |
58
+ | `textAttachment` | `object` | Long-form text: `{ plaintext, link_attachment_url? }` |
59
+ | `gifAttachment` | `object` | GIF: `{ gif_id, provider }` |
60
+ | `locationId` | `string` | Location ID from `searchLocations` |
61
+ | `children` | `string[]` | Carousel item IDs from `createCarouselItem` |
62
+ | `autoPublishText` | `boolean` | Skip the publish step for text posts |
31
63
 
32
- To import straight from JSR:
64
+ `publishThreadsContainer(userId, accessToken, containerId, getPermalink?)`
65
+ publishes a container. Pass `true` for `getPermalink` to get `{ id, permalink }`
66
+ instead of just the ID string.
33
67
 
34
- ```typescript
35
- import { ThreadsPostRequest, createThreadsContainer, publishThreadsContainer } from 'jsr:@codybrom/denim';
36
- ```
68
+ `createCarouselItem(request)` creates individual items for a carousel post.
69
+ Takes the same request shape but `mediaType` must be `"IMAGE"` or `"VIDEO"`.
37
70
 
38
- ### Basic Usage
71
+ `repost(mediaId, accessToken)` reposts an existing thread. Returns `{ id }`.
39
72
 
40
- ```typescript
41
- import { createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim";
73
+ `deleteThread(mediaId, accessToken)` deletes a thread. Returns
74
+ `{ success: boolean, deleted_id?: string }`.
42
75
 
43
- const request: ThreadsPostRequest = {
44
- userId: "YOUR_USER_ID",
45
- accessToken: "YOUR_ACCESS_TOKEN",
46
- mediaType: "TEXT",
47
- text: "Check out Denim on GitHub!",
48
- linkAttachment: "https://github.com/codybrom/denim",
49
- replyControl: "everyone",
50
- };
76
+ ## Retrieval
51
77
 
52
- // Create a container
53
- const containerId = await createThreadsContainer(request);
78
+ All retrieval functions accept an optional `fields` string array to request
79
+ specific fields, and an optional `PaginationOptions` object
80
+ (`{ since?, until?, limit?, before?, after? }`).
54
81
 
55
- // Publish the container
56
- const publishedId = await publishThreadsContainer(request.userId, request.accessToken, containerId);
82
+ `getThreadsList(userId, accessToken, options?, fields?)` returns a user's
83
+ threads as `{ data: ThreadsPost[], paging }`.
57
84
 
58
- console.log(`Post published with ID: ${publishedId}`);
59
- ```
85
+ `getSingleThread(mediaId, accessToken, fields?)` returns a single `ThreadsPost`.
60
86
 
61
- #### Retrieving Publishing Rate Limit
87
+ `getGhostPosts(userId, accessToken, options?, fields?)` returns a user's ghost
88
+ posts.
62
89
 
63
- ```typescript
64
- import { getPublishingLimit } from "jsr:@codybrom/denim";
65
-
66
- const userId = "YOUR_USER_ID";
67
- const accessToken = "YOUR_ACCESS_TOKEN";
68
-
69
- try {
70
- const rateLimit = await getPublishingLimit(userId, accessToken);
71
- console.log("Current usage:", rateLimit.quota_usage);
72
- console.log("Total quota:", rateLimit.config.quota_total);
73
- console.log("Quota duration (seconds):", rateLimit.config.quota_duration);
74
- } catch (error) {
75
- console.error("Failed to retrieve rate limit information:", error);
76
- }
77
- ```
90
+ ## Profiles
78
91
 
79
- ### Posting Different Media Types
92
+ `getProfile(userId, accessToken, fields?)` returns the authenticated user's
93
+ `ThreadsProfile` (username, name, bio, profile picture, verification status).
80
94
 
81
- #### Text-only Post
95
+ `lookupProfile(accessToken, username, fields?)` looks up any public profile by
96
+ username. Returns a `PublicProfile` with follower counts and engagement stats.
97
+ Requires `threads_profile_discovery` permission.
82
98
 
83
- ```typescript
84
- const textRequest: ThreadsPostRequest = {
85
- userId: "YOUR_USER_ID",
86
- accessToken: "YOUR_ACCESS_TOKEN",
87
- mediaType: "TEXT",
88
- text: "This is a text-only post on Threads!",
89
- };
90
- ```
99
+ `getProfilePosts(accessToken, username, options?, fields?)` returns a public
100
+ profile's posts.
91
101
 
92
- #### Text Post with Link Attachment
102
+ ## Replies
93
103
 
94
- ```typescript
95
- const textRequest: ThreadsPostRequest = {
96
- userId: "YOUR_USER_ID",
97
- accessToken: "YOUR_ACCESS_TOKEN",
98
- mediaType: "TEXT",
99
- text: "This is a post with an attached link on Threads!",
100
- linkAttachment: "https://example.com",
101
- };
102
- ```
104
+ `getReplies(mediaId, accessToken, options?, fields?, reverse?)` returns direct
105
+ replies to a post. Pass `reverse: false` for chronological order (default is
106
+ reverse chronological).
103
107
 
104
- #### Image Post with Alt Text
108
+ `getConversation(mediaId, accessToken, options?, fields?, reverse?)` returns the
109
+ full conversation thread (replies and nested replies). Pass `reverse: false` for
110
+ chronological order.
105
111
 
106
- ```typescript
107
- const imageRequest: ThreadsPostRequest = {
108
- userId: "YOUR_USER_ID",
109
- accessToken: "YOUR_ACCESS_TOKEN",
110
- mediaType: "IMAGE",
111
- text: "Check out this image!",
112
- imageUrl: "https://example.com/image.jpg",
113
- altText: "A beautiful sunset over the ocean",
114
- };
115
- ```
112
+ `getUserReplies(userId, accessToken, options?, fields?)` returns all replies
113
+ made by a user.
116
114
 
117
- #### Video Post
115
+ `manageReply(replyId, accessToken, hide)` hides or unhides a reply. Pass `true`
116
+ to hide, `false` to unhide.
118
117
 
119
- ```typescript
120
- const videoRequest: ThreadsPostRequest = {
121
- userId: "YOUR_USER_ID",
122
- accessToken: "YOUR_ACCESS_TOKEN",
123
- mediaType: "VIDEO",
124
- text: "Watch this video!",
125
- videoUrl: "https://example.com/video.mp4",
126
- };
127
- ```
118
+ ## Insights
128
119
 
129
- #### Video Post with Alt Text, Reply Control and Geo-gating* (requires special account permission)
120
+ `getMediaInsights(mediaId, accessToken, metrics)` returns metrics for a post.
121
+ Pass metric names as a string array: `"views"`, `"likes"`, `"replies"`,
122
+ `"reposts"`, `"quotes"`, `"shares"`.
130
123
 
131
124
  ```typescript
132
- const videoRequest: ThreadsPostRequest = {
133
- userId: "YOUR_USER_ID",
134
- accessToken: "YOUR_ACCESS_TOKEN",
135
- mediaType: "VIDEO",
136
- text: "Watch this video!",
137
- videoUrl: "https://example.com/video.mp4",
138
- altText: "A tutorial on how to make a chocolate cake",
139
- allowlistedCountryCodes: ["US", "GB"],
140
- replyControl: "mentioned_only",
141
- };
125
+ const insights = await getMediaInsights(postId, token, ["views", "likes"]);
126
+ // insights.data[0].values[0].value => 42
142
127
  ```
143
128
 
144
- #### Carousel Post
129
+ `getUserInsights(userId, accessToken, metrics, options?)` returns user-level
130
+ metrics. Accepts an options object with `since`/`until` timestamps and
131
+ `breakdown` for demographics.
145
132
 
146
- ```typescript
147
- import { createCarouselItem, createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim";
148
-
149
- // First, create carousel items
150
- const item1Id = await createCarouselItem({
151
- userId: "YOUR_USER_ID",
152
- accessToken: "YOUR_ACCESS_TOKEN",
153
- mediaType: "IMAGE",
154
- imageUrl: "https://example.com/image1.jpg",
155
- altText: "First image in the carousel",
156
- });
133
+ ## Search & Locations
157
134
 
158
- const item2Id = await createCarouselItem({
159
- userId: "YOUR_USER_ID",
160
- accessToken: "YOUR_ACCESS_TOKEN",
161
- mediaType: "VIDEO",
162
- videoUrl: "https://example.com/video.mp4",
163
- altText: "Video in the carousel",
164
- });
135
+ `searchKeyword(accessToken, options, fields?)` searches posts by keyword or
136
+ topic tag. Options:
137
+ `{ q, search_type?, search_mode?, media_type?, author_username?,
138
+ ...pagination }`.
139
+ Requires `threads_keyword_search` permission for searching beyond your own
140
+ posts.
165
141
 
166
- // Then, create the carousel post
167
- const carouselRequest: ThreadsPostRequest = {
168
- userId: "YOUR_USER_ID",
169
- accessToken: "YOUR_ACCESS_TOKEN",
170
- mediaType: "CAROUSEL",
171
- text: "Check out this carousel post!",
172
- children: [item1Id, item2Id],
173
- replyControl: "everyone",
174
- };
142
+ `searchLocations(accessToken, options, fields?)` searches for locations by name
143
+ or coordinates. Options: `{ query?, latitude?, longitude? }`. Returns location
144
+ objects with IDs you can pass to `createThreadsContainer` as `locationId`.
175
145
 
176
- const containerId = await createThreadsContainer(carouselRequest);
177
- const publishedId = await publishThreadsContainer(carouselRequest.userId, carouselRequest.accessToken, containerId);
146
+ `getLocation(locationId, accessToken, fields?)` returns details for a location
147
+ (name, address, city, country, coordinates).
178
148
 
179
- console.log(`Carousel post published with ID: ${publishedId}`);
180
- ```
149
+ ## Tokens
181
150
 
182
- ## Deploying as an Edge Function
151
+ `exchangeCodeForToken(clientId, clientSecret, code, redirectUri)` exchanges an
152
+ OAuth authorization code for a short-lived access token. Returns
153
+ `{ access_token, user_id }`.
183
154
 
184
- Denim can be easily deployed as an edge function. An example implementation is provided in `examples/edge-function.ts`.
155
+ `getAppAccessToken(clientId, clientSecret)` gets an app-level access token via
156
+ client credentials. Returns `{ access_token, token_type }`.
185
157
 
186
- To deploy:
158
+ `exchangeToken(clientSecret, accessToken)` exchanges a short-lived token for a
159
+ long-lived one (60 days). Returns `{ access_token, token_type, expires_in }`.
187
160
 
188
- 1. Copy the `examples/edge-function.ts` file to your project.
189
- 2. Deploy this file to your serverless platform that supports Deno.
190
- 3. Send POST requests to your function's URI with the appropriate JSON body.
161
+ `refreshToken(accessToken)` refreshes a long-lived token before it expires. Same
162
+ return shape.
191
163
 
192
- ### Example cURL Commands
164
+ `debugToken(accessToken, inputToken)` returns metadata about a token: app ID,
165
+ scopes, expiry, validity.
193
166
 
194
- ```bash
195
- # Post a text-only Thread
196
- curl -X POST <YOUR_FUNCTION_URI> \
197
- -H "Content-Type: application/json" \
198
- -H "Authorization: Bearer YOUR_AUTH_KEY" \
199
- -d '{
200
- "userId": "YOUR_USER_ID",
201
- "accessToken": "YOUR_ACCESS_TOKEN",
202
- "mediaType": "TEXT",
203
- "text": "Hello from Denim!"
204
- }'
205
-
206
- # Post an image Thread
207
- curl -X POST <YOUR_FUNCTION_URI> \
208
- -H "Content-Type: application/json" \
209
- -H "Authorization: Bearer YOUR_AUTH_KEY" \
210
- -d '{
211
- "userId": "YOUR_USER_ID",
212
- "accessToken": "YOUR_ACCESS_TOKEN",
213
- "mediaType": "IMAGE",
214
- "text": "Check out this image I posted with Denim!",
215
- "imageUrl": "https://example.com/image.jpg"
216
- }'
217
-
218
- # Post a video Thread
219
- curl -X POST <YOUR_FUNCTION_URI> \
220
- -H "Content-Type: application/json" \
221
- -H "Authorization: Bearer YOUR_AUTH_KEY" \
222
- -d '{
223
- "userId": "YOUR_USER_ID",
224
- "accessToken": "YOUR_ACCESS_TOKEN",
225
- "mediaType": "VIDEO",
226
- "text": "Watch this video I posted with Denim!",
227
- "videoUrl": "https://example.com/video.mp4"
228
- }'
229
- ```
167
+ ## Other
168
+
169
+ `getPublishingLimit(userId, accessToken, fields?)` returns rate limit info: post
170
+ quota, reply quota, and remaining usage.
230
171
 
231
- Note: Replace with your actual authorization headers if your edge function requires them (or remove them).
172
+ `getMentions(userId, accessToken, options?, fields?)` returns posts that mention
173
+ the authenticated user.
232
174
 
233
- ## Security Note
175
+ `getOEmbed(accessToken, url, maxWidth?)` returns embeddable HTML for a Threads
176
+ post URL. Returns `{ html, provider_name, type, version, width }`.
234
177
 
235
- Ensure that your function is deployed with appropriate access controls and authentication mechanisms to protect sensitive data like access tokens.
178
+ ## Utilities
179
+
180
+ `validateRequest(request)` checks a `ThreadsPostRequest` for invalid
181
+ combinations (wrong media type for polls, too many text entities, etc.) and
182
+ throws descriptive errors. Called automatically by `createThreadsContainer`.
183
+
184
+ `checkContainerStatus(containerId, accessToken)` polls a container's publishing
185
+ status. Returns `{ status, error_message? }` where `status` is `"FINISHED"`,
186
+ `"IN_PROGRESS"`, `"EXPIRED"`, `"ERROR"`, or `"PUBLISHED"`.
236
187
 
237
188
  ## Testing
238
189
 
239
- To run the tests:
190
+ Denim ships a `MockThreadsAPI` interface for testing without network requests.
191
+ Set an implementation on `globalThis.threadsAPI` and all functions route through
192
+ it instead of calling the Threads API:
240
193
 
241
- ```bash
242
- deno test mod_test.ts
194
+ ```typescript
195
+ import { MockThreadsAPIImpl } from "@codybrom/denim";
196
+
197
+ const mock = new MockThreadsAPIImpl();
198
+ (globalThis as any).threadsAPI = mock;
199
+
200
+ // Now all denim functions use the mock
201
+ const container = await createThreadsContainer({ ... });
202
+
203
+ // Enable error mode to test failure paths
204
+ mock.setErrorMode(true);
243
205
  ```
244
206
 
245
- ## Contributing
207
+ See `mod_test.ts` for more examples.
246
208
 
247
- Contributions are welcome! Please feel free to submit a Pull Request.
209
+ ```bash
210
+ deno task test
211
+ ```
248
212
 
249
213
  ## License
250
214
 
251
- [MIT License](LICENSE)
215
+ [MIT](LICENSE)
@@ -0,0 +1,86 @@
1
+ import { THREADS_API_BASE_URL } from "../constants.ts";
2
+ import type { ThreadsPostRequest } from "../types.ts";
3
+ import { getAPI } from "../utils/getAPI.ts";
4
+ /**
5
+ * Creates a carousel item for a Threads carousel post.
6
+ *
7
+ * This function sends a request to the Threads API to create a single item
8
+ * that will be part of a carousel post. It can be used for both image and
9
+ * video items.
10
+ *
11
+ * @param request - The request object containing carousel item details
12
+ * @param request.userId - The user ID of the Threads account
13
+ * @param request.accessToken - The access token for authentication
14
+ * @param request.mediaType - The type of media for this carousel item ('IMAGE' or 'VIDEO')
15
+ * @param request.imageUrl - The URL of the image (required if mediaType is 'IMAGE')
16
+ * @param request.videoUrl - The URL of the video (required if mediaType is 'VIDEO')
17
+ * @param request.altText - Optional accessibility text for the image or video
18
+ * @returns A Promise that resolves to the carousel item ID
19
+ * @throws Will throw an error if the API request fails or returns an invalid response
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * const itemRequest = {
24
+ * userId: "123456",
25
+ * accessToken: "your_access_token",
26
+ * mediaType: "IMAGE" as const,
27
+ * imageUrl: "https://example.com/image.jpg",
28
+ * altText: "A beautiful landscape"
29
+ * };
30
+ * const itemId = await createCarouselItem(itemRequest);
31
+ * ```
32
+ */
33
+ export async function createCarouselItem(
34
+ request: Omit<ThreadsPostRequest, "mediaType"> & {
35
+ mediaType: "IMAGE" | "VIDEO";
36
+ },
37
+ ): Promise<string> {
38
+ const api = getAPI();
39
+ if (api) {
40
+ return api.createCarouselItem(request);
41
+ }
42
+ if (request.mediaType !== "IMAGE" && request.mediaType !== "VIDEO") {
43
+ throw new Error("Carousel items must be either IMAGE or VIDEO type");
44
+ }
45
+
46
+ if (request.mediaType === "IMAGE" && !request.imageUrl) {
47
+ throw new Error("imageUrl is required for IMAGE type carousel items");
48
+ }
49
+
50
+ if (request.mediaType === "VIDEO" && !request.videoUrl) {
51
+ throw new Error("videoUrl is required for VIDEO type carousel items");
52
+ }
53
+
54
+ const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
55
+ const body = new URLSearchParams({
56
+ access_token: request.accessToken,
57
+ media_type: request.mediaType,
58
+ is_carousel_item: "true",
59
+ });
60
+ if (request.imageUrl) body.append("image_url", request.imageUrl);
61
+ if (request.videoUrl) body.append("video_url", request.videoUrl);
62
+ if (request.altText) body.append("alt_text", request.altText);
63
+
64
+ const response = await fetch(url, {
65
+ method: "POST",
66
+ body: body,
67
+ headers: {
68
+ "Content-Type": "application/x-www-form-urlencoded",
69
+ },
70
+ });
71
+
72
+ const responseText = await response.text();
73
+
74
+ if (!response.ok) {
75
+ throw new Error(
76
+ `Failed to create carousel item (${response.status}): ${responseText}`,
77
+ );
78
+ }
79
+
80
+ try {
81
+ const data = JSON.parse(responseText);
82
+ return data.id;
83
+ } catch (_error) {
84
+ throw new Error(`Invalid response from Threads API: ${responseText}`);
85
+ }
86
+ }
@@ -0,0 +1,122 @@
1
+ import { THREADS_API_BASE_URL } from "../constants.ts";
2
+ import type { ThreadsPostRequest } from "../types.ts";
3
+ import { getAPI } from "../utils/getAPI.ts";
4
+ import { validateRequest } from "../utils/validateRequest.ts";
5
+ /**
6
+ * Creates a Threads media container.
7
+ *
8
+ * @param request - The ThreadsPostRequest object containing post details
9
+ * @returns A Promise that resolves to the container ID
10
+ * @throws Will throw an error if the API request fails
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * const request: ThreadsPostRequest = {
15
+ * userId: "123456",
16
+ * accessToken: "your_access_token",
17
+ * mediaType: "VIDEO",
18
+ * text: "Check out this video!",
19
+ * videoUrl: "https://example.com/video.mp4",
20
+ * altText: "A cool video"
21
+ * };
22
+ * const containerId = await createThreadsContainer(request);
23
+ * ```
24
+ */
25
+ export async function createThreadsContainer(
26
+ request: ThreadsPostRequest,
27
+ ): Promise<string> {
28
+ const api = getAPI();
29
+ if (api) {
30
+ return api.createThreadsContainer(request);
31
+ }
32
+ try {
33
+ // Input validation
34
+ await validateRequest(request);
35
+
36
+ const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
37
+ const body = new URLSearchParams({
38
+ access_token: request.accessToken,
39
+ media_type: request.mediaType,
40
+ });
41
+
42
+ // Add common optional parameters
43
+ if (request.text) body.append("text", request.text);
44
+ if (request.altText) body.append("alt_text", request.altText);
45
+ if (request.replyControl) {
46
+ body.append("reply_control", request.replyControl);
47
+ }
48
+ if (request.allowlistedCountryCodes) {
49
+ body.append(
50
+ "allowlisted_country_codes",
51
+ JSON.stringify(request.allowlistedCountryCodes),
52
+ );
53
+ }
54
+
55
+ // New optional parameters
56
+ if (request.replyToId) body.append("reply_to_id", request.replyToId);
57
+ if (request.quotePostId) {
58
+ body.append("quote_post_id", request.quotePostId);
59
+ }
60
+ if (request.topicTag) body.append("topic_tag", request.topicTag);
61
+ if (request.isGhostPost !== undefined) {
62
+ body.append("is_ghost_post", String(request.isGhostPost));
63
+ }
64
+ if (request.locationId) body.append("location_id", request.locationId);
65
+ if (request.autoPublishText !== undefined) {
66
+ body.append("auto_publish_text", String(request.autoPublishText));
67
+ }
68
+ if (request.isSpoilerMedia !== undefined) {
69
+ body.append("is_spoiler_media", String(request.isSpoilerMedia));
70
+ }
71
+
72
+ // JSON-serialized parameters
73
+ if (request.pollAttachment) {
74
+ body.append("poll_attachment", JSON.stringify(request.pollAttachment));
75
+ }
76
+ if (request.textEntities) {
77
+ body.append("text_entities", JSON.stringify(request.textEntities));
78
+ }
79
+ if (request.textAttachment) {
80
+ body.append("text_attachment", JSON.stringify(request.textAttachment));
81
+ }
82
+ if (request.gifAttachment) {
83
+ body.append("gif_attachment", JSON.stringify(request.gifAttachment));
84
+ }
85
+
86
+ // Handle media type specific parameters
87
+ if (request.mediaType === "VIDEO" && request.videoUrl) {
88
+ body.append("video_url", request.videoUrl);
89
+ } else if (request.mediaType === "IMAGE" && request.imageUrl) {
90
+ body.append("image_url", request.imageUrl);
91
+ } else if (request.mediaType === "TEXT" && request.linkAttachment) {
92
+ body.append("link_attachment", request.linkAttachment);
93
+ } else if (request.mediaType === "CAROUSEL" && request.children) {
94
+ body.append("children", request.children.join(","));
95
+ }
96
+
97
+ const response = await fetch(url, {
98
+ method: "POST",
99
+ body: body,
100
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
101
+ });
102
+
103
+ const responseText = await response.text();
104
+
105
+ if (!response.ok) {
106
+ throw new Error(
107
+ `Failed to create container (${response.status} ${response.statusText}): ${responseText}`,
108
+ );
109
+ }
110
+
111
+ const data = JSON.parse(responseText);
112
+
113
+ return data.id;
114
+ } catch (error) {
115
+ // Rethrow the error if it's already an Error instance
116
+ if (error instanceof Error) {
117
+ throw error;
118
+ }
119
+ // Otherwise, wrap it in a new Error
120
+ throw new Error(`Failed to create Threads container: ${String(error)}`);
121
+ }
122
+ }