tredi-sdk 0.1.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/dist/index.js ADDED
@@ -0,0 +1,890 @@
1
+ // src/constants.ts
2
+ var DEFAULT_BASE_URL = "https://graph.threads.net";
3
+ var AUTHORIZATION_BASE_URL = "https://threads.net";
4
+ var DEFAULT_API_VERSION = "v1.0";
5
+ var DEFAULT_TIMEOUT_MS = 3e4;
6
+ var DEFAULT_RETRY = {
7
+ maxRetries: 2,
8
+ initialDelayMs: 500,
9
+ maxDelayMs: 8e3,
10
+ backoffFactor: 2
11
+ };
12
+ var RATE_LIMIT_ERROR_CODES = /* @__PURE__ */ new Set([4, 17, 32, 613]);
13
+ var INVALID_TOKEN_ERROR_CODE = 190;
14
+ var THREADS_SCOPES = [
15
+ "threads_basic",
16
+ "threads_content_publish",
17
+ "threads_read_replies",
18
+ "threads_manage_replies",
19
+ "threads_manage_insights",
20
+ "threads_keyword_search",
21
+ "threads_delete",
22
+ "threads_location_tagging"
23
+ ];
24
+
25
+ // src/errors.ts
26
+ var ThreadsError = class extends Error {
27
+ constructor(message, options) {
28
+ super(message, options);
29
+ this.name = new.target.name;
30
+ Object.setPrototypeOf(this, new.target.prototype);
31
+ }
32
+ };
33
+ var ThreadsValidationError = class extends ThreadsError {
34
+ };
35
+ var ThreadsTimeoutError = class extends ThreadsError {
36
+ };
37
+ var ThreadsNetworkError = class extends ThreadsError {
38
+ };
39
+ var ThreadsAPIError = class extends ThreadsError {
40
+ status;
41
+ code;
42
+ subcode;
43
+ type;
44
+ fbtraceId;
45
+ constructor(details, options) {
46
+ super(details.message, options);
47
+ this.status = details.status;
48
+ this.code = details.code;
49
+ this.subcode = details.subcode;
50
+ this.type = details.type;
51
+ this.fbtraceId = details.fbtraceId;
52
+ }
53
+ };
54
+ var ThreadsAuthError = class extends ThreadsAPIError {
55
+ };
56
+ var ThreadsRateLimitError = class extends ThreadsAPIError {
57
+ retryAfterMs;
58
+ constructor(details, options) {
59
+ super(details, options);
60
+ this.retryAfterMs = details.retryAfterMs;
61
+ }
62
+ };
63
+ function parseRetryAfterMs(value) {
64
+ if (!value) return void 0;
65
+ const seconds = Number(value);
66
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
67
+ const date = Date.parse(value);
68
+ if (Number.isNaN(date)) return void 0;
69
+ return Math.max(0, date - Date.now());
70
+ }
71
+ function toApiError(status, body, headers) {
72
+ const error = body?.error;
73
+ const details = {
74
+ message: error?.message ?? `Threads API request failed with HTTP ${status}`,
75
+ status,
76
+ code: error?.code,
77
+ subcode: error?.error_subcode,
78
+ type: error?.type,
79
+ fbtraceId: error?.fbtrace_id
80
+ };
81
+ if (status === 401 || details.code === INVALID_TOKEN_ERROR_CODE) {
82
+ return new ThreadsAuthError(details);
83
+ }
84
+ if (status === 429 || details.code != null && RATE_LIMIT_ERROR_CODES.has(details.code)) {
85
+ return new ThreadsRateLimitError({
86
+ ...details,
87
+ retryAfterMs: parseRetryAfterMs(headers?.get("retry-after") ?? null)
88
+ });
89
+ }
90
+ return new ThreadsAPIError(details);
91
+ }
92
+
93
+ // src/logger.ts
94
+ var noopLogger = { log() {
95
+ } };
96
+ var SENSITIVE_KEYS = /* @__PURE__ */ new Set([
97
+ "access_token",
98
+ "client_secret",
99
+ "code"
100
+ ]);
101
+ var REDACTED = "REDACTED";
102
+ function redactParams(params) {
103
+ if (!params) return {};
104
+ const out = {};
105
+ for (const [key, value] of Object.entries(params)) {
106
+ out[key] = SENSITIVE_KEYS.has(key) ? REDACTED : value;
107
+ }
108
+ return out;
109
+ }
110
+ function redactUrl(url) {
111
+ try {
112
+ const parsed = new URL(url);
113
+ for (const key of SENSITIVE_KEYS) {
114
+ if (parsed.searchParams.has(key)) parsed.searchParams.set(key, REDACTED);
115
+ }
116
+ return parsed.toString();
117
+ } catch {
118
+ return url.replace(
119
+ /(access_token|client_secret|code)=[^&\s]+/gi,
120
+ `$1=${REDACTED}`
121
+ );
122
+ }
123
+ }
124
+
125
+ // src/http.ts
126
+ function buildQuery(params) {
127
+ const query = new URLSearchParams();
128
+ for (const [key, value] of Object.entries(params)) {
129
+ if (value === void 0 || value === null) continue;
130
+ if (Array.isArray(value) || typeof value === "object" && !(value instanceof Date)) {
131
+ query.set(key, JSON.stringify(value));
132
+ } else if (value instanceof Date) {
133
+ query.set(key, String(Math.floor(value.getTime() / 1e3)));
134
+ } else {
135
+ query.set(key, String(value));
136
+ }
137
+ }
138
+ return query;
139
+ }
140
+ function appendQuery(url, query) {
141
+ const qs = query.toString();
142
+ if (!qs) return url;
143
+ return url.includes("?") ? `${url}&${qs}` : `${url}?${qs}`;
144
+ }
145
+ function isRetryable(method, error) {
146
+ if (error instanceof ThreadsRateLimitError) return true;
147
+ if (error instanceof ThreadsNetworkError) return true;
148
+ if (error instanceof ThreadsTimeoutError) return method === "GET";
149
+ if (error instanceof ThreadsAPIError) {
150
+ return method === "GET" && error.status != null && error.status >= 500;
151
+ }
152
+ return false;
153
+ }
154
+ function backoffDelayMs(attempt, config, retryAfterMs) {
155
+ if (retryAfterMs != null) return Math.min(retryAfterMs, config.maxDelayMs);
156
+ const exponential = config.initialDelayMs * config.backoffFactor ** attempt;
157
+ const capped = Math.min(exponential, config.maxDelayMs);
158
+ return capped / 2 + Math.random() * (capped / 2);
159
+ }
160
+ function sleep(ms) {
161
+ return new Promise((resolve) => setTimeout(resolve, ms));
162
+ }
163
+ function safeJsonParse(text) {
164
+ try {
165
+ return JSON.parse(text);
166
+ } catch {
167
+ return void 0;
168
+ }
169
+ }
170
+ async function sendOnce(req) {
171
+ const { method, url, params = {}, accessToken, timeoutMs, fetchImpl, signal } = req;
172
+ const controller = new AbortController();
173
+ let timedOut = false;
174
+ const onExternalAbort = () => controller.abort();
175
+ if (signal) {
176
+ if (signal.aborted) controller.abort();
177
+ else signal.addEventListener("abort", onExternalAbort, { once: true });
178
+ }
179
+ const timer = setTimeout(() => {
180
+ timedOut = true;
181
+ controller.abort();
182
+ }, timeoutMs);
183
+ try {
184
+ const withToken = accessToken ? { ...params, access_token: accessToken } : params;
185
+ let finalUrl = url;
186
+ const init = { method, signal: controller.signal };
187
+ if (method === "GET" || method === "DELETE") {
188
+ finalUrl = appendQuery(url, buildQuery(withToken));
189
+ } else {
190
+ init.body = buildQuery(withToken);
191
+ init.headers = { "content-type": "application/x-www-form-urlencoded" };
192
+ }
193
+ let response;
194
+ try {
195
+ response = await fetchImpl(finalUrl, init);
196
+ } catch (cause) {
197
+ if (controller.signal.aborted && timedOut) {
198
+ throw new ThreadsTimeoutError(
199
+ `Threads API request timed out after ${timeoutMs}ms`,
200
+ { cause }
201
+ );
202
+ }
203
+ throw new ThreadsNetworkError("Threads API request failed at the network layer", {
204
+ cause
205
+ });
206
+ }
207
+ const text = await response.text();
208
+ const body = text ? safeJsonParse(text) : void 0;
209
+ if (!response.ok) throw toApiError(response.status, body, response.headers);
210
+ return body;
211
+ } finally {
212
+ clearTimeout(timer);
213
+ if (signal) signal.removeEventListener("abort", onExternalAbort);
214
+ }
215
+ }
216
+ function assertCleanUrl(url) {
217
+ if (/[?#\s]/.test(url)) {
218
+ throw new ThreadsValidationError(
219
+ `Invalid request URL "${url}" \u2014 it contains "?", "#", or whitespace before query params were added. This usually means an unvalidated id (e.g. from a webhook payload) was interpolated into a resource path. Validate ids before passing them to the SDK.`
220
+ );
221
+ }
222
+ }
223
+ async function send(req) {
224
+ assertCleanUrl(req.url);
225
+ const logger = req.logger ?? noopLogger;
226
+ const retryConfig = req.retry === false ? { ...DEFAULT_RETRY, maxRetries: 0 } : req.retry;
227
+ let attempt = 0;
228
+ for (; ; ) {
229
+ const startedAt = Date.now();
230
+ try {
231
+ const result = await sendOnce(req);
232
+ logger.log("debug", "threads.request.success", {
233
+ method: req.method,
234
+ url: redactUrl(req.url),
235
+ attempt,
236
+ durationMs: Date.now() - startedAt
237
+ });
238
+ return result;
239
+ } catch (error) {
240
+ const willRetry = isRetryable(req.method, error) && attempt < retryConfig.maxRetries && !req.signal?.aborted;
241
+ logger.log(willRetry ? "warn" : "error", "threads.request.failure", {
242
+ method: req.method,
243
+ url: redactUrl(req.url),
244
+ attempt,
245
+ durationMs: Date.now() - startedAt,
246
+ willRetry,
247
+ error: error instanceof Error ? error.name : "Unknown"
248
+ });
249
+ if (!willRetry) throw error;
250
+ const retryAfterMs = error instanceof ThreadsRateLimitError ? error.retryAfterMs : void 0;
251
+ await sleep(backoffDelayMs(attempt, retryConfig, retryAfterMs));
252
+ attempt += 1;
253
+ }
254
+ }
255
+ }
256
+
257
+ // src/resources/base.ts
258
+ function fieldsParam(fields) {
259
+ return fields && fields.length > 0 ? fields.join(",") : void 0;
260
+ }
261
+
262
+ // src/resources/insights.ts
263
+ var DEFAULT_MEDIA_METRICS = [
264
+ "views",
265
+ "likes",
266
+ "replies",
267
+ "reposts",
268
+ "quotes",
269
+ "shares"
270
+ ];
271
+ var InsightsResource = class {
272
+ constructor(client) {
273
+ this.client = client;
274
+ }
275
+ client;
276
+ /**
277
+ * Returns engagement metrics for a single post.
278
+ * Requires the `threads_manage_insights` permission.
279
+ */
280
+ media(mediaId, options = {}) {
281
+ const metrics = options.metrics ?? DEFAULT_MEDIA_METRICS;
282
+ return this.client.request({
283
+ method: "GET",
284
+ path: `/${mediaId}/insights`,
285
+ params: { metric: metrics.join(",") },
286
+ signal: options.signal
287
+ });
288
+ }
289
+ /**
290
+ * Returns account-level metrics for a user.
291
+ * Requires the `threads_manage_insights` permission.
292
+ */
293
+ user(options = {}) {
294
+ const node = options.userId ?? this.client.userNode;
295
+ const metrics = options.metrics ?? ["views", "followers_count"];
296
+ return this.client.request({
297
+ method: "GET",
298
+ path: `/${node}/threads_insights`,
299
+ params: {
300
+ metric: metrics.join(","),
301
+ since: options.since,
302
+ until: options.until,
303
+ breakdown: options.breakdown
304
+ },
305
+ signal: options.signal
306
+ });
307
+ }
308
+ };
309
+
310
+ // src/resources/mentions.ts
311
+ var DEFAULT_MENTION_FIELDS = [
312
+ "id",
313
+ "username",
314
+ "text",
315
+ "timestamp",
316
+ "permalink",
317
+ "media_type"
318
+ ];
319
+ var MentionsResource = class {
320
+ constructor(client) {
321
+ this.client = client;
322
+ }
323
+ client;
324
+ /**
325
+ * Lists posts that mention the user. Requires the mentions permission in
326
+ * addition to `threads_basic`.
327
+ */
328
+ list(options = {}) {
329
+ const node = options.userId ?? this.client.userNode;
330
+ return this.client.request({
331
+ method: "GET",
332
+ path: `/${node}/mentions`,
333
+ params: {
334
+ fields: fieldsParam(options.fields ?? DEFAULT_MENTION_FIELDS),
335
+ since: options.since,
336
+ until: options.until,
337
+ limit: options.limit,
338
+ before: options.before,
339
+ after: options.after
340
+ },
341
+ signal: options.signal
342
+ });
343
+ }
344
+ };
345
+
346
+ // src/resources/posts.ts
347
+ var DEFAULT_MEDIA_FIELDS = [
348
+ "id",
349
+ "media_product_type",
350
+ "media_type",
351
+ "media_url",
352
+ "permalink",
353
+ "username",
354
+ "text",
355
+ "timestamp",
356
+ "shortcode",
357
+ "thumbnail_url",
358
+ "is_quote_post"
359
+ ];
360
+ var PostsResource = class {
361
+ constructor(client) {
362
+ this.client = client;
363
+ }
364
+ client;
365
+ /**
366
+ * Lists a user's posts, most recent first. Cursor-paginated.
367
+ * Requires the `threads_basic` permission.
368
+ */
369
+ list(options = {}) {
370
+ const node = options.userId ?? this.client.userNode;
371
+ return this.client.request({
372
+ method: "GET",
373
+ path: `/${node}/threads`,
374
+ params: {
375
+ fields: fieldsParam(options.fields ?? DEFAULT_MEDIA_FIELDS),
376
+ since: options.since,
377
+ until: options.until,
378
+ limit: options.limit,
379
+ before: options.before,
380
+ after: options.after
381
+ },
382
+ signal: options.signal
383
+ });
384
+ }
385
+ /**
386
+ * Reads a single media object (post or reply) by id.
387
+ * Requires the `threads_basic` permission.
388
+ */
389
+ get(mediaId, options = {}) {
390
+ return this.client.request({
391
+ method: "GET",
392
+ path: `/${mediaId}`,
393
+ params: { fields: fieldsParam(options.fields ?? DEFAULT_MEDIA_FIELDS) },
394
+ signal: options.signal
395
+ });
396
+ }
397
+ };
398
+
399
+ // src/resources/profile.ts
400
+ var DEFAULT_PROFILE_FIELDS = [
401
+ "id",
402
+ "username",
403
+ "name",
404
+ "threads_profile_picture_url",
405
+ "threads_biography",
406
+ "is_verified"
407
+ ];
408
+ var ProfileResource = class {
409
+ constructor(client) {
410
+ this.client = client;
411
+ }
412
+ client;
413
+ /**
414
+ * Returns profile information for a Threads user.
415
+ * Requires the `threads_basic` permission.
416
+ */
417
+ get(options = {}) {
418
+ const node = options.userId ?? this.client.userNode;
419
+ return this.client.request({
420
+ method: "GET",
421
+ path: `/${node}`,
422
+ params: { fields: fieldsParam(options.fields ?? DEFAULT_PROFILE_FIELDS) },
423
+ signal: options.signal
424
+ });
425
+ }
426
+ };
427
+
428
+ // src/resources/publishing.ts
429
+ function sleep2(ms) {
430
+ return new Promise((resolve) => setTimeout(resolve, ms));
431
+ }
432
+ var PublishingResource = class {
433
+ constructor(client) {
434
+ this.client = client;
435
+ }
436
+ client;
437
+ /** Step 1: create a media container. Returns its creation id. */
438
+ createContainer(input) {
439
+ const node = input.userId ?? this.client.userNode;
440
+ return this.client.request({
441
+ method: "POST",
442
+ path: `/${node}/threads`,
443
+ params: {
444
+ media_type: input.mediaType,
445
+ text: input.text,
446
+ image_url: input.imageUrl,
447
+ video_url: input.videoUrl,
448
+ is_carousel_item: input.isCarouselItem,
449
+ // The API expects a comma-separated list, not a JSON array.
450
+ children: input.children?.join(","),
451
+ reply_to_id: input.replyToId,
452
+ reply_control: input.replyControl,
453
+ alt_text: input.altText,
454
+ link_attachment: input.linkAttachment,
455
+ location_id: input.locationId,
456
+ quote_post_id: input.quotePostId,
457
+ topic_tag: input.topicTag,
458
+ // The API expects a JSON object string with snake_case option keys.
459
+ poll_attachment: input.pollAttachment ? JSON.stringify({
460
+ option_a: input.pollAttachment.optionA,
461
+ option_b: input.pollAttachment.optionB,
462
+ option_c: input.pollAttachment.optionC,
463
+ option_d: input.pollAttachment.optionD
464
+ }) : void 0
465
+ },
466
+ signal: input.signal
467
+ });
468
+ }
469
+ /** Step 2: publish a previously created container. Returns the post id. */
470
+ publishContainer(creationId, options = {}) {
471
+ const node = options.userId ?? this.client.userNode;
472
+ return this.client.request({
473
+ method: "POST",
474
+ path: `/${node}/threads_publish`,
475
+ params: { creation_id: creationId },
476
+ signal: options.signal
477
+ });
478
+ }
479
+ /** Reads a container's processing status. */
480
+ getContainerStatus(containerId, options = {}) {
481
+ return this.client.request({
482
+ method: "GET",
483
+ path: `/${containerId}`,
484
+ params: { fields: fieldsParam(["id", "status", "error_message"]) },
485
+ signal: options.signal
486
+ });
487
+ }
488
+ /**
489
+ * Creates a container, optionally waits for media processing, then publishes.
490
+ * `waitForReady` defaults to `true` for `VIDEO`/`CAROUSEL` (which need
491
+ * server-side processing) and `false` otherwise.
492
+ */
493
+ async createAndPublish(input, wait = {}) {
494
+ const { id: creationId } = await this.createContainer(input);
495
+ const shouldWait = wait.waitForReady ?? (input.mediaType === "VIDEO" || input.mediaType === "CAROUSEL");
496
+ if (shouldWait) {
497
+ await this.waitForContainer(creationId, wait, input.signal);
498
+ }
499
+ return this.publishContainer(creationId, {
500
+ userId: input.userId,
501
+ signal: input.signal
502
+ });
503
+ }
504
+ /** Convenience: publish a text-only post. */
505
+ publishText(text, options = {}) {
506
+ return this.createAndPublish({ ...options, mediaType: "TEXT", text });
507
+ }
508
+ /** Convenience: publish a text post with a poll (2–4 options). */
509
+ publishPoll(text, poll, options = {}) {
510
+ return this.createAndPublish({ ...options, mediaType: "TEXT", text, pollAttachment: poll });
511
+ }
512
+ /** Delete a published post. Requires the `threads_delete` scope. */
513
+ deletePost(postId, options = {}) {
514
+ return this.client.request({
515
+ method: "DELETE",
516
+ path: `/${postId}`,
517
+ signal: options.signal
518
+ });
519
+ }
520
+ /** Convenience: publish a single image post. */
521
+ publishImage(input) {
522
+ return this.createAndPublish({ ...input, mediaType: "IMAGE" });
523
+ }
524
+ /** Convenience: publish a single video post (waits for processing). */
525
+ publishVideo(input) {
526
+ return this.createAndPublish({ ...input, mediaType: "VIDEO" });
527
+ }
528
+ /**
529
+ * Convenience: publish a carousel. Each item becomes a child container, then
530
+ * a parent `CAROUSEL` container is created and published.
531
+ */
532
+ async publishCarousel(input) {
533
+ if (input.items.length < 2) {
534
+ throw new ThreadsError("A carousel requires at least 2 items.");
535
+ }
536
+ const children = await Promise.all(
537
+ input.items.map(
538
+ (item) => this.createContainer({
539
+ mediaType: item.videoUrl ? "VIDEO" : "IMAGE",
540
+ imageUrl: item.imageUrl,
541
+ videoUrl: item.videoUrl,
542
+ altText: item.altText,
543
+ isCarouselItem: true,
544
+ userId: input.userId,
545
+ signal: input.signal
546
+ }).then((ref) => ref.id)
547
+ )
548
+ );
549
+ return this.createAndPublish(
550
+ {
551
+ mediaType: "CAROUSEL",
552
+ children,
553
+ text: input.text,
554
+ replyControl: input.replyControl,
555
+ userId: input.userId,
556
+ signal: input.signal
557
+ },
558
+ input.wait ?? {}
559
+ );
560
+ }
561
+ /** Reads remaining publish/reply/delete quotas for the user. */
562
+ getPublishingLimit(options = {}) {
563
+ const node = options.userId ?? this.client.userNode;
564
+ return this.client.request({
565
+ method: "GET",
566
+ path: `/${node}/threads_publishing_limit`,
567
+ params: {
568
+ fields: fieldsParam([
569
+ "quota_usage",
570
+ "config",
571
+ "reply_quota_usage",
572
+ "reply_config",
573
+ "delete_quota_usage",
574
+ "delete_config"
575
+ ])
576
+ },
577
+ signal: options.signal
578
+ });
579
+ }
580
+ /** Polls a container until it is ready to publish, or throws on failure. */
581
+ async waitForContainer(containerId, wait, signal) {
582
+ const pollIntervalMs = wait.pollIntervalMs ?? 2e3;
583
+ const maxWaitMs = wait.maxWaitMs ?? 6e4;
584
+ const deadline = Date.now() + maxWaitMs;
585
+ for (; ; ) {
586
+ const { status, error_message } = await this.getContainerStatus(containerId, { signal });
587
+ if (status === "FINISHED" || status === "PUBLISHED") return;
588
+ if (status === "ERROR" || status === "EXPIRED") {
589
+ throw new ThreadsError(
590
+ `Container ${containerId} failed to process: ${status}${error_message ? ` (${error_message})` : ""}`
591
+ );
592
+ }
593
+ if (Date.now() + pollIntervalMs > deadline) {
594
+ throw new ThreadsError(
595
+ `Container ${containerId} not ready after ${maxWaitMs}ms (status: ${status}).`
596
+ );
597
+ }
598
+ await sleep2(pollIntervalMs);
599
+ }
600
+ }
601
+ };
602
+
603
+ // src/resources/replies.ts
604
+ var DEFAULT_REPLY_FIELDS = [
605
+ "id",
606
+ "text",
607
+ "username",
608
+ "timestamp",
609
+ "media_type",
610
+ "permalink",
611
+ "has_replies",
612
+ "is_reply",
613
+ "hide_status"
614
+ ];
615
+ var RepliesResource = class {
616
+ constructor(client, publishing) {
617
+ this.client = client;
618
+ this.publishing = publishing;
619
+ }
620
+ client;
621
+ publishing;
622
+ /**
623
+ * Lists the top-level replies to a post.
624
+ * Requires `threads_read_replies` (or `threads_manage_replies`).
625
+ */
626
+ list(mediaId, options = {}) {
627
+ return this.client.request({
628
+ method: "GET",
629
+ path: `/${mediaId}/replies`,
630
+ params: {
631
+ fields: fieldsParam(options.fields ?? DEFAULT_REPLY_FIELDS),
632
+ reverse: options.reverse
633
+ },
634
+ signal: options.signal
635
+ });
636
+ }
637
+ /**
638
+ * Lists the full conversation (all nested replies) under a post.
639
+ * Requires `threads_read_replies` (or `threads_manage_replies`).
640
+ */
641
+ conversation(mediaId, options = {}) {
642
+ return this.client.request({
643
+ method: "GET",
644
+ path: `/${mediaId}/conversation`,
645
+ params: {
646
+ fields: fieldsParam(options.fields ?? DEFAULT_REPLY_FIELDS),
647
+ reverse: options.reverse
648
+ },
649
+ signal: options.signal
650
+ });
651
+ }
652
+ /**
653
+ * Publishes a reply to an existing post. Requires `threads_content_publish`
654
+ * (and `threads_manage_replies` for replies you don't own).
655
+ */
656
+ publish(replyToId, text, options = {}) {
657
+ return this.publishing.publishText(text, { replyToId, ...options });
658
+ }
659
+ /** Hides a reply. Requires `threads_manage_replies`. */
660
+ hide(replyId, options = {}) {
661
+ return this.setHidden(replyId, true, options);
662
+ }
663
+ /** Unhides a previously hidden reply. Requires `threads_manage_replies`. */
664
+ unhide(replyId, options = {}) {
665
+ return this.setHidden(replyId, false, options);
666
+ }
667
+ /**
668
+ * Lists replies awaiting approval (when reply approvals are enabled).
669
+ * Requires `threads_manage_replies`.
670
+ */
671
+ listPending(mediaId, options = {}) {
672
+ return this.client.request({
673
+ method: "GET",
674
+ path: `/${mediaId}/pending_replies`,
675
+ params: {
676
+ fields: fieldsParam(options.fields ?? DEFAULT_REPLY_FIELDS),
677
+ reverse: options.reverse,
678
+ approval_status: options.approvalStatus
679
+ },
680
+ signal: options.signal
681
+ });
682
+ }
683
+ /**
684
+ * Approves or rejects a pending reply. Requires `threads_manage_replies`.
685
+ */
686
+ managePending(replyId, approve, options = {}) {
687
+ return this.client.request({
688
+ method: "POST",
689
+ path: `/${replyId}/manage_pending_reply`,
690
+ params: { approve },
691
+ signal: options.signal
692
+ });
693
+ }
694
+ setHidden(replyId, hide, options) {
695
+ return this.client.request({
696
+ method: "POST",
697
+ path: `/${replyId}/manage_reply`,
698
+ params: { hide },
699
+ signal: options.signal
700
+ });
701
+ }
702
+ };
703
+
704
+ // src/resources/search.ts
705
+ var DEFAULT_SEARCH_FIELDS = [
706
+ "id",
707
+ "username",
708
+ "text",
709
+ "timestamp",
710
+ "permalink",
711
+ "media_type",
712
+ "has_replies",
713
+ "is_quote_post",
714
+ "is_reply"
715
+ ];
716
+ var SearchResource = class {
717
+ constructor(client) {
718
+ this.client = client;
719
+ }
720
+ client;
721
+ /**
722
+ * Searches public Threads posts by keyword or tag. The `owner` field is never
723
+ * returned for search results. Requires the `threads_keyword_search`
724
+ * permission (plus `threads_basic`).
725
+ */
726
+ keyword(query, options = {}) {
727
+ return this.client.request({
728
+ method: "GET",
729
+ path: "/keyword_search",
730
+ params: {
731
+ q: query,
732
+ search_type: options.searchType,
733
+ search_mode: options.searchMode,
734
+ media_type: options.mediaType,
735
+ since: options.since,
736
+ until: options.until,
737
+ limit: options.limit,
738
+ author_username: options.authorUsername,
739
+ fields: fieldsParam(options.fields ?? DEFAULT_SEARCH_FIELDS)
740
+ },
741
+ signal: options.signal
742
+ });
743
+ }
744
+ };
745
+
746
+ // src/client.ts
747
+ function resolveConfig(config) {
748
+ if (!config.accessToken) {
749
+ throw new ThreadsValidationError("`accessToken` is required to create a ThreadsClient.");
750
+ }
751
+ const fetchImpl = config.fetch ?? globalThis.fetch;
752
+ if (!fetchImpl) {
753
+ throw new ThreadsValidationError(
754
+ "No fetch implementation available. Pass `fetch` or run on Node 18+ / a runtime with global fetch."
755
+ );
756
+ }
757
+ return {
758
+ accessToken: config.accessToken,
759
+ userId: config.userId ?? "me",
760
+ baseUrl: (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""),
761
+ version: config.version ?? DEFAULT_API_VERSION,
762
+ timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
763
+ retry: config.retry === false ? false : { ...DEFAULT_RETRY, ...config.retry },
764
+ logger: config.logger ?? noopLogger,
765
+ fetch: fetchImpl
766
+ };
767
+ }
768
+ var ThreadsClient = class _ThreadsClient {
769
+ profile;
770
+ posts;
771
+ publishing;
772
+ replies;
773
+ insights;
774
+ mentions;
775
+ search;
776
+ config;
777
+ constructor(config) {
778
+ this.config = resolveConfig(config);
779
+ this.profile = new ProfileResource(this);
780
+ this.posts = new PostsResource(this);
781
+ this.publishing = new PublishingResource(this);
782
+ this.replies = new RepliesResource(this, this.publishing);
783
+ this.insights = new InsightsResource(this);
784
+ this.mentions = new MentionsResource(this);
785
+ this.search = new SearchResource(this);
786
+ }
787
+ /** Default node id for user-scoped endpoints. */
788
+ get userNode() {
789
+ return this.config.userId;
790
+ }
791
+ /**
792
+ * Low-level escape hatch: issue a request to any Threads endpoint with full
793
+ * typing of the response. Prefer the resource methods; use this for endpoints
794
+ * the SDK doesn't model yet.
795
+ */
796
+ request(req) {
797
+ return send({
798
+ method: req.method,
799
+ url: `${this.config.baseUrl}/${this.config.version}${req.path}`,
800
+ params: req.params,
801
+ accessToken: this.config.accessToken,
802
+ timeoutMs: this.config.timeoutMs,
803
+ retry: this.config.retry,
804
+ logger: this.config.logger,
805
+ fetchImpl: this.config.fetch,
806
+ signal: req.signal
807
+ });
808
+ }
809
+ /** Returns a new client that shares this config but uses a different token. */
810
+ withToken(accessToken) {
811
+ return new _ThreadsClient({ ...this.config, accessToken });
812
+ }
813
+ };
814
+
815
+ // src/oauth.ts
816
+ function resolveFetch(fetchImpl) {
817
+ const impl = fetchImpl ?? globalThis.fetch;
818
+ if (!impl) {
819
+ throw new ThreadsValidationError(
820
+ "No fetch implementation available. Pass `fetch` explicitly or run on a runtime with global fetch (Node 18+)."
821
+ );
822
+ }
823
+ return impl;
824
+ }
825
+ function getAuthorizationUrl(options) {
826
+ if (options.scopes.length === 0) {
827
+ throw new ThreadsValidationError("At least one scope is required (threads_basic).");
828
+ }
829
+ const base = options.baseUrl ?? AUTHORIZATION_BASE_URL;
830
+ const query = buildQuery({
831
+ client_id: options.clientId,
832
+ redirect_uri: options.redirectUri,
833
+ response_type: "code",
834
+ scope: options.scopes.join(","),
835
+ state: options.state
836
+ });
837
+ return `${base}/oauth/authorize?${query.toString()}`;
838
+ }
839
+ async function exchangeCodeForToken(options) {
840
+ const base = options.baseUrl ?? DEFAULT_BASE_URL;
841
+ return send({
842
+ method: "POST",
843
+ url: `${base}/oauth/access_token`,
844
+ params: {
845
+ client_id: options.clientId,
846
+ client_secret: options.clientSecret,
847
+ grant_type: "authorization_code",
848
+ redirect_uri: options.redirectUri,
849
+ code: options.code
850
+ },
851
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
852
+ retry: false,
853
+ // code is single-use; never replay it
854
+ logger: options.logger,
855
+ fetchImpl: resolveFetch(options.fetch)
856
+ });
857
+ }
858
+ async function exchangeForLongLivedToken(options) {
859
+ const base = options.baseUrl ?? DEFAULT_BASE_URL;
860
+ return send({
861
+ method: "GET",
862
+ url: `${base}/access_token`,
863
+ params: {
864
+ grant_type: "th_exchange_token",
865
+ client_secret: options.clientSecret
866
+ },
867
+ accessToken: options.shortLivedToken,
868
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
869
+ retry: false,
870
+ logger: options.logger,
871
+ fetchImpl: resolveFetch(options.fetch)
872
+ });
873
+ }
874
+ async function refreshLongLivedToken(options) {
875
+ const base = options.baseUrl ?? DEFAULT_BASE_URL;
876
+ return send({
877
+ method: "GET",
878
+ url: `${base}/refresh_access_token`,
879
+ params: { grant_type: "th_refresh_token" },
880
+ accessToken: options.longLivedToken,
881
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
882
+ retry: false,
883
+ logger: options.logger,
884
+ fetchImpl: resolveFetch(options.fetch)
885
+ });
886
+ }
887
+
888
+ export { AUTHORIZATION_BASE_URL, DEFAULT_API_VERSION, DEFAULT_BASE_URL, THREADS_SCOPES, ThreadsAPIError, ThreadsAuthError, ThreadsClient, ThreadsError, ThreadsNetworkError, ThreadsRateLimitError, ThreadsTimeoutError, ThreadsValidationError, exchangeCodeForToken, exchangeForLongLivedToken, getAuthorizationUrl, noopLogger, parseRetryAfterMs, redactParams, redactUrl, refreshLongLivedToken, toApiError };
889
+ //# sourceMappingURL=index.js.map
890
+ //# sourceMappingURL=index.js.map