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/.
724 lines
24 KiB
TypeScript
724 lines
24 KiB
TypeScript
/**
|
|
* Threads connector — Threads API on graph.threads.net.
|
|
*
|
|
* Publishing is URL-backed and two-step: create a container, wait for video
|
|
* processing when applicable, then publish the container. `mediaAssetIds`
|
|
* therefore need to be public URLs, or callers can provide `resolveMediaUrl`.
|
|
* Reads deliberately expose only the connected account's own threads; the API
|
|
* endpoint is not a home timeline. Threads does not document webhooks.
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import type {
|
|
ConnectorCapabilities,
|
|
ConnectorConnection,
|
|
FeedItem,
|
|
NormalizedIdentity,
|
|
NormalizedPost,
|
|
NormalizedVideoPublication,
|
|
Page,
|
|
Publication,
|
|
VideoProcessingStatus,
|
|
} from "@tactical-media-broadcast-hub/core";
|
|
import {
|
|
assertValidManifest,
|
|
AuthError,
|
|
CapabilityNotSupportedError,
|
|
ConnectorError,
|
|
InMemoryCredentials,
|
|
pageOf,
|
|
RateLimitedHttpClient,
|
|
RateLimitError,
|
|
resolveRatePolicy,
|
|
type ConnectorManifest,
|
|
type CredentialAccessor,
|
|
type PlatformConnector,
|
|
} from "@tactical-media-broadcast-hub/connector-sdk";
|
|
|
|
const PROVIDER = "threads";
|
|
const API_ROOT = "https://graph.threads.net";
|
|
const API_BASE = `${API_ROOT}/v1.0`;
|
|
const TEXT_LIMIT = 500;
|
|
const FEED_FIELDS = "id,text,timestamp,permalink,reply_count";
|
|
const INSIGHT_METRICS = "views,likes,replies,reposts,quotes,followers_count";
|
|
|
|
const CAPABILITIES: ConnectorCapabilities = {
|
|
publishText: true,
|
|
publishImages: true,
|
|
publishCarousel: true,
|
|
publishVideo: true,
|
|
replies: true,
|
|
feed: true,
|
|
amplifyRepost: true,
|
|
amplifyQuote: true,
|
|
analytics: true,
|
|
directMessages: false,
|
|
webhooks: false,
|
|
comments: false,
|
|
deletePublication: true,
|
|
};
|
|
|
|
type MediaKind = "image" | "video";
|
|
|
|
export interface ThreadsMediaInput {
|
|
url: string;
|
|
kind: MediaKind;
|
|
}
|
|
|
|
export interface ThreadsConnectorOptions {
|
|
fetchImpl?: typeof fetch;
|
|
/** Test/deployment override; the documented default remains 0.5 req/s. */
|
|
requestsPerSecond?: number;
|
|
/** Secret store. Connection fields remain a development/test fallback. */
|
|
credentials?: CredentialAccessor;
|
|
/** Resolve a AnarchoPoster asset id to a publicly fetchable image/video URL. */
|
|
resolveMediaUrl?: (assetId: string) => Promise<string | ThreadsMediaInput>;
|
|
/** Video-container polling delay. Default 10 seconds. */
|
|
pollIntervalMs?: number;
|
|
/** Maximum status reads while publishing a video. Default 30. */
|
|
maxPollAttempts?: number;
|
|
/** Polling sleep override for tests. */
|
|
sleep?: (ms: number) => Promise<void>;
|
|
}
|
|
|
|
interface GraphErrorBody {
|
|
error?: {
|
|
message?: string;
|
|
code?: number;
|
|
error_subcode?: number;
|
|
error_user_msg?: string;
|
|
};
|
|
}
|
|
|
|
interface GraphIdResponse {
|
|
id?: string;
|
|
success?: boolean;
|
|
}
|
|
|
|
interface ThreadsIdentity {
|
|
id: string;
|
|
username?: string;
|
|
threads_profile_picture_url?: string;
|
|
}
|
|
|
|
interface ThreadsContainer {
|
|
id: string;
|
|
status?: string;
|
|
status_code?: string;
|
|
error_message?: string;
|
|
}
|
|
|
|
interface ThreadsPost {
|
|
id: string;
|
|
text?: string;
|
|
timestamp?: string;
|
|
permalink?: string;
|
|
reply_count?: number;
|
|
}
|
|
|
|
interface GraphPaging {
|
|
cursors?: { before?: string; after?: string };
|
|
next?: string;
|
|
}
|
|
|
|
interface GraphList<T> {
|
|
data: T[];
|
|
paging?: GraphPaging;
|
|
}
|
|
|
|
interface ThreadsInsight {
|
|
name: string;
|
|
period?: string;
|
|
values?: { value: number | Record<string, number>; end_time?: string }[];
|
|
total_value?: { value: number | Record<string, number> };
|
|
}
|
|
|
|
interface RefreshedToken {
|
|
access_token: string;
|
|
token_type?: string;
|
|
expires_in?: number;
|
|
}
|
|
|
|
function nonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function assertText(text: string, allowEmpty: boolean): void {
|
|
const length = [...text].length;
|
|
if (!allowEmpty && length === 0) {
|
|
throw new ConnectorError(`${PROVIDER}: text cannot be empty`, PROVIDER);
|
|
}
|
|
if (length > TEXT_LIMIT) {
|
|
throw new ConnectorError(`${PROVIDER}: text exceeds ${TEXT_LIMIT} characters`, PROVIDER);
|
|
}
|
|
}
|
|
|
|
function inferredKind(value: string): MediaKind {
|
|
const pathname = (() => {
|
|
try {
|
|
return new URL(value).pathname;
|
|
} catch {
|
|
return value;
|
|
}
|
|
})();
|
|
return /\.(?:mp4|mov|m4v|webm)$/i.test(pathname) ? "video" : "image";
|
|
}
|
|
|
|
/** Map Threads container states onto the SDK video state machine. */
|
|
export function mapThreadsContainerStatus(
|
|
remoteId: string,
|
|
container: ThreadsContainer,
|
|
): VideoProcessingStatus {
|
|
const status = (container.status_code ?? container.status ?? "IN_PROGRESS").toUpperCase();
|
|
if (status === "FINISHED" || status === "PUBLISHED") {
|
|
return { remoteId, state: "ready", progressPercent: 100 };
|
|
}
|
|
if (status === "ERROR" || status === "FAILED" || status === "EXPIRED") {
|
|
return {
|
|
remoteId,
|
|
state: "failed",
|
|
error: container.error_message ?? `container ${status.toLowerCase()}`,
|
|
};
|
|
}
|
|
if (status === "UPLOADING") return { remoteId, state: "uploading" };
|
|
return { remoteId, state: "processing" };
|
|
}
|
|
|
|
export function createThreadsConnector(options: ThreadsConnectorOptions = {}): PlatformConnector {
|
|
const ratePolicy = resolveRatePolicy(
|
|
PROVIDER,
|
|
{ dailyPostCap: 250, requestsPerSecond: 0.5 },
|
|
options.requestsPerSecond === undefined
|
|
? undefined
|
|
: { requestsPerSecond: options.requestsPerSecond },
|
|
);
|
|
const client = new RateLimitedHttpClient({
|
|
requestsPerSecond: ratePolicy.requestsPerSecond,
|
|
fetchImpl: options.fetchImpl,
|
|
});
|
|
const credentials = options.credentials ?? new InMemoryCredentials();
|
|
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
const pollIntervalMs = options.pollIntervalMs ?? 10_000;
|
|
const maxPollAttempts = options.maxPollAttempts ?? 30;
|
|
const handles = new Map<string, string>();
|
|
|
|
const manifest: ConnectorManifest = {
|
|
id: PROVIDER,
|
|
displayName: "Threads",
|
|
capabilities: CAPABILITIES,
|
|
connectionFields: [
|
|
{
|
|
name: "accessToken",
|
|
label: "Long-lived access token",
|
|
required: true,
|
|
secret: true,
|
|
},
|
|
{
|
|
name: "threadsUserId",
|
|
label: "Threads user ID",
|
|
required: true,
|
|
secret: false,
|
|
pattern: "^[0-9]+$",
|
|
},
|
|
],
|
|
oauth: {
|
|
kind: "oauth2-redirect",
|
|
authorizeUrlTemplate: "https://threads.net/oauth/authorize",
|
|
scopes: [
|
|
"threads_basic",
|
|
"threads_content_publish",
|
|
"threads_manage_replies",
|
|
"threads_manage_insights",
|
|
],
|
|
},
|
|
docsUrl: "https://developers.facebook.com/docs/threads/",
|
|
};
|
|
|
|
function userIdOf(connection: ConnectorConnection): string {
|
|
const id = nonEmptyString(connection.fields["threadsUserId"]);
|
|
if (id === undefined) {
|
|
throw new ConnectorError(
|
|
`${PROVIDER}: connection ${connection.id} has no threadsUserId`,
|
|
PROVIDER,
|
|
);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function accessTokenOf(connection: ConnectorConnection): Promise<string> {
|
|
try {
|
|
return await credentials.getAccessToken(connection);
|
|
} catch {
|
|
const token = nonEmptyString(connection.fields["accessToken"]);
|
|
if (token !== undefined) return token;
|
|
throw new AuthError(
|
|
`${PROVIDER}: no access token for connection ${connection.id}`,
|
|
PROVIDER,
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseJson(text: string): unknown {
|
|
if (text.length === 0) return {};
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch (cause) {
|
|
throw new ConnectorError(`${PROVIDER}: Graph API returned non-JSON`, PROVIDER, cause);
|
|
}
|
|
}
|
|
|
|
function graphError(
|
|
response: Response,
|
|
method: string,
|
|
url: URL,
|
|
body: unknown,
|
|
): Error {
|
|
const graph = body as GraphErrorBody;
|
|
const error = graph.error;
|
|
const detail = error?.error_user_msg ?? error?.message ?? "Graph API request failed";
|
|
const message = `${PROVIDER}: HTTP ${response.status} ${method} ${url.pathname}: ${detail}`;
|
|
const code = error?.code;
|
|
if (response.status === 429 || code === 4 || code === 17 || code === 32 || code === 613) {
|
|
return new RateLimitError(PROVIDER);
|
|
}
|
|
if (response.status === 401 || code === 190) {
|
|
return new AuthError(message, PROVIDER, false);
|
|
}
|
|
if (response.status === 403 || code === 10 || (code !== undefined && code >= 200 && code < 300)) {
|
|
return new AuthError(`${message} (missing Threads permission?)`, PROVIDER, false);
|
|
}
|
|
return new ConnectorError(message, PROVIDER, {
|
|
status: response.status,
|
|
code,
|
|
subcode: error?.error_subcode,
|
|
});
|
|
}
|
|
|
|
async function graph<T>(
|
|
connection: ConnectorConnection,
|
|
method: "GET" | "POST" | "DELETE",
|
|
path: string,
|
|
params: Record<string, string> = {},
|
|
): Promise<T> {
|
|
const url = new URL(`${API_BASE}${path}`);
|
|
const headers: Record<string, string> = {
|
|
accept: "application/json",
|
|
authorization: `Bearer ${await accessTokenOf(connection)}`,
|
|
};
|
|
const init: RequestInit = { method, headers };
|
|
if (method === "POST") {
|
|
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
init.body = new URLSearchParams(params).toString();
|
|
} else {
|
|
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
}
|
|
const response = await client.request(PROVIDER, url.toString(), init);
|
|
const body = parseJson(await response.text());
|
|
if (!response.ok) throw graphError(response, method, url, body);
|
|
return body as T;
|
|
}
|
|
|
|
async function createContainer(
|
|
connection: ConnectorConnection,
|
|
params: Record<string, string>,
|
|
): Promise<string> {
|
|
const result = await graph<GraphIdResponse>(
|
|
connection,
|
|
"POST",
|
|
`/${userIdOf(connection)}/threads`,
|
|
params,
|
|
);
|
|
if (result.id === undefined) {
|
|
throw new ConnectorError(`${PROVIDER}: container creation returned no id`, PROVIDER);
|
|
}
|
|
return result.id;
|
|
}
|
|
|
|
async function containerStatus(
|
|
connection: ConnectorConnection,
|
|
containerId: string,
|
|
): Promise<ThreadsContainer> {
|
|
return graph<ThreadsContainer>(connection, "GET", `/${encodeURIComponent(containerId)}`, {
|
|
fields: "id,status,error_message",
|
|
});
|
|
}
|
|
|
|
async function awaitContainer(
|
|
connection: ConnectorConnection,
|
|
containerId: string,
|
|
): Promise<void> {
|
|
for (let attempt = 0; attempt < maxPollAttempts; attempt += 1) {
|
|
const status = mapThreadsContainerStatus(
|
|
containerId,
|
|
await containerStatus(connection, containerId),
|
|
);
|
|
if (status.state === "ready") return;
|
|
if (status.state === "failed") {
|
|
throw new ConnectorError(
|
|
`${PROVIDER}: container ${containerId} failed: ${status.error ?? "unknown error"}`,
|
|
PROVIDER,
|
|
);
|
|
}
|
|
if (attempt + 1 < maxPollAttempts) await sleep(pollIntervalMs);
|
|
}
|
|
throw new ConnectorError(
|
|
`${PROVIDER}: container ${containerId} still processing after ${maxPollAttempts} polls`,
|
|
PROVIDER,
|
|
);
|
|
}
|
|
|
|
function publication(
|
|
connection: ConnectorConnection,
|
|
remoteId: string,
|
|
draftId: string,
|
|
providerMetadata: Record<string, unknown>,
|
|
): Publication {
|
|
return {
|
|
id: randomUUID(),
|
|
workspaceId: connection.workspaceId,
|
|
draftId,
|
|
connectionId: connection.id,
|
|
remoteId,
|
|
publishedAt: new Date(),
|
|
providerMetadata,
|
|
};
|
|
}
|
|
|
|
async function publishContainer(
|
|
connection: ConnectorConnection,
|
|
creationId: string,
|
|
draftId: string,
|
|
providerMetadata: Record<string, unknown>,
|
|
): Promise<Publication> {
|
|
const result = await graph<GraphIdResponse>(
|
|
connection,
|
|
"POST",
|
|
`/${userIdOf(connection)}/threads_publish`,
|
|
{ creation_id: creationId },
|
|
);
|
|
if (result.id === undefined) {
|
|
throw new ConnectorError(`${PROVIDER}: publish returned no media id`, PROVIDER);
|
|
}
|
|
return publication(connection, result.id, draftId, {
|
|
creationId,
|
|
dailyPostCap: ratePolicy.dailyPostCap,
|
|
...providerMetadata,
|
|
});
|
|
}
|
|
|
|
function metadataMedia(metadata: Record<string, unknown>): ThreadsMediaInput[] {
|
|
if (!Array.isArray(metadata["media"])) return [];
|
|
const media: ThreadsMediaInput[] = [];
|
|
for (const entry of metadata["media"] as unknown[]) {
|
|
if (typeof entry !== "object" || entry === null) continue;
|
|
const item = entry as Record<string, unknown>;
|
|
const url = nonEmptyString(item["url"]);
|
|
const kind = item["kind"] === "image" || item["kind"] === "video" ? item["kind"] : undefined;
|
|
if (url !== undefined && kind !== undefined) media.push({ url, kind });
|
|
}
|
|
return media;
|
|
}
|
|
|
|
async function resolveAsset(assetId: string): Promise<ThreadsMediaInput> {
|
|
const resolved = options.resolveMediaUrl === undefined
|
|
? assetId
|
|
: await options.resolveMediaUrl(assetId);
|
|
if (typeof resolved === "string") return { url: resolved, kind: inferredKind(resolved) };
|
|
return resolved;
|
|
}
|
|
|
|
async function mediaFor(post: NormalizedPost): Promise<ThreadsMediaInput[]> {
|
|
const media = metadataMedia(post.providerMetadata);
|
|
for (const assetId of post.mediaAssetIds) media.push(await resolveAsset(assetId));
|
|
return media;
|
|
}
|
|
|
|
async function createTextPublication(
|
|
connection: ConnectorConnection,
|
|
text: string,
|
|
params: Record<string, string>,
|
|
draftId: string,
|
|
metadata: Record<string, unknown>,
|
|
allowEmpty = false,
|
|
): Promise<Publication> {
|
|
assertText(text, allowEmpty);
|
|
const containerParams: Record<string, string> = {
|
|
media_type: "TEXT",
|
|
...params,
|
|
};
|
|
if (text.length > 0) containerParams.text = text;
|
|
const creationId = await createContainer(connection, containerParams);
|
|
return publishContainer(connection, creationId, draftId, metadata);
|
|
}
|
|
|
|
return {
|
|
manifest: () => {
|
|
assertValidManifest(manifest);
|
|
return manifest;
|
|
},
|
|
|
|
async authorize() {
|
|
throw new ConnectorError(
|
|
`${PROVIDER}: start OAuth at https://threads.net/oauth/authorize, exchange the code at ${API_ROOT}/oauth/access_token, then store accessToken and threadsUserId`,
|
|
PROVIDER,
|
|
);
|
|
},
|
|
|
|
async refreshCredentials(connection: ConnectorConnection) {
|
|
const url = new URL(`${API_ROOT}/refresh_access_token`);
|
|
url.searchParams.set("grant_type", "th_refresh_token");
|
|
url.searchParams.set("access_token", await accessTokenOf(connection));
|
|
const response = await client.request(PROVIDER, url.toString(), {
|
|
headers: { accept: "application/json" },
|
|
});
|
|
const body = parseJson(await response.text());
|
|
if (!response.ok) throw graphError(response, "GET", url, body);
|
|
const token = body as RefreshedToken;
|
|
if (nonEmptyString(token.access_token) === undefined) {
|
|
throw new AuthError(`${PROVIDER}: token refresh returned no access token`, PROVIDER, false);
|
|
}
|
|
await credentials.put(connection, "access_token", token.access_token);
|
|
return connection.credentialId;
|
|
},
|
|
|
|
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
|
const me = await graph<ThreadsIdentity>(connection, "GET", "/me", {
|
|
fields: "id,username,threads_profile_picture_url",
|
|
});
|
|
const username = me.username ?? me.id;
|
|
const handle = `@${username}`;
|
|
handles.set(connection.id, handle);
|
|
const identity: NormalizedIdentity = {
|
|
provider: PROVIDER,
|
|
remoteId: me.id,
|
|
handle,
|
|
};
|
|
if (me.username !== undefined) identity.displayName = me.username;
|
|
if (me.threads_profile_picture_url !== undefined) {
|
|
identity.avatarUrl = me.threads_profile_picture_url;
|
|
}
|
|
return identity;
|
|
},
|
|
|
|
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
|
const draftId = nonEmptyString(post.providerMetadata["draftId"]) ?? "direct";
|
|
const existing = nonEmptyString(post.providerMetadata["creationId"]);
|
|
if (existing !== undefined) {
|
|
await awaitContainer(connection, existing);
|
|
return publishContainer(connection, existing, draftId, { kind: "existing-container" });
|
|
}
|
|
|
|
assertText(post.text, post.mediaAssetIds.length > 0 || metadataMedia(post.providerMetadata).length > 0);
|
|
const media = await mediaFor(post);
|
|
if (media.length === 0) {
|
|
return createTextPublication(connection, post.text, {}, draftId, { kind: "text" });
|
|
}
|
|
|
|
const first = media[0];
|
|
if (media.length === 1 && first !== undefined) {
|
|
const params: Record<string, string> = first.kind === "image"
|
|
? { media_type: "IMAGE", image_url: first.url }
|
|
: { media_type: "VIDEO", video_url: first.url };
|
|
if (post.text.length > 0) params.text = post.text;
|
|
const creationId = await createContainer(connection, params);
|
|
if (first.kind === "video") await awaitContainer(connection, creationId);
|
|
return publishContainer(connection, creationId, draftId, { kind: first.kind });
|
|
}
|
|
|
|
const children: string[] = [];
|
|
for (const item of media) {
|
|
const params: Record<string, string> = item.kind === "image"
|
|
? { media_type: "IMAGE", image_url: item.url, is_carousel_item: "true" }
|
|
: { media_type: "VIDEO", video_url: item.url, is_carousel_item: "true" };
|
|
const childId = await createContainer(connection, params);
|
|
if (item.kind === "video") await awaitContainer(connection, childId);
|
|
children.push(childId);
|
|
}
|
|
const parentParams: Record<string, string> = {
|
|
media_type: "CAROUSEL",
|
|
children: children.join(","),
|
|
};
|
|
if (post.text.length > 0) parentParams.text = post.text;
|
|
const parentId = await createContainer(connection, parentParams);
|
|
return publishContainer(connection, parentId, draftId, { kind: "carousel", children });
|
|
},
|
|
|
|
async uploadVideo(
|
|
connection: ConnectorConnection,
|
|
video: NormalizedVideoPublication,
|
|
): Promise<{ remoteId: string }> {
|
|
const explicitUrl = nonEmptyString(video.providerMetadata["videoUrl"]);
|
|
const resolved = explicitUrl === undefined
|
|
? await resolveAsset(video.videoAssetId)
|
|
: { url: explicitUrl, kind: "video" as const };
|
|
const text = [video.title, video.description].filter((part): part is string => !!part).join("\n\n");
|
|
assertText(text, true);
|
|
const params: Record<string, string> = {
|
|
media_type: "VIDEO",
|
|
video_url: resolved.url,
|
|
};
|
|
if (text.length > 0) params.text = text;
|
|
return { remoteId: await createContainer(connection, params) };
|
|
},
|
|
|
|
async getVideoStatus(
|
|
connection: ConnectorConnection,
|
|
remoteId: string,
|
|
): Promise<VideoProcessingStatus> {
|
|
return mapThreadsContainerStatus(remoteId, await containerStatus(connection, remoteId));
|
|
},
|
|
|
|
async reply(
|
|
connection: ConnectorConnection,
|
|
request: { onRemoteId: string; text: string },
|
|
): Promise<Publication> {
|
|
return createTextPublication(
|
|
connection,
|
|
request.text,
|
|
{ reply_to_id: request.onRemoteId },
|
|
"reply",
|
|
{ kind: "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");
|
|
}
|
|
if (request.kind === "repost") {
|
|
const result = await graph<GraphIdResponse>(
|
|
connection,
|
|
"POST",
|
|
`/${encodeURIComponent(request.targetRemoteId)}/repost`,
|
|
);
|
|
return publication(
|
|
connection,
|
|
result.id ?? request.targetRemoteId,
|
|
"amplify:repost",
|
|
{ kind: "repost", amplifies: request.targetRemoteId },
|
|
);
|
|
}
|
|
if ((request.quoteAssetIds?.length ?? 0) > 0) {
|
|
throw new CapabilityNotSupportedError(PROVIDER, "quote posts with media");
|
|
}
|
|
const commentary = request.commentary ?? "";
|
|
return createTextPublication(
|
|
connection,
|
|
commentary,
|
|
{ quote_post_id: request.targetRemoteId },
|
|
"amplify:quote",
|
|
{ kind: "quote", amplifies: request.targetRemoteId },
|
|
true,
|
|
);
|
|
},
|
|
|
|
async unamplify(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
|
await graph(connection, "POST", `/${encodeURIComponent(remoteId)}/unrepost`);
|
|
},
|
|
|
|
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
|
try {
|
|
await graph(connection, "DELETE", `/${encodeURIComponent(remoteId)}`);
|
|
} catch (error) {
|
|
const status = error instanceof ConnectorError
|
|
? (error.cause as { status?: number } | undefined)?.status
|
|
: undefined;
|
|
if (status !== 404) throw error;
|
|
}
|
|
},
|
|
|
|
async getFeed(
|
|
connection: ConnectorConnection,
|
|
request: {
|
|
kind: "home" | "mentions" | "search";
|
|
query?: string;
|
|
cursor?: string;
|
|
limit: number;
|
|
},
|
|
): Promise<Page<FeedItem>> {
|
|
if (request.kind !== "home") {
|
|
throw new CapabilityNotSupportedError(
|
|
PROVIDER,
|
|
`feed:${request.kind} (Threads exposes only the connected account's own threads)`,
|
|
);
|
|
}
|
|
const params: Record<string, string> = {
|
|
fields: FEED_FIELDS,
|
|
limit: String(Math.max(1, Math.min(100, request.limit))),
|
|
};
|
|
if (request.cursor !== undefined) params.after = request.cursor;
|
|
const result = await graph<GraphList<ThreadsPost>>(
|
|
connection,
|
|
"GET",
|
|
`/${userIdOf(connection)}/threads`,
|
|
params,
|
|
);
|
|
const authorHandle = handles.get(connection.id) ?? `@${userIdOf(connection)}`;
|
|
const items = result.data.map(
|
|
(thread): FeedItem => ({
|
|
id: thread.id,
|
|
provider: PROVIDER,
|
|
remoteId: thread.id,
|
|
authorHandle,
|
|
text: thread.text ?? "",
|
|
mediaUrls: [],
|
|
createdAt: thread.timestamp === undefined ? new Date(0) : new Date(thread.timestamp),
|
|
metrics: { replies: thread.reply_count ?? 0 },
|
|
}),
|
|
);
|
|
const nextCursor = result.paging?.next === undefined
|
|
? undefined
|
|
: result.paging.cursors?.after;
|
|
return pageOf(items, nextCursor);
|
|
},
|
|
|
|
async getAnalytics(
|
|
connection: ConnectorConnection,
|
|
request: { subjectRemoteId?: string; from: Date; to: Date },
|
|
): Promise<{
|
|
values: Record<string, number>;
|
|
series: { at: Date; values: Record<string, number> }[];
|
|
}> {
|
|
if (request.subjectRemoteId !== undefined) {
|
|
throw new CapabilityNotSupportedError(
|
|
PROVIDER,
|
|
"per-publication analytics (this connector exposes account threads_insights)",
|
|
);
|
|
}
|
|
const result = await graph<GraphList<ThreadsInsight>>(
|
|
connection,
|
|
"GET",
|
|
`/${userIdOf(connection)}/threads_insights`,
|
|
{
|
|
metric: INSIGHT_METRICS,
|
|
since: String(Math.floor(request.from.getTime() / 1000)),
|
|
until: String(Math.floor(request.to.getTime() / 1000)),
|
|
},
|
|
);
|
|
const values: Record<string, number> = {};
|
|
const byTime = new Map<string, Record<string, number>>();
|
|
for (const insight of result.data) {
|
|
const total = insight.total_value?.value;
|
|
if (typeof total === "number") values[insight.name] = total;
|
|
for (const point of insight.values ?? []) {
|
|
if (point.end_time === undefined || typeof point.value !== "number") continue;
|
|
const at = byTime.get(point.end_time) ?? {};
|
|
at[insight.name] = point.value;
|
|
byTime.set(point.end_time, at);
|
|
}
|
|
}
|
|
const series = [...byTime.entries()]
|
|
.map(([at, pointValues]) => ({ at: new Date(at), values: pointValues }))
|
|
.sort((left, right) => left.at.getTime() - right.at.getTime());
|
|
return { values, series };
|
|
},
|
|
};
|
|
}
|