@codybrom/denim 1.3.4 → 1.3.5

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/mod.ts CHANGED
@@ -1,3 +1,27 @@
1
+ // mod.ts
2
+ import type {
3
+ ThreadsPostRequest,
4
+ PublishingLimit,
5
+ ThreadsPost,
6
+ ThreadsListResponse,
7
+ MockThreadsAPI,
8
+ } from "./types.ts";
9
+ export type {
10
+ ThreadsPostRequest,
11
+ PublishingLimit,
12
+ ThreadsPost,
13
+ ThreadsListResponse,
14
+ };
15
+
16
+ /**
17
+ * Retrieves the mock API instance if available.
18
+ *
19
+ * @returns The mock API instance or null if not available
20
+ */
21
+ function getAPI(): MockThreadsAPI | null {
22
+ return (globalThis as { threadsAPI?: MockThreadsAPI }).threadsAPI || null;
23
+ }
24
+
1
25
  /**
2
26
  * @module
3
27
  *
@@ -8,34 +32,6 @@
8
32
  /** The base URL for the Threads API */
9
33
  export const THREADS_API_BASE_URL = "https://graph.threads.net/v1.0";
10
34
 
11
- /**
12
- * Represents a request to post content on Threads.
13
- */
14
- export interface ThreadsPostRequest {
15
- /** The user ID of the Threads account */
16
- userId: string;
17
- /** The access token for authentication */
18
- accessToken: string;
19
- /** The type of media being posted */
20
- mediaType: "TEXT" | "IMAGE" | "VIDEO" | "CAROUSEL";
21
- /** The text content of the post (optional) */
22
- text?: string;
23
- /** The URL of the image to be posted (optional, for IMAGE type) */
24
- imageUrl?: string;
25
- /** The URL of the video to be posted (optional, for VIDEO type) */
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 - requires special API access) */
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[];
37
- }
38
-
39
35
  /**
40
36
  * Creates a Threads media container.
41
37
  *
@@ -58,65 +54,82 @@ export interface ThreadsPostRequest {
58
54
  */
