@codybrom/denim 1.0.2 → 1.2.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.
@@ -1,4 +1,5 @@
1
1
  name: Publish
2
+
2
3
  on:
3
4
  push:
4
5
  branches:
@@ -15,5 +16,16 @@ jobs:
15
16
  steps:
16
17
  - uses: actions/checkout@v4
17
18
 
18
- - name: Publish package
19
+ - name: Publish package to JSR
19
20
  run: npx jsr publish
21
+
22
+ - name: Set up Node.js
23
+ uses: actions/setup-node@v4
24
+ with:
25
+ node-version: '20.x'
26
+ registry-url: 'https://registry.npmjs.org'
27
+
28
+ - name: Publish package to NPM
29
+ run: npm publish --provenance --access public
30
+ env:
31
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,11 @@
1
+ {
2
+ "gpt-context-generator.detectedFileExtensions": [
3
+ "js",
4
+ "jsx",
5
+ "ts",
6
+ "tsx",
7
+ "mdx",
8
+ "json",
9
+ "md"
10
+ ]
11
+ }
package/deno.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@codybrom/denim",
3
- "version": "1.0.2",
4
- "description": "A Deno function that posts to Threads with text, image, or video.",
3
+ "version": "1.2.0",
4
+ "description": "A Deno function for posting to Threads.",
5
5
  "entry": "./mod.ts",
6
6
  "exports": {
7
7
  ".": "./mod.ts"
@@ -4,10 +4,28 @@ import {
4
4
  type ThreadsPostRequest,
5
5
  createThreadsContainer,
6
6
  publishThreadsContainer,
7
- } from "jsr:@codybrom/denim@^1.0.2";
7
+ createCarouselItem,
8
+ checkHealth,
9
+ getPublishingLimit,
10
+ } from "jsr:@codybrom/denim@^1.1.0";
8
11
 
9
12
  async function postToThreads(request: ThreadsPostRequest): Promise<string> {
10
13
  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
+ // Check rate limit
21
+ const rateLimit = await getPublishingLimit(
22
+ request.userId,
23
+ request.accessToken
24
+ );
25
+ if (rateLimit.quota_usage >= rateLimit.config.quota_total) {
26
+ throw new Error("Rate limit exceeded. Please try again later.");
27
+ }
28
+
11
29
  if (request.mediaType === "VIDEO" && request.videoUrl) {
12
30
  delete request.imageUrl;
13
31
  }
@@ -30,42 +48,35 @@ async function postToThreads(request: ThreadsPostRequest): Promise<string> {
30
48
  }
31
49
 
32
50
  Deno.serve(async (req: Request) => {
33
- // Health check endpoint
34
- if (req.method === "GET" && new URL(req.url).pathname === "/health") {
35
- return new Response(JSON.stringify({ status: "ok" }), {
36
- status: 200,
37
- headers: { "Content-Type": "application/json" },
38
- });
39
- }
51
+ const url = new URL(req.url);
40
52
 
41
- if (req.method !== "POST") {
42
- return new Response("Method Not Allowed", { status: 405 });
43
- }
44
-
45
- // Log incoming request (without sensitive data)
46
- console.log(`Received ${req.method} request to ${new URL(req.url).pathname}`);
47
-
48
- try {
49
- let body;
53
+ // Health check endpoint
54
+ if (req.method === "GET" && url.pathname === "/health") {
50
55
  try {
51
- body = await req.json();
56
+ const healthStatus = await checkHealth();
57
+ return new Response(JSON.stringify(healthStatus), {
58
+ status: 200,
59
+ headers: { "Content-Type": "application/json" },
60
+ });
52
61
  } catch (error) {
53
62
  return new Response(
54
- JSON.stringify({
55
- success: false,
56
- error: "Invalid JSON in request body",
57
- details: error.message,
58
- }),
63
+ JSON.stringify({ status: "error", message: error.message }),
59
64
  {
60
- status: 400,
65
+ status: 500,
61
66
  headers: { "Content-Type": "application/json" },
62
67
  }
63
68
  );
64
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");
65
76
 
66
- if (!body.userId || !body.accessToken || !body.mediaType) {
77
+ if (!userId || !accessToken) {
67
78
  return new Response(
68
- JSON.stringify({ success: false, error: "Missing required fields" }),
79
+ JSON.stringify({ error: "Missing userId or accessToken" }),
69
80
  {
70
81
  status: 400,
71
82
  headers: { "Content-Type": "application/json" },
@@ -73,31 +84,83 @@ Deno.serve(async (req: Request) => {
73
84
  );
74
85
  }
75
86
 
76
- const postRequest: ThreadsPostRequest = {
77
- userId: body.userId,
78
- accessToken: body.accessToken,
79
- mediaType: body.mediaType,
80
- text: body.text,
81
- imageUrl: body.imageUrl,
82
- videoUrl: body.videoUrl,
83
- };
84
-
85
- const publishedId = await postToThreads(postRequest);
86
-
87
- return new Response(JSON.stringify({ success: true, publishedId }), {
88
- status: 200,
89
- headers: { "Content-Type": "application/json" },
90
- });
91
- } catch (error) {
92
- console.error("Error processing request:", error);
93
- return new Response(
94
- JSON.stringify({ success: false, error: error.message }),
95
- {
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 }), {
96
95
  status: 500,
97
96
  headers: { "Content-Type": "application/json" },
97
+ });
98
+ }
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" },
112
+ }
113
+ );
98
114
  }
99
- );
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,
140
+ });
141
+ postRequest.children.push(itemId);
142
+ }
143
+ }
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
+ );
160
+ }
100
161
  }
