@happyvertical/social 0.74.8
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/AGENT.md +33 -0
- package/LICENSE +7 -0
- package/README.md +485 -0
- package/dist/index.d.ts +1114 -0
- package/dist/index.js +2695 -0
- package/dist/index.js.map +1 -0
- package/metadata.json +29 -0
- package/package.json +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2695 @@
|
|
|
1
|
+
import { randomUUID, createHmac, createHash } from "node:crypto";
|
|
2
|
+
import { createLogger } from "@happyvertical/logger";
|
|
3
|
+
async function resolveMediaData(file, options = {}) {
|
|
4
|
+
const explicitMimeType = normalizeMimeType(options.explicitMimeType);
|
|
5
|
+
if (Buffer.isBuffer(file)) {
|
|
6
|
+
return {
|
|
7
|
+
data: file,
|
|
8
|
+
mimeType: explicitMimeType ?? detectMimeType(file) ?? options.fallbackMimeType ?? "application/octet-stream"
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const response = await fetch(file);
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
const snippet = await response.text();
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Failed to fetch media from ${file}: ${response.status} ${response.statusText}${snippet ? ` - ${snippet.slice(0, 200)}` : ""}`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const data = Buffer.from(await response.arrayBuffer());
|
|
19
|
+
return {
|
|
20
|
+
data,
|
|
21
|
+
mimeType: explicitMimeType ?? normalizeMimeType(response.headers.get("content-type")) ?? detectMimeType(data) ?? options.fallbackMimeType ?? "application/octet-stream"
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function normalizeMimeType(value) {
|
|
25
|
+
const mimeType = value?.split(";")[0]?.trim().toLowerCase();
|
|
26
|
+
if (!mimeType || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(mimeType)) {
|
|
27
|
+
return void 0;
|
|
28
|
+
}
|
|
29
|
+
return mimeType;
|
|
30
|
+
}
|
|
31
|
+
function detectMimeType(data) {
|
|
32
|
+
if (data.length >= 8 && data.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
33
|
+
return "image/png";
|
|
34
|
+
}
|
|
35
|
+
if (data.length >= 3 && data[0] === 255 && data[1] === 216) {
|
|
36
|
+
return "image/jpeg";
|
|
37
|
+
}
|
|
38
|
+
const header = data.subarray(0, 12).toString("ascii");
|
|
39
|
+
if (header.startsWith("GIF87a") || header.startsWith("GIF89a")) {
|
|
40
|
+
return "image/gif";
|
|
41
|
+
}
|
|
42
|
+
if (header.startsWith("RIFF") && header.slice(8, 12) === "WEBP") {
|
|
43
|
+
return "image/webp";
|
|
44
|
+
}
|
|
45
|
+
if (data.length >= 12 && data.subarray(4, 8).toString("ascii") === "ftyp") {
|
|
46
|
+
const brand = data.subarray(8, 12).toString("ascii");
|
|
47
|
+
return brand === "qt " ? "video/quicktime" : "video/mp4";
|
|
48
|
+
}
|
|
49
|
+
if (data.length >= 4 && data[0] === 26 && data[1] === 69 && data[2] === 223 && data[3] === 163) {
|
|
50
|
+
return "video/webm";
|
|
51
|
+
}
|
|
52
|
+
return void 0;
|
|
53
|
+
}
|
|
54
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
55
|
+
137,
|
|
56
|
+
80,
|
|
57
|
+
78,
|
|
58
|
+
71,
|
|
59
|
+
13,
|
|
60
|
+
10,
|
|
61
|
+
26,
|
|
62
|
+
10
|
|
63
|
+
]);
|
|
64
|
+
function resolvePublishMode(config) {
|
|
65
|
+
return config.publishMode ?? "public";
|
|
66
|
+
}
|
|
67
|
+
function isPublicPublishMode(mode) {
|
|
68
|
+
return mode === "public";
|
|
69
|
+
}
|
|
70
|
+
function createSafetyResult(options) {
|
|
71
|
+
const status = options.mode === "dry_run" ? "dry_run" : "staged";
|
|
72
|
+
const id = options.remoteId ?? `${options.platform}-${status}-${randomUUID()}`;
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
url: "",
|
|
76
|
+
status,
|
|
77
|
+
metadata: {
|
|
78
|
+
publishMode: options.mode,
|
|
79
|
+
safety: true,
|
|
80
|
+
staged: options.staged ?? status === "staged",
|
|
81
|
+
postType: options.postType,
|
|
82
|
+
remoteId: options.remoteId,
|
|
83
|
+
payload: options.payload,
|
|
84
|
+
note: options.note,
|
|
85
|
+
...options.metadata
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
class SocialError extends Error {
|
|
90
|
+
constructor(message, code, platform, statusCode) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.code = code;
|
|
93
|
+
this.platform = platform;
|
|
94
|
+
this.statusCode = statusCode;
|
|
95
|
+
this.name = "SocialError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
class SocialRateLimitError extends SocialError {
|
|
99
|
+
constructor(platform, retryAfter) {
|
|
100
|
+
super(
|
|
101
|
+
`Rate limit exceeded${retryAfter ? `, retry after ${retryAfter}s` : ""}`,
|
|
102
|
+
"RATE_LIMIT",
|
|
103
|
+
platform,
|
|
104
|
+
429
|
|
105
|
+
);
|
|
106
|
+
this.retryAfter = retryAfter;
|
|
107
|
+
this.name = "SocialRateLimitError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
class SocialAuthError extends SocialError {
|
|
111
|
+
constructor(platform, message = "Authentication failed") {
|
|
112
|
+
super(message, "AUTH_ERROR", platform, 401);
|
|
113
|
+
this.name = "SocialAuthError";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const DEFAULT_PDS = "https://bsky.social";
|
|
117
|
+
class BlueskyAdapter {
|
|
118
|
+
platform = "bluesky";
|
|
119
|
+
config;
|
|
120
|
+
session;
|
|
121
|
+
constructor(config) {
|
|
122
|
+
this.config = config;
|
|
123
|
+
}
|
|
124
|
+
get pdsUrl() {
|
|
125
|
+
return this.config.pdsUrl ?? DEFAULT_PDS;
|
|
126
|
+
}
|
|
127
|
+
async authenticate() {
|
|
128
|
+
const response = await fetch(
|
|
129
|
+
`${this.pdsUrl}/xrpc/com.atproto.server.createSession`,
|
|
130
|
+
{
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
identifier: this.config.identifier,
|
|
135
|
+
password: this.config.password
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
const error = await response.json();
|
|
141
|
+
throw new SocialAuthError(
|
|
142
|
+
"bluesky",
|
|
143
|
+
error.message ?? "Authentication failed"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const data = await response.json();
|
|
147
|
+
this.session = {
|
|
148
|
+
did: data.did,
|
|
149
|
+
handle: data.handle,
|
|
150
|
+
accessJwt: data.accessJwt,
|
|
151
|
+
refreshJwt: data.refreshJwt
|
|
152
|
+
};
|
|
153
|
+
return {
|
|
154
|
+
accessToken: data.accessJwt,
|
|
155
|
+
refreshToken: data.refreshJwt
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
async refreshToken(refreshJwt) {
|
|
159
|
+
const response = await fetch(
|
|
160
|
+
`${this.pdsUrl}/xrpc/com.atproto.server.refreshSession`,
|
|
161
|
+
{
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
Authorization: `Bearer ${refreshJwt}`
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
throw new SocialAuthError("bluesky", "Session refresh failed");
|
|
171
|
+
}
|
|
172
|
+
const data = await response.json();
|
|
173
|
+
this.session = {
|
|
174
|
+
did: data.did,
|
|
175
|
+
handle: data.handle,
|
|
176
|
+
accessJwt: data.accessJwt,
|
|
177
|
+
refreshJwt: data.refreshJwt
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
accessToken: data.accessJwt,
|
|
181
|
+
refreshToken: data.refreshJwt
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
async publishVideo(_video) {
|
|
185
|
+
throw new SocialError(
|
|
186
|
+
"Bluesky video publishing is not supported yet",
|
|
187
|
+
"NOT_SUPPORTED",
|
|
188
|
+
"bluesky"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
async publishImage(image) {
|
|
192
|
+
const publishMode = resolvePublishMode(this.config);
|
|
193
|
+
const text = this.buildPostText(
|
|
194
|
+
image.description,
|
|
195
|
+
image.tags,
|
|
196
|
+
image.linkUrl
|
|
197
|
+
);
|
|
198
|
+
if (publishMode === "dry_run") {
|
|
199
|
+
return createSafetyResult({
|
|
200
|
+
platform: this.platform,
|
|
201
|
+
mode: publishMode,
|
|
202
|
+
postType: "image",
|
|
203
|
+
payload: {
|
|
204
|
+
text,
|
|
205
|
+
altText: image.altText,
|
|
206
|
+
linkUrl: image.linkUrl
|
|
207
|
+
},
|
|
208
|
+
note: "Bluesky dry run: blob was not uploaded and no post was created."
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (!this.session) {
|
|
212
|
+
await this.authenticate();
|
|
213
|
+
}
|
|
214
|
+
const imageData = await resolveMediaData(image.file, {
|
|
215
|
+
explicitMimeType: image.mimeType,
|
|
216
|
+
fallbackMimeType: "image/png"
|
|
217
|
+
});
|
|
218
|
+
const blobResponse = await fetch(
|
|
219
|
+
`${this.pdsUrl}/xrpc/com.atproto.repo.uploadBlob`,
|
|
220
|
+
{
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: {
|
|
223
|
+
Authorization: `Bearer ${this.session?.accessJwt}`,
|
|
224
|
+
"Content-Type": imageData.mimeType
|
|
225
|
+
},
|
|
226
|
+
body: new Uint8Array(imageData.data)
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
if (!blobResponse.ok) {
|
|
230
|
+
await this.handleError(blobResponse);
|
|
231
|
+
}
|
|
232
|
+
const blobData = await blobResponse.json();
|
|
233
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
234
|
+
return createSafetyResult({
|
|
235
|
+
platform: this.platform,
|
|
236
|
+
mode: publishMode,
|
|
237
|
+
postType: "image",
|
|
238
|
+
payload: {
|
|
239
|
+
text,
|
|
240
|
+
altText: image.altText,
|
|
241
|
+
blob: blobData.blob,
|
|
242
|
+
linkUrl: image.linkUrl,
|
|
243
|
+
mimeType: imageData.mimeType
|
|
244
|
+
},
|
|
245
|
+
remoteId: blobData.blob?.ref?.$link ?? blobData.blob?.cid,
|
|
246
|
+
staged: true,
|
|
247
|
+
note: "Bluesky blob uploaded but no post record was created."
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const record = {
|
|
251
|
+
$type: "app.bsky.feed.post",
|
|
252
|
+
text,
|
|
253
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
254
|
+
embed: {
|
|
255
|
+
$type: "app.bsky.embed.images",
|
|
256
|
+
images: [
|
|
257
|
+
{
|
|
258
|
+
alt: image.altText ?? "",
|
|
259
|
+
image: blobData.blob
|
|
260
|
+
}
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
return this.createPost(record);
|
|
265
|
+
}
|
|
266
|
+
async publishText(text) {
|
|
267
|
+
const publishMode = resolvePublishMode(this.config);
|
|
268
|
+
const postText = this.buildPostText(text.text, text.tags);
|
|
269
|
+
const record = {
|
|
270
|
+
$type: "app.bsky.feed.post",
|
|
271
|
+
text: postText,
|
|
272
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
273
|
+
};
|
|
274
|
+
if (text.linkUrl) {
|
|
275
|
+
record.embed = {
|
|
276
|
+
$type: "app.bsky.embed.external",
|
|
277
|
+
external: {
|
|
278
|
+
uri: text.linkUrl,
|
|
279
|
+
title: "",
|
|
280
|
+
// Would need to fetch
|
|
281
|
+
description: ""
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
const urlStart = postText.indexOf(text.linkUrl);
|
|
285
|
+
if (urlStart >= 0) {
|
|
286
|
+
record.facets = [
|
|
287
|
+
{
|
|
288
|
+
index: {
|
|
289
|
+
byteStart: urlStart,
|
|
290
|
+
byteEnd: urlStart + text.linkUrl.length
|
|
291
|
+
},
|
|
292
|
+
features: [
|
|
293
|
+
{
|
|
294
|
+
$type: "app.bsky.richtext.facet#link",
|
|
295
|
+
uri: text.linkUrl
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
}
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
303
|
+
return createSafetyResult({
|
|
304
|
+
platform: this.platform,
|
|
305
|
+
mode: publishMode,
|
|
306
|
+
postType: "text",
|
|
307
|
+
payload: record,
|
|
308
|
+
metadata: text.replyTo ? { replyTo: text.replyTo } : void 0,
|
|
309
|
+
note: publishMode === "dry_run" ? "Bluesky dry run: no post record was created." : "Bluesky has no non-public text staging endpoint; no post record was created."
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!this.session) {
|
|
313
|
+
await this.authenticate();
|
|
314
|
+
}
|
|
315
|
+
if (text.replyTo) {
|
|
316
|
+
record.reply = await this.getReplyRef(text.replyTo);
|
|
317
|
+
}
|
|
318
|
+
return this.createPost(record);
|
|
319
|
+
}
|
|
320
|
+
async publishLink(link) {
|
|
321
|
+
const publishMode = resolvePublishMode(this.config);
|
|
322
|
+
const postText = this.buildPostText(
|
|
323
|
+
link.text ?? link.title ?? link.description ?? link.url,
|
|
324
|
+
link.tags
|
|
325
|
+
);
|
|
326
|
+
const record = {
|
|
327
|
+
$type: "app.bsky.feed.post",
|
|
328
|
+
text: postText,
|
|
329
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
330
|
+
embed: {
|
|
331
|
+
$type: "app.bsky.embed.external",
|
|
332
|
+
external: {
|
|
333
|
+
uri: link.url,
|
|
334
|
+
title: link.title ?? link.url,
|
|
335
|
+
description: link.description ?? ""
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
340
|
+
return createSafetyResult({
|
|
341
|
+
platform: this.platform,
|
|
342
|
+
mode: publishMode,
|
|
343
|
+
postType: "link",
|
|
344
|
+
payload: record,
|
|
345
|
+
note: publishMode === "dry_run" ? "Bluesky dry run: no post record was created." : "Bluesky has no non-public link staging endpoint; no post record was created."
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (!this.session) {
|
|
349
|
+
await this.authenticate();
|
|
350
|
+
}
|
|
351
|
+
return this.createPost(record);
|
|
352
|
+
}
|
|
353
|
+
async createPost(record) {
|
|
354
|
+
const response = await fetch(
|
|
355
|
+
`${this.pdsUrl}/xrpc/com.atproto.repo.createRecord`,
|
|
356
|
+
{
|
|
357
|
+
method: "POST",
|
|
358
|
+
headers: {
|
|
359
|
+
Authorization: `Bearer ${this.session?.accessJwt}`,
|
|
360
|
+
"Content-Type": "application/json"
|
|
361
|
+
},
|
|
362
|
+
body: JSON.stringify({
|
|
363
|
+
repo: this.session?.did,
|
|
364
|
+
collection: "app.bsky.feed.post",
|
|
365
|
+
record
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
);
|
|
369
|
+
if (!response.ok) {
|
|
370
|
+
await this.handleError(response);
|
|
371
|
+
}
|
|
372
|
+
const data = await response.json();
|
|
373
|
+
const postId = data.uri.split("/").pop();
|
|
374
|
+
return {
|
|
375
|
+
id: data.uri,
|
|
376
|
+
url: `https://bsky.app/profile/${this.session?.handle}/post/${postId}`,
|
|
377
|
+
status: "published",
|
|
378
|
+
publishedAt: /* @__PURE__ */ new Date(),
|
|
379
|
+
metadata: { publishMode: resolvePublishMode(this.config) }
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
async getPost(postId) {
|
|
383
|
+
if (!this.session) {
|
|
384
|
+
await this.authenticate();
|
|
385
|
+
}
|
|
386
|
+
const uri = postId.startsWith("at://") ? postId : `at://${this.session?.did}/app.bsky.feed.post/${postId}`;
|
|
387
|
+
const response = await fetch(
|
|
388
|
+
`${this.pdsUrl}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(uri)}&depth=0`,
|
|
389
|
+
{
|
|
390
|
+
headers: { Authorization: `Bearer ${this.session?.accessJwt}` }
|
|
391
|
+
}
|
|
392
|
+
);
|
|
393
|
+
if (!response.ok) {
|
|
394
|
+
await this.handleError(response);
|
|
395
|
+
}
|
|
396
|
+
const data = await response.json();
|
|
397
|
+
const post = data.thread.post;
|
|
398
|
+
return {
|
|
399
|
+
id: post.uri,
|
|
400
|
+
url: `https://bsky.app/profile/${post.author.handle}/post/${post.uri.split("/").pop()}`,
|
|
401
|
+
type: "text",
|
|
402
|
+
description: post.record.text,
|
|
403
|
+
publishedAt: new Date(post.record.createdAt),
|
|
404
|
+
visibility: "public",
|
|
405
|
+
analytics: {
|
|
406
|
+
likes: post.likeCount,
|
|
407
|
+
shares: post.repostCount,
|
|
408
|
+
comments: post.replyCount,
|
|
409
|
+
lastUpdated: /* @__PURE__ */ new Date(),
|
|
410
|
+
raw: {
|
|
411
|
+
likeCount: post.likeCount,
|
|
412
|
+
repostCount: post.repostCount,
|
|
413
|
+
replyCount: post.replyCount,
|
|
414
|
+
quoteCount: post.quoteCount
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async deletePost(postId) {
|
|
420
|
+
if (!this.session) {
|
|
421
|
+
await this.authenticate();
|
|
422
|
+
}
|
|
423
|
+
const rkey = postId.includes("/") ? postId.split("/").pop() : postId;
|
|
424
|
+
const response = await fetch(
|
|
425
|
+
`${this.pdsUrl}/xrpc/com.atproto.repo.deleteRecord`,
|
|
426
|
+
{
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: {
|
|
429
|
+
Authorization: `Bearer ${this.session?.accessJwt}`,
|
|
430
|
+
"Content-Type": "application/json"
|
|
431
|
+
},
|
|
432
|
+
body: JSON.stringify({
|
|
433
|
+
repo: this.session?.did,
|
|
434
|
+
collection: "app.bsky.feed.post",
|
|
435
|
+
rkey
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
if (!response.ok) {
|
|
440
|
+
await this.handleError(response);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
async getAnalytics(postId) {
|
|
444
|
+
const post = await this.getPost(postId);
|
|
445
|
+
return post.analytics ?? {};
|
|
446
|
+
}
|
|
447
|
+
getCapabilities() {
|
|
448
|
+
return {
|
|
449
|
+
video: false,
|
|
450
|
+
// Limited video support
|
|
451
|
+
image: true,
|
|
452
|
+
text: true,
|
|
453
|
+
link: true,
|
|
454
|
+
linkAttachment: true,
|
|
455
|
+
scheduling: false,
|
|
456
|
+
analytics: true,
|
|
457
|
+
rawAnalytics: true,
|
|
458
|
+
publishModes: ["dry_run", "stage_remote", "public"],
|
|
459
|
+
staging: true,
|
|
460
|
+
privatePublishing: false,
|
|
461
|
+
maxVideoLength: 0,
|
|
462
|
+
maxVideoSize: 0,
|
|
463
|
+
supportedVideoFormats: [],
|
|
464
|
+
aspectRatios: ["1:1", "16:9", "4:3"],
|
|
465
|
+
maxTextLength: 300,
|
|
466
|
+
maxHashtags: void 0,
|
|
467
|
+
// No limit
|
|
468
|
+
supportedPostTypes: ["text", "image", "link"]
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Get reply reference for threading
|
|
473
|
+
*/
|
|
474
|
+
async getReplyRef(parentUri) {
|
|
475
|
+
const response = await fetch(
|
|
476
|
+
`${this.pdsUrl}/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(parentUri)}&depth=0`,
|
|
477
|
+
{
|
|
478
|
+
headers: { Authorization: `Bearer ${this.session?.accessJwt}` }
|
|
479
|
+
}
|
|
480
|
+
);
|
|
481
|
+
if (!response.ok) {
|
|
482
|
+
throw new SocialError(
|
|
483
|
+
"Failed to get parent post",
|
|
484
|
+
"NOT_FOUND",
|
|
485
|
+
"bluesky"
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const data = await response.json();
|
|
489
|
+
const parent = data.thread.post;
|
|
490
|
+
const root = parent.record.reply?.root ?? {
|
|
491
|
+
uri: parent.uri,
|
|
492
|
+
cid: parent.cid
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
root,
|
|
496
|
+
parent: {
|
|
497
|
+
uri: parent.uri,
|
|
498
|
+
cid: parent.cid
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Build post text with hashtags
|
|
504
|
+
*/
|
|
505
|
+
buildPostText(text, tags, linkUrl) {
|
|
506
|
+
let result = text ?? "";
|
|
507
|
+
if (linkUrl && !result.includes(linkUrl)) {
|
|
508
|
+
result += `
|
|
509
|
+
|
|
510
|
+
${linkUrl}`;
|
|
511
|
+
}
|
|
512
|
+
if (tags && tags.length > 0) {
|
|
513
|
+
const hashtags = tags.map((t) => t.startsWith("#") ? t : `#${t}`);
|
|
514
|
+
result += `
|
|
515
|
+
|
|
516
|
+
${hashtags.join(" ")}`;
|
|
517
|
+
}
|
|
518
|
+
if (result.length > 300) {
|
|
519
|
+
result = `${result.substring(0, 297)}...`;
|
|
520
|
+
}
|
|
521
|
+
return result;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Handle API errors
|
|
525
|
+
*/
|
|
526
|
+
async handleError(response) {
|
|
527
|
+
const text = await response.text();
|
|
528
|
+
let error;
|
|
529
|
+
try {
|
|
530
|
+
error = JSON.parse(text);
|
|
531
|
+
} catch {
|
|
532
|
+
error = { message: text };
|
|
533
|
+
}
|
|
534
|
+
if (response.status === 401) {
|
|
535
|
+
throw new SocialAuthError("bluesky", error.message ?? "Unauthorized");
|
|
536
|
+
}
|
|
537
|
+
if (response.status === 429) {
|
|
538
|
+
throw new SocialRateLimitError("bluesky");
|
|
539
|
+
}
|
|
540
|
+
throw new SocialError(
|
|
541
|
+
error.message ?? "API request failed",
|
|
542
|
+
error.error ?? "API_ERROR",
|
|
543
|
+
"bluesky",
|
|
544
|
+
response.status
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
class FacebookPageAdapter {
|
|
549
|
+
platform = "facebook";
|
|
550
|
+
config;
|
|
551
|
+
constructor(config) {
|
|
552
|
+
this.config = config;
|
|
553
|
+
}
|
|
554
|
+
get graphUrl() {
|
|
555
|
+
return `https://graph.facebook.com/${this.config.apiVersion ?? "v24.0"}`;
|
|
556
|
+
}
|
|
557
|
+
async authenticate() {
|
|
558
|
+
const response = await fetch(
|
|
559
|
+
`${this.graphUrl}/${this.config.pageId}?fields=id,name,link&access_token=${encodeURIComponent(this.config.accessToken)}`
|
|
560
|
+
);
|
|
561
|
+
if (!response.ok) {
|
|
562
|
+
await this.handleError(response);
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
accessToken: this.config.accessToken
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
async refreshToken(_refreshToken) {
|
|
569
|
+
throw new SocialError(
|
|
570
|
+
"Facebook Page access tokens are refreshed through Meta OAuth",
|
|
571
|
+
"NOT_IMPLEMENTED",
|
|
572
|
+
"facebook"
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
async publishText(text) {
|
|
576
|
+
const publishMode = resolvePublishMode(this.config);
|
|
577
|
+
if (publishMode === "dry_run") {
|
|
578
|
+
return createSafetyResult({
|
|
579
|
+
platform: this.platform,
|
|
580
|
+
mode: publishMode,
|
|
581
|
+
postType: "text",
|
|
582
|
+
payload: {
|
|
583
|
+
message: this.buildPostText(text.text, text.tags),
|
|
584
|
+
link: text.linkUrl
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
return this.createFeedPost(
|
|
589
|
+
{
|
|
590
|
+
message: this.buildPostText(text.text, text.tags),
|
|
591
|
+
...text.linkUrl ? { link: text.linkUrl } : {},
|
|
592
|
+
...this.safetyFeedFields(publishMode, text.scheduledAt)
|
|
593
|
+
},
|
|
594
|
+
publishMode
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
async publishLink(link) {
|
|
598
|
+
const publishMode = resolvePublishMode(this.config);
|
|
599
|
+
const payload = {
|
|
600
|
+
message: this.buildPostText(
|
|
601
|
+
link.text ?? link.title ?? link.description ?? "",
|
|
602
|
+
link.tags
|
|
603
|
+
),
|
|
604
|
+
link: link.url,
|
|
605
|
+
...this.safetyFeedFields(publishMode, link.scheduledAt)
|
|
606
|
+
};
|
|
607
|
+
if (publishMode === "dry_run") {
|
|
608
|
+
return createSafetyResult({
|
|
609
|
+
platform: this.platform,
|
|
610
|
+
mode: publishMode,
|
|
611
|
+
postType: "link",
|
|
612
|
+
payload
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return this.createFeedPost(payload, publishMode);
|
|
616
|
+
}
|
|
617
|
+
async publishImage(image) {
|
|
618
|
+
const publishMode = resolvePublishMode(this.config);
|
|
619
|
+
if (publishMode === "dry_run") {
|
|
620
|
+
return createSafetyResult({
|
|
621
|
+
platform: this.platform,
|
|
622
|
+
mode: publishMode,
|
|
623
|
+
postType: "image",
|
|
624
|
+
payload: {
|
|
625
|
+
caption: this.buildPostText(
|
|
626
|
+
image.description,
|
|
627
|
+
image.tags,
|
|
628
|
+
image.linkUrl
|
|
629
|
+
),
|
|
630
|
+
url: typeof image.file === "string" ? image.file : void 0,
|
|
631
|
+
link: image.linkUrl
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
const form = new FormData();
|
|
636
|
+
form.set("access_token", this.config.accessToken);
|
|
637
|
+
form.set(
|
|
638
|
+
"caption",
|
|
639
|
+
this.buildPostText(image.description, image.tags, image.linkUrl)
|
|
640
|
+
);
|
|
641
|
+
for (const [key, value] of Object.entries(
|
|
642
|
+
this.safetyFeedFields(publishMode, image.scheduledAt)
|
|
643
|
+
)) {
|
|
644
|
+
if (value !== void 0) form.set(key, value);
|
|
645
|
+
}
|
|
646
|
+
if (typeof image.file === "string") {
|
|
647
|
+
form.set("url", image.file);
|
|
648
|
+
} else {
|
|
649
|
+
form.set("source", new Blob([new Uint8Array(image.file)]));
|
|
650
|
+
}
|
|
651
|
+
const response = await fetch(
|
|
652
|
+
`${this.graphUrl}/${this.config.pageId}/photos`,
|
|
653
|
+
{
|
|
654
|
+
method: "POST",
|
|
655
|
+
body: form
|
|
656
|
+
}
|
|
657
|
+
);
|
|
658
|
+
if (!response.ok) {
|
|
659
|
+
await this.handleError(response);
|
|
660
|
+
}
|
|
661
|
+
const data = await response.json();
|
|
662
|
+
return this.toPostResult(
|
|
663
|
+
data.post_id ?? data.id,
|
|
664
|
+
this.safetyResultStatus(publishMode, image.scheduledAt),
|
|
665
|
+
publishMode
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
async publishVideo(video) {
|
|
669
|
+
const publishMode = resolvePublishMode(this.config);
|
|
670
|
+
if (publishMode === "dry_run") {
|
|
671
|
+
return createSafetyResult({
|
|
672
|
+
platform: this.platform,
|
|
673
|
+
mode: publishMode,
|
|
674
|
+
postType: "video",
|
|
675
|
+
payload: {
|
|
676
|
+
title: video.title,
|
|
677
|
+
description: this.buildPostText(
|
|
678
|
+
video.description,
|
|
679
|
+
video.tags,
|
|
680
|
+
video.linkUrl
|
|
681
|
+
),
|
|
682
|
+
fileUrl: typeof video.file === "string" ? video.file : void 0,
|
|
683
|
+
link: video.linkUrl,
|
|
684
|
+
scheduledAt: video.scheduledAt?.toISOString()
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
const form = new FormData();
|
|
689
|
+
form.set("access_token", this.config.accessToken);
|
|
690
|
+
form.set(
|
|
691
|
+
"description",
|
|
692
|
+
this.buildPostText(video.description, video.tags, video.linkUrl)
|
|
693
|
+
);
|
|
694
|
+
for (const [key, value] of Object.entries(
|
|
695
|
+
this.safetyFeedFields(publishMode, video.scheduledAt)
|
|
696
|
+
)) {
|
|
697
|
+
if (value !== void 0) form.set(key, value);
|
|
698
|
+
}
|
|
699
|
+
if (video.title) {
|
|
700
|
+
form.set("title", video.title);
|
|
701
|
+
}
|
|
702
|
+
if (video.linkUrl) {
|
|
703
|
+
form.set("embeddable", "true");
|
|
704
|
+
}
|
|
705
|
+
if (typeof video.file === "string") {
|
|
706
|
+
form.set("file_url", video.file);
|
|
707
|
+
} else {
|
|
708
|
+
form.set("source", new Blob([new Uint8Array(video.file)]));
|
|
709
|
+
}
|
|
710
|
+
const response = await fetch(
|
|
711
|
+
`${this.graphUrl}/${this.config.pageId}/videos`,
|
|
712
|
+
{
|
|
713
|
+
method: "POST",
|
|
714
|
+
body: form
|
|
715
|
+
}
|
|
716
|
+
);
|
|
717
|
+
if (!response.ok) {
|
|
718
|
+
await this.handleError(response);
|
|
719
|
+
}
|
|
720
|
+
const data = await response.json();
|
|
721
|
+
return this.toPostResult(
|
|
722
|
+
data.id,
|
|
723
|
+
this.safetyResultStatus(publishMode, video.scheduledAt, "processing"),
|
|
724
|
+
publishMode
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
async getPost(postId) {
|
|
728
|
+
const response = await fetch(
|
|
729
|
+
`${this.graphUrl}/${postId}?fields=id,message,created_time,permalink_url,attachments{media_type},insights.metric(post_impressions,post_clicks,post_reactions_by_type_total,post_comments,post_shares)&access_token=${encodeURIComponent(this.config.accessToken)}`
|
|
730
|
+
);
|
|
731
|
+
if (!response.ok) {
|
|
732
|
+
await this.handleError(response);
|
|
733
|
+
}
|
|
734
|
+
const data = await response.json();
|
|
735
|
+
const mediaType = data.attachments?.data?.[0]?.media_type;
|
|
736
|
+
return {
|
|
737
|
+
id: data.id,
|
|
738
|
+
url: data.permalink_url ?? `https://www.facebook.com/${data.id}`,
|
|
739
|
+
type: mediaType === "video" ? "video" : mediaType === "photo" ? "image" : mediaType === "share" ? "link" : "text",
|
|
740
|
+
description: data.message,
|
|
741
|
+
publishedAt: data.created_time ? new Date(data.created_time) : /* @__PURE__ */ new Date(),
|
|
742
|
+
visibility: "public",
|
|
743
|
+
analytics: this.parseInsights(data.insights?.data ?? [])
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
async deletePost(postId) {
|
|
747
|
+
const response = await fetch(
|
|
748
|
+
`${this.graphUrl}/${postId}?access_token=${encodeURIComponent(this.config.accessToken)}`,
|
|
749
|
+
{ method: "DELETE" }
|
|
750
|
+
);
|
|
751
|
+
if (!response.ok) {
|
|
752
|
+
await this.handleError(response);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
async getAnalytics(postId) {
|
|
756
|
+
const post = await this.getPost(postId);
|
|
757
|
+
return post.analytics ?? {};
|
|
758
|
+
}
|
|
759
|
+
getCapabilities() {
|
|
760
|
+
return {
|
|
761
|
+
video: true,
|
|
762
|
+
image: true,
|
|
763
|
+
text: true,
|
|
764
|
+
link: true,
|
|
765
|
+
linkAttachment: true,
|
|
766
|
+
scheduling: true,
|
|
767
|
+
analytics: true,
|
|
768
|
+
rawAnalytics: true,
|
|
769
|
+
publishModes: [
|
|
770
|
+
"dry_run",
|
|
771
|
+
"stage_remote",
|
|
772
|
+
"private_or_scheduled",
|
|
773
|
+
"public"
|
|
774
|
+
],
|
|
775
|
+
staging: true,
|
|
776
|
+
privatePublishing: true,
|
|
777
|
+
maxVideoLength: 240 * 60,
|
|
778
|
+
maxVideoSize: 10 * 1024 * 1024 * 1024,
|
|
779
|
+
supportedVideoFormats: ["mp4", "mov"],
|
|
780
|
+
aspectRatios: ["16:9", "1:1", "9:16", "4:5"],
|
|
781
|
+
maxTextLength: 63206,
|
|
782
|
+
maxHashtags: void 0,
|
|
783
|
+
supportedPostTypes: ["text", "image", "video", "link"]
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
async createFeedPost(fields, publishMode = "public") {
|
|
787
|
+
const body = new URLSearchParams();
|
|
788
|
+
body.set("access_token", this.config.accessToken);
|
|
789
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
790
|
+
if (value !== void 0 && value !== "") {
|
|
791
|
+
body.set(key, value);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const response = await fetch(
|
|
795
|
+
`${this.graphUrl}/${this.config.pageId}/feed`,
|
|
796
|
+
{
|
|
797
|
+
method: "POST",
|
|
798
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
799
|
+
body
|
|
800
|
+
}
|
|
801
|
+
);
|
|
802
|
+
if (!response.ok) {
|
|
803
|
+
await this.handleError(response);
|
|
804
|
+
}
|
|
805
|
+
const data = await response.json();
|
|
806
|
+
const status = fields.published === "false" && fields.scheduled_publish_time ? "scheduled" : fields.published === "false" ? "staged" : "published";
|
|
807
|
+
return this.toPostResult(data.id, status, publishMode);
|
|
808
|
+
}
|
|
809
|
+
toPostResult(id, status, publishMode = "public") {
|
|
810
|
+
return {
|
|
811
|
+
id,
|
|
812
|
+
url: `https://www.facebook.com/${id}`,
|
|
813
|
+
status,
|
|
814
|
+
publishedAt: status === "published" ? /* @__PURE__ */ new Date() : void 0,
|
|
815
|
+
metadata: {
|
|
816
|
+
publishMode,
|
|
817
|
+
safety: publishMode !== "public"
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
safetyFeedFields(publishMode, scheduledAt) {
|
|
822
|
+
if (isPublicPublishMode(publishMode)) {
|
|
823
|
+
return {};
|
|
824
|
+
}
|
|
825
|
+
return {
|
|
826
|
+
published: "false",
|
|
827
|
+
...scheduledAt ? {
|
|
828
|
+
scheduled_publish_time: Math.floor(
|
|
829
|
+
scheduledAt.getTime() / 1e3
|
|
830
|
+
).toString()
|
|
831
|
+
} : {}
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
safetyResultStatus(publishMode, scheduledAt, publicStatus = "published") {
|
|
835
|
+
if (isPublicPublishMode(publishMode)) return publicStatus;
|
|
836
|
+
return scheduledAt ? "scheduled" : "staged";
|
|
837
|
+
}
|
|
838
|
+
buildPostText(text, tags, linkUrl) {
|
|
839
|
+
let result = text ?? "";
|
|
840
|
+
if (linkUrl && !result.includes(linkUrl)) {
|
|
841
|
+
result += result.length > 0 ? `
|
|
842
|
+
|
|
843
|
+
${linkUrl}` : linkUrl;
|
|
844
|
+
}
|
|
845
|
+
if (tags && tags.length > 0) {
|
|
846
|
+
const hashtags = tags.map(
|
|
847
|
+
(tag) => tag.startsWith("#") ? tag : `#${tag}`
|
|
848
|
+
);
|
|
849
|
+
result += result.length > 0 ? `
|
|
850
|
+
|
|
851
|
+
${hashtags.join(" ")}` : hashtags.join(" ");
|
|
852
|
+
}
|
|
853
|
+
return result;
|
|
854
|
+
}
|
|
855
|
+
parseInsights(insights) {
|
|
856
|
+
const analytics = { lastUpdated: /* @__PURE__ */ new Date(), raw: insights };
|
|
857
|
+
for (const insight of insights) {
|
|
858
|
+
const value = insight.values?.[0]?.value;
|
|
859
|
+
switch (insight.name) {
|
|
860
|
+
case "post_impressions":
|
|
861
|
+
analytics.views = typeof value === "number" ? value : void 0;
|
|
862
|
+
analytics.impressions = analytics.views;
|
|
863
|
+
break;
|
|
864
|
+
case "post_clicks":
|
|
865
|
+
analytics.clicks = typeof value === "number" ? value : void 0;
|
|
866
|
+
break;
|
|
867
|
+
case "post_comments":
|
|
868
|
+
analytics.comments = typeof value === "number" ? value : void 0;
|
|
869
|
+
break;
|
|
870
|
+
case "post_shares":
|
|
871
|
+
analytics.shares = typeof value === "number" ? value : void 0;
|
|
872
|
+
break;
|
|
873
|
+
case "post_reactions_by_type_total":
|
|
874
|
+
analytics.likes = typeof value === "object" && value !== null ? this.sumPositiveReactions(value) : void 0;
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return analytics;
|
|
879
|
+
}
|
|
880
|
+
sumPositiveReactions(reactions) {
|
|
881
|
+
return ["like", "love", "haha", "wow"].reduce((sum, key) => {
|
|
882
|
+
const count = reactions[key];
|
|
883
|
+
return sum + (typeof count === "number" ? count : 0);
|
|
884
|
+
}, 0);
|
|
885
|
+
}
|
|
886
|
+
async handleError(response) {
|
|
887
|
+
const text = await response.text();
|
|
888
|
+
let error;
|
|
889
|
+
try {
|
|
890
|
+
error = JSON.parse(text);
|
|
891
|
+
} catch {
|
|
892
|
+
error = { error: { message: text } };
|
|
893
|
+
}
|
|
894
|
+
if (response.status === 401 || error.error?.code === 190) {
|
|
895
|
+
throw new SocialAuthError(
|
|
896
|
+
"facebook",
|
|
897
|
+
error.error?.message ?? "Unauthorized"
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
if (response.status === 429 || error.error?.code === 4) {
|
|
901
|
+
throw new SocialRateLimitError("facebook");
|
|
902
|
+
}
|
|
903
|
+
throw new SocialError(
|
|
904
|
+
error.error?.message ?? "API request failed",
|
|
905
|
+
error.error?.code?.toString() ?? "API_ERROR",
|
|
906
|
+
"facebook",
|
|
907
|
+
response.status
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const THREADS_API_URL = "https://graph.threads.net/v1.0";
|
|
912
|
+
class ThreadsAdapter {
|
|
913
|
+
platform = "threads";
|
|
914
|
+
config;
|
|
915
|
+
constructor(config) {
|
|
916
|
+
this.config = config;
|
|
917
|
+
}
|
|
918
|
+
async authenticate() {
|
|
919
|
+
const response = await fetch(
|
|
920
|
+
`${THREADS_API_URL}/${this.config.userId}?fields=id,username`,
|
|
921
|
+
{
|
|
922
|
+
headers: {
|
|
923
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
);
|
|
927
|
+
if (!response.ok) {
|
|
928
|
+
const error = await response.json();
|
|
929
|
+
throw new SocialAuthError(
|
|
930
|
+
"threads",
|
|
931
|
+
error.error?.message ?? "Authentication failed"
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
accessToken: this.config.accessToken
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
async refreshToken(_refreshToken) {
|
|
939
|
+
throw new SocialError(
|
|
940
|
+
"Use Meta OAuth token exchange for refresh",
|
|
941
|
+
"NOT_IMPLEMENTED",
|
|
942
|
+
"threads"
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
async publishVideo(video) {
|
|
946
|
+
const publishMode = resolvePublishMode(this.config);
|
|
947
|
+
const text = this.buildPostText(video.description, video.tags);
|
|
948
|
+
const dryRunPayload = {
|
|
949
|
+
media_type: "VIDEO",
|
|
950
|
+
...typeof video.file === "string" ? { video_url: video.file } : {},
|
|
951
|
+
text,
|
|
952
|
+
...video.linkUrl ? { link_attachment: video.linkUrl } : {}
|
|
953
|
+
};
|
|
954
|
+
if (publishMode === "dry_run") {
|
|
955
|
+
return createSafetyResult({
|
|
956
|
+
platform: this.platform,
|
|
957
|
+
mode: publishMode,
|
|
958
|
+
postType: "video",
|
|
959
|
+
payload: dryRunPayload,
|
|
960
|
+
note: "Threads dry run: media container was not created."
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
const videoUrl = Buffer.isBuffer(video.file) ? await this.uploadToTempStorage(video.file, "video/mp4") : video.file;
|
|
964
|
+
const payload = {
|
|
965
|
+
media_type: "VIDEO",
|
|
966
|
+
video_url: videoUrl,
|
|
967
|
+
text,
|
|
968
|
+
...video.linkUrl ? { link_attachment: video.linkUrl } : {}
|
|
969
|
+
};
|
|
970
|
+
const containerResponse = await fetch(
|
|
971
|
+
`${THREADS_API_URL}/${this.config.userId}/threads`,
|
|
972
|
+
{
|
|
973
|
+
method: "POST",
|
|
974
|
+
headers: {
|
|
975
|
+
"Content-Type": "application/json",
|
|
976
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
977
|
+
},
|
|
978
|
+
body: JSON.stringify(payload)
|
|
979
|
+
}
|
|
980
|
+
);
|
|
981
|
+
if (!containerResponse.ok) {
|
|
982
|
+
await this.handleError(containerResponse);
|
|
983
|
+
}
|
|
984
|
+
const containerData = await containerResponse.json();
|
|
985
|
+
const containerId = containerData.id;
|
|
986
|
+
await this.waitForContainer(containerId);
|
|
987
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
988
|
+
return createSafetyResult({
|
|
989
|
+
platform: this.platform,
|
|
990
|
+
mode: publishMode,
|
|
991
|
+
postType: "video",
|
|
992
|
+
payload,
|
|
993
|
+
remoteId: containerId,
|
|
994
|
+
staged: true,
|
|
995
|
+
note: "Threads media container created but not published."
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
return this.publishContainer(containerId, publishMode);
|
|
999
|
+
}
|
|
1000
|
+
async publishImage(image) {
|
|
1001
|
+
const publishMode = resolvePublishMode(this.config);
|
|
1002
|
+
const text = this.buildPostText(image.description, image.tags);
|
|
1003
|
+
const dryRunPayload = {
|
|
1004
|
+
media_type: "IMAGE",
|
|
1005
|
+
...typeof image.file === "string" ? { image_url: image.file } : {},
|
|
1006
|
+
text,
|
|
1007
|
+
...image.linkUrl ? { link_attachment: image.linkUrl } : {}
|
|
1008
|
+
};
|
|
1009
|
+
if (publishMode === "dry_run") {
|
|
1010
|
+
return createSafetyResult({
|
|
1011
|
+
platform: this.platform,
|
|
1012
|
+
mode: publishMode,
|
|
1013
|
+
postType: "image",
|
|
1014
|
+
payload: dryRunPayload,
|
|
1015
|
+
note: "Threads dry run: media container was not created."
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
const imageUrl = Buffer.isBuffer(image.file) ? await this.uploadToTempStorage(image.file, "image/png") : image.file;
|
|
1019
|
+
const payload = {
|
|
1020
|
+
media_type: "IMAGE",
|
|
1021
|
+
image_url: imageUrl,
|
|
1022
|
+
text,
|
|
1023
|
+
...image.linkUrl ? { link_attachment: image.linkUrl } : {}
|
|
1024
|
+
};
|
|
1025
|
+
const containerResponse = await fetch(
|
|
1026
|
+
`${THREADS_API_URL}/${this.config.userId}/threads`,
|
|
1027
|
+
{
|
|
1028
|
+
method: "POST",
|
|
1029
|
+
headers: {
|
|
1030
|
+
"Content-Type": "application/json",
|
|
1031
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
1032
|
+
},
|
|
1033
|
+
body: JSON.stringify(payload)
|
|
1034
|
+
}
|
|
1035
|
+
);
|
|
1036
|
+
if (!containerResponse.ok) {
|
|
1037
|
+
await this.handleError(containerResponse);
|
|
1038
|
+
}
|
|
1039
|
+
const containerData = await containerResponse.json();
|
|
1040
|
+
const containerId = containerData.id;
|
|
1041
|
+
await this.waitForContainer(containerId);
|
|
1042
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
1043
|
+
return createSafetyResult({
|
|
1044
|
+
platform: this.platform,
|
|
1045
|
+
mode: publishMode,
|
|
1046
|
+
postType: "image",
|
|
1047
|
+
payload,
|
|
1048
|
+
remoteId: containerId,
|
|
1049
|
+
staged: true,
|
|
1050
|
+
note: "Threads media container created but not published."
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
return this.publishContainer(containerId, publishMode);
|
|
1054
|
+
}
|
|
1055
|
+
async publishText(text) {
|
|
1056
|
+
const publishMode = resolvePublishMode(this.config);
|
|
1057
|
+
const postText = this.buildPostText(text.text, text.tags);
|
|
1058
|
+
const body = {
|
|
1059
|
+
media_type: "TEXT",
|
|
1060
|
+
text: postText
|
|
1061
|
+
};
|
|
1062
|
+
if (text.linkUrl) {
|
|
1063
|
+
body.link_attachment = text.linkUrl;
|
|
1064
|
+
}
|
|
1065
|
+
if (text.replyTo) {
|
|
1066
|
+
body.reply_to_id = text.replyTo;
|
|
1067
|
+
}
|
|
1068
|
+
if (publishMode === "dry_run") {
|
|
1069
|
+
return createSafetyResult({
|
|
1070
|
+
platform: this.platform,
|
|
1071
|
+
mode: publishMode,
|
|
1072
|
+
postType: "text",
|
|
1073
|
+
payload: body,
|
|
1074
|
+
note: "Threads dry run: text container was not created."
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
const containerResponse = await fetch(
|
|
1078
|
+
`${THREADS_API_URL}/${this.config.userId}/threads`,
|
|
1079
|
+
{
|
|
1080
|
+
method: "POST",
|
|
1081
|
+
headers: {
|
|
1082
|
+
"Content-Type": "application/json",
|
|
1083
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
1084
|
+
},
|
|
1085
|
+
body: JSON.stringify(body)
|
|
1086
|
+
}
|
|
1087
|
+
);
|
|
1088
|
+
if (!containerResponse.ok) {
|
|
1089
|
+
await this.handleError(containerResponse);
|
|
1090
|
+
}
|
|
1091
|
+
const containerData = await containerResponse.json();
|
|
1092
|
+
const containerId = containerData.id;
|
|
1093
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
1094
|
+
return createSafetyResult({
|
|
1095
|
+
platform: this.platform,
|
|
1096
|
+
mode: publishMode,
|
|
1097
|
+
postType: "text",
|
|
1098
|
+
payload: body,
|
|
1099
|
+
remoteId: containerId,
|
|
1100
|
+
staged: true,
|
|
1101
|
+
note: "Threads text container created but not published."
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
return this.publishContainer(containerId, publishMode);
|
|
1105
|
+
}
|
|
1106
|
+
async publishLink(link) {
|
|
1107
|
+
return this.publishText({
|
|
1108
|
+
text: link.text ?? link.title ?? link.description ?? link.url,
|
|
1109
|
+
tags: link.tags,
|
|
1110
|
+
linkUrl: link.url,
|
|
1111
|
+
scheduledAt: link.scheduledAt,
|
|
1112
|
+
linkBehavior: link.linkBehavior
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Wait for media container to finish processing
|
|
1117
|
+
*/
|
|
1118
|
+
async waitForContainer(containerId) {
|
|
1119
|
+
let checkCount = 0;
|
|
1120
|
+
const maxChecks = 60;
|
|
1121
|
+
while (checkCount < maxChecks) {
|
|
1122
|
+
const statusResponse = await fetch(
|
|
1123
|
+
`${THREADS_API_URL}/${containerId}?fields=status`,
|
|
1124
|
+
{
|
|
1125
|
+
headers: {
|
|
1126
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
);
|
|
1130
|
+
if (!statusResponse.ok) {
|
|
1131
|
+
throw new SocialError(
|
|
1132
|
+
"Failed to check container status",
|
|
1133
|
+
"STATUS_CHECK_FAILED",
|
|
1134
|
+
"threads"
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
const statusData = await statusResponse.json();
|
|
1138
|
+
if (statusData.status === "FINISHED") {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
if (statusData.status === "ERROR") {
|
|
1142
|
+
throw new SocialError(
|
|
1143
|
+
"Media processing failed",
|
|
1144
|
+
"PROCESSING_FAILED",
|
|
1145
|
+
"threads"
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
await new Promise((resolve) => setTimeout(resolve, 5e3));
|
|
1149
|
+
checkCount++;
|
|
1150
|
+
}
|
|
1151
|
+
throw new SocialError(
|
|
1152
|
+
"Media processing timeout",
|
|
1153
|
+
"PROCESSING_TIMEOUT",
|
|
1154
|
+
"threads"
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Publish a prepared container
|
|
1159
|
+
*/
|
|
1160
|
+
async publishContainer(containerId, publishMode = "public") {
|
|
1161
|
+
const publishResponse = await fetch(
|
|
1162
|
+
`${THREADS_API_URL}/${this.config.userId}/threads_publish`,
|
|
1163
|
+
{
|
|
1164
|
+
method: "POST",
|
|
1165
|
+
headers: {
|
|
1166
|
+
"Content-Type": "application/json",
|
|
1167
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
1168
|
+
},
|
|
1169
|
+
body: JSON.stringify({
|
|
1170
|
+
creation_id: containerId
|
|
1171
|
+
})
|
|
1172
|
+
}
|
|
1173
|
+
);
|
|
1174
|
+
if (!publishResponse.ok) {
|
|
1175
|
+
await this.handleError(publishResponse);
|
|
1176
|
+
}
|
|
1177
|
+
const publishData = await publishResponse.json();
|
|
1178
|
+
const threadResponse = await fetch(
|
|
1179
|
+
`${THREADS_API_URL}/${publishData.id}?fields=id,permalink`,
|
|
1180
|
+
{
|
|
1181
|
+
headers: {
|
|
1182
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
);
|
|
1186
|
+
const threadData = await threadResponse.json();
|
|
1187
|
+
return {
|
|
1188
|
+
id: publishData.id,
|
|
1189
|
+
url: threadData.permalink ?? `https://www.threads.net/@${this.config.userId}/post/${publishData.id}`,
|
|
1190
|
+
status: "published",
|
|
1191
|
+
publishedAt: /* @__PURE__ */ new Date(),
|
|
1192
|
+
metadata: { publishMode }
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Upload buffer to temporary storage and return URL
|
|
1197
|
+
* Note: In production, this would upload to a CDN or storage service
|
|
1198
|
+
*/
|
|
1199
|
+
async uploadToTempStorage(_buffer, _mimeType) {
|
|
1200
|
+
throw new SocialError(
|
|
1201
|
+
"Buffer upload requires external storage configuration. Provide a URL instead.",
|
|
1202
|
+
"NOT_IMPLEMENTED",
|
|
1203
|
+
"threads"
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
async getPost(postId) {
|
|
1207
|
+
const response = await fetch(
|
|
1208
|
+
`${THREADS_API_URL}/${postId}?fields=id,text,timestamp,media_type,permalink`,
|
|
1209
|
+
{
|
|
1210
|
+
headers: {
|
|
1211
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
);
|
|
1215
|
+
if (!response.ok) {
|
|
1216
|
+
await this.handleError(response);
|
|
1217
|
+
}
|
|
1218
|
+
const data = await response.json();
|
|
1219
|
+
let analytics = {};
|
|
1220
|
+
try {
|
|
1221
|
+
const insightsResponse = await fetch(
|
|
1222
|
+
`${THREADS_API_URL}/${postId}/insights?metric=views,likes,replies,reposts`,
|
|
1223
|
+
{
|
|
1224
|
+
headers: {
|
|
1225
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
);
|
|
1229
|
+
if (insightsResponse.ok) {
|
|
1230
|
+
const insightsData = await insightsResponse.json();
|
|
1231
|
+
analytics = this.parseInsights(insightsData.data);
|
|
1232
|
+
}
|
|
1233
|
+
} catch {
|
|
1234
|
+
}
|
|
1235
|
+
return {
|
|
1236
|
+
id: data.id,
|
|
1237
|
+
url: data.permalink,
|
|
1238
|
+
type: this.mapMediaType(data.media_type),
|
|
1239
|
+
description: data.text,
|
|
1240
|
+
publishedAt: new Date(data.timestamp),
|
|
1241
|
+
visibility: "public",
|
|
1242
|
+
analytics
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
async deletePost(_postId) {
|
|
1246
|
+
throw new SocialError(
|
|
1247
|
+
"Threads does not support programmatic post deletion",
|
|
1248
|
+
"NOT_SUPPORTED",
|
|
1249
|
+
"threads"
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
async getAnalytics(postId) {
|
|
1253
|
+
const response = await fetch(
|
|
1254
|
+
`${THREADS_API_URL}/${postId}/insights?metric=views,likes,replies,reposts,quotes`,
|
|
1255
|
+
{
|
|
1256
|
+
headers: {
|
|
1257
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
);
|
|
1261
|
+
if (!response.ok) {
|
|
1262
|
+
await this.handleError(response);
|
|
1263
|
+
}
|
|
1264
|
+
const data = await response.json();
|
|
1265
|
+
return this.parseInsights(data.data);
|
|
1266
|
+
}
|
|
1267
|
+
getCapabilities() {
|
|
1268
|
+
return {
|
|
1269
|
+
video: true,
|
|
1270
|
+
image: true,
|
|
1271
|
+
text: true,
|
|
1272
|
+
link: true,
|
|
1273
|
+
linkAttachment: true,
|
|
1274
|
+
scheduling: false,
|
|
1275
|
+
analytics: true,
|
|
1276
|
+
rawAnalytics: true,
|
|
1277
|
+
requiresPublicMediaUrl: true,
|
|
1278
|
+
publishModes: ["dry_run", "stage_remote", "public"],
|
|
1279
|
+
staging: true,
|
|
1280
|
+
privatePublishing: false,
|
|
1281
|
+
maxVideoLength: 300,
|
|
1282
|
+
// 5 minutes
|
|
1283
|
+
maxVideoSize: 1024 * 1024 * 1024,
|
|
1284
|
+
// 1GB
|
|
1285
|
+
supportedVideoFormats: ["mp4", "mov"],
|
|
1286
|
+
aspectRatios: ["1:1", "4:5", "9:16"],
|
|
1287
|
+
maxTextLength: 500,
|
|
1288
|
+
maxHashtags: void 0,
|
|
1289
|
+
supportedPostTypes: ["text", "image", "video", "link"]
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Build post text with hashtags and link
|
|
1294
|
+
*/
|
|
1295
|
+
buildPostText(text, tags) {
|
|
1296
|
+
let result = text ?? "";
|
|
1297
|
+
if (tags && tags.length > 0) {
|
|
1298
|
+
const hashtags = tags.map((t) => t.startsWith("#") ? t : `#${t}`);
|
|
1299
|
+
result += `
|
|
1300
|
+
|
|
1301
|
+
${hashtags.join(" ")}`;
|
|
1302
|
+
}
|
|
1303
|
+
if (result.length > 500) {
|
|
1304
|
+
result = `${result.substring(0, 497)}...`;
|
|
1305
|
+
}
|
|
1306
|
+
return result;
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Parse insights data into PostAnalytics
|
|
1310
|
+
*/
|
|
1311
|
+
parseInsights(insights) {
|
|
1312
|
+
const analytics = {};
|
|
1313
|
+
for (const insight of insights) {
|
|
1314
|
+
const value = insight.values?.[0]?.value;
|
|
1315
|
+
switch (insight.name) {
|
|
1316
|
+
case "views":
|
|
1317
|
+
analytics.views = typeof value === "number" ? value : void 0;
|
|
1318
|
+
analytics.impressions = analytics.views;
|
|
1319
|
+
break;
|
|
1320
|
+
case "likes":
|
|
1321
|
+
analytics.likes = typeof value === "number" ? value : void 0;
|
|
1322
|
+
break;
|
|
1323
|
+
case "replies":
|
|
1324
|
+
analytics.comments = typeof value === "number" ? value : void 0;
|
|
1325
|
+
break;
|
|
1326
|
+
case "reposts":
|
|
1327
|
+
case "quotes":
|
|
1328
|
+
if (typeof value === "number") {
|
|
1329
|
+
analytics.shares = (analytics.shares ?? 0) + value;
|
|
1330
|
+
}
|
|
1331
|
+
break;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
analytics.lastUpdated = /* @__PURE__ */ new Date();
|
|
1335
|
+
analytics.raw = insights;
|
|
1336
|
+
return analytics;
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Map Threads media type to our type
|
|
1340
|
+
*/
|
|
1341
|
+
mapMediaType(mediaType) {
|
|
1342
|
+
switch (mediaType) {
|
|
1343
|
+
case "VIDEO":
|
|
1344
|
+
return "video";
|
|
1345
|
+
case "IMAGE":
|
|
1346
|
+
case "CAROUSEL_ALBUM":
|
|
1347
|
+
return "image";
|
|
1348
|
+
default:
|
|
1349
|
+
return "text";
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Handle API errors
|
|
1354
|
+
*/
|
|
1355
|
+
async handleError(response) {
|
|
1356
|
+
const text = await response.text();
|
|
1357
|
+
let error;
|
|
1358
|
+
try {
|
|
1359
|
+
error = JSON.parse(text);
|
|
1360
|
+
} catch {
|
|
1361
|
+
error = { error: { message: text } };
|
|
1362
|
+
}
|
|
1363
|
+
if (response.status === 401 || error.error?.code === 190) {
|
|
1364
|
+
throw new SocialAuthError(
|
|
1365
|
+
"threads",
|
|
1366
|
+
error.error?.message ?? "Unauthorized"
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
if (response.status === 429 || error.error?.code === 4) {
|
|
1370
|
+
throw new SocialRateLimitError("threads");
|
|
1371
|
+
}
|
|
1372
|
+
throw new SocialError(
|
|
1373
|
+
error.error?.message ?? "API request failed",
|
|
1374
|
+
error.error?.code?.toString() ?? "API_ERROR",
|
|
1375
|
+
"threads",
|
|
1376
|
+
response.status
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
const X_API_URL = "https://api.twitter.com/2";
|
|
1381
|
+
const X_UPLOAD_URL = "https://upload.twitter.com/1.1";
|
|
1382
|
+
const X_API_MEDIA_UPLOAD_URL = "https://api.x.com/2/media/upload";
|
|
1383
|
+
const X_API_MEDIA_METADATA_URL = "https://api.x.com/2/media/metadata";
|
|
1384
|
+
const X_OAUTH_TOKEN_URL = "https://api.x.com/2/oauth2/token";
|
|
1385
|
+
class XAdapter {
|
|
1386
|
+
platform = "x";
|
|
1387
|
+
config;
|
|
1388
|
+
logger;
|
|
1389
|
+
constructor(config) {
|
|
1390
|
+
this.config = config;
|
|
1391
|
+
this.logger = createLogger({ level: "info" });
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* Generate OAuth 1.0a signature for request
|
|
1395
|
+
*/
|
|
1396
|
+
generateOAuthSignature(method, url, params) {
|
|
1397
|
+
const { apiKey, apiSecret, accessSecret } = this.requireOAuth1Config();
|
|
1398
|
+
const oauthParams = {
|
|
1399
|
+
oauth_consumer_key: apiKey,
|
|
1400
|
+
oauth_nonce: this.generateNonce(),
|
|
1401
|
+
oauth_signature_method: "HMAC-SHA1",
|
|
1402
|
+
oauth_timestamp: Math.floor(Date.now() / 1e3).toString(),
|
|
1403
|
+
oauth_token: this.config.accessToken,
|
|
1404
|
+
oauth_version: "1.0",
|
|
1405
|
+
...params
|
|
1406
|
+
};
|
|
1407
|
+
const sortedParams = Object.keys(oauthParams).sort().map(
|
|
1408
|
+
(k) => `${this.percentEncode(k)}=${this.percentEncode(oauthParams[k])}`
|
|
1409
|
+
).join("&");
|
|
1410
|
+
const signatureBase = [
|
|
1411
|
+
method.toUpperCase(),
|
|
1412
|
+
this.percentEncode(url),
|
|
1413
|
+
this.percentEncode(sortedParams)
|
|
1414
|
+
].join("&");
|
|
1415
|
+
const signingKey = `${this.percentEncode(apiSecret)}&${this.percentEncode(accessSecret)}`;
|
|
1416
|
+
const signature = this.hmacSha1(signatureBase, signingKey);
|
|
1417
|
+
const authParams = {
|
|
1418
|
+
...oauthParams,
|
|
1419
|
+
oauth_signature: signature
|
|
1420
|
+
};
|
|
1421
|
+
return "OAuth " + Object.keys(authParams).filter((k) => k.startsWith("oauth_")).sort().map(
|
|
1422
|
+
(k) => `${this.percentEncode(k)}="${this.percentEncode(authParams[k])}"`
|
|
1423
|
+
).join(", ");
|
|
1424
|
+
}
|
|
1425
|
+
generateNonce() {
|
|
1426
|
+
return randomUUID().replace(/-/g, "");
|
|
1427
|
+
}
|
|
1428
|
+
percentEncode(str) {
|
|
1429
|
+
return encodeURIComponent(str).replace(
|
|
1430
|
+
/[!'()*]/g,
|
|
1431
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
|
|
1432
|
+
);
|
|
1433
|
+
}
|
|
1434
|
+
hmacSha1(data, key) {
|
|
1435
|
+
return createHmac("sha1", key).update(data).digest("base64");
|
|
1436
|
+
}
|
|
1437
|
+
async authenticate() {
|
|
1438
|
+
const response = await this.makeRequest(
|
|
1439
|
+
"GET",
|
|
1440
|
+
`${X_API_URL}/users/me?user.fields=username,name`
|
|
1441
|
+
);
|
|
1442
|
+
if (!response.ok) {
|
|
1443
|
+
throw new SocialAuthError("x", "Invalid credentials");
|
|
1444
|
+
}
|
|
1445
|
+
return {
|
|
1446
|
+
accessToken: this.config.accessToken,
|
|
1447
|
+
refreshToken: this.config.refreshToken
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
async refreshToken(refreshToken) {
|
|
1451
|
+
if (!this.usesOAuth2()) {
|
|
1452
|
+
throw new SocialError(
|
|
1453
|
+
"OAuth 1.0a does not support token refresh",
|
|
1454
|
+
"NOT_SUPPORTED",
|
|
1455
|
+
"x"
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
if (!this.config.clientId || !this.config.clientSecret) {
|
|
1459
|
+
throw new SocialAuthError("x", "Missing OAuth 2.0 client credentials");
|
|
1460
|
+
}
|
|
1461
|
+
const response = await fetch(X_OAUTH_TOKEN_URL, {
|
|
1462
|
+
method: "POST",
|
|
1463
|
+
headers: {
|
|
1464
|
+
Authorization: this.basicAuthHeader(
|
|
1465
|
+
this.config.clientId,
|
|
1466
|
+
this.config.clientSecret
|
|
1467
|
+
),
|
|
1468
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1469
|
+
},
|
|
1470
|
+
body: new URLSearchParams({
|
|
1471
|
+
grant_type: "refresh_token",
|
|
1472
|
+
refresh_token: refreshToken
|
|
1473
|
+
})
|
|
1474
|
+
});
|
|
1475
|
+
if (!response.ok) {
|
|
1476
|
+
await this.handleError(response);
|
|
1477
|
+
}
|
|
1478
|
+
const token = await response.json();
|
|
1479
|
+
return {
|
|
1480
|
+
accessToken: token.access_token,
|
|
1481
|
+
refreshToken: token.refresh_token,
|
|
1482
|
+
expiresAt: typeof token.expires_in === "number" ? new Date(Date.now() + token.expires_in * 1e3) : void 0,
|
|
1483
|
+
tokenType: token.token_type,
|
|
1484
|
+
scopes: typeof token.scope === "string" ? token.scope.split(" ") : void 0
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
basicAuthHeader(clientId, clientSecret) {
|
|
1488
|
+
return `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString(
|
|
1489
|
+
"base64"
|
|
1490
|
+
)}`;
|
|
1491
|
+
}
|
|
1492
|
+
async publishVideo(video) {
|
|
1493
|
+
const publishMode = resolvePublishMode(this.config);
|
|
1494
|
+
const linkBehavior = this.resolveLinkBehavior(video.linkBehavior);
|
|
1495
|
+
const text = this.buildPostText(
|
|
1496
|
+
video.description,
|
|
1497
|
+
video.tags,
|
|
1498
|
+
video.linkUrl,
|
|
1499
|
+
linkBehavior
|
|
1500
|
+
);
|
|
1501
|
+
const payload = { text, linkBehavior, tags: video.tags };
|
|
1502
|
+
if (publishMode === "dry_run") {
|
|
1503
|
+
return createSafetyResult({
|
|
1504
|
+
platform: this.platform,
|
|
1505
|
+
mode: publishMode,
|
|
1506
|
+
postType: "video",
|
|
1507
|
+
payload,
|
|
1508
|
+
note: "X dry run: media was not uploaded and no post was created."
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
const mediaId = await this.uploadMedia(video.file, "video", video.mimeType);
|
|
1512
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
1513
|
+
return createSafetyResult({
|
|
1514
|
+
platform: this.platform,
|
|
1515
|
+
mode: publishMode,
|
|
1516
|
+
postType: "video",
|
|
1517
|
+
payload: { ...payload, mediaId },
|
|
1518
|
+
remoteId: mediaId,
|
|
1519
|
+
staged: true,
|
|
1520
|
+
note: "X media uploaded but no post was created."
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
const response = await this.makeRequest("POST", `${X_API_URL}/tweets`, {
|
|
1524
|
+
text,
|
|
1525
|
+
media: { media_ids: [mediaId] }
|
|
1526
|
+
});
|
|
1527
|
+
if (!response.ok) {
|
|
1528
|
+
await this.handleError(response);
|
|
1529
|
+
}
|
|
1530
|
+
const data = await response.json();
|
|
1531
|
+
if (video.linkUrl && linkBehavior === "reply") {
|
|
1532
|
+
await this.postLinkReply(data.data.id, video.linkUrl);
|
|
1533
|
+
}
|
|
1534
|
+
return {
|
|
1535
|
+
id: data.data.id,
|
|
1536
|
+
url: `https://x.com/i/status/${data.data.id}`,
|
|
1537
|
+
status: "published",
|
|
1538
|
+
publishedAt: /* @__PURE__ */ new Date(),
|
|
1539
|
+
metadata: { publishMode }
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
async publishImage(image) {
|
|
1543
|
+
const publishMode = resolvePublishMode(this.config);
|
|
1544
|
+
const linkBehavior = this.resolveLinkBehavior(image.linkBehavior);
|
|
1545
|
+
const text = this.buildPostText(
|
|
1546
|
+
image.description,
|
|
1547
|
+
image.tags,
|
|
1548
|
+
image.linkUrl,
|
|
1549
|
+
linkBehavior
|
|
1550
|
+
);
|
|
1551
|
+
const payload = {
|
|
1552
|
+
text,
|
|
1553
|
+
linkBehavior,
|
|
1554
|
+
tags: image.tags,
|
|
1555
|
+
altText: image.altText
|
|
1556
|
+
};
|
|
1557
|
+
if (publishMode === "dry_run") {
|
|
1558
|
+
return createSafetyResult({
|
|
1559
|
+
platform: this.platform,
|
|
1560
|
+
mode: publishMode,
|
|
1561
|
+
postType: "image",
|
|
1562
|
+
payload,
|
|
1563
|
+
note: "X dry run: media was not uploaded and no post was created."
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
const mediaId = await this.uploadMedia(image.file, "image", image.mimeType);
|
|
1567
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
1568
|
+
if (image.altText) {
|
|
1569
|
+
await this.setMediaAltText(mediaId, image.altText);
|
|
1570
|
+
}
|
|
1571
|
+
return createSafetyResult({
|
|
1572
|
+
platform: this.platform,
|
|
1573
|
+
mode: publishMode,
|
|
1574
|
+
postType: "image",
|
|
1575
|
+
payload: { ...payload, mediaId },
|
|
1576
|
+
remoteId: mediaId,
|
|
1577
|
+
staged: true,
|
|
1578
|
+
note: "X media uploaded but no post was created."
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
const body = {
|
|
1582
|
+
text,
|
|
1583
|
+
media: { media_ids: [mediaId] }
|
|
1584
|
+
};
|
|
1585
|
+
if (image.altText) {
|
|
1586
|
+
await this.setMediaAltText(mediaId, image.altText);
|
|
1587
|
+
}
|
|
1588
|
+
const response = await this.makeRequest(
|
|
1589
|
+
"POST",
|
|
1590
|
+
`${X_API_URL}/tweets`,
|
|
1591
|
+
body
|
|
1592
|
+
);
|
|
1593
|
+
if (!response.ok) {
|
|
1594
|
+
await this.handleError(response);
|
|
1595
|
+
}
|
|
1596
|
+
const data = await response.json();
|
|
1597
|
+
if (image.linkUrl && linkBehavior === "reply") {
|
|
1598
|
+
await this.postLinkReply(data.data.id, image.linkUrl);
|
|
1599
|
+
}
|
|
1600
|
+
return {
|
|
1601
|
+
id: data.data.id,
|
|
1602
|
+
url: `https://x.com/i/status/${data.data.id}`,
|
|
1603
|
+
status: "published",
|
|
1604
|
+
publishedAt: /* @__PURE__ */ new Date(),
|
|
1605
|
+
metadata: { publishMode }
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
async publishText(text) {
|
|
1609
|
+
const publishMode = resolvePublishMode(this.config);
|
|
1610
|
+
const linkBehavior = this.resolveLinkBehavior(text.linkBehavior);
|
|
1611
|
+
const postText = this.buildPostText(
|
|
1612
|
+
text.text,
|
|
1613
|
+
text.tags,
|
|
1614
|
+
text.linkUrl,
|
|
1615
|
+
linkBehavior
|
|
1616
|
+
);
|
|
1617
|
+
const body = { text: postText };
|
|
1618
|
+
if (text.replyTo) {
|
|
1619
|
+
body.reply = { in_reply_to_tweet_id: text.replyTo };
|
|
1620
|
+
}
|
|
1621
|
+
if (!isPublicPublishMode(publishMode)) {
|
|
1622
|
+
return createSafetyResult({
|
|
1623
|
+
platform: this.platform,
|
|
1624
|
+
mode: publishMode,
|
|
1625
|
+
postType: "text",
|
|
1626
|
+
payload: body,
|
|
1627
|
+
note: publishMode === "dry_run" ? "X dry run: no post was created." : "X has no non-public text staging endpoint; no post was created."
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
const response = await this.makeRequest(
|
|
1631
|
+
"POST",
|
|
1632
|
+
`${X_API_URL}/tweets`,
|
|
1633
|
+
body
|
|
1634
|
+
);
|
|
1635
|
+
if (!response.ok) {
|
|
1636
|
+
await this.handleError(response);
|
|
1637
|
+
}
|
|
1638
|
+
const data = await response.json();
|
|
1639
|
+
if (text.linkUrl && linkBehavior === "reply" && !text.replyTo) {
|
|
1640
|
+
await this.postLinkReply(data.data.id, text.linkUrl);
|
|
1641
|
+
}
|
|
1642
|
+
return {
|
|
1643
|
+
id: data.data.id,
|
|
1644
|
+
url: `https://x.com/i/status/${data.data.id}`,
|
|
1645
|
+
status: "published",
|
|
1646
|
+
publishedAt: /* @__PURE__ */ new Date(),
|
|
1647
|
+
metadata: { publishMode }
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
async publishLink(link) {
|
|
1651
|
+
return this.publishText({
|
|
1652
|
+
text: link.text ?? link.title ?? link.description ?? link.url,
|
|
1653
|
+
tags: link.tags,
|
|
1654
|
+
linkUrl: link.url,
|
|
1655
|
+
scheduledAt: link.scheduledAt,
|
|
1656
|
+
linkBehavior: link.linkBehavior
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Upload media to Twitter
|
|
1661
|
+
*/
|
|
1662
|
+
async uploadMedia(file, type, mimeType) {
|
|
1663
|
+
if (this.usesOAuth2()) {
|
|
1664
|
+
return this.uploadMediaV2(file, type, mimeType);
|
|
1665
|
+
}
|
|
1666
|
+
return this.uploadMediaOAuth1(file, type, mimeType);
|
|
1667
|
+
}
|
|
1668
|
+
async readMediaData(file, type, mimeType) {
|
|
1669
|
+
return resolveMediaData(file, {
|
|
1670
|
+
explicitMimeType: mimeType,
|
|
1671
|
+
fallbackMimeType: type === "video" ? "video/mp4" : "image/png"
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Upload media using X API v2 and OAuth 2.0 user context.
|
|
1676
|
+
*/
|
|
1677
|
+
async uploadMediaV2(file, type, mimeType) {
|
|
1678
|
+
const mediaData = await this.readMediaData(file, type, mimeType);
|
|
1679
|
+
const mediaType = mediaData.mimeType;
|
|
1680
|
+
const mediaCategory = type === "video" ? "tweet_video" : "tweet_image";
|
|
1681
|
+
const initResponse = await this.makeBearerUploadRequest("POST", {
|
|
1682
|
+
command: "INIT",
|
|
1683
|
+
total_bytes: String(mediaData.data.length),
|
|
1684
|
+
media_type: mediaType,
|
|
1685
|
+
media_category: mediaCategory
|
|
1686
|
+
});
|
|
1687
|
+
if (!initResponse.ok) {
|
|
1688
|
+
throw new SocialError("Media upload init failed", "UPLOAD_FAILED", "x");
|
|
1689
|
+
}
|
|
1690
|
+
const initData = await initResponse.json();
|
|
1691
|
+
const mediaId = initData.data?.id;
|
|
1692
|
+
if (!mediaId) {
|
|
1693
|
+
throw new SocialError(
|
|
1694
|
+
"Media upload init did not return a media id",
|
|
1695
|
+
"UPLOAD_FAILED",
|
|
1696
|
+
"x"
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
const chunkSize = 5 * 1024 * 1024;
|
|
1700
|
+
let segmentIndex = 0;
|
|
1701
|
+
for (let offset = 0; offset < mediaData.data.length; offset += chunkSize) {
|
|
1702
|
+
const chunk = mediaData.data.subarray(offset, offset + chunkSize);
|
|
1703
|
+
const formData = new FormData();
|
|
1704
|
+
formData.append("command", "APPEND");
|
|
1705
|
+
formData.append("media_id", mediaId);
|
|
1706
|
+
formData.append("segment_index", segmentIndex.toString());
|
|
1707
|
+
formData.append(
|
|
1708
|
+
"media",
|
|
1709
|
+
new Blob([new Uint8Array(chunk)], { type: mediaType })
|
|
1710
|
+
);
|
|
1711
|
+
const appendResponse = await this.makeBearerUploadRequest(
|
|
1712
|
+
"POST",
|
|
1713
|
+
formData
|
|
1714
|
+
);
|
|
1715
|
+
if (!appendResponse.ok) {
|
|
1716
|
+
throw new SocialError(
|
|
1717
|
+
"Media upload append failed",
|
|
1718
|
+
"UPLOAD_FAILED",
|
|
1719
|
+
"x"
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
segmentIndex++;
|
|
1723
|
+
}
|
|
1724
|
+
const finalizeResponse = await this.makeBearerUploadRequest("POST", {
|
|
1725
|
+
command: "FINALIZE",
|
|
1726
|
+
media_id: mediaId
|
|
1727
|
+
});
|
|
1728
|
+
if (!finalizeResponse.ok) {
|
|
1729
|
+
throw new SocialError(
|
|
1730
|
+
"Media upload finalize failed",
|
|
1731
|
+
"UPLOAD_FAILED",
|
|
1732
|
+
"x"
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
const finalizeData = await finalizeResponse.json();
|
|
1736
|
+
if (type === "video" && finalizeData.data?.processing_info) {
|
|
1737
|
+
await this.waitForProcessing(mediaId);
|
|
1738
|
+
}
|
|
1739
|
+
return mediaId;
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* Upload media using legacy OAuth 1.0a upload endpoints.
|
|
1743
|
+
*/
|
|
1744
|
+
async uploadMediaOAuth1(file, type, mimeType) {
|
|
1745
|
+
const mediaData = await this.readMediaData(file, type, mimeType);
|
|
1746
|
+
const mediaType = mediaData.mimeType;
|
|
1747
|
+
const mediaCategory = type === "video" ? "tweet_video" : "tweet_image";
|
|
1748
|
+
const initResponse = await this.makeUploadRequest(
|
|
1749
|
+
"POST",
|
|
1750
|
+
`${X_UPLOAD_URL}/media/upload.json`,
|
|
1751
|
+
{
|
|
1752
|
+
command: "INIT",
|
|
1753
|
+
total_bytes: mediaData.data.length,
|
|
1754
|
+
media_type: mediaType,
|
|
1755
|
+
media_category: mediaCategory
|
|
1756
|
+
}
|
|
1757
|
+
);
|
|
1758
|
+
if (!initResponse.ok) {
|
|
1759
|
+
throw new SocialError("Media upload init failed", "UPLOAD_FAILED", "x");
|
|
1760
|
+
}
|
|
1761
|
+
const initData = await initResponse.json();
|
|
1762
|
+
const mediaId = initData.media_id_string;
|
|
1763
|
+
const chunkSize = 5 * 1024 * 1024;
|
|
1764
|
+
let segmentIndex = 0;
|
|
1765
|
+
for (let offset = 0; offset < mediaData.data.length; offset += chunkSize) {
|
|
1766
|
+
const chunk = mediaData.data.subarray(offset, offset + chunkSize);
|
|
1767
|
+
const formData = new FormData();
|
|
1768
|
+
formData.append("command", "APPEND");
|
|
1769
|
+
formData.append("media_id", mediaId);
|
|
1770
|
+
formData.append("segment_index", segmentIndex.toString());
|
|
1771
|
+
formData.append(
|
|
1772
|
+
"media",
|
|
1773
|
+
new Blob([new Uint8Array(chunk)], { type: mediaType })
|
|
1774
|
+
);
|
|
1775
|
+
const appendResponse = await this.makeUploadRequest(
|
|
1776
|
+
"POST",
|
|
1777
|
+
`${X_UPLOAD_URL}/media/upload.json`,
|
|
1778
|
+
formData,
|
|
1779
|
+
true
|
|
1780
|
+
);
|
|
1781
|
+
if (!appendResponse.ok) {
|
|
1782
|
+
throw new SocialError(
|
|
1783
|
+
"Media upload append failed",
|
|
1784
|
+
"UPLOAD_FAILED",
|
|
1785
|
+
"x"
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
segmentIndex++;
|
|
1789
|
+
}
|
|
1790
|
+
const finalizeResponse = await this.makeUploadRequest(
|
|
1791
|
+
"POST",
|
|
1792
|
+
`${X_UPLOAD_URL}/media/upload.json`,
|
|
1793
|
+
{
|
|
1794
|
+
command: "FINALIZE",
|
|
1795
|
+
media_id: mediaId
|
|
1796
|
+
}
|
|
1797
|
+
);
|
|
1798
|
+
if (!finalizeResponse.ok) {
|
|
1799
|
+
throw new SocialError(
|
|
1800
|
+
"Media upload finalize failed",
|
|
1801
|
+
"UPLOAD_FAILED",
|
|
1802
|
+
"x"
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
const finalizeData = await finalizeResponse.json();
|
|
1806
|
+
if (type === "video" && finalizeData.processing_info) {
|
|
1807
|
+
await this.waitForProcessing(mediaId);
|
|
1808
|
+
}
|
|
1809
|
+
return mediaId;
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Wait for media processing to complete
|
|
1813
|
+
*/
|
|
1814
|
+
async waitForProcessing(mediaId) {
|
|
1815
|
+
let checkCount = 0;
|
|
1816
|
+
const maxChecks = 60;
|
|
1817
|
+
while (checkCount < maxChecks) {
|
|
1818
|
+
const statusResponse = this.usesOAuth2() ? await this.makeBearerUploadRequest("GET", {
|
|
1819
|
+
command: "STATUS",
|
|
1820
|
+
media_id: mediaId
|
|
1821
|
+
}) : await this.makeUploadRequest(
|
|
1822
|
+
"GET",
|
|
1823
|
+
`${X_UPLOAD_URL}/media/upload.json`,
|
|
1824
|
+
{
|
|
1825
|
+
command: "STATUS",
|
|
1826
|
+
media_id: mediaId
|
|
1827
|
+
}
|
|
1828
|
+
);
|
|
1829
|
+
const statusData = await statusResponse.json();
|
|
1830
|
+
const processingInfo = statusData.processing_info ?? statusData.data?.processing_info;
|
|
1831
|
+
if (!processingInfo || processingInfo.state === "succeeded") {
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (processingInfo.state === "failed") {
|
|
1835
|
+
throw new SocialError(
|
|
1836
|
+
processingInfo.error?.message ?? "Media processing failed",
|
|
1837
|
+
"PROCESSING_FAILED",
|
|
1838
|
+
"x"
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1841
|
+
const waitTime = (processingInfo.check_after_secs ?? 5) * 1e3;
|
|
1842
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
1843
|
+
checkCount++;
|
|
1844
|
+
}
|
|
1845
|
+
throw new SocialError(
|
|
1846
|
+
"Media processing timeout",
|
|
1847
|
+
"PROCESSING_TIMEOUT",
|
|
1848
|
+
"x"
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Set alt text for uploaded media
|
|
1853
|
+
* Uses JSON body as required by metadata/create endpoint
|
|
1854
|
+
*/
|
|
1855
|
+
async setMediaAltText(mediaId, altText) {
|
|
1856
|
+
if (this.usesOAuth2()) {
|
|
1857
|
+
const response2 = await this.makeRequest(
|
|
1858
|
+
"POST",
|
|
1859
|
+
X_API_MEDIA_METADATA_URL,
|
|
1860
|
+
{
|
|
1861
|
+
id: mediaId,
|
|
1862
|
+
metadata: {
|
|
1863
|
+
alt_text: { text: altText }
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
);
|
|
1867
|
+
if (!response2.ok) {
|
|
1868
|
+
await this.handleError(response2);
|
|
1869
|
+
}
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
const url = `${X_UPLOAD_URL}/media/metadata/create.json`;
|
|
1873
|
+
const authHeader = this.generateOAuthSignature("POST", url, {});
|
|
1874
|
+
const response = await fetch(url, {
|
|
1875
|
+
method: "POST",
|
|
1876
|
+
headers: {
|
|
1877
|
+
Authorization: authHeader,
|
|
1878
|
+
"Content-Type": "application/json"
|
|
1879
|
+
},
|
|
1880
|
+
body: JSON.stringify({
|
|
1881
|
+
media_id: mediaId,
|
|
1882
|
+
alt_text: { text: altText }
|
|
1883
|
+
})
|
|
1884
|
+
});
|
|
1885
|
+
if (!response.ok) {
|
|
1886
|
+
await this.handleError(response);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Post link as reply (algorithm-friendly pattern)
|
|
1891
|
+
*/
|
|
1892
|
+
async postLinkReply(parentId, linkUrl) {
|
|
1893
|
+
await this.makeRequest("POST", `${X_API_URL}/tweets`, {
|
|
1894
|
+
text: linkUrl,
|
|
1895
|
+
reply: { in_reply_to_tweet_id: parentId }
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
async getPost(postId) {
|
|
1899
|
+
const response = await this.makeRequest(
|
|
1900
|
+
"GET",
|
|
1901
|
+
`${X_API_URL}/tweets/${postId}?tweet.fields=created_at,public_metrics,attachments&expansions=attachments.media_keys&media.fields=type`
|
|
1902
|
+
);
|
|
1903
|
+
if (!response.ok) {
|
|
1904
|
+
await this.handleError(response);
|
|
1905
|
+
}
|
|
1906
|
+
const data = await response.json();
|
|
1907
|
+
const tweet = data.data;
|
|
1908
|
+
return {
|
|
1909
|
+
id: tweet.id,
|
|
1910
|
+
url: `https://x.com/i/status/${tweet.id}`,
|
|
1911
|
+
type: this.resolvePostType(tweet, data.includes?.media),
|
|
1912
|
+
description: tweet.text,
|
|
1913
|
+
publishedAt: new Date(tweet.created_at),
|
|
1914
|
+
visibility: "public",
|
|
1915
|
+
analytics: {
|
|
1916
|
+
likes: tweet.public_metrics?.like_count,
|
|
1917
|
+
shares: tweet.public_metrics?.retweet_count,
|
|
1918
|
+
comments: tweet.public_metrics?.reply_count,
|
|
1919
|
+
views: tweet.public_metrics?.impression_count,
|
|
1920
|
+
impressions: tweet.public_metrics?.impression_count,
|
|
1921
|
+
lastUpdated: /* @__PURE__ */ new Date(),
|
|
1922
|
+
raw: tweet.public_metrics
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
async deletePost(postId) {
|
|
1927
|
+
const response = await this.makeRequest(
|
|
1928
|
+
"DELETE",
|
|
1929
|
+
`${X_API_URL}/tweets/${postId}`
|
|
1930
|
+
);
|
|
1931
|
+
if (!response.ok && response.status !== 200) {
|
|
1932
|
+
await this.handleError(response);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
async getAnalytics(postId) {
|
|
1936
|
+
const post = await this.getPost(postId);
|
|
1937
|
+
return post.analytics ?? {};
|
|
1938
|
+
}
|
|
1939
|
+
getCapabilities() {
|
|
1940
|
+
return {
|
|
1941
|
+
video: true,
|
|
1942
|
+
image: true,
|
|
1943
|
+
text: true,
|
|
1944
|
+
link: true,
|
|
1945
|
+
linkAttachment: false,
|
|
1946
|
+
scheduling: false,
|
|
1947
|
+
// Would need Twitter Ads API
|
|
1948
|
+
analytics: true,
|
|
1949
|
+
rawAnalytics: true,
|
|
1950
|
+
publishModes: ["dry_run", "stage_remote", "public"],
|
|
1951
|
+
staging: true,
|
|
1952
|
+
privatePublishing: false,
|
|
1953
|
+
maxVideoLength: 140,
|
|
1954
|
+
// 2 minutes 20 seconds
|
|
1955
|
+
maxVideoSize: 512 * 1024 * 1024,
|
|
1956
|
+
// 512MB
|
|
1957
|
+
supportedVideoFormats: ["mp4", "mov"],
|
|
1958
|
+
aspectRatios: ["16:9", "1:1", "9:16"],
|
|
1959
|
+
maxTextLength: 280,
|
|
1960
|
+
maxHashtags: void 0,
|
|
1961
|
+
// No hard limit
|
|
1962
|
+
supportedPostTypes: ["text", "image", "video", "link"]
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
resolveLinkBehavior(override) {
|
|
1966
|
+
return override ?? this.config.linkBehavior ?? "inline";
|
|
1967
|
+
}
|
|
1968
|
+
usesOAuth2() {
|
|
1969
|
+
if (this.config.authType) {
|
|
1970
|
+
return this.config.authType === "oauth2";
|
|
1971
|
+
}
|
|
1972
|
+
return !this.config.accessSecret;
|
|
1973
|
+
}
|
|
1974
|
+
requireOAuth1Config() {
|
|
1975
|
+
if (!this.config.apiKey || !this.config.apiSecret || !this.config.accessSecret) {
|
|
1976
|
+
throw new SocialAuthError("x", "Missing OAuth 1.0a credentials");
|
|
1977
|
+
}
|
|
1978
|
+
return {
|
|
1979
|
+
apiKey: this.config.apiKey,
|
|
1980
|
+
apiSecret: this.config.apiSecret,
|
|
1981
|
+
accessSecret: this.config.accessSecret
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Build post text with hashtags
|
|
1986
|
+
*/
|
|
1987
|
+
buildPostText(text, tags, linkUrl, linkBehavior = "inline") {
|
|
1988
|
+
let result = text ?? "";
|
|
1989
|
+
const suffixParts = [];
|
|
1990
|
+
if (linkUrl && linkBehavior === "inline") {
|
|
1991
|
+
result = result.replace(linkUrl, "").trim();
|
|
1992
|
+
suffixParts.push(linkUrl);
|
|
1993
|
+
}
|
|
1994
|
+
if (tags && tags.length > 0) {
|
|
1995
|
+
const hashtags = tags.map((t) => t.startsWith("#") ? t : `#${t}`);
|
|
1996
|
+
suffixParts.push(hashtags.join(" "));
|
|
1997
|
+
}
|
|
1998
|
+
const suffix = suffixParts.join("\n\n");
|
|
1999
|
+
if (!suffix) {
|
|
2000
|
+
return this.truncatePostText(result, 280);
|
|
2001
|
+
}
|
|
2002
|
+
const separator = result.length > 0 ? "\n\n" : "";
|
|
2003
|
+
const suffixBudget = suffix.length + separator.length;
|
|
2004
|
+
if (suffixBudget >= 280) {
|
|
2005
|
+
return this.truncatePostText(suffix, 280);
|
|
2006
|
+
}
|
|
2007
|
+
const textBudget = 280 - suffixBudget;
|
|
2008
|
+
const truncatedText = this.truncatePostText(result, textBudget);
|
|
2009
|
+
return truncatedText ? `${truncatedText}${separator}${suffix}` : suffix;
|
|
2010
|
+
}
|
|
2011
|
+
truncatePostText(text, maxLength) {
|
|
2012
|
+
if (text.length <= maxLength) {
|
|
2013
|
+
return text;
|
|
2014
|
+
}
|
|
2015
|
+
if (maxLength <= 3) {
|
|
2016
|
+
return text.slice(0, maxLength);
|
|
2017
|
+
}
|
|
2018
|
+
return `${text.slice(0, maxLength - 3)}...`;
|
|
2019
|
+
}
|
|
2020
|
+
resolvePostType(tweet, media) {
|
|
2021
|
+
const mediaKey = tweet.attachments?.media_keys?.[0];
|
|
2022
|
+
if (!mediaKey) {
|
|
2023
|
+
return "text";
|
|
2024
|
+
}
|
|
2025
|
+
const mediaType = media?.find((item) => item.media_key === mediaKey)?.type ?? this.inferMediaTypeFromKey(mediaKey);
|
|
2026
|
+
if (mediaType === "video" || mediaType === "animated_gif") {
|
|
2027
|
+
return "video";
|
|
2028
|
+
}
|
|
2029
|
+
return "image";
|
|
2030
|
+
}
|
|
2031
|
+
inferMediaTypeFromKey(mediaKey) {
|
|
2032
|
+
if (mediaKey.startsWith("13_")) return "video";
|
|
2033
|
+
if (mediaKey.startsWith("7_")) return "animated_gif";
|
|
2034
|
+
if (mediaKey.startsWith("3_")) return "photo";
|
|
2035
|
+
return void 0;
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Make authenticated request to Twitter API v2
|
|
2039
|
+
*/
|
|
2040
|
+
async makeRequest(method, url, body) {
|
|
2041
|
+
const parsedUrl = new URL(url);
|
|
2042
|
+
const signatureParams = Object.fromEntries(parsedUrl.searchParams);
|
|
2043
|
+
const signingUrl = `${parsedUrl.origin}${parsedUrl.pathname}`;
|
|
2044
|
+
const authHeader = this.usesOAuth2() ? `Bearer ${this.config.accessToken}` : this.generateOAuthSignature(method, signingUrl, signatureParams);
|
|
2045
|
+
const options = {
|
|
2046
|
+
method,
|
|
2047
|
+
headers: {
|
|
2048
|
+
Authorization: authHeader,
|
|
2049
|
+
"Content-Type": "application/json"
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
if (body) {
|
|
2053
|
+
options.body = JSON.stringify(body);
|
|
2054
|
+
}
|
|
2055
|
+
return fetch(url, options);
|
|
2056
|
+
}
|
|
2057
|
+
async makeBearerUploadRequest(method, params) {
|
|
2058
|
+
let url = X_API_MEDIA_UPLOAD_URL;
|
|
2059
|
+
const options = {
|
|
2060
|
+
method,
|
|
2061
|
+
headers: {
|
|
2062
|
+
Authorization: `Bearer ${this.config.accessToken}`
|
|
2063
|
+
}
|
|
2064
|
+
};
|
|
2065
|
+
if (method === "GET" && !(params instanceof FormData)) {
|
|
2066
|
+
url = `${url}?${new URLSearchParams(params).toString()}`;
|
|
2067
|
+
} else if (params instanceof FormData) {
|
|
2068
|
+
options.body = params;
|
|
2069
|
+
} else {
|
|
2070
|
+
const formData = new FormData();
|
|
2071
|
+
for (const [key, value] of Object.entries(params)) {
|
|
2072
|
+
formData.append(key, value);
|
|
2073
|
+
}
|
|
2074
|
+
options.body = formData;
|
|
2075
|
+
}
|
|
2076
|
+
return fetch(url, options);
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Make authenticated request to Twitter Upload API
|
|
2080
|
+
*/
|
|
2081
|
+
async makeUploadRequest(method, url, params, isFormData = false) {
|
|
2082
|
+
const stringParams = params instanceof FormData ? {} : Object.fromEntries(
|
|
2083
|
+
Object.entries(params).map(([k, v]) => [k, String(v)])
|
|
2084
|
+
);
|
|
2085
|
+
const queryParams = method === "GET" || !isFormData && !(params instanceof FormData) ? stringParams : {};
|
|
2086
|
+
const authHeader = this.generateOAuthSignature(method, url, queryParams);
|
|
2087
|
+
const options = {
|
|
2088
|
+
method,
|
|
2089
|
+
headers: {
|
|
2090
|
+
Authorization: authHeader
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
if (method === "GET") {
|
|
2094
|
+
const queryString = new URLSearchParams(stringParams).toString();
|
|
2095
|
+
url = `${url}?${queryString}`;
|
|
2096
|
+
} else if (isFormData && params instanceof FormData) {
|
|
2097
|
+
options.body = params;
|
|
2098
|
+
} else if (!(params instanceof FormData)) {
|
|
2099
|
+
options.headers = {
|
|
2100
|
+
...options.headers,
|
|
2101
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
2102
|
+
};
|
|
2103
|
+
options.body = new URLSearchParams(stringParams).toString();
|
|
2104
|
+
}
|
|
2105
|
+
return fetch(url, options);
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Handle API errors
|
|
2109
|
+
*/
|
|
2110
|
+
async handleError(response) {
|
|
2111
|
+
const text = await response.text();
|
|
2112
|
+
let error;
|
|
2113
|
+
try {
|
|
2114
|
+
error = JSON.parse(text);
|
|
2115
|
+
} catch {
|
|
2116
|
+
error = { detail: text };
|
|
2117
|
+
}
|
|
2118
|
+
if (response.status === 401) {
|
|
2119
|
+
throw new SocialAuthError("x", error.detail ?? "Unauthorized");
|
|
2120
|
+
}
|
|
2121
|
+
if (response.status === 429) {
|
|
2122
|
+
const resetTime = response.headers.get("x-rate-limit-reset");
|
|
2123
|
+
const retryAfter = resetTime ? parseInt(resetTime, 10) - Math.floor(Date.now() / 1e3) : void 0;
|
|
2124
|
+
throw new SocialRateLimitError("x", retryAfter);
|
|
2125
|
+
}
|
|
2126
|
+
throw new SocialError(
|
|
2127
|
+
error.detail ?? error.title ?? "API request failed",
|
|
2128
|
+
error.type ?? "API_ERROR",
|
|
2129
|
+
"x",
|
|
2130
|
+
response.status
|
|
2131
|
+
);
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
const YOUTUBE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
2135
|
+
const YOUTUBE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
2136
|
+
const YOUTUBE_API_URL = "https://www.googleapis.com/youtube/v3";
|
|
2137
|
+
const YOUTUBE_UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3";
|
|
2138
|
+
const YOUTUBE_CATEGORIES = {
|
|
2139
|
+
NEWS_POLITICS: "25"
|
|
2140
|
+
};
|
|
2141
|
+
const DEFAULT_CATEGORY_ID = YOUTUBE_CATEGORIES.NEWS_POLITICS;
|
|
2142
|
+
const DEFAULT_SCOPES = [
|
|
2143
|
+
"https://www.googleapis.com/auth/youtube.upload",
|
|
2144
|
+
"https://www.googleapis.com/auth/youtube.readonly"
|
|
2145
|
+
];
|
|
2146
|
+
class YouTubeAdapter {
|
|
2147
|
+
platform = "youtube";
|
|
2148
|
+
config;
|
|
2149
|
+
logger;
|
|
2150
|
+
currentAccessToken;
|
|
2151
|
+
constructor(config) {
|
|
2152
|
+
this.config = config;
|
|
2153
|
+
this.currentAccessToken = config.accessToken;
|
|
2154
|
+
this.logger = createLogger({ level: "info" });
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Generate OAuth authorization URL
|
|
2158
|
+
*/
|
|
2159
|
+
getAuthorizationUrl(options = {}) {
|
|
2160
|
+
const state = options.state ?? randomUUID();
|
|
2161
|
+
const scopes = options.scopes ?? DEFAULT_SCOPES;
|
|
2162
|
+
const codeVerifier = options.codeVerifier ?? this.generateCodeVerifier();
|
|
2163
|
+
const codeChallenge = this.generateCodeChallenge(codeVerifier);
|
|
2164
|
+
const params = new URLSearchParams({
|
|
2165
|
+
client_id: this.config.clientId,
|
|
2166
|
+
redirect_uri: options.redirectUri ?? this.config.redirectUri ?? "",
|
|
2167
|
+
response_type: "code",
|
|
2168
|
+
scope: scopes.join(" "),
|
|
2169
|
+
state,
|
|
2170
|
+
code_challenge: codeChallenge,
|
|
2171
|
+
code_challenge_method: "S256",
|
|
2172
|
+
access_type: "offline",
|
|
2173
|
+
prompt: "consent"
|
|
2174
|
+
});
|
|
2175
|
+
return {
|
|
2176
|
+
url: `${YOUTUBE_AUTH_URL}?${params}`,
|
|
2177
|
+
state,
|
|
2178
|
+
codeVerifier
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Exchange authorization code for tokens
|
|
2183
|
+
*/
|
|
2184
|
+
async exchangeCode(params) {
|
|
2185
|
+
const body = new URLSearchParams({
|
|
2186
|
+
client_id: this.config.clientId,
|
|
2187
|
+
client_secret: this.config.clientSecret,
|
|
2188
|
+
code: params.code,
|
|
2189
|
+
grant_type: "authorization_code",
|
|
2190
|
+
redirect_uri: params.redirectUri ?? this.config.redirectUri ?? ""
|
|
2191
|
+
});
|
|
2192
|
+
if (params.codeVerifier) {
|
|
2193
|
+
body.set("code_verifier", params.codeVerifier);
|
|
2194
|
+
}
|
|
2195
|
+
const response = await fetch(YOUTUBE_TOKEN_URL, {
|
|
2196
|
+
method: "POST",
|
|
2197
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
2198
|
+
body
|
|
2199
|
+
});
|
|
2200
|
+
if (!response.ok) {
|
|
2201
|
+
const error = await response.text();
|
|
2202
|
+
throw new SocialAuthError("youtube", `Token exchange failed: ${error}`);
|
|
2203
|
+
}
|
|
2204
|
+
const data = await response.json();
|
|
2205
|
+
this.currentAccessToken = data.access_token;
|
|
2206
|
+
return {
|
|
2207
|
+
accessToken: data.access_token,
|
|
2208
|
+
refreshToken: data.refresh_token,
|
|
2209
|
+
expiresAt: data.expires_in ? new Date(Date.now() + data.expires_in * 1e3) : void 0,
|
|
2210
|
+
tokenType: data.token_type,
|
|
2211
|
+
scopes: data.scope?.split(" ")
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
async authenticate() {
|
|
2215
|
+
if (!this.config.accessToken) {
|
|
2216
|
+
throw new SocialAuthError("youtube", "No access token configured");
|
|
2217
|
+
}
|
|
2218
|
+
const response = await fetch(
|
|
2219
|
+
`https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${this.config.accessToken}`
|
|
2220
|
+
);
|
|
2221
|
+
if (!response.ok) {
|
|
2222
|
+
if (this.config.refreshToken) {
|
|
2223
|
+
return this.refreshToken(this.config.refreshToken);
|
|
2224
|
+
}
|
|
2225
|
+
throw new SocialAuthError("youtube", "Invalid access token");
|
|
2226
|
+
}
|
|
2227
|
+
const data = await response.json();
|
|
2228
|
+
return {
|
|
2229
|
+
accessToken: this.config.accessToken,
|
|
2230
|
+
expiresAt: data.expires_in ? new Date(Date.now() + data.expires_in * 1e3) : void 0,
|
|
2231
|
+
scopes: data.scope?.split(" ")
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
async refreshToken(refreshToken) {
|
|
2235
|
+
const body = new URLSearchParams({
|
|
2236
|
+
client_id: this.config.clientId,
|
|
2237
|
+
client_secret: this.config.clientSecret,
|
|
2238
|
+
refresh_token: refreshToken,
|
|
2239
|
+
grant_type: "refresh_token"
|
|
2240
|
+
});
|
|
2241
|
+
const response = await fetch(YOUTUBE_TOKEN_URL, {
|
|
2242
|
+
method: "POST",
|
|
2243
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
2244
|
+
body
|
|
2245
|
+
});
|
|
2246
|
+
if (!response.ok) {
|
|
2247
|
+
const error = await response.text();
|
|
2248
|
+
throw new SocialAuthError("youtube", `Token refresh failed: ${error}`);
|
|
2249
|
+
}
|
|
2250
|
+
const data = await response.json();
|
|
2251
|
+
this.currentAccessToken = data.access_token;
|
|
2252
|
+
return {
|
|
2253
|
+
accessToken: data.access_token,
|
|
2254
|
+
refreshToken: data.refresh_token ?? refreshToken,
|
|
2255
|
+
expiresAt: data.expires_in ? new Date(Date.now() + data.expires_in * 1e3) : void 0,
|
|
2256
|
+
tokenType: data.token_type
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
async publishVideo(video) {
|
|
2260
|
+
const publishMode = resolvePublishMode(this.config);
|
|
2261
|
+
const safeVisibility = isPublicPublishMode(publishMode) ? video.visibility ?? "public" : "private";
|
|
2262
|
+
if (publishMode === "dry_run") {
|
|
2263
|
+
return createSafetyResult({
|
|
2264
|
+
platform: this.platform,
|
|
2265
|
+
mode: publishMode,
|
|
2266
|
+
postType: "video",
|
|
2267
|
+
payload: {
|
|
2268
|
+
title: video.title ?? "Untitled",
|
|
2269
|
+
description: this.buildDescription(video),
|
|
2270
|
+
tags: video.tags,
|
|
2271
|
+
categoryId: video.categoryId ?? DEFAULT_CATEGORY_ID,
|
|
2272
|
+
privacyStatus: safeVisibility,
|
|
2273
|
+
isShort: video.isShort ?? false,
|
|
2274
|
+
scheduledAt: video.scheduledAt?.toISOString()
|
|
2275
|
+
},
|
|
2276
|
+
note: "YouTube dry run: no video was uploaded."
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
const accessToken = this.currentAccessToken ?? this.config.accessToken;
|
|
2280
|
+
if (!accessToken) {
|
|
2281
|
+
throw new SocialAuthError("youtube", "No access token");
|
|
2282
|
+
}
|
|
2283
|
+
const metadata = {
|
|
2284
|
+
snippet: {
|
|
2285
|
+
title: video.title ?? "Untitled",
|
|
2286
|
+
description: this.buildDescription(video),
|
|
2287
|
+
tags: video.tags,
|
|
2288
|
+
categoryId: video.categoryId ?? DEFAULT_CATEGORY_ID
|
|
2289
|
+
},
|
|
2290
|
+
status: {
|
|
2291
|
+
privacyStatus: safeVisibility,
|
|
2292
|
+
selfDeclaredMadeForKids: false,
|
|
2293
|
+
...video.scheduledAt && {
|
|
2294
|
+
publishAt: video.scheduledAt.toISOString(),
|
|
2295
|
+
privacyStatus: "private"
|
|
2296
|
+
// Must be private for scheduling
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
};
|
|
2300
|
+
const videoData = await resolveMediaData(video.file, {
|
|
2301
|
+
explicitMimeType: video.mimeType,
|
|
2302
|
+
fallbackMimeType: "video/mp4"
|
|
2303
|
+
});
|
|
2304
|
+
const initResponse = await fetch(
|
|
2305
|
+
`${YOUTUBE_UPLOAD_URL}/videos?uploadType=resumable&part=snippet,status`,
|
|
2306
|
+
{
|
|
2307
|
+
method: "POST",
|
|
2308
|
+
headers: {
|
|
2309
|
+
Authorization: `Bearer ${accessToken}`,
|
|
2310
|
+
"Content-Type": "application/json",
|
|
2311
|
+
"X-Upload-Content-Type": videoData.mimeType,
|
|
2312
|
+
"X-Upload-Content-Length": videoData.data.length.toString()
|
|
2313
|
+
},
|
|
2314
|
+
body: JSON.stringify(metadata)
|
|
2315
|
+
}
|
|
2316
|
+
);
|
|
2317
|
+
if (!initResponse.ok) {
|
|
2318
|
+
await this.handleError(initResponse);
|
|
2319
|
+
}
|
|
2320
|
+
const uploadUrl = initResponse.headers.get("Location");
|
|
2321
|
+
if (!uploadUrl) {
|
|
2322
|
+
throw new SocialError(
|
|
2323
|
+
"Failed to get upload URL",
|
|
2324
|
+
"UPLOAD_INIT_FAILED",
|
|
2325
|
+
"youtube"
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
2329
|
+
method: "PUT",
|
|
2330
|
+
headers: {
|
|
2331
|
+
"Content-Type": videoData.mimeType,
|
|
2332
|
+
"Content-Length": videoData.data.length.toString()
|
|
2333
|
+
},
|
|
2334
|
+
body: new Uint8Array(videoData.data)
|
|
2335
|
+
});
|
|
2336
|
+
if (!uploadResponse.ok) {
|
|
2337
|
+
await this.handleError(uploadResponse);
|
|
2338
|
+
}
|
|
2339
|
+
const result = await uploadResponse.json();
|
|
2340
|
+
if (video.thumbnail) {
|
|
2341
|
+
await this.uploadThumbnail(
|
|
2342
|
+
result.id,
|
|
2343
|
+
video.thumbnail,
|
|
2344
|
+
accessToken,
|
|
2345
|
+
video.thumbnailMimeType
|
|
2346
|
+
);
|
|
2347
|
+
}
|
|
2348
|
+
const status = video.scheduledAt ? "scheduled" : isPublicPublishMode(publishMode) ? "processing" : "staged";
|
|
2349
|
+
return {
|
|
2350
|
+
id: result.id,
|
|
2351
|
+
url: `https://youtube.com/watch?v=${result.id}`,
|
|
2352
|
+
status,
|
|
2353
|
+
publishedAt: status === "processing" ? /* @__PURE__ */ new Date() : void 0,
|
|
2354
|
+
scheduledAt: video.scheduledAt,
|
|
2355
|
+
metadata: {
|
|
2356
|
+
channelId: result.snippet?.channelId,
|
|
2357
|
+
channelTitle: result.snippet?.channelTitle,
|
|
2358
|
+
publishMode,
|
|
2359
|
+
privacyStatus: metadata.status.privacyStatus,
|
|
2360
|
+
safety: !isPublicPublishMode(publishMode)
|
|
2361
|
+
}
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Upload custom thumbnail
|
|
2366
|
+
* @returns true if upload succeeded, false otherwise
|
|
2367
|
+
*/
|
|
2368
|
+
async uploadThumbnail(videoId, thumbnail, accessToken, thumbnailMimeType) {
|
|
2369
|
+
try {
|
|
2370
|
+
const thumbnailData = await resolveMediaData(thumbnail, {
|
|
2371
|
+
explicitMimeType: thumbnailMimeType,
|
|
2372
|
+
fallbackMimeType: "image/png"
|
|
2373
|
+
});
|
|
2374
|
+
const response = await fetch(
|
|
2375
|
+
`${YOUTUBE_UPLOAD_URL}/thumbnails/set?videoId=${videoId}`,
|
|
2376
|
+
{
|
|
2377
|
+
method: "POST",
|
|
2378
|
+
headers: {
|
|
2379
|
+
Authorization: `Bearer ${accessToken}`,
|
|
2380
|
+
"Content-Type": thumbnailData.mimeType
|
|
2381
|
+
},
|
|
2382
|
+
body: new Uint8Array(thumbnailData.data)
|
|
2383
|
+
}
|
|
2384
|
+
);
|
|
2385
|
+
if (!response.ok) {
|
|
2386
|
+
const errorText = await response.text();
|
|
2387
|
+
this.logger.warn("Failed to upload thumbnail", {
|
|
2388
|
+
videoId,
|
|
2389
|
+
status: response.status,
|
|
2390
|
+
error: errorText
|
|
2391
|
+
});
|
|
2392
|
+
return false;
|
|
2393
|
+
}
|
|
2394
|
+
return true;
|
|
2395
|
+
} catch (error) {
|
|
2396
|
+
this.logger.warn("Thumbnail upload error", {
|
|
2397
|
+
videoId,
|
|
2398
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2399
|
+
});
|
|
2400
|
+
return false;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
async publishImage(_image) {
|
|
2404
|
+
throw new SocialError(
|
|
2405
|
+
"YouTube does not support image-only posts",
|
|
2406
|
+
"NOT_SUPPORTED",
|
|
2407
|
+
"youtube"
|
|
2408
|
+
);
|
|
2409
|
+
}
|
|
2410
|
+
async publishText(_text) {
|
|
2411
|
+
throw new SocialError(
|
|
2412
|
+
"YouTube does not support text-only posts",
|
|
2413
|
+
"NOT_SUPPORTED",
|
|
2414
|
+
"youtube"
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
async publishLink(_link) {
|
|
2418
|
+
throw new SocialError(
|
|
2419
|
+
"YouTube does not support link-only posts",
|
|
2420
|
+
"NOT_SUPPORTED",
|
|
2421
|
+
"youtube"
|
|
2422
|
+
);
|
|
2423
|
+
}
|
|
2424
|
+
async getPost(postId) {
|
|
2425
|
+
const accessToken = this.currentAccessToken ?? this.config.accessToken;
|
|
2426
|
+
if (!accessToken) {
|
|
2427
|
+
throw new SocialAuthError("youtube", "No access token");
|
|
2428
|
+
}
|
|
2429
|
+
const response = await fetch(
|
|
2430
|
+
`${YOUTUBE_API_URL}/videos?id=${postId}&part=snippet,status,statistics`,
|
|
2431
|
+
{
|
|
2432
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2433
|
+
}
|
|
2434
|
+
);
|
|
2435
|
+
if (!response.ok) {
|
|
2436
|
+
await this.handleError(response);
|
|
2437
|
+
}
|
|
2438
|
+
const data = await response.json();
|
|
2439
|
+
const video = data.items?.[0];
|
|
2440
|
+
if (!video) {
|
|
2441
|
+
throw new SocialError("Video not found", "NOT_FOUND", "youtube", 404);
|
|
2442
|
+
}
|
|
2443
|
+
const statistics = video.statistics ?? {};
|
|
2444
|
+
return {
|
|
2445
|
+
id: video.id,
|
|
2446
|
+
url: `https://youtube.com/watch?v=${video.id}`,
|
|
2447
|
+
type: "video",
|
|
2448
|
+
title: video.snippet.title,
|
|
2449
|
+
description: video.snippet.description,
|
|
2450
|
+
publishedAt: new Date(video.snippet.publishedAt),
|
|
2451
|
+
visibility: video.status.privacyStatus,
|
|
2452
|
+
analytics: {
|
|
2453
|
+
views: this.parseMetric(statistics.viewCount),
|
|
2454
|
+
likes: this.parseMetric(statistics.likeCount),
|
|
2455
|
+
comments: this.parseMetric(statistics.commentCount),
|
|
2456
|
+
lastUpdated: /* @__PURE__ */ new Date(),
|
|
2457
|
+
raw: statistics
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
async deletePost(postId) {
|
|
2462
|
+
const accessToken = this.currentAccessToken ?? this.config.accessToken;
|
|
2463
|
+
if (!accessToken) {
|
|
2464
|
+
throw new SocialAuthError("youtube", "No access token");
|
|
2465
|
+
}
|
|
2466
|
+
const response = await fetch(`${YOUTUBE_API_URL}/videos?id=${postId}`, {
|
|
2467
|
+
method: "DELETE",
|
|
2468
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
2469
|
+
});
|
|
2470
|
+
if (!response.ok && response.status !== 204) {
|
|
2471
|
+
await this.handleError(response);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
async getAnalytics(postId) {
|
|
2475
|
+
const post = await this.getPost(postId);
|
|
2476
|
+
return post.analytics ?? {};
|
|
2477
|
+
}
|
|
2478
|
+
getCapabilities() {
|
|
2479
|
+
return {
|
|
2480
|
+
video: true,
|
|
2481
|
+
image: false,
|
|
2482
|
+
text: false,
|
|
2483
|
+
link: false,
|
|
2484
|
+
scheduling: true,
|
|
2485
|
+
analytics: true,
|
|
2486
|
+
rawAnalytics: true,
|
|
2487
|
+
publishModes: ["dry_run", "private_or_scheduled", "public"],
|
|
2488
|
+
staging: false,
|
|
2489
|
+
privatePublishing: true,
|
|
2490
|
+
maxVideoLength: 60 * 60 * 12,
|
|
2491
|
+
// 12 hours (with verification)
|
|
2492
|
+
maxVideoSize: 256 * 1024 * 1024 * 1024,
|
|
2493
|
+
// 256GB
|
|
2494
|
+
supportedVideoFormats: ["mp4", "mov", "avi", "wmv", "flv", "webm"],
|
|
2495
|
+
aspectRatios: ["16:9", "9:16", "1:1", "4:3"],
|
|
2496
|
+
maxTextLength: 5e3,
|
|
2497
|
+
// Description
|
|
2498
|
+
maxHashtags: 15,
|
|
2499
|
+
supportedPostTypes: ["video"]
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
/**
|
|
2503
|
+
* Build description with link and hashtags
|
|
2504
|
+
*/
|
|
2505
|
+
buildDescription(video) {
|
|
2506
|
+
let description = video.description ?? "";
|
|
2507
|
+
if (video.linkUrl) {
|
|
2508
|
+
description += `
|
|
2509
|
+
|
|
2510
|
+
${video.linkUrl}`;
|
|
2511
|
+
}
|
|
2512
|
+
const tags = video.isShort ? Array.from(/* @__PURE__ */ new Set([...video.tags ?? [], "Shorts"])) : video.tags;
|
|
2513
|
+
if (tags && tags.length > 0) {
|
|
2514
|
+
const hashtags = tags.map((t) => t.startsWith("#") ? t : `#${t}`);
|
|
2515
|
+
description += `
|
|
2516
|
+
|
|
2517
|
+
${hashtags.join(" ")}`;
|
|
2518
|
+
}
|
|
2519
|
+
return description;
|
|
2520
|
+
}
|
|
2521
|
+
parseMetric(value) {
|
|
2522
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
2523
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
2524
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* Handle API errors
|
|
2528
|
+
*/
|
|
2529
|
+
async handleError(response) {
|
|
2530
|
+
const text = await response.text();
|
|
2531
|
+
let error;
|
|
2532
|
+
try {
|
|
2533
|
+
error = JSON.parse(text);
|
|
2534
|
+
} catch {
|
|
2535
|
+
error = { error: { message: text } };
|
|
2536
|
+
}
|
|
2537
|
+
if (response.status === 401) {
|
|
2538
|
+
throw new SocialAuthError(
|
|
2539
|
+
"youtube",
|
|
2540
|
+
error.error?.message ?? "Unauthorized"
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
if (response.status === 429) {
|
|
2544
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
2545
|
+
throw new SocialRateLimitError(
|
|
2546
|
+
"youtube",
|
|
2547
|
+
retryAfter ? parseInt(retryAfter, 10) : void 0
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
throw new SocialError(
|
|
2551
|
+
error.error?.message ?? "API request failed",
|
|
2552
|
+
error.error?.code?.toString() ?? "API_ERROR",
|
|
2553
|
+
"youtube",
|
|
2554
|
+
response.status
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
/**
|
|
2558
|
+
* Generate PKCE code verifier
|
|
2559
|
+
*/
|
|
2560
|
+
generateCodeVerifier() {
|
|
2561
|
+
const array = new Uint8Array(32);
|
|
2562
|
+
crypto.getRandomValues(array);
|
|
2563
|
+
return btoa(String.fromCharCode(...array)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
2564
|
+
}
|
|
2565
|
+
/**
|
|
2566
|
+
* Generate PKCE code challenge from verifier using S256
|
|
2567
|
+
*/
|
|
2568
|
+
generateCodeChallenge(verifier) {
|
|
2569
|
+
const hash = createHash("sha256").update(verifier).digest();
|
|
2570
|
+
return Buffer.from(hash).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
async function getSocial(config) {
|
|
2574
|
+
let adapter;
|
|
2575
|
+
switch (config.type) {
|
|
2576
|
+
case "youtube":
|
|
2577
|
+
adapter = new YouTubeAdapter(config);
|
|
2578
|
+
break;
|
|
2579
|
+
case "threads":
|
|
2580
|
+
adapter = new ThreadsAdapter(config);
|
|
2581
|
+
break;
|
|
2582
|
+
case "facebook":
|
|
2583
|
+
adapter = new FacebookPageAdapter(config);
|
|
2584
|
+
break;
|
|
2585
|
+
case "x":
|
|
2586
|
+
adapter = new XAdapter(config);
|
|
2587
|
+
break;
|
|
2588
|
+
case "bluesky":
|
|
2589
|
+
adapter = new BlueskyAdapter(config);
|
|
2590
|
+
break;
|
|
2591
|
+
default:
|
|
2592
|
+
throw new SocialError(
|
|
2593
|
+
`Unknown platform type: ${config.type}`,
|
|
2594
|
+
"UNKNOWN_PLATFORM"
|
|
2595
|
+
);
|
|
2596
|
+
}
|
|
2597
|
+
return adapter;
|
|
2598
|
+
}
|
|
2599
|
+
async function getSocialMulti(configs) {
|
|
2600
|
+
return Promise.all(configs.map(getSocial));
|
|
2601
|
+
}
|
|
2602
|
+
async function publishToAll(adapters, content) {
|
|
2603
|
+
const results = /* @__PURE__ */ new Map();
|
|
2604
|
+
await Promise.all(
|
|
2605
|
+
adapters.map(async (adapter) => {
|
|
2606
|
+
try {
|
|
2607
|
+
let result;
|
|
2608
|
+
switch (content.type) {
|
|
2609
|
+
case "text":
|
|
2610
|
+
result = await adapter.publishText({
|
|
2611
|
+
text: content.text ?? content.description ?? "",
|
|
2612
|
+
linkUrl: content.linkUrl,
|
|
2613
|
+
tags: content.tags
|
|
2614
|
+
});
|
|
2615
|
+
break;
|
|
2616
|
+
case "link": {
|
|
2617
|
+
const url = content.url ?? content.linkUrl;
|
|
2618
|
+
if (!url) {
|
|
2619
|
+
throw new SocialError(
|
|
2620
|
+
"Link URL required",
|
|
2621
|
+
"MISSING_LINK_URL",
|
|
2622
|
+
adapter.platform
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
result = await adapter.publishLink({
|
|
2626
|
+
url,
|
|
2627
|
+
text: content.text,
|
|
2628
|
+
title: content.title,
|
|
2629
|
+
description: content.description,
|
|
2630
|
+
tags: content.tags
|
|
2631
|
+
});
|
|
2632
|
+
break;
|
|
2633
|
+
}
|
|
2634
|
+
case "image":
|
|
2635
|
+
if (!content.file) {
|
|
2636
|
+
throw new SocialError(
|
|
2637
|
+
"Image file required",
|
|
2638
|
+
"MISSING_FILE",
|
|
2639
|
+
adapter.platform
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2642
|
+
result = await adapter.publishImage({
|
|
2643
|
+
file: content.file,
|
|
2644
|
+
description: content.description ?? content.text,
|
|
2645
|
+
linkUrl: content.linkUrl,
|
|
2646
|
+
tags: content.tags,
|
|
2647
|
+
altText: content.altText,
|
|
2648
|
+
mimeType: content.mimeType
|
|
2649
|
+
});
|
|
2650
|
+
break;
|
|
2651
|
+
case "video":
|
|
2652
|
+
if (!content.file) {
|
|
2653
|
+
throw new SocialError(
|
|
2654
|
+
"Video file required",
|
|
2655
|
+
"MISSING_FILE",
|
|
2656
|
+
adapter.platform
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
result = await adapter.publishVideo({
|
|
2660
|
+
file: content.file,
|
|
2661
|
+
title: content.title,
|
|
2662
|
+
description: content.description ?? content.text,
|
|
2663
|
+
linkUrl: content.linkUrl,
|
|
2664
|
+
tags: content.tags,
|
|
2665
|
+
mimeType: content.mimeType,
|
|
2666
|
+
thumbnail: content.thumbnail,
|
|
2667
|
+
thumbnailMimeType: content.thumbnailMimeType
|
|
2668
|
+
});
|
|
2669
|
+
break;
|
|
2670
|
+
}
|
|
2671
|
+
results.set(adapter.platform, { success: true, result });
|
|
2672
|
+
} catch (error) {
|
|
2673
|
+
results.set(adapter.platform, {
|
|
2674
|
+
success: false,
|
|
2675
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
})
|
|
2679
|
+
);
|
|
2680
|
+
return results;
|
|
2681
|
+
}
|
|
2682
|
+
export {
|
|
2683
|
+
BlueskyAdapter,
|
|
2684
|
+
FacebookPageAdapter,
|
|
2685
|
+
SocialAuthError,
|
|
2686
|
+
SocialError,
|
|
2687
|
+
SocialRateLimitError,
|
|
2688
|
+
ThreadsAdapter,
|
|
2689
|
+
XAdapter,
|
|
2690
|
+
YouTubeAdapter,
|
|
2691
|
+
getSocial,
|
|
2692
|
+
getSocialMulti,
|
|
2693
|
+
publishToAll
|
|
2694
|
+
};
|
|
2695
|
+
//# sourceMappingURL=index.js.map
|