diff --git a/apps/sim/app/api/tools/linear/projects/route.ts b/apps/sim/app/api/tools/linear/projects/route.ts index abf3ad14135..e7ca9bab1a7 100644 --- a/apps/sim/app/api/tools/linear/projects/route.ts +++ b/apps/sim/app/api/tools/linear/projects/route.ts @@ -45,17 +45,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const linearClient = new LinearClient({ accessToken }) - let projects: Array<{ id: string; name: string }> = [] - const team = await linearClient.team(teamId) - const projectsResult = await team.projects() - projects = projectsResult.nodes.map((project: Project) => ({ - id: project.id, - name: project.name, - })) + /** + * teamId may be a single ID or a comma-separated list when the basic-mode + * team selector is in multi-select. Fetch projects from each team in + * parallel and dedupe by project ID (Linear projects can be cross-team). + */ + const teamIds = teamId + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + const perTeam = await Promise.all( + teamIds.map(async (id) => { + const team = await linearClient.team(id) + const result = await team.projects() + return result.nodes.map((project: Project) => ({ + id: project.id, + name: project.name, + })) + }) + ) + + const seen = new Set() + const projects: Array<{ id: string; name: string }> = [] + for (const teamProjects of perTeam) { + for (const project of teamProjects) { + if (seen.has(project.id)) continue + seen.add(project.id) + projects.push(project) + } + } if (projects.length === 0) { - logger.info('No projects found for team', { teamId }) + logger.info('No projects found for team(s)', { teamIds }) } return NextResponse.json({ projects }) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx index ad215ab4a19..55760bb818d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/add-connector-modal.tsx @@ -29,6 +29,7 @@ import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' +import type { ConfigFieldValue } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -108,6 +109,7 @@ export function AddConnectorModal({ setCanonicalModes, canonicalGroups, isFieldVisible, + isFieldPopulated, handleFieldChange, toggleCanonicalMode, resolveSourceConfig, @@ -150,7 +152,7 @@ export function AddConnectorModal({ for (const field of connectorConfig.configFields) { if (!field.required) continue if (!isFieldVisible(field)) continue - if (!sourceConfig[field.id]?.trim()) return false + if (!isFieldPopulated(field)) return false } return true }, [ @@ -158,8 +160,8 @@ export function AddConnectorModal({ isApiKeyMode, apiKeyValue, effectiveCredentialId, - sourceConfig, isFieldVisible, + isFieldPopulated, ]) const handleSubmit = () => { @@ -169,7 +171,13 @@ export function AddConnectorModal({ const resolvedConfig: Record = {} for (const [key, value] of Object.entries(resolveSourceConfig())) { - if (value) resolvedConfig[key] = value + if (Array.isArray(value)) { + if (value.length > 0) resolvedConfig[key] = value + } else if (typeof value === 'string') { + if (value) resolvedConfig[key] = value + } else if (value !== undefined && value !== null) { + resolvedConfig[key] = value + } } if (disabledTagIds.size > 0) { resolvedConfig.disabledTagIds = Array.from(disabledTagIds) @@ -370,8 +378,8 @@ export function AddConnectorModal({ {field.type === 'selector' && field.selectorKey ? ( handleFieldChange(field.id, value)} + value={sourceConfig[field.id] ?? (field.multi ? [] : '')} + onChange={(value: ConfigFieldValue) => handleFieldChange(field.id, value)} credentialId={effectiveCredentialId} sourceConfig={sourceConfig} configFields={connectorConfig.configFields} @@ -385,13 +393,21 @@ export function AddConnectorModal({ label: opt.label, value: opt.id, }))} - value={sourceConfig[field.id] || undefined} + value={ + typeof sourceConfig[field.id] === 'string' + ? (sourceConfig[field.id] as string) || undefined + : undefined + } onChange={(value) => handleFieldChange(field.id, value)} placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`} /> ) : ( handleFieldChange(field.id, e.target.value)} placeholder={field.placeholder} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx index 527971dfc28..aab56688cad 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field/connector-selector-field.tsx @@ -3,6 +3,10 @@ import { useMemo } from 'react' import { Combobox, type ComboboxOption, Loader } from '@/components/emcn' import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context' +import type { + ConfigFieldMap, + ConfigFieldValue, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { getDependsOnFields } from '@/blocks/utils' import type { ConnectorConfigField } from '@/connectors/types' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' @@ -10,10 +14,10 @@ import { useSelectorOptions } from '@/hooks/selectors/use-selector-query' interface ConnectorSelectorFieldProps { field: ConnectorConfigField & { selectorKey: SelectorKey } - value: string - onChange: (value: string) => void + value: ConfigFieldValue + onChange: (value: ConfigFieldValue) => void credentialId: string | null - sourceConfig: Record + sourceConfig: ConfigFieldMap configFields: ConnectorConfigField[] canonicalModes: Record disabled?: boolean @@ -29,6 +33,8 @@ export function ConnectorSelectorField({ canonicalModes, disabled, }: ConnectorSelectorFieldProps) { + const isMulti = Boolean(field.multi) + const context = useMemo(() => { const ctx: SelectorContext = {} if (credentialId) ctx.oauthCredential = credentialId @@ -73,11 +79,34 @@ export function ConnectorSelectorField({ ) } + if (isMulti) { + const multiValues = Array.isArray(value) ? value : value ? [value] : [] + return ( + onChange(values)} + searchable + searchPlaceholder={`Search ${field.title.toLowerCase()}...`} + placeholder={ + !credentialId + ? 'Connect an account first' + : !depsResolved + ? `Select ${getDependencyLabel(field, configFields)} first` + : field.placeholder || `Select ${field.title.toLowerCase()}` + } + disabled={disabled || !credentialId || !depsResolved} + /> + ) + } + + const singleValue = Array.isArray(value) ? value[0] : value return ( onChange(next)} searchable searchPlaceholder={`Search ${field.title.toLowerCase()}...`} placeholder={ @@ -96,18 +125,28 @@ function resolveDepValue( depFieldId: string, configFields: ConnectorConfigField[], canonicalModes: Record, - sourceConfig: Record + sourceConfig: ConfigFieldMap ): string { const depField = configFields.find((f) => f.id === depFieldId) - if (!depField?.canonicalParamId) return sourceConfig[depFieldId] ?? '' + /** + * For multi-value parent fields, pass all selected values to dependent + * selectors as a comma-joined string so the downstream selector can load + * options across every selected parent (e.g. Linear projects across multiple + * selected teams). Single-value parents pass through unchanged. + */ + const readDep = (raw: ConfigFieldValue | undefined): string => { + if (Array.isArray(raw)) return raw.join(',') + return raw ?? '' + } + if (!depField?.canonicalParamId) return readDep(sourceConfig[depFieldId]) const activeMode = canonicalModes[depField.canonicalParamId] ?? 'basic' - if (depField.mode === activeMode) return sourceConfig[depFieldId] ?? '' + if (depField.mode === activeMode) return readDep(sourceConfig[depFieldId]) const activeField = configFields.find( (f) => f.canonicalParamId === depField.canonicalParamId && f.mode === activeMode ) - return activeField ? (sourceConfig[activeField.id] ?? '') : (sourceConfig[depFieldId] ?? '') + return activeField ? readDep(sourceConfig[activeField.id]) : readDep(sourceConfig[depFieldId]) } function getDependencyLabel( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx index 01fdbf39fc0..a15578145f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/edit-connector-modal/edit-connector-modal.tsx @@ -28,6 +28,10 @@ import { getSubscriptionAccessState } from '@/lib/billing/client' import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/connector-selector-field' import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts' import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge' +import type { + ConfigFieldMap, + ConfigFieldValue, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { useConnectorConfigFields } from '@/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields' import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation' import { CONNECTOR_REGISTRY } from '@/connectors/registry' @@ -61,6 +65,51 @@ function readPersistedCanonicalModes( return result } +/** + * Deep equality for sourceConfig values (string, string[], or undefined/null). + * + * Empty string, empty array, and nullish are treated as equivalent to absence. + * When either side is an array (multi-value field), both sides are normalized + * to string[] via CSV-split-and-trim so a persisted legacy scalar `"ENG"` + * compares equal to an in-memory `["ENG"]` and a persisted CSV `"ENG,PROJ"` + * compares equal to `["ENG","PROJ"]`. Without this, opening edit on a + * pre-multi-select connector would falsely show unsaved changes. + */ +function valuesEqual(a: unknown, b: unknown): boolean { + const isEmpty = (v: unknown): boolean => { + if (v == null) return true + if (Array.isArray(v)) return v.length === 0 + if (typeof v === 'string') return v.trim() === '' + return false + } + if (isEmpty(a) && isEmpty(b)) return true + + const toArray = (v: unknown): string[] | null => { + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string') + if (typeof v === 'string') { + return v + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + return null + } + + if (Array.isArray(a) || Array.isArray(b)) { + const arrA = toArray(a) ?? [] + const arrB = toArray(b) ?? [] + if (arrA.length !== arrB.length) return false + /** + * Order-insensitive: the multi-select UI does not guarantee insertion order + * matches the server-returned order, so `["PROD","ENG"]` and `["ENG","PROD"]` + * should be treated as equal to avoid a false unsaved-changes state. + */ + const setA = new Set(arrA) + return arrB.every((v) => setA.has(v)) + } + return a === b +} + function didCanonicalModesChange( current: Record, persisted: Record @@ -96,11 +145,16 @@ export function EditConnectorModal({ * manual input), both field IDs get the same value so toggling preserves it. * Captured once on mount; editing state is owned by the hook afterward. */ - const [initialSourceConfig] = useState>(() => { - const config: Record = {} + const [initialSourceConfig] = useState(() => { + const config: ConfigFieldMap = {} if (!connectorConfig) { for (const [key, value] of Object.entries(connector.sourceConfig)) { - if (!INTERNAL_CONFIG_KEYS.has(key)) config[key] = String(value ?? '') + if (INTERNAL_CONFIG_KEYS.has(key)) continue + if (Array.isArray(value)) { + config[key] = value.filter((v): v is string => typeof v === 'string') + } else { + config[key] = String(value ?? '') + } } return config } @@ -108,7 +162,21 @@ export function EditConnectorModal({ const canonicalId = field.canonicalParamId ?? field.id if (INTERNAL_CONFIG_KEYS.has(canonicalId)) continue const rawValue = connector.sourceConfig[canonicalId] - if (rawValue !== undefined) config[field.id] = String(rawValue ?? '') + if (rawValue === undefined) continue + if (field.multi) { + if (Array.isArray(rawValue)) { + config[field.id] = rawValue.filter((v): v is string => typeof v === 'string') + } else if (typeof rawValue === 'string') { + config[field.id] = rawValue + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } else { + config[field.id] = [] + } + } else { + config[field.id] = String(rawValue ?? '') + } } return config }) @@ -147,7 +215,7 @@ export function EditConnectorModal({ if (didCanonicalModesChange(canonicalModes, persistedCanonicalModes)) return true const resolved = resolveSourceConfig() for (const [key, value] of Object.entries(resolved)) { - if (String(connector.sourceConfig[key] ?? '') !== value) return true + if (!valuesEqual(connector.sourceConfig[key], value)) return true } return false }, [ @@ -169,9 +237,9 @@ export function EditConnectorModal({ } const resolved = resolveSourceConfig() - const changedEntries: Record = {} + const changedEntries: Record = {} for (const [key, value] of Object.entries(resolved)) { - if (String(connector.sourceConfig[key] ?? '') !== value) changedEntries[key] = value + if (!valuesEqual(connector.sourceConfig[key], value)) changedEntries[key] = value } const modesChanged = didCanonicalModesChange(canonicalModes, persistedCanonicalModes) @@ -276,12 +344,12 @@ export function EditConnectorModal({ interface SettingsTabProps { connectorConfig: ConnectorConfig | null - sourceConfig: Record + sourceConfig: ConfigFieldMap credentialId: string | null canonicalGroups: Map canonicalModes: Record onToggleCanonicalMode: (canonicalId: string) => void - onFieldChange: (fieldId: string, value: string) => void + onFieldChange: (fieldId: string, value: ConfigFieldValue) => void isFieldVisible: (field: ConnectorConfigField) => boolean syncInterval: number setSyncInterval: (v: number) => void @@ -344,8 +412,8 @@ function SettingsTab({ {field.type === 'selector' && field.selectorKey ? ( onFieldChange(field.id, value)} + value={sourceConfig[field.id] ?? (field.multi ? [] : '')} + onChange={(value: ConfigFieldValue) => onFieldChange(field.id, value)} credentialId={credentialId} sourceConfig={sourceConfig} configFields={connectorConfig.configFields} @@ -359,13 +427,21 @@ function SettingsTab({ label: opt.label, value: opt.id, }))} - value={sourceConfig[field.id] || undefined} + value={ + typeof sourceConfig[field.id] === 'string' + ? (sourceConfig[field.id] as string) || undefined + : undefined + } onChange={(value) => onFieldChange(field.id, value)} placeholder={field.placeholder || `Select ${field.title.toLowerCase()}`} /> ) : ( onFieldChange(field.id, e.target.value)} placeholder={field.placeholder} /> diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts index 8419b749602..737c96caced 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-config-fields.ts @@ -4,42 +4,75 @@ import { useCallback, useMemo, useState } from 'react' import { getDependsOnFields } from '@/blocks/utils' import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types' +export type ConfigFieldValue = string | string[] +export type ConfigFieldMap = Record + export interface UseConnectorConfigFieldsOptions { connectorConfig: ConnectorConfig | null - initialSourceConfig?: Record + initialSourceConfig?: ConfigFieldMap initialCanonicalModes?: Record } export interface UseConnectorConfigFieldsResult { - sourceConfig: Record - setSourceConfig: React.Dispatch>> + sourceConfig: ConfigFieldMap + setSourceConfig: React.Dispatch> canonicalModes: Record setCanonicalModes: React.Dispatch>> canonicalGroups: Map isFieldVisible: (field: ConnectorConfigField) => boolean - handleFieldChange: (fieldId: string, value: string) => void + isFieldPopulated: (field: ConnectorConfigField) => boolean + handleFieldChange: (fieldId: string, value: ConfigFieldValue) => void toggleCanonicalMode: (canonicalId: string) => void - resolveSourceConfig: () => Record + resolveSourceConfig: () => Record +} + +function isMultiField(field: ConnectorConfigField | undefined): boolean { + return Boolean(field?.multi) +} + +function emptyValue(field: ConnectorConfigField | undefined): ConfigFieldValue { + return isMultiField(field) ? [] : '' +} + +/** + * Coerces a stored value to the shape expected by the field (string vs string[]). + * Multi fields accept either a string[] or a CSV string from advanced mode. + */ +function coerceForField(field: ConnectorConfigField, raw: unknown): ConfigFieldValue { + if (isMultiField(field)) { + if (Array.isArray(raw)) return raw.filter((v): v is string => typeof v === 'string') + if (typeof raw === 'string') { + const trimmed = raw.trim() + if (!trimmed) return [] + return trimmed + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + } + return [] + } + if (Array.isArray(raw)) { + return raw.filter((v): v is string => typeof v === 'string').join(',') + } + return raw == null ? '' : String(raw) +} + +function isValuePopulated(value: ConfigFieldValue): boolean { + if (Array.isArray(value)) return value.length > 0 + return value.trim().length > 0 } /** * Shared state and helpers for connector configuration fields that support - * canonical pairs (selector + manual input sharing a `canonicalParamId`). - * - * - Tracks current field values and active mode (basic/advanced) per canonical group. - * - Computes the dependency graph including canonical-sibling expansion so that - * changing a dependency clears both siblings of any dependent canonical pair. - * - Returns `resolveSourceConfig` which collapses the per-field map back to a - * canonical-keyed object ready to submit. + * canonical pairs (selector + manual input sharing a `canonicalParamId`) and + * multi-value fields (selector or short-input with `multi: true`). */ export function useConnectorConfigFields({ connectorConfig, initialSourceConfig, initialCanonicalModes, }: UseConnectorConfigFieldsOptions): UseConnectorConfigFieldsResult { - const [sourceConfig, setSourceConfig] = useState>( - () => initialSourceConfig ?? {} - ) + const [sourceConfig, setSourceConfig] = useState(() => initialSourceConfig ?? {}) const [canonicalModes, setCanonicalModes] = useState>( () => initialCanonicalModes ?? {} ) @@ -56,6 +89,13 @@ export function useConnectorConfigFields({ return groups }, [connectorConfig]) + const fieldsById = useMemo(() => { + const map = new Map() + if (!connectorConfig) return map + for (const field of connectorConfig.configFields) map.set(field.id, field) + return map + }, [connectorConfig]) + const dependentFieldIds = useMemo(() => { const result = new Map() if (!connectorConfig) return result @@ -104,12 +144,18 @@ export function useConnectorConfigFields({ [canonicalModes] ) - const handleFieldChange = (fieldId: string, value: string) => { + const isFieldPopulated = useCallback( + (field: ConnectorConfigField): boolean => + isValuePopulated(sourceConfig[field.id] ?? emptyValue(field)), + [sourceConfig] + ) + + const handleFieldChange = (fieldId: string, value: ConfigFieldValue) => { setSourceConfig((prev) => { - const next = { ...prev, [fieldId]: value } + const next: ConfigFieldMap = { ...prev, [fieldId]: value } const toClear = dependentFieldIds.get(fieldId) if (toClear) { - for (const depId of toClear) next[depId] = '' + for (const depId of toClear) next[depId] = emptyValue(fieldsById.get(depId)) } return next }) @@ -122,8 +168,8 @@ export function useConnectorConfigFields({ })) } - const resolveSourceConfig = useCallback((): Record => { - const resolved: Record = {} + const resolveSourceConfig = useCallback((): Record => { + const resolved: Record = {} const processed = new Set() if (!connectorConfig) return resolved @@ -135,9 +181,11 @@ export function useConnectorConfigFields({ if (!group) continue const activeMode = canonicalModes[field.canonicalParamId] ?? 'basic' const activeField = group.find((f) => f.mode === activeMode) ?? group[0] - resolved[field.canonicalParamId] = sourceConfig[activeField.id] ?? '' + const raw = sourceConfig[activeField.id] ?? emptyValue(activeField) + resolved[field.canonicalParamId] = coerceForField(activeField, raw) } else { - resolved[field.id] = sourceConfig[field.id] ?? '' + const raw = sourceConfig[field.id] ?? emptyValue(field) + resolved[field.id] = coerceForField(field, raw) } } return resolved @@ -150,6 +198,7 @@ export function useConnectorConfigFields({ setCanonicalModes, canonicalGroups, isFieldVisible, + isFieldPopulated, handleFieldChange, toggleCanonicalMode, resolveSourceConfig, diff --git a/apps/sim/connectors/confluence/confluence.ts b/apps/sim/connectors/confluence/confluence.ts index 2180f932f08..8a4fa32ef39 100644 --- a/apps/sim/connectors/confluence/confluence.ts +++ b/apps/sim/connectors/confluence/confluence.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { ConfluenceIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { htmlToPlainText, joinTagArray, parseTagDate } from '@/connectors/utils' +import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/confluence/utils' const logger = createLogger('ConfluenceConnector') @@ -15,6 +15,18 @@ export function escapeCql(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') } +/** + * Builds a CQL clause restricting content to the given space keys. + * Single key uses `space = "X"`; multiple keys use `space in ("X","Y")`. + */ +function buildSpaceClause(spaceKeys: string[]): string { + if (spaceKeys.length === 1) { + return `space="${escapeCql(spaceKeys[0])}"` + } + const list = spaceKeys.map((k) => `"${escapeCql(k)}"`).join(',') + return `space in (${list})` +} + /** * Fetches labels for a batch of page IDs using the v2 labels endpoint. */ @@ -162,22 +174,24 @@ export const confluenceConnector: ConnectorConfig = { }, { id: 'spaceSelector', - title: 'Space', + title: 'Spaces', type: 'selector', selectorKey: 'confluence.spaces', canonicalParamId: 'spaceKey', mode: 'basic', + multi: true, dependsOn: ['domain'], - placeholder: 'Select a space', + placeholder: 'Select one or more spaces', required: true, }, { id: 'spaceKey', - title: 'Space Key', + title: 'Space Keys', type: 'short-input', canonicalParamId: 'spaceKey', mode: 'advanced', - placeholder: 'e.g. ENG, PRODUCT', + multi: true, + placeholder: 'e.g. ENG, PRODUCT (comma-separated for multiple)', required: true, }, { @@ -214,23 +228,32 @@ export const confluenceConnector: ConnectorConfig = { syncContext?: Record ): Promise => { const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string) - const spaceKey = sourceConfig.spaceKey as string + const spaceKeys = parseMultiValue(sourceConfig.spaceKey) const contentType = (sourceConfig.contentType as string) || 'page' const labelFilter = (sourceConfig.labelFilter as string) || '' const maxPages = sourceConfig.maxPages ? Number(sourceConfig.maxPages) : 0 + if (spaceKeys.length === 0) { + throw new Error('At least one space key is required') + } + let cloudId = syncContext?.cloudId as string | undefined if (!cloudId) { cloudId = await getConfluenceCloudId(domain, accessToken) if (syncContext) syncContext.cloudId = cloudId } - if (labelFilter.trim()) { + /** + * Route through CQL when a label filter is set or when multiple spaces are + * selected — the v2 `/spaces/{spaceId}/pages` endpoint is single-space only, + * but CQL natively supports `space in (...)`. + */ + if (labelFilter.trim() || spaceKeys.length > 1) { return listDocumentsViaCql( cloudId, accessToken, domain, - spaceKey, + spaceKeys, contentType, labelFilter, maxPages, @@ -239,6 +262,7 @@ export const confluenceConnector: ConnectorConfig = { ) } + const spaceKey = spaceKeys[0] let spaceId = syncContext?.spaceId as string | undefined if (!spaceId) { spaceId = await resolveSpaceId(cloudId, accessToken, spaceKey) @@ -333,10 +357,10 @@ export const confluenceConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const domain = sourceConfig.domain as string - const spaceKey = sourceConfig.spaceKey as string + const spaceKeys = parseMultiValue(sourceConfig.spaceKey) - if (!domain || !spaceKey) { - return { valid: false, error: 'Domain and space key are required' } + if (!domain || spaceKeys.length === 0) { + return { valid: false, error: 'Domain and at least one space key are required' } } const maxPages = sourceConfig.maxPages as string | undefined @@ -346,7 +370,10 @@ export const confluenceConnector: ConnectorConfig = { try { const cloudId = await getConfluenceCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS) - const spaceUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?keys=${encodeURIComponent(spaceKey)}&limit=1` + const params = new URLSearchParams() + for (const key of spaceKeys) params.append('keys', key) + params.append('limit', String(Math.max(spaceKeys.length, 1))) + const spaceUrl = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/spaces?${params.toString()}` const response = await fetchWithRetry( spaceUrl, { @@ -359,11 +386,17 @@ export const confluenceConnector: ConnectorConfig = { VALIDATE_RETRY_OPTIONS ) if (!response.ok) { - return { valid: false, error: `Failed to validate space: ${response.status}` } + return { valid: false, error: `Failed to validate spaces: ${response.status}` } } const data = await response.json() - if (!data.results?.length) { - return { valid: false, error: `Space "${spaceKey}" not found` } + const results = (data.results as Array> | undefined) ?? [] + const foundKeys = new Set(results.map((r) => String(r.key))) + const missing = spaceKeys.filter((k) => !foundKeys.has(k)) + if (missing.length > 0) { + return { + valid: false, + error: `Space${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`, + } } return { valid: true } } catch (error) { @@ -562,7 +595,7 @@ async function listDocumentsViaCql( cloudId: string, accessToken: string, domain: string, - spaceKey: string, + spaceKeys: string[], contentType: string, labelFilter: string, maxPages: number, @@ -575,7 +608,7 @@ async function listDocumentsViaCql( .filter(Boolean) // Build CQL query - let cql = `space="${escapeCql(spaceKey)}"` + let cql = buildSpaceClause(spaceKeys) if (contentType === 'blogpost') { cql += ' AND type="blogpost"' diff --git a/apps/sim/connectors/gmail/gmail.ts b/apps/sim/connectors/gmail/gmail.ts index bff9a86e915..eab77834c27 100644 --- a/apps/sim/connectors/gmail/gmail.ts +++ b/apps/sim/connectors/gmail/gmail.ts @@ -3,7 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { GmailIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { htmlToPlainText, joinTagArray, parseTagDate } from '@/connectors/utils' +import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('GmailConnector') @@ -45,16 +45,40 @@ interface GmailLabel { type?: string } +/** + * Formats a single Gmail label name for use in a `label:` operator. + * Gmail search syntax accepts quoted strings for labels containing spaces; + * unquoted label tokens have spaces replaced with hyphens. + */ +function formatLabelToken(name: string): string { + const trimmed = name.trim() + if (!trimmed) return '' + if (/\s/.test(trimmed)) { + const escaped = trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + return `label:"${escaped}"` + } + return `label:${trimmed}` +} + /** * Builds a Gmail search query string from the source config. * Combines the user's custom query with the label and date range filters. + * When multiple labels are provided, they are OR-joined: `(label:A OR label:B)`. */ function buildSearchQuery(sourceConfig: Record): string { const parts: string[] = [] - const labelName = sourceConfig.label as string | undefined - if (labelName?.trim()) { - parts.push(`label:${labelName.trim().replace(/\s+/g, '-')}`) + const labelNames = parseMultiValue(sourceConfig.label) + if (labelNames.length === 1) { + const token = formatLabelToken(labelNames[0]) + if (token) parts.push(token) + } else if (labelNames.length > 1) { + const tokens = labelNames.map(formatLabelToken).filter(Boolean) + if (tokens.length === 1) { + parts.push(tokens[0]) + } else if (tokens.length > 1) { + parts.push(`(${tokens.join(' OR ')})`) + } } const dateRange = (sourceConfig.dateRange as string) || 'all' @@ -88,8 +112,18 @@ function buildSearchQuery(sourceConfig: Record): string { } const customQuery = sourceConfig.query as string | undefined - if (customQuery?.trim()) { - parts.push(customQuery.trim()) + const trimmedCustom = customQuery?.trim() + if (trimmedCustom) { + /** + * Wrap the user-supplied query in parentheses whenever it contains an OR + * so it's AND-joined as a single clause with the preceding label / category + * / date filters. Always wrap (rather than try to detect existing outer + * parens) because a regex like /^\(.*\)$/ misclassifies inputs such as + * `(from:alice) OR (from:bob)` where the parens don't bracket the whole + * expression. Double-wrapping is a no-op in Gmail search syntax. + */ + const needsGroup = /\bOR\b/i.test(trimmedCustom) + parts.push(needsGroup ? `(${trimmedCustom})` : trimmedCustom) } return parts.join(' ') @@ -318,24 +352,26 @@ export const gmailConnector: ConnectorConfig = { configFields: [ { id: 'labelSelector', - title: 'Label', + title: 'Labels', type: 'selector', selectorKey: 'gmail.labels', canonicalParamId: 'label', mode: 'basic', - placeholder: 'Select a label', + multi: true, + placeholder: 'Select one or more labels', required: false, - description: 'Only sync emails with this label. Leave empty for all mail.', + description: 'Only sync emails matching any of these labels. Leave empty for all mail.', }, { id: 'label', - title: 'Label', + title: 'Labels', type: 'short-input', canonicalParamId: 'label', mode: 'advanced', - placeholder: 'e.g. INBOX, IMPORTANT, or a custom label name', + multi: true, + placeholder: 'e.g. INBOX, IMPORTANT (comma-separated; commas in label names not supported)', required: false, - description: 'Only sync emails with this label. Leave empty for all mail.', + description: 'Only sync emails matching any of these labels. Leave empty for all mail.', }, { id: 'dateRange', @@ -529,9 +565,9 @@ export const gmailConnector: ConnectorConfig = { return { valid: false, error: `Failed to access Gmail: ${profileResponse.status}` } } - // If a label is specified, verify it exists - const labelName = sourceConfig.label as string | undefined - if (labelName?.trim()) { + // If labels are specified, verify each one exists + const labelNames = parseMultiValue(sourceConfig.label) + if (labelNames.length > 0) { const labelsUrl = `${GMAIL_API_BASE}/labels` const labelsResponse = await fetchWithRetry( labelsUrl, @@ -551,13 +587,13 @@ export const gmailConnector: ConnectorConfig = { const labelsData = await labelsResponse.json() const labels = (labelsData.labels || []) as GmailLabel[] - const normalized = labelName.trim().toLowerCase() - const match = labels.find((l) => l.name.toLowerCase() === normalized) + const labelNameSet = new Set(labels.map((l) => l.name.toLowerCase())) + const missing = labelNames.filter((name) => !labelNameSet.has(name.toLowerCase())) - if (!match) { + if (missing.length > 0) { return { valid: false, - error: `Label "${labelName}" not found. Available labels: ${labels + error: `Label(s) not found: ${missing.join(', ')}. Available labels: ${labels .filter( (l) => l.type !== 'system' || diff --git a/apps/sim/connectors/google-calendar/google-calendar.ts b/apps/sim/connectors/google-calendar/google-calendar.ts index 83b1e1fd7b9..104743b535d 100644 --- a/apps/sim/connectors/google-calendar/google-calendar.ts +++ b/apps/sim/connectors/google-calendar/google-calendar.ts @@ -3,7 +3,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { GoogleCalendarIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { parseTagDate } from '@/connectors/utils' +import { parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('GoogleCalendarConnector') @@ -195,8 +195,19 @@ function getTimeRange(sourceConfig: Record): { timeMin: string; /** * Converts a CalendarEvent to an ExternalDocument. + * + * Backward compatibility: when only a single calendar is configured (the only + * code path that existed before multi-calendar support), externalId and + * contentHash use the legacy non-namespaced format so existing connectors see + * zero churn on re-sync. When 2+ calendars are configured, we namespace by + * calendarId because Google Calendar event IDs are only unique within a + * single calendar. */ -function eventToDocument(event: CalendarEvent): ExternalDocument | null { +function eventToDocument( + event: CalendarEvent, + calendarId: string, + isMultiCalendar: boolean +): ExternalDocument | null { if (event.status === 'cancelled') return null const content = eventToContent(event) @@ -205,14 +216,20 @@ function eventToDocument(event: CalendarEvent): ExternalDocument | null { const startTime = event.start?.dateTime || event.start?.date || '' const attendeeCount = event.attendees?.filter((a) => !a.resource).length || 0 + const externalId = isMultiCalendar ? `${calendarId}:${event.id}` : event.id + const contentHash = isMultiCalendar + ? `gcal:${calendarId}:${event.id}:${event.updated ?? ''}` + : `gcal:${event.id}:${event.updated ?? ''}` + return { - externalId: event.id, + externalId, title: event.summary || 'Untitled Event', content, mimeType: 'text/plain', sourceUrl: event.htmlLink || `https://calendar.google.com/calendar/event?eid=${event.id}`, - contentHash: `gcal:${event.id}:${event.updated ?? ''}`, + contentHash, metadata: { + calendarId, startTime, endTime: event.end?.dateTime || event.end?.date || '', location: event.location || '', @@ -242,24 +259,27 @@ export const googleCalendarConnector: ConnectorConfig = { configFields: [ { id: 'calendarSelector', - title: 'Calendar', + title: 'Calendars', type: 'selector', selectorKey: 'google.calendar', canonicalParamId: 'calendarId', mode: 'basic', - placeholder: 'Select a calendar', + multi: true, + placeholder: 'Select one or more calendars', required: false, - description: 'The calendar to sync from. Defaults to your primary calendar.', + description: 'Calendars to sync from. Defaults to your primary calendar.', }, { id: 'calendarId', - title: 'Calendar ID', + title: 'Calendar IDs', type: 'short-input', canonicalParamId: 'calendarId', mode: 'advanced', - placeholder: 'e.g. primary (default: primary)', + multi: true, + placeholder: 'e.g. primary, team@group.calendar.google.com (comma-separated for multiple)', required: false, - description: 'The calendar to sync from. Use "primary" for your main calendar.', + description: + 'Calendars to sync from. Use "primary" for your main calendar. Defaults to "primary".', }, { id: 'dateRange', @@ -296,10 +316,39 @@ export const googleCalendarConnector: ConnectorConfig = { cursor?: string, syncContext?: Record ): Promise => { - const calendarId = ((sourceConfig.calendarId as string) || 'primary').trim() + const parsedCalendarIds = parseMultiValue(sourceConfig.calendarId) + const calendarIds = parsedCalendarIds.length > 0 ? parsedCalendarIds : ['primary'] const { timeMin, timeMax } = getTimeRange(sourceConfig) const searchQuery = (sourceConfig.searchQuery as string) || '' + /** + * Cursor format: + * - For a single calendar with legacy cursors: the raw pageToken string + * - For multi-calendar walking: JSON-encoded { calendarIndex, pageToken } + */ + let calendarIndex = 0 + let pageToken: string | undefined + + if (cursor) { + try { + const parsed = JSON.parse(cursor) as { calendarIndex: number; pageToken?: string } + if (typeof parsed.calendarIndex === 'number') { + calendarIndex = parsed.calendarIndex + pageToken = parsed.pageToken + } else { + pageToken = cursor + } + } catch { + pageToken = cursor + } + } + + if (calendarIndex >= calendarIds.length) { + return { documents: [], hasMore: false } + } + + const calendarId = calendarIds[calendarIndex] + const queryParams = new URLSearchParams({ singleEvents: 'true', orderBy: 'startTime', @@ -312,17 +361,19 @@ export const googleCalendarConnector: ConnectorConfig = { queryParams.set('q', searchQuery.trim()) } - if (cursor) { - queryParams.set('pageToken', cursor) + if (pageToken) { + queryParams.set('pageToken', pageToken) } const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${queryParams.toString()}` logger.info('Listing Google Calendar events', { calendarId, + calendarIndex, + calendarCount: calendarIds.length, timeMin, timeMax, - cursor: cursor ?? 'initial', + hasPageToken: Boolean(pageToken), }) const response = await fetchWithRetry(url, { @@ -337,6 +388,7 @@ export const googleCalendarConnector: ConnectorConfig = { const errorText = await response.text() logger.error('Failed to list Google Calendar events', { status: response.status, + calendarId, error: errorText, }) throw new Error(`Failed to list Google Calendar events: ${response.status}`) @@ -345,9 +397,10 @@ export const googleCalendarConnector: ConnectorConfig = { const data = await response.json() const events = (data.items || []) as CalendarEvent[] + const isMultiCalendar = calendarIds.length > 1 const documents: ExternalDocument[] = [] for (const event of events) { - const doc = eventToDocument(event) + const doc = eventToDocument(event, calendarId, isMultiCalendar) if (doc) documents.push(doc) } @@ -359,11 +412,28 @@ export const googleCalendarConnector: ConnectorConfig = { const nextPageToken = data.nextPageToken as string | undefined - return { - documents, - nextCursor: hitLimit ? undefined : nextPageToken, - hasMore: hitLimit ? false : Boolean(nextPageToken), + if (hitLimit) { + return { documents, hasMore: false } } + + if (nextPageToken) { + return { + documents, + nextCursor: JSON.stringify({ calendarIndex, pageToken: nextPageToken }), + hasMore: true, + } + } + + const nextCalendarIndex = calendarIndex + 1 + if (nextCalendarIndex < calendarIds.length) { + return { + documents, + nextCursor: JSON.stringify({ calendarIndex: nextCalendarIndex }), + hasMore: true, + } + } + + return { documents, hasMore: false } }, getDocument: async ( @@ -371,8 +441,41 @@ export const googleCalendarConnector: ConnectorConfig = { sourceConfig: Record, externalId: string ): Promise => { - const calendarId = ((sourceConfig.calendarId as string) || 'primary').trim() - const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(externalId)}` + /** + * externalId format depends on connector configuration: + * - Single-calendar (1 calendar configured): externalId = eventId (legacy + * and current single-calendar format). + * - Multi-calendar (2+ calendars configured): externalId = + * `calendarId:eventId`. The first `:` is the separator — event IDs never + * contain `:` while calendar IDs (e.g. `user@group.calendar.google.com`) + * may include URL-safe chars but not `:`. + * + * Legacy in-flight rows that lack a separator fall back to the configured + * calendar (or `primary`). + */ + const parsedCalendarIds = parseMultiValue(sourceConfig.calendarId) + const calendarIds = parsedCalendarIds.length > 0 ? parsedCalendarIds : ['primary'] + + /** + * Derive `isMultiCalendar` from the externalId itself, not from the current + * config. If a row was synced under a multi-calendar config and the user + * later removed calendars, the row's externalId still has the prefix — + * returning a doc without the prefix would mint a duplicate via the sync + * engine's externalId-keyed matching. + */ + const separatorIndex = externalId.indexOf(':') + const isMultiCalendar = separatorIndex !== -1 + let calendarId: string + let eventId: string + if (separatorIndex === -1) { + calendarId = calendarIds[0] ?? 'primary' + eventId = externalId + } else { + calendarId = externalId.slice(0, separatorIndex) + eventId = externalId.slice(separatorIndex + 1) + } + + const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}` const response = await fetchWithRetry(url, { method: 'GET', @@ -391,7 +494,7 @@ export const googleCalendarConnector: ConnectorConfig = { if (event.status === 'cancelled') return null - return eventToDocument(event) ?? null + return eventToDocument(event, calendarId, isMultiCalendar) ?? null }, validateConfig: async ( @@ -403,27 +506,37 @@ export const googleCalendarConnector: ConnectorConfig = { return { valid: false, error: 'Max events must be a positive number' } } + const parsedCalendarIds = parseMultiValue(sourceConfig.calendarId) + const calendarIds = parsedCalendarIds.length > 0 ? parsedCalendarIds : ['primary'] + try { - const calendarId = ((sourceConfig.calendarId as string) || 'primary').trim() - const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?maxResults=1&singleEvents=true&orderBy=startTime&timeMin=${encodeURIComponent(new Date().toISOString())}` - - const response = await fetchWithRetry( - url, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', + for (const calendarId of calendarIds) { + const url = `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?maxResults=1&singleEvents=true&orderBy=startTime&timeMin=${encodeURIComponent(new Date().toISOString())}` + + const response = await fetchWithRetry( + url, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, }, - }, - VALIDATE_RETRY_OPTIONS - ) - - if (!response.ok) { - if (response.status === 404) { - return { valid: false, error: 'Calendar not found. Check the calendar ID.' } + VALIDATE_RETRY_OPTIONS + ) + + if (!response.ok) { + if (response.status === 404) { + return { + valid: false, + error: `Calendar not found: ${calendarId}. Check the calendar ID.`, + } + } + return { + valid: false, + error: `Failed to access Google Calendar "${calendarId}": ${response.status}`, + } } - return { valid: false, error: `Failed to access Google Calendar: ${response.status}` } } return { valid: true } diff --git a/apps/sim/connectors/jira/jira.ts b/apps/sim/connectors/jira/jira.ts index 0341d15b81d..409dca78020 100644 --- a/apps/sim/connectors/jira/jira.ts +++ b/apps/sim/connectors/jira/jira.ts @@ -3,13 +3,27 @@ import { toError } from '@sim/utils/errors' import { JiraIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseTagDate } from '@/connectors/utils' +import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' import { extractAdfText, getJiraCloudId } from '@/tools/jira/utils' const logger = createLogger('JiraConnector') const PAGE_SIZE = 50 +/** + * Builds a JQL clause restricting issues to the given project keys. + * Single key uses `project = "X"`; multiple keys use `project in ("X","Y")`. + * Each key is escaped for inclusion in a JQL double-quoted string. + */ +function buildProjectClause(projectKeys: string[]): string { + const escapeKey = (key: string) => key.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + if (projectKeys.length === 1) { + return `project = "${escapeKey(projectKeys[0])}"` + } + const list = projectKeys.map((k) => `"${escapeKey(k)}"`).join(',') + return `project in (${list})` +} + /** * Builds a plain-text representation of a Jira issue for knowledge base indexing. */ @@ -108,22 +122,24 @@ export const jiraConnector: ConnectorConfig = { }, { id: 'projectSelector', - title: 'Project', + title: 'Projects', type: 'selector', selectorKey: 'jira.projects', canonicalParamId: 'projectKey', mode: 'basic', + multi: true, dependsOn: ['domain'], - placeholder: 'Select a project', + placeholder: 'Select one or more projects', required: true, }, { id: 'projectKey', - title: 'Project Key', + title: 'Project Keys', type: 'short-input', canonicalParamId: 'projectKey', mode: 'advanced', - placeholder: 'e.g. ENG, PROJ', + multi: true, + placeholder: 'e.g. ENG, PROJ (comma-separated for multiple)', required: true, }, { @@ -149,20 +165,24 @@ export const jiraConnector: ConnectorConfig = { syncContext?: Record ): Promise => { const domain = sourceConfig.domain as string - const projectKey = sourceConfig.projectKey as string + const projectKeys = parseMultiValue(sourceConfig.projectKey) const jqlFilter = (sourceConfig.jql as string) || '' const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0 + if (projectKeys.length === 0) { + throw new Error('At least one project key is required') + } + let cloudId = syncContext?.cloudId as string | undefined if (!cloudId) { cloudId = await getJiraCloudId(domain, accessToken) if (syncContext) syncContext.cloudId = cloudId } - const safeKey = projectKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"') - let jql = `project = "${safeKey}" ORDER BY updated DESC` + const projectClause = buildProjectClause(projectKeys) + let jql = `${projectClause} ORDER BY updated DESC` if (jqlFilter.trim()) { - jql = `project = "${safeKey}" AND (${jqlFilter.trim()}) ORDER BY updated DESC` + jql = `${projectClause} AND (${jqlFilter.trim()}) ORDER BY updated DESC` } /** @@ -200,7 +220,10 @@ export const jiraConnector: ConnectorConfig = { const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` - logger.info(`Listing Jira issues for project ${projectKey}`, { hasCursor: Boolean(cursor) }) + logger.info(`Listing Jira issues for ${projectKeys.length} project(s)`, { + projectKeys, + hasCursor: Boolean(cursor), + }) const response = await fetchWithRetry(url, { method: 'GET', @@ -292,10 +315,10 @@ export const jiraConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const domain = sourceConfig.domain as string - const projectKey = sourceConfig.projectKey as string + const projectKeys = parseMultiValue(sourceConfig.projectKey) - if (!domain || !projectKey) { - return { valid: false, error: 'Domain and project key are required' } + if (!domain || projectKeys.length === 0) { + return { valid: false, error: 'Domain and at least one project key are required' } } const maxIssues = sourceConfig.maxIssues as string | undefined @@ -308,9 +331,9 @@ export const jiraConnector: ConnectorConfig = { try { const cloudId = await getJiraCloudId(domain, accessToken, VALIDATE_RETRY_OPTIONS) + const projectClause = buildProjectClause(projectKeys) const params = new URLSearchParams() - const safeKey = projectKey.replace(/\\/g, '\\\\').replace(/"/g, '\\"') - params.append('jql', `project = "${safeKey}"`) + params.append('jql', projectClause) params.append('maxResults', '1') const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${params.toString()}` @@ -329,14 +352,17 @@ export const jiraConnector: ConnectorConfig = { if (!response.ok) { const errorText = await response.text() if (response.status === 400) { - return { valid: false, error: `Project "${projectKey}" not found or JQL is invalid` } + return { + valid: false, + error: `One or more projects not found (${projectKeys.join(', ')}) or JQL is invalid`, + } } return { valid: false, error: `Failed to validate: ${response.status} - ${errorText}` } } if (jqlFilter) { const filterParams = new URLSearchParams() - filterParams.append('jql', `project = "${safeKey}" AND (${jqlFilter})`) + filterParams.append('jql', `${projectClause} AND (${jqlFilter})`) filterParams.append('maxResults', '1') const filterUrl = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/search/jql?${filterParams.toString()}` diff --git a/apps/sim/connectors/linear/linear.ts b/apps/sim/connectors/linear/linear.ts index 6aa181fe653..72067d2069f 100644 --- a/apps/sim/connectors/linear/linear.ts +++ b/apps/sim/connectors/linear/linear.ts @@ -4,7 +4,7 @@ import { LinearIcon } from '@/components/icons' import type { RetryOptions } from '@/lib/knowledge/documents/utils' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseTagDate } from '@/connectors/utils' +import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('LinearConnector') @@ -135,28 +135,38 @@ const TEAMS_QUERY = ` * Dynamically builds a GraphQL issues query with only the filter clauses * that have values, preventing null comparators from being sent to Linear. */ -function buildIssuesQuery(sourceConfig: Record): { +function buildIssuesQuery( + sourceConfig: Record, + teamIds: string[], + projectIds: string[] +): { query: string variables: Record } { - const teamId = (sourceConfig.teamId as string) || '' - const projectId = (sourceConfig.projectId as string) || '' const stateFilter = (sourceConfig.stateFilter as string) || '' const varDefs: string[] = ['$first: Int!', '$after: String'] const filterClauses: string[] = [] const variables: Record = {} - if (teamId) { + if (teamIds.length === 1) { varDefs.push('$teamId: ID!') filterClauses.push('team: { id: { eq: $teamId } }') - variables.teamId = teamId + variables.teamId = teamIds[0] + } else if (teamIds.length > 1) { + varDefs.push('$teamIds: [ID!]!') + filterClauses.push('team: { id: { in: $teamIds } }') + variables.teamIds = teamIds } - if (projectId) { + if (projectIds.length === 1) { varDefs.push('$projectId: ID!') filterClauses.push('project: { id: { eq: $projectId } }') - variables.projectId = projectId + variables.projectId = projectIds[0] + } else if (projectIds.length > 1) { + varDefs.push('$projectIds: [ID!]!') + filterClauses.push('project: { id: { in: $projectIds } }') + variables.projectIds = projectIds } if (stateFilter) { @@ -202,41 +212,45 @@ export const linearConnector: ConnectorConfig = { configFields: [ { id: 'teamSelector', - title: 'Team', + title: 'Teams', type: 'selector', selectorKey: 'linear.teams', canonicalParamId: 'teamId', mode: 'basic', - placeholder: 'Select a team (optional)', + multi: true, + placeholder: 'Select one or more teams (optional)', required: false, }, { id: 'teamId', - title: 'Team ID', + title: 'Team IDs', type: 'short-input', canonicalParamId: 'teamId', mode: 'advanced', - placeholder: 'e.g. abc123 (leave empty for all teams)', + multi: true, + placeholder: 'e.g. abc123, def456 (comma-separated for multiple)', required: false, }, { id: 'projectSelector', - title: 'Project', + title: 'Projects', type: 'selector', selectorKey: 'linear.projects', canonicalParamId: 'projectId', mode: 'basic', + multi: true, dependsOn: ['teamSelector'], - placeholder: 'Select a project (optional)', + placeholder: 'Select one or more projects (optional)', required: false, }, { id: 'projectId', - title: 'Project ID', + title: 'Project IDs', type: 'short-input', canonicalParamId: 'projectId', mode: 'advanced', - placeholder: 'e.g. def456 (leave empty for all projects)', + multi: true, + placeholder: 'e.g. def456, ghi789 (comma-separated for multiple)', required: false, }, { @@ -264,14 +278,17 @@ export const linearConnector: ConnectorConfig = { const maxIssues = sourceConfig.maxIssues ? Number(sourceConfig.maxIssues) : 0 const pageSize = maxIssues > 0 ? Math.min(maxIssues, 50) : 50 - const { query, variables } = buildIssuesQuery(sourceConfig) + const teamIds = parseMultiValue(sourceConfig.teamId) + const projectIds = parseMultiValue(sourceConfig.projectId) + + const { query, variables } = buildIssuesQuery(sourceConfig, teamIds, projectIds) const allVars = { ...variables, first: pageSize, after: cursor || undefined } logger.info('Listing Linear issues', { cursor, pageSize, - hasTeamFilter: Boolean(sourceConfig.teamId), - hasProjectFilter: Boolean(sourceConfig.projectId), + teamFilterCount: teamIds.length, + projectFilterCount: projectIds.length, }) const data = await linearGraphQL(accessToken, query, allVars) @@ -389,13 +406,14 @@ export const linearConnector: ConnectorConfig = { } } - const teamId = sourceConfig.teamId as string | undefined - if (teamId) { - const found = teams.some((t) => t.id === teamId) - if (!found) { + const requestedTeamIds = parseMultiValue(sourceConfig.teamId) + if (requestedTeamIds.length > 0) { + const availableIds = new Set(teams.map((t) => t.id as string)) + const missing = requestedTeamIds.filter((id) => !availableIds.has(id)) + if (missing.length > 0) { return { valid: false, - error: `Team ID "${teamId}" not found. Available teams: ${teams.map((t) => `${t.name} (${t.id})`).join(', ')}`, + error: `Team ID(s) not found: ${missing.join(', ')}. Available teams: ${teams.map((t) => `${t.name} (${t.id})`).join(', ')}`, } } } diff --git a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts index bfb857085cc..3d18b127726 100644 --- a/apps/sim/connectors/microsoft-teams/microsoft-teams.ts +++ b/apps/sim/connectors/microsoft-teams/microsoft-teams.ts @@ -3,7 +3,12 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { MicrosoftTeamsIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { computeContentHash, htmlToPlainText, parseTagDate } from '@/connectors/utils' +import { + computeContentHash, + htmlToPlainText, + parseMultiValue, + parseTagDate, +} from '@/connectors/utils' const logger = createLogger('MicrosoftTeamsConnector') @@ -108,8 +113,11 @@ async function fetchChannelMessages( (msg) => msg.messageType === 'message' && !msg.deletedDateTime ) + // Messages are sorted by lastModifiedDateTime (per Graph docs), so the first + // user message on the first page reflects the most recent activity. if (!lastActivityTs && userMessages.length > 0) { - lastActivityTs = userMessages[0].createdDateTime + const first = userMessages[0] + lastActivityTs = first.lastModifiedDateTime || first.createdDateTime } allMessages.push(...userMessages) @@ -159,7 +167,8 @@ async function resolveChannel( // Fetch all channels for the team let nextLink: string | undefined - const initialPath = `/teams/${encodeURIComponent(teamId)}/channels` + // $select avoids the expensive `email` property per Graph perf guidance. + const initialPath = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName,description` let currentUrl: string = initialPath do { @@ -217,24 +226,26 @@ export const microsoftTeamsConnector: ConnectorConfig = { }, { id: 'channelSelector', - title: 'Channel', + title: 'Channels', type: 'selector', selectorKey: 'microsoft.channels', canonicalParamId: 'channel', mode: 'basic', + multi: true, dependsOn: ['teamSelector'], - placeholder: 'Select a channel', + placeholder: 'Select one or more channels', required: true, }, { id: 'channel', - title: 'Channel', + title: 'Channels', type: 'short-input', canonicalParamId: 'channel', mode: 'advanced', - placeholder: 'e.g. General or 19:abc123@thread.tacv2', + multi: true, + placeholder: 'e.g. General, Announcements (comma-separated for multiple)', required: true, - description: 'Channel name or ID to sync messages from', + description: 'Channel names or IDs to sync messages from', }, { id: 'maxMessages', @@ -252,60 +263,68 @@ export const microsoftTeamsConnector: ConnectorConfig = { _syncContext?: Record ): Promise => { const teamId = sourceConfig.teamId as string - const channelInput = sourceConfig.channel as string + const channelInputs = parseMultiValue(sourceConfig.channel) if (!teamId?.trim()) { throw new Error('Team ID is required') } - if (!channelInput?.trim()) { - throw new Error('Channel is required') + if (channelInputs.length === 0) { + throw new Error('At least one channel is required') } const maxMessages = sourceConfig.maxMessages ? Number(sourceConfig.maxMessages) : DEFAULT_MAX_MESSAGES - logger.info('Syncing Microsoft Teams channel', { teamId, channel: channelInput, maxMessages }) + logger.info('Syncing Microsoft Teams channels', { + teamId, + channels: channelInputs, + maxMessages, + }) - const channel = await resolveChannel(accessToken, teamId, channelInput) - if (!channel) { - throw new Error(`Channel not found: ${channelInput}`) - } + const documents: ExternalDocument[] = [] - const { messages, lastActivityTs } = await fetchChannelMessages( - accessToken, - teamId, - channel.id, - maxMessages - ) + for (const channelInput of channelInputs) { + const channel = await resolveChannel(accessToken, teamId, channelInput) + if (!channel) { + throw new Error(`Channel not found: ${channelInput}`) + } - const content = formatMessages(messages) - if (!content.trim()) { - logger.info(`No messages found in channel: ${channel.displayName}`) - return { documents: [], hasMore: false } - } + const { messages, lastActivityTs } = await fetchChannelMessages( + accessToken, + teamId, + channel.id, + maxMessages + ) - const contentHash = await computeContentHash(content) - - const sourceUrl = `https://teams.microsoft.com/l/channel/${encodeURIComponent(channel.id)}/${encodeURIComponent(channel.displayName)}?groupId=${encodeURIComponent(teamId)}` - - const document: ExternalDocument = { - externalId: channel.id, - title: channel.displayName, - content, - mimeType: 'text/plain', - sourceUrl, - contentHash, - metadata: { - channelName: channel.displayName, - messageCount: messages.length, - lastActivity: lastActivityTs || undefined, - description: channel.description || undefined, - }, + const content = formatMessages(messages) + if (!content.trim()) { + logger.info(`No messages found in channel: ${channel.displayName}`) + continue + } + + const contentHash = await computeContentHash(content) + + const sourceUrl = `https://teams.microsoft.com/l/channel/${encodeURIComponent(channel.id)}/${encodeURIComponent(channel.displayName)}?groupId=${encodeURIComponent(teamId)}` + + documents.push({ + externalId: channel.id, + title: channel.displayName, + content, + mimeType: 'text/plain', + sourceUrl, + contentHash, + metadata: { + channelName: channel.displayName, + messageCount: messages.length, + lastActivity: lastActivityTs || undefined, + description: channel.description || undefined, + }, + }) } - // Each channel is one document; no pagination needed + // All selected channels are emitted in a single page; no pagination needed return { - documents: [document], + documents, hasMore: false, } }, @@ -371,15 +390,15 @@ export const microsoftTeamsConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const teamId = sourceConfig.teamId as string | undefined - const channelInput = sourceConfig.channel as string | undefined + const channelInputs = parseMultiValue(sourceConfig.channel) const maxMessages = sourceConfig.maxMessages as string | undefined if (!teamId?.trim()) { return { valid: false, error: 'Team ID is required' } } - if (!channelInput?.trim()) { - return { valid: false, error: 'Channel is required' } + if (channelInputs.length === 0) { + return { valid: false, error: 'At least one channel is required' } } if (maxMessages && (Number.isNaN(Number(maxMessages)) || Number(maxMessages) <= 0)) { @@ -387,15 +406,17 @@ export const microsoftTeamsConnector: ConnectorConfig = { } try { - const channel = await resolveChannel(accessToken, teamId, channelInput.trim()) - if (!channel) { - return { valid: false, error: `Channel not found: ${channelInput}` } + for (const channelInput of channelInputs) { + const channel = await resolveChannel(accessToken, teamId, channelInput) + if (!channel) { + return { valid: false, error: `Channel not found: ${channelInput}` } + } + + // Verify we can read messages by fetching a single message + const messagesPath = `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channel.id)}/messages?$top=1` + await graphApiGet(messagesPath, accessToken, VALIDATE_RETRY_OPTIONS) } - // Verify we can read messages by fetching a single message - const messagesPath = `/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channel.id)}/messages?$top=1` - await graphApiGet(messagesPath, accessToken, VALIDATE_RETRY_OPTIONS) - return { valid: true } } catch (error) { const message = getErrorMessage(error, 'Failed to validate configuration') diff --git a/apps/sim/connectors/notion/notion.ts b/apps/sim/connectors/notion/notion.ts index 49da592e51e..e4181cd4803 100644 --- a/apps/sim/connectors/notion/notion.ts +++ b/apps/sim/connectors/notion/notion.ts @@ -3,7 +3,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { NotionIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { joinTagArray, parseTagDate } from '@/connectors/utils' +import { joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('NotionConnector') @@ -199,22 +199,24 @@ export const notionConnector: ConnectorConfig = { }, { id: 'databaseSelector', - title: 'Database', + title: 'Databases', type: 'selector', selectorKey: 'notion.databases', canonicalParamId: 'databaseId', mode: 'basic', - placeholder: 'Select a database', + multi: true, + placeholder: 'Select one or more databases', required: false, }, { id: 'databaseId', - title: 'Database ID', + title: 'Database IDs', type: 'short-input', canonicalParamId: 'databaseId', mode: 'advanced', + multi: true, required: false, - placeholder: 'e.g. 8a3b5f6e-1234-5678-abcd-ef0123456789', + placeholder: 'e.g. 8a3b5f6e-..., 9c4d6e7f-... (comma-separated for multiple)', }, { id: 'rootPageId', @@ -246,12 +248,12 @@ export const notionConnector: ConnectorConfig = { syncContext?: Record ): Promise => { const scope = (sourceConfig.scope as string) || 'workspace' - const databaseId = (sourceConfig.databaseId as string)?.trim() + const databaseIds = parseMultiValue(sourceConfig.databaseId) const rootPageId = (sourceConfig.rootPageId as string)?.trim() const maxPages = sourceConfig.maxPages ? Number(sourceConfig.maxPages) : 0 - if (scope === 'database' && databaseId) { - return listFromDatabase(accessToken, databaseId, maxPages, cursor, syncContext) + if (scope === 'database' && databaseIds.length > 0) { + return listFromDatabases(accessToken, databaseIds, maxPages, cursor, syncContext) } if (scope === 'page' && rootPageId) { @@ -304,7 +306,7 @@ export const notionConnector: ConnectorConfig = { sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { const scope = (sourceConfig.scope as string) || 'workspace' - const databaseId = (sourceConfig.databaseId as string)?.trim() + const databaseIds = parseMultiValue(sourceConfig.databaseId) const rootPageId = (sourceConfig.rootPageId as string)?.trim() const maxPages = sourceConfig.maxPages as string | undefined @@ -312,8 +314,11 @@ export const notionConnector: ConnectorConfig = { return { valid: false, error: 'Max pages must be a positive number' } } - if (scope === 'database' && !databaseId) { - return { valid: false, error: 'Database ID is required when scope is "Specific database"' } + if (scope === 'database' && databaseIds.length === 0) { + return { + valid: false, + error: 'At least one database is required when scope is "Specific database"', + } } if (scope === 'page' && !rootPageId) { @@ -322,21 +327,26 @@ export const notionConnector: ConnectorConfig = { try { // Verify the token works - if (scope === 'database' && databaseId) { - // Verify database is accessible - const response = await fetchWithRetry( - `${NOTION_BASE_URL}/databases/${databaseId}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Notion-Version': NOTION_API_VERSION, + if (scope === 'database' && databaseIds.length > 0) { + // Verify every database is accessible + for (const databaseId of databaseIds) { + const response = await fetchWithRetry( + `${NOTION_BASE_URL}/databases/${databaseId}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': NOTION_API_VERSION, + }, }, - }, - VALIDATE_RETRY_OPTIONS - ) - if (!response.ok) { - return { valid: false, error: `Cannot access database: ${response.status}` } + VALIDATE_RETRY_OPTIONS + ) + if (!response.ok) { + return { + valid: false, + error: `Cannot access database ${databaseId}: ${response.status}`, + } + } } } else if (scope === 'page' && rootPageId) { // Verify page is accessible @@ -467,58 +477,131 @@ async function listFromWorkspace( } /** - * Lists pages from a specific Notion database. + * Lists pages from one or more Notion databases. + * + * Notion's `/v1/databases/{database_id}/query` endpoint is per-database — there + * is no batch query endpoint — so multiple databases are walked sequentially. + * + * Cursor format: + * - Single database: the Notion `start_cursor` string directly, or undefined. + * - Multiple databases: JSON-encoded `{ databaseIndex, cursor }` where + * `databaseIndex` is the position into `databaseIds` currently being drained + * and `cursor` is the Notion `start_cursor` for that database (or undefined + * when starting a fresh database). + * + * Page IDs returned by Notion are globally-unique UUIDs, so each page's + * `externalId` does not need to be namespaced by database. */ -async function listFromDatabase( +async function listFromDatabases( accessToken: string, - databaseId: string, + databaseIds: string[], maxPages: number, cursor?: string, syncContext?: Record ): Promise { - const body: Record = { - page_size: 100, - } + let databaseIndex = 0 + let startCursor: string | undefined if (cursor) { - body.start_cursor = cursor + if (databaseIds.length === 1) { + // Single-database path: cursor is always a bare Notion `next_cursor` string, + // matching the legacy pre-multi-select format. Never JSON-decode here. + startCursor = cursor + } else { + try { + const parsed = JSON.parse(cursor) as unknown + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as { databaseIndex?: unknown }).databaseIndex === 'number' + ) { + const compound = parsed as { databaseIndex: number; cursor?: string } + databaseIndex = compound.databaseIndex + startCursor = typeof compound.cursor === 'string' ? compound.cursor : undefined + } else { + // Legacy single-DB cursor carried forward into a now-multi-DB config: + // treat it as the start cursor for the first database. + startCursor = cursor + } + } catch { + startCursor = cursor + } + } } - logger.info('Querying Notion database', { databaseId, cursor }) + const documents: ExternalDocument[] = [] + let nextCursor: string | undefined + let hasMore = false + + while (databaseIndex < databaseIds.length) { + const databaseId = databaseIds[databaseIndex] + const body: Record = { page_size: 100 } + if (startCursor) body.start_cursor = startCursor + + logger.info('Querying Notion database', { + databaseId, + databaseIndex, + databaseCount: databaseIds.length, + startCursor, + }) - const response = await fetchWithRetry(`${NOTION_BASE_URL}/databases/${databaseId}/query`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Notion-Version': NOTION_API_VERSION, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) + const response = await fetchWithRetry(`${NOTION_BASE_URL}/databases/${databaseId}/query`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Notion-Version': NOTION_API_VERSION, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) - if (!response.ok) { - const errorText = await response.text() - logger.error('Failed to query Notion database', { status: response.status, error: errorText }) - throw new Error(`Failed to query Notion database: ${response.status}`) - } + if (!response.ok) { + const errorText = await response.text() + logger.error('Failed to query Notion database', { + databaseId, + status: response.status, + error: errorText, + }) + throw new Error(`Failed to query Notion database ${databaseId}: ${response.status}`) + } - const data = await response.json() - const results = (data.results || []) as Record[] - const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean)) + const data = await response.json() + const results = (data.results || []) as Record[] + const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean)) + documents.push(...pages.map(pageToStub)) + + if (data.has_more === true && typeof data.next_cursor === 'string') { + const nextStart = data.next_cursor as string + nextCursor = + databaseIds.length === 1 ? nextStart : JSON.stringify({ databaseIndex, cursor: nextStart }) + hasMore = true + break + } - const documents = pages.map(pageToStub) + databaseIndex++ + startCursor = undefined + + if (databaseIndex < databaseIds.length) { + nextCursor = + databaseIds.length === 1 ? undefined : JSON.stringify({ databaseIndex, cursor: undefined }) + hasMore = true + break + } + } const totalFetched = ((syncContext?.totalDocsFetched as number) ?? 0) + documents.length if (syncContext) syncContext.totalDocsFetched = totalFetched const hitLimit = maxPages > 0 && totalFetched >= maxPages - if (hitLimit && syncContext) syncContext.listingCapped = true - - const nextCursor = hitLimit ? undefined : ((data.next_cursor as string) ?? undefined) + if (hitLimit) { + if (syncContext) syncContext.listingCapped = true + hasMore = false + nextCursor = undefined + } return { documents, - nextCursor, - hasMore: hitLimit ? false : data.has_more === true, + nextCursor: hasMore ? nextCursor : undefined, + hasMore, } } diff --git a/apps/sim/connectors/slack/slack.ts b/apps/sim/connectors/slack/slack.ts index 209a6fb4231..814fe13a734 100644 --- a/apps/sim/connectors/slack/slack.ts +++ b/apps/sim/connectors/slack/slack.ts @@ -3,7 +3,7 @@ import { toError } from '@sim/utils/errors' import { SlackIcon } from '@/components/icons' import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types' -import { parseTagDate } from '@/connectors/utils' +import { parseMultiValue, parseTagDate } from '@/connectors/utils' const logger = createLogger('SlackConnector') @@ -41,12 +41,16 @@ const SLACK_NOISE_SUBTYPES = new Set([ interface SlackMessage { type: string user?: string + username?: string + bot_id?: string text?: string ts: string subtype?: string edited?: { ts: string; user?: string } latest_reply?: string reply_count?: number + attachments?: Record[] + blocks?: Record[] } interface SlackChannel { @@ -197,9 +201,116 @@ async function fetchChannelMessages( return { messages: trimmed, lastActivityTs, oldestTs } } +/** + * Pulls user-visible text from a Slack message's `text`, legacy `attachments`, + * and Block Kit `blocks`. Apps like GitHub typically post a short `text` + * summary with the actual PR/issue content inside attachments or blocks, so + * reading `text` alone drops the meaningful body. + */ +function extractMessageContent(msg: SlackMessage): string { + const parts: string[] = [] + if (msg.text) parts.push(msg.text) + + for (const attachment of msg.attachments ?? []) { + for (const key of ['pretext', 'author_name', 'title', 'text', 'footer'] as const) { + const v = attachment[key] + if (typeof v === 'string' && v.trim()) parts.push(v) + } + const fields = attachment.fields + if (Array.isArray(fields)) { + for (const f of fields) { + if (!f || typeof f !== 'object') continue + const fo = f as Record + const title = typeof fo.title === 'string' ? fo.title : '' + const value = typeof fo.value === 'string' ? fo.value : '' + if (title && value) parts.push(`${title}: ${value}`) + else if (title || value) parts.push(title || value) + } + } + /** + * Attachments may also embed Block Kit blocks + * (https://docs.slack.dev/legacy/legacy-messaging/legacy-secondary-message-attachments). + * Apps like GitHub put the bulk of the PR/issue body inside attachment.blocks. + */ + const nestedBlocks = attachment.blocks + if (Array.isArray(nestedBlocks)) { + for (const block of nestedBlocks) { + const blockParts: string[] = [] + walkBlockText(block, blockParts) + if (blockParts.length > 0) parts.push(blockParts.join(' ')) + } + } + } + + for (const block of msg.blocks ?? []) { + const blockParts: string[] = [] + walkBlockText(block, blockParts) + if (blockParts.length > 0) parts.push(blockParts.join(' ')) + } + + return parts.filter((s) => s.trim().length > 0).join('\n') +} + +/** + * Recursively walks Block Kit nodes pulling leaf text. Covers section + * (`text` + `fields` + `accessory`), header (`text`), context + * (`elements[].text`/`alt_text`), image blocks (`alt_text` + `title`), and + * rich_text (nested `elements[].elements[]`). Link nodes without text fall + * back to their URL; emoji nodes render as `:name:`; broadcast leafs render + * as `@here`/`@channel`/`@everyone`; date leafs render their `fallback`; + * user/channel/usergroup mentions render their referenced id. + */ +function walkBlockText(node: unknown, out: string[]): void { + if (!node || typeof node !== 'object') return + const n = node as Record + if (typeof n.text === 'string') { + out.push(n.text) + } else if (n.text && typeof n.text === 'object') { + walkBlockText(n.text, out) + } + if (Array.isArray(n.fields)) { + for (const f of n.fields) walkBlockText(f, out) + } + if (Array.isArray(n.elements)) { + for (const e of n.elements) walkBlockText(e, out) + } + /** + * Section blocks expose a single side accessory (button, image, overflow + * menu) that frequently carries user-visible labels. + */ + if (n.accessory && typeof n.accessory === 'object') { + walkBlockText(n.accessory, out) + } + if (typeof n.alt_text === 'string' && n.alt_text.trim()) { + out.push(n.alt_text) + } + if (n.type === 'link' && typeof n.url === 'string' && typeof n.text !== 'string') { + out.push(n.url) + } + if (n.type === 'emoji' && typeof n.name === 'string') { + out.push(`:${n.name}:`) + } + if (n.type === 'broadcast' && typeof n.range === 'string') { + out.push(`@${n.range}`) + } + if (n.type === 'user' && typeof n.user_id === 'string') { + out.push(`<@${n.user_id}>`) + } + if (n.type === 'channel' && typeof n.channel_id === 'string') { + out.push(`<#${n.channel_id}>`) + } + if (n.type === 'usergroup' && typeof n.usergroup_id === 'string') { + out.push(``) + } + if (n.type === 'date' && typeof n.fallback === 'string') { + out.push(n.fallback) + } +} + /** * Converts fetched messages into a single document content string. - * Each line: "[ISO timestamp] username: message text" + * Each entry: "[ISO timestamp] username: message text" (text may span lines + * when the message has rich attachment/block content). */ async function formatMessages( accessToken: string, @@ -212,8 +323,6 @@ async function formatMessages( const chronological = [...messages].reverse() for (const msg of chronological) { - // Skip non-user messages (join/leave, bot messages without text, etc.) - if (!msg.text) continue /** * Drop only known noise subtypes (channel join/leave/topic events, * bot add/remove, etc.). Per https://api.slack.com/events/message any @@ -222,12 +331,15 @@ async function formatMessages( */ if (msg.subtype && SLACK_NOISE_SUBTYPES.has(msg.subtype)) continue + const content = extractMessageContent(msg) + if (!content) continue + const timestamp = formatSlackTimestamp(msg.ts) const userName = msg.user ? await resolveUserName(accessToken, msg.user, syncContext) - : 'unknown' + : msg.username || 'unknown' - lines.push(`[${timestamp}] ${userName}: ${msg.text}`) + lines.push(`[${timestamp}] ${userName}: ${content}`) } return lines.join('\n') @@ -360,7 +472,13 @@ async function buildSlackChannelDocument( * in catches deletes (count drops) but still cannot detect reply edits * without fetching `conversations.replies` for each parent. */ - const contentHash = `slack:${channel.id}:${oldestTs ?? 'empty'}:${lastActivityTs ?? 'empty'}:${messageCount}:${maxEditTs || 'noedit'}:${maxReplyTs || 'noreply'}:${totalReplies}` + /** + * `slack-v2` prefix forces a one-time re-sync for channels indexed before + * we started extracting attachment + Block Kit content from bot messages. + * Per-message `ts` and `messageCount` are unchanged, so without the version + * bump the hash would match and richer content would not be re-embedded. + */ + const contentHash = `slack-v2:${channel.id}:${oldestTs ?? 'empty'}:${lastActivityTs ?? 'empty'}:${messageCount}:${maxEditTs || 'noedit'}:${maxReplyTs || 'noreply'}:${totalReplies}` return { content, contentHash, messageCount, lastActivityTs } } @@ -387,24 +505,26 @@ export const slackConnector: ConnectorConfig = { configFields: [ { id: 'channelSelector', - title: 'Channel', + title: 'Channels', type: 'selector', selectorKey: 'slack.channels', canonicalParamId: 'channel', mode: 'basic', - placeholder: 'Select a channel', + multi: true, + placeholder: 'Select one or more channels', required: true, - description: 'Channel to sync messages from', + description: 'Channels to sync messages from', }, { id: 'channel', - title: 'Channel', + title: 'Channels', type: 'short-input', canonicalParamId: 'channel', mode: 'advanced', - placeholder: 'e.g. general or C01ABC23DEF', + multi: true, + placeholder: 'e.g. general, C01ABC23DEF (comma-separated for multiple)', required: true, - description: 'Channel name or ID to sync messages from', + description: 'Channel names or IDs to sync messages from', }, { id: 'maxMessages', @@ -421,57 +541,69 @@ export const slackConnector: ConnectorConfig = { _cursor?: string, syncContext?: Record ): Promise => { - const channelInput = sourceConfig.channel as string - if (!channelInput?.trim()) { - throw new Error('Channel is required') + const channelInputs = parseMultiValue(sourceConfig.channel) + if (channelInputs.length === 0) { + throw new Error('At least one channel is required') } const maxMessages = sourceConfig.maxMessages ? Number(sourceConfig.maxMessages) : DEFAULT_MAX_MESSAGES - logger.info('Syncing Slack channel', { channel: channelInput, maxMessages }) + logger.info('Syncing Slack channels', { channels: channelInputs, maxMessages }) - const channel = await resolveChannel(accessToken, channelInput) - if (!channel) { - throw new Error(`Channel not found: ${channelInput}`) - } + const teamId = await resolveTeamId(accessToken, syncContext) + const documents: ExternalDocument[] = [] + + for (const channelInput of channelInputs) { + const channel = await resolveChannel(accessToken, channelInput) + if (!channel) { + /** + * Fail loudly rather than silently skipping. A configured channel that + * suddenly stops resolving (bot removed, channel archived, renamed) + * would otherwise have its previously-indexed document orphaned and + * deleted by the sync engine with no error surfaced. Matches the MS + * Teams connector's behaviour. + */ + throw new Error(`Channel not found: ${channelInput}`) + } - const { content, contentHash, messageCount, lastActivityTs } = await buildSlackChannelDocument( - accessToken, - channel, - maxMessages, - syncContext - ) - if (!content.trim()) { - logger.info(`No messages found in channel: #${channel.name}`) - return { documents: [], hasMore: false } - } + const { content, contentHash, messageCount, lastActivityTs } = + await buildSlackChannelDocument(accessToken, channel, maxMessages, syncContext) + if (!content.trim()) { + logger.info(`No messages found in channel: #${channel.name}`) + continue + } - const teamId = await resolveTeamId(accessToken, syncContext) - const sourceUrl = teamId - ? `https://app.slack.com/client/${teamId}/${channel.id}` - : `https://app.slack.com/client/${channel.id}` - - const document: ExternalDocument = { - externalId: channel.id, - title: `#${channel.name}`, - content, - mimeType: 'text/plain', - sourceUrl, - contentHash, - metadata: { - channelName: channel.name, - messageCount, - lastActivity: lastActivityTs ? formatSlackTimestamp(lastActivityTs) : undefined, - topic: channel.topic?.value, - purpose: channel.purpose?.value, - }, + const sourceUrl = teamId + ? `https://app.slack.com/client/${teamId}/${channel.id}` + : `https://app.slack.com/client/${channel.id}` + + documents.push({ + externalId: channel.id, + title: `#${channel.name}`, + content, + mimeType: 'text/plain', + sourceUrl, + contentHash, + metadata: { + channelName: channel.name, + messageCount, + lastActivity: lastActivityTs ? formatSlackTimestamp(lastActivityTs) : undefined, + topic: channel.topic?.value, + purpose: channel.purpose?.value, + }, + }) } - // Each channel is one document; no pagination needed + /** + * All channels are processed in one call — the multi-select UI keeps the + * count small, and each channel is an independent document with its own + * `externalId` and `contentHash`, so the sync engine treats them as + * independent documents. + */ return { - documents: [document], + documents, hasMore: false, } }, @@ -527,11 +659,11 @@ export const slackConnector: ConnectorConfig = { accessToken: string, sourceConfig: Record ): Promise<{ valid: boolean; error?: string }> => { - const channelInput = sourceConfig.channel as string | undefined + const channelInputs = parseMultiValue(sourceConfig.channel) const maxMessages = sourceConfig.maxMessages as string | undefined - if (!channelInput?.trim()) { - return { valid: false, error: 'Channel is required' } + if (channelInputs.length === 0) { + return { valid: false, error: 'At least one channel is required' } } if (maxMessages && (Number.isNaN(Number(maxMessages)) || Number(maxMessages) <= 0)) { @@ -539,21 +671,37 @@ export const slackConnector: ConnectorConfig = { } try { - const trimmed = channelInput.trim().replace(/^#/, '') + /** + * Validate every selected channel. ID-shaped inputs use `conversations.info` + * directly; name-shaped inputs are resolved by paginating `conversations.list` + * once and matching all remaining names against the same pages — this avoids + * walking the full channel list once per name. + */ + const nameLookups: string[] = [] + for (const input of channelInputs) { + const trimmed = input.trim().replace(/^#/, '') + + if (/^[CG][A-Z0-9]+$/.test(trimmed)) { + try { + await slackApiGet( + 'conversations.info', + accessToken, + { channel: trimmed }, + VALIDATE_RETRY_OPTIONS + ) + } catch { + return { valid: false, error: `Channel not found: ${input}` } + } + } else { + nameLookups.push(trimmed) + } + } - // If it looks like a channel ID, verify directly. DMs (D...) are excluded - // because we don't request im:*/mpim:* scopes — see resolveChannel. - if (/^[CG][A-Z0-9]+$/.test(trimmed)) { - await slackApiGet( - 'conversations.info', - accessToken, - { channel: trimmed }, - VALIDATE_RETRY_OPTIONS - ) + if (nameLookups.length === 0) { return { valid: true } } - // Otherwise search by name (include private channels the bot is in) + const remaining = new Set(nameLookups) let cursor: string | undefined do { const params: Record = { @@ -573,14 +721,20 @@ export const slackConnector: ConnectorConfig = { ) const channels = (data.channels as SlackChannel[]) || [] - const match = channels.find((ch) => ch.name === trimmed) - if (match) return { valid: true } + for (const ch of channels) { + if (remaining.has(ch.name)) { + remaining.delete(ch.name) + } + } + + if (remaining.size === 0) return { valid: true } const responseMeta = data.response_metadata as { next_cursor?: string } | undefined cursor = responseMeta?.next_cursor || undefined } while (cursor) - return { valid: false, error: `Channel not found: ${channelInput}` } + const missing = Array.from(remaining) + return { valid: false, error: `Channel(s) not found: ${missing.join(', ')}` } } catch (error) { const message = toError(error).message || 'Failed to validate configuration' return { valid: false, error: message } diff --git a/apps/sim/connectors/types.ts b/apps/sim/connectors/types.ts index 927678f12a5..71452b5c512 100644 --- a/apps/sim/connectors/types.ts +++ b/apps/sim/connectors/types.ts @@ -74,6 +74,14 @@ export interface ConnectorConfigField { mode?: 'basic' | 'advanced' /** Links selector + manual input fields that resolve to the same config key */ canonicalParamId?: string + + /** + * When true, the field accepts multiple values. + * - For `selector` fields, renders the picker in multi-select mode and persists `string[]` to sourceConfig. + * - For `short-input` fields, accepts a comma-separated list and persists `string[]` to sourceConfig. + * Connector handlers receive `string | string[]` and should normalize via `parseMultiValue`. + */ + multi?: boolean } /** diff --git a/apps/sim/connectors/utils.ts b/apps/sim/connectors/utils.ts index dce83b7a76e..391d3a590f8 100644 --- a/apps/sim/connectors/utils.ts +++ b/apps/sim/connectors/utils.ts @@ -43,3 +43,38 @@ export function joinTagArray(value: unknown): string | undefined { const arr = Array.isArray(value) ? (value as string[]) : [] return arr.length > 0 ? arr.join(', ') : undefined } + +/** + * Normalizes a multi-value sourceConfig field into a trimmed, deduplicated string array. + * + * Accepts a string (CSV from advanced manual input or legacy single-value), an array + * of strings (from multi-select UI or new array storage), or undefined/null. Always + * returns a string[] — connectors call this once at the top of listDocuments to + * branch on `values.length` for single vs multi behavior. + */ +export function parseMultiValue(value: unknown): string[] { + if (Array.isArray(value)) { + const seen = new Set() + const out: string[] = [] + for (const item of value) { + if (typeof item !== 'string') continue + const trimmed = item.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + out.push(trimmed) + } + return out + } + if (typeof value === 'string') { + const seen = new Set() + const out: string[] = [] + for (const part of value.split(',')) { + const trimmed = part.trim() + if (!trimmed || seen.has(trimmed)) continue + seen.add(trimmed) + out.push(trimmed) + } + return out + } + return [] +} diff --git a/apps/sim/hooks/selectors/providers/confluence/selectors.ts b/apps/sim/hooks/selectors/providers/confluence/selectors.ts index 84e0e528609..7ebf81640bb 100644 --- a/apps/sim/hooks/selectors/providers/confluence/selectors.ts +++ b/apps/sim/hooks/selectors/providers/confluence/selectors.ts @@ -37,7 +37,7 @@ export const confluenceSelectors = { signal, }) for (const space of data.spaces || []) { - collected.push({ id: space.id, label: formatConfluenceSpaceLabel(space) }) + collected.push({ id: space.key, label: formatConfluenceSpaceLabel(space) }) } cursor = data.nextCursor } while (cursor) @@ -57,7 +57,7 @@ export const confluenceSelectors = { }) return { items: (data.spaces || []).map((space) => ({ - id: space.id, + id: space.key, label: formatConfluenceSpaceLabel(space), })), nextCursor: data.nextCursor, @@ -81,9 +81,9 @@ export const confluenceSelectors = { }, signal, }) - const space = (data.spaces || []).find((s) => s.id === detailId) ?? null + const space = (data.spaces || []).find((s) => s.key === detailId) ?? null if (!space) return null - return { id: space.id, label: formatConfluenceSpaceLabel(space) } + return { id: space.key, label: formatConfluenceSpaceLabel(space) } }, }, 'confluence.pages': {