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-discord",
"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,34 @@
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
import { createDiscordConnector } from "./index.js";
const connection: ConnectorConnection = {
id: "conn-discord",
workspaceId: "ws-1",
provider: "discord",
channelId: "120000000000000002",
credentialId: { kind: "credential-ref", id: "cred-discord" },
fields: {
botToken: "discord-test-token",
guildId: "120000000000000001",
},
createdAt: new Date(),
};
const fetchImpl: typeof fetch = async (input, init): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url === "https://discord.com/api/v10/users/@me") {
if (new Headers(init?.headers).get("authorization") !== "Bot discord-test-token") {
return new Response(JSON.stringify({ message: "401: Unauthorized" }), { status: 401 });
}
return new Response(JSON.stringify({
id: "120000000000000003",
username: "anarchoposter",
global_name: "AnarchoPoster",
avatar: null,
}));
}
return new Response(JSON.stringify({ message: `unexpected request: ${url}` }), { status: 404 });
};
connectorContractTests(createDiscordConnector({ fetchImpl }), { connection });

View file

@ -0,0 +1,369 @@
import type {
Comment,
ConnectorCapabilities,
ConnectorConnection,
FeedItem,
NormalizedIdentity,
NormalizedPost,
NormalizedWebhookEvent,
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 = "discord";
const API_BASE = "https://discord.com/api/v10";
const CAPABILITIES: ConnectorCapabilities = {
publishText: true,
feed: true,
comments: true,
replies: true,
directMessages: false,
amplifyRepost: false,
webhooks: true,
};
export interface DiscordConnectorOptions {
fetchImpl?: typeof fetch;
}
interface DiscordUser {
id: string;
username: string;
global_name?: string | null;
avatar?: string | null;
}
interface DiscordMessage {
id: string;
channel_id: string;
content: string;
timestamp: string;
author: DiscordUser;
attachments?: { url: string }[];
reactions?: { count: number }[];
message_reference?: { message_id?: string };
}
interface DiscordWebhook {
id: string;
token?: string;
url?: string;
}
interface DiscordApiError {
code?: number;
message?: string;
}
function headerValue(headers: Record<string, string>, name: string): string | undefined {
return Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
}
function recordBody(body: unknown): Record<string, unknown> {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new ConnectorError(`${PROVIDER}: malformed webhook payload`, PROVIDER);
}
return body as Record<string, unknown>;
}
export function createDiscordConnector(options: DiscordConnectorOptions = {}): PlatformConnector {
const client = new RateLimitedHttpClient({
requestsPerSecond: 5,
fetchImpl: options.fetchImpl,
});
const subscribedWebhookIds = new Set<string>();
let publicationSequence = 0;
const manifest: ConnectorManifest = {
id: PROVIDER,
displayName: "Discord",
capabilities: CAPABILITIES,
connectionFields: [
{
name: "botToken",
label: "Bot token",
required: true,
secret: true,
placeholder: "Discord bot token",
},
{
name: "guildId",
label: "Server ID",
required: true,
secret: false,
pattern: "^\\d{16,22}$",
},
],
oauth: { kind: "token" },
webhookRoute: "/webhooks/discord",
docsUrl: "https://discord.com/developers/docs/intro",
};
function tokenOf(connection: ConnectorConnection): string {
const token = connection.fields["botToken"];
if (token === undefined || token.trim().length === 0) {
throw new AuthError(`${PROVIDER}: missing botToken connection field`, PROVIDER, false);
}
return token;
}
function guildOf(connection: ConnectorConnection): string {
const guildId = connection.fields["guildId"];
if (guildId === undefined || guildId.trim().length === 0) {
throw new ConnectorError(`${PROVIDER}: missing guildId connection field`, PROVIDER);
}
return guildId;
}
function channelOf(connection: ConnectorConnection): string {
const channelId = connection.channelId || connection.fields["channelId"];
if (channelId === undefined || channelId.trim().length === 0) {
throw new ConnectorError(`${PROVIDER}: connection has no channelId`, PROVIDER);
}
return channelId;
}
async function api<T>(
connection: ConnectorConnection,
path: string,
init: RequestInit = {},
): Promise<T> {
const response = await client.request(PROVIDER, `${API_BASE}${path}`, {
...init,
headers: {
accept: "application/json",
authorization: `Bot ${tokenOf(connection)}`,
...init.headers,
},
});
let body: unknown;
try {
body = await response.json();
} catch (cause) {
throw new ConnectorError(
`${PROVIDER}: non-JSON response for ${path} (HTTP ${response.status})`,
PROVIDER,
cause,
);
}
if (!response.ok) {
const error = body as DiscordApiError;
const detail = error.message ?? `HTTP ${response.status}`;
if (response.status === 401 || response.status === 403) {
throw new AuthError(`${PROVIDER}: ${detail}`, PROVIDER, response.status === 403);
}
throw new ConnectorError(`${PROVIDER}: ${detail}`, PROVIDER);
}
return body as T;
}
function jsonPost(body: Record<string, unknown>): RequestInit {
return {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
};
}
function publication(
connection: ConnectorConnection,
message: DiscordMessage,
draftId: string,
metadata: Record<string, unknown>,
): Publication {
return {
id: `discord-${(publicationSequence += 1)}-${message.id}`,
workspaceId: connection.workspaceId,
draftId,
connectionId: connection.id,
remoteId: message.id,
remoteUrl: `https://discord.com/channels/${guildOf(connection)}/${message.channel_id}/${message.id}`,
publishedAt: new Date(message.timestamp),
providerMetadata: metadata,
};
}
function feedItem(message: DiscordMessage): FeedItem {
return {
id: message.id,
provider: PROVIDER,
remoteId: message.id,
authorHandle: message.author.username,
text: message.content,
mediaUrls: (message.attachments ?? []).map((attachment) => attachment.url),
createdAt: new Date(message.timestamp),
metrics: {
reactions: (message.reactions ?? []).reduce((total, reaction) => total + reaction.count, 0),
},
};
}
return {
manifest: () => {
assertValidManifest(manifest);
return manifest;
},
async authorize() {
throw new ConnectorError(
`${PROVIDER}: bot-token auth only — create an application at discord.com/developers/applications`,
PROVIDER,
);
},
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
const user = await api<DiscordUser>(connection, "/users/@me");
return {
provider: PROVIDER,
remoteId: user.id,
handle: user.username,
displayName: user.global_name ?? user.username,
avatarUrl:
user.avatar === undefined || user.avatar === null
? undefined
: `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`,
};
},
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
const channelId = channelOf(connection);
const embeds = Array.isArray(post.providerMetadata["embeds"])
? post.providerMetadata["embeds"]
: [];
const message = await api<DiscordMessage>(
connection,
`/channels/${encodeURIComponent(channelId)}/messages`,
jsonPost({ content: post.text, embeds }),
);
return publication(connection, message, "direct", {
channelId,
messageId: message.id,
...post.providerMetadata,
});
},
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}`);
}
const channelId = channelOf(connection);
const params = new URLSearchParams({ limit: String(Math.max(1, Math.min(100, request.limit))) });
if (request.cursor !== undefined) params.set("before", request.cursor);
const messages = await api<DiscordMessage[]>(
connection,
`/channels/${encodeURIComponent(channelId)}/messages?${params}`,
);
const last = messages.at(-1);
return {
items: messages.map(feedItem),
nextCursor: messages.length >= Number(params.get("limit")) ? last?.id : undefined,
};
},
async getComments(
connection: ConnectorConnection,
request: { onRemoteId: string; cursor?: string; limit: number },
): Promise<Page<Comment>> {
const channelId = channelOf(connection);
const limit = Math.max(1, Math.min(100, request.limit));
const params = new URLSearchParams({ limit: String(limit) });
if (request.cursor !== undefined) params.set("before", request.cursor);
const messages = await api<DiscordMessage[]>(
connection,
`/channels/${encodeURIComponent(channelId)}/messages?${params}`,
);
const replies = messages.filter(
(message) => message.message_reference?.message_id === request.onRemoteId,
);
return {
items: replies.map((message) => ({
id: message.id,
provider: PROVIDER,
remoteId: message.id,
onRemoteId: request.onRemoteId,
authorHandle: message.author.username,
text: message.content,
createdAt: new Date(message.timestamp),
})),
nextCursor: messages.length >= limit ? messages.at(-1)?.id : undefined,
};
},
async reply(
connection: ConnectorConnection,
request: { onRemoteId: string; text: string },
): Promise<Publication> {
const channelId = channelOf(connection);
const message = await api<DiscordMessage>(
connection,
`/channels/${encodeURIComponent(channelId)}/messages`,
jsonPost({
content: request.text,
message_reference: { message_id: request.onRemoteId },
}),
);
return publication(connection, message, "reply", {
channelId,
messageId: message.id,
onRemoteId: request.onRemoteId,
});
},
async subscribeWebhooks(
connection: ConnectorConnection,
request: { topics: string[] },
): Promise<{ subscriptionId: string }> {
const channelId = channelOf(connection);
const webhook = await api<DiscordWebhook>(
connection,
`/channels/${encodeURIComponent(channelId)}/webhooks`,
jsonPost({ name: `AnarchoPoster (${request.topics.join(", ") || "messages"})` }),
);
subscribedWebhookIds.add(webhook.id);
const url = webhook.url ?? (
webhook.token === undefined
? undefined
: `${API_BASE}/webhooks/${webhook.id}/${webhook.token}`
);
if (url === undefined) {
throw new ConnectorError(`${PROVIDER}: webhook response did not include a URL`, PROVIDER);
}
return { subscriptionId: url };
},
async handleWebhook(request: {
headers: Record<string, string>;
body: unknown;
}): Promise<NormalizedWebhookEvent> {
const payload = recordBody(request.body);
const bodyWebhookId = typeof payload["webhook_id"] === "string"
? payload["webhook_id"]
: undefined;
const webhookId = headerValue(request.headers, "x-discord-webhook-id") ?? bodyWebhookId;
if (webhookId === undefined || !subscribedWebhookIds.has(webhookId)) {
throw new AuthError(`${PROVIDER}: unknown webhook id`, PROVIDER, false);
}
const eventType = typeof payload["type"] === "string" ? payload["type"] : "message";
return {
provider: PROVIDER,
eventType,
connectionKey: webhookId,
payload,
receivedAt: new Date(),
};
},
};
}

View file

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