@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.
- package/.github/workflows/publish.yml +13 -1
- package/.vscode/settings.json +11 -0
- package/deno.json +2 -2
- package/examples/edge-function.ts +149 -64
- package/mod.ts +174 -7
- package/mod_test.ts +411 -30
- package/package.json +1 -1
- package/readme.md +100 -10
|
@@ -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 }}
|
package/deno.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codybrom/denim",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "A Deno function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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:
|
|
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 (!
|
|
77
|
+
if (!userId || !accessToken) {
|
|
67
78
|
return new Response(
|
|
68
|
-
JSON.stringify({
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
-
|
|
111
|
-
-
|
|
112
|
-
-
|
|
113
|
-
|
|
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: "
|
|
42
|
-
* text: "
|
|
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.
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
83
|
+
const containerId = await createThreadsContainer(requestData);
|
|
84
|
+
assertEquals(containerId, "container123");
|
|
85
|
+
}
|
|
86
|
+
);
|
|
58
87
|
|
|
59
|
-
Deno.test(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
package/readme.md
CHANGED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- Create and publish posts on Threads
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
####
|
|
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`.
|