162
+
163
+ return new Response("Not Found", { status: 404 });
101
164
  });
102
165
 
103
166
  /*
@@ -105,32 +168,33 @@ Deno.serve(async (req: Request) => {
105
168
 
106
169
  1. Deploy this file to your serverless platform that supports Deno.
107
170
 
108
- 2. Send POST requests to <YOUR_FUNCTION_URI> with JSON body containing:
109
- - YOUR_AUTH_KEY: Your custom authorization key (if used, otherwise remove the header)
110
- - userId: Your Threads user ID
111
- - accessToken: Your Threads API access token
112
- - mediaType: "TEXT", "IMAGE", or "VIDEO"
113
- - text: The text content of your post
114
- - imageUrl: URL of the image (for IMAGE posts)
115
- - videoUrl: URL of the video (for VIDEO posts)
116
-
171
+ 2. Send requests to <YOUR_FUNCTION_URI> with the following endpoints:
172
+
173
+ GET /health - Check the API health status
174
+ GET /rate-limit?userId=YOUR_USER_ID&accessToken=YOUR_ACCESS_TOKEN - Check rate limit
175
+ POST /post - Create and publish a post (see below for details)
176
+
117
177
  Example curl commands:
118
178
 
179
+ # Check API health
180
+ curl -X GET <YOUR_FUNCTION_URI>/health
181
+
182
+ # Check rate limit
183
+ curl -X GET "<YOUR_FUNCTION_URI>/rate-limit?userId=YOUR_USER_ID&accessToken=YOUR_ACCESS_TOKEN"
184
+
119
185
  # Post a text-only Thread
120
- curl -X POST <YOUR_FUNCTION_URI> \
186
+ curl -X POST <YOUR_FUNCTION_URI>/post \
121
187
  -H "Content-Type: application/json" \
122
- -H "Authorization: Bearer YOUR_AUTH_KEY" \
123
188
  -d '{
124
189
  "userId": "YOUR_USER_ID",
125
190
  "accessToken": "YOUR_ACCESS_TOKEN",
126
191
  "mediaType": "TEXT",
127
192
  "text": "Hello from Denim!"
128
193
  }'
129
-
194
+
130
195
  # Post an image Thread
131
- curl -X POST <YOUR_FUNCTION_URI> \
196
+ curl -X POST <YOUR_FUNCTION_URI>/post \
132
197
  -H "Content-Type: application/json" \
133
- -H "Authorization: Bearer YOUR_AUTH_KEY" \
134
198
  -d '{
135
199
  "userId": "YOUR_USER_ID",
136
200
  "accessToken": "YOUR_ACCESS_TOKEN",
@@ -140,9 +204,8 @@ Deno.serve(async (req: Request) => {
140
204
  }'
141
205
 
142
206
  # Post a video Thread
143
- curl -X POST <YOUR_FUNCTION_URI> \
207
+ curl -X POST <YOUR_FUNCTION_URI>/post \
144
208
  -H "Content-Type: application/json" \
145
- -H "Authorization: Bearer YOUR_AUTH_KEY" \
146
209
  -d '{
147
210
  "userId": "YOUR_USER_ID",
148
211
  "accessToken": "YOUR_ACCESS_TOKEN",
@@ -151,9 +214,31 @@ Deno.serve(async (req: Request) => {
151
214
  "videoUrl": "https://example.com/video.mp4"
152
215
  }'
153
216
 
217
+ # Post a carousel Thread
218
+ curl -X POST <YOUR_FUNCTION_URI>/post \
219
+ -H "Content-Type: application/json" \
220
+ -d '{
221
+ "userId": "YOUR_USER_ID",
222
+ "accessToken": "YOUR_ACCESS_TOKEN",
223
+ "mediaType": "CAROUSEL",
224
+ "text": "Check out this carousel!",
225
+ "carouselItems": [
226
+ {
227
+ "mediaType": "IMAGE",
228
+ "imageUrl": "https://example.com/image1.jpg",
229
+ "altText": "First image"
230
+ },
231
+ {
232
+ "mediaType": "VIDEO",
233
+ "videoUrl": "https://example.com/video.mp4",
234
+ "altText": "A video"
235
+ }
236
+ ]
237
+ }'
238
+
154
239
  Note: If both videoUrl and imageUrl are provided in a request with mediaType "VIDEO",
155
240
  the imageUrl will be ignored, and only the video will be posted.
156
-
241
+
157
242
  Security Note: Ensure that your function is deployed with appropriate access controls
158
243
  and authentication mechanisms to protect sensitive data like access tokens.
159
244
  */
package/mod.ts CHANGED
@@ -17,13 +17,23 @@ export interface ThreadsPostRequest {
17
17
  /** The access token for authentication */
18
18
  accessToken: string;
19
19
  /** The type of media being posted */
20
- mediaType: "TEXT" | "IMAGE" | "VIDEO";
20
+ mediaType: "TEXT" | "IMAGE" | "VIDEO" | "CAROUSEL";
21
21
  /** The text content of the post (optional) */
22
22
  text?: string;
23
- /** The URL of the image to be posted (optional) */
23
+ /** The URL of the image to be posted (optional, for IMAGE type) */
24
24
  imageUrl?: string;
25
- /** The URL of the video to be posted (optional) */
25
+ /** The URL of the video to be posted (optional, for VIDEO type) */
26
26
  videoUrl?: string;
27
+ /** The accessibility text for the image or video (optional) */
28
+ altText?: string;
29
+ /** The URL to be attached as a link to the post (optional, for text posts only) */
30
+ linkAttachment?: string;
31
+ /** List of country codes where the post should be visible (optional) */
32
+ allowlistedCountryCodes?: string[];
33
+ /** Controls who can reply to the post (optional) */
34
+ replyControl?: "everyone" | "accounts_you_follow" | "mentioned_only";
35
+ /** Array of carousel item IDs (required for CAROUSEL type, not applicable for other types) */
36
+ children?: string[];
27
37
  }
28
38
 
29
39
  /**
@@ -38,8 +48,11 @@ export interface ThreadsPostRequest {
38
48
  * const request: ThreadsPostRequest = {
39
49
  * userId: "123456",
40
50
  * accessToken: "your_access_token",
41
- * mediaType: "TEXT",
42
- * text: "Hello, Threads!"
51
+ * mediaType: "CAROUSEL",
52
+ * text: "Check out this carousel!",
53
+ * children: ["item1", "item2"],
54
+ * allowlistedCountryCodes: ["US", "CA"],
55
+ * replyControl: "everyone"
43
56
  * };
44
57
  * const containerId = await createThreadsContainer(request);
45
58
  * ```
@@ -47,13 +60,44 @@ export interface ThreadsPostRequest {
47
60
  export async function createThreadsContainer(
48
61
  request: ThreadsPostRequest
49
62
  ): 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
+ }
82
+
50
83
  const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
51
84
  const body = new URLSearchParams({
52
85
  access_token: request.accessToken,
53
86
  media_type: request.mediaType,
54
87
  ...(request.text && { text: request.text }),
55
- ...(request.imageUrl && { image_url: request.imageUrl }),
56
- ...(request.videoUrl && { video_url: request.videoUrl }),
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(",") }),
57
101
  });
58
102
 
59
103
  console.log(`Sending request to: ${url}`);
@@ -86,6 +130,92 @@ export async function createThreadsContainer(
86
130
  }
87
131
  }
88
132
 
133
+ /**
134
+ * Creates a carousel item for a Threads carousel post.
135
+ *
136
+ * This function sends a request to the Threads API to create a single item
137
+ * that will be part of a carousel post. It can be used for both image and
138
+ * video items.
139
+ *
140
+ * @param request - The request object containing carousel item details
141
+ * @param request.userId - The user ID of the Threads account
142
+ * @param request.accessToken - The access token for authentication
143
+ * @param request.mediaType - The type of media for this carousel item ('IMAGE' or 'VIDEO')
144
+ * @param request.imageUrl - The URL of the image (required if mediaType is 'IMAGE')
145
+ * @param request.videoUrl - The URL of the video (required if mediaType is 'VIDEO')
146
+ * @param request.altText - Optional accessibility text for the image or video
147
+ * @returns A Promise that resolves to the carousel item ID
148
+ * @throws Will throw an error if the API request fails or returns an invalid response
149
+ *
150
+ * @example
151
+ * ```typescript
152
+ * const itemRequest = {
153
+ * userId: "123456",
154
+ * accessToken: "your_access_token",
155
+ * mediaType: "IMAGE" as const,
156
+ * imageUrl: "https://example.com/image.jpg",
157
+ * altText: "A beautiful landscape"
158
+ * };
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
+ * }
165
+ * ```
166
+ */
167
+ export async function createCarouselItem(
168
+ request: Omit<ThreadsPostRequest, "mediaType"> & {
169
+ mediaType: "IMAGE" | "VIDEO";
170
+ }
171
+ ): Promise<string> {
172
+ if (request.mediaType !== "IMAGE" && request.mediaType !== "VIDEO") {
173
+ throw new Error("Carousel items must be either IMAGE or VIDEO type");
174
+ }
175
+
176
+ if (request.mediaType === "IMAGE" && !request.imageUrl) {
177
+ throw new Error("imageUrl is required for IMAGE type carousel items");
178
+ }
179
+
180
+ if (request.mediaType === "VIDEO" && !request.videoUrl) {
181
+ throw new Error("videoUrl is required for VIDEO type carousel items");
182
+ }
183
+
184
+ const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
185
+ const body = new URLSearchParams({
186
+ access_token: request.accessToken,
187
+ media_type: request.mediaType,
188
+ is_carousel_item: "true",
189
+ ...(request.imageUrl && { image_url: request.imageUrl }),
190
+ ...(request.videoUrl && { video_url: request.videoUrl }),
191
+ ...(request.altText && { alt_text: request.altText }),
192
+ });
193
+
194
+ const response = await fetch(url, {
195
+ method: "POST",
196
+ body: body,
197
+ headers: {
198
+ "Content-Type": "application/x-www-form-urlencoded",
199
+ },
200
+ });
201
+
202
+ const responseText = await response.text();
203
+
204
+ if (!response.ok) {
205
+ throw new Error(
206
+ `Failed to create carousel item: ${response.statusText}. Details: ${responseText}`
207
+ );
208
+ }
209
+
210
+ try {
211
+ const data = JSON.parse(responseText);
212
+ return data.id;
213
+ } catch (error) {
214
+ console.error(`Failed to parse response JSON: ${error}`);
215
+ throw new Error(`Invalid response from Threads API: ${responseText}`);
216
+ }
217
+ }
218
+
89
219
  /**
90
220
  * Publishes a Threads media container.
91
221
  *
@@ -202,3 +332,40 @@ export function serveRequests() {
202
332
  }
203
333
  });
204
334
  }
335
+
336
+ /**
337
+ * Retrieves the current publishing rate limit usage for a user.
338
+ *
339
+ * @param userId - The user ID of the Threads account
340
+ * @param accessToken - The access token for authentication
341
+ * @returns A Promise that resolves to the rate limit usage information
342
+ */
343
+ export async function getPublishingLimit(
344
+ userId: string,
345
+ accessToken: string
346
+ ): Promise<{
347
+ quota_usage: number;
348
+ config: {
349
+ quota_total: number;
350
+ quota_duration: number;
351
+ };
352
+ }> {
353
+ const url = `${THREADS_API_BASE_URL}/${userId}/threads_publishing_limit`;
354
+ const params = new URLSearchParams({
355
+ access_token: accessToken,
356
+ fields: "quota_usage,config",
357
+ });
358
+
359
+ const response = await fetch(`${url}?${params}`);
360
+ const data = await response.json();
361
+
362
+ if (!response.ok) {
363
+ throw new Error(
364
+ `Failed to get publishing limit: ${
365
+ data.error?.message || response.statusText
366
+ }`
367
+ );
368
+ }
369
+
370
+ return data.data[0];
371
+ }
package/mod_test.ts CHANGED
@@ -6,13 +6,15 @@ import {
6
6
  import {
7
7
  createThreadsContainer,
8
8
  publishThreadsContainer,
9
+ createCarouselItem,
10
+ getPublishingLimit,
9
11
  type ThreadsPostRequest,
10
12
  } from "./mod.ts";
11
13
 
12
14
  // Mock fetch response
13
15
  globalThis.fetch = (
14
16
  input: string | URL | Request,
15
- _init?: RequestInit
17
+ init?: RequestInit
16
18
  ): Promise<Response> => {
17
19
  const url =
18
20
  typeof input === "string"
@@ -21,14 +23,37 @@ globalThis.fetch = (
21
23
  ? input.toString()
22
24
  : input.url;
23
25
 
24
- if (url.includes("threads_publish")) {
25
- return Promise.resolve({
26
- ok: true,
27
- status: 200,
28
- statusText: "OK",
29
- text: () => Promise.resolve(JSON.stringify({ id: "published123" })),
30
- } as Response);
31
- } else if (url.includes("threads")) {
26
+ const body =
27
+ init?.body instanceof URLSearchParams ? init.body : new URLSearchParams();
28
+
29
+ if (url.includes("threads")) {
30
+ if (url.includes("threads_publish")) {
31
+ return Promise.resolve({
32
+ ok: true,
33
+ status: 200,
34
+ statusText: "OK",
35
+ text: () => Promise.resolve(JSON.stringify({ id: "published123" })),
36
+ } as Response);
37
+ }
38
+
39
+ if (body.get("is_carousel_item") === "true") {
40
+ if (body.get("access_token") === "invalid_token") {
41
+ return Promise.resolve({
42
+ ok: false,
43
+ status: 400,
44
+ statusText: "Bad Request",
45
+ text: () =>
46
+ Promise.resolve(JSON.stringify({ error: "Invalid access token" })),
47
+ } as Response);
48
+ }
49
+ return Promise.resolve({
50
+ ok: true,
51
+ status: 200,
52
+ statusText: "OK",
53
+ text: () => Promise.resolve(JSON.stringify({ id: "item123" })),
54
+ } as Response);
55
+ }
56
+
32
57
  return Promise.resolve({
33
58
  ok: true,
34
59
  status: 200,
@@ -36,6 +61,7 @@ globalThis.fetch = (
36
61
  text: () => Promise.resolve(JSON.stringify({ id: "container123" })),
37
62
  } as Response);
38
63
  }
64
+
39
65
  return Promise.resolve({
40
66
  ok: false,
41
67
  status: 500,
@@ -44,30 +70,74 @@ globalThis.fetch = (
44
70
  } as Response);
45
71
  };
46
72
 
47
- Deno.test("createThreadsContainer should return container ID", async () => {
48
- const requestData: ThreadsPostRequest = {
49
- userId: "12345",
50
- accessToken: "token",
51
- mediaType: "TEXT",
52
- text: "Hello, Threads!",
53
- };
73
+ Deno.test(
74
+ "createThreadsContainer should return container ID for basic text post",
75
+ async () => {
76
+ const requestData: ThreadsPostRequest = {
77
+ userId: "12345",
78
+ accessToken: "token",
79
+ mediaType: "TEXT",
80
+ text: "Hello, Threads!",
81
+ };
54
82
 
55
- const containerId = await createThreadsContainer(requestData);
56
- assertEquals(containerId, "container123");
57
- });
83
+ const containerId = await createThreadsContainer(requestData);
84
+ assertEquals(containerId, "container123");
85
+ }
86
+ );
58
87
 
59
- Deno.test("publishThreadsContainer should return published ID", async () => {
60
- const userId = "12345";
61
- const accessToken = "token";
62
- const containerId = "container123";
88
+ Deno.test(
89
+ "createThreadsContainer should return container ID with text post with link attachment, reply control, and allowlisted countries",
90
+ async () => {
91
+ const requestData: ThreadsPostRequest = {
92
+ userId: "12345",
93
+ accessToken: "token",
94
+ mediaType: "TEXT",
95
+ text: "Hello, Threads!",
96
+ linkAttachment: "https://example.com",
97
+ replyControl: "everyone",
98
+ allowlistedCountryCodes: ["US", "CA"],
99
+ };
63
100
 
64
- const publishedId = await publishThreadsContainer(
65
- userId,
66
- accessToken,
67
- containerId
68
- );
69
- assertEquals(publishedId, "published123");
70
- });
101
+ const containerId = await createThreadsContainer(requestData);
102
+ assertEquals(containerId, "container123");
103
+ }
104
+ );
105
+
106
+ Deno.test(
107
+ "createThreadsContainer should handle image post with alt text",
108
+ async () => {
109
+ const requestData: ThreadsPostRequest = {
110
+ userId: "12345",
111
+ accessToken: "token",
112
+ mediaType: "IMAGE",
113
+ text: "Check out this image!",
114
+ imageUrl: "https://example.com/image.jpg",
115
+ altText: "A beautiful sunset",
116
+ };
117
+
118
+ const containerId = await createThreadsContainer(requestData);
119
+ assertEquals(containerId, "container123");
120
+ }
121
+ );
122
+
123
+ Deno.test(
124
+ "createThreadsContainer should handle video post with all features",
125
+ async () => {
126
+ const requestData: ThreadsPostRequest = {
127
+ userId: "12345",
128
+ accessToken: "token",
129
+ mediaType: "VIDEO",
130
+ text: "Watch this video!",
131
+ videoUrl: "https://example.com/video.mp4",
132
+ altText: "A tutorial video",
133
+ replyControl: "mentioned_only",
134
+ allowlistedCountryCodes: ["US", "GB"],
135
+ };
136
+
137
+ const containerId = await createThreadsContainer(requestData);
138
+ assertEquals(containerId, "container123");
139
+ }
140
+ );
71
141
 
72
142
  Deno.test("createThreadsContainer should throw error on failure", async () => {
73
143
  const requestData: ThreadsPostRequest = {
@@ -75,6 +145,7 @@ Deno.test("createThreadsContainer should throw error on failure", async () => {
75
145
  accessToken: "token",
76
146
  mediaType: "TEXT",
77
147
  text: "Hello, Threads!",
148
+ linkAttachment: "https://example.com",
78
149
  };
79
150
 
80
151
  globalThis.fetch = (): Promise<Response> =>
@@ -93,6 +164,118 @@ Deno.test("createThreadsContainer should throw error on failure", async () => {
93
164
  "Failed to create Threads container"
94
165
  );
95
166
  });
167
+ Deno.test("createCarouselItem should return item ID", async () => {
168
+ const requestData = {
169
+ userId: "12345",
170
+ accessToken: "token",
171
+ mediaType: "IMAGE" as const,
172
+ imageUrl: "https://example.com/image.jpg",
173
+ altText: "Test image",
174
+ };
175
+
176
+ globalThis.fetch = (
177
+ _input: string | URL | Request,
178
+ init?: RequestInit
179
+ ): Promise<Response> => {
180
+ const body =
181
+ init?.body instanceof URLSearchParams ? init.body : new URLSearchParams();
182
+ if (body.get("is_carousel_item") === "true") {
183
+ return Promise.resolve({
184
+ ok: true,
185
+ status: 200,
186
+ statusText: "OK",
187
+ text: () => Promise.resolve(JSON.stringify({ id: "item123" })),
188
+ } as Response);
189
+ }
190
+ return Promise.resolve({
191
+ ok: false,
192
+ status: 500,
193
+ statusText: "Internal Server Error",
194
+ text: () => Promise.resolve("Error"),
195
+ } as Response);
196
+ };
197
+
198
+ const itemId = await createCarouselItem(requestData);
199
+ assertEquals(itemId, "item123");
200
+ });
201
+
202
+ Deno.test("createCarouselItem should handle video items", async () => {
203
+ const requestData = {
204
+ userId: "12345",
205
+ accessToken: "token",
206
+ mediaType: "VIDEO" as const,
207
+ videoUrl: "https://example.com/video.mp4",
208
+ altText: "Test video",
209
+ };
210
+
211
+ globalThis.fetch = (
212
+ _input: string | URL | Request,
213
+ init?: RequestInit
214
+ ): Promise<Response> => {
215
+ const body =
216
+ init?.body instanceof URLSearchParams ? init.body : new URLSearchParams();
217
+ if (body.get("is_carousel_item") === "true") {
218
+ return Promise.resolve({
219
+ ok: true,
220
+ status: 200,
221
+ statusText: "OK",
222
+ text: () => Promise.resolve(JSON.stringify({ id: "item123" })),
223
+ } as Response);
224
+ }
225
+ return Promise.resolve({
226
+ ok: false,
227
+ status: 500,
228
+ statusText: "Internal Server Error",
229
+ text: () => Promise.resolve("Error"),
230
+ } as Response);
231
+ };
232
+
233
+ const itemId = await createCarouselItem(requestData);
234
+ assertEquals(itemId, "item123");
235
+ });
236
+
237
+ Deno.test("createThreadsContainer should handle carousel post", async () => {
238
+ const requestData: ThreadsPostRequest = {
239
+ userId: "12345",
240
+ accessToken: "token",
241
+ mediaType: "CAROUSEL",
242
+ text: "Check out this carousel!",
243
+ children: ["item123", "item456"],
244
+ replyControl: "everyone",
245
+ allowlistedCountryCodes: ["US", "CA"],
246
+ };
247
+
248
+ const containerId = await createThreadsContainer(requestData);
249
+ assertEquals(containerId, "container123");
250
+ });
251
+
252
+ Deno.test("createCarouselItem should throw error on failure", async () => {
253
+ const requestData = {
254
+ userId: "12345",
255
+ accessToken: "invalid_token",
256
+ mediaType: "IMAGE" as const,
257
+ imageUrl: "https://example.com/image.jpg",
258
+ };
259
+
260
+ await assertRejects(
261
+ () => createCarouselItem(requestData),
262
+ Error,
263
+ "Failed to create carousel item"
264
+ );
265
+ });
266
+
267
+ Deno.test("publishThreadsContainer should return published ID", async () => {
268
+ const userId = "12345";
269
+ const accessToken = "token";
270
+ const containerId = "container123";
271
+
272
+ const publishedId = await publishThreadsContainer(
273
+ userId,
274
+ accessToken,
275
+ containerId
276
+ );
277
+ assertEquals(publishedId, "published123");
278
+ });
96
279
 
97
280
  Deno.test("publishThreadsContainer should throw error on failure", async () => {
98
281
  const userId = "12345";
@@ -118,3 +301,201 @@ Deno.test("publishThreadsContainer should throw error on failure", async () => {
118
301
  "Failed to publish Threads container"
119
302
  );
120
303
  });
304
+
305
+ Deno.test(
306
+ "createThreadsContainer should throw error when imageUrl is provided for non-IMAGE type",
307
+ async () => {
308
+ const requestData: ThreadsPostRequest = {
309
+ userId: "12345",
310
+ accessToken: "token",
311
+ mediaType: "TEXT",
312
+ text: "This shouldn't work",
313
+ imageUrl: "https://example.com/image.jpg",
314
+ };
315
+
316
+ await assertRejects(
317
+ () => createThreadsContainer(requestData),
318
+ Error,
319
+ "imageUrl can only be used with IMAGE media type"
320
+ );
321
+ }
322
+ );
323
+
324
+ Deno.test(
325
+ "createThreadsContainer should throw error when videoUrl is provided for non-VIDEO type",
326
+ async () => {
327
+ const requestData: ThreadsPostRequest = {
328
+ userId: "12345",
329
+ accessToken: "token",
330
+ mediaType: "IMAGE",
331
+ imageUrl: "https://example.com/image.jpg",
332
+ videoUrl: "https://example.com/video.mp4",
333
+ };
334
+
335
+ await assertRejects(
336
+ () => createThreadsContainer(requestData),
337
+ Error,
338
+ "videoUrl can only be used with VIDEO media type"
339
+ );
340
+ }
341
+ );
342
+
343
+ Deno.test(
344
+ "createThreadsContainer should throw error when linkAttachment is provided for non-TEXT type",
345
+ async () => {
346
+ const requestData: ThreadsPostRequest = {
347
+ userId: "12345",
348
+ accessToken: "token",
349
+ mediaType: "IMAGE",
350
+ imageUrl: "https://example.com/image.jpg",
351
+ linkAttachment: "https://example.com",
352
+ };
353
+
354
+ await assertRejects(
355
+ () => createThreadsContainer(requestData),
356
+ Error,
357
+ "linkAttachment can only be used with TEXT media type"
358
+ );
359
+ }
360
+ );
361
+
362
+ Deno.test(
363
+ "createThreadsContainer should throw error when children is provided for non-CAROUSEL type",
364
+ async () => {
365
+ const requestData: ThreadsPostRequest = {
366
+ userId: "12345",
367
+ accessToken: "token",
368
+ mediaType: "IMAGE",
369
+ imageUrl: "https://example.com/image.jpg",
370
+ children: ["item1", "item2"],
371
+ };
372
+
373
+ await assertRejects(
374
+ async () => {
375
+ await createThreadsContainer(requestData);
376
+ },
377
+ Error,
378
+ "Failed to create Threads container"
379
+ );
380
+ }
381
+ );
382
+
383
+ Deno.test(
384
+ "createThreadsContainer should throw error when CAROUSEL type is used without children",
385
+ async () => {
386
+ const requestData: ThreadsPostRequest = {
387
+ userId: "12345",
388
+ accessToken: "token",
389
+ mediaType: "CAROUSEL",
390
+ text: "This carousel has no items",
391
+ };
392
+
393
+ await assertRejects(
394
+ async () => {
395
+ await createThreadsContainer(requestData);
396
+ },
397
+ Error,
398
+ "Failed to create Threads container"
399
+ );
400
+ }
401
+ );
402
+
403
+ Deno.test(
404
+ "createThreadsContainer should not throw error when attributes are used correctly",
405
+ async () => {
406
+ const textRequest: ThreadsPostRequest = {
407
+ userId: "12345",
408
+ accessToken: "token",
409
+ mediaType: "TEXT",
410
+ text: "This is a text post",
411
+ linkAttachment: "https://example.com",
412
+ };
413
+
414
+ const imageRequest: ThreadsPostRequest = {
415
+ userId: "12345",
416
+ accessToken: "token",
417
+ mediaType: "IMAGE",
418
+ imageUrl: "https://example.com/image.jpg",
419
+ altText: "An example image",
420
+ };
421
+
422
+ const videoRequest: ThreadsPostRequest = {
423
+ userId: "12345",
424
+ accessToken: "token",
425
+ mediaType: "VIDEO",
426
+ videoUrl: "https://example.com/video.mp4",
427
+ altText: "An example video",
428
+ };
429
+
430
+ const carouselRequest: ThreadsPostRequest = {
431
+ userId: "12345",
432
+ accessToken: "token",
433
+ mediaType: "CAROUSEL",
434
+ text: "A carousel post",
435
+ children: ["item1", "item2"],
436
+ };
437
+
438
+ const textContainerId = await createThreadsContainer(textRequest);
439
+ const imageContainerId = await createThreadsContainer(imageRequest);
440
+ const videoContainerId = await createThreadsContainer(videoRequest);
441
+ const carouselContainerId = await createThreadsContainer(carouselRequest);
442
+
443
+ assertEquals(textContainerId, "container123");
444
+ assertEquals(imageContainerId, "container123");
445
+ assertEquals(videoContainerId, "container123");
446
+ assertEquals(carouselContainerId, "container123");
447
+ }
448
+ );
449
+
450
+ Deno.test(
451
+ "getPublishingLimit should return rate limit information",
452
+ async () => {
453
+ const userId = "12345";
454
+ const accessToken = "valid_token";
455
+
456
+ globalThis.fetch = (_input: string | URL | Request): Promise<Response> => {
457
+ return Promise.resolve({
458
+ ok: true,
459
+ status: 200,
460
+ json: () =>
461
+ Promise.resolve({
462
+ data: [
463
+ {
464
+ quota_usage: 10,
465
+ config: {
466
+ quota_total: 250,
467
+ quota_duration: 86400,
468
+ },
469
+ },
470
+ ],
471
+ }),
472
+ } as Response);
473
+ };
474
+
475
+ const result = await getPublishingLimit(userId, accessToken);
476
+ assertEquals(result.quota_usage, 10);
477
+ assertEquals(result.config.quota_total, 250);
478
+ assertEquals(result.config.quota_duration, 86400);
479
+ }
480
+ );
481
+
482
+ Deno.test("getPublishingLimit should throw error on failure", async () => {
483
+ const userId = "12345";
484
+ const accessToken = "invalid_token";
485
+
486
+ globalThis.fetch = (_input: string | URL | Request): Promise<Response> => {
487
+ return Promise.resolve({
488
+ ok: false,
489
+ status: 400,
490
+ statusText: "Bad Request",
491
+ json: () =>
492
+ Promise.resolve({ error: { message: "Invalid access token" } }),
493
+ } as Response);
494
+ };
495
+
496
+ await assertRejects(
497
+ () => getPublishingLimit(userId, accessToken),
498
+ Error,
499
+ "Failed to get publishing limit"
500
+ );
501
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codybrom/denim",
3
- "version": "1.0.2",
3
+ "version": "1.2.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
@@ -6,10 +6,14 @@
6
6
 
7
7
  ## Features
8
8
 
9
- - Create and publish posts on Threads
10
- - Support for text-only, image, and video posts
11
- - Easy-to-use API
12
- - Deployable as an edge function
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
13
17
 
14
18
  ## Installation
15
19
 
@@ -25,23 +29,24 @@ This will add the latest version of Denim to your project's dependencies.
25
29
 
26
30
  ## Usage
27
31
 
28
- To import straight from JSR,
32
+ To import straight from JSR:
29
33
 
30
34
  ```typescript
31
- import { ThreadsPostRequest, createThreadsContainer, publishThreadsContainer } from 'jsr:@codybrom/denim@^1.0.2';
35
+ import { ThreadsPostRequest, createThreadsContainer, publishThreadsContainer } from 'jsr:@codybrom/denim@^1.2.0';
32
36
  ```
33
37
 
34
-
35
38
  ### Basic Usage
36
39
 
37
40
  ```typescript
38
- import { createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim@^1.0.2";
41
+ import { createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim@^1.2.0";
39
42
 
40
43
  const request: ThreadsPostRequest = {
41
44
  userId: "YOUR_USER_ID",
42
45
  accessToken: "YOUR_ACCESS_TOKEN",
43
46
  mediaType: "TEXT",
44
- text: "Hello, Threads!",
47
+ text: "Check out Denim on GitHub!",
48
+ linkAttachment: "https://github.com/codybrom/denim",
49
+ replyControl: "everyone",
45
50
  };
46
51
 
47
52
  // Create a container
@@ -53,6 +58,24 @@ const publishedId = await publishThreadsContainer(request.userId, request.access
53
58
  console.log(`Post published with ID: ${publishedId}`);
54
59
  ```
55
60
 
61
+ #### Retrieving Publishing Rate Limit
62
+
63
+ ```typescript
64
+ import { getPublishingLimit } from "jsr:@codybrom/denim@^1.2.0";
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
+ ```
78
+
56
79
  ### Posting Different Media Types
57
80
 
58
81
  #### Text-only Post
@@ -66,7 +89,19 @@ const textRequest: ThreadsPostRequest = {
66
89
  };
67
90
  ```
68
91
 
69
- #### Image Post
92
+ #### Text Post with Link Attachment
93
+
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
+ ```
103
+
104
+ #### Image Post with Alt Text
70
105
 
71
106
  ```typescript
72
107
  const imageRequest: ThreadsPostRequest = {
@@ -75,6 +110,7 @@ const imageRequest: ThreadsPostRequest = {
75
110
  mediaType: "IMAGE",
76
111
  text: "Check out this image!",
77
112
  imageUrl: "https://example.com/image.jpg",
113
+ altText: "A beautiful sunset over the ocean",
78
114
  };
79
115
  ```
80
116
 
@@ -90,6 +126,60 @@ const videoRequest: ThreadsPostRequest = {
90
126
  };
91
127
  ```
92
128
 
129
+ #### Video Post with Alt Text, Reply Control and Geo-gating
130
+
131
+ ```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
+ };
142
+ ```
143
+
144
+ #### Carousel Post
145
+
146
+ ```typescript
147
+ import { createCarouselItem, createThreadsContainer, publishThreadsContainer, ThreadsPostRequest } from "jsr:@codybrom/denim@^1.0.4";
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
+ });
157
+
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
+ });
165
+
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
+ allowlistedCountryCodes: ["US", "CA", "MX"],
175
+ };
176
+
177
+ const containerId = await createThreadsContainer(carouselRequest);
178
+ const publishedId = await publishThreadsContainer(carouselRequest.userId, carouselRequest.accessToken, containerId);
179
+
180
+ console.log(`Carousel post published with ID: ${publishedId}`);
181
+ ```
182
+
93
183
  ## Deploying as an Edge Function
94
184
 
95
185
  Denim can be easily deployed as an edge function. An example implementation is provided in `examples/edge-function.ts`.