59
55
  export async function createThreadsContainer(
60
56
  request: ThreadsPostRequest
61
- ): Promise<string> {
62
- // Input validation
63
- validateRequest(request);
64
-
65
- const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
66
- const body = new URLSearchParams({
67
- access_token: request.accessToken,
68
- media_type: request.mediaType,
69
- });
70
-
71
- // Add common optional parameters
72
- if (request.text) body.append("text", request.text);
73
- if (request.altText) body.append("alt_text", request.altText);
74
- if (request.replyControl) body.append("reply_control", request.replyControl);
75
- if (request.allowlistedCountryCodes) {
76
- body.append(
77
- "allowlisted_country_codes",
78
- request.allowlistedCountryCodes.join(",")
79
- );
57
+ ): Promise<string | { id: string; permalink: string }> {
58
+ const api = getAPI();
59
+ if (api) {
60
+ // Use mock API
61
+ return api.createThreadsContainer(request);
80
62
  }
63
+ try {
64
+ // Input validation
65
+ validateRequest(request);
66
+
67
+ const url = `${THREADS_API_BASE_URL}/${request.userId}/threads`;
68
+ const body = new URLSearchParams({
69
+ access_token: request.accessToken,
70
+ media_type: request.mediaType,
71
+ });
72
+
73
+ // Add common optional parameters
74
+ if (request.text) body.append("text", request.text);
75
+ if (request.altText) body.append("alt_text", request.altText);
76
+ if (request.replyControl)
77
+ body.append("reply_control", request.replyControl);
78
+ if (request.allowlistedCountryCodes) {
79
+ body.append(
80
+ "allowlisted_country_codes",
81
+ request.allowlistedCountryCodes.join(",")
82
+ );
83
+ }
81
84
 
82
- // Handle media type specific parameters
83
- if (request.mediaType === "VIDEO") {
84
- const videoItemId = await createVideoItemContainer(request);
85
- body.set("media_type", "CAROUSEL");
86
- body.append("children", videoItemId);
87
- } else if (request.mediaType === "IMAGE" && request.imageUrl) {
88
- body.append("image_url", request.imageUrl);
89
- } else if (request.mediaType === "TEXT" && request.linkAttachment) {
90
- body.append("link_attachment", request.linkAttachment);
91
- } else if (request.mediaType === "CAROUSEL" && request.children) {
92
- body.append("children", request.children.join(","));
93
- }
85
+ // Handle media type specific parameters
86
+ if (request.mediaType === "VIDEO" && request.videoUrl) {
87
+ const videoItemId = await createVideoItemContainer(request);
88
+ body.set("media_type", "CAROUSEL");
89
+ body.append("children", videoItemId);
90
+ } else if (request.mediaType === "IMAGE" && request.imageUrl) {
91
+ body.append("image_url", request.imageUrl);
92
+ } else if (request.mediaType === "TEXT" && request.linkAttachment) {
93
+ body.append("link_attachment", request.linkAttachment);
94
+ } else if (request.mediaType === "CAROUSEL" && request.children) {
95
+ body.append("children", request.children.join(","));
96
+ }
94
97
 
95
- console.log(`Sending request to: ${url}`);
96
- console.log(`Request body: ${body.toString()}`);
98
+ console.log(`Sending request to: ${url}`);
99
+ console.log(`Request body: ${body.toString()}`);
97
100
 
98
- const response = await fetch(url, {
99
- method: "POST",
100
- body: body,
101
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
102
- });
101
+ const response = await fetch(url, {
102
+ method: "POST",
103
+ body: body,
104
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
105
+ });
103
106
 
104
- const responseText = await response.text();
105
- console.log(`Response status: ${response.status} ${response.statusText}`);
106
- console.log(`Response body: ${responseText}`);
107
+ const responseText = await response.text();
107
108
 
108
- if (!response.ok) {
109
- throw new Error(
110
- `Failed to create Threads container: ${response.statusText}. Details: ${responseText}`
111
- );
112
- }
109
+ console.log(`Response status: ${response.status} ${response.statusText}`);
110
+ console.log(`Response body: ${responseText}`);
111
+
112
+ if (!response.ok) {
113
+ throw new Error(`Internal Server Error. Details: ${responseText}`);
114
+ }
113
115
 
114
- try {
115
116
  const data = JSON.parse(responseText);
116
- return data.id;
117
+
118
+ // If getPermalink is true, fetch the permalink
119
+ if (request.getPermalink) {
120
+ const threadData = await getSingleThread(data.id, request.accessToken);
121
+ return {
122
+ id: data.id,
123
+ permalink: threadData.permalink || "",
124
+ };
125
+ } else {
126
+ return data.id;
127
+ }
117
128
  } catch (error) {
118
- console.error(`Failed to parse response JSON: ${error}`);
119
- throw new Error(`Invalid response from Threads API: ${responseText}`);
129
+ // Access error message safely
130
+ const errorMessage =
131
+ error instanceof Error ? error.message : "Unknown Error";
132
+ throw new Error(`Failed to create Threads container: ${errorMessage}`);
120
133
  }
121
134
  }
122
135
 
@@ -168,6 +181,7 @@ function validateRequest(request: ThreadsPostRequest): void {
168
181
 
169
182
  /**
170
183
  * Creates a video item container for Threads.
184
+ *
171
185
  * @param request - The ThreadsPostRequest object containing video post details
172
186
  * @returns A Promise that resolves to the video item container ID
173
187
  * @throws Will throw an error if the API request fails
@@ -242,7 +256,12 @@ export async function createCarouselItem(
242
256
  request: Omit<ThreadsPostRequest, "mediaType"> & {
243
257
  mediaType: "IMAGE" | "VIDEO";
244
258
  }
245
- ): Promise<string> {
259
+ ): Promise<string | { id: string }> {
260
+ const api = getAPI();
261
+ if (api) {
262
+ // Use mock API
263
+ return api.createCarouselItem(request);
264
+ }
246
265
  if (request.mediaType !== "IMAGE" && request.mediaType !== "VIDEO") {
247
266
  throw new Error("Carousel items must be either IMAGE or VIDEO type");
248
267
  }
@@ -292,6 +311,7 @@ export async function createCarouselItem(
292
311
 
293
312
  /**
294
313
  * Checks the status of a Threads container.
314
+ *
295
315
  * @param containerId - The ID of the container to check
296
316
  * @param accessToken - The access token for authentication
297
317
  * @returns A Promise that resolves to the container status
@@ -329,56 +349,91 @@ async function checkContainerStatus(
329
349
  export async function publishThreadsContainer(
330
350
  userId: string,
331
351
  accessToken: string,
332
- containerId: string
333
- ): Promise<string> {
334
- const publishUrl = `${THREADS_API_BASE_URL}/${userId}/threads_publish`;
335
- const publishBody = new URLSearchParams({
336
- access_token: accessToken,
337
- creation_id: containerId,
338
- });
339
-
340
- const publishResponse = await fetch(publishUrl, {
341
- method: "POST",
342
- body: publishBody,
343
- headers: {
344
- "Content-Type": "application/x-www-form-urlencoded",
345
- },
346
- });
347
-
348
- if (!publishResponse.ok) {
349
- throw new Error(
350
- `Failed to publish Threads container: ${publishResponse.statusText}`
352
+ containerId: string,
353
+ getPermalink: boolean = false
354
+ ): Promise<string | { id: string; permalink: string }> {
355
+ const api = getAPI();
356
+ if (api) {
357
+ // Use mock API
358
+ return api.publishThreadsContainer(
359
+ userId,
360
+ accessToken,
361
+ containerId,
362
+ getPermalink
351
363
  );
352
364
  }
365
+ try {
366
+ const publishUrl = `${THREADS_API_BASE_URL}/${userId}/threads_publish`;
367
+ const publishBody = new URLSearchParams({
368
+ access_token: accessToken,
369
+ creation_id: containerId,
370
+ });
371
+
372
+ const publishResponse = await fetch(publishUrl, {
373
+ method: "POST",
374
+ body: publishBody,
375
+ headers: {
376
+ "Content-Type": "application/x-www-form-urlencoded",
377
+ },
378
+ });
379
+
380
+ if (!publishResponse.ok) {
381
+ throw new Error(
382
+ `Failed to publish Threads container: ${publishResponse.statusText}`
383
+ );
384
+ }
353
385
 
354
- // Check container status
355
- let status = await checkContainerStatus(containerId, accessToken);
356
- let attempts = 0;
357
- const maxAttempts = 5;
386
+ const publishData = await publishResponse.json();
387
+
388
+ if (getPermalink) {
389
+ const mediaId = publishData.id;
390
+ const permalinkUrl = `${THREADS_API_BASE_URL}/${mediaId}?fields=permalink&access_token=${accessToken}`;
391
+ const permalinkResponse = await fetch(permalinkUrl);
392
+
393
+ if (permalinkResponse.ok) {
394
+ const permalinkData = await permalinkResponse.json();
395
+ return {
396
+ id: mediaId,
397
+ permalink: permalinkData.permalink,
398
+ };
399
+ } else {
400
+ throw new Error("Failed to fetch permalink");
401
+ }
402
+ }
358
403
 
359
- while (
360
- status !== "PUBLISHED" &&
361
- status !== "FINISHED" &&
362
- attempts < maxAttempts
363
- ) {
364
- await new Promise((resolve) => setTimeout(resolve, 60000)); // Wait for 1 minute
365
- status = await checkContainerStatus(containerId, accessToken);
366
- attempts++;
367
- }
404
+ // Check container status
405
+ let status = await checkContainerStatus(containerId, accessToken);
406
+ let attempts = 0;
407
+ const maxAttempts = 5;
408
+
409
+ while (
410
+ status !== "PUBLISHED" &&
411
+ status !== "FINISHED" &&
412
+ attempts < maxAttempts
413
+ ) {
414
+ await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second
415
+ status = await checkContainerStatus(containerId, accessToken);
416
+ attempts++;
417
+ }
368
418
 
369
- if (status === "ERROR") {
370
- throw new Error(`Failed to publish container. Error: ${status}`);
371
- }
419
+ if (status === "ERROR") {
420
+ throw new Error(`Failed to publish container. Error: ${status}`);
421
+ }
372
422
 
373
- if (status !== "PUBLISHED" && status !== "FINISHED") {
374
- throw new Error(
375
- `Container not published after ${maxAttempts} attempts. Current status: ${status}`
376
- );
377
- }
423
+ if (status !== "PUBLISHED" && status !== "FINISHED") {
424
+ throw new Error(
425
+ `Container not published after ${maxAttempts} attempts. Current status: ${status}`
426
+ );
427
+ }
378
428
 
379
- return containerId; // Return the container ID as the published ID
429
+ return publishData.id;
430
+ } catch (error) {
431
+ if (error instanceof Error) {
432
+ throw new Error(`Failed to publish Threads container: ${error.message}`);
433
+ }
434
+ throw error;
435
+ }
380
436
  }
381
-
382
437
  /**
383
438
  * Serves HTTP requests to create and publish Threads posts.
384
439
  *
@@ -386,6 +441,8 @@ export async function publishThreadsContainer(
386
441
  * containing ThreadsPostRequest data. It creates a container and
387
442
  * immediately publishes it.
388
443
  *
444
+ * @throws Will throw an error if the request is invalid or if there's an error during processing
445
+ *
389
446
  * @example
390
447
  * ```typescript
391
448
  * // Start the server
@@ -393,6 +450,8 @@ export async function publishThreadsContainer(
393
450
  * ```
394
451
  */
395
452
  export function serveRequests() {
453
+ const api = getAPI();
454
+
396
455
  Deno.serve(async (req) => {
397
456
  if (req.method !== "POST") {
398
457
  return new Response("Method Not Allowed", { status: 405 });
@@ -416,17 +475,47 @@ export function serveRequests() {
416
475
  );
417
476
  }
418
477
 
419
- // Create the Threads container
420
- const containerId = await createThreadsContainer(requestData);
478
+ let containerResult;
479
+ let publishResult;
480
+
481
+ if (api) {
482
+ // Use mock API
483
+ containerResult = await api.createThreadsContainer(requestData);
484
+ publishResult = await api.publishThreadsContainer(
485
+ requestData.userId,
486
+ requestData.accessToken,
487
+ typeof containerResult === "string"
488
+ ? containerResult
489
+ : containerResult.id,
490
+ requestData.getPermalink
491
+ );
492
+ } else {
493
+ // Use real API calls
494
+ containerResult = await createThreadsContainer(requestData);
495
+ if (typeof containerResult === "string") {
496
+ publishResult = await publishThreadsContainer(
497
+ requestData.userId,
498
+ requestData.accessToken,
499
+ containerResult,
500
+ requestData.getPermalink
501
+ );
502
+ } else {
503
+ publishResult = containerResult;
504
+ }
505
+ }
421
506
 
422
- // Immediately attempt to publish the Threads container
423
- const publishedId = await publishThreadsContainer(
424
- requestData.userId,
425
- requestData.accessToken,
426
- containerId
427
- );
507
+ let responseData;
508
+ if (typeof publishResult === "string") {
509
+ responseData = { success: true, publishedId: publishResult };
510
+ } else {
511
+ responseData = {
512
+ success: true,
513
+ publishedId: publishResult.id,
514
+ permalink: publishResult.permalink,
515
+ };
516
+ }
428
517
 
429
- return new Response(JSON.stringify({ success: true, publishedId }), {
518
+ return new Response(JSON.stringify(responseData), {
430
519
  headers: { "Content-Type": "application/json" },
431
520
  });
432
521
  } catch (error) {
@@ -462,13 +551,12 @@ export function serveRequests() {
462
551
  export async function getPublishingLimit(
463
552
  userId: string,
464
553
  accessToken: string
465
- ): Promise<{
466
- quota_usage: number;
467
- config: {
468
- quota_total: number;
469
- quota_duration: number;
470
- };
471
- }> {
554
+ ): Promise<PublishingLimit> {
555
+ const api = getAPI();
556
+ if (api) {
557
+ // Use mock API
558
+ return api.getPublishingLimit(userId, accessToken);
559
+ }
472
560
  const url = `${THREADS_API_BASE_URL}/${userId}/threads_publishing_limit`;
473
561
  const params = new URLSearchParams({
474
562
  access_token: accessToken,
@@ -476,15 +564,94 @@ export async function getPublishingLimit(
476
564
  });
477
565
 
478
566
  const response = await fetch(`${url}?${params}`);
567
+ if (!response.ok) {
568
+ throw new Error(`Failed to get publishing limit: ${response.statusText}`);
569
+ }
570
+
479
571
  const data = await response.json();
572
+ return data.data[0];
573
+ }
480
574
 
575
+ /**
576
+ * Retrieves a list of all threads created by a user.
577
+ *
578
+ * @param userId - The user ID of the Threads account
579
+ * @param accessToken - The access token for authentication
580
+ * @param options - Optional parameters for the request
581
+ * @param options.since - Start date for fetching threads (ISO 8601 format)
582
+ * @param options.until - End date for fetching threads (ISO 8601 format)
583
+ * @param options.limit - Maximum number of threads to return
584
+ * @param options.after - Cursor for pagination (next page)
585
+ * @param options.before - Cursor for pagination (previous page)
586
+ * @returns A Promise that resolves to the ThreadsListResponse
587
+ * @throws Will throw an error if the API request fails
588
+ */
589
+ export async function getThreadsList(
590
+ userId: string,
591
+ accessToken: string,
592
+ options?: {
593
+ since?: string;
594
+ until?: string;
595
+ limit?: number;
596
+ after?: string;
597
+ before?: string;
598
+ }
599
+ ): Promise<ThreadsListResponse> {
600
+ const api = getAPI();
601
+ if (api) {
602
+ // Use mock API
603
+ return api.getThreadsList(userId, accessToken, options);
604
+ }
605
+ const fields =
606
+ "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post";
607
+ const url = new URL(`${THREADS_API_BASE_URL}/${userId}/threads`);
608
+ url.searchParams.append("fields", fields);
609
+ url.searchParams.append("access_token", accessToken);
610
+
611
+ if (options) {
612
+ if (options.since) url.searchParams.append("since", options.since);
613
+ if (options.until) url.searchParams.append("until", options.until);
614
+ if (options.limit)
615
+ url.searchParams.append("limit", options.limit.toString());
616
+ if (options.after) url.searchParams.append("after", options.after);
617
+ if (options.before) url.searchParams.append("before", options.before);
618
+ }
619
+
620
+ const response = await fetch(url.toString());
481
621
  if (!response.ok) {
482
- throw new Error(
483
- `Failed to get publishing limit: ${
484
- data.error?.message || response.statusText
485
- }`
486
- );
622
+ throw new Error(`Failed to retrieve threads list: ${response.statusText}`);
487
623
  }
488
624
 
489
- return data.data[0];
625
+ return await response.json();
626
+ }
627
+
628
+ /**
629
+ * Retrieves a single Threads media object.
630
+ *
631
+ * @param mediaId - The ID of the Threads media object
632
+ * @param accessToken - The access token for authentication
633
+ * @returns A Promise that resolves to the ThreadsPost object
634
+ * @throws Will throw an error if the API request fails
635
+ */
636
+ export async function getSingleThread(
637
+ mediaId: string,
638
+ accessToken: string
639
+ ): Promise<ThreadsPost> {
640
+ const api = getAPI();
641
+ if (api) {
642
+ // Use mock API
643
+ return api.getSingleThread(mediaId, accessToken);
644
+ }
645
+ const fields =
646
+ "id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post";
647
+ const url = new URL(`${THREADS_API_BASE_URL}/${mediaId}`);
648
+ url.searchParams.append("fields", fields);
649
+ url.searchParams.append("access_token", accessToken);
650
+
651
+ const response = await fetch(url.toString());
652
+ if (!response.ok) {
653
+ throw new Error(`Failed to retrieve thread: ${response.statusText}`);
654
+ }
655
+
656
+ return await response.json();
490
657
  }