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/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +278 -0
- package/dist/index.cjs +912 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +798 -0
- package/dist/index.d.ts +798 -0
- package/dist/index.js +890 -0
- package/dist/index.js.map +1 -0
- package/docs/README.md +14 -0
- package/docs/api-reference.md +452 -0
- package/docs/architecture.md +172 -0
- package/docs/guides.md +322 -0
- package/examples/fetch-insights.ts +32 -0
- package/examples/oauth-flow.ts +55 -0
- package/examples/publish-text.ts +39 -0
- package/examples/reply-autopilot.ts +62 -0
- package/package.json +76 -0
|
@@ -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
|
+
})
|