improvement(kb-connectors): multi-select fields + Slack bot/app message extraction (#4711)

* improvement(kb-connectors): multi-select fields + Slack bot/app message extraction

Adds multi-value support to KB connector configuration fields and applies it
across 8 connectors: Jira (projects), Confluence (spaces), Slack (channels),
Microsoft Teams (channels), Google Calendar (calendars), Gmail (labels),
Notion (databases), and Linear (teams + projects). Each connector emits
byte-identical externalId for legacy single-value configs so existing rows
reconcile in place via the sync engine's externalId-keyed matching.

Framework changes:
- ConnectorConfigField gains `multi?: boolean`
- New `parseMultiValue` helper in @/connectors/utils
- useConnectorConfigFields state model upgraded to string|string[]
- ConnectorSelectorField renders Combobox in multi-select mode when `field.multi`
- Add/edit connector modals handle array values end-to-end

Per-connector specifics:
- Jira: JQL `project in (...)` for 2+ keys, `project = X` for one
- Confluence: routes through CQL `space in (...)` when multi; v2 fast path stays
  for single+no-label; also fixes selector returning space.id instead of space.key
- Slack: loops per channel emitting one document each; extracts text from
  attachments and Block Kit blocks (incl. nested attachment.blocks where GitHub
  embeds PR bodies); contentHash bumped to slack-v2: to force one-time re-embed
- Microsoft Teams: loops per channel within a single team
- Google Calendar: compound cursor across calendars; single-calendar keeps
  legacy externalId/contentHash for zero-churn
- Gmail: (label:A OR label:B) with quoted-form for labels with spaces
- Notion: sequential walk via JSON compound cursor; single-DB keeps bare cursor
- Linear: GraphQL IdComparator.in for multi, eq for single

* fix(kb-connectors): valuesEqual treats legacy scalar as equal to multi-array

Existing connectors created before multi-select store sourceConfig values as
scalars (e.g. projectKey: "ENG"). With the field now declared multi: true,
resolveSourceConfig returns an array (["ENG"]), and the original valuesEqual
fell through to a strict reference comparison — falsely flagging unsaved
changes on open and triggering an unnecessary string→array shape rewrite on
save.

valuesEqual now normalizes both sides to string[] via CSV-split when either
is an array, so persisted scalar and in-memory array of the same content
compare equal. Single-value (non-multi) fields keep strict string equality.

* fix(kb-connectors): GCal externalId on config downgrade, Slack silent skip, valuesEqual order

- google-calendar getDocument: derive isMultiCalendar from the externalId's
  `:` separator instead of the current config count. Prevents duplicates when
  a user downgrades from multi to single calendar — previously the returned
  doc lost its `calendarId:` prefix and was treated as a new row by the sync
  engine, orphaning the original.
- slack listDocuments: throw on unresolvable channel instead of silently
  skipping. Matches MS Teams behaviour. Silent skip would let the sync
  engine orphan-delete the previously indexed channel content if a bot was
  removed or a channel was archived/renamed mid-life.
- edit-connector-modal valuesEqual: order-insensitive comparison for multi-
  select arrays via Set membership. Multi-select UI doesn't guarantee
  insertion order matches the server-returned order, so `["A","B"]` vs
  `["B","A"]` would otherwise flag false unsaved changes.

* chore(kb-connectors): use emptyValue() fallback in isFieldPopulated for consistency

Behavior unchanged — isValuePopulated('') and isValuePopulated([]) both return
false — but reading the field-typed fallback inline matches the convention
used elsewhere in the hook (coerceForField, handleFieldChange, resolveSourceConfig).

* fix(kb-connectors): Linear projects selector loads across all selected teams

When the team selector is in multi-select mode, the basic-mode projects
dropdown was passing only the first team ID into the linear.projects
selector context (via readFirst in resolveDepValue), so projects from other
selected teams were invisible.

resolveDepValue now joins multi-value parents into a CSV string so dependent
selectors receive every selected parent ID. The /api/tools/linear/projects
route splits the CSV teamId, fetches projects from each team in parallel,
and dedupes by project ID. Single-team configs pass through unchanged
(`split(",")` on a bare ID yields a one-element array).

The AND-of-filters semantics in buildIssuesQuery is intentional and matches
standard GraphQL filter behavior — a user filtering on teams [A,B] and
projects [X,Y] gets issues in (A or B) AND (X or Y). With this fix the
project dropdown now shows every project under any selected team so the
user can compose the right project set.

* fix(gmail-connector): always wrap OR-containing custom query, not just unwrapped ones

The previous check `!/^\(.*\)$/.test(trimmedCustom)` was supposed to avoid
double-wrapping an already-parenthesized expression, but it false-positives
on inputs like `(from:alice) OR (from:bob)` where the parens don't bracket
the whole string. Those would skip wrapping and the top-level OR would bind
across the preceding label / category / date filters instead of the custom
clause.

Always wrap when an OR is present — double-parens are a no-op in Gmail
search syntax, so `((from:a OR from:b))` parses the same as `(from:a OR
from:b)`. Simpler than walking parens depth and provably safe.
This commit is contained in:
Waleed
2026-05-21 19:41:45 -07:00
committed by GitHub
parent 952eb1216f
commit e0551b3a1e
16 changed files with 1092 additions and 362 deletions
@@ -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<string>()
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 })
@@ -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<string, unknown> = {}
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 ? (
<ConnectorSelectorField
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
value={sourceConfig[field.id] || ''}
onChange={(value) => 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()}`}
/>
) : (
<Input
value={sourceConfig[field.id] || ''}
value={
Array.isArray(sourceConfig[field.id])
? (sourceConfig[field.id] as string[]).join(', ')
: (sourceConfig[field.id] as string) || ''
}
onChange={(e) => handleFieldChange(field.id, e.target.value)}
placeholder={field.placeholder}
/>
@@ -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<string, string>
sourceConfig: ConfigFieldMap
configFields: ConnectorConfigField[]
canonicalModes: Record<string, 'basic' | 'advanced'>
disabled?: boolean
@@ -29,6 +33,8 @@ export function ConnectorSelectorField({
canonicalModes,
disabled,
}: ConnectorSelectorFieldProps) {
const isMulti = Boolean(field.multi)
const context = useMemo<SelectorContext>(() => {
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 (
<Combobox
multiSelect
options={comboboxOptions}
multiSelectValues={multiValues}
onMultiSelectChange={(values) => 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 (
<Combobox
options={comboboxOptions}
value={value || undefined}
onChange={onChange}
value={singleValue || undefined}
onChange={(next) => onChange(next)}
searchable
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
placeholder={
@@ -96,18 +125,28 @@ function resolveDepValue(
depFieldId: string,
configFields: ConnectorConfigField[],
canonicalModes: Record<string, 'basic' | 'advanced'>,
sourceConfig: Record<string, string>
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(
@@ -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<string, 'basic' | 'advanced'>,
persisted: Record<string, 'basic' | 'advanced'>
@@ -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<Record<string, string>>(() => {
const config: Record<string, string> = {}
const [initialSourceConfig] = useState<ConfigFieldMap>(() => {
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<string, string> = {}
const changedEntries: Record<string, unknown> = {}
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<string, string>
sourceConfig: ConfigFieldMap
credentialId: string | null
canonicalGroups: Map<string, ConnectorConfigField[]>
canonicalModes: Record<string, 'basic' | 'advanced'>
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 ? (
<ConnectorSelectorField
field={field as ConnectorConfigField & { selectorKey: SelectorKey }}
value={sourceConfig[field.id] || ''}
onChange={(value) => 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()}`}
/>
) : (
<Input
value={sourceConfig[field.id] || ''}
value={
Array.isArray(sourceConfig[field.id])
? (sourceConfig[field.id] as string[]).join(', ')
: (sourceConfig[field.id] as string) || ''
}
onChange={(e) => onFieldChange(field.id, e.target.value)}
placeholder={field.placeholder}
/>
@@ -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<string, ConfigFieldValue>
export interface UseConnectorConfigFieldsOptions {
connectorConfig: ConnectorConfig | null
initialSourceConfig?: Record<string, string>
initialSourceConfig?: ConfigFieldMap
initialCanonicalModes?: Record<string, 'basic' | 'advanced'>
}
export interface UseConnectorConfigFieldsResult {
sourceConfig: Record<string, string>
setSourceConfig: React.Dispatch<React.SetStateAction<Record<string, string>>>
sourceConfig: ConfigFieldMap
setSourceConfig: React.Dispatch<React.SetStateAction<ConfigFieldMap>>
canonicalModes: Record<string, 'basic' | 'advanced'>
setCanonicalModes: React.Dispatch<React.SetStateAction<Record<string, 'basic' | 'advanced'>>>
canonicalGroups: Map<string, ConnectorConfigField[]>
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<string, string>
resolveSourceConfig: () => Record<string, unknown>
}
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<Record<string, string>>(
() => initialSourceConfig ?? {}
)
const [sourceConfig, setSourceConfig] = useState<ConfigFieldMap>(() => initialSourceConfig ?? {})
const [canonicalModes, setCanonicalModes] = useState<Record<string, 'basic' | 'advanced'>>(
() => initialCanonicalModes ?? {}
)
@@ -56,6 +89,13 @@ export function useConnectorConfigFields({
return groups
}, [connectorConfig])
const fieldsById = useMemo(() => {
const map = new Map<string, ConnectorConfigField>()
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<string, string[]>()
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<string, string> => {
const resolved: Record<string, string> = {}
const resolveSourceConfig = useCallback((): Record<string, unknown> => {
const resolved: Record<string, unknown> = {}
const processed = new Set<string>()
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,
+50 -17
View File
@@ -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<string, unknown>
): Promise<ExternalDocumentList> => {
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<string, unknown>
): 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<Record<string, unknown>> | 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"'
+55 -19
View File
@@ -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, unknown>): 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, unknown>): 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' ||
@@ -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<string, unknown>): { 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<string, unknown>
): Promise<ExternalDocumentList> => {
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<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
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())}`
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',
const response = await fetchWithRetry(
url,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
},
},
VALIDATE_RETRY_OPTIONS
)
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
if (response.status === 404) {
return { valid: false, error: 'Calendar not found. Check the calendar ID.' }
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 }
+43 -17
View File
@@ -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<string, unknown>
): Promise<ExternalDocumentList> => {
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<string, unknown>
): 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()}`
+42 -24
View File
@@ -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<string, unknown>): {
function buildIssuesQuery(
sourceConfig: Record<string, unknown>,
teamIds: string[],
projectIds: string[]
): {
query: string
variables: Record<string, unknown>
} {
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<string, unknown> = {}
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(', ')}`,
}
}
}
@@ -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<string, unknown>
): Promise<ExternalDocumentList> => {
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 })
const channel = await resolveChannel(accessToken, teamId, channelInput)
if (!channel) {
throw new Error(`Channel not found: ${channelInput}`)
}
const { messages, lastActivityTs } = await fetchChannelMessages(
accessToken,
logger.info('Syncing Microsoft Teams channels', {
teamId,
channel.id,
maxMessages
)
channels: channelInputs,
maxMessages,
})
const content = formatMessages(messages)
if (!content.trim()) {
logger.info(`No messages found in channel: ${channel.displayName}`)
return { documents: [], hasMore: false }
const documents: ExternalDocument[] = []
for (const channelInput of channelInputs) {
const channel = await resolveChannel(accessToken, teamId, channelInput)
if (!channel) {
throw new Error(`Channel not found: ${channelInput}`)
}
const { messages, lastActivityTs } = await fetchChannelMessages(
accessToken,
teamId,
channel.id,
maxMessages
)
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,
},
})
}
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,
},
}
// 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<string, unknown>
): 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,14 +406,16 @@ 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<TeamsMessagesResponse>(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<TeamsMessagesResponse>(messagesPath, accessToken, VALIDATE_RETRY_OPTIONS)
}
return { valid: true }
} catch (error) {
+140 -57
View File
@@ -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<string, unknown>
): Promise<ExternalDocumentList> => {
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<string, unknown>
): 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<string, unknown>
): Promise<ExternalDocumentList> {
const body: Record<string, unknown> = {
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
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),
})
while (databaseIndex < databaseIds.length) {
const databaseId = databaseIds[databaseIndex]
const body: Record<string, unknown> = { page_size: 100 }
if (startCursor) body.start_cursor = startCursor
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}`)
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),
})
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<string, unknown>[]
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
}
databaseIndex++
startCursor = undefined
if (databaseIndex < databaseIds.length) {
nextCursor =
databaseIds.length === 1 ? undefined : JSON.stringify({ databaseIndex, cursor: undefined })
hasMore = true
break
}
}
const data = await response.json()
const results = (data.results || []) as Record<string, unknown>[]
const pages = results.filter((r) => r.object === 'page' && !(r.archived as boolean))
const documents = pages.map(pageToStub)
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,
}
}
+223 -69
View File
@@ -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<string, unknown>[]
blocks?: Record<string, unknown>[]
}
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<string, unknown>
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<string, unknown>
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(`<!subteam^${n.usergroup_id}>`)
}
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<string, unknown>
): Promise<ExternalDocumentList> => {
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 })
const channel = await resolveChannel(accessToken, channelInput)
if (!channel) {
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 }
}
logger.info('Syncing Slack channels', { channels: channelInputs, maxMessages })
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 documents: ExternalDocument[] = []
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,
},
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}`)
continue
}
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<string, unknown>
): 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 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 (/^[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 (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<string, string> = {
@@ -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 }
+8
View File
@@ -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
}
/**
+35
View File
@@ -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<string>()
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<string>()
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 []
}
@@ -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': {