@happyvertical/social 0.80.0 → 0.80.1

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