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.
@@ -0,0 +1,172 @@
1
+ # tredi-sdk — Architecture
2
+
3
+ How the SDK is put together and why. Read this if you're maintaining the SDK,
4
+ reviewing it, or integrating it deeply. For usage see the
5
+ [API reference](./api-reference.md) and [guides](./guides.md).
6
+
7
+ ## Design goals
8
+
9
+ 1. **Faithful to the docs** — only documented endpoints, fields, and scopes. No invented surface.
10
+ 2. **Production-safe by default** — timeouts, retries, and error typing are on out of the box; retries never duplicate a write.
11
+ 3. **Secure** — tokens and secrets never reach logs.
12
+ 4. **Lean** — zero runtime dependencies; native `fetch`; no abstraction without a second caller.
13
+ 5. **Portable** — ESM + CJS, Node 18+/Bun/Deno/edge; `fetch` is injectable.
14
+
15
+ ## Module map
16
+
17
+ ```mermaid
18
+ graph TD
19
+ index["index.ts (public exports)"]
20
+ client["client.ts — ThreadsClient"]
21
+ http["http.ts — send() engine"]
22
+ errors["errors.ts"]
23
+ logger["logger.ts (redaction)"]
24
+ constants["constants.ts"]
25
+ oauth["oauth.ts (standalone)"]
26
+ base["resources/base.ts (ThreadsRequester)"]
27
+ res["resources/* (profile, posts, publishing,<br/>replies, insights, mentions, search)"]
28
+
29
+ index --> client
30
+ index --> oauth
31
+ index --> errors
32
+ index --> logger
33
+ index --> constants
34
+ client --> http
35
+ client --> res
36
+ res --> base
37
+ oauth --> http
38
+ http --> errors
39
+ http --> logger
40
+ http --> constants
41
+ errors --> constants
42
+ ```
43
+
44
+ Two things talk to the network — `client.request()` and the `oauth.*`
45
+ functions — and both go through the single `send()` engine in `http.ts`.
46
+ Resources never touch `fetch`; they only build params and call
47
+ `ThreadsRequester.request()`. That narrow interface (defined in
48
+ [`resources/base.ts`](../src/resources/base.ts)) keeps the dependency graph
49
+ acyclic and makes resources trivial to test.
50
+
51
+ | File | Responsibility |
52
+ |---|---|
53
+ | `client.ts` | Config resolution, resource wiring, URL building, escape hatch |
54
+ | `http.ts` | Timeout, retry/backoff, request building, response parsing |
55
+ | `errors.ts` | Typed error hierarchy + Graph error mapping |
56
+ | `logger.ts` | Logger interface + secret redaction |
57
+ | `oauth.ts` | Authorization URL + token exchanges (no client needed) |
58
+ | `constants.ts` | Hosts, version, defaults, scope list — the only "magic strings" |
59
+ | `types.ts` | API response/request types (erased at runtime) |
60
+ | `resources/*` | One file per API area; thin param-builders |
61
+
62
+ ## Request lifecycle
63
+
64
+ ```mermaid
65
+ sequenceDiagram
66
+ participant App
67
+ participant R as Resource
68
+ participant C as ThreadsClient
69
+ participant S as send()
70
+ participant F as fetch
71
+
72
+ App->>R: publishing.publishText("hi")
73
+ R->>C: request({ method, path, params })
74
+ C->>S: send({ url, accessToken, timeout, retry, logger })
75
+ loop attempt 0..maxRetries
76
+ S->>F: fetch(url, { signal: timeout })
77
+ alt 2xx
78
+ F-->>S: Response
79
+ S->>S: parse JSON
80
+ S-->>C: data (logs request.success)
81
+ else non-2xx / network / timeout
82
+ F-->>S: error
83
+ S->>S: toApiError + isRetryable(method, err)?
84
+ alt retryable
85
+ S->>S: sleep(backoff + jitter), retry
86
+ else not retryable
87
+ S-->>C: throw typed error (logs request.failure)
88
+ end
89
+ end
90
+ end
91
+ C-->>App: ContainerRef | throws ThreadsError
92
+ ```
93
+
94
+ - **Timeout** — every attempt runs under an `AbortController` armed with `timeoutMs`. A caller `signal` is linked so external cancellation also works.
95
+ - **Token placement** — GET puts `access_token` in the query (Graph requirement); POST puts it in the form **body**, keeping it out of the URL. Either way it's never logged.
96
+ - **Parsing** — the body is read as text and JSON-parsed defensively; non-2xx maps to a typed error via `toApiError`.
97
+
98
+ ## Retry & idempotency model
99
+
100
+ Retries use exponential backoff with **equal jitter** (`delay = cap/2 +
101
+ random(0, cap/2)`), capped at `maxDelayMs`, and honor a server `Retry-After`
102
+ when present. The retry decision is **method-aware** so a publish is never
103
+ duplicated:
104
+
105
+ | Failure | GET | POST |
106
+ |---|---|---|
107
+ | Network error (no response) | retry | retry |
108
+ | HTTP 429 (rejected before processing) | retry | retry |
109
+ | Timeout | retry | **no** |
110
+ | HTTP 5xx | retry | **no** |
111
+ | HTTP 4xx (except 429) | no | no |
112
+
113
+ The reasoning: a POST that times out or 5xxes may have already taken effect
114
+ (e.g. a published post), so re-sending could create a duplicate. A 429 means the
115
+ request was rejected before processing, and a network error means no response was
116
+ received — both are safe to retry on any method. This lives in `isRetryable()`
117
+ ([http.ts](../src/http.ts)) and is unit-tested across the full matrix.
118
+
119
+ ## Error mapping
120
+
121
+ `toApiError(status, body, headers)` reads the Graph error envelope
122
+ (`error.{message,type,code,error_subcode,fbtrace_id}`) and picks the most
123
+ specific subtype:
124
+
125
+ ```mermaid
126
+ graph LR
127
+ R[non-2xx response] --> A{status 401<br/>or code 190?}
128
+ A -- yes --> AUTH[ThreadsAuthError]
129
+ A -- no --> B{status 429<br/>or throttle code?}
130
+ B -- yes --> RL[ThreadsRateLimitError]
131
+ B -- no --> API[ThreadsAPIError]
132
+ ```
133
+
134
+ The primary rate-limit signal is HTTP 429; a small set of well-documented Graph
135
+ throttle codes (4, 17, 32, 613) act as a secondary signal. Everything else stays
136
+ a generic `ThreadsAPIError` with the code exposed, so callers can branch without
137
+ the SDK guessing at semantics it can't verify.
138
+
139
+ ## Security model
140
+
141
+ - **No logging of secrets** — the SDK never calls `console`. It calls the injected `logger`, and the only request field logged is the URL, run through `redactUrl()`. Tokens are appended *inside* `send()` after the logged value is computed, so they cannot leak. A test asserts the token never appears in any log entry.
142
+ - **Secrets server-side** — `exchangeCodeForToken` / `exchangeForLongLivedToken` need the app secret; the docstrings and README mark them server-side only.
143
+ - **Per-token instances** — a client is bound to one token; `withToken()` returns a fresh instance for another user rather than mutating shared state.
144
+ - **At-rest encryption is the host's job** — the SDK holds tokens only in memory; storage/encryption (e.g. Supabase Vault) is left to the application.
145
+
146
+ ## Key decisions & trade-offs
147
+
148
+ | Decision | Why | Trade-off |
149
+ |---|---|---|
150
+ | **Zero runtime deps, native `fetch`** | Smallest install, no supply-chain surface, runs everywhere | Requires Node 18+ or an injected `fetch` |
151
+ | **Class client with resource namespaces** | Familiar DX (Stripe/Octokit-style), discoverable | The client unit isn't maximally tree-shakeable — mitigated by keeping OAuth/errors/types standalone |
152
+ | **Single `send()` engine** | One place for timeout/retry/errors/logging; reused by OAuth | Slightly more plumbing in callers (they build URLs) |
153
+ | **Idempotency-aware retries** | Never double-publish | POST 5xx/timeout surface as errors for the caller to handle |
154
+ | **Dual ESM + CJS** | Works as a standalone package and as an internal dep in a CJS app | Two output formats to ship |
155
+ | **No auto-pagination helper** | YAGNI; cursors are exposed and a 5-line loop covers it | Callers page manually (see guides) |
156
+ | **Post deletion omitted** | Exact method/path not confirmed in the docs pass; "don't invent" | Add once verified — flagged in README/CHANGELOG |
157
+
158
+ ## Testing strategy
159
+
160
+ Tests inject a mock `fetch` (no network, no framework magic — see
161
+ [`test/helpers.ts`](../test/helpers.ts)) and assert on the recorded request
162
+ shape and the parsed result. Coverage concentrates on the engine (retry,
163
+ timeout, error mapping, redaction, request shaping, OAuth, container polling)
164
+ rather than thin pass-through resource methods. Run `pnpm test:coverage` for the
165
+ current numbers.
166
+
167
+ ## Build & distribution
168
+
169
+ `tsup` bundles `src/index.ts` to `dist/` as ESM (`index.js`), CJS
170
+ (`index.cjs`), and declarations (`index.d.ts` / `index.d.cts`), with source maps
171
+ and tree-shaking enabled. `package.json` sets `sideEffects: false` and an
172
+ `exports` map so bundlers pick the right format and can drop unused code.
package/docs/guides.md ADDED
@@ -0,0 +1,322 @@
1
+ # tredi-sdk — Guides
2
+
3
+ Task-based recipes. Each is self-contained. For exhaustive signatures see the
4
+ [API reference](./api-reference.md); runnable versions of several recipes live in
5
+ [`../examples`](../examples).
6
+
7
+ - [1. Server-side OAuth](#1-server-side-oauth)
8
+ - [2. Storing & refreshing tokens](#2-storing--refreshing-tokens)
9
+ - [3. Publishing posts](#3-publishing-posts)
10
+ - [4. Automating replies](#4-automating-replies)
11
+ - [5. Reading analytics](#5-reading-analytics)
12
+ - [6. Monitoring keywords & mentions](#6-monitoring-keywords--mentions)
13
+ - [7. Errors & rate limits](#7-errors--rate-limits)
14
+ - [8. Pagination](#8-pagination)
15
+ - [9. Custom runtime / injecting fetch](#9-custom-runtime--injecting-fetch)
16
+ - [10. Logging](#10-logging)
17
+
18
+ ---
19
+
20
+ ## 1. Server-side OAuth
21
+
22
+ The full handshake. Token exchanges require the app secret — keep them on the
23
+ server. A complete version is in [`examples/oauth-flow.ts`](../examples/oauth-flow.ts).
24
+
25
+ ```ts
26
+ import {
27
+ getAuthorizationUrl,
28
+ exchangeCodeForToken,
29
+ exchangeForLongLivedToken,
30
+ } from 'tredi-sdk'
31
+
32
+ // Route: GET /auth/threads/login
33
+ export function login(req, res) {
34
+ const state = crypto.randomUUID()
35
+ saveState(req.session, state) // verify on callback (CSRF)
36
+ res.redirect(
37
+ getAuthorizationUrl({
38
+ clientId: process.env.THREADS_APP_ID!,
39
+ redirectUri: process.env.THREADS_REDIRECT_URI!,
40
+ scopes: ['threads_basic', 'threads_content_publish', 'threads_manage_replies'],
41
+ state,
42
+ }),
43
+ )
44
+ }
45
+
46
+ // Route: GET /auth/threads/callback?code=...&state=...
47
+ export async function callback(req, res) {
48
+ if (req.query.state !== readState(req.session)) return res.status(403).end()
49
+
50
+ const short = await exchangeCodeForToken({
51
+ clientId: process.env.THREADS_APP_ID!,
52
+ clientSecret: process.env.THREADS_APP_SECRET!,
53
+ code: req.query.code,
54
+ redirectUri: process.env.THREADS_REDIRECT_URI!,
55
+ })
56
+
57
+ const long = await exchangeForLongLivedToken({
58
+ clientSecret: process.env.THREADS_APP_SECRET!,
59
+ shortLivedToken: short.access_token,
60
+ })
61
+
62
+ await saveToken(short.user_id, long.access_token, long.expires_in) // encrypt at rest
63
+ res.redirect('/dashboard')
64
+ }
65
+ ```
66
+
67
+ ---
68
+
69
+ ## 2. Storing & refreshing tokens
70
+
71
+ Long-lived tokens last ~60 days and must be refreshed while still valid (and at
72
+ least 24h old). Run a daily job over tokens nearing expiry.
73
+
74
+ ```ts
75
+ import { refreshLongLivedToken } from 'tredi-sdk'
76
+
77
+ async function refreshExpiringTokens() {
78
+ const soon = await db.tokens.dueForRefresh() // e.g. expiring within 7 days
79
+ for (const row of soon) {
80
+ try {
81
+ const { access_token, expires_in } = await refreshLongLivedToken({
82
+ longLivedToken: row.token,
83
+ })
84
+ await db.tokens.update(row.userId, { token: access_token, expiresIn: expires_in })
85
+ } catch (err) {
86
+ // A token not refreshed within 60 days expires permanently — re-auth the user.
87
+ await notifyReauthNeeded(row.userId)
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ > Store tokens encrypted (e.g. Supabase Vault). The SDK keeps them in memory only.
94
+
95
+ ---
96
+
97
+ ## 3. Publishing posts
98
+
99
+ The convenience methods run the create-container → publish flow for you.
100
+
101
+ ```ts
102
+ import { ThreadsClient } from 'tredi-sdk'
103
+ const threads = new ThreadsClient({ accessToken: token })
104
+
105
+ // Text
106
+ await threads.publishing.publishText('gm ☕')
107
+
108
+ // Image (with alt text for accessibility)
109
+ await threads.publishing.publishImage({
110
+ imageUrl: 'https://cdn.example.com/a.jpg',
111
+ text: 'Morning shot',
112
+ altText: 'A latte on a wooden table',
113
+ })
114
+
115
+ // Video — waits for server-side processing before publishing
116
+ await threads.publishing.publishVideo({ videoUrl: 'https://cdn.example.com/v.mp4' })
117
+
118
+ // Carousel — ≥2 items
119
+ await threads.publishing.publishCarousel({
120
+ items: [{ imageUrl: 'https://…/1.jpg' }, { imageUrl: 'https://…/2.jpg' }],
121
+ text: 'A set',
122
+ })
123
+ ```
124
+
125
+ Need control over timing (e.g. publish a video later)? Drive the steps yourself:
126
+
127
+ ```ts
128
+ const { id: creationId } = await threads.publishing.createContainer({
129
+ mediaType: 'VIDEO',
130
+ videoUrl: 'https://…/v.mp4',
131
+ })
132
+
133
+ // Poll until ready, then publish when you choose.
134
+ let status = await threads.publishing.getContainerStatus(creationId)
135
+ while (status.status === 'IN_PROGRESS') {
136
+ await new Promise((r) => setTimeout(r, 3000))
137
+ status = await threads.publishing.getContainerStatus(creationId)
138
+ }
139
+ if (status.status === 'FINISHED') await threads.publishing.publishContainer(creationId)
140
+ ```
141
+
142
+ Check quota before bulk publishing:
143
+
144
+ ```ts
145
+ const limit = await threads.publishing.getPublishingLimit()
146
+ console.log(`${limit.quota_usage}/${limit.config?.quota_total} posts used (24h)`)
147
+ ```
148
+
149
+ ---
150
+
151
+ ## 4. Automating replies
152
+
153
+ Poll a post, auto-answer new replies, and hide spam. Full version in
154
+ [`examples/reply-autopilot.ts`](../examples/reply-autopilot.ts).
155
+
156
+ ```ts
157
+ const seen = new Set<string>()
158
+
159
+ async function tick(mediaId: string) {
160
+ const { data } = await threads.replies.list(mediaId, { reverse: true })
161
+ for (const reply of data) {
162
+ if (!reply.id || seen.has(reply.id)) continue
163
+ seen.add(reply.id)
164
+
165
+ if (isSpam(reply)) {
166
+ await threads.replies.hide(reply.id)
167
+ continue
168
+ }
169
+ const text = await generateReply(reply) // your AI / rules
170
+ await threads.replies.publish(mediaId, text)
171
+ }
172
+ }
173
+ ```
174
+
175
+ If reply approvals are enabled on the post, work the queue instead:
176
+
177
+ ```ts
178
+ const pending = await threads.replies.listPending(mediaId, { approvalStatus: 'pending' })
179
+ for (const r of pending.data) {
180
+ await threads.replies.managePending(r.id!, !isSpam(r)) // approve or reject
181
+ }
182
+ ```
183
+
184
+ ---
185
+
186
+ ## 5. Reading analytics
187
+
188
+ ```ts
189
+ // Account level
190
+ const account = await threads.insights.user({
191
+ metrics: ['views', 'likes', 'followers_count'],
192
+ })
193
+ for (const m of account.data) {
194
+ const value = m.total_value?.value ?? m.values?.at(-1)?.value
195
+ console.log(m.name, value)
196
+ }
197
+
198
+ // Follower demographics (breakdown is required by the API)
199
+ await threads.insights.user({ metrics: ['follower_demographics'], breakdown: 'country' })
200
+
201
+ // Per-post
202
+ const post = await threads.insights.media('MEDIA_ID', ['views', 'likes', 'replies'])
203
+ ```
204
+
205
+ ---
206
+
207
+ ## 6. Monitoring keywords & mentions
208
+
209
+ ```ts
210
+ // Keyword search (needs threads_keyword_search)
211
+ const recent = await threads.search.keyword('cold brew', {
212
+ searchType: 'RECENT',
213
+ mediaType: 'TEXT',
214
+ limit: 50,
215
+ })
216
+
217
+ // Mentions of the authenticated user
218
+ const mentions = await threads.mentions.list({ limit: 25 })
219
+ ```
220
+
221
+ ---
222
+
223
+ ## 7. Errors & rate limits
224
+
225
+ Catch broadly with `ThreadsError`, or narrow to act on specific failures.
226
+
227
+ ```ts
228
+ import {
229
+ ThreadsAuthError,
230
+ ThreadsRateLimitError,
231
+ ThreadsValidationError,
232
+ ThreadsError,
233
+ } from 'tredi-sdk'
234
+
235
+ try {
236
+ await threads.publishing.publishText('hi')
237
+ } catch (err) {
238
+ if (err instanceof ThreadsAuthError) {
239
+ await refreshTokenAndRetry() // token expired/invalid
240
+ } else if (err instanceof ThreadsRateLimitError) {
241
+ await wait(err.retryAfterMs ?? 60_000) // throttled
242
+ } else if (err instanceof ThreadsValidationError) {
243
+ throw err // bug in your input — fix it
244
+ } else if (err instanceof ThreadsError) {
245
+ report(err) // ThreadsAPIError / Network / Timeout
246
+ }
247
+ }
248
+ ```
249
+
250
+ The SDK already retries transient failures (network, 429, and 5xx/timeouts on
251
+ GETs) with backoff. Tune or disable it:
252
+
253
+ ```ts
254
+ new ThreadsClient({ accessToken, retry: { maxRetries: 4, maxDelayMs: 15_000 } })
255
+ new ThreadsClient({ accessToken, retry: false }) // handle retries yourself
256
+ ```
257
+
258
+ Writes (POST) are **not** retried on 5xx/timeout to avoid duplicate posts — see
259
+ [architecture.md](./architecture.md#retry--idempotency-model).
260
+
261
+ ---
262
+
263
+ ## 8. Pagination
264
+
265
+ List endpoints return `{ data, paging: { cursors: { after } } }`. Loop with the
266
+ `after` cursor:
267
+
268
+ ```ts
269
+ async function* allPosts(threads: ThreadsClient) {
270
+ let after: string | undefined
271
+ do {
272
+ const page = await threads.posts.list({ limit: 50, after })
273
+ yield* page.data
274
+ after = page.paging?.cursors?.after
275
+ } while (after)
276
+ }
277
+
278
+ for await (const post of allPosts(threads)) {
279
+ console.log(post.id, post.text)
280
+ }
281
+ ```
282
+
283
+ The same pattern works for `replies.list`, `mentions.list`, and `search.keyword`.
284
+
285
+ ---
286
+
287
+ ## 9. Custom runtime / injecting fetch
288
+
289
+ On runtimes without global `fetch`, or in tests, pass your own:
290
+
291
+ ```ts
292
+ import { ThreadsClient } from 'tredi-sdk'
293
+
294
+ const threads = new ThreadsClient({
295
+ accessToken: token,
296
+ fetch: myFetch, // any (url, init) => Promise<Response>
297
+ baseUrl: 'https://graph.threads.net',
298
+ timeoutMs: 15_000,
299
+ })
300
+ ```
301
+
302
+ In unit tests, a fake `fetch` lets you assert request shape without a network —
303
+ see [`test/helpers.ts`](../test/helpers.ts).
304
+
305
+ ---
306
+
307
+ ## 10. Logging
308
+
309
+ Wire the logger to your stack. Secrets are redacted before they reach you.
310
+
311
+ ```ts
312
+ import pino from 'pino'
313
+ const log = pino()
314
+
315
+ const threads = new ThreadsClient({
316
+ accessToken: token,
317
+ logger: { log: (level, msg, ctx) => log[level]({ ...ctx }, msg) },
318
+ })
319
+ // emits: threads.request.success { method, url: "…access_token=REDACTED", attempt, durationMs }
320
+ ```
321
+
322
+ Pass nothing to stay silent (the default `noopLogger`).
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Pull account + recent-post analytics.
3
+ *
4
+ * THREADS_TOKEN=... node --experimental-strip-types examples/fetch-insights.ts
5
+ */
6
+
7
+ import { ThreadsClient } from 'tredi-sdk'
8
+
9
+ const threads = new ThreadsClient({ accessToken: process.env.THREADS_TOKEN! })
10
+
11
+ async function main() {
12
+ const me = await threads.profile.get()
13
+ console.log(`@${me.username} (${me.id})`)
14
+
15
+ const account = await threads.insights.user({ metrics: ['views', 'likes', 'followers_count'] })
16
+ for (const metric of account.data) {
17
+ const value = metric.total_value?.value ?? metric.values?.at(-1)?.value
18
+ console.log(` ${metric.name}: ${value ?? 'n/a'}`)
19
+ }
20
+
21
+ const posts = await threads.posts.list({ limit: 5 })
22
+ for (const post of posts.data) {
23
+ const stats = await threads.insights.media(post.id!, { metrics: ['views', 'likes', 'replies'] })
24
+ const summary = stats.data.map((m) => `${m.name}=${m.total_value?.value ?? 0}`).join(' ')
25
+ console.log(` ${post.id} → ${summary}`)
26
+ }
27
+ }
28
+
29
+ main().catch((e) => {
30
+ console.error(e)
31
+ process.exit(1)
32
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * OAuth flow (server-side). Shows the four steps end to end.
3
+ * Run pieces of this inside your web framework's route handlers.
4
+ *
5
+ * THREADS_APP_ID=... THREADS_APP_SECRET=... node --experimental-strip-types examples/oauth-flow.ts
6
+ */
7
+
8
+ import {
9
+ exchangeCodeForToken,
10
+ exchangeForLongLivedToken,
11
+ getAuthorizationUrl,
12
+ refreshLongLivedToken,
13
+ } from 'tredi-sdk'
14
+
15
+ const APP_ID = process.env.THREADS_APP_ID!
16
+ const APP_SECRET = process.env.THREADS_APP_SECRET!
17
+ const REDIRECT_URI = 'https://app.example.com/auth/threads/callback'
18
+
19
+ // Step 1 — send the user here (store `state` to verify on return).
20
+ export function loginUrl(state: string): string {
21
+ return getAuthorizationUrl({
22
+ clientId: APP_ID,
23
+ redirectUri: REDIRECT_URI,
24
+ scopes: ['threads_basic', 'threads_content_publish', 'threads_manage_replies'],
25
+ state,
26
+ })
27
+ }
28
+
29
+ // Steps 2–3 — handle the redirect: code → short token → long-lived token.
30
+ export async function handleCallback(code: string) {
31
+ const short = await exchangeCodeForToken({
32
+ clientId: APP_ID,
33
+ clientSecret: APP_SECRET,
34
+ code,
35
+ redirectUri: REDIRECT_URI,
36
+ })
37
+
38
+ const long = await exchangeForLongLivedToken({
39
+ clientSecret: APP_SECRET,
40
+ shortLivedToken: short.access_token,
41
+ })
42
+
43
+ // Persist long.access_token + expiry against short.user_id (encrypted at rest).
44
+ return { userId: short.user_id, token: long.access_token, expiresIn: long.expires_in }
45
+ }
46
+
47
+ // Step 4 — run on a schedule before the 60-day expiry (token must be ≥24h old).
48
+ export async function refresh(token: string) {
49
+ const refreshed = await refreshLongLivedToken({ longLivedToken: token })
50
+ return { token: refreshed.access_token, expiresIn: refreshed.expires_in }
51
+ }
52
+
53
+ if (import.meta.url === `file://${process.argv[1]}`) {
54
+ console.log('Authorize URL:\n', loginUrl('demo-state'))
55
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Publish a post and read back its insights.
3
+ *
4
+ * THREADS_TOKEN=... node --experimental-strip-types examples/publish-text.ts "Hello, Threads"
5
+ */
6
+
7
+ import { ThreadsClient, ThreadsRateLimitError } from 'tredi-sdk'
8
+
9
+ const threads = new ThreadsClient({
10
+ accessToken: process.env.THREADS_TOKEN!,
11
+ logger: { log: (level, message, ctx) => console[level === 'debug' ? 'log' : level](message, ctx ?? '') },
12
+ })
13
+
14
+ async function main() {
15
+ const text = process.argv[2] ?? 'Posted via tredi-sdk'
16
+
17
+ // Check quota before publishing.
18
+ const limit = await threads.publishing.getPublishingLimit()
19
+ console.log('Posts used in last 24h:', limit.quota_usage, '/', limit.config?.quota_total)
20
+
21
+ try {
22
+ const post = await threads.publishing.publishText(text)
23
+ console.log('Published post id:', post.id)
24
+
25
+ const insights = await threads.insights.media(post.id, { metrics: ['views', 'likes'] })
26
+ console.log('Insights:', insights.data.map((m) => `${m.name}=${m.total_value?.value ?? 0}`))
27
+ } catch (err) {
28
+ if (err instanceof ThreadsRateLimitError) {
29
+ console.error(`Rate limited. Retry after ~${(err.retryAfterMs ?? 60_000) / 1000}s`)
30
+ } else {
31
+ throw err
32
+ }
33
+ }
34
+ }
35
+
36
+ main().catch((e) => {
37
+ console.error(e)
38
+ process.exit(1)
39
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Autopilot reply bot: poll a post's replies, auto-answer new ones, and hide
3
+ * spam. Swap `generateReply` / `isSpam` for your own AI/moderation logic — the
4
+ * SDK supplies every Threads call this loop needs.
5
+ *
6
+ * THREADS_TOKEN=... node --experimental-strip-types examples/reply-autopilot.ts MEDIA_ID
7
+ */
8
+
9
+ import { ThreadsClient, type ThreadsReply } from 'tredi-sdk'
10
+
11
+ const threads = new ThreadsClient({ accessToken: process.env.THREADS_TOKEN! })
12
+ const POLL_INTERVAL_MS = 30_000
13
+
14
+ // Replace these with real implementations (Claude, a classifier, rules, …).
15
+ function isSpam(reply: ThreadsReply): boolean {
16
+ return /https?:\/\/|free money|crypto/i.test(reply.text ?? '')
17
+ }
18
+ async function generateReply(reply: ThreadsReply): Promise<string> {
19
+ return `Thanks for the reply, @${reply.username}! 🙌`
20
+ }
21
+
22
+ async function processNewReplies(mediaId: string, seen: Set<string>) {
23
+ const { data } = await threads.replies.list(mediaId, { reverse: true })
24
+
25
+ for (const reply of data) {
26
+ if (!reply.id || seen.has(reply.id) || reply.is_reply === false) continue
27
+ seen.add(reply.id)
28
+
29
+ if (isSpam(reply)) {
30
+ await threads.replies.hide(reply.id)
31
+ console.log(`Hid spam reply ${reply.id}`)
32
+ continue
33
+ }
34
+
35
+ const text = await generateReply(reply)
36
+ const answer = await threads.replies.publish(mediaId, text)
37
+ console.log(`Replied ${answer.id} → ${reply.id}`)
38
+ }
39
+ }
40
+
41
+ async function main() {
42
+ const mediaId = process.argv[2]
43
+ if (!mediaId) throw new Error('Usage: reply-autopilot.ts <MEDIA_ID>')
44
+
45
+ const seen = new Set<string>()
46
+ console.log(`Autopilot watching ${mediaId} every ${POLL_INTERVAL_MS / 1000}s…`)
47
+
48
+ for (;;) {
49
+ try {
50
+ await processNewReplies(mediaId, seen)
51
+ } catch (err) {
52
+ // The SDK already retried transient failures; log and keep the loop alive.
53
+ console.error('poll failed:', err instanceof Error ? err.message : err)
54
+ }
55
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS))
56
+ }
57
+ }
58
+
59
+ main().catch((e) => {
60
+ console.error(e)
61
+ process.exit(1)
62
+ })