import type { DestinationAdapter } from "./dispatchAdapters"; import { postChatMessage as defaultPostChatMessage, type SlackChatPostMessageResult, } from "./slackClient"; export interface SlackWorkspaceForAdapter { status: string; teamId: string; } export interface SecretManagerClient { getSecret(vault: string, key: string): Promise; } export interface CreateSlackDestinationAdapterDeps { loadConnection: () => Promise; secretManager: SecretManagerClient; markConnectionRevoked?: (reason: string) => Promise; postChatMessage?: ( botToken: string, channel: string, text: string, ) => Promise; } // Slack `error` values that mean the stored bot token is no longer usable. const TOKEN_REVOKED_ERRORS = new Set(["invalid_auth", "token_revoked", "account_inactive"]); // Slack `error` values that mean the target channel is unreachable: the bot is // not a member, or the externalChannelRef is invalid. Split out from the generic // class because the operator action is specific and common (invite the bot to // the channel, or fix the binding). const CHANNEL_UNREACHABLE_ERRORS = new Set(["not_in_channel", "channel_not_found"]); function redact(result: { ok: boolean; ts?: string; channel?: string; error?: string }): string { // Persist only delivery-trace essentials — never the posted text/blocks or // Slack internal metadata. return JSON.stringify({ ok: result.ok, ts: result.ts ?? null, channel: result.channel ?? null, error: result.ok ? null : (result.error ?? null), }); } export function createSlackDestinationAdapter( deps: CreateSlackDestinationAdapterDeps, ): DestinationAdapter { const postFn = deps.postChatMessage ?? defaultPostChatMessage; return { async send({ channelId, externalChannelRef, subject, body }) { if (channelId !== "SLACK") { return { ok: false, errorClass: "ChannelMismatch", errorDetail: channelId, providerResponse: redact({ ok: false, error: "channel_mismatch" }), }; } const conn = await deps.loadConnection(); if (!conn || conn.status !== "ACTIVE") { return { ok: false, errorClass: "WorkspaceNotConnected", errorDetail: "workspace_not_connected", providerResponse: redact({ ok: false, error: "workspace_not_connected" }), }; } const botToken = await deps.secretManager.getSecret("slack-tokens", conn.teamId); if (!botToken) { return { ok: false, errorClass: "WorkspaceNotConnected", errorDetail: "workspace_not_connected", providerResponse: redact({ ok: false, error: "workspace_not_connected" }), }; } const text = subject || body; let result: SlackChatPostMessageResult; try { result = await postFn(botToken, externalChannelRef, text); } catch (e) { if (e instanceof Error && e.name === "TimeoutError") { return { ok: false, errorClass: "SlackTimeout", errorDetail: "timeout", providerResponse: redact({ ok: false, error: "timeout" }), }; } const detail = e instanceof Error ? e.message : "post_failed"; return { ok: false, errorClass: "SlackPostThrew", errorDetail: detail, providerResponse: redact({ ok: false, error: detail }), }; } const providerResponse = redact(result); if (result.ok) { return { ok: true, ...(result.ts ? { externalMessageId: result.ts } : {}), providerResponse, }; } const slackError = result.error ?? "unknown"; // Token revoked / auth dead: the connection is unusable until a // re-install. Optionally flip the stored row to REVOKED (best-effort) // so later sends fail fast as workspace_not_connected. if (TOKEN_REVOKED_ERRORS.has(slackError)) { if (deps.markConnectionRevoked) { try { await deps.markConnectionRevoked(slackError); } catch { // Best-effort: the delivery outcome below already records the // revocation cause. } } return { ok: false, errorClass: "SlackTokenRevoked", errorDetail: slackError, providerResponse, }; } // Rate limited (HTTP 429): keep the Retry-After hint in the failure // detail so it lands on DestinationDeliveryLog.failureReason. if (slackError === "ratelimited") { return { ok: false, errorClass: "SlackRateLimited", errorDetail: result.retryAfterSeconds !== undefined ? `ratelimited retry_after=${result.retryAfterSeconds}` : "ratelimited", providerResponse, }; } // Bot not in channel / channel gone: a specific, common operator fix. if (CHANNEL_UNREACHABLE_ERRORS.has(slackError)) { return { ok: false, errorClass: "SlackChannelUnreachable", errorDetail: slackError, providerResponse, }; } // Everything else stays a plain Slack API error. return { ok: false, errorClass: "SlackApiError", errorDetail: slackError, providerResponse, }; }, }; }