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, 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 { 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; } export function createSlackConnector(options: SlackConnectorOptions = {}): PlatformConnector { const client = new RateLimitedHttpClient({ requestsPerSecond: 1, fetchImpl: options.fetchImpl, }); const signingSecrets = new Map(); 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( connection: ConnectorConnection, method: string, init: RequestInit = {}, query?: URLSearchParams, ): Promise { 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): 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, ): 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 { const identity = await api(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 { const channel = channelOf(connection); const blocks = Array.isArray(post.providerMetadata["blocks"]) ? post.providerMetadata["blocks"] : []; const result = await api( 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> { 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(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> { 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(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 { const channel = channelOf(connection); const result = await api( 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; body: unknown; }): Promise { 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)["type"] === "string" ? String((innerEvent as Record)["type"]) : undefined; return { provider: PROVIDER, eventType: outerType === "event_callback" && innerType !== undefined ? innerType : outerType, connectionKey: teamId, payload, receivedAt: new Date(), }; }, }; }