From 24ae58986e3f3441e457872f114034d795d4ebf0 Mon Sep 17 00:00:00 2001 From: siisee11 Date: Tue, 2 Jun 2026 16:21:59 +0900 Subject: [PATCH 1/4] feat: add Slack source connector --- .../connectors/ConnectorBrowser.astro | 3 +- apps/landing/src/data/connectors.test.ts | 7 + .../src/landing/content/brand-icons.tsx | 2 + apps/landing/src/styles/connectors.css | 26 +- .../__snapshots__/credentials.test.ts.snap | 1 + packages/db/src/credentials.test.ts | 73 +++ packages/db/src/credentials.ts | 53 ++ .../__snapshots__/data-sources.test.ts.snap | 2 + packages/db/src/source-providers.ts | 34 ++ .../src/source-api/adapters/slack.test.ts | 196 +++++++ .../server/src/source-api/adapters/slack.ts | 520 ++++++++++++++++++ packages/server/src/source-api/registry.ts | 2 + 12 files changed, 912 insertions(+), 7 deletions(-) create mode 100644 packages/server/src/source-api/adapters/slack.test.ts create mode 100644 packages/server/src/source-api/adapters/slack.ts diff --git a/apps/landing/src/components/connectors/ConnectorBrowser.astro b/apps/landing/src/components/connectors/ConnectorBrowser.astro index fad96120..db0c6504 100644 --- a/apps/landing/src/components/connectors/ConnectorBrowser.astro +++ b/apps/landing/src/components/connectors/ConnectorBrowser.astro @@ -96,7 +96,8 @@ const categoryOptions = [ {connector.capabilities.map((capability) => {capability})}

{connector.label}

-

{connector.category}

+

{connector.category}

+

{connector.description}

)) diff --git a/apps/landing/src/data/connectors.test.ts b/apps/landing/src/data/connectors.test.ts index a8171508..96fad4e9 100644 --- a/apps/landing/src/data/connectors.test.ts +++ b/apps/landing/src/data/connectors.test.ts @@ -13,6 +13,13 @@ describe("DATA_SOURCE_CONNECTORS", () => { it("includes recently added source providers", () => { expect(DATA_SOURCE_CONNECTORS).toEqual( expect.arrayContaining([ + expect.objectContaining({ + capabilities: ["API"], + category: "Productivity", + description: expect.stringContaining("Slack"), + key: "slack", + label: "Slack", + }), expect.objectContaining({ category: "Productivity", key: "cal", diff --git a/apps/landing/src/landing/content/brand-icons.tsx b/apps/landing/src/landing/content/brand-icons.tsx index 503b1df4..dc646b08 100644 --- a/apps/landing/src/landing/content/brand-icons.tsx +++ b/apps/landing/src/landing/content/brand-icons.tsx @@ -25,6 +25,7 @@ import { siPostgresql, siPosthog, siSentry, + siSlack, siSnowflake, siSupabase, siTiktok, @@ -230,6 +231,7 @@ const BRAND_ICONS = { posthog: createSimpleBrandIcon(siPosthog), sentry: createSimpleBrandIcon(siSentry), sendgrid: SendGridIcon, + slack: createSimpleBrandIcon(siSlack), snowflake: createSimpleBrandIcon(siSnowflake), supabase: createSimpleBrandIcon(siSupabase), tiktok_marketing: createSimpleBrandIcon(siTiktok), diff --git a/apps/landing/src/styles/connectors.css b/apps/landing/src/styles/connectors.css index f182ac5e..0bacc097 100644 --- a/apps/landing/src/styles/connectors.css +++ b/apps/landing/src/styles/connectors.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; @@ -250,6 +250,7 @@ display: inline-flex; align-items: center; justify-content: center; + margin-top: 3px; width: 76px; height: 76px; border-radius: 18px; @@ -309,16 +310,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; +} + @media (max-width: 1100px) { .connectors-browser { grid-template-columns: 1fr; @@ -368,7 +382,7 @@ } .connector-card { - min-height: 112px; + min-height: 158px; border-radius: 22px; } } diff --git a/packages/db/src/__snapshots__/credentials.test.ts.snap b/packages/db/src/__snapshots__/credentials.test.ts.snap index 3ecf66d5..f99a1c6d 100644 --- a/packages/db/src/__snapshots__/credentials.test.ts.snap +++ b/packages/db/src/__snapshots__/credentials.test.ts.snap @@ -30,6 +30,7 @@ exports[`credentials schemas > 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, ]); From 8ba8b8afd43709f41d3565e917319d5109a68101 Mon Sep 17 00:00:00 2001 From: siisee11 Date: Tue, 2 Jun 2026 16:30:16 +0900 Subject: [PATCH 2/4] fix: use local Slack brand icon --- apps/landing/src/shared/icons/brands.tsx | 31 ++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/landing/src/shared/icons/brands.tsx b/apps/landing/src/shared/icons/brands.tsx index dc646b08..66d62fe4 100644 --- a/apps/landing/src/shared/icons/brands.tsx +++ b/apps/landing/src/shared/icons/brands.tsx @@ -25,7 +25,6 @@ import { siPostgresql, siPosthog, siSentry, - siSlack, siSnowflake, siSupabase, siTiktok, @@ -174,6 +173,34 @@ function SendGridIcon({ size, ...props }: IconSvgProps) { ); } +function SlackIcon({ size, ...props }: IconSvgProps) { + return ( + + + + + + + ); +} + function CloudflareD1Icon({ size, ...props }: IconSvgProps) { return ( Date: Tue, 2 Jun 2026 16:32:57 +0900 Subject: [PATCH 3/4] style: format Slack brand icon --- apps/landing/src/shared/icons/brands.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/landing/src/shared/icons/brands.tsx b/apps/landing/src/shared/icons/brands.tsx index 66d62fe4..a71719b7 100644 --- a/apps/landing/src/shared/icons/brands.tsx +++ b/apps/landing/src/shared/icons/brands.tsx @@ -175,12 +175,7 @@ function SendGridIcon({ size, ...props }: IconSvgProps) { function SlackIcon({ size, ...props }: IconSvgProps) { return ( - + Date: Tue, 2 Jun 2026 16:38:17 +0900 Subject: [PATCH 4/4] fix: add Slack dashboard provider icon --- .../src/components/provider-icons.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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),