@codybrom/denim 1.3.5 → 2.0.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.
Files changed (46) hide show
  1. package/.github/workflows/publish.yml +17 -7
  2. package/.vscode/settings.json +34 -9
  3. package/CHANGELOG.md +128 -0
  4. package/deno.json +22 -8
  5. package/deno.lock +17 -59
  6. package/examples/edge-function.ts +171 -177
  7. package/mod.ts +138 -650
  8. package/mod_test.ts +1287 -380
  9. package/package.json +22 -22
  10. package/readme.md +155 -191
  11. package/src/api/createCarouselItem.ts +86 -0
  12. package/src/api/createThreadsContainer.ts +122 -0
  13. package/src/api/debugToken.ts +35 -0
  14. package/src/api/deleteThread.ts +36 -0
  15. package/src/api/exchangeCodeForToken.ts +50 -0
  16. package/src/api/exchangeToken.ts +36 -0
  17. package/src/api/getAppAccessToken.ts +35 -0
  18. package/src/api/getConversation.ts +51 -0
  19. package/src/api/getGhostPosts.ts +50 -0
  20. package/src/api/getLocation.ts +38 -0
  21. package/src/api/getMediaInsights.ts +39 -0
  22. package/src/api/getMentions.ts +57 -0
  23. package/src/api/getOEmbed.ts +41 -0
  24. package/src/api/getProfile.ts +46 -0
  25. package/src/api/getProfilePosts.ts +53 -0
  26. package/src/api/getPublishingLimit.ts +59 -0
  27. package/src/api/getReplies.ts +51 -0
  28. package/src/api/getSingleThread.ts +37 -0
  29. package/src/api/getThreadsList.ts +49 -0
  30. package/src/api/getUserInsights.ts +54 -0
  31. package/src/api/getUserReplies.ts +54 -0
  32. package/src/api/lookupProfile.ts +53 -0
  33. package/src/api/manageReply.ts +41 -0
  34. package/src/api/publishThreadsContainer.ts +107 -0
  35. package/src/api/refreshToken.ts +33 -0
  36. package/src/api/repost.ts +38 -0
  37. package/src/api/searchKeyword.ts +86 -0
  38. package/src/api/searchLocations.ts +46 -0
  39. package/src/constants.ts +80 -0
  40. package/src/types.ts +925 -0
  41. package/src/utils/checkContainerStatus.ts +39 -0
  42. package/src/utils/getAPI.ts +13 -0
  43. package/src/utils/mock_threads_api.ts +582 -0
  44. package/src/utils/validateRequest.ts +166 -0
  45. package/mock_threads_api.ts +0 -174
  46. package/types.ts +0 -235
@@ -0,0 +1,166 @@
1
+ import type { ThreadsPostRequest } from "../types.ts";
2
+ /**
3
+ * Validates the ThreadsPostRequest object to ensure correct usage of media-specific properties.
4
+ *
5
+ * @param request - The ThreadsPostRequest object to validate
6
+ * @throws Will throw an error if the request contains invalid combinations of media type and properties
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * const request: ThreadsPostRequest = {
11
+ * userId: "123456",
12
+ * accessToken: "your_access_token",
13
+ * mediaType: "IMAGE",
14
+ * imageUrl: "https://example.com/image.jpg"
15
+ * };
16
+ * await validateRequest(request); // This will not throw an error
17
+ * ```
18
+ */
19
+ export async function validateRequest(
20
+ request: ThreadsPostRequest,
21
+ ): Promise<void> {
22
+ // Check for invalid combinations first
23
+ if (request.mediaType !== "IMAGE" && request.imageUrl) {
24
+ throw new Error("imageUrl can only be used with IMAGE media type");
25
+ }
26
+ if (request.mediaType !== "VIDEO" && request.videoUrl) {
27
+ throw new Error("videoUrl can only be used with VIDEO media type");
28
+ }
29
+ if (request.mediaType !== "TEXT" && request.linkAttachment) {
30
+ throw new Error("linkAttachment can only be used with TEXT media type");
31
+ }
32
+ if (request.mediaType !== "CAROUSEL" && request.children) {
33
+ throw new Error("children can only be used with CAROUSEL media type");
34
+ }
35
+
36
+ // Poll attachment can only be used with TEXT posts
37
+ if (request.pollAttachment && request.mediaType !== "TEXT") {
38
+ throw new Error("pollAttachment can only be used with TEXT media type");
39
+ }
40
+
41
+ // GIF attachment can only be used with TEXT posts
42
+ if (request.gifAttachment && request.mediaType !== "TEXT") {
43
+ throw new Error("gifAttachment can only be used with TEXT media type");
44
+ }
45
+
46
+ // Ghost posts can only be TEXT and cannot be replies
47
+ if (request.isGhostPost) {
48
+ if (request.mediaType !== "TEXT") {
49
+ throw new Error("isGhostPost can only be used with TEXT media type");
50
+ }
51
+ if (request.replyToId) {
52
+ throw new Error("isGhostPost cannot be used together with replyToId");
53
+ }
54
+ }
55
+
56
+ // Text attachment can only be used with TEXT posts and not with polls
57
+ if (request.textAttachment) {
58
+ if (request.mediaType !== "TEXT") {
59
+ throw new Error("textAttachment can only be used with TEXT media type");
60
+ }
61
+ if (request.pollAttachment) {
62
+ throw new Error(
63
+ "textAttachment cannot be used together with pollAttachment",
64
+ );
65
+ }
66
+ }
67
+
68
+ // Text entities limited to 10
69
+ if (request.textEntities && request.textEntities.length > 10) {
70
+ throw new Error("textEntities cannot have more than 10 entries");
71
+ }
72
+
73
+ // If combinations are valid, do media-specific validations
74
+ if (request.mediaType === "IMAGE" && request.imageUrl) {
75
+ await validateImageSpecs(request.imageUrl);
76
+ }
77
+ if (request.mediaType === "VIDEO" && request.videoUrl) {
78
+ await validateVideoSpecs(request.videoUrl);
79
+ }
80
+ if (request.mediaType === "TEXT" && request.linkAttachment) {
81
+ validateLinkUrl(request.linkAttachment);
82
+ }
83
+ if (
84
+ request.mediaType === "CAROUSEL" &&
85
+ (!request.children || request.children.length < 2)
86
+ ) {
87
+ throw new Error("CAROUSEL media type requires at least 2 children");
88
+ }
89
+ }
90
+
91
+ async function validateImageSpecs(imageUrl: string): Promise<void> {
92
+ let response: Response;
93
+ try {
94
+ const controller = new AbortController();
95
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
96
+ response = await fetch(imageUrl, {
97
+ method: "HEAD",
98
+ signal: controller.signal,
99
+ });
100
+ clearTimeout(timeoutId);
101
+ } catch (_error) {
102
+ // HEAD not supported or network error — skip validation
103
+ return;
104
+ }
105
+
106
+ if (response.status === 405) return;
107
+
108
+ if (!response.ok) {
109
+ throw new Error(`Failed to fetch image: ${response.statusText}`);
110
+ }
111
+
112
+ const contentType = response.headers.get("content-type")?.split(";")[0]
113
+ .trim();
114
+ if (!contentType || !["image/jpeg", "image/png"].includes(contentType)) {
115
+ throw new Error("Image format must be JPEG or PNG");
116
+ }
117
+
118
+ const contentLength = response.headers.get("content-length");
119
+ if (contentLength && parseInt(contentLength, 10) > 8 * 1024 * 1024) {
120
+ throw new Error("Image file size must not exceed 8 MB");
121
+ }
122
+ }
123
+
124
+ async function validateVideoSpecs(videoUrl: string): Promise<void> {
125
+ let response: Response;
126
+ try {
127
+ const controller = new AbortController();
128
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
129
+ response = await fetch(videoUrl, {
130
+ method: "HEAD",
131
+ signal: controller.signal,
132
+ });
133
+ clearTimeout(timeoutId);
134
+ } catch (_error) {
135
+ // HEAD not supported or network error — skip validation
136
+ return;
137
+ }
138
+
139
+ if (response.status === 405) return;
140
+
141
+ if (!response.ok) {
142
+ throw new Error(`Failed to fetch video: ${response.statusText}`);
143
+ }
144
+
145
+ const contentType = response.headers.get("content-type")?.split(";")[0]
146
+ .trim();
147
+ if (!contentType || !["video/quicktime", "video/mp4"].includes(contentType)) {
148
+ throw new Error("Video format must be MOV or MP4");
149
+ }
150
+
151
+ const contentLength = response.headers.get("content-length");
152
+ if (contentLength && parseInt(contentLength, 10) > 1024 * 1024 * 1024) {
153
+ throw new Error("Video file size must not exceed 1 GB");
154
+ }
155
+ }
156
+
157
+ function validateLinkUrl(url: string): void {
158
+ // Pattern to ensure URL starts with http:// or https:// and has a valid domain
159
+ const urlPattern = /^https?:\/\/[\w.-]+\.[a-zA-Z]{2,}/;
160
+
161
+ if (!urlPattern.test(url)) {
162
+ throw new Error(
163
+ "Invalid URL format for linkAttachment. URL must start with http:// or https:// and contain a valid domain",
164
+ );
165
+ }
166
+ }
@@ -1,174 +0,0 @@
1
- // mock_threads_api.ts
2
-
3
- import type {
4
- ThreadsContainer,
5
- ThreadsPost,
6
- ThreadsProfile,
7
- PublishingLimit,
8
- ThreadsPostRequest,
9
- ThreadsListResponse,
10
- } from "./types.ts";
11
-
12
- export class MockThreadsAPI implements MockThreadsAPI {
13
- private containers: Map<string, ThreadsContainer> = new Map();
14
- private posts: Map<string, ThreadsPost> = new Map();
15
- private users: Map<string, ThreadsProfile> = new Map();
16
- private publishingLimits: Map<string, PublishingLimit> = new Map();
17
- private errorMode = false;
18
-
19
- constructor() {
20
- // Initialize with some sample data
21
- this.users.set("12345", {
22
- id: "12345",
23
- username: "testuser",
24
- name: "Test User",
25
- threadsProfilePictureUrl: "https://example.com/profile.jpg",
26
- threadsBiography: "This is a test user",
27
- });
28
-
29
- this.publishingLimits.set("12345", {
30
- quota_usage: 10,
31
- config: {
32
- quota_total: 250,
33
- quota_duration: 86400,
34
- },
35
- });
36
- }
37
-
38
- setErrorMode(mode: boolean) {
39
- this.errorMode = mode;
40
- }
41
-
42
- createThreadsContainer(
43
- request: ThreadsPostRequest
44
- ): Promise<string | { id: string; permalink: string }> {
45
- if (this.errorMode) {
46
- return Promise.reject(new Error("Failed to create Threads container"));
47
- }
48
- const containerId = `container_${Math.random().toString(36).substring(7)}`;
49
- const permalink = `https://www.threads.net/@${request.userId}/post/${containerId}`;
50
- const container: ThreadsContainer = {
51
- id: containerId,
52
- permalink,
53
- status: "FINISHED",
54
- };
55
- this.containers.set(containerId, container);
56
-
57
- // Create a post immediately when creating a container
58
- const postId = `post_${Math.random().toString(36).substring(7)}`;
59
- const post: ThreadsPost = {
60
- id: postId,
61
- media_product_type: "THREADS",
62
- media_type: request.mediaType,
63
- permalink,
64
- owner: { id: request.userId },
65
- username: "testuser",
66
- text: request.text || "",
67
- timestamp: new Date().toISOString(),
68
- shortcode: postId,
69
- is_quote_post: false,
70
- hasReplies: false,
71
- isReply: false,
72
- isReplyOwnedByMe: false,
73
- };
74
- this.posts.set(postId, post);
75
-
76
- // Always return an object with both id and permalink
77
- return Promise.resolve({ id: containerId, permalink });
78
- }
79
-
80
- publishThreadsContainer(
81
- _userId: string,
82
- _accessToken: string,
83
- containerId: string,
84
- getPermalink: boolean = false
85
- ): Promise<string | { id: string; permalink: string }> {
86
- if (this.errorMode) {
87
- return Promise.reject(new Error("Failed to publish Threads container"));
88
- }
89
- const container = this.containers.get(containerId);
90
- if (!container) {
91
- return Promise.reject(new Error("Container not found"));
92
- }
93
-
94
- // Find the post associated with this container
95
- const existingPost = Array.from(this.posts.values()).find(
96
- (post) => post.permalink === container.permalink
97
- );
98
-
99
- if (!existingPost) {
100
- return Promise.reject(
101
- new Error("Post not found for the given container")
102
- );
103
- }
104
-
105
- return Promise.resolve(
106
- getPermalink
107
- ? { id: existingPost.id, permalink: existingPost.permalink || "" }
108
- : existingPost.id
109
- );
110
- }
111
-
112
- createCarouselItem(
113
- request: Omit<ThreadsPostRequest, "mediaType"> & {
114
- mediaType: "IMAGE" | "VIDEO";
115
- }
116
- ): Promise<string | { id: string }> {
117
- const itemId = `item_${Math.random().toString(36).substring(7)}`;
118
- const container: ThreadsContainer = {
119
- id: itemId,
120
- permalink: `https://www.threads.net/@${request.userId}/post/${itemId}`,
121
- status: "FINISHED",
122
- };
123
- this.containers.set(itemId, container);
124
- return Promise.resolve({ id: itemId });
125
- }
126
-
127
- getPublishingLimit(
128
- userId: string,
129
- _accessToken: string
130
- ): Promise<PublishingLimit> {
131
- if (this.errorMode) {
132
- return Promise.reject(new Error("Failed to get publishing limit"));
133
- }
134
- const limit = this.publishingLimits.get(userId);
135
- if (!limit) {
136
- return Promise.reject(new Error("Publishing limit not found"));
137
- }
138
- return Promise.resolve(limit);
139
- }
140
-
141
- getThreadsList(
142
- userId: string,
143
- _accessToken: string,
144
- options?: {
145
- since?: string;
146
- until?: string;
147
- limit?: number;
148
- after?: string;
149
- before?: string;
150
- }
151
- ): Promise<ThreadsListResponse> {
152
- const threads = Array.from(this.posts.values())
153
- .filter((post) => post.owner.id === userId)
154
- .slice(0, options?.limit || 25);
155
-
156
- return Promise.resolve({
157
- data: threads as ThreadsPost[],
158
- paging: {
159
- cursors: {
160
- before: "BEFORE_CURSOR",
161
- after: "AFTER_CURSOR",
162
- },
163
- },
164
- });
165
- }
166
-
167
- getSingleThread(mediaId: string, _accessToken: string): Promise<ThreadsPost> {
168
- const post = this.posts.get(mediaId);
169
- if (!post) {
170
- return Promise.reject(new Error("Thread not found"));
171
- }
172
- return Promise.resolve(post as ThreadsPost);
173
- }
174
- }
package/types.ts DELETED
@@ -1,235 +0,0 @@
1
- // types.ts
2
-
3
- /**
4
- * Represents the types of media that can be posted on Threads.
5
- */
6
- export type MediaType = "TEXT" | "IMAGE" | "VIDEO" | "CAROUSEL";
7
-
8
- /**
9
- * Represents the options for controlling who can reply to a post.
10
- */
11
- export type ReplyControl =
12
- | "everyone"
13
- | "accounts_you_follow"
14
- | "mentioned_only";
15
-
16
- /**
17
- * Represents a request to post content on Threads.
18
- */
19
- export interface ThreadsPostRequest {
20
- /** The user ID of the Threads account */
21
- userId: string;
22
- /** The access token for authentication */
23
- accessToken: string;
24
- /** The type of media being posted */
25
- mediaType: MediaType;
26
- /** The text content of the post (optional) */
27
- text?: string;
28
- /** The URL of the image to be posted (optional, for IMAGE type) */
29
- imageUrl?: string;
30
- /** The URL of the video to be posted (optional, for VIDEO type) */
31
- videoUrl?: string;
32
- /** The accessibility text for the image or video (optional) */
33
- altText?: string;
34
- /** The URL to be attached as a link to the post (optional, for text posts only) */
35
- linkAttachment?: string;
36
- /** List of country codes where the post should be visible (optional - requires special API access) */
37
- allowlistedCountryCodes?: string[];
38
- /** Controls who can reply to the post (optional) */
39
- replyControl?: ReplyControl;
40
- /** Array of carousel item IDs (required for CAROUSEL type, not applicable for other types) */
41
- children?: string[];
42
- /** Whether to return the permalink of the post (optional, default: false) */
43
- getPermalink?: boolean;
44
- }
45
-
46
- /**
47
- * Represents a single Threads media object.
48
- */
49
- export interface ThreadsPost {
50
- /** Unique identifier for the media object */
51
- id: string;
52
- /** Type of product where the media is published (e.g., "THREADS") */
53
- media_product_type: string;
54
- /** Type of media (e.g., "TEXT", "IMAGE", "VIDEO", "CAROUSEL") */
55
- media_type: MediaType;
56
- /** URL of the media content (if applicable) */
57
- media_url?: string;
58
- /** Permanent link to the post */
59
- permalink?: string;
60
- /** Information about the owner of the post */
61
- owner: { id: string };
62
- /** Username of the account that created the post */
63
- username: string;
64
- /** Text content of the post */
65
- text?: string;
66
- /** Timestamp of when the post was created (ISO 8601 format) */
67
- timestamp: string;
68
- /** Short code identifier for the media */
69
- shortcode: string;
70
- /** URL of the thumbnail image (for video posts) */
71
- thumbnail_url?: string;
72
- /** List of child posts (for carousel posts) */
73
- children?: ThreadsPost[];
74
- /** Indicates if the post is a quote of another post */
75
- is_quote_post: boolean;
76
- /** Accessibility text for the image or video */
77
- altText?: string;
78
- /** URL of the attached link */
79
- linkAttachmentUrl?: string;
80
- /** Indicates if the post has replies */
81
- hasReplies: boolean;
82
- /** Indicates if the post is a reply to another post */
83
- isReply: boolean;
84
- /** Indicates if the reply is owned by the current user */
85
- isReplyOwnedByMe: boolean;
86
- /** Information about the root post (for replies) */
87
- rootPost?: { id: string };
88
- /** Information about the post being replied to */
89
- repliedTo?: { id: string };
90
- /** Visibility status of the post */
91
- hideStatus?: "VISIBLE" | "HIDDEN";
92
- /** Controls who can reply to the post */
93
- replyAudience?: ReplyControl;
94
- }
95
-
96
- /**
97
- * Represents the response structure when retrieving a list of Threads.
98
- */
99
- export interface ThreadsListResponse {
100
- /** Array of ThreadsPost representing the retrieved posts */
101
- data: ThreadsPost[];
102
- /** Pagination information */
103
- paging?: {
104
- /** Cursors for navigating through pages of results */
105
- cursors: {
106
- /** Cursor for the previous page */
107
- before: string;
108
- /** Cursor for the next page */
109
- after: string;
110
- };
111
- };
112
- }
113
-
114
- /**
115
- * Represents the publishing limit information for a user.
116
- */
117
- export interface PublishingLimit {
118
- /** Current usage count towards the quota */
119
- quota_usage: number;
120
- /** Configuration for the publishing limit */
121
- config: {
122
- /** Total allowed quota */
123
- quota_total: number;
124
- /** Duration of the quota period in seconds */
125
- quota_duration: number;
126
- };
127
- }
128
-
129
- /**
130
- * Represents a Threads media container.
131
- */
132
- export interface ThreadsContainer {
133
- /** Unique identifier for the container */
134
- id: string;
135
- /** Permanent link to the container */
136
- permalink: string;
137
- /** Status of the container */
138
- status: "FINISHED" | "FAILED";
139
- /** Error message if the container failed */
140
- errorMessage?: string;
141
- }
142
-
143
- /**
144
- * Represents a Threads user profile.
145
- */
146
- export interface ThreadsProfile {
147
- /** Unique identifier for the user */
148
- id: string;
149
- /** Username of the account */
150
- username: string;
151
- /** Display name of the user */
152
- name: string;
153
- /** URL of the user's profile picture */
154
- threadsProfilePictureUrl: string;
155
- /** Biography text of the user */
156
- threadsBiography: string;
157
- }
158
-
159
- /**
160
- * Represents the mock API for Threads operations.
161
- */
162
- export interface MockThreadsAPI {
163
- /**
164
- * Creates a Threads media container.
165
- * @param request The request object containing post details
166
- * @returns A promise that resolves to either a string ID or an object with ID and permalink
167
- */
168
- createThreadsContainer(
169
- request: ThreadsPostRequest
170
- ): Promise<string | { id: string; permalink: string }>;
171
-
172
- /**
173
- * Publishes a Threads media container.
174
- * @param userId The user ID
175
- * @param accessToken The access token
176
- * @param containerId The ID of the container to publish
177
- * @param getPermalink Whether to return the permalink
178
- * @returns A promise that resolves to either a string ID or an object with ID and permalink
179
- */
180
- publishThreadsContainer(
181
- userId: string,
182
- accessToken: string,
183
- containerId: string,
184
- getPermalink?: boolean
185
- ): Promise<string | { id: string; permalink: string }>;
186
-
187
- /**
188
- * Creates a carousel item for a Threads post.
189
- * @param request The request object containing carousel item details
190
- * @returns A promise that resolves to either a string ID or an object with ID
191
- */
192
- createCarouselItem(
193
- request: Omit<ThreadsPostRequest, "mediaType"> & {
194
- mediaType: "IMAGE" | "VIDEO";
195
- }
196
- ): Promise<string | { id: string }>;
197
-
198
- /**
199
- * Retrieves the publishing limit for a user.
200
- * @param userId The user ID
201
- * @param accessToken The access token
202
- * @returns A promise that resolves to the publishing limit information
203
- */
204
- getPublishingLimit(
205
- userId: string,
206
- accessToken: string
207
- ): Promise<PublishingLimit>;
208
-
209
- /**
210
- * Retrieves a list of Threads posts for a user.
211
- * @param userId The user ID
212
- * @param accessToken The access token
213
- * @param options Optional parameters for pagination and date range
214
- * @returns A promise that resolves to the Threads list response
215
- */
216
- getThreadsList(
217
- userId: string,
218
- accessToken: string,
219
- options?: {
220
- since?: string;
221
- until?: string;
222
- limit?: number;
223
- after?: string;
224
- before?: string;
225
- }
226
- ): Promise<ThreadsListResponse>;
227
-
228
- /**
229
- * Retrieves a single Thread post.
230
- * @param mediaId The ID of the media to retrieve
231
- * @param accessToken The access token
232
- * @returns A promise that resolves to the Thread post
233
- */
234
- getSingleThread(mediaId: string, accessToken: string): Promise<ThreadsPost>;
235
- }