@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.
Files changed (46) hide show
  1. package/.github/workflows/publish.yml +17 -7
  2. package/.vscode/settings.json +34 -9
  3. package/CHANGELOG.md +137 -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 -635
  8. package/mod_test.ts +1287 -431
  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,39 @@
1
+ import { THREADS_API_BASE_URL } from "../constants.ts";
2
+ /**
3
+ * Checks the status of a Threads container.
4
+ *
5
+ * @param containerId - The ID of the container to check
6
+ * @param accessToken - The access token for authentication
7
+ * @returns A Promise that resolves to the container status and optional error message
8
+ * @throws Will throw an error if the API request fails
9
+ */
10
+ export async function checkContainerStatus(
11
+ containerId: string,
12
+ accessToken: string,
13
+ ): Promise<{
14
+ status: "EXPIRED" | "ERROR" | "FINISHED" | "IN_PROGRESS" | "PUBLISHED";
15
+ error_message?: string;
16
+ }> {
17
+ const url = new URL(`${THREADS_API_BASE_URL}/${containerId}`);
18
+ url.searchParams.append("fields", "status,error_message");
19
+ url.searchParams.append("access_token", accessToken);
20
+
21
+ const response = await fetch(url.toString());
22
+ if (!response.ok) {
23
+ const errorBody = await response.text();
24
+ throw new Error(
25
+ `Failed to check container status (${response.status}): ${errorBody}`,
26
+ );
27
+ }
28
+
29
+ const data = await response.json();
30
+ return {
31
+ status: data.status as
32
+ | "EXPIRED"
33
+ | "ERROR"
34
+ | "FINISHED"
35
+ | "IN_PROGRESS"
36
+ | "PUBLISHED",
37
+ error_message: data.error_message,
38
+ };
39
+ }
@@ -0,0 +1,13 @@
1
+ import type { MockThreadsAPI } from "../types.ts";
2
+ type GlobalWithEnvironment = typeof globalThis & {
3
+ threadsAPI?: MockThreadsAPI;
4
+ };
5
+
6
+ /**
7
+ * Retrieves the mock API instance if available.
8
+ *
9
+ * @returns The mock API instance or null if not available
10
+ */
11
+ export function getAPI(): MockThreadsAPI | null {
12
+ return (globalThis as GlobalWithEnvironment).threadsAPI || null;
13
+ }
@@ -0,0 +1,582 @@
1
+ import type {
2
+ AuthCodeResponse,
3
+ CursorPaginationOptions,
4
+ DebugTokenInfo,
5
+ KeywordSearchOptions,
6
+ LocationSearchOptions,
7
+ MediaInsightsResponse,
8
+ MockThreadsAPI,
9
+ OEmbedResponse,
10
+ PaginationOptions,
11
+ PublicProfile,
12
+ PublishingLimit,
13
+ ResponseMediaType,
14
+ ThreadsContainer,
15
+ ThreadsListResponse,
16
+ ThreadsLocation,
17
+ ThreadsPost,
18
+ ThreadsPostRequest,
19
+ ThreadsProfile,
20
+ TokenResponse,
21
+ UserInsightsOptions,
22
+ UserInsightsResponse,
23
+ } from "../types.ts";
24
+
25
+ export class MockThreadsAPIImpl implements MockThreadsAPI {
26
+ private containers: Map<string, ThreadsContainer> = new Map();
27
+ private posts: Map<string, ThreadsPost> = new Map();
28
+ private users: Map<string, ThreadsProfile> = new Map();
29
+ private publishingLimits: Map<string, PublishingLimit> = new Map();
30
+ private errorMode = false;
31
+
32
+ constructor() {
33
+ // Initialize with some sample data
34
+ this.users.set("12345", {
35
+ id: "12345",
36
+ username: "testuser",
37
+ name: "Test User",
38
+ threads_profile_picture_url: "https://example.com/profile.jpg",
39
+ threads_biography: "This is a test user",
40
+ is_verified: false,
41
+ });
42
+
43
+ this.publishingLimits.set("12345", {
44
+ quota_usage: 10,
45
+ config: {
46
+ quota_total: 250,
47
+ quota_duration: 86400,
48
+ },
49
+ reply_quota_usage: 5,
50
+ reply_config: {
51
+ quota_total: 1000,
52
+ quota_duration: 86400,
53
+ },
54
+ });
55
+ }
56
+
57
+ setErrorMode(mode: boolean) {
58
+ this.errorMode = mode;
59
+ }
60
+
61
+ createThreadsContainer(
62
+ request: ThreadsPostRequest,
63
+ ): Promise<string> {
64
+ if (this.errorMode) {
65
+ return Promise.reject(new Error("Failed to create Threads container"));
66
+ }
67
+ const containerId = `container_${Math.random().toString(36).substring(7)}`;
68
+ const permalink =
69
+ `https://www.threads.net/@${request.userId}/post/${containerId}`;
70
+ const container: ThreadsContainer = {
71
+ id: containerId,
72
+ permalink,
73
+ status: "FINISHED",
74
+ };
75
+ this.containers.set(containerId, container);
76
+
77
+ // Create a post immediately when creating a container
78
+ const postId = `post_${Math.random().toString(36).substring(7)}`;
79
+ const post: ThreadsPost = {
80
+ id: postId,
81
+ media_product_type: "THREADS",
82
+ media_type: request.mediaType as ResponseMediaType,
83
+ permalink,
84
+ owner: { id: request.userId },
85
+ username: "testuser",
86
+ text: request.text || "",
87
+ timestamp: new Date().toISOString(),
88
+ shortcode: postId,
89
+ is_quote_post: false,
90
+ has_replies: false,
91
+ is_reply: false,
92
+ is_reply_owned_by_me: false,
93
+ };
94
+ this.posts.set(postId, post);
95
+
96
+ return Promise.resolve(containerId);
97
+ }
98
+
99
+ publishThreadsContainer(
100
+ _userId: string,
101
+ _accessToken: string,
102
+ containerId: string,
103
+ getPermalink: boolean = false,
104
+ ): Promise<string | { id: string; permalink: string }> {
105
+ if (this.errorMode) {
106
+ return Promise.reject(new Error("Failed to publish Threads container"));
107
+ }
108
+ const container = this.containers.get(containerId);
109
+ if (!container) {
110
+ return Promise.reject(new Error("Container not found"));
111
+ }
112
+
113
+ // Find the post associated with this container
114
+ const existingPost = Array.from(this.posts.values()).find(
115
+ (post) => post.permalink === container.permalink,
116
+ );
117
+
118
+ if (!existingPost) {
119
+ return Promise.reject(
120
+ new Error("Post not found for the given container"),
121
+ );
122
+ }
123
+
124
+ return Promise.resolve(
125
+ getPermalink
126
+ ? {
127
+ id: existingPost.id,
128
+ permalink: existingPost.permalink || "",
129
+ }
130
+ : existingPost.id,
131
+ );
132
+ }
133
+
134
+ createCarouselItem(
135
+ request: Omit<ThreadsPostRequest, "mediaType"> & {
136
+ mediaType: "IMAGE" | "VIDEO";
137
+ },
138
+ ): Promise<string> {
139
+ if (this.errorMode) {
140
+ return Promise.reject(new Error("Failed to create carousel item"));
141
+ }
142
+ const itemId = `item_${Math.random().toString(36).substring(7)}`;
143
+ const container: ThreadsContainer = {
144
+ id: itemId,
145
+ permalink: `https://www.threads.net/@${request.userId}/post/${itemId}`,
146
+ status: "FINISHED",
147
+ };
148
+ this.containers.set(itemId, container);
149
+ return Promise.resolve(itemId);
150
+ }
151
+
152
+ getPublishingLimit(
153
+ userId: string,
154
+ _accessToken: string,
155
+ _fields?: string[],
156
+ ): Promise<PublishingLimit> {
157
+ if (this.errorMode) {
158
+ return Promise.reject(new Error("Failed to get publishing limit"));
159
+ }
160
+ const limit = this.publishingLimits.get(userId);
161
+ if (!limit) {
162
+ return Promise.reject(new Error("Publishing limit not found"));
163
+ }
164
+ return Promise.resolve(limit);
165
+ }
166
+
167
+ getThreadsList(
168
+ userId: string,
169
+ _accessToken: string,
170
+ options?: PaginationOptions,
171
+ _fields?: string[],
172
+ ): Promise<ThreadsListResponse> {
173
+ if (this.errorMode) {
174
+ return Promise.reject(new Error("Failed to retrieve threads list"));
175
+ }
176
+ const threads = Array.from(this.posts.values())
177
+ .filter((post) => post.owner?.id === userId)
178
+ .slice(0, options?.limit || 25);
179
+
180
+ return Promise.resolve({
181
+ data: threads,
182
+ paging: {
183
+ cursors: {
184
+ before: "BEFORE_CURSOR",
185
+ after: "AFTER_CURSOR",
186
+ },
187
+ },
188
+ });
189
+ }
190
+
191
+ getSingleThread(
192
+ mediaId: string,
193
+ _accessToken: string,
194
+ _fields?: string[],
195
+ ): Promise<ThreadsPost> {
196
+ if (this.errorMode) {
197
+ return Promise.reject(new Error("Failed to retrieve thread"));
198
+ }
199
+ const post = this.posts.get(mediaId);
200
+ if (!post) {
201
+ return Promise.reject(new Error("Thread not found"));
202
+ }
203
+ return Promise.resolve(post);
204
+ }
205
+
206
+ repost(mediaId: string, _accessToken: string): Promise<{ id: string }> {
207
+ if (this.errorMode) {
208
+ return Promise.reject(new Error("Failed to repost"));
209
+ }
210
+ const repostId = `repost_${Math.random().toString(36).substring(7)}`;
211
+ const originalPost = this.posts.get(mediaId);
212
+ if (!originalPost) {
213
+ return Promise.reject(new Error("Post not found"));
214
+ }
215
+ return Promise.resolve({ id: repostId });
216
+ }
217
+
218
+ deleteThread(
219
+ mediaId: string,
220
+ _accessToken: string,
221
+ ): Promise<{ success: boolean; deleted_id?: string }> {
222
+ if (this.errorMode) {
223
+ return Promise.reject(new Error("Failed to delete thread"));
224
+ }
225
+ const post = this.posts.get(mediaId);
226
+ if (!post) {
227
+ return Promise.reject(new Error("Thread not found"));
228
+ }
229
+ this.posts.delete(mediaId);
230
+ return Promise.resolve({ success: true, deleted_id: mediaId });
231
+ }
232
+
233
+ getProfile(
234
+ userId: string,
235
+ _accessToken: string,
236
+ _fields?: string[],
237
+ ): Promise<ThreadsProfile> {
238
+ if (this.errorMode) {
239
+ return Promise.reject(new Error("Failed to get profile"));
240
+ }
241
+ const user = this.users.get(userId);
242
+ if (!user) {
243
+ return Promise.reject(new Error("User not found"));
244
+ }
245
+ return Promise.resolve(user);
246
+ }
247
+
248
+ lookupProfile(
249
+ _accessToken: string,
250
+ username: string,
251
+ _fields?: string[],
252
+ ): Promise<PublicProfile> {
253
+ if (this.errorMode) {
254
+ return Promise.reject(new Error("Failed to look up profile"));
255
+ }
256
+ const user = Array.from(this.users.values()).find(
257
+ (u) => u.username === username,
258
+ );
259
+ if (!user) {
260
+ return Promise.reject(new Error("Profile not found"));
261
+ }
262
+ return Promise.resolve({
263
+ id: user.id,
264
+ username: user.username,
265
+ name: user.name,
266
+ profile_picture_url: user.threads_profile_picture_url,
267
+ biography: user.threads_biography,
268
+ is_verified: user.is_verified,
269
+ });
270
+ }
271
+
272
+ getProfilePosts(
273
+ _accessToken: string,
274
+ _username: string,
275
+ options?: PaginationOptions,
276
+ _fields?: string[],
277
+ ): Promise<ThreadsListResponse> {
278
+ if (this.errorMode) {
279
+ return Promise.reject(new Error("Failed to get profile posts"));
280
+ }
281
+ const posts = Array.from(this.posts.values()).slice(
282
+ 0,
283
+ options?.limit || 25,
284
+ );
285
+ return Promise.resolve({
286
+ data: posts,
287
+ paging: {
288
+ cursors: { before: "BEFORE_CURSOR", after: "AFTER_CURSOR" },
289
+ },
290
+ });
291
+ }
292
+
293
+ getGhostPosts(
294
+ userId: string,
295
+ _accessToken: string,
296
+ options?: PaginationOptions,
297
+ _fields?: string[],
298
+ ): Promise<ThreadsListResponse> {
299
+ if (this.errorMode) {
300
+ return Promise.reject(new Error("Failed to get ghost posts"));
301
+ }
302
+ const posts = Array.from(this.posts.values())
303
+ .filter((p) => p.owner?.id === userId)
304
+ .slice(0, options?.limit || 25);
305
+ return Promise.resolve({
306
+ data: posts,
307
+ paging: {
308
+ cursors: { before: "BEFORE_CURSOR", after: "AFTER_CURSOR" },
309
+ },
310
+ });
311
+ }
312
+
313
+ getUserReplies(
314
+ userId: string,
315
+ _accessToken: string,
316
+ options?: PaginationOptions,
317
+ _fields?: string[],
318
+ ): Promise<ThreadsListResponse> {
319
+ if (this.errorMode) {
320
+ return Promise.reject(new Error("Failed to get user replies"));
321
+ }
322
+ const replies = Array.from(this.posts.values())
323
+ .filter((p) => p.owner?.id === userId && p.is_reply)
324
+ .slice(0, options?.limit || 25);
325
+ return Promise.resolve({
326
+ data: replies,
327
+ paging: {
328
+ cursors: { before: "BEFORE_CURSOR", after: "AFTER_CURSOR" },
329
+ },
330
+ });
331
+ }
332
+
333
+ getReplies(
334
+ _mediaId: string,
335
+ _accessToken: string,
336
+ _options?: CursorPaginationOptions,
337
+ _fields?: string[],
338
+ _reverse?: boolean,
339
+ ): Promise<ThreadsListResponse> {
340
+ if (this.errorMode) {
341
+ return Promise.reject(new Error("Failed to get replies"));
342
+ }
343
+ return Promise.resolve({
344
+ data: Array.from(this.posts.values()).slice(0, 25),
345
+ paging: {
346
+ cursors: { before: "BEFORE_CURSOR", after: "AFTER_CURSOR" },
347
+ },
348
+ });
349
+ }
350
+
351
+ getConversation(
352
+ _mediaId: string,
353
+ _accessToken: string,
354
+ _options?: CursorPaginationOptions,
355
+ _fields?: string[],
356
+ _reverse?: boolean,
357
+ ): Promise<ThreadsListResponse> {
358
+ if (this.errorMode) {
359
+ return Promise.reject(new Error("Failed to get conversation"));
360
+ }
361
+ return Promise.resolve({
362
+ data: Array.from(this.posts.values()).slice(0, 25),
363
+ paging: {
364
+ cursors: { before: "BEFORE_CURSOR", after: "AFTER_CURSOR" },
365
+ },
366
+ });
367
+ }
368
+
369
+ manageReply(
370
+ _replyId: string,
371
+ _accessToken: string,
372
+ _hide: boolean,
373
+ ): Promise<{ success: boolean }> {
374
+ if (this.errorMode) {
375
+ return Promise.reject(new Error("Failed to manage reply"));
376
+ }
377
+ return Promise.resolve({ success: true });
378
+ }
379
+
380
+ getMentions(
381
+ _userId: string,
382
+ _accessToken: string,
383
+ options?: PaginationOptions,
384
+ _fields?: string[],
385
+ ): Promise<ThreadsListResponse> {
386
+ if (this.errorMode) {
387
+ return Promise.reject(new Error("Failed to get mentions"));
388
+ }
389
+ return Promise.resolve({
390
+ data: Array.from(this.posts.values()).slice(0, options?.limit || 25),
391
+ paging: {
392
+ cursors: { before: "BEFORE_CURSOR", after: "AFTER_CURSOR" },
393
+ },
394
+ });
395
+ }
396
+
397
+ getMediaInsights(
398
+ _mediaId: string,
399
+ _accessToken: string,
400
+ metrics: string[],
401
+ ): Promise<MediaInsightsResponse> {
402
+ if (this.errorMode) {
403
+ return Promise.reject(new Error("Failed to get media insights"));
404
+ }
405
+ return Promise.resolve({
406
+ data: metrics.map((m) => ({
407
+ name: m,
408
+ period: "lifetime",
409
+ values: [{ value: 42 }],
410
+ title: m,
411
+ description: `${m} metric`,
412
+ id: `${m}_id`,
413
+ })),
414
+ });
415
+ }
416
+
417
+ getUserInsights(
418
+ _userId: string,
419
+ _accessToken: string,
420
+ metrics: string[],
421
+ _options?: UserInsightsOptions,
422
+ ): Promise<UserInsightsResponse> {
423
+ if (this.errorMode) {
424
+ return Promise.reject(new Error("Failed to get user insights"));
425
+ }
426
+ return Promise.resolve({
427
+ data: metrics.map((m) => ({
428
+ name: m,
429
+ period: "day",
430
+ values: [{ value: 100 }],
431
+ title: m,
432
+ description: `${m} metric`,
433
+ id: `${m}_id`,
434
+ })),
435
+ });
436
+ }
437
+
438
+ searchKeyword(
439
+ _accessToken: string,
440
+ _options: KeywordSearchOptions,
441
+ _fields?: string[],
442
+ ): Promise<ThreadsListResponse> {
443
+ if (this.errorMode) {
444
+ return Promise.reject(new Error("Failed to search keywords"));
445
+ }
446
+ return Promise.resolve({
447
+ data: Array.from(this.posts.values()),
448
+ paging: {
449
+ cursors: { before: "BEFORE_CURSOR", after: "AFTER_CURSOR" },
450
+ },
451
+ });
452
+ }
453
+
454
+ searchLocations(
455
+ _accessToken: string,
456
+ _options: LocationSearchOptions,
457
+ _fields?: string[],
458
+ ): Promise<{ data: ThreadsLocation[] }> {
459
+ if (this.errorMode) {
460
+ return Promise.reject(new Error("Failed to search locations"));
461
+ }
462
+ return Promise.resolve({
463
+ data: [
464
+ {
465
+ id: "loc_123",
466
+ name: "Test Location",
467
+ latitude: 37.7749,
468
+ longitude: -122.4194,
469
+ },
470
+ ],
471
+ });
472
+ }
473
+
474
+ getLocation(
475
+ locationId: string,
476
+ _accessToken: string,
477
+ _fields?: string[],
478
+ ): Promise<ThreadsLocation> {
479
+ if (this.errorMode) {
480
+ return Promise.reject(new Error("Failed to get location"));
481
+ }
482
+ return Promise.resolve({
483
+ id: locationId,
484
+ name: "Test Location",
485
+ address: "123 Test St",
486
+ city: "Test City",
487
+ country: "US",
488
+ latitude: 37.7749,
489
+ longitude: -122.4194,
490
+ });
491
+ }
492
+
493
+ exchangeCodeForToken(
494
+ _clientId: string,
495
+ _clientSecret: string,
496
+ _code: string,
497
+ _redirectUri: string,
498
+ ): Promise<AuthCodeResponse> {
499
+ if (this.errorMode) {
500
+ return Promise.reject(
501
+ new Error("Failed to exchange authorization code"),
502
+ );
503
+ }
504
+ return Promise.resolve({
505
+ access_token: "short_lived_token_abc123",
506
+ user_id: "12345",
507
+ });
508
+ }
509
+
510
+ getAppAccessToken(
511
+ _clientId: string,
512
+ _clientSecret: string,
513
+ ): Promise<TokenResponse> {
514
+ if (this.errorMode) {
515
+ return Promise.reject(new Error("Failed to get app access token"));
516
+ }
517
+ return Promise.resolve({
518
+ access_token: "TH|1234567890|abcd1234",
519
+ token_type: "bearer",
520
+ });
521
+ }
522
+
523
+ exchangeToken(
524
+ _clientSecret: string,
525
+ _accessToken: string,
526
+ ): Promise<TokenResponse> {
527
+ if (this.errorMode) {
528
+ return Promise.reject(new Error("Failed to exchange token"));
529
+ }
530
+ return Promise.resolve({
531
+ access_token: "long_lived_token_abc123",
532
+ token_type: "bearer",
533
+ expires_in: 5184000,
534
+ });
535
+ }
536
+
537
+ refreshToken(_accessToken: string): Promise<TokenResponse> {
538
+ if (this.errorMode) {
539
+ return Promise.reject(new Error("Failed to refresh token"));
540
+ }
541
+ return Promise.resolve({
542
+ access_token: "refreshed_token_abc123",
543
+ token_type: "bearer",
544
+ expires_in: 5184000,
545
+ });
546
+ }
547
+
548
+ debugToken(
549
+ _accessToken: string,
550
+ _inputToken: string,
551
+ ): Promise<DebugTokenInfo> {
552
+ if (this.errorMode) {
553
+ return Promise.reject(new Error("Failed to debug token"));
554
+ }
555
+ return Promise.resolve({
556
+ data: {
557
+ type: "USER",
558
+ application: "Test App",
559
+ is_valid: true,
560
+ scopes: ["threads_basic", "threads_content_publish"],
561
+ user_id: "12345",
562
+ },
563
+ });
564
+ }
565
+
566
+ getOEmbed(
567
+ _accessToken: string,
568
+ _url: string,
569
+ _maxWidth?: number,
570
+ ): Promise<OEmbedResponse> {
571
+ if (this.errorMode) {
572
+ return Promise.reject(new Error("Failed to get oEmbed"));
573
+ }
574
+ return Promise.resolve({
575
+ html: "<blockquote>Embedded Threads post</blockquote>",
576
+ provider_name: "Threads",
577
+ type: "rich",
578
+ version: "1.0",
579
+ width: 550,
580
+ });
581
+ }
582
+ }