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,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&amp;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"]);
});
});

View 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(/&amp;/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") }),
);
},
};
}