diff --git a/apps/dashboard/src/components/provider-icons.tsx b/apps/dashboard/src/components/provider-icons.tsx
index 2cdbb6e8..0fca4ed5 100644
--- a/apps/dashboard/src/components/provider-icons.tsx
+++ b/apps/dashboard/src/components/provider-icons.tsx
@@ -266,6 +266,29 @@ function SendGridIcon({ size = 24, ...props }: ProviderIconProps) {
);
}
+function SlackIcon({ size = 24, ...props }: ProviderIconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
export const GitHubIcon = createSimpleProviderIcon(siGithub);
export const ProviderIcons = {
@@ -300,6 +323,7 @@ export const ProviderIcons = {
posthog: createSimpleProviderIcon(siPosthog),
sendgrid: SendGridIcon,
sentry: createSimpleProviderIcon(siSentry),
+ slack: SlackIcon,
snowflake: createSimpleProviderIcon(siSnowflake),
tiktok_marketing: createSimpleProviderIcon(siTiktok),
vercel: createSimpleProviderIcon(siVercel),
diff --git a/apps/landing/src/features/connectors/Browser.astro b/apps/landing/src/features/connectors/Browser.astro
index 0fc7ba80..eb25c36e 100644
--- a/apps/landing/src/features/connectors/Browser.astro
+++ b/apps/landing/src/features/connectors/Browser.astro
@@ -100,7 +100,8 @@ const categoryOptions = [
{connector.capabilities.map((capability) => {capability})}
{connector.label}
- {connector.category}
+ {connector.category}
+ {connector.description}
))
diff --git a/apps/landing/src/features/connectors/data.test.ts b/apps/landing/src/features/connectors/data.test.ts
index 06fab4a9..4c736bce 100644
--- a/apps/landing/src/features/connectors/data.test.ts
+++ b/apps/landing/src/features/connectors/data.test.ts
@@ -38,6 +38,19 @@ describe("DATA_SOURCE_CONNECTORS", () => {
);
});
+ it("includes Slack as an API connector with provider description copy", () => {
+ expect(getConnector("slack")).toEqual(
+ expect.objectContaining({
+ capabilities: ["API"],
+ category: "Productivity",
+ description: expect.stringContaining("Slack"),
+ key: "slack",
+ label: "Slack",
+ slug: "slack",
+ })
+ );
+ });
+
it("creates connector FAQ copy from provider metadata", () => {
const connector = getConnector("github");
const faqs = getConnectorFaqs(connector);
diff --git a/apps/landing/src/features/connectors/styles.css b/apps/landing/src/features/connectors/styles.css
index f3e22e66..4cfceb4a 100644
--- a/apps/landing/src/features/connectors/styles.css
+++ b/apps/landing/src/features/connectors/styles.css
@@ -221,8 +221,8 @@
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 16px;
- align-items: center;
- min-height: 126px;
+ align-items: start;
+ min-height: 174px;
padding: 12px 18px 12px 12px;
border: 1px solid var(--connector-line);
border-radius: 28px;
@@ -251,6 +251,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
+ margin-top: 3px;
width: 76px;
height: 76px;
border-radius: 18px;
@@ -310,16 +311,29 @@
white-space: nowrap;
}
-.connector-card p {
+.connector-card-category,
+.connector-card-description {
margin: 6px 0 0;
- overflow: hidden;
color: var(--connector-muted);
+ line-height: 1.35;
+}
+
+.connector-card-category {
+ overflow: hidden;
font-size: 18px;
- line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
+.connector-card-description {
+ display: -webkit-box;
+ overflow: hidden;
+ color: var(--connector-soft);
+ font-size: 14px;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+}
+
.connector-detail-main {
width: min(100%, 1180px);
margin: 0 auto;
@@ -715,7 +729,7 @@
}
.connector-card {
- min-height: 112px;
+ min-height: 158px;
border-radius: 22px;
}
diff --git a/apps/landing/src/shared/icons/brands.tsx b/apps/landing/src/shared/icons/brands.tsx
index 503b1df4..a71719b7 100644
--- a/apps/landing/src/shared/icons/brands.tsx
+++ b/apps/landing/src/shared/icons/brands.tsx
@@ -173,6 +173,29 @@ function SendGridIcon({ size, ...props }: IconSvgProps) {
);
}
+function SlackIcon({ size, ...props }: IconSvgProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
function CloudflareD1Icon({ size, ...props }: IconSvgProps) {
return (
credentialSchemaMap > matches supported provider
"posthog",
"sendgrid",
"sentry",
+ "slack",
"snowflake",
"tiktok_marketing",
"vercel",
diff --git a/packages/db/src/credentials.test.ts b/packages/db/src/credentials.test.ts
index 4b43bedb..d7317ee5 100644
--- a/packages/db/src/credentials.test.ts
+++ b/packages/db/src/credentials.test.ts
@@ -25,6 +25,7 @@ import {
isLinearCredentials,
isMongoCredentials,
isOAuthCredentials,
+ isSlackCredentials,
isTokenExpired,
LaminarCredentialsSchema,
LinearCredentialsSchema,
@@ -38,6 +39,7 @@ import {
PostHogCredentialsSchema,
SendGridCredentialsSchema,
SentryCredentialsSchema,
+ SlackCredentialsSchema,
SnowflakeCredentialsSchema,
TikTokMarketingCredentialsSchema,
VercelCredentialsSchema,
@@ -73,6 +75,7 @@ import type {
PostHogCredentials,
SendGridCredentials,
SentryCredentials,
+ SlackCredentials,
SnowflakeCredentials,
TikTokMarketingCredentials,
VercelCredentials,
@@ -1407,6 +1410,58 @@ describe("credentials schemas", () => {
});
});
+ describe("SlackCredentialsSchema", () => {
+ it("validates canonical Slack credentials", () => {
+ const credentials: SlackCredentials = {
+ botScopes: ["channels:read", "channels:history"],
+ botToken: "xoxb-token",
+ botUserId: "U123",
+ teamId: "T123",
+ teamName: "Acme",
+ type: "slack",
+ };
+
+ const result = SlackCredentialsSchema.safeParse(credentials);
+ expect(result.success).toBe(true);
+ });
+
+ it("normalizes legacy comma-delimited scope credentials", () => {
+ const result = SlackCredentialsSchema.safeParse({
+ botToken: "xoxb-token",
+ botUserId: "U123",
+ scope: "channels:read, channels:history,",
+ teamId: "T123",
+ teamName: "Acme",
+ type: "slack",
+ });
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data).toEqual({
+ botScopes: ["channels:read", "channels:history"],
+ botToken: "xoxb-token",
+ botUserId: "U123",
+ teamId: "T123",
+ teamName: "Acme",
+ type: "slack",
+ });
+ }
+ });
+
+ it("rejects blank bot tokens", () => {
+ const result = SlackCredentialsSchema.safeParse({
+ botScopes: ["channels:read"],
+ botToken: " ",
+ botUserId: "U123",
+ teamId: "T123",
+ teamName: "Acme",
+ type: "slack",
+ });
+
+ expect(result.success).toBe(false);
+ });
+ });
+
describe("CalCredentialsSchema", () => {
it("defaults apiVersion to the current v2 header version", () => {
const result = CalCredentialsSchema.safeParse({
@@ -1857,6 +1912,10 @@ describe("credentials schemas", () => {
expect(credentialSchemaMap.discord).toBe(DiscordCredentialsSchema);
});
+ it("should map slack to SlackCredentialsSchema", () => {
+ expect(credentialSchemaMap.slack).toBe(SlackCredentialsSchema);
+ });
+
it("should map cal to CalCredentialsSchema", () => {
expect(credentialSchemaMap.cal).toBe(CalCredentialsSchema);
});
@@ -1953,6 +2012,20 @@ describe("credentials schemas", () => {
});
});
+ describe("isSlackCredentials", () => {
+ it("should return true for Slack credentials", () => {
+ const credentials: SlackCredentials = {
+ botScopes: ["channels:read"],
+ botToken: "xoxb-token",
+ botUserId: "U123",
+ teamId: "T123",
+ teamName: "Acme",
+ type: "slack",
+ };
+ expect(isSlackCredentials(credentials)).toBe(true);
+ });
+ });
+
describe("normalizeEnvVarName", () => {
it("matches normalized env var snapshots", () => {
expect({
diff --git a/packages/db/src/credentials.ts b/packages/db/src/credentials.ts
index 84474375..ed67bf0b 100644
--- a/packages/db/src/credentials.ts
+++ b/packages/db/src/credentials.ts
@@ -273,6 +273,51 @@ export const DiscordCredentialsSchema = z.object({
export type DiscordCredentials = z.infer;
+const parseSlackScopeList = (scope: string): string[] =>
+ scope
+ .split(",")
+ .map((value) => value.trim())
+ .filter((value) => value.length > 0);
+
+const SlackCredentialsBaseSchema = z.object({
+ botToken: requiredOpaqueString("Bot token is required"),
+ botUserId: z.string(),
+ teamId: trimmedString("Team ID is required"),
+ teamName: z.string(),
+ type: z.literal("slack"),
+});
+
+const CanonicalSlackCredentialsSchema = SlackCredentialsBaseSchema.extend({
+ botScopes: z.array(z.string()),
+});
+
+const LegacySlackCredentialsSchema = SlackCredentialsBaseSchema.extend({
+ botScopes: z.array(z.string()).optional(),
+ scope: z.string(),
+});
+
+export const SlackCredentialsSchema = z
+ .union([CanonicalSlackCredentialsSchema, LegacySlackCredentialsSchema])
+ .transform((credentials) => {
+ if ("scope" in credentials) {
+ // Comment: Older Slack installs stored a comma-delimited `scope` string.
+ // Normalize on read so source-api adapters only receive `botScopes`.
+ return {
+ botScopes:
+ credentials.botScopes ?? parseSlackScopeList(credentials.scope),
+ botToken: credentials.botToken,
+ botUserId: credentials.botUserId,
+ teamId: credentials.teamId,
+ teamName: credentials.teamName,
+ type: credentials.type,
+ };
+ }
+
+ return credentials;
+ });
+
+export type SlackCredentials = z.infer;
+
export const CalCredentialsSchema = z.object({
apiBaseUrl: optionalTrimmedUrl("API base URL must be a valid URL"),
apiKey: requiredOpaqueString("API key is required"),
@@ -471,6 +516,7 @@ export const CredentialsSchema = z.union([
GitHubCredentialsSchema,
AirtableCredentialsSchema,
DiscordCredentialsSchema,
+ SlackCredentialsSchema,
CalCredentialsSchema,
GranolaCredentialsSchema,
GoogleSearchConsoleCredentialsSchema,
@@ -552,6 +598,7 @@ export const credentialSchemaMap = {
posthog: PostHogCredentialsSchema,
sendgrid: SendGridCredentialsSchema,
sentry: SentryCredentialsSchema,
+ slack: SlackCredentialsSchema,
snowflake: SnowflakeCredentialsSchema,
tiktok_marketing: TikTokMarketingCredentialsSchema,
vercel: VercelCredentialsSchema,
@@ -627,6 +674,12 @@ export function isLinearCredentials(
return credentials.type === "linear";
}
+export function isSlackCredentials(
+ credentials: Credentials
+): credentials is SlackCredentials {
+ return credentials.type === "slack";
+}
+
export function normalizeEnvVarName(name: string): string {
return name
.toUpperCase()
diff --git a/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap b/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap
index b1bc1fe0..f757c2a8 100644
--- a/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap
+++ b/packages/db/src/schema/__snapshots__/data-sources.test.ts.snap
@@ -21,6 +21,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = `
"github",
"airtable",
"discord",
+ "slack",
"cal",
"granola",
"google_search_console",
@@ -55,6 +56,7 @@ exports[`data-sources schema > matches provider type snapshots 1`] = `
"github",
"airtable",
"discord",
+ "slack",
"cal",
"granola",
"google_search_console",
diff --git a/packages/db/src/source-providers.ts b/packages/db/src/source-providers.ts
index 48395d19..a39ddaaa 100644
--- a/packages/db/src/source-providers.ts
+++ b/packages/db/src/source-providers.ts
@@ -29,6 +29,7 @@ import {
PostHogCredentialsSchema,
SendGridCredentialsSchema,
SentryCredentialsSchema,
+ SlackCredentialsSchema,
SnowflakeCredentialsSchema,
TikTokMarketingCredentialsSchema,
VercelCredentialsSchema,
@@ -624,6 +625,39 @@ export const SOURCE_PROVIDER_REGISTRY = {
},
},
},
+ slack: {
+ label: "Slack",
+ credentialSchema: SlackCredentialsSchema,
+ credentialType: "slack",
+ connectable: true,
+ analysisSource: true,
+ queryInterface: false,
+ sourceApiInterface: true,
+ testable: false,
+ dashboardConnectable: true,
+ dashboardCredentialForm: "slack_oauth",
+ publicCategory: "Productivity",
+ guide: {
+ summary:
+ "Connect Slack so agents can read channel and thread history the installed app can access.",
+ steps: [
+ "Authorize or install the Slack app for the target workspace.",
+ "Invite the app to any private channels the source should analyze.",
+ "Use channel IDs or channel names when querying Slack history.",
+ ],
+ exampleInput: {
+ sourceKey: "slack_workspace",
+ credentials: {
+ type: "slack",
+ botToken: "xoxb-...",
+ botUserId: "U1234567890",
+ teamId: "T1234567890",
+ teamName: "Acme",
+ botScopes: ["channels:read", "channels:history"],
+ },
+ },
+ },
+ },
cal: {
label: "Cal.com",
credentialSchema: CalCredentialsSchema,
diff --git a/packages/server/src/source-api/adapters/slack.test.ts b/packages/server/src/source-api/adapters/slack.test.ts
new file mode 100644
index 00000000..af016bd0
--- /dev/null
+++ b/packages/server/src/source-api/adapters/slack.test.ts
@@ -0,0 +1,196 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { PreparedSourceConnection, SourceApiActorContext } from "../types";
+import { slackSourceApiAdapter } from "./slack";
+
+const originalFetch = globalThis.fetch;
+
+const actor: SourceApiActorContext = {
+ capabilities: ["source_api.execute"],
+ membershipRoles: ["owner"],
+ organizationId: "org_1",
+ organizationSlug: "acme",
+ userId: "user_1",
+};
+
+const source: PreparedSourceConnection = {
+ credentials: {
+ botScopes: ["channels:read", "channels:history"],
+ botToken: "xoxb-token",
+ botUserId: "U123",
+ teamId: "T123",
+ teamName: "Acme",
+ type: "slack",
+ },
+ displayName: "Slack Workspace",
+ id: "source_1",
+ provider: "slack",
+ sourceKey: "slack-workspace",
+};
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ vi.restoreAllMocks();
+});
+
+describe("slack source API adapter", () => {
+ it("describes Slack structured operations", async () => {
+ const descriptor = await slackSourceApiAdapter.describe({
+ actor,
+ source,
+ });
+
+ expect(descriptor.source.provider).toBe("slack");
+ expect(descriptor.operations.map((operation) => operation.name)).toEqual([
+ "list_channels",
+ "fetch_channel_history",
+ "fetch_thread_replies",
+ ]);
+ });
+
+ it("normalizes channel history requests into a structured plan", async () => {
+ const descriptor = await slackSourceApiAdapter.describe({
+ actor,
+ source,
+ });
+
+ const plan = await slackSourceApiAdapter.normalize({
+ actor,
+ descriptor,
+ request: {
+ body: { kind: "none" },
+ fieldPatch: {
+ limit: 50,
+ },
+ headers: [],
+ operation: "fetch_channel_history",
+ selector: "#general",
+ },
+ source,
+ });
+
+ expect(plan).toMatchObject({
+ kind: "structured_request",
+ method: "POST",
+ operation: "fetch_channel_history",
+ provider: "slack",
+ request: {
+ limit: 50,
+ },
+ selector: "#general",
+ selectorTemplate: "channels/{channel}",
+ });
+ });
+
+ it("executes Slack channel history with bot authorization", async () => {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ ok: true,
+ messages: [{ text: "hello", ts: "1730000000.000000" }],
+ }),
+ {
+ headers: {
+ "content-type": "application/json",
+ },
+ status: 200,
+ }
+ )
+ );
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
+
+ const response = await slackSourceApiAdapter.execute({
+ actor,
+ prepared: {
+ body: { kind: "none" },
+ bodyKind: "none",
+ bodyPaths: ["limit"],
+ descriptorVersion: "slack.v1",
+ headerNames: [],
+ headers: [],
+ kind: "structured_request",
+ method: "POST",
+ operation: "fetch_channel_history",
+ paginationPolicy: "none",
+ preparedBinding: "binding",
+ provider: "slack",
+ request: {
+ limit: 25,
+ },
+ selector: "C1234567890",
+ selectorTemplate: "channels/{channel}",
+ sourceId: "source_1",
+ sourceKey: "slack-workspace",
+ },
+ source,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({
+ kind: "json",
+ value: {
+ messages: [{ text: "hello", ts: "1730000000.000000" }],
+ ok: true,
+ },
+ });
+ const [calledUrl, calledInit] = fetchMock.mock.calls[0] ?? [];
+ expect(String(calledUrl)).toBe(
+ "https://slack.com/api/conversations.history"
+ );
+ expect(calledInit?.headers).toMatchObject({
+ Authorization: "Bearer xoxb-token",
+ "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
+ });
+ expect(String(calledInit?.body)).toBe("channel=C1234567890&limit=25");
+ });
+
+ it("rejects thread replies without a thread timestamp", async () => {
+ const descriptor = await slackSourceApiAdapter.describe({
+ actor,
+ source,
+ });
+
+ await expect(
+ slackSourceApiAdapter.normalize({
+ actor,
+ descriptor,
+ request: {
+ body: { kind: "none" },
+ fieldPatch: {},
+ headers: [],
+ operation: "fetch_thread_replies",
+ selector: "#general",
+ },
+ source,
+ })
+ ).resolves.toMatchObject({
+ operation: "fetch_thread_replies",
+ });
+
+ await expect(
+ slackSourceApiAdapter.execute({
+ actor,
+ prepared: {
+ body: { kind: "none" },
+ bodyKind: "none",
+ bodyPaths: [],
+ descriptorVersion: "slack.v1",
+ headerNames: [],
+ headers: [],
+ kind: "structured_request",
+ method: "POST",
+ operation: "fetch_thread_replies",
+ paginationPolicy: "none",
+ preparedBinding: "binding",
+ provider: "slack",
+ request: {},
+ selector: "C1234567890",
+ selectorTemplate: "channels/{channel}",
+ sourceId: "source_1",
+ sourceKey: "slack-workspace",
+ },
+ source,
+ })
+ ).rejects.toThrow('Slack thread replies require request field "ts"');
+ });
+});
diff --git a/packages/server/src/source-api/adapters/slack.ts b/packages/server/src/source-api/adapters/slack.ts
new file mode 100644
index 00000000..3ec96b14
--- /dev/null
+++ b/packages/server/src/source-api/adapters/slack.ts
@@ -0,0 +1,520 @@
+import type { JsonObject, JsonValue } from "@bufbuild/protobuf";
+import { isRecord } from "@onequery/base";
+import type { SlackCredentials } from "@onequery/db/server";
+import { z } from "zod";
+
+import {
+ SourceApiInvalidRequestError,
+ SourceApiUnsupportedOperationError,
+} from "../errors";
+import {
+ filterAllowedResponseHeaders,
+ normalizeAllowedHeaders,
+} from "../helpers/http-rest";
+import {
+ createStructuredRequestOperation,
+ mergeStructuredFieldPatch,
+} from "../helpers/structured";
+import type {
+ PreparedSourceConnection,
+ SourceApiAdapter,
+ SourceApiDescriptor,
+ SourceApiExample,
+ SourceApiExecutionResult,
+ SourceApiOperation,
+ SourceApiRequestBody,
+} from "../types";
+
+const SLACK_DESCRIPTOR_VERSION = "slack.v1";
+const SLACK_API_BASE_URL = "https://slack.com/api";
+const SLACK_ALLOWED_RESPONSE_HEADERS = ["content-type"] as const;
+const CHANNEL_ID_PATTERN = /^[CDG][A-Z0-9]+$/;
+
+const slackOperationSchema = z.enum([
+ "list_channels",
+ "fetch_channel_history",
+ "fetch_thread_replies",
+]);
+
+type SlackOperation = z.infer;
+
+const slackBaseRequestSchema = z
+ .object({
+ cursor: z.string().trim().min(1).optional(),
+ inclusive: z.boolean().optional(),
+ latest: z.string().trim().min(1).optional(),
+ limit: z.coerce.number().int().min(1).max(200).optional(),
+ oldest: z.string().trim().min(1).optional(),
+ })
+ .loose();
+
+const slackListChannelsRequestSchema = slackBaseRequestSchema.extend({
+ exclude_archived: z.boolean().optional(),
+ types: z.string().trim().min(1).optional(),
+});
+
+const slackChannelHistoryRequestSchema = slackBaseRequestSchema;
+
+const slackThreadRepliesRequestSchema = slackBaseRequestSchema.extend({
+ thread_ts: z.string().trim().min(1).optional(),
+ ts: z.string().trim().min(1).optional(),
+});
+
+type SlackRequest = z.infer &
+ Record;
+
+class SlackInvalidRequestError extends SourceApiInvalidRequestError {}
+
+type SlackApiResponse = {
+ body: { kind: "json"; value: JsonValue };
+ contentType: string;
+ headers: [];
+ status: number;
+};
+
+export const slackSourceApiAdapter: SourceApiAdapter = {
+ provider: "slack",
+ async describe({ source }) {
+ const examples = buildSlackExamples(source.sourceKey);
+
+ return {
+ descriptorVersion: SLACK_DESCRIPTOR_VERSION,
+ examples,
+ notes: [
+ "Slack returns only channels and messages the installed app can access.",
+ "Invite the Slack app to private channels before querying them.",
+ "Use Slack timestamps as strings for oldest, latest, and thread ts values.",
+ ],
+ operations: [
+ createStructuredRequestOperation({
+ allowedResponseHeaders: SLACK_ALLOWED_RESPONSE_HEADERS,
+ description:
+ "List public and private Slack channels visible to the installed app. Optional fields: types, limit, cursor, exclude_archived.",
+ examples: examples.filter(
+ (example) => example.label === "List channels"
+ ),
+ fieldPolicy: {
+ supportsArrayPaths: false,
+ supportsNestedPaths: false,
+ },
+ name: "list_channels",
+ notes: [
+ "Defaults to `public_channel,private_channel` and excludes archived channels.",
+ ],
+ summary: "List Slack channels.",
+ }),
+ createStructuredRequestOperation({
+ allowedResponseHeaders: SLACK_ALLOWED_RESPONSE_HEADERS,
+ description:
+ "Fetch recent Slack messages from one channel. Selector may be a channel ID or #channel-name. Optional fields: limit, cursor, oldest, latest, inclusive.",
+ examples: examples.filter(
+ (example) => example.label === "Fetch channel history"
+ ),
+ fieldPolicy: {
+ supportsArrayPaths: false,
+ supportsNestedPaths: false,
+ },
+ name: "fetch_channel_history",
+ selectorKind: "path",
+ selectorLabel: "CHANNEL",
+ summary: "Fetch Slack channel history.",
+ }),
+ createStructuredRequestOperation({
+ allowedResponseHeaders: SLACK_ALLOWED_RESPONSE_HEADERS,
+ description:
+ "Fetch replies from a Slack thread. Selector may be a channel ID or #channel-name. Required field: ts or thread_ts. Optional fields: limit, cursor, oldest, latest, inclusive.",
+ examples: examples.filter(
+ (example) => example.label === "Fetch thread replies"
+ ),
+ fieldPolicy: {
+ supportsArrayPaths: false,
+ supportsNestedPaths: false,
+ },
+ name: "fetch_thread_replies",
+ selectorKind: "path",
+ selectorLabel: "CHANNEL",
+ summary: "Fetch Slack thread replies.",
+ }),
+ ],
+ source: {
+ displayName: source.displayName,
+ provider: source.provider,
+ sourceKey: source.sourceKey,
+ },
+ };
+ },
+ async normalize({ descriptor, request, source }) {
+ const operation = requireSlackOperation({
+ descriptor,
+ operationName: request.operation,
+ });
+
+ if (request.methodOverride?.trim()) {
+ throw new SlackInvalidRequestError(
+ `Slack operation "${operation.name}" does not support method overrides`
+ );
+ }
+
+ const headers = normalizeAllowedHeaders({
+ allowedNames: operation.headerPolicy.allowedRequestHeaders,
+ headers: request.headers,
+ });
+ const normalizedRequest = parseSlackRequest({
+ operation: operation.name,
+ value: mergeStructuredFieldPatch({
+ base: parseSlackRequestBody(request.body),
+ patch: request.fieldPatch,
+ }),
+ });
+ const selector = normalizeSlackSelector({
+ operation: operation.name,
+ selector: request.selector,
+ });
+
+ return {
+ body: request.body,
+ descriptorVersion: descriptor.descriptorVersion,
+ headers,
+ kind: "structured_request",
+ method: "POST",
+ operation: operation.name,
+ paginationPolicy: operation.paginationPolicy,
+ provider: source.provider,
+ request: normalizedRequest as JsonObject,
+ selector,
+ selectorTemplate: selector ? "channels/{channel}" : undefined,
+ sourceId: source.id,
+ sourceKey: source.sourceKey,
+ };
+ },
+ async execute({ prepared, source }) {
+ if (prepared.kind !== "structured_request") {
+ throw new Error(
+ `Slack source API operation "${prepared.operation}" requires a structured plan`
+ );
+ }
+
+ const operation = parseSlackOperation(prepared.operation);
+ const request = parseSlackRequest({
+ operation,
+ value: prepared.request,
+ });
+ const credentials = requireSlackCredentials(source);
+ const response = await executeSlackOperation({
+ credentials,
+ operation,
+ request,
+ selector: prepared.selector,
+ });
+
+ return {
+ body: response.body,
+ contentType: response.contentType,
+ headers: filterAllowedResponseHeaders({
+ allowedNames: SLACK_ALLOWED_RESPONSE_HEADERS,
+ contentType: response.contentType,
+ headers: response.headers,
+ }),
+ operation: prepared.operation,
+ selector: prepared.selector,
+ source: {
+ displayName: source.displayName,
+ provider: source.provider,
+ sourceKey: source.sourceKey,
+ },
+ status: response.status,
+ } satisfies SourceApiExecutionResult;
+ },
+};
+
+function buildSlackExamples(sourceKey: string): SourceApiExample[] {
+ return [
+ {
+ command: `source_api_execute connectionName="${sourceKey}" operationName="list_channels" fieldPatch=[{name:"limit",jsonValue:"100"}]`,
+ description: "List channels visible to the installed Slack app.",
+ label: "List channels",
+ },
+ {
+ command: `source_api_execute connectionName="${sourceKey}" operationName="fetch_channel_history" selector="#general" fieldPatch=[{name:"limit",jsonValue:"50"}]`,
+ description: "Fetch recent messages from a channel.",
+ label: "Fetch channel history",
+ },
+ {
+ command: `source_api_execute connectionName="${sourceKey}" operationName="fetch_thread_replies" selector="#general" fieldPatch=[{name:"ts",jsonValue:"\\"1730000000.000000\\""}]`,
+ description: "Fetch replies from a Slack thread.",
+ label: "Fetch thread replies",
+ },
+ ];
+}
+
+function requireSlackOperation(input: {
+ descriptor: SourceApiDescriptor;
+ operationName: string;
+}): SourceApiOperation {
+ const operation = input.descriptor.operations.find(
+ (candidate) => candidate.name === input.operationName
+ );
+ if (!operation) {
+ throw new SourceApiUnsupportedOperationError(input.operationName);
+ }
+ return operation;
+}
+
+function parseSlackOperation(operation: string): SlackOperation {
+ const parsed = slackOperationSchema.safeParse(operation);
+ if (!parsed.success) {
+ throw new SourceApiUnsupportedOperationError(operation);
+ }
+ return parsed.data;
+}
+
+function parseSlackRequest(input: {
+ operation: string;
+ value: unknown;
+}): SlackRequest {
+ const schema =
+ input.operation === "list_channels"
+ ? slackListChannelsRequestSchema
+ : input.operation === "fetch_thread_replies"
+ ? slackThreadRepliesRequestSchema
+ : input.operation === "fetch_channel_history"
+ ? slackChannelHistoryRequestSchema
+ : null;
+
+ if (!schema) {
+ throw new SourceApiUnsupportedOperationError(input.operation);
+ }
+
+ const parsed = schema.safeParse(input.value);
+ if (!parsed.success) {
+ throw new SlackInvalidRequestError(
+ `Invalid Slack ${input.operation} request: ${z.prettifyError(parsed.error)}`
+ );
+ }
+ return parsed.data;
+}
+
+function parseSlackRequestBody(body: SourceApiRequestBody): JsonObject {
+ if (body.kind === "none") {
+ return {};
+ }
+ if (body.kind === "json" && isRecord(body.value)) {
+ return body.value;
+ }
+ throw new SlackInvalidRequestError(
+ "Slack source API requests must use a JSON object body or fieldPatch entries"
+ );
+}
+
+function normalizeSlackSelector(input: {
+ operation: string;
+ selector?: string;
+}): string | undefined {
+ const selector = input.selector?.trim();
+ if (input.operation === "list_channels") {
+ if (selector) {
+ throw new SlackInvalidRequestError(
+ 'Slack operation "list_channels" does not accept a selector'
+ );
+ }
+ return undefined;
+ }
+
+ if (!selector) {
+ throw new SlackInvalidRequestError(
+ `Slack operation "${input.operation}" requires a channel selector`
+ );
+ }
+ return selector;
+}
+
+function requireSlackCredentials(
+ source: PreparedSourceConnection
+): SlackCredentials {
+ if (source.credentials.type !== "slack") {
+ throw new SlackInvalidRequestError(
+ `Slack source API requires Slack credentials, received "${source.credentials.type}"`
+ );
+ }
+ return source.credentials;
+}
+
+async function executeSlackOperation(input: {
+ credentials: SlackCredentials;
+ operation: SlackOperation;
+ request: SlackRequest;
+ selector?: string;
+}): Promise {
+ switch (input.operation) {
+ case "list_channels":
+ return callSlackApi({
+ credentials: input.credentials,
+ method: "conversations.list",
+ params: {
+ cursor: input.request.cursor,
+ exclude_archived: input.request.exclude_archived ?? true,
+ limit: input.request.limit ?? 100,
+ types: input.request.types ?? "public_channel,private_channel",
+ },
+ });
+ case "fetch_channel_history": {
+ const channel = await resolveSlackChannel({
+ credentials: input.credentials,
+ selector: input.selector,
+ });
+ return callSlackApi({
+ credentials: input.credentials,
+ method: "conversations.history",
+ params: {
+ channel,
+ cursor: input.request.cursor,
+ inclusive: input.request.inclusive,
+ latest: input.request.latest,
+ limit: input.request.limit ?? 50,
+ oldest: input.request.oldest,
+ },
+ });
+ }
+ case "fetch_thread_replies": {
+ const ts =
+ typeof input.request.ts === "string"
+ ? input.request.ts
+ : input.request.thread_ts;
+ if (!ts) {
+ throw new SlackInvalidRequestError(
+ 'Slack thread replies require request field "ts" or "thread_ts"'
+ );
+ }
+ const channel = await resolveSlackChannel({
+ credentials: input.credentials,
+ selector: input.selector,
+ });
+ return callSlackApi({
+ credentials: input.credentials,
+ method: "conversations.replies",
+ params: {
+ channel,
+ cursor: input.request.cursor,
+ inclusive: input.request.inclusive,
+ latest: input.request.latest,
+ limit: input.request.limit ?? 50,
+ oldest: input.request.oldest,
+ ts,
+ },
+ });
+ }
+ }
+}
+
+async function resolveSlackChannel(input: {
+ credentials: SlackCredentials;
+ selector?: string;
+}): Promise {
+ const normalized = normalizeChannelSelector(input.selector);
+ if (!normalized) {
+ throw new SlackInvalidRequestError("Slack channel selector is required");
+ }
+ if (CHANNEL_ID_PATTERN.test(normalized)) {
+ return normalized;
+ }
+
+ let cursor: string | undefined;
+ for (let page = 0; page < 20; page += 1) {
+ const response = await callSlackApi({
+ credentials: input.credentials,
+ method: "conversations.list",
+ params: {
+ cursor,
+ limit: 200,
+ types: "public_channel,private_channel",
+ },
+ });
+ const value = response.body.value;
+ const channels = isRecord(value) ? value.channels : undefined;
+ if (Array.isArray(channels)) {
+ const found = channels.find(
+ (channel) =>
+ isRecord(channel) &&
+ channel.name === normalized &&
+ typeof channel.id === "string"
+ );
+ if (isRecord(found) && typeof found.id === "string") {
+ return found.id;
+ }
+ }
+
+ const metadata = isRecord(value) ? value.response_metadata : undefined;
+ cursor =
+ isRecord(metadata) && typeof metadata.next_cursor === "string"
+ ? metadata.next_cursor
+ : undefined;
+ if (!cursor) {
+ break;
+ }
+ }
+
+ throw new SlackInvalidRequestError(
+ `Slack channel "${normalized}" was not found`
+ );
+}
+
+function normalizeChannelSelector(selector: string | undefined): string | null {
+ const trimmed = selector?.trim() ?? "";
+ if (!trimmed) {
+ return null;
+ }
+ return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
+}
+
+async function callSlackApi(input: {
+ credentials: SlackCredentials;
+ method: string;
+ params: Record;
+}): Promise {
+ const body = new URLSearchParams();
+ for (const [key, value] of Object.entries(input.params)) {
+ const stringValue = stringifySlackParam(value);
+ if (stringValue !== undefined && stringValue.length > 0) {
+ body.set(key, stringValue);
+ }
+ }
+
+ const response = await fetch(`${SLACK_API_BASE_URL}/${input.method}`, {
+ body,
+ headers: {
+ Authorization: `Bearer ${input.credentials.botToken}`,
+ "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
+ },
+ method: "POST",
+ });
+ const value = (await response.json().catch(() => null)) as JsonValue;
+
+ if (!response.ok) {
+ throw new Error(`Slack API HTTP error ${response.status}`);
+ }
+ if (isRecord(value) && value.ok === false) {
+ const error =
+ typeof value.error === "string" ? value.error : "unknown_error";
+ throw new Error(`Slack API error: ${error}`);
+ }
+
+ return {
+ body: { kind: "json", value },
+ contentType: "application/json",
+ headers: [],
+ status: response.status,
+ };
+}
+
+function stringifySlackParam(value: unknown): string | undefined {
+ if (value === undefined || value === null) {
+ return undefined;
+ }
+ if (typeof value === "string") {
+ return value;
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ return JSON.stringify(value);
+}
diff --git a/packages/server/src/source-api/registry.ts b/packages/server/src/source-api/registry.ts
index 5c795761..c659bd3d 100644
--- a/packages/server/src/source-api/registry.ts
+++ b/packages/server/src/source-api/registry.ts
@@ -21,6 +21,7 @@ import { mongodbSourceApiAdapter } from "./adapters/mongodb";
import { postHogSourceApiAdapter } from "./adapters/posthog";
import { sendGridSourceApiAdapter } from "./adapters/sendgrid";
import { sentrySourceApiAdapter } from "./adapters/sentry";
+import { slackSourceApiAdapter } from "./adapters/slack";
import { tiktokMarketingSourceApiAdapter } from "./adapters/tiktok-marketing";
import { vercelSourceApiAdapter } from "./adapters/vercel";
import {
@@ -88,6 +89,7 @@ export const sourceApiRegistry = createSourceApiRegistry([
postHogSourceApiAdapter,
sendGridSourceApiAdapter,
sentrySourceApiAdapter,
+ slackSourceApiAdapter,
tiktokMarketingSourceApiAdapter,
vercelSourceApiAdapter,
]);