@happyvertical/social 0.74.8

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