improvement(slack): merge slack_v2 auth into one credential picker for accounts and custom bots (#5800)

* improvement(slack): merge slack_v2 auth into one credential picker for accounts and custom bots

* improvement(slack): label the Sim app group in the merged credential picker

* improvement(slack): move provider-specific picker copy and modal dispatch out of the credential selector
This commit is contained in:
Theodore Li
2026-07-21 15:15:17 -04:00
committed by GitHub
parent 7db9108d15
commit 5be35b57a3
10 changed files with 257 additions and 267 deletions
@@ -178,6 +178,7 @@ export function ConnectServiceAccountModal({
credentialId={credentialId}
initialDisplayName={credentialDisplayName}
initialDescription={credentialDescription}
onCreated={onCreated}
/>
)
}
@@ -1,7 +1,7 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Button, Combobox } from '@sim/emcn'
import { Button, Combobox, type ComboboxOptionGroup } from '@sim/emcn'
import { ExternalLink, KeyRound } from 'lucide-react'
import { useParams } from 'next/navigation'
import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state'
@@ -18,7 +18,6 @@ import {
ConnectServiceAccountModal,
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { ConnectSlackBotModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-slack-bot-modal/connect-slack-bot-modal'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
import { useDependsOnGate } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate'
@@ -53,8 +52,7 @@ export function CredentialSelector({
const workspaceId = (params?.workspaceId as string) || ''
const [showConnectModal, setShowConnectModal] = useState(false)
const [showOAuthModal, setShowOAuthModal] = useState(false)
const [showSlackBotModal, setShowSlackBotModal] = useState(false)
const [showServiceAccountModal, setShowServiceAccountModal] = useState(false)
const [showSetupModal, setShowSetupModal] = useState(false)
const [editingValue, setEditingValue] = useState('')
const [isEditing, setIsEditing] = useState(false)
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
@@ -104,24 +102,32 @@ export function CredentialSelector({
const credentialsLoading = isAllCredentials ? allCredentialsLoading : oauthCredentialsLoading
const credentialKind = subBlock.credentialKind
const isMergedKinds = credentialKind === 'any'
const credentials = useMemo(() => {
// A custom-bot or service-account picker lists only the reusable
// service-account credentials, including in trigger mode.
if (credentialKind === 'custom-bot' || credentialKind === 'service-account') {
// A service-account picker lists only the reusable service-account
// credentials, including in trigger mode. A merged ('any') picker lists
// OAuth accounts and service accounts together.
if (credentialKind === 'service-account') {
return rawCredentials.filter((cred) => cred.type === 'service_account')
}
if (isMergedKinds) {
return rawCredentials
}
return isTriggerMode && !subBlock.allowServiceAccounts
? rawCredentials.filter((cred) => cred.type !== 'service_account')
: rawCredentials
}, [rawCredentials, isTriggerMode, credentialKind, subBlock.allowServiceAccounts])
}, [rawCredentials, isTriggerMode, credentialKind, isMergedKinds, subBlock.allowServiceAccounts])
// Resolved service-account provider metadata for the token-paste connect
// modal. Gated on `credentialKind` and using the non-throwing lookup so it
// never runs (or throws) for the OAuth / custom-bot pickers.
// Resolved service-account provider metadata for the setup modal. Gated on
// `credentialKind` and using the non-throwing lookup so it never runs (or
// throws) for plain OAuth pickers.
const serviceAccountService = useMemo(
() => (credentialKind === 'service-account' ? getServiceConfigByServiceId(serviceId) : null),
[credentialKind, serviceId]
() =>
credentialKind === 'service-account' || isMergedKinds
? getServiceConfigByServiceId(serviceId)
: null,
[credentialKind, isMergedKinds, serviceId]
)
const selectedCredential = useMemo(
@@ -198,12 +204,8 @@ export function CredentialSelector({
)
const handleAddCredential = useCallback(() => {
if (credentialKind === 'custom-bot') {
setShowSlackBotModal(true)
return
}
if (credentialKind === 'service-account') {
setShowServiceAccountModal(true)
setShowSetupModal(true)
return
}
setShowConnectModal(true)
@@ -239,6 +241,7 @@ export function CredentialSelector({
const oauthCredentials = allWorkspaceCredentials.filter((c) => c.type === 'oauth')
return oauthCredentials.map((cred) => ({ label: cred.displayName, value: cred.id }))
}
if (isMergedKinds) return []
const options = credentials.map((cred) => ({
label: cred.name,
@@ -248,17 +251,14 @@ export function CredentialSelector({
options.push({
label:
credentialKind === 'custom-bot'
? credentials.length > 0
? 'Connect another custom bot'
: 'Set up a custom bot'
: credentialKind === 'service-account'
? credentials.length > 0
credentialKind === 'service-account'
? (subBlock.credentialLabels?.serviceAccountConnect ??
(credentials.length > 0
? `Add another ${getProviderName(provider)} key`
: `Add ${getProviderName(provider)} key`
: credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
: `Add ${getProviderName(provider)} key`))
: credentials.length > 0
? `Connect another ${getProviderName(provider)} account`
: `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='size-3' />,
})
@@ -266,9 +266,54 @@ export function CredentialSelector({
return options
}, [
isAllCredentials,
isMergedKinds,
allWorkspaceCredentials,
credentials,
credentialKind,
subBlock.credentialLabels,
provider,
getProviderIcon,
getProviderName,
])
const comboboxGroups = useMemo<ComboboxOptionGroup[] | undefined>(() => {
if (!isMergedKinds) return undefined
const labels = subBlock.credentialLabels
const toOption = (cred: (typeof credentials)[number]) => ({
label: cred.name,
value: cred.id,
iconElement: getProviderIcon((cred.provider ?? provider) as OAuthProvider),
})
return [
{
section: labels?.oauthGroup ?? `${getProviderName(provider)} accounts`,
items: [
...credentials.filter((c) => c.type !== 'service_account').map(toOption),
{
label: labels?.oauthConnect ?? `Connect ${getProviderName(provider)} account`,
value: '__connect_account__',
iconElement: <ExternalLink className='size-3' />,
},
],
},
{
section: labels?.serviceAccountGroup ?? 'Service accounts',
items: [
...credentials.filter((c) => c.type === 'service_account').map(toOption),
{
label: labels?.serviceAccountConnect ?? `Add ${getProviderName(provider)} key`,
value: '__connect_service_account__',
iconElement: <ExternalLink className='size-3' />,
},
],
},
]
}, [
isMergedKinds,
subBlock.credentialLabels,
credentials,
provider,
getProviderIcon,
getProviderName,
@@ -320,9 +365,17 @@ export function CredentialSelector({
const handleComboboxChange = useCallback(
(value: string) => {
if (value === '__connect_account__') {
if (isMergedKinds) {
setShowConnectModal(true)
return
}
handleAddCredential()
return
}
if (value === '__connect_service_account__') {
setShowSetupModal(true)
return
}
const matchedCred = (
isAllCredentials ? allWorkspaceCredentials.filter((c) => c.type === 'oauth') : credentials
@@ -335,13 +388,21 @@ export function CredentialSelector({
setIsEditing(true)
setEditingValue(value)
},
[isAllCredentials, allWorkspaceCredentials, credentials, handleAddCredential, handleSelect]
[
isAllCredentials,
isMergedKinds,
allWorkspaceCredentials,
credentials,
handleAddCredential,
handleSelect,
]
)
return (
<div>
<Combobox
options={comboboxOptions}
groups={comboboxGroups}
value={displayValue}
selectedValue={selectedId}
onChange={handleComboboxChange}
@@ -350,8 +411,10 @@ export function CredentialSelector({
hasDependencies && !depsSatisfied ? 'Fill in required fields above first' : label
}
disabled={effectiveDisabled}
editable={true}
filterOptions={true}
editable={!isMergedKinds}
filterOptions={!isMergedKinds}
searchable={isMergedKinds}
searchPlaceholder='Search credentials...'
isLoading={credentialsLoading}
overlayContent={overlayContent}
className={overlayContent ? 'pl-7' : ''}
@@ -371,7 +434,7 @@ export function CredentialSelector({
workflowId: activeWorkflowId || '',
displayName: selectedCredential?.name ?? getProviderName(provider),
providerId: effectiveProviderId,
preCount: credentials.length,
preCount: credentials.filter((c) => c.type !== 'service_account').length,
workspaceId,
requestedAt: Date.now(),
})
@@ -417,22 +480,10 @@ export function CredentialSelector({
/>
)}
{showSlackBotModal && (
<ConnectSlackBotModal
open={showSlackBotModal}
onOpenChange={setShowSlackBotModal}
workspaceId={workspaceId}
onCreated={(newCredentialId) => {
setStoreValue(newCredentialId)
refetchCredentials()
}}
/>
)}
{showServiceAccountModal && serviceAccountService?.serviceAccountProviderId && (
{showSetupModal && serviceAccountService?.serviceAccountProviderId && (
<ConnectServiceAccountModal
open={showServiceAccountModal}
onOpenChange={setShowServiceAccountModal}
open={showSetupModal}
onOpenChange={setShowSetupModal}
workspaceId={workspaceId}
serviceAccountProviderId={
serviceAccountService.serviceAccountProviderId as ServiceAccountProviderId
@@ -56,11 +56,13 @@ import { useWebhookManagement } from '@/hooks/use-webhook-management'
const SLACK_OVERRIDES: SelectorOverrides = {
transformContext: (context, deps) => {
// v1 gates on authMethod (raw bot token vs OAuth); v2 has one merged
// credential field for actions and customBotCredential for triggers.
const authMethod = deps.authMethod as string
const oauthCredential =
authMethod === 'bot_token'
? String(deps.customBotCredential ?? deps.botToken ?? '')
: String(deps.credential ?? deps.customBotCredential ?? deps.triggerCredentials ?? '')
? String(deps.botToken ?? '')
: String(deps.credential ?? deps.customBotCredential ?? '')
return { ...context, oauthCredential }
},
}
+47 -39
View File
@@ -2627,48 +2627,51 @@ export const SlackBlockMeta = {
],
} as const satisfies BlockMeta
/**
* Custom Bot picker used by slack_v2 in place of v1's raw bot-token field — a
* canonical basic/advanced pair (dropdown + manual credential-ID paste),
* mirroring the OAuth `credential`/`manualCredential` pair.
*/
const SLACK_CUSTOM_BOT_SUBBLOCKS: SubBlockConfig[] = [
{
id: 'customBotCredential',
title: 'Slack Bot',
type: 'oauth-input',
canonicalParamId: 'botCredential',
mode: 'basic',
serviceId: 'slack',
credentialKind: 'custom-bot',
requiredScopes: getScopesForService('slack'),
placeholder: 'Select a connected bot',
dependsOn: ['authMethod'],
condition: { field: 'authMethod', value: 'bot_token' },
required: true,
},
{
id: 'manualCustomBotCredential',
title: 'Bot Credential ID',
type: 'short-input',
canonicalParamId: 'botCredential',
mode: 'advanced',
placeholder: 'Enter bot credential ID',
dependsOn: ['authMethod'],
condition: { field: 'authMethod', value: 'bot_token' },
required: true,
},
]
const SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS = new Set(
getTrigger('slack_webhook').subBlocks.map((sb) => sb.id)
)
/**
* Adapts a v1 subblock for slack_v2's merged credential picker: fields gated on
* the removed `authMethod` dropdown now depend on the single `credential` field.
*/
function adaptSubBlockForV2(sb: SubBlockConfig): SubBlockConfig {
const { dependsOn, condition, ...rest } = sb
if (sb.id === 'credential') {
return {
...rest,
credentialKind: 'any',
placeholder: 'Select Slack account or bot',
credentialLabels: {
oauthGroup: 'Sim app',
oauthConnect: 'Connect the Sim app',
serviceAccountGroup: 'Custom bots',
serviceAccountConnect: 'Set up a custom bot',
},
}
}
if (sb.id === 'manualCredential') {
return { ...rest, placeholder: 'Enter credential ID' }
}
if (dependsOn && !Array.isArray(dependsOn) && dependsOn.all?.includes('authMethod')) {
return { ...sb, dependsOn: ['credential'] }
}
return sb
}
const {
authMethod: _authMethod,
botToken: _botToken,
botCredential: _botCredential,
...slackV2Inputs
} = SlackBlock.inputs
/**
* slack_v2 — the go-forward Slack action block. Identical operations, tools, and
* outputs to v1 (shared by reference), but the "Custom Bot" auth method selects
* a reusable bot credential set up once, instead of pasting a raw token. Also
* hosts the redesigned slack_oauth trigger (v1 keeps the legacy slack_webhook).
* outputs to v1 (shared by reference), but auth is a single credential picker
* listing Sim OAuth accounts and reusable custom bots together — the credential's
* kind is resolved server-side, so no auth-method choice is needed. Also hosts
* the redesigned slack_oauth trigger (v1 keeps the legacy slack_webhook).
*/
export const SlackV2Block: BlockConfig<SlackResponse> = {
...SlackBlock,
@@ -2683,13 +2686,18 @@ export const SlackV2Block: BlockConfig<SlackResponse> = {
...SlackBlock.subBlocks.flatMap((sb) => {
// Drop the legacy paste-secret trigger config (v1 hosts slack_webhook)
// and v1's raw bot-token auth field — the trigger set includes an
// id-colliding 'botToken', so the set check covers both.
// id-colliding 'botToken', so the set check covers both. The authMethod
// dropdown is gone: the merged credential picker covers both auth kinds.
if (SLACK_WEBHOOK_TRIGGER_SUBBLOCK_IDS.has(sb.id)) return []
if (sb.id === 'authMethod') return [sb, ...SLACK_CUSTOM_BOT_SUBBLOCKS]
return [sb]
if (sb.id === 'authMethod') return []
return [adaptSubBlockForV2(sb)]
}),
...getTrigger('slack_oauth').subBlocks,
],
inputs: {
...slackV2Inputs,
oauthCredential: { type: 'string', description: 'Slack credential (OAuth account or bot)' },
},
triggers: {
enabled: true,
available: ['slack_oauth'],
+17 -7
View File
@@ -367,14 +367,24 @@ export interface SubBlockConfig {
serviceId?: string
requiredScopes?: string[]
/**
* Narrows an `oauth-input` selector to a specific credential kind. `'custom-bot'`
* lists only reusable custom Slack bot credentials (service-account type) and its
* connect row opens the custom-bot setup modal instead of the OAuth flow.
* `'service-account'` is the generic equivalent for a no-OAuth provider: it lists
* only service-account credentials and its connect row opens the descriptor-driven
* token-paste modal (`ConnectServiceAccountModal`).
* Narrows an `oauth-input` selector to a specific credential kind.
* `'service-account'` lists only service-account credentials; its connect row
* opens the provider's setup modal (resolved from the service-account setup
* registry — a bespoke wizard when registered, the generic token-paste modal
* otherwise). `'any'` lists OAuth accounts and service accounts together in a
* grouped dropdown with a connect action for each kind.
*/
credentialKind?: 'custom-bot' | 'service-account'
credentialKind?: 'service-account' | 'any'
/**
* Overrides the credential picker's section and connect-row copy. Unset keys
* fall back to generic provider-derived labels.
*/
credentialLabels?: {
oauthGroup?: string
oauthConnect?: string
serviceAccountGroup?: string
serviceAccountConnect?: string
}
/**
* Opts a trigger-mode `oauth-input` selector into listing service-account
* credentials, which are otherwise excluded in trigger mode. Set only when the
+39
View File
@@ -62,6 +62,23 @@ const tableTrigger = trigger([
{ id: 'manualTableId', mode: 'trigger-advanced', canonicalParamId: 'tableId', required: true },
])
const slackTrigger = trigger([
{ id: 'eventType', mode: 'trigger', required: true },
{
id: 'customBotCredential',
mode: 'trigger',
canonicalParamId: 'botCredential',
serviceId: 'slack',
required: true,
},
{
id: 'manualBotCredential',
mode: 'trigger-advanced',
canonicalParamId: 'botCredential',
required: true,
},
])
function makeBlock(
type: string,
subBlockValues: Record<string, unknown>,
@@ -155,6 +172,28 @@ describe('buildProviderConfig canonical collapse', () => {
const { providerConfig } = buildProviderConfig(block, 'table_new_row', tableTrigger)
expect(providerConfig.tableId).toBe('ACTIVE')
})
it('collapses the slack bot credential pair under botCredential for the routing branch', () => {
const block = makeBlock('slack_v2', {
eventType: 'message',
customBotCredential: 'cred_bot_1',
})
const result = buildProviderConfig(block, 'slack_oauth', slackTrigger)
expect(result.providerConfig.botCredential).toBe('cred_bot_1')
expect(result.providerConfig.eventType).toBe('message')
// The slack trigger has no generic triggerCredentials field — the routing
// branch resolves botCredential itself.
expect(result.credentialReference).toBeUndefined()
expect(result.credentialServiceId).toBeUndefined()
})
it('reports a missing required slack bot credential as a missing field', () => {
const block = makeBlock('slack_v2', { eventType: 'message' })
const result = buildProviderConfig(block, 'slack_oauth', slackTrigger)
expect(result.missingFields.length).toBeGreaterThan(0)
})
})
describe('resolveTriggerCredentialId', () => {
+37 -106
View File
@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { account, credential, webhook, workflowDeploymentVersion } from '@sim/db/schema'
import { credential, webhook, workflowDeploymentVersion } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateShortId } from '@sim/utils/id'
@@ -15,7 +15,6 @@ import {
projectDesiredWebhookProviderConfig,
} from '@/lib/webhooks/provider-subscriptions'
import { getProviderHandler } from '@/lib/webhooks/providers'
import { fetchSlackTeamId } from '@/lib/webhooks/providers/slack'
import {
prepareStableWebhookRegistrations,
type StableDesiredWebhookRegistration,
@@ -27,17 +26,12 @@ import {
isCanonicalPair,
resolveActiveCanonicalValue,
} from '@/lib/workflows/subblocks/visibility'
import {
getSlackBotCredential,
refreshAccessTokenIfNeeded,
resolveOAuthAccountId,
} from '@/app/api/auth/oauth/utils'
import { getSlackBotCredential } from '@/app/api/auth/oauth/utils'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { getTrigger, isTriggerValid } from '@/triggers'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
import { SIM_SUBSCRIBED_EVENTS } from '@/triggers/slack/shared'
const logger = createLogger('DeployWebhookSync')
@@ -431,106 +425,43 @@ async function resolveWebhookConfigForBlock(input: {
let effectivePath: string | null = triggerPath
let routingKey: string | null = null
if (triggerId === 'slack_oauth') {
const appType = typeof providerConfig.appType === 'string' ? providerConfig.appType : 'custom'
if (appType === 'sim') {
const eventType =
typeof providerConfig.eventType === 'string' ? providerConfig.eventType : null
if (eventType && !SIM_SUBSCRIBED_EVENTS.includes(eventType)) {
return {
success: false,
error: {
message:
'This event is not available on the Sim Slack app. Use a custom app or choose a supported event.',
status: 400,
},
}
// Custom-bot only: events route by the bot credential to one shared ingest
// URL verified with the bot's own signing secret. The native Sim-app path
// (team_id routing via the official app) is intentionally not supported yet.
const botCredentialId =
typeof providerConfig.botCredential === 'string' ? providerConfig.botCredential : undefined
if (!botCredentialId) {
return {
success: false,
error: { message: 'Select a Slack bot credential for the trigger.', status: 400 },
}
if (!credentialId) {
return {
success: false,
error: { message: 'Select a Slack account for the trigger.', status: 400 },
}
}
let tokenOwnerUserId = input.userId
const resolvedAccount = await resolveOAuthAccountId(credentialId)
if (resolvedAccount?.accountId) {
const [owner] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, resolvedAccount.accountId))
.limit(1)
if (owner?.userId) tokenOwnerUserId = owner.userId
}
const botToken = await refreshAccessTokenIfNeeded(
credentialId,
tokenOwnerUserId,
input.requestId
)
if (!botToken) {
return {
success: false,
error: {
message: 'Could not access the connected Slack account. Reconnect it and try again.',
status: 400,
},
}
}
try {
const { teamId, userId: botUserId } = await fetchSlackTeamId(botToken)
routingKey = teamId
if (botUserId) providerConfig.bot_user_id = botUserId
} catch (error: unknown) {
logger.error(
`[${input.requestId}] Slack team_id resolution failed for ${input.block.id}`,
error
)
return {
success: false,
error: {
message: 'Could not verify the connected Slack workspace. Reconnect it and try again.',
status: 400,
},
}
}
effectiveProvider = 'slack_app'
effectivePath = null
} else {
const botCredentialId =
typeof providerConfig.botCredential === 'string' ? providerConfig.botCredential : undefined
if (!botCredentialId) {
return {
success: false,
error: { message: 'Select a Slack bot credential for the trigger.', status: 400 },
}
}
const botCredential = await getSlackBotCredential(botCredentialId)
if (!botCredential) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is missing or invalid. Reconnect it.',
status: 400,
},
}
}
const workflowWorkspace =
typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined
if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is not available in this workspace.',
status: 400,
},
}
}
effectiveProvider = 'slack'
effectivePath = null
routingKey = botCredentialId
providerConfig.credentialId = botCredentialId
if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId
}
const botCredential = await getSlackBotCredential(botCredentialId)
if (!botCredential) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is missing or invalid. Reconnect it.',
status: 400,
},
}
}
const workflowWorkspace =
typeof input.workflow.workspaceId === 'string' ? input.workflow.workspaceId : undefined
if (!workflowWorkspace || botCredential.workspaceId !== workflowWorkspace) {
return {
success: false,
error: {
message: 'The selected Slack bot credential is not available in this workspace.',
status: 400,
},
}
}
effectiveProvider = 'slack'
effectivePath = null
routingKey = botCredentialId
providerConfig.credentialId = botCredentialId
if (botCredential.botUserId) providerConfig.bot_user_id = botCredential.botUserId
}
return {
@@ -356,6 +356,8 @@ export const CREDENTIAL_SUBBLOCK_IDS = new Set([
'credential',
'manualCredential',
'triggerCredentials',
'customBotCredential',
'manualBotCredential',
])
async function migrateCredentialIds(
+11 -55
View File
@@ -1,9 +1,7 @@
import { SlackIcon } from '@/components/icons'
import { getScopesForService } from '@/lib/oauth/utils'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import {
SLACK_ALL_EVENT_OPTIONS,
SLACK_SIM_EVENT_OPTIONS,
SLACK_SOURCE_OPTIONS,
SLACK_THREAD_OPTIONS,
SLACK_TRIGGER_OUTPUTS,
@@ -25,14 +23,11 @@ const BOT_FILTER_EVENTS = ['message', 'app_mention']
const OWN_MESSAGE_EVENTS = ['message', 'app_mention', 'reaction_added', 'reaction_removed']
/**
* Unified Slack trigger. App Type selects how events arrive:
* - `sim` (default): the official Sim Slack app the user OAuth-connects — events
* route to this workflow by Slack `team_id` (stored as the webhook `routingKey`,
* derived at deploy time). No signing secret, bot token, or app setup.
* - `custom`: a bring-your-own Slack app, selected as a reusable bot credential
* (set up once). Events route by that credential (`webhook.routingKey =
* credentialId`) to one shared ingest URL, so many triggers on the same bot
* share a single Request URL, verified with the bot's own signing secret.
* Slack trigger backed by a reusable custom-bot credential (set up once, reused
* across triggers and actions). Events route by that credential
* (`webhook.routingKey = credentialId`) to one shared ingest URL, so many
* triggers on the same bot share a single Request URL, verified with the bot's
* own signing secret.
*/
export const slackOAuthTrigger: TriggerConfig = {
id: 'slack_oauth',
@@ -47,50 +42,12 @@ export const slackOAuthTrigger: TriggerConfig = {
id: 'eventType',
title: 'Event',
type: 'dropdown',
options: [...SLACK_SIM_EVENT_OPTIONS],
options: [...SLACK_ALL_EVENT_OPTIONS],
placeholder: 'Select an event',
description:
'The single Slack event this trigger fires on. Add another trigger block for another event.',
required: true,
mode: 'trigger',
dependsOn: ['appType'],
fetchOptions: async (blockId: string) => {
const appType = useSubBlockStore.getState().getValue(blockId, 'appType')
return appType === 'custom' ? [...SLACK_ALL_EVENT_OPTIONS] : [...SLACK_SIM_EVENT_OPTIONS]
},
},
{
id: 'appType',
title: 'App Type',
type: 'dropdown',
// Ship 1 exposes custom bots only; the native "Sim" app mode returns in a
// later ship (un-hide + default back to 'sim'). Hidden — not removed — so
// the seeded 'custom' value keeps every `appType==='custom'` condition, the
// event-catalog fetch, and the deploy routing branch resolving correctly.
hidden: true,
options: [
{ label: 'Sim', id: 'sim' },
{ label: 'Custom', id: 'custom' },
],
value: () => 'custom',
// `value()` only seeds editor-created blocks; `defaultValue` is what
// buildProviderConfig persists when the stored value is absent
// (imported / programmatically-created workflows).
defaultValue: 'custom',
description: 'Use the official Sim Slack app, or your own custom Slack app.',
mode: 'trigger',
},
{
id: 'triggerCredentials',
title: 'Slack Account',
type: 'oauth-input',
canonicalParamId: 'oauthCredential',
serviceId: 'slack',
requiredScopes: getScopesForService('slack'),
placeholder: 'Select Slack account',
required: { field: 'appType', value: 'sim' },
mode: 'trigger',
condition: { field: 'appType', value: 'sim' },
},
{
id: 'customBotCredential',
@@ -98,14 +55,14 @@ export const slackOAuthTrigger: TriggerConfig = {
type: 'oauth-input',
canonicalParamId: 'botCredential',
serviceId: 'slack',
credentialKind: 'custom-bot',
credentialKind: 'service-account',
credentialLabels: { serviceAccountConnect: 'Set up a custom bot' },
requiredScopes: getScopesForService('slack'),
placeholder: 'Select a connected bot',
description:
'Choose a custom Slack bot you set up once and reuse across triggers and actions.',
required: { field: 'appType', value: 'custom' },
required: true,
mode: 'trigger',
condition: { field: 'appType', value: 'custom' },
},
{
id: 'manualBotCredential',
@@ -114,9 +71,8 @@ export const slackOAuthTrigger: TriggerConfig = {
canonicalParamId: 'botCredential',
placeholder: 'Enter bot credential ID',
description: 'Set the custom bot credential ID directly.',
required: { field: 'appType', value: 'custom' },
required: true,
mode: 'trigger-advanced',
condition: { field: 'appType', value: 'custom' },
},
{
id: 'source',
@@ -142,7 +98,7 @@ export const slackOAuthTrigger: TriggerConfig = {
placeholder: 'Any channel the bot is in',
description:
'Restrict to specific channels. Leave empty to trigger on any channel the bot has been added to.',
dependsOn: { any: ['triggerCredentials', 'customBotCredential'] },
dependsOn: ['customBotCredential'],
required: false,
mode: 'trigger',
condition: { field: 'eventType', value: CHANNEL_FILTER_EVENTS },
+1 -11
View File
@@ -271,17 +271,7 @@ export const slackEventById = new Map<string, SlackEventCatalogEntry>(
SLACK_EVENT_CATALOG.map((entry) => [entry.id, entry])
)
/** Event ids the official shared Sim app subscribes to (Sim-mode gating SOT). */
export const SIM_SUBSCRIBED_EVENTS: readonly string[] = SLACK_EVENT_CATALOG.filter(
(entry) => entry.simSubscribed
).map((entry) => entry.id)
/** Dropdown options for Sim mode — only officially-subscribed events. */
export const SLACK_SIM_EVENT_OPTIONS = SLACK_EVENT_CATALOG.filter(
(entry) => entry.simSubscribed
).map((entry) => ({ label: entry.label, id: entry.id }))
/** Dropdown options for Custom mode — every event (manifest generated on demand). */
/** Dropdown options for the event picker — every event (manifest generated on demand). */
export const SLACK_ALL_EVENT_OPTIONS = SLACK_EVENT_CATALOG.map((entry) => ({
label: entry.label,
id: entry.id,