Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
22
packages/connector-linkedin/package.json
Normal file
22
packages/connector-linkedin/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@tactical-media-broadcast-hub/connector-linkedin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tactical-media-broadcast-hub/connector-sdk": "workspace:*",
|
||||
"@tactical-media-broadcast-hub/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"typescript": "^5.5.4",
|
||||
"vitest": "^2.0.5"
|
||||
}
|
||||
}
|
||||
345
packages/connector-linkedin/src/contract.test.ts
Normal file
345
packages/connector-linkedin/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection, MediaAsset, NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import { createLinkedInConnector } from "./index.js";
|
||||
|
||||
const TOKEN = "linkedin-access-token";
|
||||
const MEMBER_URN = "urn:li:person:member123";
|
||||
const ORGANIZATION_URN = "urn:li:organization:987654";
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-linkedin",
|
||||
workspaceId: "ws-1",
|
||||
provider: "linkedin",
|
||||
channelId: MEMBER_URN,
|
||||
credentialId: { kind: "credential-ref", id: "cred-linkedin" },
|
||||
fields: { accessToken: TOKEN, memberUrn: MEMBER_URN },
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
interface StubCall {
|
||||
url: URL;
|
||||
method: string;
|
||||
headers: Headers;
|
||||
body?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface StubPost {
|
||||
urn: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface StubState {
|
||||
calls: StubCall[];
|
||||
posts: StubPost[];
|
||||
nextPost: number;
|
||||
nextComment: number;
|
||||
uploadedBytes: number;
|
||||
}
|
||||
|
||||
function response(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
||||
return new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(input: Parameters<typeof fetch>[0]): URL {
|
||||
if (typeof input === "string") return new URL(input);
|
||||
if (input instanceof URL) return input;
|
||||
return new URL(input.url);
|
||||
}
|
||||
|
||||
function requestBody(init: RequestInit | undefined): Record<string, unknown> | undefined {
|
||||
return typeof init?.body === "string"
|
||||
? JSON.parse(init.body) as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function stubLinkedIn(state: StubState): typeof fetch {
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const url = requestUrl(input);
|
||||
const method = init?.method ?? "GET";
|
||||
const headers = new Headers(init?.headers);
|
||||
const body = requestBody(init);
|
||||
state.calls.push({ url, method, headers, body });
|
||||
|
||||
if (url.host === "uploads.linkedin.test" && method === "PUT") {
|
||||
expect(headers.get("authorization")).toBeNull();
|
||||
const bytes = await new Response(init?.body).arrayBuffer();
|
||||
state.uploadedBytes = bytes.byteLength;
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
|
||||
expect(url.origin).toBe("https://api.linkedin.com");
|
||||
expect(headers.get("authorization")).toBe(`Bearer ${TOKEN}`);
|
||||
expect(headers.get("x-restli-protocol-version")).toBe("2.0.0");
|
||||
|
||||
if (method === "GET" && url.pathname === "/v2/userinfo") {
|
||||
return response({
|
||||
sub: "member123",
|
||||
name: "Ada Lovelace",
|
||||
preferred_username: "ada-lovelace",
|
||||
picture: "https://media.example.test/ada.jpg",
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "POST" && url.pathname === "/rest/assets") {
|
||||
expect(url.searchParams.get("action")).toBe("registerUpload");
|
||||
return response({
|
||||
value: {
|
||||
asset: "urn:li:digitalmediaAsset:image123",
|
||||
uploadMechanism: {
|
||||
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest": {
|
||||
uploadUrl: "https://uploads.linkedin.test/image123",
|
||||
headers: { "x-amz-server-side-encryption": "AES256" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (method === "POST" && url.pathname === "/rest/posts") {
|
||||
const urn = `urn:li:share:${state.nextPost}`;
|
||||
state.nextPost += 1;
|
||||
state.posts.push({ urn, body: body ?? {} });
|
||||
const headerName = state.nextPost % 2 === 0 ? "x-restli-id" : "x-linkedin-id";
|
||||
return response(undefined, 201, { [headerName]: urn });
|
||||
}
|
||||
|
||||
if (method === "DELETE" && url.pathname.startsWith("/rest/posts/")) {
|
||||
const urn = decodeURIComponent(url.pathname.slice("/rest/posts/".length));
|
||||
state.posts = state.posts.filter((post) => post.urn !== urn);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/rest/socialActions/") && url.pathname.endsWith("/comments")) {
|
||||
const encoded = url.pathname.slice("/rest/socialActions/".length, -"/comments".length);
|
||||
const parent = decodeURIComponent(encoded);
|
||||
if (method === "GET") {
|
||||
return response({
|
||||
elements: [
|
||||
{
|
||||
id: "urn:li:comment:(urn:li:activity:123,456)",
|
||||
actor: MEMBER_URN,
|
||||
message: { text: "Useful context" },
|
||||
created: { time: 1_780_000_000_000 },
|
||||
},
|
||||
],
|
||||
paging: { start: 0, count: 1, total: 1 },
|
||||
});
|
||||
}
|
||||
const urn = `urn:li:comment:${state.nextComment}`;
|
||||
state.nextComment += 1;
|
||||
expect(body).toMatchObject({ actor: MEMBER_URN, message: { text: "Agreed" } });
|
||||
return response(undefined, 201, { "x-restli-id": urn, "x-parent-id": parent });
|
||||
}
|
||||
|
||||
return response({ message: `Unhandled ${method} ${url.pathname}` }, 404);
|
||||
};
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
state: StubState;
|
||||
connector: ReturnType<typeof createLinkedInConnector>;
|
||||
}
|
||||
|
||||
function harness(): Harness {
|
||||
const state: StubState = {
|
||||
calls: [],
|
||||
posts: [],
|
||||
nextPost: 1,
|
||||
nextComment: 1,
|
||||
uploadedBytes: 0,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
connector: createLinkedInConnector({
|
||||
fetchImpl: stubLinkedIn(state),
|
||||
openAsset: async () => new Uint8Array([137, 80, 78, 71]),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function post(
|
||||
text: string,
|
||||
mediaAssetIds: string[] = [],
|
||||
providerMetadata: Record<string, unknown> = {},
|
||||
): NormalizedPost {
|
||||
return {
|
||||
text,
|
||||
mediaAssetIds,
|
||||
visibility: "public",
|
||||
providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
connectorContractTests(harness().connector, { connection });
|
||||
|
||||
describe("linkedin connector", () => {
|
||||
it("declares the OAuth setup and honest capabilities", () => {
|
||||
const manifest = harness().connector.manifest();
|
||||
expect(manifest).toMatchObject({
|
||||
id: "linkedin",
|
||||
displayName: "LinkedIn",
|
||||
capabilities: {
|
||||
publishText: true,
|
||||
publishImages: true,
|
||||
feed: false,
|
||||
comments: true,
|
||||
replies: true,
|
||||
amplifyRepost: true,
|
||||
amplifyQuote: true,
|
||||
analytics: false,
|
||||
deletePublication: true,
|
||||
directMessages: false,
|
||||
webhooks: false,
|
||||
nativeScheduling: false,
|
||||
},
|
||||
oauth: {
|
||||
kind: "oauth2-redirect",
|
||||
authorizeUrlTemplate: "https://www.linkedin.com/oauth/v2/authorization",
|
||||
},
|
||||
});
|
||||
expect(manifest.connectionFields).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "accessToken", required: true, secret: true }),
|
||||
expect.objectContaining({ name: "memberUrn", required: true, secret: false }),
|
||||
expect.objectContaining({ name: "organizationUrn", required: false, secret: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the OIDC userinfo identity", async () => {
|
||||
const { connector } = harness();
|
||||
await expect(connector.getIdentity(connection)).resolves.toEqual({
|
||||
provider: "linkedin",
|
||||
remoteId: "member123",
|
||||
handle: "ada-lovelace",
|
||||
displayName: "Ada Lovelace",
|
||||
avatarUrl: "https://media.example.test/ada.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the full Posts API shape and extracts x-restli-id", async () => {
|
||||
const { state, connector } = harness();
|
||||
const publication = await connector.publish?.(connection, post("Launch day"));
|
||||
expect(publication?.remoteId).toBe("urn:li:share:1");
|
||||
expect(state.posts[0]?.body).toEqual({
|
||||
author: MEMBER_URN,
|
||||
commentary: "Launch day",
|
||||
visibility: "PUBLIC",
|
||||
distribution: {
|
||||
feedDistribution: "MAIN_FEED",
|
||||
targetEntities: [],
|
||||
thirdPartyDistributionChannels: [],
|
||||
},
|
||||
lifecycleState: "PUBLISHED",
|
||||
isReshareDisabledByAuthor: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes as the configured organization and references uploaded images", async () => {
|
||||
const organizationConnection: ConnectorConnection = {
|
||||
...connection,
|
||||
fields: { ...connection.fields, organizationUrn: ORGANIZATION_URN },
|
||||
};
|
||||
const { state, connector } = harness();
|
||||
await connector.publish?.(
|
||||
organizationConnection,
|
||||
post("Company update", ["urn:li:image:first", "urn:li:image:second"]),
|
||||
);
|
||||
expect(state.posts[0]?.body).toMatchObject({
|
||||
author: ORGANIZATION_URN,
|
||||
content: {
|
||||
multiImage: {
|
||||
images: [{ id: "urn:li:image:first" }, { id: "urn:li:image:second" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("supports a publish reshareContext roundtrip", async () => {
|
||||
const { state, connector } = harness();
|
||||
const parent = "urn:li:share:original";
|
||||
const result = await connector.publish?.(
|
||||
connection,
|
||||
post("A useful read", [], { reshareContext: { parent } }),
|
||||
);
|
||||
expect(result?.providerMetadata).toMatchObject({ reshareParent: parent });
|
||||
expect(state.posts[0]?.body).toMatchObject({
|
||||
commentary: "A useful read",
|
||||
reshareContext: { parent },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps repost and quote amplification to attributed reshare posts", async () => {
|
||||
const parent = "urn:li:share:original";
|
||||
const repostHarness = harness();
|
||||
const repost = await repostHarness.connector.amplify?.(connection, {
|
||||
kind: "repost",
|
||||
targetRemoteId: parent,
|
||||
});
|
||||
expect(repost?.remoteId).toBe("urn:li:share:1");
|
||||
expect(repostHarness.state.posts[0]?.body).toMatchObject({
|
||||
commentary: "",
|
||||
reshareContext: { parent },
|
||||
});
|
||||
|
||||
const quoteHarness = harness();
|
||||
const quote = await quoteHarness.connector.amplify?.(connection, {
|
||||
kind: "quote",
|
||||
targetRemoteId: parent,
|
||||
commentary: "My take",
|
||||
});
|
||||
expect(quote?.remoteId).toBe("urn:li:share:1");
|
||||
expect(quoteHarness.state.posts[0]?.body).toMatchObject({
|
||||
commentary: "My take",
|
||||
reshareContext: { parent },
|
||||
});
|
||||
});
|
||||
|
||||
it("registers and PUTs an image upload", async () => {
|
||||
const media: MediaAsset = {
|
||||
id: "image-1",
|
||||
workspaceId: "ws-1",
|
||||
storageKey: "s3://assets/image-1",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 4,
|
||||
width: 640,
|
||||
height: 480,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
const { state, connector } = harness();
|
||||
const uploaded = await connector.uploadMedia?.(connection, media);
|
||||
expect(uploaded).toEqual({
|
||||
remoteId: "urn:li:digitalmediaAsset:image123",
|
||||
mimeType: "image/png",
|
||||
width: 640,
|
||||
height: 480,
|
||||
});
|
||||
expect(state.uploadedBytes).toBe(4);
|
||||
});
|
||||
|
||||
it("reads comments, replies, and deletes an owned publication", async () => {
|
||||
const { state, connector } = harness();
|
||||
const comments = await connector.getComments?.(connection, {
|
||||
onRemoteId: "urn:li:share:original",
|
||||
limit: 10,
|
||||
});
|
||||
expect(comments?.items[0]).toMatchObject({
|
||||
authorHandle: MEMBER_URN,
|
||||
text: "Useful context",
|
||||
});
|
||||
const reply = await connector.reply?.(connection, {
|
||||
onRemoteId: "urn:li:share:original",
|
||||
text: "Agreed",
|
||||
});
|
||||
expect(reply?.remoteId).toBe("urn:li:comment:1");
|
||||
|
||||
const published = await connector.publish?.(connection, post("Temporary"));
|
||||
expect(state.posts).toHaveLength(1);
|
||||
await connector.deletePublication?.(connection, published?.remoteId ?? "");
|
||||
expect(state.posts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
525
packages/connector-linkedin/src/index.ts
Normal file
525
packages/connector-linkedin/src/index.ts
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
Comment,
|
||||
ConnectorCapabilities,
|
||||
ConnectorConnection,
|
||||
MediaAsset,
|
||||
NormalizedIdentity,
|
||||
NormalizedPost,
|
||||
Page,
|
||||
Publication,
|
||||
RemoteMedia,
|
||||
Visibility,
|
||||
} from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
assertValidManifest,
|
||||
AuthError,
|
||||
CapabilityNotSupportedError,
|
||||
ConnectorError,
|
||||
RateLimitedHttpClient,
|
||||
type ConnectorManifest,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
||||
const PROVIDER = "linkedin";
|
||||
const API_BASE = "https://api.linkedin.com";
|
||||
const LINKEDIN_VERSION = "202510";
|
||||
|
||||
export const LINKEDIN_RATE_POLICY = { requestsPerSecond: 2 } as const;
|
||||
|
||||
const CAPABILITIES: ConnectorCapabilities = {
|
||||
publishText: true,
|
||||
publishImages: true,
|
||||
feed: false,
|
||||
comments: true,
|
||||
replies: true,
|
||||
amplifyRepost: true,
|
||||
amplifyQuote: true,
|
||||
analytics: false,
|
||||
deletePublication: true,
|
||||
directMessages: false,
|
||||
webhooks: false,
|
||||
nativeScheduling: false,
|
||||
};
|
||||
|
||||
export interface LinkedInConnectorOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Supplies image bytes when MediaAsset.storageKey is not an HTTP(S) URL. */
|
||||
openAsset?: (media: MediaAsset) => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
sub: string;
|
||||
name?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
preferred_username?: string;
|
||||
picture?: string;
|
||||
}
|
||||
|
||||
interface RegisterUploadResponse {
|
||||
value?: {
|
||||
asset?: string;
|
||||
uploadMechanism?: {
|
||||
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"?: {
|
||||
uploadUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkedInComment {
|
||||
id?: string;
|
||||
actor?: string;
|
||||
message?: { text?: string };
|
||||
created?: { time?: number };
|
||||
lastModified?: { time?: number };
|
||||
}
|
||||
|
||||
interface CommentsResponse {
|
||||
elements?: LinkedInComment[];
|
||||
paging?: { start?: number; count?: number; total?: number };
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function jsonBody(payload: Record<string, unknown>): RequestInit {
|
||||
return {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function visibility(value: Visibility): "PUBLIC" | "CONNECTIONS" | "LOGGED_IN" {
|
||||
switch (value) {
|
||||
case "followers":
|
||||
case "private":
|
||||
return "CONNECTIONS";
|
||||
case "unlisted":
|
||||
return "LOGGED_IN";
|
||||
case "public":
|
||||
case "provider_default":
|
||||
return "PUBLIC";
|
||||
}
|
||||
}
|
||||
|
||||
function nestedReshareParent(metadata: Record<string, unknown>): string | undefined {
|
||||
const direct = nonEmptyString(metadata["reshareParent"])
|
||||
?? nonEmptyString(metadata["parentPostUrn"]);
|
||||
if (direct !== undefined) return direct;
|
||||
const context = metadata["reshareContext"];
|
||||
return isRecord(context) ? nonEmptyString(context["parent"]) : undefined;
|
||||
}
|
||||
|
||||
function draftId(metadata: Record<string, unknown>, fallback = "direct"): string {
|
||||
return nonEmptyString(metadata["draftId"]) ?? fallback;
|
||||
}
|
||||
|
||||
function mediaContent(mediaUrns: string[]): Record<string, unknown> | undefined {
|
||||
if (mediaUrns.length === 0) return undefined;
|
||||
if (mediaUrns.length === 1) return { media: { id: mediaUrns[0] } };
|
||||
return { multiImage: { images: mediaUrns.map((id) => ({ id })) } };
|
||||
}
|
||||
|
||||
function postUrnFromBody(body: unknown): string | undefined {
|
||||
if (!isRecord(body)) return undefined;
|
||||
const direct = nonEmptyString(body["id"]);
|
||||
if (direct !== undefined) return direct;
|
||||
const value = body["value"];
|
||||
return isRecord(value) ? nonEmptyString(value["id"]) : undefined;
|
||||
}
|
||||
|
||||
/** LinkedIn Posts API connector. Personal feed reads and analytics stay undeclared. */
|
||||
export function createLinkedInConnector(options: LinkedInConnectorOptions = {}): PlatformConnector {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: LINKEDIN_RATE_POLICY.requestsPerSecond,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const manifest: ConnectorManifest = {
|
||||
id: PROVIDER,
|
||||
displayName: "LinkedIn",
|
||||
capabilities: CAPABILITIES,
|
||||
connectionFields: [
|
||||
{
|
||||
name: "accessToken",
|
||||
label: "Access token",
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "memberUrn",
|
||||
label: "Member URN",
|
||||
required: true,
|
||||
secret: false,
|
||||
pattern: "^urn:li:person:[A-Za-z0-9_-]+$",
|
||||
placeholder: "urn:li:person:abc123",
|
||||
},
|
||||
{
|
||||
name: "organizationUrn",
|
||||
label: "Organization URN (optional)",
|
||||
required: false,
|
||||
secret: false,
|
||||
pattern: "^urn:li:organization:[0-9]+$",
|
||||
placeholder: "urn:li:organization:123456",
|
||||
},
|
||||
],
|
||||
oauth: {
|
||||
kind: "oauth2-redirect",
|
||||
authorizeUrlTemplate: "https://www.linkedin.com/oauth/v2/authorization",
|
||||
scopes: [
|
||||
"openid",
|
||||
"profile",
|
||||
"w_member_social",
|
||||
"r_member_social",
|
||||
"rw_organization_social",
|
||||
],
|
||||
},
|
||||
docsUrl: "https://learn.microsoft.com/linkedin/marketing/community-management/shares/posts-api",
|
||||
};
|
||||
|
||||
function accessToken(connection: ConnectorConnection): string {
|
||||
const token = nonEmptyString(connection.fields["accessToken"]);
|
||||
if (token === undefined) {
|
||||
throw new AuthError(`${PROVIDER}: missing accessToken connection field`, PROVIDER, false);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function memberUrn(connection: ConnectorConnection): string {
|
||||
const urn = nonEmptyString(connection.fields["memberUrn"]);
|
||||
if (urn === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: missing memberUrn connection field`, PROVIDER);
|
||||
}
|
||||
return urn;
|
||||
}
|
||||
|
||||
function authorUrn(
|
||||
connection: ConnectorConnection,
|
||||
metadata: Record<string, unknown> = {},
|
||||
): string {
|
||||
const member = memberUrn(connection);
|
||||
const organization = nonEmptyString(connection.fields["organizationUrn"]);
|
||||
const explicit = nonEmptyString(metadata["authorUrn"]);
|
||||
if (explicit !== undefined && explicit !== member && explicit !== organization) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: authorUrn must match the connection's memberUrn or organizationUrn`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
return explicit ?? organization ?? member;
|
||||
}
|
||||
|
||||
async function errorFor(response: Response, method: string, url: string): Promise<Error> {
|
||||
const detail = (await response.text()).trim();
|
||||
const suffix = detail.length > 0 ? `: ${detail}` : "";
|
||||
const message = `${PROVIDER}: HTTP ${response.status} ${method} ${new URL(url).pathname}${suffix}`;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return new AuthError(message, PROVIDER, false);
|
||||
}
|
||||
return new ConnectorError(message, PROVIDER, { status: response.status });
|
||||
}
|
||||
|
||||
async function api(
|
||||
connection: ConnectorConnection,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("accept", "application/json");
|
||||
headers.set("authorization", `Bearer ${accessToken(connection)}`);
|
||||
headers.set("linkedin-version", LINKEDIN_VERSION);
|
||||
headers.set("x-restli-protocol-version", "2.0.0");
|
||||
const method = init.method ?? "GET";
|
||||
const response = await client.request(PROVIDER, url, { ...init, headers });
|
||||
if (!response.ok) throw await errorFor(response, method, url);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function json<T>(
|
||||
connection: ConnectorConnection,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const response = await api(connection, path, init);
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: non-JSON response from ${path} (HTTP ${response.status})`,
|
||||
PROVIDER,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createdUrn(
|
||||
connection: ConnectorConnection,
|
||||
path: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const response = await api(connection, path, jsonBody(payload));
|
||||
const headerUrn = nonEmptyString(response.headers.get("x-restli-id"))
|
||||
?? nonEmptyString(response.headers.get("x-linkedin-id"));
|
||||
if (headerUrn !== undefined) return headerUrn;
|
||||
|
||||
const text = await response.text();
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
const bodyUrn = postUrnFromBody(JSON.parse(text) as unknown);
|
||||
if (bodyUrn !== undefined) return bodyUrn;
|
||||
} catch {
|
||||
// The create response body is optional; the missing-id error below is clearer.
|
||||
}
|
||||
}
|
||||
throw new ConnectorError(`${PROVIDER}: create response contained no resource URN`, PROVIDER);
|
||||
}
|
||||
|
||||
function publication(
|
||||
connection: ConnectorConnection,
|
||||
remoteId: string,
|
||||
sourceDraftId: string,
|
||||
providerMetadata: Record<string, unknown>,
|
||||
): Publication {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId: sourceDraftId,
|
||||
connectionId: connection.id,
|
||||
remoteId,
|
||||
publishedAt: new Date(),
|
||||
providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
function postPayload(
|
||||
connection: ConnectorConnection,
|
||||
commentary: string,
|
||||
postVisibility: Visibility,
|
||||
metadata: Record<string, unknown>,
|
||||
mediaUrns: string[] = [],
|
||||
parent?: string,
|
||||
): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
author: authorUrn(connection, metadata),
|
||||
commentary,
|
||||
visibility: visibility(postVisibility),
|
||||
distribution: {
|
||||
feedDistribution: "MAIN_FEED",
|
||||
targetEntities: [],
|
||||
thirdPartyDistributionChannels: [],
|
||||
},
|
||||
lifecycleState: "PUBLISHED",
|
||||
isReshareDisabledByAuthor: false,
|
||||
};
|
||||
const content = mediaContent(mediaUrns);
|
||||
if (content !== undefined) payload["content"] = content;
|
||||
if (parent !== undefined) payload["reshareContext"] = { parent };
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function imageBytes(media: MediaAsset): Promise<Uint8Array> {
|
||||
if (options.openAsset !== undefined) return options.openAsset(media);
|
||||
if (!/^https?:\/\//i.test(media.storageKey)) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: uploadMedia needs openAsset when storageKey is not an HTTP(S) URL`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const response = await fetchImpl(media.storageKey);
|
||||
if (!response.ok) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: could not read media bytes (HTTP ${response.status})`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
return {
|
||||
manifest: () => {
|
||||
assertValidManifest(manifest);
|
||||
return manifest;
|
||||
},
|
||||
|
||||
async authorize() {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: start OAuth at ${manifest.oauth?.authorizeUrlTemplate ?? "the authorization endpoint"}, exchange the code with POST https://www.linkedin.com/oauth/v2/accessToken, then store accessToken and memberUrn`,
|
||||
PROVIDER,
|
||||
);
|
||||
},
|
||||
|
||||
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
const user = await json<UserInfo>(connection, "/v2/userinfo");
|
||||
const componentName = [user.given_name, user.family_name]
|
||||
.map(nonEmptyString)
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join(" ");
|
||||
const displayName = nonEmptyString(user.name)
|
||||
?? nonEmptyString(componentName)
|
||||
?? user.sub;
|
||||
const handle = nonEmptyString(user.preferred_username) ?? displayName;
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
remoteId: user.sub,
|
||||
handle,
|
||||
displayName,
|
||||
avatarUrl: nonEmptyString(user.picture),
|
||||
};
|
||||
},
|
||||
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
const parent = nestedReshareParent(post.providerMetadata);
|
||||
const payload = postPayload(
|
||||
connection,
|
||||
post.text,
|
||||
post.visibility,
|
||||
post.providerMetadata,
|
||||
post.mediaAssetIds,
|
||||
parent,
|
||||
);
|
||||
const remoteId = await createdUrn(connection, "/rest/posts", payload);
|
||||
return publication(connection, remoteId, draftId(post.providerMetadata), {
|
||||
...post.providerMetadata,
|
||||
author: payload["author"],
|
||||
mediaUrns: post.mediaAssetIds,
|
||||
...(parent === undefined ? {} : { reshareParent: parent }),
|
||||
});
|
||||
},
|
||||
|
||||
async uploadMedia(connection: ConnectorConnection, media: MediaAsset): Promise<RemoteMedia> {
|
||||
if (!media.mimeType.startsWith("image/")) {
|
||||
throw new CapabilityNotSupportedError(PROVIDER, `uploadMedia:${media.mimeType}`);
|
||||
}
|
||||
const owner = authorUrn(connection);
|
||||
const registered = await json<RegisterUploadResponse>(
|
||||
connection,
|
||||
"/rest/assets?action=registerUpload",
|
||||
jsonBody({
|
||||
registerUploadRequest: {
|
||||
recipes: ["urn:li:digitalmediaRecipe:feedshare-image"],
|
||||
owner,
|
||||
serviceRelationships: [
|
||||
{
|
||||
relationshipType: "OWNER",
|
||||
identifier: "urn:li:userGeneratedContent",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const asset = nonEmptyString(registered.value?.asset);
|
||||
const upload = registered.value?.uploadMechanism?.[
|
||||
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
|
||||
];
|
||||
const uploadUrl = nonEmptyString(upload?.uploadUrl);
|
||||
if (asset === undefined || uploadUrl === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: registerUpload response is missing asset or uploadUrl`, PROVIDER);
|
||||
}
|
||||
const bytes = await imageBytes(media);
|
||||
const headers = new Headers(upload?.headers);
|
||||
if (!headers.has("content-type")) headers.set("content-type", media.mimeType);
|
||||
const uploaded = await client.request(PROVIDER, uploadUrl, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: new Blob([bytes], { type: media.mimeType }),
|
||||
});
|
||||
if (!uploaded.ok) throw await errorFor(uploaded, "PUT", uploadUrl);
|
||||
return {
|
||||
remoteId: asset,
|
||||
mimeType: media.mimeType,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
};
|
||||
},
|
||||
|
||||
async getComments(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; cursor?: string; limit: number },
|
||||
): Promise<Page<Comment>> {
|
||||
const start = Math.max(0, Number(request.cursor ?? "0") || 0);
|
||||
const count = Math.max(1, Math.min(100, request.limit));
|
||||
const path = `/rest/socialActions/${encodeURIComponent(request.onRemoteId)}/comments?start=${start}&count=${count}`;
|
||||
const data = await json<CommentsResponse>(connection, path);
|
||||
const elements = data.elements ?? [];
|
||||
const items = elements.map((comment, index): Comment => {
|
||||
const remoteId = nonEmptyString(comment.id) ?? `${request.onRemoteId}:comment:${start + index}`;
|
||||
return {
|
||||
id: remoteId,
|
||||
provider: PROVIDER,
|
||||
remoteId,
|
||||
onRemoteId: request.onRemoteId,
|
||||
authorHandle: nonEmptyString(comment.actor) ?? "unknown",
|
||||
text: comment.message?.text ?? "",
|
||||
createdAt: new Date(comment.created?.time ?? comment.lastModified?.time ?? 0),
|
||||
};
|
||||
});
|
||||
const total = data.paging?.total;
|
||||
const next = start + elements.length;
|
||||
return {
|
||||
items,
|
||||
...(total !== undefined && next < total ? { nextCursor: String(next) } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
async reply(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; text: string },
|
||||
): Promise<Publication> {
|
||||
const remoteId = await createdUrn(
|
||||
connection,
|
||||
`/rest/socialActions/${encodeURIComponent(request.onRemoteId)}/comments`,
|
||||
{
|
||||
actor: authorUrn(connection),
|
||||
message: { text: request.text },
|
||||
},
|
||||
);
|
||||
return publication(connection, remoteId, "reply", {
|
||||
inReplyTo: request.onRemoteId,
|
||||
});
|
||||
},
|
||||
|
||||
async amplify(
|
||||
connection: ConnectorConnection,
|
||||
request: {
|
||||
kind: "repost" | "quote" | "share";
|
||||
targetRemoteId: string;
|
||||
commentary?: string;
|
||||
quoteAssetIds?: string[];
|
||||
},
|
||||
): Promise<Publication> {
|
||||
if (request.kind === "share") {
|
||||
throw new CapabilityNotSupportedError(PROVIDER, "amplifyShare");
|
||||
}
|
||||
const commentary = request.kind === "quote" ? request.commentary ?? "" : "";
|
||||
const mediaUrns = request.kind === "quote" ? request.quoteAssetIds ?? [] : [];
|
||||
const payload = postPayload(
|
||||
connection,
|
||||
commentary,
|
||||
"public",
|
||||
{},
|
||||
mediaUrns,
|
||||
request.targetRemoteId,
|
||||
);
|
||||
const remoteId = await createdUrn(connection, "/rest/posts", payload);
|
||||
return publication(connection, remoteId, request.kind, {
|
||||
kind: request.kind,
|
||||
reshareParent: request.targetRemoteId,
|
||||
});
|
||||
},
|
||||
|
||||
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
await api(connection, `/rest/posts/${encodeURIComponent(remoteId)}`, { method: "DELETE" });
|
||||
},
|
||||
};
|
||||
}
|
||||
8
packages/connector-linkedin/tsconfig.json
Normal file
8
packages/connector-linkedin/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue