@opencoredev/social-sdk 0.4.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.
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +5 -0
- package/dist/cloud/common.d.ts +8 -4
- package/dist/cloud/common.js +8 -9
- package/dist/cloud/media.d.ts +4 -0
- package/dist/cloud/media.js +37 -34
- package/dist/cloud/postfast.d.ts +54 -0
- package/dist/cloud/postfast.js +578 -0
- package/package.json +5 -1
package/dist/cli.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import type { SocialAdapter } from "./core/index.js";
|
|
3
3
|
/** Adapters the diagnostic CLI can construct offline. */
|
|
4
|
-
export declare const adapterNames: readonly ["mock", "zernio", "post-for-me", "bluesky", "x", "threads", "youtube", "tiktok", "instagram", "linkedin"];
|
|
4
|
+
export declare const adapterNames: readonly ["mock", "zernio", "post-for-me", "postfast", "bluesky", "x", "threads", "youtube", "tiktok", "instagram", "linkedin"];
|
|
5
5
|
export type AdapterName = (typeof adapterNames)[number];
|
|
6
6
|
export declare function isAdapterName(value: string): value is AdapterName;
|
|
7
7
|
export declare function createDiagnosticAdapter(name: AdapterName, accountId?: string): SocialAdapter<unknown>;
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { decodePublishRequest, PublishRequestInputError } from "./cli-request.js
|
|
|
7
7
|
import { mockBackend } from "./testing/index.js";
|
|
8
8
|
import { zernio } from "./cloud/zernio.js";
|
|
9
9
|
import { postForMe } from "./cloud/post-for-me.js";
|
|
10
|
+
import { postfast } from "./cloud/postfast.js";
|
|
10
11
|
import { bluesky } from "./platforms/bluesky.js";
|
|
11
12
|
import { x } from "./platforms/x.js";
|
|
12
13
|
import { threads } from "./platforms/threads.js";
|
|
@@ -19,6 +20,7 @@ export const adapterNames = [
|
|
|
19
20
|
"mock",
|
|
20
21
|
"zernio",
|
|
21
22
|
"post-for-me",
|
|
23
|
+
"postfast",
|
|
22
24
|
"bluesky",
|
|
23
25
|
"x",
|
|
24
26
|
"threads",
|
|
@@ -37,6 +39,7 @@ const environmentNames = {
|
|
|
37
39
|
mock: [],
|
|
38
40
|
zernio: ["ZERNIO_API_KEY"],
|
|
39
41
|
"post-for-me": ["POST_FOR_ME_API_KEY"],
|
|
42
|
+
postfast: ["POSTFAST_API_KEY"],
|
|
40
43
|
bluesky: ["BLUESKY_SERVICE", "BLUESKY_DID", "BLUESKY_ACCESS_JWT"],
|
|
41
44
|
x: ["X_USER_ID", "X_ACCESS_TOKEN"],
|
|
42
45
|
threads: ["THREADS_USER_ID", "THREADS_ACCESS_TOKEN"],
|
|
@@ -56,6 +59,8 @@ export function createDiagnosticAdapter(name, accountId = "diagnostic-account")
|
|
|
56
59
|
return zernio({ apiKey: "offline-placeholder", fetch: noNetwork });
|
|
57
60
|
case "post-for-me":
|
|
58
61
|
return postForMe({ apiKey: "offline-placeholder", fetch: noNetwork });
|
|
62
|
+
case "postfast":
|
|
63
|
+
return postfast({ apiKey: "offline-placeholder", fetch: noNetwork });
|
|
59
64
|
case "bluesky":
|
|
60
65
|
return bluesky({
|
|
61
66
|
auth: { service: "https://bsky.social", did: accountId, accessJwt: "offline-placeholder" },
|
package/dist/cloud/common.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ManagedMediaStore } from "./media.js";
|
|
2
|
-
import type { AdapterOperationContext, CapabilityManifest, ConnectedAccountRef, JsonObject, JsonValue, MediaAttachment, Platform, PreparationIssue, PreparedPublishTarget } from "../core/types.js";
|
|
2
|
+
import type { AdapterOperationContext, CapabilityDeclaration, CapabilityManifest, ConnectedAccountRef, JsonObject, JsonValue, MediaAttachment, Platform, PreparationIssue, PreparedPublishTarget } from "../core/types.js";
|
|
3
3
|
import { type HttpOptions } from "../transport/http.js";
|
|
4
4
|
import { type JsonField } from "../transport/validation.js";
|
|
5
5
|
export interface ManagedOptions extends HttpOptions {
|
|
@@ -11,11 +11,15 @@ export interface ManagedOptions extends HttpOptions {
|
|
|
11
11
|
}
|
|
12
12
|
export declare const selectedPlatforms: readonly ["x", "threads", "bluesky", "youtube", "tiktok", "instagram", "linkedin", "facebook"];
|
|
13
13
|
export declare function platform(value: JsonField): Platform;
|
|
14
|
-
|
|
15
|
-
export
|
|
14
|
+
/** How a provider expects its server-side API key. Defaults to a bearer token. */
|
|
15
|
+
export type ManagedAuthHeader = (apiKey: string) => readonly [name: string, value: string];
|
|
16
|
+
export declare function managedHttp(origin: string, options: ManagedOptions, authHeader?: ManagedAuthHeader): (path: string, context: AdapterOperationContext, body?: JsonObject, query?: Record<string, string>, method?: "GET" | "POST" | "PUT" | "DELETE") => Promise<JsonValue>;
|
|
17
|
+
export type PublishFormats = (platform: (typeof selectedPlatforms)[number]) => CapabilityDeclaration["formats"];
|
|
18
|
+
export declare const publishFormats: PublishFormats;
|
|
19
|
+
export declare function capabilityManifest(backend: string, apiRevision: string, operations: readonly string[], formats?: PublishFormats): CapabilityManifest;
|
|
16
20
|
export declare function optionsObject(target: PreparedPublishTarget): JsonObject;
|
|
17
21
|
/** Every accepted normalized option has an intentional provider mapping. */
|
|
18
|
-
export declare function managedOptionIssues(target: PreparedPublishTarget, provider: "zernio" | "post-for-me"): PreparationIssue[];
|
|
22
|
+
export declare function managedOptionIssues(target: PreparedPublishTarget, provider: "zernio" | "post-for-me" | "postfast"): PreparationIssue[];
|
|
19
23
|
export declare function managedPreparation(target: PreparedPublishTarget): PreparationIssue[];
|
|
20
24
|
export declare function uploadManagedMedia(item: MediaAttachment, presign: (body: JsonObject) => Promise<JsonValue>, config: {
|
|
21
25
|
options: ManagedOptions;
|
package/dist/cloud/common.js
CHANGED
|
@@ -25,7 +25,8 @@ export function platform(value) {
|
|
|
25
25
|
});
|
|
26
26
|
return slug;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
const bearer = (apiKey) => ["Authorization", `Bearer ${apiKey}`];
|
|
29
|
+
export function managedHttp(origin, options, authHeader = bearer) {
|
|
29
30
|
if (!options.apiKey.trim())
|
|
30
31
|
throw new SocialError({
|
|
31
32
|
code: "invalid_config",
|
|
@@ -37,10 +38,8 @@ export function managedHttp(origin, options) {
|
|
|
37
38
|
const url = new URL(origin + path);
|
|
38
39
|
for (const [key, value] of Object.entries(query))
|
|
39
40
|
url.searchParams.set(key, value);
|
|
40
|
-
const headers = new Headers({
|
|
41
|
-
|
|
42
|
-
"Content-Type": "application/json",
|
|
43
|
-
});
|
|
41
|
+
const headers = new Headers({ "Content-Type": "application/json" });
|
|
42
|
+
headers.set(...authHeader(options.apiKey));
|
|
44
43
|
if (context.targetIdempotencyKey && origin.includes("zernio.com"))
|
|
45
44
|
headers.set(path === "/v1/posts" ? "x-request-id" : "Idempotency-Key", context.targetIdempotencyKey);
|
|
46
45
|
try {
|
|
@@ -100,14 +99,14 @@ export function managedHttp(origin, options) {
|
|
|
100
99
|
}
|
|
101
100
|
};
|
|
102
101
|
}
|
|
103
|
-
|
|
102
|
+
export const publishFormats = (platform) => {
|
|
104
103
|
if (platform === "youtube")
|
|
105
104
|
return ["video"];
|
|
106
105
|
if (platform === "instagram" || platform === "tiktok")
|
|
107
106
|
return ["image", "video", "carousel"];
|
|
108
107
|
return ["text", "image", "video", "carousel"];
|
|
109
|
-
}
|
|
110
|
-
export function capabilityManifest(backend, apiRevision, operations) {
|
|
108
|
+
};
|
|
109
|
+
export function capabilityManifest(backend, apiRevision, operations, formats = publishFormats) {
|
|
111
110
|
return {
|
|
112
111
|
schemaVersion: 1,
|
|
113
112
|
backend,
|
|
@@ -118,7 +117,7 @@ export function capabilityManifest(backend, apiRevision, operations) {
|
|
|
118
117
|
operation,
|
|
119
118
|
availability: "available",
|
|
120
119
|
...definedFields({
|
|
121
|
-
formats: operation === "posts.publish" ?
|
|
120
|
+
formats: operation === "posts.publish" ? formats(platform) : undefined,
|
|
122
121
|
}),
|
|
123
122
|
notes: "Contract implementation; live account verification and provider/platform eligibility are separate.",
|
|
124
123
|
}))),
|
package/dist/cloud/media.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { type ManagedOptions } from "./common.js";
|
|
|
4
4
|
export interface ManagedMediaRecord {
|
|
5
5
|
readonly ref: MediaRef;
|
|
6
6
|
readonly publicUrl: string;
|
|
7
|
+
/** Storage key for providers that reference uploads by key rather than URL, such as PostFast. */
|
|
8
|
+
readonly providerKey?: string;
|
|
7
9
|
readonly expiresAt?: string;
|
|
8
10
|
readonly kind: "image" | "video";
|
|
9
11
|
readonly mimeType: string;
|
|
@@ -17,6 +19,8 @@ export declare class MemoryManagedMediaStore implements ManagedMediaStore {
|
|
|
17
19
|
put(record: ManagedMediaRecord): Promise<void>;
|
|
18
20
|
get(mediaId: string): Promise<ManagedMediaRecord | undefined>;
|
|
19
21
|
}
|
|
22
|
+
/** Look up an uploaded asset and prove it belongs to this account and matches the attachment. */
|
|
23
|
+
export declare function storedMedia(item: MediaAttachment, ref: MediaRef, account: ConnectedAccountRef, store: ManagedMediaStore, options: Pick<ManagedOptions, "clock">): Promise<ManagedMediaRecord>;
|
|
20
24
|
export declare function managedMedia(provider: "zernio" | "post-for-me", options: ManagedOptions, presign: (body: JsonObject, context: AdapterOperationContext) => Promise<JsonValue>): {
|
|
21
25
|
resolve: (item: MediaAttachment, account: ConnectedAccountRef, context: AdapterOperationContext) => Promise<string>;
|
|
22
26
|
upload(item: MediaAttachment, account: ConnectedAccountRef, context: AdapterOperationContext): Promise<MediaRef>;
|
package/dist/cloud/media.js
CHANGED
|
@@ -10,6 +10,42 @@ export class MemoryManagedMediaStore {
|
|
|
10
10
|
return record ? structuredClone(record) : undefined;
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
/** Look up an uploaded asset and prove it belongs to this account and matches the attachment. */
|
|
14
|
+
export async function storedMedia(item, ref, account, store, options) {
|
|
15
|
+
if (ref.backend !== account.backend ||
|
|
16
|
+
ref.accountId !== account.accountId ||
|
|
17
|
+
ref.platform !== account.platform)
|
|
18
|
+
throw new SocialError({
|
|
19
|
+
code: "unauthorized",
|
|
20
|
+
operation: "media.resolve",
|
|
21
|
+
message: "Media reference belongs to another account or backend.",
|
|
22
|
+
});
|
|
23
|
+
const record = await store.get(ref.mediaId);
|
|
24
|
+
if (!record ||
|
|
25
|
+
record.ref.backend !== ref.backend ||
|
|
26
|
+
record.ref.accountId !== ref.accountId ||
|
|
27
|
+
record.ref.platform !== ref.platform)
|
|
28
|
+
throw new SocialError({
|
|
29
|
+
code: "media_error",
|
|
30
|
+
operation: "media.resolve",
|
|
31
|
+
message: "Media reference is unknown to this server-side store.",
|
|
32
|
+
});
|
|
33
|
+
if (record.expiresAt &&
|
|
34
|
+
Date.parse(record.expiresAt) <= (options.clock?.() ?? new Date()).getTime())
|
|
35
|
+
throw new SocialError({
|
|
36
|
+
code: "media_error",
|
|
37
|
+
operation: "media.resolve",
|
|
38
|
+
message: "Stored media has expired. Upload a new asset explicitly.",
|
|
39
|
+
});
|
|
40
|
+
if (record.kind !== item.kind ||
|
|
41
|
+
(item.mimeType !== undefined && item.mimeType !== record.mimeType))
|
|
42
|
+
throw new SocialError({
|
|
43
|
+
code: "media_error",
|
|
44
|
+
operation: "media.resolve",
|
|
45
|
+
message: "Media kind or MIME type differs from the stored asset.",
|
|
46
|
+
});
|
|
47
|
+
return record;
|
|
48
|
+
}
|
|
13
49
|
export function managedMedia(provider, options, presign) {
|
|
14
50
|
const store = options.mediaStore ?? new MemoryManagedMediaStore();
|
|
15
51
|
async function resolve(item, account, context) {
|
|
@@ -26,40 +62,7 @@ export function managedMedia(provider, options, presign) {
|
|
|
26
62
|
context,
|
|
27
63
|
provider,
|
|
28
64
|
});
|
|
29
|
-
|
|
30
|
-
if (ref.backend !== account.backend ||
|
|
31
|
-
ref.accountId !== account.accountId ||
|
|
32
|
-
ref.platform !== account.platform)
|
|
33
|
-
throw new SocialError({
|
|
34
|
-
code: "unauthorized",
|
|
35
|
-
operation: "media.resolve",
|
|
36
|
-
message: "Media reference belongs to another account or backend.",
|
|
37
|
-
});
|
|
38
|
-
const record = await store.get(ref.mediaId);
|
|
39
|
-
if (!record ||
|
|
40
|
-
record.ref.backend !== ref.backend ||
|
|
41
|
-
record.ref.accountId !== ref.accountId ||
|
|
42
|
-
record.ref.platform !== ref.platform)
|
|
43
|
-
throw new SocialError({
|
|
44
|
-
code: "media_error",
|
|
45
|
-
operation: "media.resolve",
|
|
46
|
-
message: "Media reference is unknown to this server-side store.",
|
|
47
|
-
});
|
|
48
|
-
if (record.expiresAt &&
|
|
49
|
-
Date.parse(record.expiresAt) <= (options.clock?.() ?? new Date()).getTime())
|
|
50
|
-
throw new SocialError({
|
|
51
|
-
code: "media_error",
|
|
52
|
-
operation: "media.resolve",
|
|
53
|
-
message: "Stored media has expired. Upload a new asset explicitly.",
|
|
54
|
-
});
|
|
55
|
-
if (record.kind !== item.kind ||
|
|
56
|
-
(item.mimeType !== undefined && item.mimeType !== record.mimeType))
|
|
57
|
-
throw new SocialError({
|
|
58
|
-
code: "media_error",
|
|
59
|
-
operation: "media.resolve",
|
|
60
|
-
message: "Media kind or MIME type differs from the stored asset.",
|
|
61
|
-
});
|
|
62
|
-
return record.publicUrl;
|
|
65
|
+
return (await storedMedia(item, item.source.ref, account, store, options)).publicUrl;
|
|
63
66
|
}
|
|
64
67
|
return {
|
|
65
68
|
resolve,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export { MemoryManagedMediaStore, type ManagedMediaStore, type ManagedMediaRecord, } from "./media.js";
|
|
2
|
+
import type { AccountRecord, AdapterOperationContext, BackendPostRef, ConnectedAccountRef, DeliveryOutcome, MediaAttachment, MediaRef, MetricValue, PlatformPostRef, PreparationIssue, ScheduleCancellation, ScheduledJobRef } from "../core/types.js";
|
|
3
|
+
import { managedPreparation, type ManagedOptions } from "./common.js";
|
|
4
|
+
export interface PostFastConnectLinkOptions {
|
|
5
|
+
/** PostFast platform values such as `X` or `INSTAGRAM`. Omit to offer every platform. */
|
|
6
|
+
platforms?: readonly string[];
|
|
7
|
+
/** Days until the link expires. PostFast defaults to 7. */
|
|
8
|
+
expiryDays?: number;
|
|
9
|
+
redirectUrl?: string;
|
|
10
|
+
/** Your own identifier for the person connecting accounts. */
|
|
11
|
+
externalId?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* PostFast managed backend. PostFast only schedules posts, so every target needs a
|
|
15
|
+
* future `schedule.at`. Media is uploaded to PostFast storage and referenced by key.
|
|
16
|
+
*/
|
|
17
|
+
export declare function postfast(options: ManagedOptions): {
|
|
18
|
+
id: string;
|
|
19
|
+
capabilities: import("../index.js").CapabilityManifest;
|
|
20
|
+
media: {
|
|
21
|
+
upload(item: MediaAttachment, target: ConnectedAccountRef, context: AdapterOperationContext): Promise<MediaRef>;
|
|
22
|
+
};
|
|
23
|
+
accounts: {
|
|
24
|
+
list(input: {
|
|
25
|
+
cursor?: string;
|
|
26
|
+
limit?: number;
|
|
27
|
+
}, context: AdapterOperationContext): Promise<{
|
|
28
|
+
nextCursor?: string;
|
|
29
|
+
items: AccountRecord[];
|
|
30
|
+
}>;
|
|
31
|
+
get(ref: ConnectedAccountRef, context: AdapterOperationContext): Promise<AccountRecord>;
|
|
32
|
+
};
|
|
33
|
+
posts: {
|
|
34
|
+
prepareTarget(target: Parameters<typeof managedPreparation>[0]): PreparationIssue[];
|
|
35
|
+
publishTarget(target: Parameters<typeof managedPreparation>[0], context: AdapterOperationContext): Promise<DeliveryOutcome>;
|
|
36
|
+
getDelivery(ref: {
|
|
37
|
+
deliveryId: string;
|
|
38
|
+
accountId: string;
|
|
39
|
+
platform: string;
|
|
40
|
+
backend: string;
|
|
41
|
+
}, context: AdapterOperationContext): Promise<DeliveryOutcome>;
|
|
42
|
+
cancelScheduled(ref: ScheduledJobRef, context: AdapterOperationContext): Promise<ScheduleCancellation>;
|
|
43
|
+
deleteBackendRecord(ref: BackendPostRef, context: AdapterOperationContext): Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
analytics: {
|
|
46
|
+
getPostMetrics(ref: PlatformPostRef, context: AdapterOperationContext): Promise<readonly MetricValue[]>;
|
|
47
|
+
};
|
|
48
|
+
native: {
|
|
49
|
+
/** Create a hosted link where someone connects their social accounts to your workspace. */
|
|
50
|
+
createConnectLink(input: PostFastConnectLinkOptions, context: AdapterOperationContext): Promise<{
|
|
51
|
+
url: string;
|
|
52
|
+
}>;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencoredev/social-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Social platform integrations for TypeScript applications.",
|
|
5
5
|
"homepage": "https://github.com/opencoredev/social-sdk#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -41,6 +41,10 @@
|
|
|
41
41
|
"types": "./dist/cloud/post-for-me.d.ts",
|
|
42
42
|
"import": "./dist/cloud/post-for-me.js"
|
|
43
43
|
},
|
|
44
|
+
"./cloud/postfast": {
|
|
45
|
+
"types": "./dist/cloud/postfast.d.ts",
|
|
46
|
+
"import": "./dist/cloud/postfast.js"
|
|
47
|
+
},
|
|
44
48
|
"./server": {
|
|
45
49
|
"types": "./dist/server/index.d.ts",
|
|
46
50
|
"import": "./dist/server/index.js"
|