import { createHmac, timingSafeEqual } from "node:crypto"; import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config"; import { OPENGENI_SLACK_BOT_CREDENTIAL_LABEL, OPENGENI_SLACK_BOT_CREDENTIAL_ROLE, OPENGENI_SLACK_BOT_REQUIRED_SCOPES, evaluateOpenGeniSlackBotScopes, hasOpenGeniSlackBotSearchScopes, type AccessGrant, type ConnectionMetadata, type OpenGeniSlackBotDisplayName, type OpenGeniSlackBotConnectionMetadata, } from "@opengeni/contracts"; import { isOpenGeniSlackBotConnection, isTrustedScheduledSlackBotSession, openGeniSlackBotMetadata, requireOpenGeniSlackBotConnection, scheduledSlackBotConnectionId, } from "@opengeni/core"; import { buildConnectionTokenResolver, claimSlackBotDeleteOperation, claimSlackBotPostOperation, claimSlackBotUpdateOperation, completeSlackBotDeleteOperation, completeSlackBotPostOperation, completeSlackBotUpdateOperation, getSession, listConnectionsMetadata, markSlackBotDeleteOperationProviderStarted, markSlackBotPostOperationProviderStarted, recordAuditEvent, releaseSlackBotDeleteOperationClaim, releaseSlackBotPostOperationClaim, releaseSlackBotUpdateOperationClaim, setConnectionStatus, type Database, } from "@opengeni/db"; import { readResponseBodyBounded, readResponseJsonBounded, type FetchLike, } from "@opengeni/network"; import { HTTPException } from "hono/http-exception"; const SLACK_API_BASE = "https://slack.com/api/"; const SLACK_RESPONSE_MAX_BYTES = 2 * 1024 * 1024; const SLACK_FILE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024; export const SLACK_REACTION_IMAGE_MAX_BYTES = 4 * 1024 * 1024; const SLACK_FILE_CONTENT_PAGE_CHARS = 50_000; const SLACK_TIMEOUT_MS = 10_000; const MAX_CHANNEL_PAGE = 200; const MAX_HISTORY_PAGE = 100; const MAX_THREAD_PAGE = 100; const MAX_REACTION_CONTEXT_MESSAGES = 15; const MAX_REACTION_CONTEXT_PAGES = 8; const MAX_REACTION_CONTEXT_SEEN_MESSAGES = MAX_REACTION_CONTEXT_MESSAGES * MAX_REACTION_CONTEXT_PAGES; // Leave headroom for PostgreSQL jsonb's canonical text spacing under the // database's independent 128 KiB CHECK constraint. const MAX_REACTION_CONTEXT_CHECKPOINT_BYTES = 120 * 1024; const MAX_REACTION_CONTEXT_CHECKPOINT_AGE_MS = 24 * 60 * 60_000; const MAX_REACTION_CONTEXT_CHECKPOINT_CLOCK_SKEW_MS = 5 * 60_000; const MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS = 1_500; const MAX_REACTION_CONTEXT_CHECKPOINT_FILES = 16; const SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION = 1; const MAX_USER_PAGE = 200; /** Slack caps `assistant.search.context` at 20 results per request. */ const MAX_SEARCH_PAGE = 20; /** Bounded projection cap for one search result's content excerpt. */ const MAX_SEARCH_CONTENT_CHARS = 2_000; /** Matches the MCP input schema's cap; Slack signals its own via query_too_long. */ const MAX_SEARCH_QUERY_CHARS = 500; const MAX_FILE_PAGE = 200; const MAX_FILE_CURSOR_LENGTH = 1_024; const SLACK_FILE_CURSOR_VERSION = "files-v1"; const MAX_PROJECTED_TEXT = 4_000; const SLACK_POST_CLAIM_LEASE_MS = 30_000; const MAX_SLACK_POST_RECONCILIATION_PAGES = 8; const SLACK_UPDATE_CLAIM_LEASE_MS = 30_000; const SLACK_DELETE_CLAIM_LEASE_MS = 30_000; const MAX_SLACK_BLOCKS = 50; const MAX_SLACK_BLOCK_BYTES = 32 * 1024; const SLACK_PRIVATE_FILE_HOSTS = new Set(["files.slack.com", "slack.com"]); const SLACK_REACTION_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); type SlackPayload = Record & { ok?: unknown; error?: unknown }; export type SlackFilesListPage = { count: number; page: number }; export type SlackFilesCursorContext = { connectionId: string; key: Uint8Array; }; export type VerifiedOpenGeniSlackBot = { grantedScopes: string[]; metadata: OpenGeniSlackBotConnectionMetadata; }; export type PreparedSlackReactionImage = Readonly<{ fileId: string; filename: string; declaredMimeType: string; declaredSizeBytes: number | null; downloadUrl: URL | null; }>; export type DownloadedSlackReactionImage = Readonly<{ fileId: string; filename: string; contentType: "image/png" | "image/jpeg" | "image/webp"; bytes: Uint8Array; }>; export type ExchangedOpenGeniSlackAuthorization = Readonly<{ accessToken: string; appId: string; }>; export async function exchangeOpenGeniSlackAuthorizationCode( input: { code: string; clientId: string; clientSecret: string; redirectUri: string; }, fetchImpl: FetchLike = fetch, ): Promise { const body = new URLSearchParams({ code: input.code, client_id: input.clientId, client_secret: input.clientSecret, redirect_uri: input.redirectUri, }); let response: Response; try { response = await fetchImpl(`${SLACK_API_BASE}oauth.v2.access`, { method: "POST", headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded", }, body: body.toString(), redirect: "error", signal: AbortSignal.timeout(SLACK_TIMEOUT_MS), }); } catch { throw new HTTPException(502, { message: "Slack installation token exchange failed", }); } if (!response.ok) { throw new HTTPException(502, { message: "Slack installation token exchange failed", }); } const payload = await readResponseJsonBounded( response, SLACK_RESPONSE_MAX_BYTES, "Slack OAuth response", ); const record = slackRecord(payload); if (!record || record.ok !== true) { throw new SlackBotProviderError(slackString(record?.error) || "oauth_exchange_failed"); } const accessToken = slackString(record.access_token); if (!accessToken?.startsWith("xoxb-")) { throw new HTTPException(502, { message: "Slack installation did not return a bot token", }); } const appId = slackString(record.app_id); if (!appId) { throw new HTTPException(502, { message: "Slack installation did not return an app identity", }); } return { accessToken, appId }; } export type SlackBotReceipt = { credentialRole: typeof OPENGENI_SLACK_BOT_CREDENTIAL_ROLE; credentialLabel: typeof OPENGENI_SLACK_BOT_CREDENTIAL_LABEL; connectionId: string; slackTeamId: string; operation: SlackBotOperation; operationId?: string; clientMessageId?: string; }; export type SlackMessageBlock = | { type: "section"; block_id?: string; text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }; } | { type: "actions"; block_id: string; elements: Array<{ type: "button"; action_id: string; value: string; text: { type: "plain_text"; text: string; emoji?: boolean }; style?: "primary" | "danger"; }>; } | { type: "context"; block_id?: string; elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>; } | { type: "divider" }; export type SlackHomeBlock = | { type: "header"; block_id?: string; text: { type: "plain_text"; text: string; emoji?: boolean }; } | { type: "section"; block_id?: string; text: { type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }; accessory?: { type: "button"; action_id: string; text: { type: "plain_text"; text: string; emoji?: boolean }; url: string; }; } | { type: "context"; block_id?: string; elements: Array<{ type: "mrkdwn" | "plain_text"; text: string; emoji?: boolean }>; } | { type: "actions"; block_id: string; elements: Array<{ type: "button"; action_id: string; text: { type: "plain_text"; text: string; emoji?: boolean }; url: string; style?: "primary" | "danger"; }>; } | { type: "divider" }; type SlackBotOperation = | "channels.list" | "search.context" | "channel_history.read" | "thread_replies.read" | "users.list" | "files.list" | "file.info" | "file.content.read" | "home.publish" | "message.post" | "message.update" | "message.delete"; type SlackBotContext = { accountId: string; workspaceId: string; subjectId: string | null; sessionId?: string | null; scheduledTaskId?: string | null; }; type SlackCallAuthority = Readonly<{ operation: SlackBotOperation }>; type SlackProviderAuthorization = () => Promise; export type SlackReactionContextCheckpointBinding = { inboxId: string; accountId: string; workspaceId: string; connectionId: string; providerEventId: string; providerMessageId: string; slackTeamId: string; slackChannelId: string; slackMessageTs: string; }; type SlackReactionCheckpointMessage = { timestamp: string; userId: string; botId: string; threadTimestamp: string; text: string; files: Array<{ id: string; label: string }>; }; type SlackReactionContextCheckpointUnsigned = { version: typeof SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION; binding: SlackReactionContextCheckpointBinding; state: { createdAtMs: number; pageCount: number; nextCursor: string; seenCursors: string[]; seenMessageTimestamps: string[]; threadTimestamp: string | null; messages: SlackReactionCheckpointMessage[]; }; }; export type SlackReactionContextCheckpoint = SlackReactionContextCheckpointUnsigned & { signature: string; }; export class SlackBotProviderError extends Error { constructor( readonly code: string, readonly retryAfterMs: number | null = null, ) { super(`Slack bot request failed: ${safeSlackCode(code)}`); this.name = "SlackBotProviderError"; } } export async function authorizeSlackSharedImageRead( channel: { isArchived: boolean; isShared: boolean; isExternallyShared: boolean; isOrgShared: boolean; isPendingExternallyShared: boolean; isMpim: boolean; }, authorizeSharedRead: (() => Promise) | undefined, ): Promise { if (channel.isArchived) { throw new SlackBotProviderError("is_archived"); } if ( !channel.isShared && !channel.isExternallyShared && !channel.isOrgShared && !channel.isPendingExternallyShared && !channel.isMpim ) { throw new SlackBotProviderError("slack_connect_unsupported"); } if (!authorizeSharedRead) { throw new SlackBotProviderError("slack_connect_unsupported"); } await authorizeSharedRead(); } const SLACK_CREDENTIAL_REJECTION_CODES = new Set([ "account_inactive", "invalid_auth", "not_authed", "token_expired", "token_revoked", ]); function slackCredentialRejected(error: unknown): error is SlackBotProviderError { return error instanceof SlackBotProviderError && SLACK_CREDENTIAL_REJECTION_CODES.has(error.code); } export type SlackBotCredentialVerificationFailureReason = "scope_mismatch" | "identity_mismatch"; export class SlackBotCredentialVerificationError extends HTTPException { constructor( readonly failureReason: SlackBotCredentialVerificationFailureReason, message: string, ) { super(422, { message }); this.name = "SlackBotCredentialVerificationError"; } } /** * Validates a write-only xoxb credential before it can enter encrypted storage. * The OAuth exchange's app_id is the immutable app authority. Slack bot profile * names are mutable presentation data and may lag manifest/App Home changes, so * they must not decide whether a credential belongs to this installation. */ export async function verifyOpenGeniSlackBotCredential( token: string, expected: Readonly<{ appId: string; displayName: OpenGeniSlackBotDisplayName; }>, fetchImpl: FetchLike = fetch, now: Date = new Date(), ): Promise { const authResponse = await slackApiFetch(fetchImpl, "auth.test", token, {}); const grantedScopes = parseGrantedScopes(authResponse.response.headers.get("x-oauth-scopes")); assertOpenGeniSlackBotScopes(grantedScopes); const auth = authResponse.payload; const slackTeamId = requiredSlackString(auth.team_id, "team_id"); const slackTeamName = requiredSlackString(auth.team, "team"); const botUserId = requiredSlackString(auth.user_id, "user_id"); const botId = requiredSlackString(auth.bot_id, "bot_id"); const userResponse = await slackApiFetch(fetchImpl, "users.info", token, { user: botUserId, }); const user = slackRecord(userResponse.payload.user); if (!user || user.is_bot !== true || user.deleted === true) { throw new SlackBotCredentialVerificationError( "identity_mismatch", "Slack credential must identify an active bot user", ); } const profile = slackRecord(user.profile); const installedAppId = slackString(profile?.api_app_id); if (installedAppId !== expected.appId) { throw new SlackBotCredentialVerificationError( "identity_mismatch", "Slack credential does not belong to the authorized Slack app", ); } return { grantedScopes, metadata: { credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE, credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL, slackTeamId, slackTeamName, botUserId, botId, botDisplayName: expected.displayName, verifiedAt: now.toISOString(), }, }; } export async function resolveSlackBotConnectionForTool(input: { db: Database; grant: AccessGrant; sessionId: string | null; requestedConnectionId?: string; }): Promise<{ connection: ConnectionMetadata; metadata: OpenGeniSlackBotConnectionMetadata; context: SlackBotContext; }> { const session = input.sessionId ? await getSession(input.db, input.grant.workspaceId, input.sessionId) : null; if (input.sessionId && !session) { throw new Error("signed Slack bot session was not found"); } const boundConnectionId = scheduledSlackBotConnectionId(session?.metadata); if (boundConnectionId && (!session || !isTrustedScheduledSlackBotSession(session))) { throw new Error("OpenGeni Slack bot routing metadata is not scheduler-authorized"); } if ( boundConnectionId && input.requestedConnectionId && input.requestedConnectionId !== boundConnectionId ) { throw new Error("this scheduled session is bound to a different OpenGeni Slack bot connection"); } if (!boundConnectionId && !input.grant.permissions.includes("connections:read")) { throw new Error("connections:read is required to select an OpenGeni Slack bot connection"); } let connectionId = boundConnectionId ?? input.requestedConnectionId; if (!connectionId) { const activeConnections = ( await listConnectionsMetadata(input.db, input.grant.workspaceId, null) ).filter( (connection) => connection.status === "active" && isOpenGeniSlackBotConnection(connection), ); if (activeConnections.length === 0) { throw new Error("no active OpenGeni Slack bot connection is installed in this workspace"); } if (activeConnections.length > 1) { const principals = new Set( activeConnections.map((candidate) => { const metadata = openGeniSlackBotMetadata(candidate.metadata)!; return `${metadata.slackTeamId}:${metadata.botId}:${metadata.botUserId}`; }), ); if (principals.size > 1) { throw new Error( "connectionId is required because this workspace has multiple active OpenGeni Slack bot connections", ); } } connectionId = activeConnections[0]!.id; } const connection = await requireOpenGeniSlackBotConnection( input.db, input.grant.workspaceId, connectionId, ); const metadata = openGeniSlackBotMetadata(connection.metadata); if (!metadata) { throw new Error("OpenGeni Slack bot connection metadata is invalid"); } return { connection, metadata, context: { accountId: input.grant.accountId, workspaceId: input.grant.workspaceId, subjectId: input.grant.subjectId, sessionId: input.sessionId, scheduledTaskId: typeof session?.metadata.scheduledTaskId === "string" ? session.metadata.scheduledTaskId : null, }, }; } export class OpenGeniSlackBotClient { private readonly resolveCredential: ReturnType; constructor( private readonly db: Database, private readonly settings: Settings, private readonly connection: ConnectionMetadata, private readonly metadata: OpenGeniSlackBotConnectionMetadata, private readonly context: SlackBotContext, private readonly fetchImpl: FetchLike = fetch, private readonly authorizeProviderRequest?: SlackProviderAuthorization, ) { this.resolveCredential = buildConnectionTokenResolver(db, settings); } async listChannels(input: { limit?: number; cursor?: string } = {}) { return await this.withAudit("channels.list", async (headers) => { const payload = await this.call(headers, "conversations.list", { types: "public_channel,private_channel", exclude_archived: "true", limit: String(boundedInt(input.limit, MAX_CHANNEL_PAGE, 100)), ...(input.cursor ? { cursor: input.cursor } : {}), }); return { channels: slackArray(payload.channels) .map(projectChannel) .filter((channel): channel is NonNullable => channel !== null), nextCursor: responseCursor(payload), }; }); } async verifyChannelAccess(channelId: string) { const headers = await this.headersFor("channel_history.read"); return await this.requireMemberChannel(headers, channelId); } /** * Replace one Slack user's private App Home view. `views.publish` is a * naturally convergent replace operation: Slack event retries may safely * repeat the exact bounded view without creating duplicate provider state. */ async publishHomeView(input: { userId: string; blocks: SlackHomeBlock[]; hash?: string | null }) { const userId = requiredSlackString(input.userId, "user_id"); const blocks = validateSlackHomeBlocks(input.blocks); const hash = input.hash ? requiredSlackString(input.hash, "hash") : null; return await this.withAudit("home.publish", async (headers) => { const payload = await this.call(headers, "views.publish", { user_id: userId, view: JSON.stringify({ type: "home", blocks }), ...(hash ? { hash } : {}), }); return { viewId: requiredSlackString(slackRecord(payload.view)?.id, "view.id"), }; }); } async slackTaskPolicyFacts(channelId: string, userId: string) { return await this.withAudit("channel_history.read", async (headers) => { const conversation = await this.requireMemberChannel(headers, channelId); const governed = conversation.isShared || conversation.isExternallyShared || conversation.isOrgShared || conversation.isPendingExternallyShared || conversation.isMpim; if (!governed) return { conversation, initiator: null }; const payload = await this.call(headers, "users.info", { user: userId }); const initiator = projectSlackTaskPolicyUser(payload.user, this.metadata.slackTeamId); if (!initiator || initiator.id !== userId) { throw new SlackBotProviderError("slack_task_initiator_unavailable"); } return { conversation, initiator }; }); } async channelHistory(input: { channelId: string; limit?: number; cursor?: string; latest?: string; inclusive?: boolean; authorizeRead?: () => Promise; }) { return await this.withAudit("channel_history.read", async (headers) => { const info = await this.requireMemberChannel(headers, input.channelId); await input.authorizeRead?.(); const payload = await this.call(headers, "conversations.history", { channel: input.channelId, limit: String(boundedInt(input.limit, MAX_HISTORY_PAGE, 50)), ...(input.cursor ? { cursor: input.cursor } : {}), ...(input.latest ? { latest: input.latest } : {}), ...(input.latest && input.inclusive ? { inclusive: "true" } : {}), }); return { channel: info, messages: slackArray(payload.messages).map(projectMessage), nextCursor: responseCursor(payload), }; }); } async threadReplies(input: { channelId: string; threadTimestamp: string; limit?: number; cursor?: string; oldest?: string; latest?: string; inclusive?: boolean; authorizeRead?: () => Promise; }) { return await this.withAudit("thread_replies.read", async (headers) => { const info = await this.requireMemberChannel(headers, input.channelId); await input.authorizeRead?.(); const payload = await this.call(headers, "conversations.replies", { channel: input.channelId, ts: input.threadTimestamp, limit: String(boundedInt(input.limit, MAX_THREAD_PAGE, 50)), ...(input.cursor ? { cursor: input.cursor } : {}), ...(input.oldest ? { oldest: input.oldest } : {}), ...(input.latest ? { latest: input.latest } : {}), ...((input.oldest || input.latest) && input.inclusive ? { inclusive: "true" } : {}), }); return { channel: info, threadTimestamp: input.threadTimestamp, messages: slackArray(payload.messages).map(projectMessage), nextCursor: responseCursor(payload), }; }); } async reactionMessageContext(input: { channelId: string; messageTimestamp: string; checkpoint: unknown | null; checkpointBinding: SlackReactionContextCheckpointBinding; saveCheckpoint: (checkpoint: SlackReactionContextCheckpoint) => Promise; }) { return await this.withAudit("thread_replies.read", async (headers) => { const checkpointKey = environmentsEncryptionKeyBytes(this.settings); if (!checkpointKey) throw new Error("connection encryption is not configured"); assertSlackReactionCheckpointBinding( input.checkpointBinding, this.context, this.connection.id, this.metadata.slackTeamId, input.channelId, input.messageTimestamp, ); const restored = input.checkpoint ? parseSlackReactionContextCheckpoint( input.checkpoint, input.checkpointBinding, checkpointKey, ) : null; const info = await this.requireMemberChannel(headers, input.channelId); if (info.isShared || info.isExternallyShared || info.isOrgShared) { throw new SlackBotProviderError("slack_connect_unsupported"); } const messages: ReturnType[] = restored ? restored.state.messages.map(projectSlackReactionCheckpointMessage) : []; const seenMessageTimestamps = new Set(restored?.state.seenMessageTimestamps ?? []); const seenCursors = new Set(restored?.state.seenCursors ?? []); let cursor: string | null = restored?.state.nextCursor ?? null; let nextCursor: string | null = cursor; let threadTimestamp: string | null = restored?.state.threadTimestamp ?? null; let reactedMessage: ReturnType | null = null; const checkpointCreatedAtMs = restored?.state.createdAtMs ?? Date.now(); const firstPage = restored?.state.pageCount ?? 0; for (let page = firstPage; page < MAX_REACTION_CONTEXT_PAGES; page += 1) { const payload = await this.call(headers, "conversations.replies", { channel: input.channelId, // Slack accepts either the parent timestamp or a message timestamp from // inside the thread and returns the containing thread. ts: input.messageTimestamp, limit: String(MAX_REACTION_CONTEXT_MESSAGES), ...(cursor ? { cursor } : {}), }); const pageMessages = slackArray(payload.messages) .map(projectMessage) .filter((message) => message.timestamp.length > 0); const first = pageMessages[0]; threadTimestamp ??= first?.threadTimestamp || first?.timestamp || null; for (const message of pageMessages) { if (seenMessageTimestamps.has(message.timestamp)) continue; seenMessageTimestamps.add(message.timestamp); messages.push(message); } reactedMessage = reactedMessage ?? pageMessages.find((message) => message.timestamp === input.messageTimestamp) ?? null; nextCursor = responseCursor(payload); if (reactedMessage || !nextCursor) break; if (seenCursors.has(nextCursor)) { throw new SlackBotProviderError("reaction_pagination_invalid"); } seenCursors.add(nextCursor); const pageCount = page + 1; if (pageCount >= MAX_REACTION_CONTEXT_PAGES) { throw new SlackBotProviderError("reaction_pagination_exhausted"); } const retainedMessages = selectSlackReactionCheckpointMessages(messages); messages.splice(0, messages.length, ...retainedMessages); await input.saveCheckpoint( createSlackReactionContextCheckpoint( input.checkpointBinding, { createdAtMs: checkpointCreatedAtMs, pageCount, nextCursor, seenCursors: [...seenCursors], seenMessageTimestamps: [...seenMessageTimestamps], threadTimestamp, messages: retainedMessages.map(slackReactionCheckpointMessage), }, checkpointKey, ), ); cursor = nextCursor; } if (!reactedMessage || !threadTimestamp) { throw new SlackBotProviderError("message_not_found"); } const boundedMessages = selectSlackReactionContextMessages( messages, reactedMessage.timestamp, ); return { channel: info, threadTimestamp, reactedMessage, messages: boundedMessages, truncated: nextCursor !== null || seenMessageTimestamps.size > boundedMessages.length, }; }); } /** * Re-fetch and authorize the exact reacted-message files before any byte or * workspace-storage mutation. The returned private URLs are process-local * capabilities only and must never be persisted, logged, or projected into * session input. */ async prepareReactionImageDownloads(input: { channelId: string; files: readonly { id: string; name: string; title: string }[]; authorizeSharedRead?: () => Promise; }): Promise { const result = await this.withAudit("file.content.read", async (headers) => { if (input.authorizeSharedRead) { const channel = await this.requireMemberChannel(headers, input.channelId); await authorizeSlackSharedImageRead(channel, input.authorizeSharedRead); } else { await this.requireActiveNonSharedMemberChannel(headers, input.channelId); } const prepared: PreparedSlackReactionImage[] = []; for (const candidate of input.files) { await input.authorizeSharedRead?.(); const payload = await this.call(headers, "files.info", { file: candidate.id }); const fileRecord = slackRecord(payload.file); const file = projectFile(fileRecord); if (!fileRecord || !file || file.id !== candidate.id) { throw new SlackBotProviderError("file_not_found"); } if (!fileIsSharedToChannel(fileRecord, input.channelId)) { throw new SlackBotProviderError("file_not_shared_to_channel"); } let downloadUrl: URL | null = null; try { downloadUrl = privateSlackFileUrl(fileRecord); } catch { // A malformed provider URL is a per-file omission, not permission to // send a credential to a different destination. } prepared.push( Object.freeze({ fileId: file.id, filename: file.name || file.title || candidate.name || candidate.title || file.id, declaredMimeType: normalizedContentType(file.mimetype), declaredSizeBytes: file.size, downloadUrl, }), ); } return { files: prepared }; }); return result.files; } /** Download and fully validate one previously authorized Slack image. */ async downloadReactionImage( input: PreparedSlackReactionImage, authorizeSharedRead?: () => Promise, ): Promise { return await this.withAudit("file.content.read", async () => { if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(input.declaredMimeType)) { throw new SlackBotProviderError("unsupported_file_type"); } if (!input.downloadUrl) throw new SlackBotProviderError("file_content_unavailable"); if ( input.declaredSizeBytes !== null && (input.declaredSizeBytes < 1 || input.declaredSizeBytes > SLACK_REACTION_IMAGE_MAX_BYTES) ) { throw new SlackBotProviderError("invalid_file_size"); } const response = await this.fetchPrivateFile( input.downloadUrl, "file.content.read", authorizeSharedRead, ); const responseContentType = normalizedContentType(response.headers.get("content-type")); if (!SLACK_REACTION_IMAGE_MIME_TYPES.has(responseContentType)) { await response.body?.cancel().catch(() => undefined); throw new SlackBotProviderError("unsupported_file_type"); } let bytes: Uint8Array; try { bytes = await readResponseBodyBounded( response, SLACK_REACTION_IMAGE_MAX_BYTES, "Slack image content", ); } catch { throw new SlackBotProviderError("invalid_file_content"); } const sniffed = sniffSlackReactionImageMime(bytes); if (!sniffed) throw new SlackBotProviderError("invalid_file_content"); if (input.declaredMimeType !== sniffed || responseContentType !== sniffed) { throw new SlackBotProviderError("file_content_type_mismatch"); } if (input.declaredSizeBytes !== null && input.declaredSizeBytes !== bytes.byteLength) { throw new SlackBotProviderError("file_size_mismatch"); } return Object.freeze({ fileId: input.fileId, filename: input.filename, contentType: sniffed, bytes, }); }); } async listUsers(input: { limit?: number; cursor?: string } = {}) { return await this.withAudit("users.list", async (headers) => { const payload = await this.call(headers, "users.list", { limit: String(boundedInt(input.limit, MAX_USER_PAGE, 100)), ...(input.cursor ? { cursor: input.cursor } : {}), }); return { users: slackArray(payload.members) .map(projectUser) .filter((user): user is NonNullable => user !== null), nextCursor: responseCursor(payload), }; }); } /** * Workspace-wide public search through Slack's Real-time Search API * (`assistant.search.context`) under the bot identity. * * The bot never carries private-search authority: `channel_types` is pinned * to `public_channel` server-side regardless of caller input, so private * channels, DMs, and MPIMs remain reachable only through a member's personal * hosted-MCP grant. An install predating the search scopes fails closed with * a reinstall hint instead of leaking Slack's `missing_scope` error. */ async searchContext(input: { query: string; contentTypes?: readonly ("messages" | "files" | "channels")[]; includeBots?: boolean; /** UNIX seconds bounds on message timestamps. */ before?: number; after?: number; sort?: "score" | "timestamp"; sortDir?: "asc" | "desc"; cursor?: string; limit?: number; }) { const query = typeof input.query === "string" ? input.query.trim() : ""; if (!query || query.length > MAX_SEARCH_QUERY_CHARS) { throw new SlackBotProviderError("invalid_query"); } const contentTypes = input.contentTypes?.length ? [...new Set(input.contentTypes)] : ["messages"]; return await this.withAudit("search.context", async (headers) => { // Inside the audit boundary so a fail-closed denial on a legacy install // leaves the same `failed` evidence as a provider rejection. if (!hasOpenGeniSlackBotSearchScopes(this.connection.grantedScopes)) { throw new SlackBotProviderError("slack_bot_search_scopes_missing"); } const payload = await this.call(headers, "assistant.search.context", { query, channel_types: "public_channel", content_types: contentTypes.join(","), ...(input.includeBots ? { include_bots: "true" } : {}), ...(input.before !== undefined ? { before: String(Math.trunc(input.before)) } : {}), ...(input.after !== undefined ? { after: String(Math.trunc(input.after)) } : {}), ...(input.sort ? { sort: input.sort } : {}), ...(input.sortDir ? { sort_dir: input.sortDir } : {}), ...(input.cursor ? { cursor: input.cursor } : {}), limit: String(boundedInt(input.limit, MAX_SEARCH_PAGE, MAX_SEARCH_PAGE)), }); const results = slackRecord(payload.results); return { messages: slackArray(results?.messages) .map(projectSearchMessage) .filter((message): message is NonNullable => message !== null), files: slackArray(results?.files) .map(projectSearchFile) .filter((file): file is NonNullable => file !== null), channels: slackArray(results?.channels) .map(projectSearchChannel) .filter((channel): channel is NonNullable => channel !== null), nextCursor: responseCursor(payload), }; }); } async listFiles(input: { channelId: string; limit?: number; cursor?: string }) { return await this.withAudit("files.list", async (headers) => { const requestedPage = this.fileListPage(input); const info = await this.requireMemberChannel(headers, input.channelId); const payload = await this.call(headers, "files.list", { channel: input.channelId, count: String(requestedPage.count), page: String(requestedPage.page), }); const files = slackArray(payload.files) .map(projectFile) .filter((file): file is NonNullable => file !== null); const nextPage = nextSlackFilesListPage(payload, requestedPage, files.length); return { channel: info, files, nextCursor: nextPage === null ? null : this.fileListCursor({ channelId: input.channelId, count: requestedPage.count, page: nextPage, }), }; }); } async fileInfo(input: { channelId: string; fileId: string; parentFileId?: string }) { return await this.withAudit("file.info", async (headers) => { const info = await this.requireMemberChannel(headers, input.channelId); const { file } = await this.requireFileForChannel(headers, "file.info", input); return { channel: info, file }; }); } async fileContent(input: { channelId: string; fileId: string; parentFileId?: string; offset?: number; }) { return await this.withAudit("file.content.read", async (headers) => { const info = await this.requireMemberChannel(headers, input.channelId); const { fileRecord, file, parentFileRecord } = await this.requireFileForChannel( headers, "file.content.read", input, ); const embeddedTranscript = embeddedHuddleTranscription(fileRecord, parentFileRecord); const { contentType, content } = embeddedTranscript ?? (await this.readPrivateFileText(fileRecord, "file.content.read")); const offset = typeof input.offset === "number" && Number.isInteger(input.offset) && input.offset >= 0 ? input.offset : 0; if (offset > content.length) { throw new SlackBotProviderError("invalid_file_offset"); } const page = content.slice(offset, offset + SLACK_FILE_CONTENT_PAGE_CHARS); const nextOffset = offset + page.length < content.length ? offset + page.length : null; return { channel: info, file, contentType, offset, content: page, nextOffset, truncated: nextOffset !== null, }; }); } /** * Internal server-owned delivery only. Generic model-facing MCP callers do * not have a trustworthy durable logical-delivery identity and must never * reach this method with a caller-generated operation ID. */ async postMessage(input: { operationId: string; channelId?: string; userId?: string; threadTimestamp?: string; text: string; blocks?: SlackMessageBlock[]; requireActiveNonSharedChannel?: boolean; }) { const operation = "message.post" as const; const claimHolderId = crypto.randomUUID(); let claimAcquired = false; let providerCallStarted = false; let outcomeUnknown = false; try { const headers = await this.headersFor(operation); let channelId = input.channelId; if (input.userId) { const opened = await this.call(headers, "conversations.open", { users: input.userId, }); channelId = requiredSlackString(slackRecord(opened.channel)?.id, "channel.id"); } else if (channelId && !input.requireActiveNonSharedChannel) { await this.requireMemberChannel(headers, channelId); } if (!channelId) { throw new Error("exactly one of channelId or userId is required"); } const targetKind = input.userId ? "user" : "channel"; const targetId = input.userId ?? input.channelId!; const blocks = validateSlackMessageBlocks(input.blocks); const requestDigest = this.postRequestDigest({ operationId: input.operationId, targetKind, targetId, ...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}), text: input.text, ...(blocks ? { blocks } : {}), }); const claim = await claimSlackBotPostOperation(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, targetKind, targetId, requestDigest, claimHolderId, claimLeaseMs: SLACK_POST_CLAIM_LEASE_MS, }); if (claim.kind === "connection_not_found") { throw new Error("OpenGeni Slack bot connection no longer exists"); } if (claim.kind === "conflict") { throw new Error("operationId is already bound to a different Slack post request"); } if (claim.kind === "in_progress") { throw new Error("Slack post operation is already in progress; retry the same operationId"); } if (claim.kind === "completed") { return this.completedPostResult(claim.operation, input.operationId, input.threadTimestamp); } claimAcquired = true; outcomeUnknown = claim.kind === "reconcile"; if (claim.kind === "reconcile") { const reconciled = await this.reconcilePostMessage({ operationId: input.operationId, channelId, ...(input.threadTimestamp ? { threadTimestamp: input.threadTimestamp } : {}), text: input.text, }); const completed = await completeSlackBotPostOperation(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, slackChannelId: channelId, slackMessageTimestamp: reconciled.timestamp, subjectId: this.context.subjectId, auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId), }); if (completed.kind !== "completed") { throw new Error("Slack post reconciliation lost its durable operation claim"); } claimAcquired = false; return this.completedPostResult( completed.operation, input.operationId, input.threadTimestamp, ); } if (input.requireActiveNonSharedChannel) { if (input.userId || !input.channelId) { throw new Error("active non-shared channel validation requires channelId"); } await this.requireActiveNonSharedMemberChannel(headers, channelId); } const providerStarted = await markSlackBotPostOperationProviderStarted(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, }); if (!providerStarted) { throw new Error("Slack post operation lost its durable claim before provider call"); } providerCallStarted = true; const posted = await this.call(headers, "chat.postMessage", { channel: channelId, text: input.text, ...(blocks ? { blocks: JSON.stringify(blocks) } : {}), client_msg_id: input.operationId, unfurl_links: "false", unfurl_media: "false", ...(input.threadTimestamp ? { thread_ts: input.threadTimestamp } : {}), }); const slackChannelId = requiredSlackString(posted.channel, "channel"); const slackMessageTimestamp = requiredSlackString(posted.ts, "ts"); const completed = await completeSlackBotPostOperation(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, slackChannelId, slackMessageTimestamp, subjectId: this.context.subjectId, auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId), }); if (completed.kind !== "completed") { throw new Error("Slack post completion lost its durable operation claim"); } claimAcquired = false; return this.completedPostResult( completed.operation, input.operationId, input.threadTimestamp, ); } catch (error) { const failureCode = safeFailureCode(error); const ambiguous = providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error); if (claimAcquired) { await releaseSlackBotPostOperationClaim(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, outcomeUnknown: outcomeUnknown || ambiguous, failureCode, }).catch(() => undefined); } await this.recordAudit( operation, outcomeUnknown || ambiguous ? "ambiguous" : "failed", failureCode, input.operationId, ); throw error; } } async updateMessage(input: { operationId: string; channelId: string; timestamp: string; text: string; blocks?: SlackMessageBlock[]; }): Promise<{ channelId: string; timestamp: string; receipt: SlackBotReceipt }> { const operation = "message.update" as const; const claimHolderId = crypto.randomUUID(); let claimAcquired = false; let providerCallStarted = false; const blocks = validateSlackMessageBlocks(input.blocks); try { const headers = await this.headersFor(operation); await this.requireMemberChannel(headers, input.channelId); const requestDigest = this.updateRequestDigest({ ...input, ...(blocks ? { blocks } : {}) }); const claim = await claimSlackBotUpdateOperation(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, slackChannelId: input.channelId, slackMessageTimestamp: input.timestamp, requestDigest, claimHolderId, claimLeaseMs: SLACK_UPDATE_CLAIM_LEASE_MS, }); if (claim.kind === "connection_not_found") { throw new Error("OpenGeni Slack bot connection no longer exists"); } if (claim.kind === "conflict") { throw new Error("operationId is already bound to a different Slack update request"); } if (claim.kind === "in_progress") { throw new Error( "Slack update operation is already in progress; retry the same operationId", ); } if (claim.kind === "completed") { return { channelId: claim.operation.slackChannelId, timestamp: claim.operation.slackMessageTimestamp, receipt: this.receipt(operation, input.operationId), }; } claimAcquired = true; providerCallStarted = true; const updated = await this.call(headers, "chat.update", { channel: input.channelId, ts: input.timestamp, text: input.text, ...(blocks ? { blocks: JSON.stringify(blocks) } : {}), }); const slackChannelId = requiredSlackString(updated.channel, "channel"); const slackMessageTimestamp = requiredSlackString(updated.ts, "ts"); if (slackChannelId !== input.channelId || slackMessageTimestamp !== input.timestamp) { throw new SlackBotProviderError("message_update_identity_mismatch"); } const completed = await completeSlackBotUpdateOperation(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, subjectId: this.context.subjectId, auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId), }); if (completed !== "completed") { throw new Error("Slack update completion lost its durable operation claim"); } claimAcquired = false; return { channelId: slackChannelId, timestamp: slackMessageTimestamp, receipt: this.receipt(operation, input.operationId), }; } catch (error) { const failureCode = safeFailureCode(error); if (claimAcquired) { await releaseSlackBotUpdateOperationClaim(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, failureCode, }).catch(() => undefined); } await this.recordAudit( operation, providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error) ? "ambiguous" : "failed", failureCode, input.operationId, ); throw error; } } async deleteMessage(input: { operationId: string; channelId: string; timestamp: string }) { const operation = "message.delete" as const; const claimHolderId = crypto.randomUUID(); const principal = this.deletePrincipal(); const requestDigest = this.deleteRequestDigest(input); let claimAcquired = false; let providerCallStarted = false; let outcomeUnknown = false; try { const claim = await claimSlackBotDeleteOperation(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, principalType: principal.type, principalId: principal.id, toolName: "slack_bot_delete_message", channelId: input.channelId, messageTimestamp: input.timestamp, requestDigest, claimHolderId, claimLeaseMs: SLACK_DELETE_CLAIM_LEASE_MS, }); if (claim.kind === "connection_not_found") { throw new Error("OpenGeni Slack bot connection no longer exists"); } if (claim.kind === "conflict") { throw new Error("operationId is already bound to a different Slack delete request"); } if (claim.kind === "in_progress") { throw new Error( "Slack delete operation is already in progress; retry the same operationId", ); } if (claim.kind === "completed") { return this.completedDeleteResult(claim.operation, input.operationId); } claimAcquired = true; outcomeUnknown = claim.kind === "reconcile"; const headers = await this.headersFor(operation); await this.requireMemberChannel(headers, input.channelId); if (claim.kind === "reconcile") { const exists = await this.slackMessageExists(input.channelId, input.timestamp); if (!exists) { const completed = await completeSlackBotDeleteOperation(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, slackChannelId: input.channelId, slackMessageTimestamp: input.timestamp, subjectId: this.context.subjectId, auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId), }); if (completed.kind !== "completed") { throw new Error("Slack delete reconciliation lost its durable operation claim"); } claimAcquired = false; return this.completedDeleteResult(completed.operation, input.operationId); } } const providerStarted = await markSlackBotDeleteOperationProviderStarted(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, }); if (!providerStarted) { throw new Error("Slack delete operation lost its durable claim before provider call"); } providerCallStarted = true; const deleted = await this.call(headers, "chat.delete", { channel: input.channelId, ts: input.timestamp, }).catch((error) => { if (error instanceof SlackBotProviderError && error.code === "message_not_found") { return { ok: true, channel: input.channelId, ts: input.timestamp }; } throw error; }); const completed = await completeSlackBotDeleteOperation(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, slackChannelId: requiredSlackString(deleted.channel, "channel"), slackMessageTimestamp: requiredSlackString(deleted.ts, "ts"), subjectId: this.context.subjectId, auditMetadata: this.auditMetadata(operation, "succeeded", undefined, input.operationId), }); if (completed.kind !== "completed") { throw new Error("Slack delete completion lost its durable operation claim"); } claimAcquired = false; return this.completedDeleteResult(completed.operation, input.operationId); } catch (error) { const failureCode = safeFailureCode(error); const ambiguous = providerCallStarted && slackMutationOutcomeMayBeAmbiguous(error); if (claimAcquired) { await releaseSlackBotDeleteOperationClaim(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, connectionId: this.connection.id, operationId: input.operationId, claimHolderId, outcomeUnknown: outcomeUnknown || ambiguous, failureCode, }).catch(() => undefined); } await this.recordAudit( operation, ambiguous ? "ambiguous" : "failed", failureCode, input.operationId, ); throw error; } } private async slackMessageExists(channelId: string, timestamp: string): Promise { const headers = await this.headersFor("message.delete"); try { await this.call(headers, "chat.getPermalink", { channel: channelId, message_ts: timestamp, }); return true; } catch (error) { if (error instanceof SlackBotProviderError && error.code === "message_not_found") { return false; } throw error; } } private async reconcilePostMessage(input: { operationId: string; channelId: string; threadTimestamp?: string; text: string; }): Promise<{ timestamp: string }> { const method = input.threadTimestamp ? "conversations.replies" : "conversations.history"; const headers = await this.headersFor("message.post"); const seenCursors = new Set(); let cursor: string | null = null; let matchedTimestamp: string | null = null; let exhausted = false; for (let page = 0; page < MAX_SLACK_POST_RECONCILIATION_PAGES; page += 1) { const payload = await this.call(headers, method, { channel: input.channelId, limit: String(input.threadTimestamp ? MAX_THREAD_PAGE : MAX_HISTORY_PAGE), ...(input.threadTimestamp ? { ts: input.threadTimestamp } : {}), ...(cursor ? { cursor } : {}), }); for (const value of slackArray(payload.messages)) { const message = slackRecord(value); if (!message || slackString(message.client_msg_id) !== input.operationId) continue; const timestamp = requiredSlackString(message.ts, "message.ts"); const threadTimestamp = slackString(message.thread_ts); const threadMatches = input.threadTimestamp ? threadTimestamp === input.threadTimestamp : !threadTimestamp || threadTimestamp === timestamp; if (slackString(message.text) !== input.text || !threadMatches) { throw new SlackBotProviderError("post_reconciliation_mismatch"); } if (matchedTimestamp && matchedTimestamp !== timestamp) { throw new SlackBotProviderError("post_reconciliation_duplicate"); } matchedTimestamp = timestamp; } const nextCursor = responseCursor(payload); if (!nextCursor) { exhausted = true; break; } if (seenCursors.has(nextCursor)) { throw new SlackBotProviderError("post_reconciliation_invalid_cursor"); } seenCursors.add(nextCursor); cursor = nextCursor; } if (!exhausted) { throw new SlackBotProviderError("post_reconciliation_truncated"); } if (!matchedTimestamp) { throw new SlackBotProviderError("post_outcome_unknown"); } return { timestamp: matchedTimestamp }; } private async requireMemberChannel(headers: SlackCallAuthority, channelId: string) { const payload = await this.call(headers, "conversations.info", { channel: channelId, }); const projected = projectChannel(payload.channel); // Slack omits `is_member` for a bot's one-to-one App Home conversation. // A successful conversations.info response with `is_im` is itself proof // that this installation can address that direct conversation. if (!projected || (projected.isMember !== true && projected.isDirectMessage !== true)) { throw new SlackBotProviderError("not_in_channel"); } return projected; } private async requireActiveNonSharedMemberChannel( headers: SlackCallAuthority, channelId: string, ) { const projected = await this.requireMemberChannel(headers, channelId); if (projected.isArchived) { throw new SlackBotProviderError("is_archived"); } if (projected.isShared || projected.isExternallyShared || projected.isOrgShared) { throw new SlackBotProviderError("slack_connect_unsupported"); } return projected; } private async requireFileForChannel( headers: SlackCallAuthority, operation: "file.info" | "file.content.read", input: { channelId: string; fileId: string; parentFileId?: string }, ) { const payload = await this.call(headers, "files.info", { file: input.fileId, ...(operation === "file.content.read" ? { include_transcription: "true" } : {}), }); const fileRecord = slackRecord(payload.file); const file = projectFile(fileRecord); if (!fileRecord || !file) { throw new SlackBotProviderError("file_not_found"); } if (fileIsSharedToChannel(fileRecord, input.channelId)) { return { fileRecord, file, parentFileRecord: null }; } if (!input.parentFileId || input.parentFileId === input.fileId) { throw new SlackBotProviderError("file_not_found"); } const parentPayload = await this.call(headers, "files.info", { file: input.parentFileId, }); const parentRecord = slackRecord(parentPayload.file); if (!parentRecord || !fileIsSharedToChannel(parentRecord, input.channelId)) { throw new SlackBotProviderError("file_not_found"); } if (parentReferencesSlackFile(parentRecord, input.fileId)) { return { fileRecord, file, parentFileRecord: parentRecord }; } const parent = await this.readPrivateFileText(parentRecord, operation); if (!embeddedSlackFileIds(parent.content).has(input.fileId)) { throw new SlackBotProviderError("file_not_found"); } return { fileRecord, file, parentFileRecord: parentRecord }; } private async readPrivateFileText( fileRecord: Record, operation: "file.info" | "file.content.read", ): Promise<{ contentType: string; content: string }> { const downloadUrl = privateSlackFileUrl(fileRecord); if (!downloadUrl) { throw new SlackBotProviderError("file_content_unavailable"); } let response: Response; try { response = await this.fetchPrivateFile(downloadUrl, operation); } catch (error) { if ( error instanceof SlackBotProviderError && error.code === "file_requires_user_access" && slackString(fileRecord.mode) === "huddle_transcript" ) { throw new SlackBotProviderError("huddle_transcript_requires_participant_access"); } throw error; } const contentType = normalizedContentType(response.headers.get("content-type")); if (!isSupportedSlackTextContentType(contentType)) { await response.body?.cancel().catch(() => undefined); throw new SlackBotProviderError("unsupported_file_type"); } try { const content = new TextDecoder("utf-8", { fatal: true }).decode( await readResponseBodyBounded( response, SLACK_FILE_RESPONSE_MAX_BYTES, "Slack file content", ), ); return { contentType, content }; } catch { throw new SlackBotProviderError("invalid_file_content"); } } private async call( authority: SlackCallAuthority, method: string, params: Record, ): Promise { try { const headers = await this.headersForDestination( authority.operation, `${SLACK_API_BASE}${method}`, ); return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload; } catch (error) { if (slackCredentialRejected(error)) { await setConnectionStatus(this.db, this.context.workspaceId, "needs_reauth", error.code, { id: this.connection.id, version: this.connection.version, subjectId: null, }).catch(() => false); } throw error; } } private async withAudit>( operation: SlackBotOperation, run: (authority: SlackCallAuthority) => Promise, ): Promise { try { const result = await run(await this.headersFor(operation)); await this.recordAudit(operation, "succeeded"); return { ...result, receipt: this.receipt(operation) }; } catch (error) { await this.recordAudit(operation, "failed", safeFailureCode(error)); throw error; } } private async headersFor(operation: SlackBotOperation): Promise { return Object.freeze({ operation }); } private async headersForDestination( operation: SlackBotOperation, destinationUrl: string, ): Promise> { const result = await this.resolveCredential({ workspaceId: this.context.workspaceId, serverId: "opengeni-slack-bot", toolName: `slack_bot_${operation.replaceAll(".", "_")}`, connectionRef: { connectionId: this.connection.id, providerDomain: "slack.com", kind: "app_install", scopes: [...OPENGENI_SLACK_BOT_REQUIRED_SCOPES], subjectScope: "workspace", }, destinationUrl, }); if (result.status !== "ok" || result.connectionId !== this.connection.id) { throw new Error("OpenGeni Slack bot connection needs to be reinstalled"); } const current = await requireOpenGeniSlackBotConnection( this.db, this.context.workspaceId, this.connection.id, ); const currentMetadata = openGeniSlackBotMetadata(current.metadata); if ( current.accountId !== this.context.accountId || current.version !== this.connection.version || result.connectionVersion !== this.connection.version || !currentMetadata || currentMetadata.slackTeamId !== this.metadata.slackTeamId || currentMetadata.botId !== this.metadata.botId || currentMetadata.botUserId !== this.metadata.botUserId ) { throw new Error("OpenGeni Slack bot connection authority changed"); } // The adapter callback is a fallible preflight. The canonical credential // callback must remain the final await before the physical fetch. if (this.authorizeProviderRequest && (await this.authorizeProviderRequest()) === false) { throw new Error("OpenGeni Slack bot provider request is no longer authorized"); } if (result.authorizeProviderRequest && !(await result.authorizeProviderRequest())) { throw new Error("OpenGeni Slack bot provider request is no longer authorized"); } return result.headers; } private async fetchPrivateFile( url: URL, operation: "file.info" | "file.content.read", authorizeBeforeFetch?: () => Promise, ): Promise { const response = await this.fetchPrivateFileOnce(url, operation, authorizeBeforeFetch); if (response.status < 300 || response.status >= 400) { return response; } const location = response.headers.get("location"); await response.body?.cancel().catch(() => undefined); if (!location) throw new SlackBotProviderError(`http_${response.status}`); let redirected: URL; try { redirected = new URL(location, url); } catch { throw new SlackBotProviderError("invalid_file_redirect"); } if (isSlackOwnedInteractiveFileRedirect(redirected)) { throw new SlackBotProviderError("file_requires_user_access"); } try { assertPrivateSlackFileUrl(redirected); } catch { throw new SlackBotProviderError("invalid_file_redirect"); } return await this.fetchPrivateFileOnce(redirected, operation, authorizeBeforeFetch); } private async fetchPrivateFileOnce( url: URL, operation: "file.info" | "file.content.read", authorizeBeforeFetch?: () => Promise, ): Promise { try { assertPrivateSlackFileUrl(url); } catch { throw new SlackBotProviderError("invalid_file_url"); } await authorizeBeforeFetch?.(); const headers = await this.headersForDestination(operation, url.toString()); let response: Response; try { response = await this.fetchImpl(url, { method: "GET", headers: { ...headers, accept: "text/*, application/json, application/xml, application/xhtml+xml", }, redirect: "manual", signal: AbortSignal.timeout(SLACK_TIMEOUT_MS), }); } catch { throw new SlackBotProviderError("transport_error"); } if (!response.ok && (response.status < 300 || response.status >= 400)) { await response.body?.cancel().catch(() => undefined); throw new SlackBotProviderError(`http_${response.status}`); } return response; } private receipt(operation: SlackBotOperation, operationId?: string): SlackBotReceipt { return { credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE, credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL, connectionId: this.connection.id, slackTeamId: this.metadata.slackTeamId, operation, ...(operationId ? { operationId } : {}), ...(operation === "message.post" && operationId ? { clientMessageId: operationId } : {}), }; } private completedPostResult( operation: { slackChannelId: string | null; slackMessageTimestamp: string | null; }, operationId: string, threadTimestamp?: string, ) { if (!operation.slackChannelId || !operation.slackMessageTimestamp) { throw new Error("completed Slack post operation is missing its provider result"); } return { channelId: operation.slackChannelId, timestamp: operation.slackMessageTimestamp, threadTimestamp: threadTimestamp ?? null, receipt: this.receipt("message.post", operationId), }; } private completedDeleteResult( operation: { slackChannelId: string | null; slackMessageTimestamp: string | null; }, operationId: string, ) { if (!operation.slackChannelId || !operation.slackMessageTimestamp) { throw new Error("completed Slack delete operation is missing its provider result"); } return { channelId: operation.slackChannelId, timestamp: operation.slackMessageTimestamp, deleted: true, receipt: this.receipt("message.delete", operationId), }; } private postRequestDigest(input: { operationId: string; targetKind: "channel" | "user"; targetId: string; threadTimestamp?: string; text: string; blocks?: SlackMessageBlock[]; }): string { const key = environmentsEncryptionKeyBytes(this.settings); if (!key) throw new Error("connection encryption is not configured"); return createHmac("sha256", key) .update( JSON.stringify({ operationId: input.operationId, connectionId: this.connection.id, targetKind: input.targetKind, targetId: input.targetId, threadTimestamp: input.threadTimestamp ?? null, text: input.text, blocks: input.blocks ?? null, }), ) .digest("hex"); } private updateRequestDigest(input: { operationId: string; channelId: string; timestamp: string; text: string; blocks?: SlackMessageBlock[]; }): string { const key = environmentsEncryptionKeyBytes(this.settings); if (!key) throw new Error("connection encryption is not configured"); return createHmac("sha256", key) .update( JSON.stringify({ operationId: input.operationId, connectionId: this.connection.id, channelId: input.channelId, timestamp: input.timestamp, text: input.text, blocks: input.blocks ?? null, }), ) .digest("hex"); } private deleteRequestDigest(input: { operationId: string; channelId: string; timestamp: string; }): string { const key = environmentsEncryptionKeyBytes(this.settings); if (!key) throw new Error("connection encryption is not configured"); return createHmac("sha256", key) .update( JSON.stringify({ operationId: input.operationId, connectionId: this.connection.id, toolName: "slack_bot_delete_message", channelId: input.channelId, timestamp: input.timestamp, }), ) .digest("hex"); } private deletePrincipal(): { type: "subject" | "service"; id: string } { if (this.context.subjectId) { return { type: "subject", id: this.context.subjectId }; } if (this.context.scheduledTaskId) { return { type: "service", id: `scheduler:${this.context.scheduledTaskId}`, }; } return { type: "service", id: `session:${this.context.sessionId ?? "workspace"}`, }; } private fileListPage(input: { channelId: string; limit?: number; cursor?: string; }): SlackFilesListPage { const key = environmentsEncryptionKeyBytes(this.settings); if (!key) throw new Error("connection encryption is not configured"); return resolveSlackFilesListPage(input, { connectionId: this.connection.id, key, }); } private fileListCursor(input: { channelId: string; count: number; page: number }): string { const key = environmentsEncryptionKeyBytes(this.settings); if (!key) throw new Error("connection encryption is not configured"); return createSlackFilesListCursor(input, { connectionId: this.connection.id, key, }); } private async recordAudit( operation: SlackBotOperation, outcome: "succeeded" | "failed" | "ambiguous", failureCode?: string, operationId?: string, ): Promise { await recordAuditEvent(this.db, { accountId: this.context.accountId, workspaceId: this.context.workspaceId, subjectId: this.context.subjectId, action: `slack_bot.${operation}`, targetType: "connection", targetId: this.connection.id, metadata: this.auditMetadata(operation, outcome, failureCode, operationId), }); } private auditMetadata( operation: SlackBotOperation, outcome: "succeeded" | "failed" | "ambiguous", failureCode?: string, operationId?: string, ): Record { return { ...this.receipt(operation, operationId), outcome, ...(failureCode ? { failureCode } : {}), ...(this.context.sessionId ? { sessionId: this.context.sessionId } : {}), ...(this.context.scheduledTaskId ? { scheduledTaskId: this.context.scheduledTaskId } : {}), }; } } export function createOpenGeniSlackBotClient( deps: { db: Database; settings: Settings; slackFetch?: typeof fetch; authorizeProviderRequest?: SlackProviderAuthorization; }, resolved: Awaited>, ): OpenGeniSlackBotClient { return new OpenGeniSlackBotClient( deps.db, deps.settings, resolved.connection, resolved.metadata, resolved.context, deps.slackFetch, deps.authorizeProviderRequest, ); } export async function createOpenGeniSlackBotInteractionClient( deps: { db: Database; settings: Settings; slackFetch?: typeof fetch; authorizeProviderRequest?: SlackProviderAuthorization; }, input: { accountId: string; workspaceId: string; connectionId: string; subjectId: string; sessionId?: string | null; }, ): Promise { const connection = await requireOpenGeniSlackBotConnection( deps.db, input.workspaceId, input.connectionId, ); if (connection.accountId !== input.accountId) { throw new Error("OpenGeni Slack bot connection tenant mismatch"); } const metadata = openGeniSlackBotMetadata(connection.metadata); if (!metadata) throw new Error("OpenGeni Slack bot connection metadata is invalid"); return new OpenGeniSlackBotClient( deps.db, deps.settings, connection, metadata, { accountId: input.accountId, workspaceId: input.workspaceId, subjectId: input.subjectId, sessionId: input.sessionId ?? null, scheduledTaskId: null, }, deps.slackFetch, deps.authorizeProviderRequest, ); } async function slackApiFetch( fetchImpl: FetchLike, method: string, token: string, params: Record, ) { return await slackApiFetchWithHeaders( fetchImpl, method, { authorization: `Bearer ${token}` }, params, ); } async function slackApiFetchWithHeaders( fetchImpl: FetchLike, method: string, credentialHeaders: Record, params: Record, ): Promise<{ response: Response; payload: SlackPayload }> { if (!/^[a-z]+(?:\.[a-z]+){1,2}$/i.test(method)) { throw new Error("invalid Slack API method"); } const url = new URL(method, SLACK_API_BASE); const body = new URLSearchParams(params); let response: Response; try { response = await fetchImpl(url, { method: "POST", headers: { ...credentialHeaders, accept: "application/json", "content-type": "application/x-www-form-urlencoded", }, body, signal: AbortSignal.timeout(SLACK_TIMEOUT_MS), }); } catch { throw new SlackBotProviderError("transport_error"); } if (!response.ok) { const retryAfterMs = slackRetryAfterMs(response); await response.body?.cancel().catch(() => undefined); throw new SlackBotProviderError(`http_${response.status}`, retryAfterMs); } let payload: SlackPayload; try { payload = await readResponseJsonBounded( response, SLACK_RESPONSE_MAX_BYTES, `Slack ${method} response`, ); } catch { throw new SlackBotProviderError("invalid_response"); } if (payload.ok !== true) { throw new SlackBotProviderError(slackString(payload.error) || "unknown_error"); } return { response, payload }; } function slackRetryAfterMs(response: Response): number | null { if (response.status !== 429) return null; const raw = response.headers.get("retry-after"); if (!raw || !/^\d+$/.test(raw)) return null; const seconds = Number(raw); if (!Number.isSafeInteger(seconds) || seconds < 1) return null; return Math.min(seconds, 3_600) * 1_000; } function assertOpenGeniSlackBotScopes(grantedScopes: string[]): void { const policy = evaluateOpenGeniSlackBotScopes(grantedScopes); if (!policy.accepted) { const facts = [ ...(policy.missingRequired.length ? [`missing: ${policy.missingRequired.join(", ")}`] : []), ...(policy.unsupported.length ? [`unsupported: ${policy.unsupported.join(", ")}`] : []), ]; throw new SlackBotCredentialVerificationError( "scope_mismatch", `Slack bot scopes do not satisfy the OpenGeni manifest (${facts.join("; ")})`, ); } } function parseGrantedScopes(header: string | null): string[] { if (!header) { throw new SlackBotCredentialVerificationError( "scope_mismatch", "Slack did not report granted bot scopes", ); } return [ ...new Set( header .split(",") .map((scope) => scope.trim()) .filter(Boolean), ), ].sort(); } function projectChannel(value: unknown) { const channel = slackRecord(value); const id = slackString(channel?.id); if (!channel || !id) return null; return { id, name: boundedSlackString(channel.name, 256), isPrivate: channel.is_private === true, isMember: channel.is_member === true, isDirectMessage: channel.is_im === true, isMpim: channel.is_mpim === true, isArchived: channel.is_archived === true, isShared: channel.is_shared === true, isExternallyShared: channel.is_ext_shared === true, isOrgShared: channel.is_org_shared === true, isPendingExternallyShared: channel.is_pending_ext_shared === true, contextTeamId: nullableBoundedSlackString(channel.context_team_id, 128), connectedTeamIds: slackStringArrayOrNull(channel.connected_team_ids, 128), sharedTeamIds: slackStringArrayOrNull(channel.shared_team_ids, 128), topic: boundedSlackString(slackRecord(channel.topic)?.value, 1_024), purpose: boundedSlackString(slackRecord(channel.purpose)?.value, 1_024), numMembers: typeof channel.num_members === "number" && Number.isSafeInteger(channel.num_members) ? channel.num_members : null, }; } function projectSlackTaskPolicyUser(value: unknown, installationTeamId: string) { const user = slackRecord(value); const id = slackString(user?.id); const teamId = nullableBoundedSlackString(user?.team_id ?? user?.team, 128); if (!user || !id) return null; const guestFactsPresent = typeof user.is_restricted === "boolean" && typeof user.is_ultra_restricted === "boolean"; const explicitExternal = typeof user.is_external === "boolean" ? user.is_external : null; return { id, teamId, isGuest: guestFactsPresent ? user.is_restricted === true || user.is_ultra_restricted === true : null, isExternal: explicitExternal !== null ? explicitExternal : teamId === null ? null : teamId !== installationTeamId, }; } function nullableBoundedSlackString(value: unknown, maxLength: number): string | null { const bounded = boundedSlackString(value, maxLength); return bounded || null; } function slackStringArrayOrNull(value: unknown, maxLength: number): string[] | null { if (!Array.isArray(value)) return null; const result: string[] = []; for (const item of value) { const bounded = boundedSlackString(item, maxLength); if (!bounded) return null; result.push(bounded); } return [...new Set(result)].sort(); } function projectMessage(value: unknown) { const message = slackRecord(value) ?? {}; return { timestamp: boundedSlackString(message.ts, 64), userId: boundedSlackString(message.user, 64), botId: boundedSlackString(message.bot_id, 64), threadTimestamp: boundedSlackString(message.thread_ts, 64), text: boundedSlackString(message.text, MAX_PROJECTED_TEXT), files: slackArray(message.files) .map(projectFile) .filter((file): file is NonNullable => file !== null), }; } function assertSlackReactionCheckpointBinding( binding: SlackReactionContextCheckpointBinding, context: SlackBotContext, connectionId: string, slackTeamId: string, channelId: string, messageTimestamp: string, ): void { if ( binding.accountId !== context.accountId || binding.workspaceId !== context.workspaceId || binding.connectionId !== connectionId || binding.slackTeamId !== slackTeamId || binding.slackChannelId !== channelId || binding.slackMessageTs !== messageTimestamp ) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } } function createSlackReactionContextCheckpoint( binding: SlackReactionContextCheckpointBinding, state: SlackReactionContextCheckpointUnsigned["state"], key: Uint8Array, ): SlackReactionContextCheckpoint { const unsigned: SlackReactionContextCheckpointUnsigned = { version: SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION, binding: { ...binding }, state: { createdAtMs: state.createdAtMs, pageCount: state.pageCount, nextCursor: state.nextCursor, seenCursors: [...state.seenCursors], seenMessageTimestamps: [...state.seenMessageTimestamps], threadTimestamp: state.threadTimestamp, messages: state.messages.map((message) => ({ ...message, files: message.files.map((file) => ({ ...file })), })), }, }; const checkpoint: SlackReactionContextCheckpoint = { ...unsigned, signature: slackReactionContextCheckpointSignature(unsigned, key), }; if ( Buffer.byteLength(JSON.stringify(checkpoint), "utf8") > MAX_REACTION_CONTEXT_CHECKPOINT_BYTES ) { throw new SlackBotProviderError("reaction_checkpoint_too_large"); } return checkpoint; } function parseSlackReactionContextCheckpoint( value: unknown, expectedBinding: SlackReactionContextCheckpointBinding, key: Uint8Array, nowMs = Date.now(), ): SlackReactionContextCheckpoint { const checkpoint = slackRecord(value); if ( !checkpoint || !hasExactSlackCheckpointKeys(checkpoint, ["binding", "signature", "state", "version"]) || Buffer.byteLength(JSON.stringify(checkpoint), "utf8") > MAX_REACTION_CONTEXT_CHECKPOINT_BYTES || checkpoint.version !== SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION ) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } const bindingValue = slackRecord(checkpoint.binding); const stateValue = slackRecord(checkpoint.state); const signature = exactSlackCheckpointString(checkpoint.signature, 64); if ( !bindingValue || !stateValue || !signature || !/^[0-9a-f]{64}$/.test(signature) || !hasExactSlackCheckpointKeys(bindingValue, [ "accountId", "connectionId", "inboxId", "providerEventId", "providerMessageId", "slackChannelId", "slackMessageTs", "slackTeamId", "workspaceId", ]) || !hasExactSlackCheckpointKeys(stateValue, [ "createdAtMs", "messages", "nextCursor", "pageCount", "seenCursors", "seenMessageTimestamps", "threadTimestamp", ]) ) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } const binding: SlackReactionContextCheckpointBinding = { inboxId: requiredSlackCheckpointString(bindingValue.inboxId, 64), accountId: requiredSlackCheckpointString(bindingValue.accountId, 64), workspaceId: requiredSlackCheckpointString(bindingValue.workspaceId, 64), connectionId: requiredSlackCheckpointString(bindingValue.connectionId, 64), providerEventId: requiredSlackCheckpointString(bindingValue.providerEventId, 256), providerMessageId: requiredSlackCheckpointString(bindingValue.providerMessageId, 256), slackTeamId: requiredSlackCheckpointString(bindingValue.slackTeamId, 64), slackChannelId: requiredSlackCheckpointString(bindingValue.slackChannelId, 64), slackMessageTs: requiredSlackCheckpointString(bindingValue.slackMessageTs, 64), }; if (!slackReactionCheckpointBindingMatches(binding, expectedBinding)) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } const createdAtMs = stateValue.createdAtMs; const pageCount = stateValue.pageCount; const nextCursor = exactSlackCheckpointString(stateValue.nextCursor, 1_024); const threadTimestamp = stateValue.threadTimestamp === null ? null : exactSlackCheckpointString(stateValue.threadTimestamp, 64); if ( typeof createdAtMs !== "number" || !Number.isSafeInteger(createdAtMs) || createdAtMs > nowMs + MAX_REACTION_CONTEXT_CHECKPOINT_CLOCK_SKEW_MS || createdAtMs < nowMs - MAX_REACTION_CONTEXT_CHECKPOINT_AGE_MS || typeof pageCount !== "number" || !Number.isSafeInteger(pageCount) || pageCount < 1 || pageCount >= MAX_REACTION_CONTEXT_PAGES || !nextCursor || (threadTimestamp === "" && stateValue.threadTimestamp !== null) || !Array.isArray(stateValue.seenCursors) || !Array.isArray(stateValue.seenMessageTimestamps) || !Array.isArray(stateValue.messages) ) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } const seenCursors = stateValue.seenCursors.map((cursor) => requiredSlackCheckpointString(cursor, 1_024), ); const seenMessageTimestamps = stateValue.seenMessageTimestamps.map((timestamp) => requiredSlackCheckpointString(timestamp, 64), ); if ( seenCursors.length !== pageCount || seenCursors.length > MAX_REACTION_CONTEXT_PAGES || new Set(seenCursors).size !== seenCursors.length || seenCursors.at(-1) !== nextCursor || seenMessageTimestamps.length > MAX_REACTION_CONTEXT_SEEN_MESSAGES || new Set(seenMessageTimestamps).size !== seenMessageTimestamps.length || seenMessageTimestamps.includes(expectedBinding.slackMessageTs) || stateValue.messages.length > MAX_REACTION_CONTEXT_MESSAGES ) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } const messages = stateValue.messages.map(parseSlackReactionCheckpointMessage); const seenTimestampIndexes = messages.map((message) => seenMessageTimestamps.indexOf(message.timestamp), ); if ( (seenMessageTimestamps.length > 0 && messages.length === 0) || (messages.length > 0 && threadTimestamp === null) || (messages.length > 0 && messages[0]!.timestamp !== seenMessageTimestamps[0]) || seenTimestampIndexes.some( (index, position) => index < 0 || (position > 0 && index <= seenTimestampIndexes[position - 1]!), ) ) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } const unsigned: SlackReactionContextCheckpointUnsigned = { version: SLACK_REACTION_CONTEXT_CHECKPOINT_VERSION, binding, state: { createdAtMs, pageCount, nextCursor, seenCursors, seenMessageTimestamps, threadTimestamp, messages, }, }; const expectedSignature = slackReactionContextCheckpointSignature(unsigned, key); const actualBytes = Buffer.from(signature, "utf8"); const expectedBytes = Buffer.from(expectedSignature, "utf8"); if (actualBytes.length !== expectedBytes.length || !timingSafeEqual(actualBytes, expectedBytes)) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } return { ...unsigned, signature }; } function slackReactionContextCheckpointSignature( checkpoint: SlackReactionContextCheckpointUnsigned, key: Uint8Array, ): string { return createHmac("sha256", key).update(JSON.stringify(checkpoint)).digest("hex"); } function slackReactionCheckpointBindingMatches( left: SlackReactionContextCheckpointBinding, right: SlackReactionContextCheckpointBinding, ): boolean { return ( left.inboxId === right.inboxId && left.accountId === right.accountId && left.workspaceId === right.workspaceId && left.connectionId === right.connectionId && left.providerEventId === right.providerEventId && left.providerMessageId === right.providerMessageId && left.slackTeamId === right.slackTeamId && left.slackChannelId === right.slackChannelId && left.slackMessageTs === right.slackMessageTs ); } function parseSlackReactionCheckpointMessage(value: unknown): SlackReactionCheckpointMessage { const message = slackRecord(value); if ( !message || !hasExactSlackCheckpointKeys(message, [ "botId", "files", "text", "threadTimestamp", "timestamp", "userId", ]) || !Array.isArray(message.files) || message.files.length > MAX_REACTION_CONTEXT_CHECKPOINT_FILES || typeof message.timestamp !== "string" || message.timestamp.length < 1 || message.timestamp.length > 64 || typeof message.userId !== "string" || message.userId.length > 64 || typeof message.botId !== "string" || message.botId.length > 64 || typeof message.threadTimestamp !== "string" || message.threadTimestamp.length > 64 || typeof message.text !== "string" || message.text.length > MAX_PROJECTED_TEXT ) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } const files = message.files.map((candidate) => { const file = slackRecord(candidate); if (!file || !hasExactSlackCheckpointKeys(file, ["id", "label"])) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } return { id: requiredSlackCheckpointString(file.id, 64), label: requiredSlackCheckpointString(file.label, 512), }; }); let fileLabelChars = 0; for (const file of files) { fileLabelChars += file.label.length + (fileLabelChars > 0 ? 2 : 0); } if (fileLabelChars > MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS) { throw new SlackBotProviderError("reaction_checkpoint_invalid"); } return { timestamp: message.timestamp, userId: message.userId, botId: message.botId, threadTimestamp: message.threadTimestamp, text: message.text, files, }; } function slackReactionCheckpointMessage( message: ReturnType, ): SlackReactionCheckpointMessage { const files: SlackReactionCheckpointMessage["files"] = []; let fileLabelChars = 0; for (const file of message.files) { const label = file.title || file.name || file.id; if (!label) continue; const addedChars = label.length + (files.length > 0 ? 2 : 0); if ( files.length >= MAX_REACTION_CONTEXT_CHECKPOINT_FILES || fileLabelChars + addedChars > MAX_REACTION_CONTEXT_CHECKPOINT_FILE_LABEL_CHARS ) { break; } files.push({ id: file.id, label }); fileLabelChars += addedChars; } return { timestamp: message.timestamp, userId: message.userId, botId: message.botId, threadTimestamp: message.threadTimestamp, text: message.text, files, }; } function projectSlackReactionCheckpointMessage( message: SlackReactionCheckpointMessage, ): ReturnType { return { timestamp: message.timestamp, userId: message.userId, botId: message.botId, threadTimestamp: message.threadTimestamp, text: message.text, files: message.files.map((file) => ({ id: file.id, name: "", title: file.label, mimetype: "", filetype: "", mode: "", size: null, originatingHuddleId: "", huddleTranscriptFileId: "", })), }; } function selectSlackReactionCheckpointMessages( messages: ReturnType[], ): ReturnType[] { if (messages.length <= MAX_REACTION_CONTEXT_MESSAGES) return [...messages]; return [messages[0]!, ...messages.slice(-(MAX_REACTION_CONTEXT_MESSAGES - 1))]; } function hasExactSlackCheckpointKeys(value: Record, expected: string[]): boolean { return Object.keys(value).sort().join(",") === [...expected].sort().join(","); } function exactSlackCheckpointString(value: unknown, max: number): string { return typeof value === "string" && value.length <= max ? value : ""; } function requiredSlackCheckpointString(value: unknown, max: number): string { const result = exactSlackCheckpointString(value, max); if (!result) throw new SlackBotProviderError("reaction_checkpoint_invalid"); return result; } function selectSlackReactionContextMessages( messages: ReturnType[], reactedTimestamp: string, ) { if (messages.length <= MAX_REACTION_CONTEXT_MESSAGES) return messages; const reactedIndex = messages.findIndex((message) => message.timestamp === reactedTimestamp); if (reactedIndex < 0) return []; const selected = new Set([0, reactedIndex]); for ( let distance = 1; selected.size < MAX_REACTION_CONTEXT_MESSAGES && distance < messages.length; distance += 1 ) { const before = reactedIndex - distance; const after = reactedIndex + distance; if (before > 0) selected.add(before); if (selected.size < MAX_REACTION_CONTEXT_MESSAGES && after < messages.length) { selected.add(after); } } for (let index = 0; selected.size < MAX_REACTION_CONTEXT_MESSAGES; index += 1) { if (index >= messages.length) break; selected.add(index); } return [...selected].sort((left, right) => left - right).map((index) => messages[index]!); } function projectFile(value: unknown) { const file = slackRecord(value); const id = slackString(file?.id); if (!file || !id) return null; const canvasMetadata = slackRecord(file.canvas_metadata); return { id, name: boundedSlackString(file.name, 512), title: boundedSlackString(file.title, 512), mimetype: boundedSlackString(file.mimetype, 256), filetype: boundedSlackString(file.filetype, 128), mode: boundedSlackString(file.mode, 64), size: typeof file.size === "number" && Number.isSafeInteger(file.size) && file.size >= 0 ? file.size : null, originatingHuddleId: boundedSlackString(canvasMetadata?.originating_huddle_id, 64), huddleTranscriptFileId: boundedSlackString(file.huddle_transcript_file_id, 64), }; } function parentReferencesSlackFile( parentFile: Record, childFileId: string, ): boolean { if (slackString(parentFile.huddle_transcript_file_id) === childFileId) { return true; } return slackArray(parentFile.embedded_file_ids).some( (candidate) => slackString(candidate) === childFileId, ); } function fileIsSharedToChannel(file: Record, channelId: string): boolean { if ( [file.channels, file.groups, file.ims].some((value) => slackArray(value).some((candidate) => slackString(candidate) === channelId), ) ) { return true; } const shares = slackRecord(file.shares); return ["public", "private"].some((visibility) => { const byChannel = slackRecord(shares?.[visibility]); return Boolean(byChannel && Object.hasOwn(byChannel, channelId)); }); } function privateSlackFileUrl(file: Record): URL | null { const raw = slackString(file.url_private_download) || slackString(file.url_private); if (!raw) return null; try { const url = new URL(raw); assertPrivateSlackFileUrl(url); return url; } catch { throw new SlackBotProviderError("invalid_file_url"); } } function assertPrivateSlackFileUrl(url: URL): void { const hostname = url.hostname.toLowerCase(); if (url.protocol !== "https:" || !SLACK_PRIVATE_FILE_HOSTS.has(hostname) || url.port) { throw new Error("Slack file URL must use an allowed HTTPS Slack host"); } if (url.username || url.password) { throw new Error("Slack file URL must not contain credentials"); } } function isSlackInteractiveFileRedirect(url: URL): boolean { return url.pathname === "/" && url.searchParams.has("redir"); } function isSlackOwnedInteractiveFileRedirect(url: URL): boolean { const hostname = url.hostname.toLowerCase(); return ( url.protocol === "https:" && !url.port && !url.username && !url.password && (hostname === "slack.com" || hostname.endsWith(".slack.com")) && isSlackInteractiveFileRedirect(url) ); } function normalizedContentType(value: string | null): string { return (value ?? "application/octet-stream").split(";", 1)[0]!.trim().toLowerCase(); } export function sniffSlackReactionImageMime( bytes: Uint8Array, ): "image/png" | "image/jpeg" | "image/webp" | null { if (bytes.byteLength < 12 || hasMarkupPrefix(bytes)) return null; if (isCompletePng(bytes)) return "image/png"; if (isCompleteJpeg(bytes)) return "image/jpeg"; if (isCompleteWebp(bytes)) return "image/webp"; return null; } function hasMarkupPrefix(bytes: Uint8Array): boolean { const prefix = new TextDecoder("utf-8", { fatal: false }) .decode(bytes.subarray(0, Math.min(bytes.byteLength, 256))) .replace(/^\uFEFF/u, "") .trimStart() .toLowerCase(); return ( prefix.startsWith(" bytes[index] === value)) return false; let offset = 8; let sawHeader = false; while (offset + 12 <= bytes.byteLength) { const length = readUint32Be(bytes, offset); const typeOffset = offset + 4; const dataOffset = offset + 8; const next = dataOffset + length + 4; if (!Number.isSafeInteger(next) || next > bytes.byteLength) return false; const type = String.fromCharCode( bytes[typeOffset]!, bytes[typeOffset + 1]!, bytes[typeOffset + 2]!, bytes[typeOffset + 3]!, ); if (!sawHeader) { if (type !== "IHDR" || length !== 13) return false; sawHeader = true; } if (type === "IEND") return length === 0 && next === bytes.byteLength; offset = next; } return false; } function isCompleteJpeg(bytes: Uint8Array): boolean { if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false; let offset = 2; let sawFrame = false; let inScan = false; while (offset < bytes.byteLength) { if (inScan) { if (bytes[offset] !== 0xff) { offset += 1; continue; } while (bytes[offset] === 0xff) offset += 1; const marker = bytes[offset]; if (marker === undefined) return false; if (marker === 0x00 || (marker >= 0xd0 && marker <= 0xd7)) { offset += 1; continue; } if (marker === 0xd9) return sawFrame && offset + 1 === bytes.byteLength; inScan = false; offset -= 1; continue; } if (bytes[offset] !== 0xff) return false; while (bytes[offset] === 0xff) offset += 1; const marker = bytes[offset++]; if (marker === undefined || marker === 0x00) return false; if (marker === 0xd9) return sawFrame && offset === bytes.byteLength; if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; if (offset + 2 > bytes.byteLength) return false; const length = (bytes[offset]! << 8) | bytes[offset + 1]!; if (length < 2 || offset + length > bytes.byteLength) return false; if ( marker === 0xc0 || marker === 0xc1 || marker === 0xc2 || marker === 0xc3 || marker === 0xc5 || marker === 0xc6 || marker === 0xc7 || marker === 0xc9 || marker === 0xca || marker === 0xcb || marker === 0xcd || marker === 0xce || marker === 0xcf ) { sawFrame = true; } if (marker === 0xda) inScan = true; offset += length; } return false; } function isCompleteWebp(bytes: Uint8Array): boolean { if ( ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 12) !== "WEBP" || readUint32Le(bytes, 4) + 8 !== bytes.byteLength ) { return false; } let offset = 12; let sawImageChunk = false; while (offset + 8 <= bytes.byteLength) { const type = ascii(bytes, offset, offset + 4); const length = readUint32Le(bytes, offset + 4); const paddedLength = length + (length % 2); const next = offset + 8 + paddedLength; if (!Number.isSafeInteger(next) || next > bytes.byteLength) return false; if (type === "VP8 " || type === "VP8L" || type === "VP8X") sawImageChunk = true; offset = next; } return sawImageChunk && offset === bytes.byteLength; } function readUint32Be(bytes: Uint8Array, offset: number): number { return ( bytes[offset]! * 0x1_000000 + bytes[offset + 1]! * 0x1_0000 + bytes[offset + 2]! * 0x100 + bytes[offset + 3]! ); } function readUint32Le(bytes: Uint8Array, offset: number): number { return ( bytes[offset]! + bytes[offset + 1]! * 0x100 + bytes[offset + 2]! * 0x1_0000 + bytes[offset + 3]! * 0x1_000000 ); } function ascii(bytes: Uint8Array, start: number, end: number): string { return String.fromCharCode(...bytes.subarray(start, end)); } function isSupportedSlackTextContentType(value: string): boolean { return ( value.startsWith("text/") || value === "application/json" || value === "application/xml" || value === "application/xhtml+xml" || value === "application/vnd.slack-docs" || value === "application/vnd.slack-huddle-transcript" ); } function embeddedHuddleTranscription( fileRecord: Record, parentFileRecord: Record | null, ): { contentType: string; content: string } | null { for (const record of [fileRecord, parentFileRecord]) { if (!record) continue; const transcription = slackRecord(record.huddle_transcription); if (!transcription) continue; const content = JSON.stringify(transcription); if (content !== "{}") { return { contentType: "application/json", content }; } } return null; } function embeddedSlackFileIds(content: string): Set { return new Set( [...content.matchAll(/\bFile ID:\s*sf:([A-Z][A-Z0-9]{4,63})\b/g)].map((match) => match[1]!), ); } /** * Bounded projections of `assistant.search.context` results. Content excerpts * are capped, Slack's rich `blocks` and context messages are deliberately * dropped, and permalinks are kept only when they are https URLs. */ function projectSearchMessage(value: unknown) { const message = slackRecord(value); const channelId = slackString(message?.channel_id); const ts = slackString(message?.message_ts); if (!message || !channelId || !ts) return null; return { channelId, channelName: boundedSlackString(message.channel_name, 256), ts, authorUserId: boundedSlackString(message.author_user_id, 64), authorName: boundedSlackString(message.author_name, 256), isAuthorBot: message.is_author_bot === true, content: boundedSlackString(message.content, MAX_SEARCH_CONTENT_CHARS), permalink: safeSlackPermalink(message.permalink), }; } function projectSearchFile(value: unknown) { const file = slackRecord(value); const id = slackString(file?.file_id); if (!file || !id) return null; return { id, title: boundedSlackString(file.title, 512), filetype: boundedSlackString(file.file_type, 128), authorUserId: boundedSlackString(file.author_user_id, 64), authorName: boundedSlackString(file.author_name, 256), dateCreated: slackUnixSeconds(file.date_created), dateUpdated: slackUnixSeconds(file.date_updated), content: boundedSlackString(file.content, MAX_SEARCH_CONTENT_CHARS), permalink: safeSlackPermalink(file.permalink), }; } function projectSearchChannel(value: unknown) { const channel = slackRecord(value); const name = slackString(channel?.name); if (!channel || !name) return null; return { name: boundedSlackString(name, 256), topic: boundedSlackString(channel.topic, 512), purpose: boundedSlackString(channel.purpose, 512), creatorUserId: boundedSlackString(channel.creator_user_id, 64), dateCreated: slackUnixSeconds(channel.date_created), permalink: safeSlackPermalink(channel.permalink), }; } function slackUnixSeconds(value: unknown): number | null { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; } function safeSlackPermalink(value: unknown): string | null { // An over-long URL is dropped, never truncated into a still-parseable one. const raw = typeof value === "string" && value.length <= 1_024 ? value : ""; if (!raw) return null; try { return new URL(raw).protocol === "https:" ? raw : null; } catch { return null; } } function projectUser(value: unknown) { const user = slackRecord(value); const id = slackString(user?.id); if (!user || !id) return null; const profile = slackRecord(user.profile); return { id, name: boundedSlackString(user.name, 256), displayName: boundedSlackString(profile?.display_name, 256), realName: boundedSlackString(profile?.real_name, 256), isBot: user.is_bot === true, deleted: user.deleted === true, }; } function responseCursor(payload: SlackPayload): string | null { return boundedSlackString(slackRecord(payload.response_metadata)?.next_cursor, 1_024) || null; } export function resolveSlackFilesListPage( input: { channelId: string; limit?: number; cursor?: string }, context: SlackFilesCursorContext, ): SlackFilesListPage { const requestedCount = boundedInt(input.limit, MAX_FILE_PAGE, 100); if (!input.cursor) return { count: requestedCount, page: 1 }; if (input.cursor.length > MAX_FILE_CURSOR_LENGTH) { throw new SlackBotProviderError("invalid_files_cursor"); } const [version, encoded, signature, extra] = input.cursor.split("."); if ( version !== SLACK_FILE_CURSOR_VERSION || !encoded || !signature || extra !== undefined || !/^[A-Za-z0-9_-]+$/.test(encoded) || !/^[A-Za-z0-9_-]+$/.test(signature) ) { throw new SlackBotProviderError("invalid_files_cursor"); } const expectedSignature = createHmac("sha256", context.key).update(encoded).digest(); const receivedSignature = Buffer.from(signature, "base64url"); if ( receivedSignature.toString("base64url") !== signature || receivedSignature.length !== expectedSignature.length || !timingSafeEqual(receivedSignature, expectedSignature) ) { throw new SlackBotProviderError("invalid_files_cursor"); } let decoded: unknown; try { const bytes = Buffer.from(encoded, "base64url"); if (bytes.toString("base64url") !== encoded) { throw new Error("non-canonical cursor"); } decoded = JSON.parse(bytes.toString("utf8")); } catch { throw new SlackBotProviderError("invalid_files_cursor"); } const cursor = slackRecord(decoded); if ( !cursor || Object.keys(cursor).sort().join(",") !== "channelId,connectionId,count,page" || cursor.connectionId !== context.connectionId || cursor.channelId !== input.channelId || !positiveSafeInt(cursor.count) || cursor.count > MAX_FILE_PAGE || !positiveSafeInt(cursor.page) || (input.limit !== undefined && requestedCount !== cursor.count) ) { throw new SlackBotProviderError("invalid_files_cursor"); } return { count: cursor.count, page: cursor.page }; } export function createSlackFilesListCursor( input: { channelId: string; count: number; page: number }, context: SlackFilesCursorContext, ): string { const encoded = Buffer.from( JSON.stringify({ connectionId: context.connectionId, channelId: input.channelId, count: input.count, page: input.page, }), ).toString("base64url"); const signature = createHmac("sha256", context.key).update(encoded).digest("base64url"); return `${SLACK_FILE_CURSOR_VERSION}.${encoded}.${signature}`; } export function nextSlackFilesListPage( payload: SlackPayload, requested: SlackFilesListPage, returnedFileCount: number, ): number | null { const responseMetadata = slackRecord(payload.response_metadata); const paging = slackRecord(payload.paging); if (responseMetadata && Object.hasOwn(responseMetadata, "next_cursor")) { throw new SlackBotProviderError("invalid_files_paging"); } const count = paging?.count; const total = paging?.total; const page = paging?.page; const pages = paging?.pages; if ( !positiveSafeInt(count) || !nonNegativeSafeInt(total) || !positiveSafeInt(page) || !nonNegativeSafeInt(pages) || count !== requested.count || page !== requested.page || returnedFileCount > count || pages !== (total === 0 ? 0 : Math.ceil(total / count)) || page > Math.max(1, pages) ) { throw new SlackBotProviderError("invalid_files_paging"); } if (page >= pages) return null; const nextPage = page + 1; if (!positiveSafeInt(nextPage)) { throw new SlackBotProviderError("invalid_files_paging"); } return nextPage; } function validateSlackMessageBlocks( blocks: SlackMessageBlock[] | undefined, ): SlackMessageBlock[] | undefined { if (blocks === undefined) return undefined; if (blocks.length < 1 || blocks.length > MAX_SLACK_BLOCKS) { throw new RangeError("Slack message blocks exceed the supported count"); } if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > MAX_SLACK_BLOCK_BYTES) { throw new RangeError("Slack message blocks exceed the supported byte size"); } return blocks; } function validateSlackHomeBlocks(blocks: SlackHomeBlock[]): SlackHomeBlock[] { if (blocks.length < 1 || blocks.length > 100) { throw new RangeError("Slack App Home blocks exceed the supported count"); } if (Buffer.byteLength(JSON.stringify(blocks), "utf8") > 48 * 1024) { throw new RangeError("Slack App Home blocks exceed the supported byte size"); } return blocks; } function boundedInt(value: number | undefined, max: number, fallback: number): number { return typeof value === "number" && Number.isInteger(value) && value > 0 ? Math.min(value, max) : fallback; } function positiveSafeInt(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value > 0; } function nonNegativeSafeInt(value: unknown): value is number { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } function slackArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function slackRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function slackString(value: unknown): string { return typeof value === "string" ? value : ""; } function requiredSlackString(value: unknown, field: string): string { const result = slackString(value); if (!result || result.length > 256) { throw new SlackBotProviderError(`invalid_${field.replaceAll(".", "_")}`); } return result; } function boundedSlackString(value: unknown, max: number): string { return slackString(value).slice(0, max); } function safeSlackCode(value: string): string { return /^[a-z0-9_.:-]{1,128}$/i.test(value) ? value : "unknown_error"; } function safeFailureCode(error: unknown): string { if (error instanceof SlackBotProviderError) return safeSlackCode(error.code); return "local_validation_failed"; } function slackMutationOutcomeMayBeAmbiguous(error: unknown): boolean { if (!(error instanceof SlackBotProviderError)) return true; if (error.code === "transport_error" || error.code === "invalid_response") return true; const httpStatus = /^http_(\d{3})$/u.exec(error.code)?.[1]; if (!httpStatus) return false; const status = Number(httpStatus); return status === 408 || status >= 500; }