chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSlackManifest } from '@/triggers/slack/capabilities'
|
||||
|
||||
const opts = { appName: 'Test Bot', webhookUrl: 'https://sim.test/api/webhooks/slack' }
|
||||
|
||||
function settingsOf(manifest: Record<string, unknown>) {
|
||||
return manifest.settings as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('buildSlackManifest - interactivity', () => {
|
||||
it('emits settings.interactivity when the interactivity capability is active', () => {
|
||||
const manifest = buildSlackManifest(new Set(['action_interactivity']), opts)
|
||||
expect(settingsOf(manifest).interactivity).toEqual({
|
||||
is_enabled: true,
|
||||
request_url: opts.webhookUrl,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits settings.interactivity when the capability is absent', () => {
|
||||
const manifest = buildSlackManifest(new Set(['action_send']), opts)
|
||||
expect(settingsOf(manifest).interactivity).toBeUndefined()
|
||||
})
|
||||
|
||||
it('enables interactivity independently of event subscriptions', () => {
|
||||
// No bot_events (interactivity-only bot) still gets the interactivity block.
|
||||
const manifest = buildSlackManifest(new Set(['action_interactivity']), opts)
|
||||
const settings = settingsOf(manifest)
|
||||
expect(settings.event_subscriptions).toBeUndefined()
|
||||
expect(settings.interactivity).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSlackManifest - description', () => {
|
||||
it('emits display_information.description and reuses it as the assistant description', () => {
|
||||
const manifest = buildSlackManifest(new Set(['action_assistant']), {
|
||||
...opts,
|
||||
description: 'Answers support questions.',
|
||||
})
|
||||
expect(manifest.display_information).toEqual({
|
||||
name: 'Test Bot',
|
||||
description: 'Answers support questions.',
|
||||
})
|
||||
const features = manifest.features as Record<string, Record<string, unknown>>
|
||||
expect(features.assistant_view.assistant_description).toBe('Answers support questions.')
|
||||
})
|
||||
|
||||
it('omits the description key and falls back for the assistant when absent', () => {
|
||||
const manifest = buildSlackManifest(new Set(['action_assistant']), opts)
|
||||
expect(manifest.display_information).toEqual({ name: 'Test Bot' })
|
||||
const features = manifest.features as Record<string, Record<string, unknown>>
|
||||
expect(features.assistant_view.assistant_description).toBe(
|
||||
'Test Bot — an AI assistant powered by Sim.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Slack app capabilities that can be toggled on in the manifest generator.
|
||||
*
|
||||
* @remarks
|
||||
* Each capability maps to a set of bot OAuth scopes and bot events that must
|
||||
* be declared in the Slack app manifest for the capability to work. The `id`
|
||||
* is also used as the sub-block storage key (shape: `trigger_*` / `action_*`)
|
||||
* so the same object serves as both a checkbox-list option and a manifest
|
||||
* builder entry. See https://api.slack.com/reference/manifests.
|
||||
*/
|
||||
|
||||
export type SlackCapabilityGroup = 'trigger' | 'action'
|
||||
|
||||
export interface SlackCapability {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
defaultChecked: boolean
|
||||
group: SlackCapabilityGroup
|
||||
scopes: readonly string[]
|
||||
events: readonly string[]
|
||||
/**
|
||||
* Marks the AI Assistant capability. When enabled the manifest additionally
|
||||
* declares the app as an Agents & AI app (`features.assistant_view`) and
|
||||
* enables the App Home messages tab — required for assistant threads, the
|
||||
* "thinking" status (`assistant.threads.setStatus`), and DM-style chat to work.
|
||||
*/
|
||||
assistant?: boolean
|
||||
/**
|
||||
* Marks the interactivity capability. When enabled the manifest declares
|
||||
* `settings.interactivity` (pointing at the same ingest URL) — required for
|
||||
* Slack to deliver `block_actions` (button/select clicks) and `view_submission`
|
||||
* (modal submits) so triggers can fire on them. Not a scope or a bot_event.
|
||||
*/
|
||||
interactivity?: boolean
|
||||
}
|
||||
|
||||
export const SLACK_CAPABILITIES: readonly SlackCapability[] = [
|
||||
{
|
||||
id: 'trigger_mention',
|
||||
label: '@mention',
|
||||
description: 'Trigger the workflow when someone @-mentions your bot.',
|
||||
defaultChecked: true,
|
||||
group: 'trigger',
|
||||
scopes: ['app_mentions:read'],
|
||||
events: ['app_mention'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_dm',
|
||||
label: 'Direct message',
|
||||
description: 'Trigger the workflow when a user sends your bot a 1:1 direct message.',
|
||||
defaultChecked: true,
|
||||
group: 'trigger',
|
||||
scopes: ['im:history', 'im:read'],
|
||||
events: ['message.im'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_public_channel',
|
||||
label: 'Public channel message',
|
||||
description: 'Trigger on messages in public channels your bot has been invited to.',
|
||||
defaultChecked: true,
|
||||
group: 'trigger',
|
||||
scopes: ['channels:history', 'channels:read'],
|
||||
events: ['message.channels'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_private_channel',
|
||||
label: 'Private channel message',
|
||||
description: 'Trigger on messages in private channels your bot has been invited to.',
|
||||
defaultChecked: true,
|
||||
group: 'trigger',
|
||||
scopes: ['groups:history', 'groups:read'],
|
||||
events: ['message.groups'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_reaction',
|
||||
label: 'Reaction',
|
||||
description:
|
||||
'Trigger when an emoji reaction is added or removed anywhere the bot can see — public, private, or DM. Slack does not allow restricting the reactions scope by channel type.',
|
||||
defaultChecked: true,
|
||||
group: 'trigger',
|
||||
scopes: ['reactions:read'],
|
||||
events: ['reaction_added', 'reaction_removed'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_file_shared',
|
||||
label: 'File shared',
|
||||
description: 'Trigger when a file is shared in a channel your bot can see.',
|
||||
defaultChecked: false,
|
||||
group: 'trigger',
|
||||
scopes: ['files:read'],
|
||||
events: ['file_shared'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_member_channel',
|
||||
label: 'Member joined / left channel',
|
||||
description: 'Trigger when a member joins or leaves a channel your bot is in.',
|
||||
defaultChecked: false,
|
||||
group: 'trigger',
|
||||
scopes: ['channels:read', 'groups:read'],
|
||||
events: ['member_joined_channel', 'member_left_channel'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_channel_lifecycle',
|
||||
label: 'Channel created / archived / renamed',
|
||||
description: 'Trigger when a channel is created, archived, or renamed.',
|
||||
defaultChecked: false,
|
||||
group: 'trigger',
|
||||
scopes: ['channels:read', 'groups:read'],
|
||||
events: ['channel_created', 'channel_archive', 'channel_rename'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_pin',
|
||||
label: 'Pin added / removed',
|
||||
description: 'Trigger when a message is pinned or unpinned in a channel.',
|
||||
defaultChecked: false,
|
||||
group: 'trigger',
|
||||
scopes: ['pins:read'],
|
||||
events: ['pin_added', 'pin_removed'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_team_join',
|
||||
label: 'Member joined workspace',
|
||||
description: 'Trigger when a new member joins the workspace.',
|
||||
defaultChecked: false,
|
||||
group: 'trigger',
|
||||
scopes: ['users:read'],
|
||||
events: ['team_join'],
|
||||
},
|
||||
{
|
||||
id: 'trigger_app_home',
|
||||
label: 'App home opened',
|
||||
description: "Trigger when a user opens your app's Home tab.",
|
||||
defaultChecked: false,
|
||||
group: 'trigger',
|
||||
scopes: [],
|
||||
events: ['app_home_opened'],
|
||||
},
|
||||
{
|
||||
id: 'action_send',
|
||||
label: 'Send messages',
|
||||
description: 'Let the bot post messages into channels it is a member of.',
|
||||
defaultChecked: true,
|
||||
group: 'action',
|
||||
scopes: ['chat:write'],
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
id: 'action_add_reaction',
|
||||
label: 'Add reactions',
|
||||
description: 'Let the bot add emoji reactions to messages.',
|
||||
defaultChecked: true,
|
||||
group: 'action',
|
||||
scopes: ['reactions:write'],
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
id: 'action_read_history',
|
||||
label: 'Read message history',
|
||||
description:
|
||||
'Let the bot page through channel, thread, and DM history (conversations.history / conversations.replies).',
|
||||
defaultChecked: true,
|
||||
group: 'action',
|
||||
scopes: ['channels:history', 'groups:history', 'im:history'],
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
id: 'action_assistant',
|
||||
label: 'AI assistant',
|
||||
description:
|
||||
'Register the bot as an AI assistant: users open an assistant thread, the bot shows a "thinking" status, and can set the thread title and suggested prompts (assistant.threads.*).',
|
||||
defaultChecked: true,
|
||||
group: 'action',
|
||||
scopes: ['assistant:write', 'im:history'],
|
||||
events: ['assistant_thread_started', 'assistant_thread_context_changed', 'message.im'],
|
||||
assistant: true,
|
||||
},
|
||||
{
|
||||
id: 'action_read_files',
|
||||
label: 'Read file attachments',
|
||||
description: 'Let the bot download file attachments on incoming messages.',
|
||||
defaultChecked: true,
|
||||
group: 'action',
|
||||
scopes: ['files:read'],
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
id: 'action_read_users',
|
||||
label: 'Look up users',
|
||||
description: 'Resolve user IDs to names, profiles, and email addresses.',
|
||||
defaultChecked: true,
|
||||
group: 'action',
|
||||
scopes: ['users:read', 'users:read.email'],
|
||||
events: [],
|
||||
},
|
||||
{
|
||||
id: 'action_interactivity',
|
||||
label: 'Buttons & modals',
|
||||
description:
|
||||
'Let workflows trigger on interactions — button/select clicks and modal submits. Enables the app’s Interactivity Request URL.',
|
||||
defaultChecked: true,
|
||||
group: 'action',
|
||||
scopes: [],
|
||||
events: [],
|
||||
interactivity: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
const WEBHOOK_URL_PLACEHOLDER = '<deploy workflow to generate webhook URL>'
|
||||
|
||||
export interface BuildManifestOptions {
|
||||
appName: string
|
||||
webhookUrl: string | null
|
||||
/** Shown on the bot's Slack profile and as the assistant description. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Slack app manifest object from a set of enabled capability ids.
|
||||
*
|
||||
* @remarks
|
||||
* - Deduplicates scopes and events across overlapping capabilities.
|
||||
* - Omits `settings.event_subscriptions` entirely when no events are selected —
|
||||
* Slack's manifest validator rejects an empty `bot_events` array.
|
||||
* - When `webhookUrl` is null, embeds a human-readable placeholder so the
|
||||
* shape is visible before the workflow is deployed.
|
||||
*/
|
||||
export function buildSlackManifest(
|
||||
enabled: ReadonlySet<string>,
|
||||
{ appName, webhookUrl, description }: BuildManifestOptions
|
||||
): Record<string, unknown> {
|
||||
const active = SLACK_CAPABILITIES.filter((c) => enabled.has(c.id))
|
||||
const scopes = [...new Set(active.flatMap((c) => c.scopes))].sort()
|
||||
const events = [...new Set(active.flatMap((c) => c.events))].sort()
|
||||
const displayName = appName.trim() || 'Sim Workflow Bot'
|
||||
const trimmedDescription = description?.trim() || ''
|
||||
const isAssistant = active.some((c) => c.assistant)
|
||||
const isInteractive = active.some((c) => c.interactivity)
|
||||
|
||||
const features: Record<string, unknown> = {
|
||||
bot_user: { display_name: displayName, always_online: true },
|
||||
}
|
||||
if (isAssistant) {
|
||||
// Declares the app as an Agents & AI app; without this Slack won't surface
|
||||
// the assistant thread UI or fire assistant_thread_* events. The messages
|
||||
// tab must be enabled so users can chat the assistant.
|
||||
features.assistant_view = {
|
||||
assistant_description:
|
||||
trimmedDescription || `${displayName} — an AI assistant powered by Sim.`,
|
||||
}
|
||||
features.app_home = {
|
||||
home_tab_enabled: false,
|
||||
messages_tab_enabled: true,
|
||||
messages_tab_read_only_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
const manifest: Record<string, unknown> = {
|
||||
display_information: trimmedDescription
|
||||
? { name: displayName, description: trimmedDescription }
|
||||
: { name: displayName },
|
||||
features,
|
||||
oauth_config: {
|
||||
scopes: { bot: scopes },
|
||||
},
|
||||
settings: {
|
||||
org_deploy_enabled: false,
|
||||
socket_mode_enabled: false,
|
||||
token_rotation_enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
const settings = manifest.settings as Record<string, unknown>
|
||||
settings.event_subscriptions = {
|
||||
request_url: webhookUrl ?? WEBHOOK_URL_PLACEHOLDER,
|
||||
bot_events: events,
|
||||
}
|
||||
}
|
||||
|
||||
// Interactivity is independent of event subscriptions — a bot can have
|
||||
// buttons/modals with no bot_events. Points at the same ingest URL.
|
||||
if (isInteractive) {
|
||||
const settings = manifest.settings as Record<string, unknown>
|
||||
settings.interactivity = {
|
||||
is_enabled: true,
|
||||
request_url: webhookUrl ?? WEBHOOK_URL_PLACEHOLDER,
|
||||
}
|
||||
}
|
||||
|
||||
return manifest
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { slackOAuthTrigger } from './oauth'
|
||||
export { slackWebhookTrigger } from './webhook'
|
||||
@@ -0,0 +1,245 @@
|
||||
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,
|
||||
slackEventsSupportingFilter,
|
||||
} from '@/triggers/slack/shared'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
// Filter sub-block gating is derived from the catalog's `filters` field so the
|
||||
// UI conditions and the ingest route share one source of truth.
|
||||
const SOURCE_FILTER_EVENTS = slackEventsSupportingFilter('source')
|
||||
const CHANNEL_FILTER_EVENTS = slackEventsSupportingFilter('channels')
|
||||
const THREAD_FILTER_EVENTS = slackEventsSupportingFilter('threads')
|
||||
const EMOJI_FILTER_EVENTS = slackEventsSupportingFilter('emoji')
|
||||
const NAME_FILTER_EVENTS = slackEventsSupportingFilter('name')
|
||||
const INTERACTION_FILTER_EVENTS = slackEventsSupportingFilter('interaction')
|
||||
// Bot/own toggles gate UI visibility only (the route applies them unconditionally),
|
||||
// so they are not catalog `filters`.
|
||||
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.
|
||||
*/
|
||||
export const slackOAuthTrigger: TriggerConfig = {
|
||||
id: 'slack_oauth',
|
||||
name: 'Slack',
|
||||
provider: 'slack_app',
|
||||
description: 'Trigger from Slack events (mentions, messages, reactions)',
|
||||
version: '1.0.0',
|
||||
icon: SlackIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'eventType',
|
||||
title: 'Event',
|
||||
type: 'dropdown',
|
||||
options: [...SLACK_SIM_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',
|
||||
title: 'Slack Bot',
|
||||
type: 'oauth-input',
|
||||
canonicalParamId: 'botCredential',
|
||||
serviceId: 'slack',
|
||||
credentialKind: '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' },
|
||||
mode: 'trigger',
|
||||
condition: { field: 'appType', value: 'custom' },
|
||||
},
|
||||
{
|
||||
id: 'manualBotCredential',
|
||||
title: 'Bot Credential ID',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'botCredential',
|
||||
placeholder: 'Enter bot credential ID',
|
||||
description: 'Set the custom bot credential ID directly.',
|
||||
required: { field: 'appType', value: 'custom' },
|
||||
mode: 'trigger-advanced',
|
||||
condition: { field: 'appType', value: 'custom' },
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
title: 'Source',
|
||||
type: 'dropdown',
|
||||
multiSelect: true,
|
||||
options: [...SLACK_SOURCE_OPTIONS],
|
||||
placeholder: 'Any source',
|
||||
description:
|
||||
'Restrict to direct messages, public channels, or private channels. Leave empty to match any.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: SOURCE_FILTER_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'channelFilter',
|
||||
title: 'Channels',
|
||||
type: 'channel-selector',
|
||||
canonicalParamId: 'channelFilter',
|
||||
multiSelect: true,
|
||||
serviceId: 'slack',
|
||||
selectorKey: 'slack.channels',
|
||||
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'] },
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: CHANNEL_FILTER_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'manualChannelFilter',
|
||||
title: 'Channel IDs',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'channelFilter',
|
||||
placeholder: 'C0123456789, C0987654321',
|
||||
description: 'Comma-separated channel IDs to restrict to. Set IDs directly here.',
|
||||
required: false,
|
||||
mode: 'trigger-advanced',
|
||||
condition: { field: 'eventType', value: CHANNEL_FILTER_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'threads',
|
||||
title: 'Threads',
|
||||
type: 'dropdown',
|
||||
options: [...SLACK_THREAD_OPTIONS],
|
||||
value: () => 'include',
|
||||
description:
|
||||
'Include thread replies, exclude them (top-level only), or fire only on thread replies.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: THREAD_FILTER_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'emoji',
|
||||
title: 'Emoji',
|
||||
type: 'short-input',
|
||||
placeholder: 'thumbsup, white_check_mark',
|
||||
description: 'Comma-separated emoji names to restrict to. Leave empty to match any emoji.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: EMOJI_FILTER_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'nameContains',
|
||||
title: 'Name contains',
|
||||
type: 'short-input',
|
||||
placeholder: 'incident-',
|
||||
description: 'Only fire when the created channel name contains this text.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: NAME_FILTER_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'interactionFilter',
|
||||
title: 'Action / Callback ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'approve_btn, deny_btn',
|
||||
description:
|
||||
'Comma-separated action_ids (buttons/selects) or callback_ids (modals) to restrict to. Leave empty to fire on any interaction.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: INTERACTION_FILTER_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'filterBotMessages',
|
||||
title: 'Ignore bot messages',
|
||||
type: 'switch',
|
||||
defaultValue: true,
|
||||
description: "Ignore messages sent by other bots. This app's own output is always ignored.",
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: BOT_FILTER_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'includeOwnMessages',
|
||||
title: 'Include own bot messages',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
description:
|
||||
"Also fire on this app's own messages and reactions. Can cause loops — use with care.",
|
||||
required: false,
|
||||
mode: 'trigger-advanced',
|
||||
condition: { field: 'eventType', value: OWN_MESSAGE_EVENTS },
|
||||
},
|
||||
{
|
||||
id: 'includeFiles',
|
||||
title: 'Include file attachments',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
description: 'Download and include file attachments from messages. Requires files:read.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
condition: { field: 'eventType', value: 'message' },
|
||||
},
|
||||
],
|
||||
|
||||
outputs: SLACK_TRIGGER_OUTPUTS,
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import type { TriggerOutput } from '@/triggers/types'
|
||||
|
||||
/**
|
||||
* Unified Slack trigger output shape, shared by the legacy bring-your-own-app
|
||||
* webhook trigger and the native OAuth (`slack_app`) trigger. Both normalize
|
||||
* Events API / interactivity / slash-command payloads into this shape via the
|
||||
* Slack webhook provider handler, so downstream blocks resolve identically
|
||||
* regardless of how the app was installed.
|
||||
*/
|
||||
export const SLACK_TRIGGER_OUTPUTS: Record<string, TriggerOutput> = {
|
||||
event: {
|
||||
type: 'object',
|
||||
description: 'Slack event data',
|
||||
properties: {
|
||||
event_type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Type of Slack payload: an Events API event (e.g., app_mention, message), an interactivity type (e.g., block_actions), or "slash_command" for slash commands',
|
||||
},
|
||||
subtype: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Message subtype (e.g., channel_join, channel_leave, bot_message, file_share). Null for regular user messages',
|
||||
},
|
||||
channel: {
|
||||
type: 'string',
|
||||
description: 'Slack channel ID where the event occurred',
|
||||
},
|
||||
channel_name: {
|
||||
type: 'string',
|
||||
description: 'Human-readable channel name',
|
||||
},
|
||||
channel_type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Type of channel (e.g., channel, group, im, mpim). Useful for distinguishing DMs from public channels',
|
||||
},
|
||||
user: {
|
||||
type: 'string',
|
||||
description: 'User ID who triggered the event',
|
||||
},
|
||||
user_name: {
|
||||
type: 'string',
|
||||
description: 'Username who triggered the event',
|
||||
},
|
||||
bot_id: {
|
||||
type: 'string',
|
||||
description: 'Bot ID if the message was sent by a bot. Null for human users',
|
||||
},
|
||||
text: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Message text content. For slash commands, the text after the command. For interactivity, the source message text (falls back to the triggering action value)',
|
||||
},
|
||||
timestamp: {
|
||||
type: 'string',
|
||||
description: 'Message timestamp from the triggering event',
|
||||
},
|
||||
thread_ts: {
|
||||
type: 'string',
|
||||
description: 'Parent thread timestamp (if message is in a thread)',
|
||||
},
|
||||
team_id: {
|
||||
type: 'string',
|
||||
description: 'Slack workspace/team ID',
|
||||
},
|
||||
event_id: {
|
||||
type: 'string',
|
||||
description: 'Unique event identifier',
|
||||
},
|
||||
reaction: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Emoji reaction name (e.g., thumbsup). Present for reaction_added/reaction_removed events',
|
||||
},
|
||||
item_user: {
|
||||
type: 'string',
|
||||
description:
|
||||
'User ID of the original message author. Present for reaction_added/reaction_removed events',
|
||||
},
|
||||
command: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Slash command name including the leading slash (e.g., /deploy). Present for slash commands',
|
||||
},
|
||||
action_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'action_id of the first interactive element triggered. Present for block_actions (button/select clicks)',
|
||||
},
|
||||
action_value: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Value carried by the first interactive element (button value, selected option, date, etc.). Present for block_actions',
|
||||
},
|
||||
actions: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Full array of interactive actions from the payload, preserving every element and its value. Present for block_actions',
|
||||
},
|
||||
response_url: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Temporary URL to post a response back to the originating message or command. Present for interactivity and slash commands',
|
||||
},
|
||||
trigger_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Short-lived trigger ID used to open a modal in response. Present for interactivity and slash commands',
|
||||
},
|
||||
callback_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Callback ID of the shortcut or view. Present for shortcuts and modal submissions',
|
||||
},
|
||||
api_app_id: {
|
||||
type: 'string',
|
||||
description: 'Slack app ID. Present for interactivity and slash commands',
|
||||
},
|
||||
app_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
"App ID of the app that produced the event (e.g. the bot that posted a message). Used to identify the app's own output",
|
||||
},
|
||||
message_ts: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Timestamp of the message the interaction originated from. Present for block_actions',
|
||||
},
|
||||
view: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Full Slack view object for modal interactions: state.values (submitted input values), private_metadata, id, callback_id, and hash. Present for view_submission/view_closed; null otherwise',
|
||||
},
|
||||
message: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Full source message object the interaction came from, including its blocks and text. Present for block_actions on a message; null otherwise',
|
||||
},
|
||||
state: {
|
||||
type: 'json',
|
||||
description:
|
||||
'Current values of all stateful elements in the surface (state.values) at the time of a block action — e.g. inputs read on a button click. Present for block_actions; null otherwise',
|
||||
},
|
||||
hasFiles: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the message has file attachments',
|
||||
},
|
||||
files: {
|
||||
type: 'file[]',
|
||||
description:
|
||||
'File attachments downloaded from the message (if includeFiles is enabled and bot token is provided)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A contextual filter a Slack event can expose. Each entry's `filters` list
|
||||
* drives both which filter sub-blocks the trigger UI shows and which checks the
|
||||
* ingest route applies:
|
||||
* - `source`: restrict a message event to DMs / public / private channels.
|
||||
* - `channels`: restrict to specific channel IDs.
|
||||
* - `threads`: include / exclude / only thread replies.
|
||||
* - `emoji`: restrict a reaction event to specific emoji names.
|
||||
* - `name`: substring match on a created channel's name.
|
||||
* - `interaction`: restrict an interactivity event to specific `action_id`s
|
||||
* (block_actions) or `callback_id`s (view_submission).
|
||||
*/
|
||||
export type SlackEventFilter = 'source' | 'channels' | 'threads' | 'emoji' | 'name' | 'interaction'
|
||||
|
||||
export interface SlackEventCatalogEntry {
|
||||
/** Selected value stored under the `eventType` sub-block. */
|
||||
id: string
|
||||
label: string
|
||||
/** True when the official shared Sim app already subscribes to the event. */
|
||||
simSubscribed: boolean
|
||||
/** Contextual filters this event supports. */
|
||||
filters: readonly SlackEventFilter[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The full catalog of selectable events for the native OAuth (`slack_app`)
|
||||
* trigger, and the single source of truth for event gating. One trigger block
|
||||
* fires on exactly one `id`. `simSubscribed` gates which events are offered in
|
||||
* Sim mode (the official app), while every event is offered in Custom mode
|
||||
* (the bring-your-own app generates a manifest that subscribes to it, driven by
|
||||
* SLACK_CAPABILITIES). `filters` drives both the trigger UI (which filter
|
||||
* sub-blocks show) and the ingest route (which checks apply).
|
||||
*/
|
||||
export const SLACK_EVENT_CATALOG: readonly SlackEventCatalogEntry[] = [
|
||||
{
|
||||
id: 'message',
|
||||
label: 'Message',
|
||||
simSubscribed: true,
|
||||
filters: ['source', 'channels', 'threads'],
|
||||
},
|
||||
{
|
||||
id: 'app_mention',
|
||||
label: 'App mentioned',
|
||||
simSubscribed: true,
|
||||
filters: ['channels', 'threads'],
|
||||
},
|
||||
{
|
||||
id: 'reaction_added',
|
||||
label: 'Reaction added',
|
||||
simSubscribed: true,
|
||||
filters: ['emoji', 'channels'],
|
||||
},
|
||||
{
|
||||
id: 'reaction_removed',
|
||||
label: 'Reaction removed',
|
||||
simSubscribed: true,
|
||||
filters: ['emoji', 'channels'],
|
||||
},
|
||||
{
|
||||
id: 'message_edited',
|
||||
label: 'Message edited',
|
||||
simSubscribed: true,
|
||||
filters: ['source', 'channels'],
|
||||
},
|
||||
{
|
||||
id: 'message_deleted',
|
||||
label: 'Message deleted',
|
||||
simSubscribed: true,
|
||||
filters: ['source', 'channels'],
|
||||
},
|
||||
{ id: 'file_shared', label: 'File shared', simSubscribed: false, filters: ['channels'] },
|
||||
{
|
||||
id: 'member_joined_channel',
|
||||
label: 'Member joined channel',
|
||||
simSubscribed: false,
|
||||
filters: ['channels'],
|
||||
},
|
||||
{
|
||||
id: 'member_left_channel',
|
||||
label: 'Member left channel',
|
||||
simSubscribed: false,
|
||||
filters: ['channels'],
|
||||
},
|
||||
{ id: 'channel_created', label: 'Channel created', simSubscribed: false, filters: ['name'] },
|
||||
{ id: 'channel_archive', label: 'Channel archived', simSubscribed: false, filters: ['channels'] },
|
||||
{ id: 'channel_rename', label: 'Channel renamed', simSubscribed: false, filters: ['channels'] },
|
||||
{ id: 'pin_added', label: 'Pin added', simSubscribed: false, filters: ['channels'] },
|
||||
{ id: 'pin_removed', label: 'Pin removed', simSubscribed: false, filters: ['channels'] },
|
||||
{ id: 'team_join', label: 'Member joined workspace', simSubscribed: false, filters: [] },
|
||||
{ id: 'app_home_opened', label: 'App home opened', simSubscribed: false, filters: [] },
|
||||
{ id: 'assistant_thread_started', label: 'Assistant opened', simSubscribed: true, filters: [] },
|
||||
{
|
||||
id: 'assistant_thread_context_changed',
|
||||
label: 'Assistant context changed',
|
||||
simSubscribed: true,
|
||||
filters: [],
|
||||
},
|
||||
{
|
||||
id: 'block_actions',
|
||||
label: 'Button / select clicked',
|
||||
simSubscribed: true,
|
||||
filters: ['interaction'],
|
||||
},
|
||||
{
|
||||
id: 'view_submission',
|
||||
label: 'Modal submitted',
|
||||
simSubscribed: true,
|
||||
filters: ['interaction'],
|
||||
},
|
||||
] as const
|
||||
|
||||
/** Catalog entry lookup by `eventType` id. */
|
||||
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). */
|
||||
export const SLACK_ALL_EVENT_OPTIONS = SLACK_EVENT_CATALOG.map((entry) => ({
|
||||
label: entry.label,
|
||||
id: entry.id,
|
||||
}))
|
||||
|
||||
/**
|
||||
* Message-source filter options. Each id maps to a Slack `channel_type`. The
|
||||
* filter is multiselect and empty means any source, so "public + private, no
|
||||
* DMs" is `[channel, group]` — a case a single-select could not express.
|
||||
*/
|
||||
export const SLACK_SOURCE_OPTIONS = [
|
||||
{ label: 'Direct message', id: 'im' },
|
||||
{ label: 'Public channel', id: 'channel' },
|
||||
{ label: 'Private channel', id: 'group' },
|
||||
] as const
|
||||
|
||||
/** Three-way thread filter options. */
|
||||
export const SLACK_THREAD_OPTIONS = [
|
||||
{ label: 'Messages and replies', id: 'include' },
|
||||
{ label: 'Messages only (no replies)', id: 'exclude' },
|
||||
{ label: 'Replies only', id: 'only' },
|
||||
] as const
|
||||
|
||||
/** True when the given event id exposes the given contextual filter. */
|
||||
export function slackEventSupportsFilter(eventType: string, filter: SlackEventFilter): boolean {
|
||||
return slackEventById.get(eventType)?.filters.includes(filter) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Event ids that expose the given filter — the catalog-derived list used for
|
||||
* trigger sub-block `condition` gating, so the UI and the ingest route share one
|
||||
* source of truth.
|
||||
*/
|
||||
export function slackEventsSupportingFilter(filter: SlackEventFilter): string[] {
|
||||
return SLACK_EVENT_CATALOG.filter((entry) => entry.filters.includes(filter)).map(
|
||||
(entry) => entry.id
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { SlackIcon } from '@/components/icons'
|
||||
import { SLACK_TRIGGER_OUTPUTS } from '@/triggers/slack/shared'
|
||||
import type { TriggerConfig } from '@/triggers/types'
|
||||
|
||||
export const slackWebhookTrigger: TriggerConfig = {
|
||||
id: 'slack_webhook',
|
||||
name: 'Slack Webhook',
|
||||
provider: 'slack',
|
||||
description: 'Trigger workflow from Slack events like mentions, messages, and reactions',
|
||||
version: '1.0.0',
|
||||
icon: SlackIcon,
|
||||
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'webhookUrlDisplay',
|
||||
title: 'Webhook URL',
|
||||
type: 'short-input',
|
||||
readOnly: true,
|
||||
showCopyButton: true,
|
||||
useWebhookUrl: true,
|
||||
placeholder: 'Webhook URL will be generated',
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'signingSecret',
|
||||
title: 'Signing Secret',
|
||||
type: 'short-input',
|
||||
placeholder: 'Enter your Slack app signing secret',
|
||||
description: 'The signing secret from your Slack app to validate request authenticity.',
|
||||
password: true,
|
||||
required: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'botToken',
|
||||
title: 'Bot Token',
|
||||
type: 'short-input',
|
||||
placeholder: 'xoxb-...',
|
||||
description:
|
||||
'The bot token from your Slack app. Required for downloading files attached to messages.',
|
||||
password: true,
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'includeFiles',
|
||||
title: 'Include File Attachments',
|
||||
type: 'switch',
|
||||
defaultValue: false,
|
||||
description:
|
||||
'Download and include file attachments from messages. Requires a bot token with files:read scope.',
|
||||
required: false,
|
||||
mode: 'trigger',
|
||||
},
|
||||
{
|
||||
id: 'setupWizard',
|
||||
title: 'Slack app setup',
|
||||
type: 'modal',
|
||||
modalId: 'slack-setup-wizard',
|
||||
description: 'Walk through manifest creation, app install, and pasting credentials.',
|
||||
hideFromPreview: true,
|
||||
mode: 'trigger',
|
||||
},
|
||||
],
|
||||
|
||||
outputs: SLACK_TRIGGER_OUTPUTS,
|
||||
|
||||
webhook: {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user