@codybrom/denim 1.2.0 → 1.3.0

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.
package/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codybrom/denim",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A Deno function for posting to Threads.",
5
5
  "entry": "./mod.ts",
6
6
  "exports": {
@@ -5,18 +5,11 @@ import {
5
5
  createThreadsContainer,
6
6
  publishThreadsContainer,
7
7
  createCarouselItem,
8
- checkHealth,
9
8
  getPublishingLimit,
10
- } from "jsr:@codybrom/denim@^1.1.0";
9
+ } from "jsr:@codybrom/denim@1.3.0";
11
10
 
12
11
  async function postToThreads(request: ThreadsPostRequest): Promise<string> {
13
12
  try {
14
- // Check API health before posting
15
- const healthStatus = await checkHealth();
16
- if (healthStatus.status !== "ok") {
17
- throw new Error(`API is not healthy. Status: ${healthStatus.status}`);
18
- }
19
-
20
13
  // Check rate limit
21
14
  const rateLimit = await getPublishingLimit(
22
15
  request.userId,
@@ -49,118 +42,105 @@ async function postToThreads(request: ThreadsPostRequest): Promise<string> {
49
42
 
50
43
  Deno.serve(async (req: Request) => {
51
44
  const url = new URL(req.url);
52
-
53
- // Health check endpoint
54
- if (req.method === "GET" && url.pathname === "/health") {
55
- try {
56
- const healthStatus = await checkHealth();
57
- return new Response(JSON.stringify(healthStatus), {
58
- status: 200,
59
- headers: { "Content-Type": "application/json" },
60
- });
61
- } catch (error) {
62
- return new Response(
63
- JSON.stringify({ status: "error", message: error.message }),
64
- {
65
- status: 500,
66
- headers: { "Content-Type": "application/json" },
45
+ const paths = url.pathname.split("/").filter((segment) => segment !== "");
46
+
47
+ switch (req.method) {
48
+ case "GET": {
49
+ switch (paths[1]) {
50
+ case "rate-limit": {
51
+ const userId = url.searchParams.get("userId");
52
+ const accessToken = url.searchParams.get("accessToken");
53
+ if (!userId || !accessToken) {
54
+ return new Response(
55
+ JSON.stringify({ error: "Missing userId or accessToken" }),
56
+ {
57
+ status: 400,
58
+ headers: { "Content-Type": "application/json" },
59
+ }
60
+ );
61
+ }
62
+ try {
63
+ const rateLimit = await getPublishingLimit(userId, accessToken);
64
+ return new Response(JSON.stringify(rateLimit), {
65
+ status: 200,
66
+ headers: { "Content-Type": "application/json" },
67
+ });
68
+ } catch (error) {
69
+ return new Response(JSON.stringify({ error: error.message }), {
70
+ status: 500,
71
+ headers: { "Content-Type": "application/json" },
72
+ });
73
+ }
67
74
  }
68
- );
69
- }
70
- }
71
-
72
- // Rate limit check endpoint
73
- if (req.method === "GET" && url.pathname === "/rate-limit") {
74
- const userId = url.searchParams.get("userId");
75
- const accessToken = url.searchParams.get("accessToken");
76
-
77
- if (!userId || !accessToken) {
78
- return new Response(
79
- JSON.stringify({ error: "Missing userId or accessToken" }),
80
- {
81
- status: 400,
82
- headers: { "Content-Type": "application/json" },
75
+ default: {
76
+ return new Response("Not Found", { status: 404 });
83
77
  }
84
- );
85
- }
86
-
87
- try {
88
- const rateLimit = await getPublishingLimit(userId, accessToken);
89
- return new Response(JSON.stringify(rateLimit), {
90
- status: 200,
91
- headers: { "Content-Type": "application/json" },
92
- });
93
- } catch (error) {
94
- return new Response(JSON.stringify({ error: error.message }), {
95
- status: 500,
96
- headers: { "Content-Type": "application/json" },
97
- });
78
+ }
98
79
  }
99
- }
100
-
101
- // Main posting endpoint
102
- if (req.method === "POST" && url.pathname === "/post") {
103
- try {
104
- const body = await req.json();
105
-
106
- if (!body.userId || !body.accessToken || !body.mediaType) {
107
- return new Response(
108
- JSON.stringify({ success: false, error: "Missing required fields" }),
109
- {
110
- status: 400,
111
- headers: { "Content-Type": "application/json" },
80
+ case "POST": {
81
+ if (paths[1] === "post") {
82
+ try {
83
+ const body = await req.json();
84
+ if (!body.userId || !body.accessToken || !body.mediaType) {
85
+ return new Response(
86
+ JSON.stringify({
87
+ success: false,
88
+ error: "Missing required fields",
89
+ }),
90
+ {
91
+ status: 400,
92
+ headers: { "Content-Type": "application/json" },
93
+ }
94
+ );
112
95
  }
113
- );
114
- }
115
-
116
- const postRequest: ThreadsPostRequest = {
117
- userId: body.userId,
118
- accessToken: body.accessToken,
119
- mediaType: body.mediaType,
120
- text: body.text,
121
- imageUrl: body.imageUrl,
122
- videoUrl: body.videoUrl,
123
- altText: body.altText,
124
- linkAttachment: body.linkAttachment,
125
- allowlistedCountryCodes: body.allowlistedCountryCodes,
126
- replyControl: body.replyControl,
127
- children: body.children,
128
- };
129
-
130
- if (postRequest.mediaType === "CAROUSEL" && body.carouselItems) {
131
- postRequest.children = [];
132
- for (const item of body.carouselItems) {
133
- const itemId = await createCarouselItem({
134
- userId: postRequest.userId,
135
- accessToken: postRequest.accessToken,
136
- mediaType: item.mediaType,
137
- imageUrl: item.imageUrl,
138
- videoUrl: item.videoUrl,
139
- altText: item.altText,
96
+ const postRequest: ThreadsPostRequest = {
97
+ userId: body.userId,
98
+ accessToken: body.accessToken,
99
+ mediaType: body.mediaType,
100
+ text: body.text,
101
+ imageUrl: body.imageUrl,
102
+ videoUrl: body.videoUrl,
103
+ altText: body.altText,
104
+ linkAttachment: body.linkAttachment,
105
+ allowlistedCountryCodes: body.allowlistedCountryCodes,
106
+ replyControl: body.replyControl,
107
+ children: body.children,
108
+ };
109
+ if (postRequest.mediaType === "CAROUSEL" && body.carouselItems) {
110
+ postRequest.children = [];
111
+ for (const item of body.carouselItems) {
112
+ const itemId = await createCarouselItem({
113
+ userId: postRequest.userId,
114
+ accessToken: postRequest.accessToken,
115
+ mediaType: item.mediaType,
116
+ imageUrl: item.imageUrl,
117
+ videoUrl: item.videoUrl,
118
+ altText: item.altText,
119
+ });
120
+ postRequest.children.push(itemId);
121
+ }
122
+ }
123
+ const publishedId = await postToThreads(postRequest);
124
+ return new Response(JSON.stringify({ success: true, publishedId }), {
125
+ status: 200,
126
+ headers: { "Content-Type": "application/json" },
140
127
  });
141
- postRequest.children.push(itemId);
128
+ } catch (error) {
129
+ console.error("Error processing request:", error);
130
+ return new Response(
131
+ JSON.stringify({ success: false, error: error.message }),
132
+ {
133
+ status: 500,
134
+ headers: { "Content-Type": "application/json" },
135
+ }
136
+ );
142
137
  }
143
138
  }
144
-
145
- const publishedId = await postToThreads(postRequest);
146
-
147
- return new Response(JSON.stringify({ success: true, publishedId }), {
148
- status: 200,
149
- headers: { "Content-Type": "application/json" },
150
- });
151
- } catch (error) {
152
- console.error("Error processing request:", error);
153
- return new Response(
154
- JSON.stringify({ success: false, error: error.message }),
155
- {
156
- status: 500,
157
- headers: { "Content-Type": "application/json" },
158
- }
159
- );
139
+ return new Response("Not Found", { status: 404 });
160
140
  }
141
+ default:
142
+ return new Response("Method Not Allowed", { status: 405 });
161
143
  }
162
-
163
- return new Response("Not Found", { status: 404 });
164
144
  });
165
145
 
166
146
  /*
package/mod.ts CHANGED
@@ -28,7 +28,7 @@ export interface ThreadsPostRequest {
28
28
  altText?: string;
29
29
  /** The URL to be attached as a link to the post (optional, for text posts only) */
30
30
  linkAttachment?: string;
31
- /** List of country codes where the post should be visible (optional) */
31
+ /** List of country codes where the post should be visible (optional - requires special API access) */
32
32
  allowlistedCountryCodes?: string[];
33
33
  /** Controls who can reply to the post (optional) */
34
34
  replyControl?: "everyone" | "accounts_you_follow" | "mentioned_only";
@@ -48,11 +48,10 @@ export interface ThreadsPostRequest {
48
48
  * const request: ThreadsPostRequest = {
49
49
  * userId: "123456",
50
50
  * accessToken: "your_access_token",
51
- * mediaType: "CAROUSEL",
52
- * text: "Check out this carousel!",
53
- * children: ["item1", "item2"],
54
- * allowlistedCountryCodes: ["US", "CA"],
55
- * replyControl: "everyone"
51
+ * mediaType: "VIDEO",
52
+ * text: "Check out this video!",
53
+ * videoUrl: "https://example.com/video.mp4",
54
+ * altText: "A cool video"
56
55
  * };
57
56
  * const containerId = await createThreadsContainer(request);
58
57
  * ```
@@ -60,7 +59,93 @@ export interface ThreadsPostRequest {
60
59
  export async function createThreadsContainer(
61
60
  request: ThreadsPostRequest
62
61
  ): Promise<string> {
63
- // Add input validation
62
+ // Input validation
63
+ validateRequest(request);
64
+
65
+ const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
66
+ const body = new URLSearchParams({
67
+ access_token: request.accessToken,
68
+ media_type: request.mediaType,
69
+ });
70
+
71
+ // Add common optional parameters
72
+ if (request.text) body.append("text", request.text);
73
+ if (request.altText) body.append("alt_text", request.altText);
74
+ if (request.replyControl) body.append("reply_control", request.replyControl);
75
+ if (request.allowlistedCountryCodes) {
76
+ body.append(
77
+ "allowlisted_country_codes",
78
+ request.allowlistedCountryCodes.join(",")
79
+ );
80
+ }
81
+
82
+ // Handle media type specific parameters
83
+ if (request.mediaType === "VIDEO") {
84
+ const videoItemId = await createVideoItemContainer(request);
85
+ body.set("media_type", "CAROUSEL");
86
+ body.append("children", videoItemId);
87
+ } else if (request.mediaType === "IMAGE" && request.imageUrl) {
88
+ body.append("image_url", request.imageUrl);
89
+ } else if (request.mediaType === "TEXT" && request.linkAttachment) {
90
+ body.append("link_attachment", request.linkAttachment);
91
+ } else if (request.mediaType === "CAROUSEL" && request.children) {
92
+ body.append("children", request.children.join(","));
93
+ }
94
+
95
+ console.log(`Sending request to: ${url}`);
96
+ console.log(`Request body: ${body.toString()}`);
97
+
98
+ const response = await fetch(url, {
99
+ method: "POST",
100
+ body: body,
101
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
102
+ });
103
+
104
+ const responseText = await response.text();
105
+ console.log(`Response status: ${response.status} ${response.statusText}`);
106
+ console.log(`Response body: ${responseText}`);
107
+
108
+ if (!response.ok) {
109
+ throw new Error(
110
+ `Failed to create Threads container: ${response.statusText}. Details: ${responseText}`
111
+ );
112
+ }
113
+
114
+ try {
115
+ const data = JSON.parse(responseText);
116
+ return data.id;
117
+ } catch (error) {
118
+ console.error(`Failed to parse response JSON: ${error}`);
119
+ throw new Error(`Invalid response from Threads API: ${responseText}`);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Validates the ThreadsPostRequest object to ensure correct usage of media-specific properties.
125
+ *
126
+ * @param request - The ThreadsPostRequest object to validate
127
+ * @throws Will throw an error if the request contains invalid combinations of media type and properties
128
+ *
129
+ * @example
130
+ * ```typescript
131
+ * const request: ThreadsPostRequest = {
132
+ * userId: "123456",
133
+ * accessToken: "your_access_token",
134
+ * mediaType: "IMAGE",
135
+ * imageUrl: "https://example.com/image.jpg"
136
+ * };
137
+ * validateRequest(request); // This will not throw an error
138
+ *
139
+ * const invalidRequest: ThreadsPostRequest = {
140
+ * userId: "123456",
141
+ * accessToken: "your_access_token",
142
+ * mediaType: "TEXT",
143
+ * imageUrl: "https://example.com/image.jpg"
144
+ * };
145
+ * validateRequest(invalidRequest); // This will throw an error
146
+ * ```
147
+ */
148
+ function validateRequest(request: ThreadsPostRequest): void {
64
149
  if (request.mediaType !== "IMAGE" && request.imageUrl) {
65
150
  throw new Error("imageUrl can only be used with IMAGE media type");
66
151
  }
@@ -79,30 +164,26 @@ export async function createThreadsContainer(
79
164
  ) {
80
165
  throw new Error("CAROUSEL media type requires at least 2 children");
81
166
  }
167
+ }
82
168
 
169
+ /**
170
+ * Creates a video item container for Threads.
171
+ * @param request - The ThreadsPostRequest object containing video post details
172
+ * @returns A Promise that resolves to the video item container ID
173
+ * @throws Will throw an error if the API request fails
174
+ */
175
+ async function createVideoItemContainer(
176
+ request: ThreadsPostRequest
177
+ ): Promise<string> {
83
178
  const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
84
179
  const body = new URLSearchParams({
85
180
  access_token: request.accessToken,
86
- media_type: request.mediaType,
87
- ...(request.text && { text: request.text }),
88
- ...(request.mediaType === "IMAGE" &&
89
- request.imageUrl && { image_url: request.imageUrl }),
90
- ...(request.mediaType === "VIDEO" &&
91
- request.videoUrl && { video_url: request.videoUrl }),
181
+ is_carousel_item: "true",
182
+ media_type: "VIDEO",
183
+ video_url: request.videoUrl!,
92
184
  ...(request.altText && { alt_text: request.altText }),
93
- ...(request.mediaType === "TEXT" &&
94
- request.linkAttachment && { link_attachment: request.linkAttachment }),
95
- ...(request.allowlistedCountryCodes && {
96
- allowlisted_country_codes: request.allowlistedCountryCodes.join(","),
97
- }),
98
- ...(request.replyControl && { reply_control: request.replyControl }),
99
- ...(request.mediaType === "CAROUSEL" &&
100
- request.children && { children: request.children.join(",") }),
101
185
  });
102
186
 
103
- console.log(`Sending request to: ${url}`);
104
- console.log(`Request body: ${body.toString()}`);
105
-
106
187
  const response = await fetch(url, {
107
188
  method: "POST",
108
189
  body: body,
@@ -112,12 +193,10 @@ export async function createThreadsContainer(
112
193
  });
113
194
 
114
195
  const responseText = await response.text();
115
- console.log(`Response status: ${response.status} ${response.statusText}`);
116
- console.log(`Response body: ${responseText}`);
117
196
 
118
197
  if (!response.ok) {
119
198
  throw new Error(
120
- `Failed to create Threads container: ${response.statusText}. Details: ${responseText}`
199
+ `Failed to create video item container: ${response.statusText}. Details: ${responseText}`
121
200
  );
122
201
  }
123
202
 
@@ -156,12 +235,7 @@ export async function createThreadsContainer(
156
235
  * imageUrl: "https://example.com/image.jpg",
157
236
  * altText: "A beautiful landscape"
158
237
  * };
159
- * try {
160
- * const itemId = await createCarouselItem(itemRequest);
161
- * console.log(`Carousel item created with ID: ${itemId}`);
162
- * } catch (error) {
163
- * console.error("Failed to create carousel item:", error);
164
- * }
238
+ * const itemId = await createCarouselItem(itemRequest);
165
239
  * ```
166
240
  */
167
241
  export async function createCarouselItem(
@@ -216,14 +290,36 @@ export async function createCarouselItem(
216
290
  }
217
291
  }
218
292
 
293
+ /**
294
+ * Checks the status of a Threads container.
295
+ * @param containerId - The ID of the container to check
296
+ * @param accessToken - The access token for authentication
297
+ * @returns A Promise that resolves to the container status
298
+ * @throws Will throw an error if the API request fails
299
+ */
300
+ async function checkContainerStatus(
301
+ containerId: string,
302
+ accessToken: string
303
+ ): Promise<string> {
304
+ const url = `${THREADS_API_BASE_URL}/${containerId}?fields=status,error_message&access_token=${accessToken}`;
305
+
306
+ const response = await fetch(url);
307
+ if (!response.ok) {
308
+ throw new Error(`Failed to check container status: ${response.statusText}`);
309
+ }
310
+
311
+ const data = await response.json();
312
+ return data.status;
313
+ }
314
+
219
315
  /**
220
316
  * Publishes a Threads media container.
221
317
  *
222
318
  * @param userId - The user ID of the Threads account
223
319
  * @param accessToken - The access token for authentication
224
320
  * @param containerId - The ID of the container to publish
225
- * @returns A Promise that resolves to the published post ID
226
- * @throws Will throw an error if the API request fails
321
+ * @returns A Promise that resolves to the published container ID
322
+ * @throws Will throw an error if the API request fails or if publishing times out
227
323
  *
228
324
  * @example
229
325
  * ```typescript
@@ -235,35 +331,52 @@ export async function publishThreadsContainer(
235
331
  accessToken: string,
236
332
  containerId: string
237
333
  ): Promise<string> {
238
- const url = `${THREADS_API_BASE_URL}/${userId}/threads_publish`;
239
- const body = new URLSearchParams({
334
+ const publishUrl = `${THREADS_API_BASE_URL}/${userId}/threads_publish`;
335
+ const publishBody = new URLSearchParams({
240
336
  access_token: accessToken,
241
337
  creation_id: containerId,
242
338
  });
243
339
 
244
- const response = await fetch(url, {
340
+ const publishResponse = await fetch(publishUrl, {
245
341
  method: "POST",
246
- body: body,
342
+ body: publishBody,
247
343
  headers: {
248
344
  "Content-Type": "application/x-www-form-urlencoded",
249
345
  },
250
346
  });
251
347
 
252
- const responseText = await response.text();
253
-
254
- if (!response.ok) {
348
+ if (!publishResponse.ok) {
255
349
  throw new Error(
256
- `Failed to publish Threads container: ${response.statusText}. Details: ${responseText}`
350
+ `Failed to publish Threads container: ${publishResponse.statusText}`
257
351
  );
258
352
  }
259
353
 
260
- try {
261
- const data = JSON.parse(responseText);
262
- return data.id;
263
- } catch (error) {
264
- console.error(`Failed to parse publish response JSON: ${error}`);
265
- throw new Error(`Invalid response from Threads API: ${responseText}`);
354
+ // Check container status
355
+ let status = await checkContainerStatus(containerId, accessToken);
356
+ let attempts = 0;
357
+ const maxAttempts = 5;
358
+
359
+ while (
360
+ status !== "PUBLISHED" &&
361
+ status !== "FINISHED" &&
362
+ attempts < maxAttempts
363
+ ) {
364
+ await new Promise((resolve) => setTimeout(resolve, 60000)); // Wait for 1 minute
365
+ status = await checkContainerStatus(containerId, accessToken);
366
+ attempts++;
367
+ }
368
+
369
+ if (status === "ERROR") {
370
+ throw new Error(`Failed to publish container. Error: ${status}`);
266
371
  }
372
+
373
+ if (status !== "PUBLISHED" && status !== "FINISHED") {
374
+ throw new Error(
375
+ `Container not published after ${maxAttempts} attempts. Current status: ${status}`
376
+ );
377
+ }
378
+
379
+ return containerId; // Return the container ID as the published ID
267
380
  }
268
381
 
269
382
  /**
@@ -339,6 +452,12 @@ export function serveRequests() {
339
452
  * @param userId - The user ID of the Threads account
340
453
  * @param accessToken - The access token for authentication
341
454
  * @returns A Promise that resolves to the rate limit usage information
455
+ * @throws Will throw an error if the API request fails
456
+ * @example
457
+ * ```typescript
458
+ * const rateLimit = await getPublishingLimit("123456", "your_access_token");
459
+ * console.log(`Current usage: ${rateLimit.quota_usage}`);
460
+ * ```
342
461
  */
343
462
  export async function getPublishingLimit(
344
463
  userId: string,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codybrom/denim",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Typescript/Deno module to simplify posting to Threads with text, images, or videos",
5
5
  "main": "mod.ts",
6
6
  "directories": {
package/readme.md CHANGED
@@ -32,13 +32,13 @@ This will add the latest version of Denim to your project's dependencies.
32
32
  To import straight from JSR:
33
33
 
34
34
  ```typescript
35
- import { ThreadsPostRequest, createThreadsContainer, publishThreadsContainer } from 'jsr:@codybrom/denim@^1.2.0';
35
+ import { ThreadsPostRequest, createThreadsContainer, publishThreadsContainer } from 'jsr:@codybrom/denim@^1.3.0';
36
36
  ```
37
37
 
38
38
  ### Basic Usage
39
39
 
40
40
  ```typescript
41
- import { createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim@^1.2.0";
41
+ import { createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim@^1.3.0";
42
42
 
43
43
  const request: ThreadsPostRequest = {
44
44
  userId: "YOUR_USER_ID",
@@ -61,7 +61,7 @@ console.log(`Post published with ID: ${publishedId}`);
61
61
  #### Retrieving Publishing Rate Limit
62
62
 
63
63
  ```typescript
64
- import { getPublishingLimit } from "jsr:@codybrom/denim@^1.2.0";
64
+ import { getPublishingLimit } from "jsr:@codybrom/denim@^1.3.0";
65
65
 
66
66
  const userId = "YOUR_USER_ID";
67
67
  const accessToken = "YOUR_ACCESS_TOKEN";
@@ -126,7 +126,7 @@ const videoRequest: ThreadsPostRequest = {
126
126
  };
127
127
  ```
128
128
 
129
- #### Video Post with Alt Text, Reply Control and Geo-gating
129
+ #### Video Post with Alt Text, Reply Control and Geo-gating* (requires special account permission)
130
130
 
131
131
  ```typescript
132
132
  const videoRequest: ThreadsPostRequest = {
@@ -171,7 +171,6 @@ const carouselRequest: ThreadsPostRequest = {
171
171
  text: "Check out this carousel post!",
172
172
  children: [item1Id, item2Id],
173
173
  replyControl: "everyone",
174
- allowlistedCountryCodes: ["US", "CA", "MX"],
175
174
  };
176
175
 
177
176
  const containerId = await createThreadsContainer(carouselRequest);