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
36
packages/connector-slack/src/contract.test.ts
Normal file
36
packages/connector-slack/src/contract.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
|
||||
import { connectorContractTests } from "@tactical-media-broadcast-hub/connector-sdk/testing";
|
||||
import { createSlackConnector } from "./index.js";
|
||||
|
||||
const connection: ConnectorConnection = {
|
||||
id: "conn-slack",
|
||||
workspaceId: "ws-1",
|
||||
provider: "slack",
|
||||
channelId: "C0123456789",
|
||||
credentialId: { kind: "credential-ref", id: "cred-slack" },
|
||||
fields: {
|
||||
botToken: "xoxb-test-token",
|
||||
teamId: "T0123456789",
|
||||
},
|
||||
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://slack.com/api/auth.test") {
|
||||
if (new Headers(init?.headers).get("authorization") !== "Bearer xoxb-test-token") {
|
||||
return new Response(JSON.stringify({ ok: false, error: "invalid_auth" }));
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
ok: true,
|
||||
team: "AnarchoPoster",
|
||||
team_id: "T0123456789",
|
||||
user: "anarchoposter",
|
||||
user_id: "U0123456789",
|
||||
bot_id: "B0123456789",
|
||||
}));
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: false, error: `unexpected request: ${url}` }));
|
||||
};
|
||||
|
||||
connectorContractTests(createSlackConnector({ fetchImpl }), { connection });
|
||||
446
packages/connector-slack/src/index.ts
Normal file
446
packages/connector-slack/src/index.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import type {
|
||||
Comment,
|
||||
ConnectorCapabilities,
|
||||
ConnectorConnection,
|
||||
FeedItem,
|
||||
NormalizedIdentity,
|
||||
NormalizedPost,
|
||||
NormalizedWebhookEvent,
|
||||
Page,
|
||||
Publication,
|
||||
} from "@tactical-media-broadcast-hub/core";
|
||||
import {
|
||||
assertValidManifest,
|
||||
AuthError,
|
||||
CapabilityNotSupportedError,
|
||||
ConnectorError,
|
||||
RateLimitError,
|
||||
RateLimitedHttpClient,
|
||||
type ConnectorManifest,
|
||||
type PlatformConnector,
|
||||
} from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
|
||||
const PROVIDER = "slack";
|
||||
const API_BASE = "https://slack.com/api/";
|
||||
const SIGNATURE_VERSION = "v0";
|
||||
const MAX_WEBHOOK_AGE_SECONDS = 60 * 5;
|
||||
|
||||
const CAPABILITIES: ConnectorCapabilities = {
|
||||
publishText: true,
|
||||
feed: true,
|
||||
comments: true,
|
||||
replies: true,
|
||||
directMessages: false,
|
||||
amplifyRepost: false,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
export interface SlackConnectorOptions {
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
interface SlackEnvelope {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
needed?: string;
|
||||
provided?: string;
|
||||
}
|
||||
|
||||
interface SlackIdentity extends SlackEnvelope {
|
||||
team?: string;
|
||||
team_id?: string;
|
||||
user?: string;
|
||||
user_id?: string;
|
||||
bot_id?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface SlackMessage {
|
||||
type?: string;
|
||||
ts: string;
|
||||
text?: string;
|
||||
user?: string;
|
||||
bot_id?: string;
|
||||
thread_ts?: string;
|
||||
reply_count?: number;
|
||||
reactions?: { count: number }[];
|
||||
}
|
||||
|
||||
interface SlackPostMessage extends SlackEnvelope {
|
||||
channel?: string;
|
||||
ts?: string;
|
||||
message?: SlackMessage;
|
||||
}
|
||||
|
||||
interface SlackHistory extends SlackEnvelope {
|
||||
messages?: SlackMessage[];
|
||||
response_metadata?: { next_cursor?: string };
|
||||
}
|
||||
|
||||
function headerValue(headers: Record<string, string>, name: string): string | undefined {
|
||||
return Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
||||
}
|
||||
|
||||
function timestampDate(timestamp: string): Date {
|
||||
const seconds = Number(timestamp);
|
||||
return Number.isFinite(seconds) ? new Date(seconds * 1000) : new Date(0);
|
||||
}
|
||||
|
||||
function parseObject(raw: string): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(`${PROVIDER}: webhook body is not JSON`, PROVIDER, cause);
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new ConnectorError(`${PROVIDER}: malformed webhook payload`, PROVIDER);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function createSlackConnector(options: SlackConnectorOptions = {}): PlatformConnector {
|
||||
const client = new RateLimitedHttpClient({
|
||||
requestsPerSecond: 1,
|
||||
fetchImpl: options.fetchImpl,
|
||||
});
|
||||
const signingSecrets = new Map<string, string>();
|
||||
let publicationSequence = 0;
|
||||
|
||||
const manifest: ConnectorManifest = {
|
||||
id: PROVIDER,
|
||||
displayName: "Slack",
|
||||
capabilities: CAPABILITIES,
|
||||
connectionFields: [
|
||||
{
|
||||
name: "botToken",
|
||||
label: "Bot token",
|
||||
required: true,
|
||||
secret: true,
|
||||
pattern: "^xoxb-",
|
||||
placeholder: "xoxb-…",
|
||||
},
|
||||
{
|
||||
name: "teamId",
|
||||
label: "Workspace ID",
|
||||
required: true,
|
||||
secret: false,
|
||||
pattern: "^[A-Z0-9]+$",
|
||||
},
|
||||
],
|
||||
oauth: { kind: "token" },
|
||||
webhookRoute: "/webhooks/slack",
|
||||
docsUrl: "https://api.slack.com/",
|
||||
};
|
||||
|
||||
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 teamOf(connection: ConnectorConnection): string {
|
||||
const teamId = connection.fields["teamId"];
|
||||
if (teamId === undefined || teamId.trim().length === 0) {
|
||||
throw new ConnectorError(`${PROVIDER}: missing teamId connection field`, PROVIDER);
|
||||
}
|
||||
return teamId;
|
||||
}
|
||||
|
||||
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 extends SlackEnvelope>(
|
||||
connection: ConnectorConnection,
|
||||
method: string,
|
||||
init: RequestInit = {},
|
||||
query?: URLSearchParams,
|
||||
): Promise<T> {
|
||||
const suffix = query === undefined ? "" : `?${query}`;
|
||||
const response = await client.request(PROVIDER, `${API_BASE}${method}${suffix}`, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${tokenOf(connection)}`,
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
let body: T;
|
||||
try {
|
||||
body = (await response.json()) as T;
|
||||
} catch (cause) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: non-JSON response from ${method} (HTTP ${response.status})`,
|
||||
PROVIDER,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ConnectorError(`${PROVIDER}: HTTP ${response.status} from ${method}`, PROVIDER);
|
||||
}
|
||||
if (!body.ok) {
|
||||
const detail = body.error ?? "unknown Slack API error";
|
||||
if (["invalid_auth", "not_authed", "token_revoked", "account_inactive"].includes(detail)) {
|
||||
throw new AuthError(`${PROVIDER}: ${detail}`, PROVIDER, false);
|
||||
}
|
||||
if (detail === "ratelimited") {
|
||||
const retryAfter = Number(response.headers.get("retry-after"));
|
||||
throw new RateLimitError(
|
||||
PROVIDER,
|
||||
Number.isFinite(retryAfter) ? retryAfter * 1000 : undefined,
|
||||
);
|
||||
}
|
||||
throw new ConnectorError(`${PROVIDER}: ${detail}`, PROVIDER);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function jsonPost(body: Record<string, unknown>): RequestInit {
|
||||
return {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
function publication(
|
||||
connection: ConnectorConnection,
|
||||
timestamp: string,
|
||||
draftId: string,
|
||||
metadata: Record<string, unknown>,
|
||||
): Publication {
|
||||
return {
|
||||
id: `slack-${(publicationSequence += 1)}-${timestamp}`,
|
||||
workspaceId: connection.workspaceId,
|
||||
draftId,
|
||||
connectionId: connection.id,
|
||||
remoteId: timestamp,
|
||||
publishedAt: timestampDate(timestamp),
|
||||
providerMetadata: metadata,
|
||||
};
|
||||
}
|
||||
|
||||
function feedItem(message: SlackMessage): FeedItem {
|
||||
return {
|
||||
id: message.ts,
|
||||
provider: PROVIDER,
|
||||
remoteId: message.ts,
|
||||
authorHandle: message.user ?? message.bot_id ?? "unknown",
|
||||
text: message.text ?? "",
|
||||
mediaUrls: [],
|
||||
createdAt: timestampDate(message.ts),
|
||||
metrics: {
|
||||
replies: message.reply_count ?? 0,
|
||||
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 — install a Slack app and connect its xoxb token`,
|
||||
PROVIDER,
|
||||
);
|
||||
},
|
||||
|
||||
async getIdentity(connection: ConnectorConnection): Promise<NormalizedIdentity> {
|
||||
const identity = await api<SlackIdentity>(connection, "auth.test");
|
||||
const configuredTeam = teamOf(connection);
|
||||
if (identity.team_id !== undefined && identity.team_id !== configuredTeam) {
|
||||
throw new AuthError(
|
||||
`${PROVIDER}: token belongs to team ${identity.team_id}, not ${configuredTeam}`,
|
||||
PROVIDER,
|
||||
false,
|
||||
);
|
||||
}
|
||||
const remoteId = identity.bot_id ?? identity.user_id;
|
||||
if (remoteId === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: auth.test returned no bot or user id`, PROVIDER);
|
||||
}
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
remoteId,
|
||||
handle: identity.user ?? remoteId,
|
||||
displayName: identity.team,
|
||||
};
|
||||
},
|
||||
|
||||
async publish(connection: ConnectorConnection, post: NormalizedPost): Promise<Publication> {
|
||||
const channel = channelOf(connection);
|
||||
const blocks = Array.isArray(post.providerMetadata["blocks"])
|
||||
? post.providerMetadata["blocks"]
|
||||
: [];
|
||||
const result = await api<SlackPostMessage>(
|
||||
connection,
|
||||
"chat.postMessage",
|
||||
jsonPost({ channel, text: post.text, blocks }),
|
||||
);
|
||||
const timestamp = result.ts ?? result.message?.ts;
|
||||
if (timestamp === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: chat.postMessage returned no timestamp`, PROVIDER);
|
||||
}
|
||||
return publication(connection, timestamp, "direct", {
|
||||
channel: result.channel ?? channel,
|
||||
...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 query = new URLSearchParams({
|
||||
channel: channelOf(connection),
|
||||
limit: String(Math.max(1, Math.min(100, request.limit))),
|
||||
});
|
||||
if (request.cursor !== undefined) query.set("cursor", request.cursor);
|
||||
const result = await api<SlackHistory>(connection, "conversations.history", {}, query);
|
||||
const cursor = result.response_metadata?.next_cursor;
|
||||
return {
|
||||
items: (result.messages ?? []).map(feedItem),
|
||||
nextCursor: cursor === undefined || cursor.length === 0 ? undefined : cursor,
|
||||
};
|
||||
},
|
||||
|
||||
async getComments(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; cursor?: string; limit: number },
|
||||
): Promise<Page<Comment>> {
|
||||
const query = new URLSearchParams({
|
||||
channel: channelOf(connection),
|
||||
ts: request.onRemoteId,
|
||||
limit: String(Math.max(1, Math.min(100, request.limit))),
|
||||
});
|
||||
if (request.cursor !== undefined) query.set("cursor", request.cursor);
|
||||
const result = await api<SlackHistory>(connection, "conversations.replies", {}, query);
|
||||
const messages = (result.messages ?? []).filter((message) => message.ts !== request.onRemoteId);
|
||||
const cursor = result.response_metadata?.next_cursor;
|
||||
return {
|
||||
items: messages.map((message) => ({
|
||||
id: message.ts,
|
||||
provider: PROVIDER,
|
||||
remoteId: message.ts,
|
||||
onRemoteId: request.onRemoteId,
|
||||
authorHandle: message.user ?? message.bot_id ?? "unknown",
|
||||
text: message.text ?? "",
|
||||
createdAt: timestampDate(message.ts),
|
||||
})),
|
||||
nextCursor: cursor === undefined || cursor.length === 0 ? undefined : cursor,
|
||||
};
|
||||
},
|
||||
|
||||
async reply(
|
||||
connection: ConnectorConnection,
|
||||
request: { onRemoteId: string; text: string },
|
||||
): Promise<Publication> {
|
||||
const channel = channelOf(connection);
|
||||
const result = await api<SlackPostMessage>(
|
||||
connection,
|
||||
"chat.postMessage",
|
||||
jsonPost({ channel, text: request.text, thread_ts: request.onRemoteId }),
|
||||
);
|
||||
const timestamp = result.ts ?? result.message?.ts;
|
||||
if (timestamp === undefined) {
|
||||
throw new ConnectorError(`${PROVIDER}: threaded chat.postMessage returned no timestamp`, PROVIDER);
|
||||
}
|
||||
return publication(connection, timestamp, "reply", {
|
||||
channel: result.channel ?? channel,
|
||||
threadTs: request.onRemoteId,
|
||||
});
|
||||
},
|
||||
|
||||
async subscribeWebhooks(
|
||||
connection: ConnectorConnection,
|
||||
request: { topics: string[] },
|
||||
): Promise<{ subscriptionId: string }> {
|
||||
const teamId = teamOf(connection);
|
||||
const signingSecret = connection.fields["signingSecret"] ?? process.env["SLACK_SIGNING_SECRET"];
|
||||
if (signingSecret === undefined || signingSecret.length === 0) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: a signingSecret field or SLACK_SIGNING_SECRET is required for Events API verification`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
signingSecrets.set(teamId, signingSecret);
|
||||
const topics = request.topics.length === 0 ? "events" : [...request.topics].sort().join(",");
|
||||
return { subscriptionId: `slack-events:${teamId}:${topics}` };
|
||||
},
|
||||
|
||||
async handleWebhook(request: {
|
||||
headers: Record<string, string>;
|
||||
body: unknown;
|
||||
}): Promise<NormalizedWebhookEvent> {
|
||||
if (typeof request.body !== "string" && !(request.body instanceof Uint8Array)) {
|
||||
throw new ConnectorError(
|
||||
`${PROVIDER}: handleWebhook needs the raw body (string or bytes)`,
|
||||
PROVIDER,
|
||||
);
|
||||
}
|
||||
const raw = typeof request.body === "string"
|
||||
? request.body
|
||||
: Buffer.from(request.body).toString("utf8");
|
||||
const timestamp = headerValue(request.headers, "x-slack-request-timestamp");
|
||||
const signature = headerValue(request.headers, "x-slack-signature");
|
||||
if (timestamp === undefined || signature === undefined) {
|
||||
throw new AuthError(`${PROVIDER}: missing webhook signature headers`, PROVIDER, false);
|
||||
}
|
||||
const timestampSeconds = Number(timestamp);
|
||||
if (
|
||||
!Number.isFinite(timestampSeconds)
|
||||
|| Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds) > MAX_WEBHOOK_AGE_SECONDS
|
||||
) {
|
||||
throw new AuthError(`${PROVIDER}: stale webhook timestamp`, PROVIDER, false);
|
||||
}
|
||||
const payload = parseObject(raw);
|
||||
const teamId = typeof payload["team_id"] === "string"
|
||||
? payload["team_id"]
|
||||
: typeof payload["team"] === "string"
|
||||
? payload["team"]
|
||||
: undefined;
|
||||
const candidateSecrets = teamId === undefined
|
||||
? [...signingSecrets.values()]
|
||||
: [signingSecrets.get(teamId)].filter((secret): secret is string => secret !== undefined);
|
||||
const supplied = Buffer.from(signature);
|
||||
const verified = candidateSecrets.some((secret) => {
|
||||
const digest = createHmac("sha256", secret)
|
||||
.update(`${SIGNATURE_VERSION}:${timestamp}:${raw}`)
|
||||
.digest("hex");
|
||||
const expected = Buffer.from(`${SIGNATURE_VERSION}=${digest}`);
|
||||
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
||||
});
|
||||
if (!verified) {
|
||||
throw new AuthError(`${PROVIDER}: invalid webhook signature`, PROVIDER, false);
|
||||
}
|
||||
const outerType = typeof payload["type"] === "string" ? payload["type"] : "event";
|
||||
const innerEvent = payload["event"];
|
||||
const innerType = typeof innerEvent === "object" && innerEvent !== null
|
||||
&& typeof (innerEvent as Record<string, unknown>)["type"] === "string"
|
||||
? String((innerEvent as Record<string, unknown>)["type"])
|
||||
: undefined;
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
eventType: outerType === "event_callback" && innerType !== undefined ? innerType : outerType,
|
||||
connectionKey: teamId,
|
||||
payload,
|
||||
receivedAt: new Date(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue