mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+1
@@ -178,6 +178,7 @@ export function ConnectServiceAccountModal({
|
||||
credentialId={credentialId}
|
||||
initialDisplayName={credentialDisplayName}
|
||||
initialDescription={credentialDescription}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+98
-47
@@ -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
|
||||
|
||||
+4
-2
@@ -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 }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user