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
376
packages/connector-reddit/src/contract.test.ts
Normal file
376
packages/connector-reddit/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import { createRedditConnector, REDDIT_RATE_POLICY } from "./index.js";
|
||||
|
||||
interface StubCall {
|
||||
url: string;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
interface StubState {
|
||||
calls: StubCall[];
|
||||
deleted: string[];
|
||||
}
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-reddit",
|
||||
workspaceId: "ws-1",
|
||||
provider: "reddit",
|
||||
channelId: "u/anarchoposter",
|
||||
credentialId: { kind: "credential-ref", id: "cred-reddit" },
|
||||
fields: {
|
||||
accessToken: "stub-access-token",
|
||||
refreshToken: "stub-refresh-token",
|
||||
username: "anarchoposter",
|
||||
},
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
function ok(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function formBody(init: RequestInit | undefined): URLSearchParams {
|
||||
const body = init?.body;
|
||||
if (!(body instanceof URLSearchParams)) {
|
||||
throw new Error("expected URLSearchParams request body");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function stubApi(state: StubState): typeof fetch {
|
||||
return async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
state.calls.push({ url, init });
|
||||
const parsed = new URL(url);
|
||||
|
||||
if (parsed.pathname === "/api/v1/me") {
|
||||
return ok({
|
||||
id: "reddit-user-1",
|
||||
name: "anarchoposter",
|
||||
icon_img: "https://styles.redditmedia.com/avatar.png?x=1&y=2",
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === "/api/submit") {
|
||||
const body = formBody(init);
|
||||
const kind = body.get("kind");
|
||||
if (kind === "crosspost") {
|
||||
if (body.get("crosspost_fullname") !== "t3_original1") {
|
||||
return new Response(JSON.stringify({ message: "Bad Request" }), { status: 400 });
|
||||
}
|
||||
return ok({
|
||||
json: {
|
||||
errors: [],
|
||||
data: {
|
||||
id: "cross123",
|
||||
url: "https://www.reddit.com/r/news/comments/cross123/crosspost/",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return ok({
|
||||
json: {
|
||||
errors: [],
|
||||
data: {
|
||||
id: kind === "link" ? "link123" : "self123",
|
||||
url: `https://www.reddit.com/r/${body.get("sr")}/comments/${kind === "link" ? "link123" : "self123"}/post/`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === "/api/comment") {
|
||||
const body = formBody(init);
|
||||
return ok({
|
||||
json: {
|
||||
errors: [],
|
||||
data: {
|
||||
id: "comment123",
|
||||
name: "t1_comment123",
|
||||
url: `https://www.reddit.com/comments/post123/_/${body.get("thing_id")}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === "/r/typescript/hot") {
|
||||
return ok({
|
||||
data: {
|
||||
after: "t3_next",
|
||||
children: [
|
||||
{
|
||||
kind: "t3",
|
||||
data: {
|
||||
id: "feed123",
|
||||
name: "t3_feed123",
|
||||
title: "TypeScript 6 ships",
|
||||
author: "compilerfan",
|
||||
score: 314,
|
||||
num_comments: 27,
|
||||
permalink: "/r/typescript/comments/feed123/typescript_6_ships/",
|
||||
selftext: "What changed in this release.",
|
||||
created_utc: 1_800_000_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === "/comments/post123.json") {
|
||||
return ok([
|
||||
{ data: { after: null, children: [] } },
|
||||
{
|
||||
data: {
|
||||
after: null,
|
||||
children: [
|
||||
{
|
||||
kind: "t1",
|
||||
data: {
|
||||
id: "comment123",
|
||||
name: "t1_comment123",
|
||||
parent_id: "t3_post123",
|
||||
author: "reader",
|
||||
body: "Useful write-up.",
|
||||
created_utc: 1_800_000_100,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (parsed.pathname === "/api/v1/message/inbox") {
|
||||
return ok({
|
||||
data: {
|
||||
after: "t4_next",
|
||||
children: [
|
||||
{
|
||||
kind: "t4",
|
||||
data: {
|
||||
id: "message123",
|
||||
name: "t4_message123",
|
||||
author: "alice",
|
||||
dest: "anarchoposter",
|
||||
body: "Can you share the source?",
|
||||
created_utc: 1_800_000_200,
|
||||
first_message_name: "t4_thread123",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (parsed.pathname === "/api/del") {
|
||||
state.deleted.push(String(formBody(init).get("id")));
|
||||
return ok({});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ message: `unhandled ${parsed.pathname}` }), { status: 404 });
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(): {
|
||||
connector: ReturnType<typeof createRedditConnector>;
|
||||
state: StubState;
|
||||
} {
|
||||
const state: StubState = { calls: [], deleted: [] };
|
||||
return {
|
||||
state,
|
||||
connector: createRedditConnector({ fetchImpl: stubApi(state) }),
|
||||
};
|
||||
}
|
||||
|
||||
function post(
|
||||
text: string,
|
||||
providerMetadata: Record<string, unknown>,
|
||||
): NormalizedPost {
|
||||
return {
|
||||
text,
|
||||
mediaAssetIds: [],
|
||||
visibility: "public",
|
||||
providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
connectorContractTests(createHarness().connector, { connection });
|
||||
|
||||
describe("reddit connector: manifest and identity", () => {
|
||||
it("declares the documented rate policy and honest capabilities", () => {
|
||||
const manifest = createHarness().connector.manifest();
|
||||
expect(REDDIT_RATE_POLICY).toEqual({ requestsPerSecond: 0.8 });
|
||||
expect(manifest.capabilities).toMatchObject({
|
||||
publishText: true,
|
||||
publishImages: false,
|
||||
feed: true,
|
||||
comments: true,
|
||||
replies: true,
|
||||
amplifyShare: true,
|
||||
amplifyRepost: false,
|
||||
amplifyQuote: false,
|
||||
directMessages: true,
|
||||
deletePublication: true,
|
||||
webhooks: false,
|
||||
nativeScheduling: false,
|
||||
});
|
||||
expect(manifest.oauth).toEqual({
|
||||
kind: "token",
|
||||
scopes: ["identity", "submit", "read", "privatemessages", "edit"],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets the current account with bearer authentication", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
const identity = await connector.getIdentity(connection);
|
||||
expect(identity).toEqual({
|
||||
provider: "reddit",
|
||||
remoteId: "reddit-user-1",
|
||||
handle: "u/anarchoposter",
|
||||
displayName: "anarchoposter",
|
||||
avatarUrl: "https://styles.redditmedia.com/avatar.png?x=1&y=2",
|
||||
});
|
||||
expect(state.calls[0]?.init?.headers).toMatchObject({
|
||||
authorization: "Bearer stub-access-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed without an access token", async () => {
|
||||
const { connector } = createHarness();
|
||||
await expect(
|
||||
connector.getIdentity({ ...connection, fields: { ...connection.fields, accessToken: "" } }),
|
||||
).rejects.toThrow(/missing OAuth access token/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reddit connector: publication roundtrips", () => {
|
||||
it("submits a self post using the first line as title", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
const publication = await connector.publish?.(
|
||||
connection,
|
||||
post("Release notes\nA detailed changelog.", { sr: "r/typescript", kind: "self" }),
|
||||
);
|
||||
const call = state.calls[0];
|
||||
expect(call?.url).toBe("https://oauth.reddit.com/api/submit");
|
||||
expect(Object.fromEntries(formBody(call?.init))).toEqual({
|
||||
api_type: "json",
|
||||
resubmit: "true",
|
||||
sr: "typescript",
|
||||
kind: "self",
|
||||
title: "Release notes",
|
||||
text: "A detailed changelog.",
|
||||
});
|
||||
expect(publication?.remoteId).toBe("self123");
|
||||
expect(publication?.providerMetadata).toMatchObject({ sr: "typescript", kind: "self" });
|
||||
});
|
||||
|
||||
it("submits a link post with providerMetadata.url", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
const publication = await connector.publish?.(
|
||||
connection,
|
||||
post("Read this", {
|
||||
sr: "typescript",
|
||||
kind: "link",
|
||||
url: "https://example.com/article",
|
||||
}),
|
||||
);
|
||||
const body = formBody(state.calls[0]?.init);
|
||||
expect(body.get("url")).toBe("https://example.com/article");
|
||||
expect(body.get("text")).toBeNull();
|
||||
expect(publication?.remoteId).toBe("link123");
|
||||
});
|
||||
|
||||
it("crossposts without triggering the stub's 400 guard", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
const publication = await connector.amplify(connection, {
|
||||
kind: "share",
|
||||
targetRemoteId: "original1",
|
||||
providerMetadata: { sr: "news" },
|
||||
});
|
||||
const body = formBody(state.calls[0]?.init);
|
||||
expect(body.get("kind")).toBe("crosspost");
|
||||
expect(body.get("crosspost_fullname")).toBe("t3_original1");
|
||||
expect(body.get("sr")).toBe("news");
|
||||
expect(publication.remoteId).toBe("cross123");
|
||||
expect(publication.draftId).toBe("amplify:share");
|
||||
});
|
||||
|
||||
it("rejects repost and quote amplification", async () => {
|
||||
const { connector } = createHarness();
|
||||
await expect(
|
||||
connector.amplify(connection, { kind: "repost", targetRemoteId: "post123" }),
|
||||
).rejects.toThrow(/amplify:repost/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reddit connector: feeds, comments, messages, and deletion", () => {
|
||||
it("maps a subreddit hot listing and cursor", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
const page = await connector.getFeed?.(connection, {
|
||||
kind: "home",
|
||||
query: "typescript",
|
||||
cursor: "t3_previous",
|
||||
limit: 25,
|
||||
});
|
||||
expect(state.calls[0]?.url).toContain("/r/typescript/hot?limit=25&after=t3_previous");
|
||||
expect(page?.nextCursor).toBe("t3_next");
|
||||
expect(page?.items[0]).toMatchObject({
|
||||
remoteId: "feed123",
|
||||
authorHandle: "u/compilerfan",
|
||||
text: "TypeScript 6 ships\n\nWhat changed in this release.",
|
||||
metrics: { score: 314, comments: 27 },
|
||||
});
|
||||
});
|
||||
|
||||
it("reads comments and submits top-level comments and replies", async () => {
|
||||
const readHarness = createHarness();
|
||||
const comments = await readHarness.connector.getComments?.(connection, {
|
||||
onRemoteId: "post123",
|
||||
limit: 25,
|
||||
});
|
||||
expect(comments?.items[0]).toMatchObject({
|
||||
remoteId: "comment123",
|
||||
onRemoteId: "t3_post123",
|
||||
authorHandle: "u/reader",
|
||||
text: "Useful write-up.",
|
||||
});
|
||||
|
||||
const topLevel = createHarness();
|
||||
await topLevel.connector.reply?.(connection, { onRemoteId: "post123", text: "Thanks!" });
|
||||
expect(formBody(topLevel.state.calls[0]?.init).get("thing_id")).toBe("t3_post123");
|
||||
|
||||
const nested = createHarness();
|
||||
const reply = await nested.connector.reply?.(connection, {
|
||||
onRemoteId: "t1_comment123",
|
||||
text: "Agreed.",
|
||||
});
|
||||
expect(formBody(nested.state.calls[0]?.init).get("thing_id")).toBe("t1_comment123");
|
||||
expect(reply?.remoteId).toBe("comment123");
|
||||
});
|
||||
|
||||
it("maps the inbox to direct messages", async () => {
|
||||
const { connector } = createHarness();
|
||||
const inbox = await connector.getMessages?.(connection, { limit: 25 });
|
||||
expect(inbox?.nextCursor).toBe("t4_next");
|
||||
expect(inbox?.items[0]).toMatchObject({
|
||||
id: "t4_message123",
|
||||
provider: "reddit",
|
||||
conversationRemoteId: "t4_thread123",
|
||||
fromHandle: "u/alice",
|
||||
toHandle: "u/anarchoposter",
|
||||
text: "Can you share the source?",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a post by fullname", async () => {
|
||||
const { connector, state } = createHarness();
|
||||
await connector.deletePublication?.(connection, "post123");
|
||||
expect(state.deleted).toEqual(["t3_post123"]);
|
||||
});
|
||||
});
|
||||
560
packages/connector-reddit/src/index.ts
Normal file
560
packages/connector-reddit/src/index.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
/**
|
||||
* Reddit connector — OAuth API access for posts, community feeds, comments,
|
||||
* crossposts, inbox messages, and deletion.
|
||||
*
|
||||
* Reddit application registration happens at https://www.reddit.com/prefs/apps.
|
||||
* The application exchanges authorization codes (and later refresh tokens) at
|
||||
* https://www.reddit.com/api/v1/access_token using HTTP Basic client auth. The
|
||||
* resulting access and refresh tokens are supplied as secret connection fields.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
Comment,
|
||||
ConnectorCapabilities,
|
||||
ConnectorConnection,
|
||||
DirectMessage,
|
||||
FeedItem,
|
||||
NormalizedIdentity,
|
||||
NormalizedPost,
|
||||
Page,
|
||||
Publication,
|
||||
} 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 = "reddit";
|
||||
const API_BASE = "https://oauth.reddit.com";
|
||||
const TITLE_LIMIT = 300;
|
||||
const DEFAULT_FEED_LIMIT = 25;
|
||||
|
||||
export const REDDIT_RATE_POLICY = { requestsPerSecond: 0.8 } as const;
|
||||
|
||||
const CAPABILITIES: ConnectorCapabilities = {
|
||||
publishText: true,
|
||||
publishImages: false,
|
||||
feed: true,
|
||||
comments: true,
|
||||
replies: true,
|
||||
amplifyRepost: false,
|
||||
amplifyQuote: false,
|
||||
amplifyShare: true,
|
||||
directMessages: true,
|
||||
deletePublication: true,
|
||||
webhooks: false,
|
||||
nativeScheduling: false,
|
||||
};
|
||||
|
||||
export interface RedditConnectorOptions {
|
||||
/** Fetch override used by contract tests and embedding applications. */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface RedditAmplifyRequest {
|
||||
kind: "repost" | "quote" | "share";
|
||||
targetRemoteId: string;
|
||||
commentary?: string;
|
||||
quoteAssetIds?: string[];
|
||||
/** Destination community for the crosspost. */
|
||||
providerMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RedditConnector extends Omit<PlatformConnector, "amplify"> {
|
||||
amplify(
|
||||
connection: ConnectorConnection,
|
||||
request: RedditAmplifyRequest,
|
||||
): Promise<Publication>;
|
||||
}
|
||||
|
||||
interface RedditMe {
|
||||
id?: string;
|
||||
name: string;
|
||||
icon_img?: string;
|
||||
}
|
||||
|
||||
interface RedditSubmitData {
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
things?: { data: RedditSubmitData }[];
|
||||
}
|
||||
|
||||
interface RedditJsonResponse {
|
||||
json: {
|
||||
errors?: unknown[];
|
||||
data?: RedditSubmitData;
|
||||
};
|
||||
}
|
||||
|
||||
interface RedditListing<T> {
|
||||
data: {
|
||||
after: string | null;
|
||||
children: { kind: string; data: T }[];
|
||||
};
|
||||
}
|
||||
|
||||
interface RedditPost {
|
||||
id: string;
|
||||
name?: string;
|
||||
title: string;
|
||||
author: string;
|
||||
score: number;
|
||||
num_comments: number;
|
||||
permalink: string;
|
||||
selftext?: string;
|
||||
created_utc: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface RedditComment {
|
||||
id: string;
|
||||
name?: string;
|
||||
parent_id?: string;
|
||||
author: string;
|
||||
body: string;
|
||||
created_utc: number;
|
||||
}
|
||||
|
||||
interface RedditMessage {
|
||||
id: string;
|
||||
name?: string;
|
||||
author?: string;
|
||||
dest?: string;
|
||||
body: string;
|
||||
created_utc: number;
|
||||
first_message_name?: string;
|
||||
parent_id?: string;
|
||||
}
|
||||
|
||||
interface SubmitResult {
|
||||
id: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
function stringMetadata(metadata: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = metadata[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function stripPrefix(value: string, prefix: string): string {
|
||||
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
||||
}
|
||||
|
||||
function subredditName(value: string): string {
|
||||
const normalized = value.trim().replace(/^\/?r\//i, "");
|
||||
if (!/^[A-Za-z0-9_]{2,21}$/.test(normalized)) {
|
||||
throw new ConnectorError(`${PROVIDER}: invalid subreddit "${value}"`, PROVIDER);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function fullname(remoteId: string, defaultKind: "t1" | "t3"): string {
|
||||
if (/^t[13]_[A-Za-z0-9]+$/.test(remoteId)) return remoteId;
|
||||
const id = remoteId.trim();
|
||||
if (!/^[A-Za-z0-9]+$/.test(id)) {
|
||||
throw new ConnectorError(`${PROVIDER}: invalid remote id "${remoteId}"`, PROVIDER);
|
||||
}
|
||||
return `${defaultKind}_${id}`;
|
||||
}
|
||||
|
||||
function formPost(values: Record<string, string>): RequestInit {
|
||||
return {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(values),
|
||||
};
|
||||
}
|
||||
|
||||
function pageOf<T>(items: T[], nextCursor: string | null | undefined): Page<T> {
|
||||
return nextCursor === null || nextCursor === undefined
|
||||
? { items }
|
||||
: { items, nextCursor };
|
||||
}
|
||||
|
||||
function normalizeAvatar(url: string | undefined): string | undefined {
|
||||
return url?.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function postText(post: RedditPost): string {
|
||||
const body = post.selftext?.trim();
|
||||
return body === undefined || body.length === 0 ? post.title : `${post.title}\n\n${body}`;
|
||||
}
|
||||
|
||||
function titleAndBody(post: NormalizedPost): { title: string; text: string } {
|
||||
const explicitTitle = stringMetadata(post.providerMetadata, "title");
|
||||
if (explicitTitle !== undefined) return { title: explicitTitle, text: post.text };
|
||||
|
||||
const normalized = post.text.trim();
|
||||
const newline = normalized.indexOf("\n");
|
||||
if (newline === -1) return { title: normalized, text: "" };
|
||||
return {
|
||||
title: normalized.slice(0, newline).trim(),
|
||||
text: normalized.slice(newline + 1).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRedditConnector(options: RedditConnectorOptions = {}): RedditConnector {
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: REDDIT_RATE_POLICY.requestsPerSecond,
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
|
||||
const manifest: ConnectorManifest = {
|
||||
id: PROVIDER,
|
||||
displayName: "Reddit",
|
||||
capabilities: CAPABILITIES,
|
||||
connectionFields: [
|
||||
{
|
||||
name: "accessToken",
|
||||
label: "OAuth access token",
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "refreshToken",
|
||||
label: "OAuth refresh token",
|
||||
required: true,
|
||||
secret: true,
|
||||
},
|
||||
{
|
||||
name: "username",
|
||||
label: "Reddit username",
|
||||
required: true,
|
||||
secret: false,
|
||||
placeholder: "your_username",
|
||||
},
|
||||
],
|
||||
oauth: {
|
||||
kind: "token",
|
||||
scopes: ["identity", "submit", "read", "privatemessages", "edit"],
|
||||
},
|
||||
docsUrl: "https://github.com/reddit-archive/reddit/wiki/OAuth2",
|
||||
};
|
||||
|
||||
function accessToken(connection: ConnectorConnection): string {
|
||||
const token = connection.fields["accessToken"];
|
||||
if (token === undefined || token.trim().length === 0) {
|
||||
throw new AuthError(
|
||||
`${PROVIDER}: missing OAuth access token in the accessToken connection field`,
|
||||
PROVIDER,
|
||||
false,
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function send(
|
||||
connection: ConnectorConnection,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const response = await client.request(PROVIDER, `${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${accessToken(connection)}`,
|
||||
"user-agent": "AnarchoPoster/0.1 (Reddit connector)",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const message = `${PROVIDER}: HTTP ${response.status} for ${new URL(response.url || `${API_BASE}${path}`).pathname}`;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AuthError(message, PROVIDER, false);
|
||||
}
|
||||
throw new ConnectorError(message, PROVIDER, { status: response.status });
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
async function api<T>(
|
||||
connection: ConnectorConnection,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const response = await send(connection, path, init);
|
||||
try {
|
||||
return (await response.json()) as T;
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(`${PROVIDER}: non-JSON response for ${path}`, PROVIDER, cause);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(
|
||||
connection: ConnectorConnection,
|
||||
values: Record<string, string>,
|
||||
): Promise<SubmitResult> {
|
||||
const response = await api<RedditJsonResponse>(
|
||||
connection,
|
||||
"/api/submit",
|
||||
formPost({ api_type: "json", resubmit: "true", ...values }),
|
||||
);
|
||||
const errors = response.json.errors ?? [];
|
||||
if (errors.length > 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: submit rejected: ${JSON.stringify(errors)}`, PROVIDER);
|
||||
}
|
||||
const data = response.json.data;
|
||||
const id = data?.id ?? (data?.name === undefined ? undefined : stripPrefix(data.name, "t3_"));
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: submit response did not include a post id`, PROVIDER);
|
||||
}
|
||||
return { id, url: data?.url };
|
||||
}
|
||||
|
||||
function publication(
|
||||
connection: ConnectorConnection,
|
||||
result: SubmitResult,
|
||||
draftId: string,
|
||||
providerMetadata: Record<string, unknown>,
|
||||
): Publication {
|
||||
return {
|
||||
id: randomUUID(),
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId,
|
||||
connectionId: connection.id,
|
||||
remoteId: result.id,
|
||||
remoteUrl: result.url,
|
||||
publishedAt: new Date(),
|
||||
providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
manifest: () => {
|
||||
assertValidManifest(manifest);
|
||||
return manifest;
|
||||
},
|
||||
|
||||
async authorize() {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: register an app at reddit.com/prefs/apps, exchange the authorization code at /api/v1/access_token, then connect the access and refresh tokens`,
|
||||
PROVIDER,
|
||||
);
|
||||
},
|
||||
|
||||
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
const me = await api<RedditMe>(connection, "/api/v1/me");
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
remoteId: me.id ?? me.name,
|
||||
handle: `u/${me.name}`,
|
||||
displayName: me.name,
|
||||
avatarUrl: normalizeAvatar(me.icon_img),
|
||||
};
|
||||
},
|
||||
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
if (post.mediaAssetIds.length > 0) {
|
||||
throw new CapabilityNotSupportedError(PROVIDER, "publishImages");
|
||||
}
|
||||
const srValue = stringMetadata(post.providerMetadata, "sr");
|
||||
if (srValue === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: providerMetadata.sr is required`, PROVIDER);
|
||||
}
|
||||
const sr = subredditName(srValue);
|
||||
const kind = stringMetadata(post.providerMetadata, "kind") ?? "self";
|
||||
if (kind !== "self" && kind !== "link") {
|
||||
throw new ConnectorError(`${PROVIDER}: providerMetadata.kind must be self or link`, PROVIDER);
|
||||
}
|
||||
const { title, text } = titleAndBody(post);
|
||||
if (title.length === 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: post title is required`, PROVIDER);
|
||||
}
|
||||
if (title.length > TITLE_LIMIT) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: title is ${title.length} characters, over the ${TITLE_LIMIT} limit`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
|
||||
const values: Record<string, string> = { sr, kind, title };
|
||||
if (kind === "self") {
|
||||
values["text"] = text;
|
||||
} else {
|
||||
const url = stringMetadata(post.providerMetadata, "url");
|
||||
if (url === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: providerMetadata.url is required for link posts`, PROVIDER);
|
||||
}
|
||||
values["url"] = url;
|
||||
}
|
||||
const result = await submit(connection, values);
|
||||
return publication(connection, result, "direct", {
|
||||
...post.providerMetadata,
|
||||
sr,
|
||||
kind,
|
||||
});
|
||||
},
|
||||
|
||||
async amplify(
|
||||
connection: ConnectorConnection,
|
||||
request: RedditAmplifyRequest,
|
||||
): Promise<Publication> {
|
||||
if (request.kind !== "share") {
|
||||
throw new CapabilityNotSupportedError(PROVIDER, `amplify:${request.kind}`);
|
||||
}
|
||||
const srValue = request.providerMetadata === undefined
|
||||
? undefined
|
||||
: stringMetadata(request.providerMetadata, "sr");
|
||||
if (srValue === undefined) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: providerMetadata.sr is required for a crosspost`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const sr = subredditName(srValue);
|
||||
const originalId = stripPrefix(request.targetRemoteId, "t3_");
|
||||
|
||||
// Manipulation-policy boundary: amplification creates a user-requested
|
||||
// crosspost. It never calls /api/vote or performs automated voting;
|
||||
// voting belongs only to an explicit, single user click in a manual UI.
|
||||
const result = await submit(connection, {
|
||||
sr,
|
||||
kind: "crosspost",
|
||||
crosspost_fullname: fullname(originalId, "t3"),
|
||||
...(request.commentary === undefined || request.commentary.length === 0
|
||||
? {}
|
||||
: { title: request.commentary.slice(0, TITLE_LIMIT) }),
|
||||
});
|
||||
return publication(connection, result, "amplify:share", {
|
||||
sr,
|
||||
kind: "crosspost",
|
||||
amplifies: originalId,
|
||||
});
|
||||
},
|
||||
|
||||
async getFeed(
|
||||
connection: ConnectorConnection,
|
||||
request: { kind: "home" | "mentions" | "search"; query?: string; cursor?: string; limit: number },
|
||||
): Promise<Page<FeedItem>> {
|
||||
if (request.kind === "mentions") {
|
||||
throw new CapabilityNotSupportedError(PROVIDER, "feed:mentions");
|
||||
}
|
||||
const srValue = request.query ?? connection.fields["subreddit"];
|
||||
if (srValue === undefined || srValue.trim().length === 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: feed query must name a subreddit`, PROVIDER);
|
||||
}
|
||||
const sr = subredditName(srValue);
|
||||
const limit = Math.max(1, Math.min(DEFAULT_FEED_LIMIT, request.limit));
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (request.cursor !== undefined) params.set("after", request.cursor);
|
||||
const listing = await api<RedditListing<RedditPost>>(
|
||||
connection,
|
||||
`/r/${encodeURIComponent(sr)}/hot?${params}`,
|
||||
);
|
||||
const items = listing.data.children.map<FeedItem>(({ data }) => ({
|
||||
id: data.name ?? `t3_${data.id}`,
|
||||
provider: PROVIDER,
|
||||
remoteId: data.id,
|
||||
authorHandle: `u/${data.author}`,
|
||||
text: postText(data),
|
||||
mediaUrls: (data.selftext ?? "").trim().length === 0 && data.url !== undefined ? [data.url] : [],
|
||||
createdAt: new Date(data.created_utc * 1000),
|
||||
metrics: { score: data.score, comments: data.num_comments },
|
||||
}));
|
||||
return pageOf(items, listing.data.after);
|
||||
},
|
||||
|
||||
async getComments(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; cursor?: string; limit: number },
|
||||
): Promise<Page<Comment>> {
|
||||
const id = stripPrefix(request.onRemoteId, "t3_");
|
||||
const limit = Math.max(1, Math.min(100, request.limit));
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (request.cursor !== undefined) params.set("after", request.cursor);
|
||||
const listings = await api<RedditListing<RedditPost | RedditComment>[]>(
|
||||
connection,
|
||||
`/comments/${encodeURIComponent(id)}.json?${params}`,
|
||||
);
|
||||
const comments = listings[1];
|
||||
if (comments === undefined) return { items: [] };
|
||||
const items = comments.data.children.flatMap<Comment>(({ kind, data }) => {
|
||||
if (kind !== "t1" || !("body" in data)) return [];
|
||||
return [{
|
||||
id: data.name ?? `t1_${data.id}`,
|
||||
provider: PROVIDER,
|
||||
remoteId: data.id,
|
||||
onRemoteId: data.parent_id ?? fullname(id, "t3"),
|
||||
authorHandle: `u/${data.author}`,
|
||||
text: data.body,
|
||||
createdAt: new Date(data.created_utc * 1000),
|
||||
}];
|
||||
});
|
||||
return pageOf(items, comments.data.after);
|
||||
},
|
||||
|
||||
async reply(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; text: string },
|
||||
): Promise<Publication> {
|
||||
const response = await api<RedditJsonResponse>(
|
||||
connection,
|
||||
"/api/comment",
|
||||
formPost({
|
||||
api_type: "json",
|
||||
thing_id: fullname(request.onRemoteId, "t3"),
|
||||
text: request.text,
|
||||
}),
|
||||
);
|
||||
const errors = response.json.errors ?? [];
|
||||
if (errors.length > 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: comment rejected: ${JSON.stringify(errors)}`, PROVIDER);
|
||||
}
|
||||
const responseData = response.json.data;
|
||||
const data = responseData?.things?.[0]?.data ?? responseData;
|
||||
const id = data?.id ?? (data?.name === undefined ? undefined : stripPrefix(data.name, "t1_"));
|
||||
if (id === undefined || id.length === 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: comment response did not include an id`, PROVIDER);
|
||||
}
|
||||
return publication(connection, { id, url: data?.url }, "reply", {
|
||||
onRemoteId: request.onRemoteId,
|
||||
});
|
||||
},
|
||||
|
||||
async getMessages(
|
||||
connection: ConnectorConnection,
|
||||
request: { conversationRemoteId?: string; cursor?: string; limit: number },
|
||||
): Promise<Page<DirectMessage>> {
|
||||
const limit = Math.max(1, Math.min(100, request.limit));
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (request.cursor !== undefined) params.set("after", request.cursor);
|
||||
const listing = await api<RedditListing<RedditMessage>>(
|
||||
connection,
|
||||
`/api/v1/message/inbox?${params}`,
|
||||
);
|
||||
const messages = listing.data.children
|
||||
.map(({ data }) => data)
|
||||
.filter((message) => {
|
||||
if (request.conversationRemoteId === undefined) return true;
|
||||
const conversationId = message.first_message_name ?? message.parent_id ?? message.name ?? message.id;
|
||||
return conversationId === request.conversationRemoteId;
|
||||
})
|
||||
.map<DirectMessage>((message) => ({
|
||||
id: message.name ?? `t4_${message.id}`,
|
||||
provider: PROVIDER,
|
||||
conversationRemoteId: message.first_message_name ?? message.parent_id ?? message.name ?? message.id,
|
||||
fromHandle: message.author === undefined ? "u/[deleted]" : `u/${message.author}`,
|
||||
toHandle: `u/${message.dest ?? connection.fields["username"] ?? "unknown"}`,
|
||||
text: message.body,
|
||||
sentAt: new Date(message.created_utc * 1000),
|
||||
}));
|
||||
return pageOf(messages, listing.data.after);
|
||||
},
|
||||
|
||||
async deletePublication(connection: ConnectorConnection, remoteId: string): Promise<void> {
|
||||
// Successful /api/del replies may have an empty body, so deletion only
|
||||
// relies on the HTTP status rather than attempting to decode JSON.
|
||||
await send(
|
||||
connection,
|
||||
"/api/del",
|
||||
formPost({ id: fullname(remoteId, "t3") }),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue