diff --git a/src/main/__tests__/integration-event-bridge.test.ts b/src/main/__tests__/integration-event-bridge.test.ts index 801e564b..bebd9a26 100644 --- a/src/main/__tests__/integration-event-bridge.test.ts +++ b/src/main/__tests__/integration-event-bridge.test.ts @@ -9,6 +9,7 @@ import { getIntegrationEventTelemetrySnapshot, IntegrationEventBridge, createWorkspaceScopedEventClient, + eventPathGlobsForIntegration, integrationSubscriptionSummaries, integrationRelayFileSyncOptions, localWatchEventPathsForFilename, @@ -306,6 +307,29 @@ test('relayfile sdk path filters broaden partial-segment Slack DM globs', () => ]) }) +test('slack DM watch globs use the user-message model and drop vestigial /slack/dms', () => { + const slackDm = integration({ + provider: 'slack', + integrationId: 'slack-1', + mountPaths: ['/slack/channels/C123ABC__proj-cloud'], + scope: { listenDms: true } + }) + const globs = eventPathGlobsForIntegration(slackDm) + assert.ok(globs.includes('/slack/users/*/messages/**'), 'canonical user-message DM watch present') + assert.ok(globs.includes('/slack/channels/D*/**'), 'raw-D diagnostic alias retained') + assert.ok(!globs.includes('/slack/dms/*/**'), 'vestigial /slack/dms watch glob dropped') + + const noDm = integration({ + provider: 'slack', + integrationId: 'slack-2', + mountPaths: ['/slack/channels/C123ABC__proj-cloud'], + scope: { listenDms: false } + }) + const noDmGlobs = eventPathGlobsForIntegration(noDm) + assert.ok(!noDmGlobs.includes('/slack/users/*/messages/**'), 'no DM watch when listenDms is off') + assert.ok(!noDmGlobs.includes('/slack/channels/D*/**'), 'no raw-D diagnostic watch when listenDms is off') +}) + test('integration event remote stream keeps a refreshable relayfile token provider', () => { const tokenProvider = async () => 'workspace-token' const options = integrationRelayFileSyncOptions({ diff --git a/src/main/integration-event-bridge.ts b/src/main/integration-event-bridge.ts index 2e72759b..e467fadf 100644 --- a/src/main/integration-event-bridge.ts +++ b/src/main/integration-event-bridge.ts @@ -38,9 +38,15 @@ const INTEGRATION_EVENT_LOG_PATH = join(homedir(), '.agentworkforce', 'pear', 'i const AGGREGATED_WARNING_REPEAT_EVERY = 25 const MAX_AGGREGATED_WARNING_KEYS = 256 const SLACK_LIVE_EVENT_WINDOW_MS = 30 * 60 * 1_000 +// DM event watch globs. Canonical 1:1 DM surface is the user-recipient model +// `/slack/users//messages` (where the adapter materializes message.im once +// D→U mapping lands). `/slack/channels/D*` is retained only as a diagnostic +// alias for raw Slack IM conversation ids the adapter still materializes +// channel-style today. `/slack/dms/*` was vestigial residue — no adapter +// resource or record ever materialized there — so it is intentionally dropped; +// it must not imply mounted/readable DM content. const SLACK_DM_EVENT_GLOBS = [ '/slack/channels/D*/**', - '/slack/dms/*/**', '/slack/users/*/messages/**' ] const MAX_EVENT_CONTEXT_PREVIEW_BYTES = 32 * 1024 @@ -480,7 +486,7 @@ function watchGlobForPath(path: string): string { return root.endsWith('/**') ? root : `${root || '/'}/**` } -function eventPathGlobsForIntegration(integration: ConnectedIntegration): string[] { +export function eventPathGlobsForIntegration(integration: ConnectedIntegration): string[] { return dedupeStrings([ ...canonicalMountPaths(integration).map(watchGlobForPath), ...(slackListenDms(integration) ? SLACK_DM_EVENT_GLOBS : []) diff --git a/src/renderer/src/components/settings/ProjectSettings.tsx b/src/renderer/src/components/settings/ProjectSettings.tsx index c9762635..ec822455 100644 --- a/src/renderer/src/components/settings/ProjectSettings.tsx +++ b/src/renderer/src/components/settings/ProjectSettings.tsx @@ -24,9 +24,15 @@ import { ProactiveAgentsSection } from '@/components/proactive/ProactiveAgentsSe import { pear, type ConnectedIntegration } from '@/lib/ipc' import { SlackChannelPicker, + SlackDmRecipientPicker, type IntegrationAccessibleResource, type ScopePickerValue } from '@/components/settings/scope-pickers' +import { + isSlackUserMessagesMountPath, + mergeSlackScopeMountPaths, + slackUserResourceFromOption +} from '@/components/settings/slack-scope' import { useAgentStore } from '@/stores/agent-store' import { normalizeChannelName, @@ -385,6 +391,7 @@ function IntegrationVisibilitySection({ const [error, setError] = useState(null) const [scopeEditorIntegrationId, setScopeEditorIntegrationId] = useState(null) const [pendingScopeValue, setPendingScopeValue] = useState(null) + const [pendingDmScopeValue, setPendingDmScopeValue] = useState(null) const [pendingSlackListenDms, setPendingSlackListenDms] = useState(null) const resourceCacheRef = useRef(new Map()) @@ -634,6 +641,23 @@ function IntegrationVisibilitySection({ }) }, [cachedResources, projectId]) + const listSlackDmRecipients = useCallback(async (integration: ConnectedIntegration): Promise => { + const cacheKey = `slack-users:${projectId}:${integration.integrationId}` + return cachedResources(cacheKey, async () => { + const listOptions = (pear.integrations as typeof pear.integrations & { + listOptions?: typeof pear.integrations.listOptions + }).listOptions + if (typeof listOptions !== 'function') { + throw new Error('Slack DM recipient options are not available yet.') + } + const options = await listOptions(projectId, integration.provider, 'users') + if (!Array.isArray(options)) { + throw new Error('Slack DM recipient options returned an unexpected response.') + } + return options.map(slackUserResourceFromOption) + }) + }, [cachedResources, projectId]) + const saveSlackSourceChannels = useCallback(async (integration: ConnectedIntegration) => { const listenDms = pendingSlackListenDms ?? slackListenDmsFromScope(integration.scope) const nextScope = { @@ -642,8 +666,18 @@ function IntegrationVisibilitySection({ selection: pendingScopeValue?.scope.selection ?? integration.scope.selection ?? 'selected', channels: pendingScopeValue?.scope.channels ?? integration.scope.channels ?? [], resources: pendingScopeValue?.scope.resources ?? integration.scope.resources ?? [], + dmUsers: pendingDmScopeValue?.scope.dmUsers ?? integration.scope.dmUsers ?? [], listenDms } + const nextMountPaths = mergeSlackScopeMountPaths({ + existing: integration.mountPaths ?? [], + channelPaths: pendingScopeValue?.mountPaths ?? null, + // Only mount concrete `/slack/users//messages` recipients — never a bare + // `/slack/users` root (emitted when no users are available to select). + dmPaths: pendingDmScopeValue + ? (pendingDmScopeValue.mountPaths ?? []).filter(isSlackUserMessagesMountPath) + : null + }) setBusyIntegrationId(integration.integrationId) setError(null) @@ -652,7 +686,7 @@ function IntegrationVisibilitySection({ projectId, integration.integrationId, nextScope, - pendingScopeValue?.mountPaths ?? integration.mountPaths + nextMountPaths ) setIntegrations((current) => current.map((entry) => @@ -661,13 +695,14 @@ function IntegrationVisibilitySection({ ) setScopeEditorIntegrationId(null) setPendingScopeValue(null) + setPendingDmScopeValue(null) setPendingSlackListenDms(null) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { setBusyIntegrationId(null) } - }, [pendingScopeValue, pendingSlackListenDms, projectId]) + }, [pendingScopeValue, pendingDmScopeValue, pendingSlackListenDms, projectId]) return (
path.split('/')[3]) + .filter(Boolean), + ...scopeStringList(integration.scope, 'dmUsers') + ])) + const slackScopeDirty = !!pendingScopeValue || !!pendingDmScopeValue || ( pendingSlackListenDms !== null && pendingSlackListenDms !== savedSlackListenDms ) const knownTargetValues = new Set([ @@ -773,6 +816,7 @@ function IntegrationVisibilitySection({ disabled={busy} onClick={() => { setPendingScopeValue(null) + setPendingDmScopeValue(null) setPendingSlackListenDms(scopeEditorOpen ? null : savedSlackListenDms) setScopeEditorIntegrationId(scopeEditorOpen ? null : integration.integrationId) }} @@ -879,9 +923,20 @@ function IntegrationVisibilitySection({ /> Direct messages - Listen for Slack DMs + + Observe DM events (watch-only). Choose recipients below to read & send their DMs. + +
+ listSlackDmRecipients(integration)} + onChange={setPendingDmScopeValue} + /> +