@opencoredev/social-sdk 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/cli-request.d.ts +15 -0
  2. package/dist/cli-request.js +193 -0
  3. package/dist/cli.d.ts +4 -3
  4. package/dist/cli.js +24 -21
  5. package/dist/cloud/common.d.ts +13 -8
  6. package/dist/cloud/common.js +40 -60
  7. package/dist/cloud/lifecycle.js +31 -35
  8. package/dist/cloud/media.d.ts +6 -2
  9. package/dist/cloud/media.js +50 -37
  10. package/dist/cloud/outcomes.d.ts +4 -3
  11. package/dist/cloud/outcomes.js +8 -15
  12. package/dist/cloud/post-for-me.js +41 -49
  13. package/dist/cloud/postfast.d.ts +54 -0
  14. package/dist/cloud/postfast.js +578 -0
  15. package/dist/cloud/zernio.js +58 -98
  16. package/dist/core/client.js +79 -99
  17. package/dist/core/fields.d.ts +14 -0
  18. package/dist/core/fields.js +14 -0
  19. package/dist/core/idempotency.d.ts +7 -2
  20. package/dist/core/idempotency.js +37 -20
  21. package/dist/core/pagination.js +8 -7
  22. package/dist/core/types.d.ts +3 -2
  23. package/dist/platforms/bluesky.d.ts +65 -1
  24. package/dist/platforms/bluesky.js +675 -276
  25. package/dist/platforms/instagram.d.ts +2 -0
  26. package/dist/platforms/instagram.js +130 -105
  27. package/dist/platforms/linkedin.d.ts +58 -1
  28. package/dist/platforms/linkedin.js +877 -107
  29. package/dist/platforms/threads.d.ts +13 -1
  30. package/dist/platforms/threads.js +204 -302
  31. package/dist/platforms/tiktok.d.ts +4 -0
  32. package/dist/platforms/tiktok.js +140 -124
  33. package/dist/platforms/webhook-adapter.d.ts +9 -0
  34. package/dist/platforms/webhook-adapter.js +24 -0
  35. package/dist/platforms/x-engagement.js +7 -12
  36. package/dist/platforms/x-stream.d.ts +83 -0
  37. package/dist/platforms/x-stream.js +350 -0
  38. package/dist/platforms/x.d.ts +72 -0
  39. package/dist/platforms/x.js +328 -119
  40. package/dist/platforms/youtube-upload.d.ts +1 -1
  41. package/dist/platforms/youtube-upload.js +6 -2
  42. package/dist/platforms/youtube.d.ts +28 -4
  43. package/dist/platforms/youtube.js +291 -133
  44. package/dist/server/bluesky-oauth.d.ts +177 -0
  45. package/dist/server/bluesky-oauth.js +1229 -0
  46. package/dist/server/connections.d.ts +14 -0
  47. package/dist/server/connections.js +10 -2
  48. package/dist/server/egress.d.ts +14 -0
  49. package/dist/server/egress.js +115 -0
  50. package/dist/server/oauth-internal.d.ts +6 -0
  51. package/dist/server/oauth-internal.js +66 -0
  52. package/dist/server/oauth.d.ts +1 -1
  53. package/dist/server/oauth.js +46 -99
  54. package/dist/server/webhooks.d.ts +136 -3
  55. package/dist/server/webhooks.js +639 -25
  56. package/dist/testing/index.js +14 -28
  57. package/dist/transport/http.d.ts +1 -1
  58. package/dist/transport/http.js +0 -1
  59. package/dist/transport/json.d.ts +7 -0
  60. package/dist/transport/json.js +32 -4
  61. package/dist/transport/upload.d.ts +1 -1
  62. package/dist/transport/upload.js +46 -38
  63. package/dist/transport/validation.d.ts +16 -5
  64. package/dist/transport/validation.js +29 -7
  65. package/package.json +6 -2
@@ -0,0 +1,578 @@
1
+ import { MemoryManagedMediaStore, storedMedia } from "./media.js";
2
+ export { MemoryManagedMediaStore, } from "./media.js";
3
+ import { defineAdapter } from "../core/adapter.js";
4
+ import { SocialError } from "../core/errors.js";
5
+ import { definedFields } from "../core/fields.js";
6
+ import { remainingBudget } from "../transport/budget.js";
7
+ import { httpsUrl, upload } from "../transport/upload.js";
8
+ import { array, isBoolean, isString, object, optionalNumber, optionalString, string, } from "../transport/validation.js";
9
+ import { accountMatches, capabilityManifest, managedHttp, managedOptionIssues, managedPreparation, optionsObject, publishFormats, selectedPlatforms, } from "./common.js";
10
+ const nativePlatforms = new Map([
11
+ ["X", "x"],
12
+ ["THREADS", "threads"],
13
+ ["BLUESKY", "bluesky"],
14
+ ["YOUTUBE", "youtube"],
15
+ ["TIKTOK", "tiktok"],
16
+ ["INSTAGRAM", "instagram"],
17
+ ["LINKEDIN", "linkedin"],
18
+ ["FACEBOOK", "facebook"],
19
+ ]);
20
+ const mimeTypes = [
21
+ "image/jpeg",
22
+ "image/png",
23
+ "image/gif",
24
+ "image/webp",
25
+ "video/mp4",
26
+ "video/webm",
27
+ "video/mov",
28
+ "video/quicktime",
29
+ ];
30
+ // PostFast's Bluesky guide documents text and image posts only.
31
+ const postfastFormats = (platform) => platform === "bluesky" ? ["text", "image", "carousel"] : publishFormats(platform);
32
+ const maxBytes = { image: 10 * 1024 * 1024, video: 250 * 1024 * 1024 };
33
+ const tiktokPrivacy = new Map([
34
+ ["PUBLIC_TO_EVERYONE", "PUBLIC"],
35
+ ["MUTUAL_FOLLOW_FRIENDS", "MUTUAL_FRIENDS"],
36
+ ["FOLLOWER_OF_CREATOR", "FOLLOWER_OF_CREATOR"],
37
+ ["SELF_ONLY", "ONLY_ME"],
38
+ ]);
39
+ const reject = (operation, message) => {
40
+ throw new SocialError({ code: "invalid_input", operation, message });
41
+ };
42
+ /** PostFast returns counters as bigint strings. Keep only values a JS number holds exactly. */
43
+ function count(value) {
44
+ const parsed = isString(value) && /^\d+$/.test(value) ? Number(value) : optionalNumber(value);
45
+ return parsed !== undefined && Number.isSafeInteger(parsed) ? parsed : undefined;
46
+ }
47
+ /**
48
+ * PostFast managed backend. PostFast only schedules posts, so every target needs a
49
+ * future `schedule.at`. Media is uploaded to PostFast storage and referenced by key.
50
+ */
51
+ export function postfast(options) {
52
+ const request = managedHttp("https://api.postfa.st", options, (apiKey) => ["pf-api-key", apiKey]);
53
+ const store = options.mediaStore ?? new MemoryManagedMediaStore();
54
+ const clock = () => options.clock?.() ?? new Date();
55
+ const now = () => clock().toISOString();
56
+ const allowHost = options.uploadHostAllowed ??
57
+ ((host) => host === "s3.amazonaws.com" ||
58
+ (host.startsWith("postfast-uploads.s3.") && host.endsWith(".amazonaws.com")));
59
+ const account = (value, backend) => {
60
+ const row = object(value);
61
+ const slug = nativePlatforms.get(string(row["platform"]));
62
+ if (!slug)
63
+ return undefined;
64
+ const handle = optionalString(row["platformUsername"]);
65
+ return {
66
+ ref: {
67
+ kind: "connected-account",
68
+ version: 1,
69
+ backend,
70
+ platform: slug,
71
+ accountId: string(row["id"]),
72
+ },
73
+ displayName: optionalString(row["displayName"]) || handle || string(row["id"]),
74
+ ...definedFields({ handle: handle || undefined }),
75
+ status: row["connectionStatus"] === "CONNECTED"
76
+ ? "connected"
77
+ : row["connectionStatus"] === "DISABLED"
78
+ ? "reconnect-required"
79
+ : "unknown",
80
+ };
81
+ };
82
+ const accounts = async (context) => array(await request("/social-media/my-social-accounts", context)).flatMap((value) => {
83
+ const parsed = account(value, context.backendInstance);
84
+ return parsed ? [parsed] : [];
85
+ });
86
+ /** Read one post record and prove it belongs to the referenced account. */
87
+ const record = async (id, ref, context, operation) => {
88
+ accountMatches(ref, context);
89
+ if (!id)
90
+ reject(operation, "A PostFast post identifier is required.");
91
+ const result = object(await request("/social-posts", context, undefined, { ids: id, limit: "1" }));
92
+ const row = array(result["data"])
93
+ .map(object)
94
+ .find((item) => item["id"] === id);
95
+ if (!row)
96
+ throw new SocialError({
97
+ code: "not_found",
98
+ operation,
99
+ message: "PostFast has no post with this identifier in the workspace.",
100
+ });
101
+ if (row["socialMediaId"] !== ref.accountId)
102
+ throw new SocialError({
103
+ code: "unauthorized",
104
+ operation,
105
+ message: "The PostFast post does not belong to the authorized account.",
106
+ });
107
+ return row;
108
+ };
109
+ const outcome = (row, target, targetIndex) => {
110
+ const id = string(row["id"]);
111
+ const status = optionalString(row["status"]) ?? "unknown";
112
+ const delivery = {
113
+ kind: "delivery",
114
+ version: 1,
115
+ backend: target.backend,
116
+ platform: target.platform,
117
+ accountId: target.accountId,
118
+ deliveryId: id,
119
+ };
120
+ const base = {
121
+ targetIndex,
122
+ account: target,
123
+ delivery,
124
+ backendState: status,
125
+ observedAt: now(),
126
+ };
127
+ switch (status) {
128
+ case "SCHEDULED":
129
+ if (row["approvalStatus"] === "PENDING_APPROVAL")
130
+ return { ...base, backendState: "SCHEDULED/PENDING_APPROVAL", state: "accepted" };
131
+ return {
132
+ ...base,
133
+ state: "scheduled",
134
+ job: {
135
+ kind: "scheduled-job",
136
+ version: 1,
137
+ backend: target.backend,
138
+ platform: target.platform,
139
+ accountId: target.accountId,
140
+ jobId: id,
141
+ },
142
+ };
143
+ case "PUBLISHED": {
144
+ const postId = optionalString(row["platformPostId"]);
145
+ if (!postId)
146
+ return {
147
+ ...base,
148
+ state: "unknown",
149
+ reason: "unmapped-state",
150
+ diagnostic: "PostFast reports publication without a native post identifier.",
151
+ };
152
+ return {
153
+ ...base,
154
+ state: "published",
155
+ post: {
156
+ kind: "platform-post",
157
+ version: 1,
158
+ backend: target.backend,
159
+ platform: target.platform,
160
+ accountId: target.accountId,
161
+ postId,
162
+ native: { backendRecordId: id },
163
+ },
164
+ };
165
+ }
166
+ case "FAILED":
167
+ return {
168
+ ...base,
169
+ state: "failed",
170
+ code: "upstream_failure",
171
+ message: "PostFast reports that this post failed. Inspect the account connection and the post in PostFast.",
172
+ retryDisposition: { kind: "never" },
173
+ };
174
+ default:
175
+ return {
176
+ ...base,
177
+ state: "unknown",
178
+ reason: "unmapped-state",
179
+ diagnostic: "PostFast returned a post status this adapter does not map.",
180
+ };
181
+ }
182
+ };
183
+ /** Upload bytes to PostFast storage and return the storage key. */
184
+ const uploadBytes = async (item, context) => {
185
+ if (item.kind === "document")
186
+ throw new SocialError({
187
+ code: "unsupported_capability",
188
+ operation: "media.upload",
189
+ message: "PostFast accepts image and video media only through this adapter.",
190
+ });
191
+ const source = item.source;
192
+ if (source.kind === "https-url" || source.kind === "media-ref")
193
+ return reject("media.upload", "Only uploadable bytes can be sent to PostFast storage.");
194
+ const mimeType = string(item.mimeType);
195
+ if (!mimeTypes.some((value) => value === mimeType))
196
+ reject("media.upload", "PostFast does not accept this media type.");
197
+ const size = item.byteSize ?? (source.kind === "blob" ? source.blob.size : undefined);
198
+ if (size !== undefined && size > maxBytes[item.kind])
199
+ throw new SocialError({
200
+ code: "media_error",
201
+ operation: "media.upload",
202
+ message: "PostFast accepts images up to 10 MB and videos up to 250 MB.",
203
+ });
204
+ const signed = array(await request("/file/get-signed-upload-urls", context, { contentType: mimeType, count: 1 }));
205
+ if (signed.length !== 1)
206
+ throw new SocialError({
207
+ code: "upstream_failure",
208
+ operation: "media.upload",
209
+ message: "PostFast returned an unexpected number of upload URLs.",
210
+ });
211
+ const entry = object(signed[0]);
212
+ const key = string(entry["key"]);
213
+ await upload({
214
+ url: string(entry["signedUrl"]),
215
+ source: {
216
+ mimeType,
217
+ ...definedFields({ size, body: source.kind === "blob" ? source.blob : undefined }),
218
+ open: source.kind === "blob" ? () => source.blob.stream() : source.open,
219
+ },
220
+ allowHost,
221
+ maxBytes: maxBytes[item.kind],
222
+ timeoutMs: remainingBudget(context),
223
+ ...definedFields({ fetch: options.fetch, signal: context.signal }),
224
+ });
225
+ return key;
226
+ };
227
+ const mediaKey = async (item, target, context) => {
228
+ if (item.source.kind !== "media-ref")
229
+ return uploadBytes(item, context);
230
+ const stored = await storedMedia(item, item.source.ref, target, store, options);
231
+ if (!stored.providerKey)
232
+ throw new SocialError({
233
+ code: "media_error",
234
+ operation: "media.resolve",
235
+ message: "This media reference was not uploaded to PostFast storage.",
236
+ });
237
+ return stored.providerKey;
238
+ };
239
+ const adapter = defineAdapter({
240
+ id: "postfast",
241
+ capabilities: capabilityManifest("postfast", "REST API, docs fetched 2026-09-24", [
242
+ "accounts.read",
243
+ "posts.publish",
244
+ "posts.status",
245
+ "posts.cancelScheduled",
246
+ "posts.deleteBackendRecord",
247
+ "analytics.read",
248
+ "media.upload",
249
+ ], postfastFormats),
250
+ media: {
251
+ async upload(item, target, context) {
252
+ accountMatches(target, context);
253
+ if (item.kind === "document")
254
+ throw new SocialError({
255
+ code: "unsupported_capability",
256
+ operation: "media.upload",
257
+ message: "PostFast accepts image and video media only through this adapter.",
258
+ });
259
+ if (item.source.kind === "media-ref") {
260
+ await mediaKey(item, target, context);
261
+ return item.source.ref;
262
+ }
263
+ if (!item.mimeType)
264
+ reject("media.upload", "Provide the asset MIME type.");
265
+ const key = await uploadBytes(item, context);
266
+ const ref = {
267
+ kind: "media",
268
+ version: 1,
269
+ backend: target.backend,
270
+ platform: target.platform,
271
+ accountId: target.accountId,
272
+ mediaId: crypto.randomUUID(),
273
+ };
274
+ // PostFast references uploads by key. The URL without its signature is kept for
275
+ // inspection only and never grants access.
276
+ const stored = {
277
+ ref,
278
+ publicUrl: `https://s3.amazonaws.com/postfast-uploads/${key}`,
279
+ providerKey: key,
280
+ kind: item.kind,
281
+ mimeType: string(item.mimeType),
282
+ };
283
+ await store.put(stored);
284
+ return ref;
285
+ },
286
+ },
287
+ accounts: {
288
+ async list(input, context) {
289
+ const offset = input.cursor === undefined ? 0 : Number(input.cursor);
290
+ const limit = input.limit ?? 25;
291
+ if (!Number.isSafeInteger(offset) ||
292
+ offset < 0 ||
293
+ !Number.isSafeInteger(limit) ||
294
+ limit < 1 ||
295
+ limit > 100)
296
+ reject("accounts.read", "Use an opaque returned cursor and a page size from 1 to 100.");
297
+ // PostFast returns every account in one response; page locally.
298
+ const all = await accounts(context);
299
+ const next = offset + limit < all.length ? String(offset + limit) : undefined;
300
+ return {
301
+ items: all.slice(offset, offset + limit),
302
+ ...definedFields({ nextCursor: next }),
303
+ };
304
+ },
305
+ async get(ref, context) {
306
+ accountMatches(ref, context);
307
+ const found = (await accounts(context)).find((item) => item.ref.accountId === ref.accountId && item.ref.platform === ref.platform);
308
+ if (!found)
309
+ throw new SocialError({
310
+ code: "not_found",
311
+ operation: "accounts.read",
312
+ message: "PostFast has no connected account matching this reference.",
313
+ });
314
+ return found;
315
+ },
316
+ },
317
+ posts: {
318
+ prepareTarget(target) {
319
+ const issues = [
320
+ ...managedPreparation(target),
321
+ ...managedOptionIssues(target, "postfast"),
322
+ ];
323
+ const fail = (code, message, severity = "error") => issues.push({ code, message, severity, targetIndex: target.targetIndex });
324
+ const at = target.schedule?.at;
325
+ if (!at)
326
+ fail("schedule.required", "PostFast only schedules posts. Set a future schedule time for this target.");
327
+ else if (!(Date.parse(at) > clock().getTime()))
328
+ fail("schedule.past", "PostFast requires a schedule time in the future.");
329
+ const media = target.content.media ?? [];
330
+ for (const item of media) {
331
+ if (item.kind === "document") {
332
+ fail("media.document_unsupported", "PostFast accepts image and video media only.");
333
+ continue;
334
+ }
335
+ if (item.source.kind === "https-url")
336
+ fail("media.url_unsupported", "PostFast accepts uploaded files only. Pass the bytes or a PostFast media reference.");
337
+ if (item.mimeType && !mimeTypes.some((value) => value === item.mimeType))
338
+ fail("media.mime_unsupported", "PostFast does not accept this media type.");
339
+ const size = item.byteSize ?? (item.source.kind === "blob" ? item.source.blob.size : undefined);
340
+ if (size !== undefined && size > maxBytes[item.kind])
341
+ fail("media.too_large", "PostFast accepts images up to 10 MB and videos up to 250 MB.");
342
+ if (item.altText !== undefined)
343
+ fail("media.alt_text_unsupported", "PostFast's post schema does not document alt text. Choose a backend with an explicit accessibility mapping.");
344
+ }
345
+ if (target.account.platform === "bluesky" && media.some((item) => item.kind === "video"))
346
+ fail("bluesky.video_unsupported", "PostFast publishes text and images to Bluesky.");
347
+ const config = optionsObject(target);
348
+ if (target.account.platform === "x" &&
349
+ config["replySettings"] !== undefined &&
350
+ config["replySettings"] !== "everyone")
351
+ fail("x.reply_settings_unsupported", "PostFast does not document X reply settings.");
352
+ if (target.account.platform === "tiktok" &&
353
+ config["draft"] !== true &&
354
+ media.some((item) => item.kind === "video")) {
355
+ if (config["privacy"] !== "PUBLIC_TO_EVERYONE")
356
+ fail("tiktok.privacy_unsupported", "PostFast publishes TikTok videos with the account's default privacy. Save a TikTok draft to keep a video private.");
357
+ else
358
+ fail("tiktok.privacy_account_default", "PostFast publishes TikTok videos with the account's default privacy.", "warning");
359
+ }
360
+ return issues;
361
+ },
362
+ async publishTarget(target, context) {
363
+ accountMatches(target.account, context);
364
+ const at = target.schedule?.at;
365
+ if (!at || !(Date.parse(at) > clock().getTime()))
366
+ reject("posts.publish", "PostFast requires a future schedule time.");
367
+ const mediaItems = [];
368
+ for (const [index, item] of (target.content.media ?? []).entries())
369
+ mediaItems.push({
370
+ key: await mediaKey(item, target.account, context),
371
+ type: item.kind === "video" ? "VIDEO" : "IMAGE",
372
+ sortOrder: index,
373
+ });
374
+ const config = optionsObject(target);
375
+ const controls = {};
376
+ if (target.account.platform === "youtube") {
377
+ controls["youtubeTitle"] = string(config["title"]);
378
+ controls["youtubePrivacy"] = string(config["visibility"]).toUpperCase();
379
+ const madeForKids = config["madeForKids"];
380
+ if (isBoolean(madeForKids))
381
+ controls["youtubeMadeForKids"] = madeForKids;
382
+ }
383
+ if (target.account.platform === "instagram" && isBoolean(config["shareToFeed"]))
384
+ controls["instagramPostToGrid"] = config["shareToFeed"];
385
+ if (target.account.platform === "tiktok") {
386
+ const privacy = tiktokPrivacy.get(String(config["privacy"]));
387
+ if (privacy)
388
+ controls["tiktokPrivacy"] = privacy;
389
+ // prepareTarget requires every one of these choices to be a boolean.
390
+ for (const [nativeKey, optionKey, negate] of [
391
+ ["tiktokAllowComments", "disableComments", true],
392
+ ["tiktokAllowDuet", "disableDuet", true],
393
+ ["tiktokAllowStitch", "disableStitch", true],
394
+ ["tiktokBrandOrganic", "ownBrand", false],
395
+ ["tiktokBrandContent", "brandedContent", false],
396
+ ["tiktokIsAigc", "aiGenerated", false],
397
+ ["tiktokIsDraft", "draft", false],
398
+ ]) {
399
+ const value = config[optionKey];
400
+ if (isBoolean(value))
401
+ controls[nativeKey] = negate ? !value : value;
402
+ }
403
+ }
404
+ // Media uploads can outlast a near schedule, and PostFast rejects a past time.
405
+ if (!(Date.parse(string(at)) > clock().getTime()))
406
+ reject("posts.publish", "The schedule time passed while media uploaded.");
407
+ const response = object(await request("/social-posts", context, {
408
+ posts: [
409
+ {
410
+ content: target.content.text ?? "",
411
+ socialMediaId: target.account.accountId,
412
+ scheduledAt: new Date(string(at)).toISOString(),
413
+ ...definedFields({ mediaItems: mediaItems.length ? mediaItems : undefined }),
414
+ },
415
+ ],
416
+ status: "SCHEDULED",
417
+ ...definedFields({
418
+ controls: Object.keys(controls).length ? controls : undefined,
419
+ }),
420
+ }));
421
+ const ids = array(response["postIds"]);
422
+ const id = ids.length === 1 && isString(ids[0]) ? ids[0] : undefined;
423
+ if (!id)
424
+ return {
425
+ state: "unknown",
426
+ reason: "ambiguous-submission",
427
+ diagnostic: "PostFast did not return exactly one post identifier.",
428
+ targetIndex: target.targetIndex,
429
+ account: target.account,
430
+ observedAt: now(),
431
+ };
432
+ try {
433
+ return outcome(await record(id, target.account, context, "posts.publish"), target.account, target.targetIndex);
434
+ }
435
+ catch (error) {
436
+ // A post was written, so an ownership mismatch stays an uncertain write instead of an error.
437
+ if (error instanceof SocialError && error.code === "unauthorized")
438
+ return {
439
+ state: "unknown",
440
+ reason: "ambiguous-submission",
441
+ diagnostic: "PostFast returned a post that does not belong to the requested account.",
442
+ targetIndex: target.targetIndex,
443
+ account: target.account,
444
+ observedAt: now(),
445
+ };
446
+ // Keep the created post visible even if the follow-up read fails.
447
+ return {
448
+ state: "accepted",
449
+ targetIndex: target.targetIndex,
450
+ account: target.account,
451
+ delivery: {
452
+ kind: "delivery",
453
+ version: 1,
454
+ backend: target.account.backend,
455
+ platform: target.account.platform,
456
+ accountId: target.account.accountId,
457
+ deliveryId: id,
458
+ },
459
+ observedAt: now(),
460
+ };
461
+ }
462
+ },
463
+ async getDelivery(ref, context) {
464
+ const slug = selectedPlatforms.find((value) => value === ref.platform);
465
+ if (!slug)
466
+ return reject("posts.status", "This platform is outside PostFast coverage.");
467
+ const target = {
468
+ kind: "connected-account",
469
+ version: 1,
470
+ backend: ref.backend,
471
+ platform: slug,
472
+ accountId: ref.accountId,
473
+ };
474
+ return outcome(await record(ref.deliveryId, target, context, "posts.status"), target, 0);
475
+ },
476
+ async cancelScheduled(ref, context) {
477
+ const row = await record(ref.jobId, ref, context, "posts.cancelScheduled");
478
+ const scheduledAt = optionalString(row["scheduledAt"]);
479
+ if (row["status"] !== "SCHEDULED" ||
480
+ !scheduledAt ||
481
+ !(Date.parse(scheduledAt) > clock().getTime()))
482
+ reject("posts.cancelScheduled", "Only a future scheduled post can be cancelled. Reconcile a due or dispatched post.");
483
+ await remove(ref.jobId, context);
484
+ return { state: "cancelled", backendRecord: "deleted" };
485
+ },
486
+ async deleteBackendRecord(ref, context) {
487
+ const row = await record(ref.recordId, ref, context, "posts.deleteBackendRecord");
488
+ if (row["status"] !== "FAILED")
489
+ reject("posts.deleteBackendRecord", "PostFast deletes failed records here. Cancel a future schedule explicitly; this never removes a published post.");
490
+ await remove(ref.recordId, context);
491
+ },
492
+ },
493
+ analytics: {
494
+ async getPostMetrics(ref, context) {
495
+ const id = optionalString(ref.native?.["backendRecordId"]);
496
+ if (!id)
497
+ reject("analytics.read", "PostFast metrics need the post reference returned by publish or status reads.");
498
+ const row = await record(string(id), ref, context, "analytics.read");
499
+ const publishedAt = optionalString(row["publishedAt"]);
500
+ if (row["platformPostId"] !== ref.postId || !publishedAt || !Date.parse(publishedAt))
501
+ reject("analytics.read", "The PostFast record does not identify this published post.");
502
+ const published = Date.parse(string(publishedAt));
503
+ const day = 24 * 60 * 60 * 1000;
504
+ const result = object(await request("/social-posts/analytics", context, undefined, {
505
+ startDate: new Date(published - day).toISOString(),
506
+ endDate: new Date(published + day).toISOString(),
507
+ socialMediaIds: ref.accountId,
508
+ }));
509
+ const match = array(result["data"])
510
+ .map(object)
511
+ .find((item) => item["id"] === id &&
512
+ item["platformPostId"] === ref.postId &&
513
+ item["socialMediaId"] === ref.accountId);
514
+ if (!match || match["latestMetric"] === null || match["latestMetric"] === undefined)
515
+ return [];
516
+ const metrics = object(match["latestMetric"]);
517
+ const values = [];
518
+ const push = (name, value, unit) => {
519
+ if (value === undefined)
520
+ return;
521
+ values.push({
522
+ name,
523
+ value,
524
+ unit,
525
+ period: "lifetime",
526
+ fetchedAt: now(),
527
+ freshness: "unknown",
528
+ source: `postfast:${ref.platform}:analytics`,
529
+ });
530
+ };
531
+ for (const [name, key] of [
532
+ ["likes", "likes"],
533
+ ["comments", "comments"],
534
+ ["shares", "shares"],
535
+ ["impressions", "impressions"],
536
+ ["reach", "reach"],
537
+ ["interactions", "totalInteractions"],
538
+ ["views", "videoViews"],
539
+ ])
540
+ push(name, count(metrics[key]), "count");
541
+ push("averageWatchTime", optionalNumber(metrics["avgWatchTimeSeconds"]), "seconds");
542
+ push("totalWatchTime", optionalNumber(metrics["totalWatchTimeSeconds"]), "seconds");
543
+ push("saveRate", optionalNumber(metrics["saveRate"]), "percentage");
544
+ return values;
545
+ },
546
+ },
547
+ native: {
548
+ /** Create a hosted link where someone connects their social accounts to your workspace. */
549
+ async createConnectLink(input, context) {
550
+ for (const value of input.platforms ?? [])
551
+ if (!nativePlatforms.has(value))
552
+ reject("accounts.connect", "Choose PostFast platforms this adapter supports.");
553
+ if (input.redirectUrl !== undefined)
554
+ httpsUrl(input.redirectUrl);
555
+ const result = object(await request("/social-media/connect-link", context, {
556
+ ...definedFields({
557
+ platforms: input.platforms ? [...input.platforms] : undefined,
558
+ expiryDays: input.expiryDays,
559
+ redirectUrl: input.redirectUrl || undefined,
560
+ externalId: input.externalId || undefined,
561
+ }),
562
+ }));
563
+ return { url: httpsUrl(string(result["connectUrl"])).href };
564
+ },
565
+ },
566
+ });
567
+ async function remove(id, context) {
568
+ const result = object(await request(`/social-posts/${encodeURIComponent(id)}`, context, undefined, {}, "DELETE"));
569
+ if (result["deleted"] !== true)
570
+ throw new SocialError({
571
+ code: "ambiguous_outcome",
572
+ operation: "posts.lifecycle",
573
+ message: "PostFast did not confirm the deletion. Reconcile before retrying.",
574
+ retryDisposition: { kind: "reconcile-first" },
575
+ });
576
+ }
577
+ return adapter;
578
+ }