mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-09 22:25:33 -05:00
Compare commits
2 Commits
cursor/dev
...
fix/slack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
060237f4dd | ||
|
|
622d0cad22 |
@@ -1,38 +0,0 @@
|
||||
# Sim Cloud Agent Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
Sim is an AI agent workflow builder. Turborepo monorepo with Bun workspaces.
|
||||
|
||||
### Services
|
||||
|
||||
| Service | Port | Command |
|
||||
|---------|------|---------|
|
||||
| Next.js App | 3000 | `bun run dev` (from root) |
|
||||
| Realtime Socket Server | 3002 | `cd apps/sim && bun run dev:sockets` |
|
||||
| Both together | 3000+3002 | `bun run dev:full` (from root) |
|
||||
| Docs site | 3001 | `cd apps/docs && bun run dev` |
|
||||
| PostgreSQL (pgvector) | 5432 | Docker container `simstudio-db` |
|
||||
|
||||
## Common Commands
|
||||
|
||||
- **Lint**: `bun run lint:check` (read-only) or `bun run lint` (auto-fix)
|
||||
- **Format**: `bun run format:check` (read-only) or `bun run format` (auto-fix)
|
||||
- **Test**: `bun run test` (all packages via turborepo)
|
||||
- **Test single app**: `cd apps/sim && bunx vitest run`
|
||||
- **Type check**: `bun run type-check`
|
||||
- **Dev**: `bun run dev:full` (Next.js app + realtime socket server)
|
||||
- **DB migrations**: `cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts`
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- Package manager is **bun** (not npm/npx). Use `bun` and `bunx`.
|
||||
- Linter/formatter is **Biome** (not ESLint/Prettier).
|
||||
- Testing framework is **Vitest** with `@sim/testing` for shared mocks/factories.
|
||||
- Database uses **Drizzle ORM** with PostgreSQL + pgvector.
|
||||
- Auth is **Better Auth** (session cookies).
|
||||
- Pre-commit hook runs `bunx lint-staged` which applies `biome check --write`.
|
||||
- `.npmrc` has `ignore-scripts=true`.
|
||||
- Docs app requires `fumadocs-mdx` generation before type-check (`bunx fumadocs-mdx` in `apps/docs/`).
|
||||
- Coding guidelines are in `CLAUDE.md` (root) and `.cursor/rules/*.mdc`.
|
||||
- See `.github/CONTRIBUTING.md` for contribution workflow details.
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { getInputFormatExample as getInputFormatExampleUtil } from '@/lib/workflows/operations/deployment-utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
|
||||
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/api-keys/components'
|
||||
import { startsWithUuid } from '@/executor/constants'
|
||||
import { useA2AAgentByWorkflow } from '@/hooks/queries/a2a/agents'
|
||||
@@ -38,6 +39,7 @@ import { useWorkspaceSettings } from '@/hooks/queries/workspace'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useSettingsModalStore } from '@/stores/modals/settings/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { A2aDeploy } from './components/a2a/a2a'
|
||||
@@ -335,6 +337,20 @@ export function DeployModal({
|
||||
setDeployError(null)
|
||||
setDeployWarnings([])
|
||||
|
||||
const { blocks, edges, loops, parallels } = useWorkflowStore.getState()
|
||||
const liveBlocks = mergeSubblockState(blocks, workflowId)
|
||||
const checkResult = runPreDeployChecks({
|
||||
blocks: liveBlocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
workflowId,
|
||||
})
|
||||
if (!checkResult.passed) {
|
||||
setDeployError(checkResult.error || 'Pre-deploy validation failed')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deployMutation.mutateAsync({ workflowId, deployChatEnabled: false })
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
|
||||
@@ -2,9 +2,6 @@ import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useNotificationStore } from '@/stores/notifications'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { mergeSubblockState } from '@/stores/workflows/utils'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { runPreDeployChecks } from './use-predeploy-checks'
|
||||
|
||||
const logger = createLogger('useDeployment')
|
||||
|
||||
@@ -25,36 +22,16 @@ export function useDeployment({
|
||||
const [isDeploying, setIsDeploying] = useState(false)
|
||||
const setDeploymentStatus = useWorkflowRegistry((state) => state.setDeploymentStatus)
|
||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||
const blocks = useWorkflowStore((state) => state.blocks)
|
||||
const edges = useWorkflowStore((state) => state.edges)
|
||||
const loops = useWorkflowStore((state) => state.loops)
|
||||
const parallels = useWorkflowStore((state) => state.parallels)
|
||||
|
||||
/**
|
||||
* Handle deploy button click
|
||||
* First deploy: calls API to deploy, then opens modal on success
|
||||
* Redeploy: validates client-side, then opens modal if valid
|
||||
* Already deployed: opens modal directly (validation happens on Update in modal)
|
||||
*/
|
||||
const handleDeployClick = useCallback(async () => {
|
||||
if (!workflowId) return { success: false, shouldOpenModal: false }
|
||||
|
||||
if (isDeployed) {
|
||||
const liveBlocks = mergeSubblockState(blocks, workflowId)
|
||||
const checkResult = runPreDeployChecks({
|
||||
blocks: liveBlocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
workflowId,
|
||||
})
|
||||
if (!checkResult.passed) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: checkResult.error || 'Pre-deploy validation failed',
|
||||
workflowId,
|
||||
})
|
||||
return { success: false, shouldOpenModal: false }
|
||||
}
|
||||
return { success: true, shouldOpenModal: true }
|
||||
}
|
||||
|
||||
@@ -101,17 +78,7 @@ export function useDeployment({
|
||||
} finally {
|
||||
setIsDeploying(false)
|
||||
}
|
||||
}, [
|
||||
workflowId,
|
||||
isDeployed,
|
||||
blocks,
|
||||
edges,
|
||||
loops,
|
||||
parallels,
|
||||
refetchDeployedState,
|
||||
setDeploymentStatus,
|
||||
addNotification,
|
||||
])
|
||||
}, [workflowId, isDeployed, refetchDeployedState, setDeploymentStatus, addNotification])
|
||||
|
||||
return {
|
||||
isDeploying,
|
||||
|
||||
@@ -527,17 +527,61 @@ export async function validateTwilioSignature(
|
||||
}
|
||||
}
|
||||
|
||||
const SLACK_FILE_HOSTS = new Set(['files.slack.com', 'files-pri.slack.com'])
|
||||
const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB
|
||||
const SLACK_MAX_FILES = 10
|
||||
const SLACK_MAX_FILES = 15
|
||||
|
||||
/**
|
||||
* Resolves the full file object from the Slack API when the event payload
|
||||
* only contains a partial file (e.g. missing url_private due to file_access restrictions).
|
||||
* @see https://docs.slack.dev/reference/methods/files.info
|
||||
*/
|
||||
async function resolveSlackFileInfo(
|
||||
fileId: string,
|
||||
botToken: string
|
||||
): Promise<{ url_private?: string; name?: string; mimetype?: string; size?: number } | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
}
|
||||
)
|
||||
|
||||
const data = (await response.json()) as {
|
||||
ok: boolean
|
||||
error?: string
|
||||
file?: Record<string, any>
|
||||
}
|
||||
|
||||
if (!data.ok || !data.file) {
|
||||
logger.warn('Slack files.info failed', { fileId, error: data.error })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
url_private: data.file.url_private,
|
||||
name: data.file.name,
|
||||
mimetype: data.file.mimetype,
|
||||
size: data.file.size,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error calling Slack files.info', {
|
||||
fileId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads file attachments from Slack using the bot token.
|
||||
* Returns files in the format expected by WebhookAttachmentProcessor:
|
||||
* { name, data (base64 string), mimeType, size }
|
||||
*
|
||||
* When the event payload contains partial file objects (missing url_private),
|
||||
* falls back to the Slack files.info API to resolve the full file metadata.
|
||||
*
|
||||
* Security:
|
||||
* - Validates each url_private against allowlisted Slack file hosts
|
||||
* - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF
|
||||
* - Enforces per-file size limit and max file count
|
||||
*/
|
||||
@@ -549,30 +593,31 @@ async function downloadSlackFiles(
|
||||
const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = []
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const urlPrivate = file.url_private as string | undefined
|
||||
let urlPrivate = file.url_private as string | undefined
|
||||
let fileName = file.name as string | undefined
|
||||
let fileMimeType = file.mimetype as string | undefined
|
||||
let fileSize = file.size as number | undefined
|
||||
|
||||
// If url_private is missing, resolve via files.info API
|
||||
if (!urlPrivate && file.id) {
|
||||
const resolved = await resolveSlackFileInfo(file.id, botToken)
|
||||
if (resolved?.url_private) {
|
||||
urlPrivate = resolved.url_private
|
||||
fileName = fileName || resolved.name
|
||||
fileMimeType = fileMimeType || resolved.mimetype
|
||||
fileSize = fileSize ?? resolved.size
|
||||
}
|
||||
}
|
||||
|
||||
if (!urlPrivate) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate the URL points to a known Slack file host
|
||||
let parsedUrl: URL
|
||||
try {
|
||||
parsedUrl = new URL(urlPrivate)
|
||||
} catch {
|
||||
logger.warn('Slack file has invalid url_private, skipping', { fileId: file.id })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!SLACK_FILE_HOSTS.has(parsedUrl.hostname)) {
|
||||
logger.warn('Slack file url_private points to unexpected host, skipping', {
|
||||
logger.warn('Slack file has no url_private and could not be resolved, skipping', {
|
||||
fileId: file.id,
|
||||
hostname: sanitizeUrlForLog(urlPrivate),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip files that exceed the size limit
|
||||
const reportedSize = Number(file.size) || 0
|
||||
const reportedSize = Number(fileSize) || 0
|
||||
if (reportedSize > SLACK_MAX_FILE_SIZE) {
|
||||
logger.warn('Slack file exceeds size limit, skipping', {
|
||||
fileId: file.id,
|
||||
@@ -618,9 +663,9 @@ async function downloadSlackFiles(
|
||||
}
|
||||
|
||||
downloaded.push({
|
||||
name: file.name || 'download',
|
||||
name: fileName || 'download',
|
||||
data: buffer.toString('base64'),
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
mimeType: fileMimeType || 'application/octet-stream',
|
||||
size: buffer.length,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -62,6 +62,45 @@ const shouldSkipEntry = (output: any): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
interface NotifyBlockErrorParams {
|
||||
error: unknown
|
||||
blockName: string
|
||||
workflowId?: string
|
||||
logContext: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an error notification for a block failure if error notifications are enabled.
|
||||
*/
|
||||
const notifyBlockError = ({ error, blockName, workflowId, logContext }: NotifyBlockErrorParams) => {
|
||||
const settings = getQueryClient().getQueryData<GeneralSettings>(generalSettingsKeys.settings())
|
||||
const isErrorNotificationsEnabled = settings?.errorNotificationsEnabled ?? true
|
||||
|
||||
if (!isErrorNotificationsEnabled) return
|
||||
|
||||
try {
|
||||
const errorMessage = String(error)
|
||||
const displayName = blockName || 'Unknown Block'
|
||||
const displayMessage = `${displayName}: ${errorMessage}`
|
||||
const copilotMessage = `${errorMessage}\n\nError in ${displayName}.\n\nPlease fix this.`
|
||||
|
||||
useNotificationStore.getState().addNotification({
|
||||
level: 'error',
|
||||
message: displayMessage,
|
||||
workflowId,
|
||||
action: {
|
||||
type: 'copilot',
|
||||
message: copilotMessage,
|
||||
},
|
||||
})
|
||||
} catch (notificationError) {
|
||||
logger.error('Failed to create block error notification', {
|
||||
...logContext,
|
||||
error: notificationError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
@@ -154,35 +193,12 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
const newEntry = get().entries[0]
|
||||
|
||||
if (newEntry?.error) {
|
||||
const settings = getQueryClient().getQueryData<GeneralSettings>(
|
||||
generalSettingsKeys.settings()
|
||||
)
|
||||
const isErrorNotificationsEnabled = settings?.errorNotificationsEnabled ?? true
|
||||
|
||||
if (isErrorNotificationsEnabled) {
|
||||
try {
|
||||
const errorMessage = String(newEntry.error)
|
||||
const blockName = newEntry.blockName || 'Unknown Block'
|
||||
const displayMessage = `${blockName}: ${errorMessage}`
|
||||
|
||||
const copilotMessage = `${errorMessage}\n\nError in ${blockName}.\n\nPlease fix this.`
|
||||
|
||||
useNotificationStore.getState().addNotification({
|
||||
level: 'error',
|
||||
message: displayMessage,
|
||||
workflowId: entry.workflowId,
|
||||
action: {
|
||||
type: 'copilot',
|
||||
message: copilotMessage,
|
||||
},
|
||||
})
|
||||
} catch (notificationError) {
|
||||
logger.error('Failed to create block error notification', {
|
||||
entryId: newEntry.id,
|
||||
error: notificationError,
|
||||
})
|
||||
}
|
||||
}
|
||||
notifyBlockError({
|
||||
error: newEntry.error,
|
||||
blockName: newEntry.blockName || 'Unknown Block',
|
||||
workflowId: entry.workflowId,
|
||||
logContext: { entryId: newEntry.id },
|
||||
})
|
||||
}
|
||||
|
||||
return newEntry
|
||||
@@ -376,6 +392,18 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
|
||||
return { entries: updatedEntries }
|
||||
})
|
||||
|
||||
if (typeof update === 'object' && update.error) {
|
||||
const matchingEntry = get().entries.find(
|
||||
(e) => e.blockId === blockId && e.executionId === executionId
|
||||
)
|
||||
notifyBlockError({
|
||||
error: update.error,
|
||||
blockName: matchingEntry?.blockName || 'Unknown Block',
|
||||
workflowId: matchingEntry?.workflowId,
|
||||
logContext: { blockId },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
cancelRunningEntries: (workflowId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user