T-HUB/packages/connector-slack/src/index.ts
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

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(),
};
},
};
}