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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View 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"
}
}

View 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);
});
});

View 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" });
},
};
}

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}