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/.
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
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(),
|
|
};
|
|
},
|
|
};
|
|
}
|