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