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
22
packages/connector-discord/package.json
Normal file
22
packages/connector-discord/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
packages/connector-discord/src/contract.test.ts
Normal file
34
packages/connector-discord/src/contract.test.ts
Normal 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 });
|
||||
369
packages/connector-discord/src/index.ts
Normal file
369
packages/connector-discord/src/index.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
8
packages/connector-discord/tsconfig.json
Normal file
8
packages/connector-discord/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue