@codybrom/denim 1.2.0 → 1.3.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.
package/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codybrom/denim",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
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,55 +59,58 @@ export interface ThreadsPostRequest {
60
59
  export async function createThreadsContainer(
61
60
  request: ThreadsPostRequest
62
61
  ): Promise<string> {
63
- // Add input validation
64
- if (request.mediaType !== "IMAGE" && request.imageUrl) {
65
- throw new Error("imageUrl can only be used with IMAGE media type");
66
- }
67
- if (request.mediaType !== "VIDEO" && request.videoUrl) {
68
- throw new Error("videoUrl can only be used with VIDEO media type");
69
- }
70
- if (request.mediaType !== "TEXT" && request.linkAttachment) {
71
- throw new Error("linkAttachment can only be used with TEXT media type");
72
- }
73
- if (request.mediaType !== "CAROUSEL" && request.children) {
74
- throw new Error("children can only be used with CAROUSEL media type");
75
- }
76
- if (
77
- request.mediaType === "CAROUSEL" &&
78
- (!request.children || request.children.length < 2)
79
- ) {
80
- throw new Error("CAROUSEL media type requires at least 2 children");
81
- }
62
+ // Input validation
63
+ validateRequest(request);
82
64
 
83
65
  const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
84
66
  const body = new URLSearchParams({
85
67
  access_token: request.accessToken,
86
68
  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 }),
92
- ...(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
69
  });
102
70
 
71
+ // Add common optional parameters
72
+ if (request.text) body.append("text", request.text);
73
+ if (request.replyControl) body.append("reply_control", request.replyControl);
74
+ if (request.allowlistedCountryCodes) {
75
+ body.append(
76
+ "allowlisted_country_codes",
77
+ request.allowlistedCountryCodes.join(",")
78
+ );
79
+ }
80
+
81
+ // Handle media type specific parameters
82
+ switch (request.mediaType) {
83
+ case "VIDEO":
84
+ if (!request.videoUrl)
85
+ throw new Error("videoUrl is required for VIDEO media type");
86
+ body.append("video_url", request.videoUrl);
87
+ if (request.altText) body.append("alt_text", request.altText);
88
+ break;
89
+ case "IMAGE":
90
+ if (!request.imageUrl)
91
+ throw new Error("imageUrl is required for IMAGE media type");
92
+ body.append("image_url", request.imageUrl);
93
+ if (request.altText) body.append("alt_text", request.altText);
94
+ break;
95
+ case "TEXT":
96
+ if (request.linkAttachment)
97
+ body.append("link_attachment", request.linkAttachment);
98
+ break;
99
+ case "CAROUSEL":
100
+ if (!request.children || request.children.length < 2) {
101
+ throw new Error("CAROUSEL media type requires at least 2 children");
102
+ }
103
+ body.append("children", request.children.join(","));
104
+ break;
105
+ }
106
+
103
107
  console.log(`Sending request to: ${url}`);
104
108
  console.log(`Request body: ${body.toString()}`);
105
109
 
106
110
  const response = await fetch(url, {
107
111
  method: "POST",
108
112
  body: body,
109
- headers: {
110
- "Content-Type": "application/x-www-form-urlencoded",
111
- },
113
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
112
114
  });
113
115
 
114
116
  const responseText = await response.text();
@@ -130,6 +132,49 @@ export async function createThreadsContainer(
130
132
  }
131
133
  }
132
134
 
135
+ /**
136
+ * Validates the ThreadsPostRequest object to ensure correct usage of media-specific properties.
137
+ *
138
+ * @param request - The ThreadsPostRequest object to validate
139
+ * @throws Will throw an error if the request contains invalid combinations of media type and properties
140
+ *
141
+ * @example
142
+ * ```typescript
143
+ * const request: ThreadsPostRequest = {
144
+ * userId: "123456",
145
+ * accessToken: "your_access_token",
146
+ * mediaType: "IMAGE",
147
+ * imageUrl: "https://example.com/image.jpg"
148
+ * };
149
+ * validateRequest(request); // This will not throw an error
150
+ *
151
+ * const invalidRequest: ThreadsPostRequest = {
152
+ * userId: "123456",
153
+ * accessToken: "your_access_token",
154
+ * mediaType: "TEXT",
155
+ * imageUrl: "https://example.com/image.jpg"
156
+ * };
157
+ * validateRequest(invalidRequest); // This will throw an error
158
+ * ```
159
+ */
160
+ function validateRequest(request: ThreadsPostRequest): void {
161
+ if (request.mediaType !== "IMAGE" && request.imageUrl) {
162
+ throw new Error("imageUrl can only be used with IMAGE media type");
163
+ }
164
+ if (request.mediaType !== "TEXT" && request.linkAttachment) {
165
+ throw new Error("linkAttachment can only be used with TEXT media type");
166
+ }
167
+ if (request.mediaType !== "CAROUSEL" && request.children) {
168
+ throw new Error("children can only be used with CAROUSEL media type");
169
+ }
170
+ if (
171
+ request.mediaType === "CAROUSEL" &&
172
+ (!request.children || request.children.length < 2)
173
+ ) {
174
+ throw new Error("CAROUSEL media type requires at least 2 children");
175
+ }
176
+ }
177
+
133
178
  /**
134
179
  * Creates a carousel item for a Threads carousel post.
135
180
  *
@@ -156,12 +201,7 @@ export async function createThreadsContainer(
156
201
  * imageUrl: "https://example.com/image.jpg",
157
202
  * altText: "A beautiful landscape"
158
203
  * };
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
- * }
204
+ * const itemId = await createCarouselItem(itemRequest);
165
205
  * ```
166
206
  */
167
207
  export async function createCarouselItem(
@@ -216,14 +256,36 @@ export async function createCarouselItem(
216
256
  }
217
257
  }
218
258
 
259
+ /**
260
+ * Checks the status of a Threads container.
261
+ * @param containerId - The ID of the container to check
262
+ * @param accessToken - The access token for authentication
263
+ * @returns A Promise that resolves to the container status
264
+ * @throws Will throw an error if the API request fails
265
+ */
266
+ async function checkContainerStatus(
267
+ containerId: string,
268
+ accessToken: string
269
+ ): Promise<string> {
270
+ const url = `${THREADS_API_BASE_URL}/${containerId}?fields=status,error_message&access_token=${accessToken}`;
271
+
272
+ const response = await fetch(url);
273
+ if (!response.ok) {
274
+ throw new Error(`Failed to check container status: ${response.statusText}`);
275
+ }
276
+
277
+ const data = await response.json();
278
+ return data.status;
279
+ }
280
+
219
281
  /**
220
282
  * Publishes a Threads media container.
221
283
  *
222
284
  * @param userId - The user ID of the Threads account
223
285
  * @param accessToken - The access token for authentication
224
286
  * @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
287
+ * @returns A Promise that resolves to the published container ID
288
+ * @throws Will throw an error if the API request fails or if publishing times out
227
289
  *
228
290
  * @example
229
291
  * ```typescript
@@ -235,35 +297,52 @@ export async function publishThreadsContainer(
235
297
  accessToken: string,
236
298
  containerId: string
237
299
  ): Promise<string> {
238
- const url = `${THREADS_API_BASE_URL}/${userId}/threads_publish`;
239
- const body = new URLSearchParams({
300
+ const publishUrl = `${THREADS_API_BASE_URL}/${userId}/threads_publish`;
301
+ const publishBody = new URLSearchParams({
240
302
  access_token: accessToken,
241
303
  creation_id: containerId,
242
304
  });
243
305
 
244
- const response = await fetch(url, {
306
+ const publishResponse = await fetch(publishUrl, {
245
307
  method: "POST",
246
- body: body,
308
+ body: publishBody,
247
309
  headers: {
248
310
  "Content-Type": "application/x-www-form-urlencoded",
249
311
  },
250
312
  });
251
313
 
252
- const responseText = await response.text();
253
-
254
- if (!response.ok) {
314
+ if (!publishResponse.ok) {
255
315
  throw new Error(
256
- `Failed to publish Threads container: ${response.statusText}. Details: ${responseText}`
316
+ `Failed to publish Threads container: ${publishResponse.statusText}`
257
317
  );
258
318
  }
259
319
 
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}`);
320
+ // Check container status
321
+ let status = await checkContainerStatus(containerId, accessToken);
322
+ let attempts = 0;
323
+ const maxAttempts = 5;
324
+
325
+ while (
326
+ status !== "PUBLISHED" &&
327
+ status !== "FINISHED" &&
328
+ attempts < maxAttempts
329
+ ) {
330
+ await new Promise((resolve) => setTimeout(resolve, 60000)); // Wait for 1 minute
331
+ status = await checkContainerStatus(containerId, accessToken);
332
+ attempts++;
333
+ }
334
+
335
+ if (status === "ERROR") {
336
+ throw new Error(`Failed to publish container. Error: ${status}`);
337
+ }
338
+
339
+ if (status !== "PUBLISHED" && status !== "FINISHED") {
340
+ throw new Error(
341
+ `Container not published after ${maxAttempts} attempts. Current status: ${status}`
342
+ );
266
343
  }
344
+
345
+ return containerId; // Return the container ID as the published ID
267
346
  }
268
347
 
269
348
  /**
@@ -339,6 +418,12 @@ export function serveRequests() {
339
418
  * @param userId - The user ID of the Threads account
340
419
  * @param accessToken - The access token for authentication
341
420
  * @returns A Promise that resolves to the rate limit usage information
421
+ * @throws Will throw an error if the API request fails
422
+ * @example
423
+ * ```typescript
424
+ * const rateLimit = await getPublishingLimit("123456", "your_access_token");
425
+ * console.log(`Current usage: ${rateLimit.quota_usage}`);
426
+ * ```
342
427
  */
343
428
  export async function getPublishingLimit(
344
429
  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.1",
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';
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";
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";
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 = {
@@ -144,7 +144,7 @@ const videoRequest: ThreadsPostRequest = {
144
144
  #### Carousel Post
145
145
 
146
146
  ```typescript
147
- import { createCarouselItem, createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim@^1.0.4";
147
+ import { createCarouselItem, createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim";
148
148
 
149
149
  // First, create carousel items
150
150
  const item1Id = await createCarouselItem({
@@ -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);