diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 2de2b437..b9064380 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -15,7 +15,11 @@ import { bus } from '@x/core/dist/runs/bus.js'; import { serviceBus } from '@x/core/dist/services/service_bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; import z from 'zod'; + +const execAsync = promisify(exec); import { RunEvent } from '@x/shared/dist/runs.js'; import { ServiceEvent } from '@x/shared/dist/service-events.js'; import container from '@x/core/dist/di/container.js'; @@ -25,6 +29,7 @@ import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; +import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import * as composioHandler from './composio-handler.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; @@ -410,6 +415,30 @@ export function setupIpcHandlers() { return { success: true }; }, + 'slack:getConfig': async () => { + const repo = container.resolve('slackConfigRepo'); + const config = await repo.getConfig(); + return { enabled: config.enabled, workspaces: config.workspaces }; + }, + 'slack:setConfig': async (_event, args) => { + const repo = container.resolve('slackConfigRepo'); + await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces }); + return { success: true }; + }, + 'slack:listWorkspaces': async () => { + try { + const { stdout } = await execAsync('agent-slack auth whoami', { timeout: 10000 }); + const parsed = JSON.parse(stdout); + const workspaces = (parsed.workspaces || []).map((w: { workspace_url?: string; workspace_name?: string }) => ({ + url: w.workspace_url || '', + name: w.workspace_name || '', + })); + return { workspaces }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to list Slack workspaces'; + return { workspaces: [], error: message }; + } + }, 'onboarding:getStatus': async () => { // Show onboarding if it hasn't been completed yet const complete = isOnboardingComplete(); diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index ad2285a0..78aee6e4 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -17,7 +17,6 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { Separator } from "@/components/ui/separator" -import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" @@ -55,11 +54,15 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) - // Composio/Slack state - const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [slackConnected, setSlackConnected] = useState(false) + // Slack state (agent-slack CLI) + const [slackEnabled, setSlackEnabled] = useState(false) const [slackLoading, setSlackLoading] = useState(true) - const [slackConnecting, setSlackConnecting] = useState(false) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) // Load available providers on mount useEffect(() => { @@ -107,76 +110,76 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } }, []) - // Load Slack connection status - const refreshSlackStatus = useCallback(async () => { + // Load Slack config + const refreshSlackConfig = useCallback(async () => { try { setSlackLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) - setSlackConnected(result.isConnected) + const result = await window.ipc.invoke('slack:getConfig', null) + setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) } catch (error) { - console.error('Failed to load Slack status:', error) - setSlackConnected(false) + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) } finally { setSlackLoading(false) } }, []) - // Connect to Slack via Composio - const startSlackConnect = useCallback(async () => { + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) try { - setSlackConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Slack') - setSlackConnecting(false) + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) } - // Success will be handled by composio:didConnect event } catch (error) { - console.error('Failed to connect to Slack:', error) - toast.error('Failed to connect to Slack') - setSlackConnecting(false) + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) } }, []) - // Handle Slack connect button click - const handleConnectSlack = useCallback(async () => { - // Check if Composio is configured - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyOpen(true) - return - } - await startSlackConnect() - }, [startSlackConnect]) - - // Handle Composio API key submission - const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { - try { - await window.ipc.invoke('composio:set-api-key', { apiKey }) - setComposioApiKeyOpen(false) - toast.success('Composio API key saved') - // Now start the Slack connection - await startSlackConnect() - } catch (error) { - console.error('Failed to save Composio API key:', error) - toast.error('Failed to save API key') - } - }, [startSlackConnect]) - - // Disconnect from Slack - const handleDisconnectSlack = useCallback(async () => { + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) try { setSlackLoading(true) - const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' }) - if (result.success) { - setSlackConnected(false) - toast.success('Disconnected from Slack') - } else { - toast.error('Failed to disconnect from Slack') - } + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') } catch (error) { - console.error('Failed to disconnect from Slack:', error) - toast.error('Failed to disconnect from Slack') + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) + } + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') } finally { setSlackLoading(false) } @@ -187,8 +190,8 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha // Refresh Granola refreshGranolaConfig() - // Refresh Slack status - refreshSlackStatus() + // Refresh Slack config + refreshSlackConfig() // Refresh OAuth providers if (providers.length === 0) return @@ -226,7 +229,7 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshSlackConfig]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -270,26 +273,6 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha return cleanup }, [refreshAllStatuses]) - // Listen for Composio connection events - useEffect(() => { - const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event - - if (toolkitSlug === 'slack') { - setSlackConnected(success) - setSlackConnecting(false) - - if (success) { - toast.success('Connected to Slack') - } else { - toast.error(error || 'Failed to connect to Slack') - } - } - }) - - return cleanup - }, []) - const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, @@ -574,62 +557,90 @@ export function ConnectorsPopover({ children, tooltip, open: openProp, onOpenCha {/* Slack */} -
-
-
- +
+
+
+
+ +
+
+ Slack + {slackEnabled && slackWorkspaces.length > 0 ? ( + + {slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
-
- Slack - {slackLoading ? ( - Checking... +
+ {(slackLoading || slackDiscovering) && ( + + )} + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> ) : ( - - Send messages and view channels - + )}
-
- {slackLoading ? ( - - ) : slackConnected ? ( - - ) : ( - - )} -
+ {slackPickerOpen && ( +
+ {slackDiscoverError ? ( +

{slackDiscoverError}

+ ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )}
)}
- ) } diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 9398f2fe..3e663b91 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,8 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2 } from "lucide-react" -// import { MessageSquare } from "lucide-react" +import { Loader2, Mic, Mail, CheckCircle2, MessageSquare } from "lucide-react" import { Dialog, @@ -23,7 +22,6 @@ import { SelectValue, } from "@/components/ui/select" import { cn } from "@/lib/utils" -import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { GoogleClientIdModal } from "@/components/google-client-id-modal" import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store" import { toast } from "sonner" @@ -80,11 +78,15 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [granolaLoading, setGranolaLoading] = useState(true) const [showMoreProviders, setShowMoreProviders] = useState(false) - // Composio/Slack state - const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [slackConnected, setSlackConnected] = useState(false) - // const [slackLoading, setSlackLoading] = useState(true) - const [slackConnecting, setSlackConnecting] = useState(false) + // Slack state (agent-slack CLI) + const [slackEnabled, setSlackEnabled] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackWorkspaces, setSlackWorkspaces] = useState>([]) + const [slackAvailableWorkspaces, setSlackAvailableWorkspaces] = useState>([]) + const [slackSelectedUrls, setSlackSelectedUrls] = useState>(new Set()) + const [slackPickerOpen, setSlackPickerOpen] = useState(false) + const [slackDiscovering, setSlackDiscovering] = useState(false) + const [slackDiscoverError, setSlackDiscoverError] = useState(null) const updateProviderConfig = useCallback( (provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string; knowledgeGraphModel: string }>) => { @@ -212,63 +214,80 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) - // Load Slack connection status - const refreshSlackStatus = useCallback(async () => { + // Load Slack config + const refreshSlackConfig = useCallback(async () => { try { - // setSlackLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) - setSlackConnected(result.isConnected) + setSlackLoading(true) + const result = await window.ipc.invoke('slack:getConfig', null) + setSlackEnabled(result.enabled) + setSlackWorkspaces(result.workspaces || []) } catch (error) { - console.error('Failed to load Slack status:', error) - setSlackConnected(false) + console.error('Failed to load Slack config:', error) + setSlackEnabled(false) + setSlackWorkspaces([]) } finally { - // setSlackLoading(false) + setSlackLoading(false) } }, []) - // Start Slack connection - const startSlackConnect = useCallback(async () => { + // Enable Slack: discover workspaces + const handleSlackEnable = useCallback(async () => { + setSlackDiscovering(true) + setSlackDiscoverError(null) try { - setSlackConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Slack') - setSlackConnecting(false) + const result = await window.ipc.invoke('slack:listWorkspaces', null) + if (result.error || result.workspaces.length === 0) { + setSlackDiscoverError(result.error || 'No Slack workspaces found. Set up with: agent-slack auth import-desktop') + setSlackAvailableWorkspaces([]) + setSlackPickerOpen(true) + } else { + setSlackAvailableWorkspaces(result.workspaces) + setSlackSelectedUrls(new Set(result.workspaces.map((w: { url: string }) => w.url))) + setSlackPickerOpen(true) } - // Success will be handled by composio:didConnect event } catch (error) { - console.error('Failed to connect to Slack:', error) - toast.error('Failed to connect to Slack') - setSlackConnecting(false) + console.error('Failed to discover Slack workspaces:', error) + setSlackDiscoverError('Failed to discover Slack workspaces') + setSlackPickerOpen(true) + } finally { + setSlackDiscovering(false) } }, []) - // Connect to Slack via Composio (checks if configured first) - /* - const handleConnectSlack = useCallback(async () => { - // Check if Composio is configured - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyOpen(true) - return - } - await startSlackConnect() - }, [startSlackConnect]) - */ - - // Handle Composio API key submission - const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => { + // Save selected Slack workspaces + const handleSlackSaveWorkspaces = useCallback(async () => { + const selected = slackAvailableWorkspaces.filter(w => slackSelectedUrls.has(w.url)) try { - await window.ipc.invoke('composio:set-api-key', { apiKey }) - setComposioApiKeyOpen(false) - toast.success('Composio API key saved') - // Now start the Slack connection - await startSlackConnect() + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: true, workspaces: selected }) + setSlackEnabled(true) + setSlackWorkspaces(selected) + setSlackPickerOpen(false) + toast.success('Slack enabled') } catch (error) { - console.error('Failed to save Composio API key:', error) - toast.error('Failed to save API key') + console.error('Failed to save Slack config:', error) + toast.error('Failed to save Slack settings') + } finally { + setSlackLoading(false) } - }, [startSlackConnect]) + }, [slackAvailableWorkspaces, slackSelectedUrls]) + + // Disable Slack + const handleSlackDisable = useCallback(async () => { + try { + setSlackLoading(true) + await window.ipc.invoke('slack:setConfig', { enabled: false, workspaces: [] }) + setSlackEnabled(false) + setSlackWorkspaces([]) + setSlackPickerOpen(false) + toast.success('Slack disabled') + } catch (error) { + console.error('Failed to update Slack config:', error) + toast.error('Failed to update Slack settings') + } finally { + setSlackLoading(false) + } + }, []) const handleNext = () => { if (currentStep < 2) { @@ -319,8 +338,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Refresh Granola refreshGranolaConfig() - // Refresh Slack status - refreshSlackStatus() + // Refresh Slack config + refreshSlackConfig() // Refresh OAuth providers if (providers.length === 0) return @@ -349,7 +368,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshSlackConfig]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -383,26 +402,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) - // Listen for Composio connection events - useEffect(() => { - const cleanup = window.ipc.on('composio:didConnect', (event) => { - const { toolkitSlug, success, error } = event - - if (toolkitSlug === 'slack') { - setSlackConnected(success) - setSlackConnecting(false) - - if (success) { - toast.success('Connected to Slack') - } else { - toast.error(error || 'Failed to connect to Slack') - } - } - }) - - return cleanup - }, []) - const startConnect = useCallback(async (provider: string, clientId?: string) => { setProviderStates(prev => ({ ...prev, @@ -546,50 +545,85 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) // Render Slack row - /* const renderSlackRow = () => ( -
-
-
- +
+
+
+
+ +
+
+ Slack + {slackEnabled && slackWorkspaces.length > 0 ? ( + + {slackWorkspaces.map(w => w.name).join(', ')} + + ) : ( + + Send messages and view channels + + )} +
-
- Slack - {slackLoading ? ( - Checking... +
+ {(slackLoading || slackDiscovering) && ( + + )} + {slackEnabled ? ( + handleSlackDisable()} + disabled={slackLoading} + /> ) : ( - - Send messages and view channels - + )}
-
- {slackLoading ? ( - - ) : slackConnected ? ( -
- - Connected -
- ) : ( - - )} -
+ {slackPickerOpen && ( +
+ {slackDiscoverError ? ( +

{slackDiscoverError}

+ ) : ( + <> + {slackAvailableWorkspaces.map(w => ( + + ))} + + + )} +
+ )}
) - */ // Step 0: LLM Setup const renderLlmSetupStep = () => { @@ -820,6 +854,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')}
+ {/* Team Communication Section */} +
+
+ Team Communication +
+ {renderSlackRow()} +
)}
@@ -837,7 +878,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step 2: Completion const renderCompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackEnabled return (
@@ -878,7 +919,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Granola (Local meeting notes)
)} - {slackConnected && ( + {slackEnabled && (
Slack (Team communication) @@ -904,12 +945,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onSubmit={handleGoogleClientIdSubmit} isSubmitting={providerStates.google?.isConnecting ?? false} /> - {}}> " --ts +agent-slack message react remove "" --ts \`\`\` -If not connected, inform the user they need to connect Slack from the settings/onboarding. +### Search -## Available Tools - -### Check Connection \`\`\` -slack-checkConnection({}) -\`\`\` -Returns whether Slack is connected and ready to use. - -### List Users -\`\`\` -slack-listUsers({ limit: 100 }) -\`\`\` -Lists users in the workspace. Use this to resolve a name to a user ID. - -### List DM Conversations -\`\`\` -slack-getDirectMessages({ limit: 50 }) -\`\`\` -Lists DM channels (type "im"). Each entry includes the DM channel ID and the user ID. - -### List Channels -\`\`\` -slack-listChannels({ types: "public_channel,private_channel", limit: 100 }) -\`\`\` -Lists channels the user has access to. - -### Get Conversation History -\`\`\` -slack-getChannelHistory({ channel: "C01234567", limit: 20 }) -\`\`\` -Fetches recent messages for a channel or DM. - -### Search Messages -\`\`\` -slack-searchMessages({ query: "in:@username", count: 20 }) -\`\`\` -Searches Slack messages using Slack search syntax. - -### Send a Message -\`\`\` -slack-sendMessage({ channel: "C01234567", text: "Hello team!" }) -\`\`\` -Sends a message to a channel or DM. Always show the draft first. - -### Execute a Slack Action -\`\`\` -slack-executeAction({ - toolSlug: "EXACT_TOOL_SLUG_FROM_DISCOVERY", - input: { /* tool-specific parameters */ } -}) -\`\`\` -Executes any Slack tool using its exact slug discovered from \`slack-listAvailableTools\`. - -### Discover Available Tools (Fallback) -\`\`\` -slack-listAvailableTools({ search: "conversation" }) -\`\`\` -Lists available Slack tools from Composio. Use this only if a builtin Slack tool fails and you need a specific slug. - -## Composio Slack Tool Catalog (Pinned) -Use the exact tool slugs below with \`slack-executeAction\` when needed. Prefer these over \`slack-listAvailableTools\` to avoid redundant discovery. - -${slackToolCatalogMarkdown} - -## Workflow - -### Step 1: Check Connection -\`\`\` -slack-checkConnection({}) +agent-slack search messages "query text" --limit 20 +agent-slack search messages "query" --channel "#channel-name" --user "@username" +agent-slack search messages "query" --after 2025-01-01 --before 2025-02-01 +agent-slack search files "query" --limit 10 \`\`\` -### Step 2: Choose the Builtin Tool -Use the builtin Slack tools above for common tasks. Only fall back to \`slack-listAvailableTools\` + \`slack-executeAction\` if something is missing. +### Channels -## Common Tasks +\`\`\` +agent-slack channel new --name "project-x" --workspace https://team.slack.com +agent-slack channel new --name "secret-project" --private +agent-slack channel invite --channel "#project-x" --users "@alice,@bob" +\`\`\` -### Find the Most Recent DM with Someone -1. Search messages first: \`slack-searchMessages({ query: "in:@Name", count: 1 })\` -2. If you need exact DM history: - - \`slack-listUsers({})\` to find the user ID - - \`slack-getDirectMessages({})\` to find the DM channel for that user - - \`slack-getChannelHistory({ channel: "D...", limit: 20 })\` +### Users -### Send a Message -1. Draft the message and show it to the user -2. ONLY after user approval, send using \`slack-sendMessage\` +\`\`\` +agent-slack user list --limit 200 +agent-slack user get "@username" +agent-slack user get U01234567 +\`\`\` -### Search Messages -1. Use \`slack-searchMessages({ query: "...", count: 20 })\` +### Canvases + +\`\`\` +agent-slack canvas get "https://team.slack.com/docs/F01234567" +agent-slack canvas get F01234567 --workspace https://team.slack.com +\`\`\` + +--- + +## 3. Multi-Workspace + +**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces. + +If the selected workspace list contains multiple entries, use \`--workspace \` to disambiguate: + +\`\`\` +agent-slack message list "#general" --workspace https://team.slack.com +\`\`\` + +If only one workspace is selected, always use \`--workspace\` with its URL to avoid ambiguity with other authenticated workspaces. + +--- + +## 4. Token Budget Control + +Use \`--limit\` to control how many messages/results are returned. Use \`--max-body-chars\` or \`--max-content-chars\` to truncate long message bodies: + +\`\`\` +agent-slack message list "#channel" --limit 10 +agent-slack search messages "query" --limit 5 --max-content-chars 2000 +\`\`\` + +--- + +## 5. Discovering More Commands + +For any command you're unsure about: + +\`\`\` +agent-slack --help +agent-slack message --help +agent-slack search --help +agent-slack channel --help +\`\`\` + +--- ## Best Practices -- **Always show drafts before sending** - Never send Slack messages without user confirmation -- **Summarize, don't dump** - When showing channel history, summarize the key points -- **Cross-reference with knowledge base** - Check if mentioned people have notes in the knowledge base - -## Error Handling - -If a Slack operation fails: -1. Try \`slack-listAvailableTools\` to verify the tool slug is correct -2. Check if Slack is still connected with \`slack-checkConnection\` -3. Inform the user of the specific error +- **Always show drafts before sending** — Never send Slack messages without user confirmation +- **Summarize, don't dump** — When showing channel history, summarize the key points rather than pasting everything +- **Prefer Slack URLs** — When referring to messages, use Slack URLs over raw channel names when available +- **Use --limit** — Always set reasonable limits to keep output concise and token-efficient +- **Resolve user IDs** — Messages contain raw user IDs like \`U078AHJP341\`. Resolve them to real names before presenting to the user. Batch all lookups into a single \`executeCommand\` call using \`;\` separators, e.g. \`agent-slack user get U078AHJP341 --workspace ... ; agent-slack user get U090UEZCEQ0 --workspace ...\` +- **Cross-reference with knowledge base** — Check if mentioned people have notes in the knowledge base `; export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts b/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts deleted file mode 100644 index d720c9d9..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts +++ /dev/null @@ -1,117 +0,0 @@ -export type SlackToolDefinition = { - name: string; - slug: string; - description: string; -}; - -export const slackToolCatalog: SlackToolDefinition[] = [ - { name: "Add Emoji Alias", slug: "SLACK_ADD_AN_EMOJI_ALIAS_IN_SLACK", description: "Adds an alias for an existing custom emoji." }, - { name: "Add Remote File", slug: "SLACK_ADD_A_REMOTE_FILE_FROM_A_SERVICE", description: "Adds a reference to an external file (e.g., GDrive, Dropbox) to Slack." }, - { name: "Add Star to Item", slug: "SLACK_ADD_A_STAR_TO_AN_ITEM", description: "Stars a channel, file, comment, or message." }, - { name: "Add Call Participants", slug: "SLACK_ADD_CALL_PARTICIPANTS", description: "Registers new participants added to a Slack call." }, - { name: "Add Emoji", slug: "SLACK_ADD_EMOJI", description: "Adds a custom emoji to a workspace via a unique name and URL." }, - { name: "Add Reaction", slug: "SLACK_ADD_REACTION_TO_AN_ITEM", description: "Adds a specified emoji reaction to a message." }, - { name: "Archive Channel", slug: "SLACK_ARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Archives a public or private channel." }, - { name: "Archive Conversation", slug: "SLACK_ARCHIVE_A_SLACK_CONVERSATION", description: "Archives a conversation by its ID." }, - { name: "Close DM/MPDM", slug: "SLACK_CLOSE_DM_OR_MULTI_PERSON_DM", description: "Closes a DM or MPDM sidebar view for the user." }, - { name: "Create Reminder", slug: "SLACK_CREATE_A_REMINDER", description: "Creates a reminder with text and time (natural language supported)." }, - { name: "Create User Group", slug: "SLACK_CREATE_A_SLACK_USER_GROUP", description: "Creates a new user group (subteam)." }, - { name: "Create Channel", slug: "SLACK_CREATE_CHANNEL", description: "Initiates a public or private channel conversation." }, - { name: "Create Channel Conversation", slug: "SLACK_CREATE_CHANNEL_BASED_CONVERSATION", description: "Creates a new channel with specific org-wide or team settings." }, - { name: "Customize URL Unfurl", slug: "SLACK_CUSTOMIZE_URL_UNFURL", description: "Defines custom content for URL previews in a specific message." }, - { name: "Delete File Comment", slug: "SLACK_DELETE_A_COMMENT_ON_A_FILE", description: "Deletes a specific comment from a file." }, - { name: "Delete File", slug: "SLACK_DELETE_A_FILE_BY_ID", description: "Permanently deletes a file by its ID." }, - { name: "Delete Channel", slug: "SLACK_DELETE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Irreversibly deletes a channel and its history (Enterprise only)." }, - { name: "Delete Scheduled Message", slug: "SLACK_DELETE_A_SCHEDULED_MESSAGE_IN_A_CHAT", description: "Deletes a pending scheduled message." }, - { name: "Delete Reminder", slug: "SLACK_DELETE_A_SLACK_REMINDER", description: "Deletes an existing reminder." }, - { name: "Delete Message", slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", description: "Deletes a message by channel ID and timestamp." }, - { name: "Delete Profile Photo", slug: "SLACK_DELETE_USER_PROFILE_PHOTO", description: "Reverts the user's profile photo to the default avatar." }, - { name: "Disable User Group", slug: "SLACK_DISABLE_AN_EXISTING_SLACK_USER_GROUP", description: "Disables (archives) a user group." }, - { name: "Enable User Group", slug: "SLACK_ENABLE_A_SPECIFIED_USER_GROUP", description: "Reactivates a disabled user group." }, - { name: "Share File Publicly", slug: "SLACK_ENABLE_PUBLIC_SHARING_OF_A_FILE", description: "Generates a public URL for a file." }, - { name: "End Call", slug: "SLACK_END_A_CALL_WITH_DURATION_AND_ID", description: "Ends an ongoing call." }, - { name: "End Snooze", slug: "SLACK_END_SNOOZE", description: "Ends the current user's snooze mode immediately." }, - { name: "End DND Session", slug: "SLACK_END_USER_DO_NOT_DISTURB_SESSION", description: "Ends the current DND session." }, - { name: "Fetch Bot Info", slug: "SLACK_FETCH_BOT_USER_INFORMATION", description: "Fetches metadata for a specific bot user." }, - { name: "Fetch History", slug: "SLACK_FETCH_CONVERSATION_HISTORY", description: "Fetches chronological messages and events from a channel." }, - { name: "Fetch Item Reactions", slug: "SLACK_FETCH_ITEM_REACTIONS", description: "Fetches all reactions for a message, file, or comment." }, - { name: "Retrieve Replies", slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", description: "Retrieves replies to a specific parent message." }, - { name: "Fetch Team Info", slug: "SLACK_FETCH_TEAM_INFO", description: "Fetches comprehensive metadata about the team." }, - { name: "Fetch Workspace Settings", slug: "SLACK_FETCH_WORKSPACE_SETTINGS_INFORMATION", description: "Retrieves detailed settings for a specific workspace." }, - { name: "Find Channels", slug: "SLACK_FIND_CHANNELS", description: "Searches channels by name, topic, or purpose." }, - { name: "Find User by Email", slug: "SLACK_FIND_USER_BY_EMAIL_ADDRESS", description: "Finds a user object using their email address." }, - { name: "Find Users", slug: "SLACK_FIND_USERS", description: "Searches users by name, email, or display name." }, - { name: "Get Conversation Preferences", slug: "SLACK_GET_CHANNEL_CONVERSATION_PREFERENCES", description: "Retrieves posting/threading preferences for a channel." }, - { name: "Get Reminder Info", slug: "SLACK_GET_REMINDER_INFORMATION", description: "Retrieves detailed information for a specific reminder." }, - { name: "Get Remote File", slug: "SLACK_GET_REMOTE_FILE", description: "Retrieves info about a previously added remote file." }, - { name: "Get Team DND Status", slug: "SLACK_GET_TEAM_DND_STATUS", description: "Retrieves the DND status for specific users." }, - { name: "Get User Presence", slug: "SLACK_GET_USER_PRESENCE_INFO", description: "Retrieves real-time presence (active/away)." }, - { name: "Invite to Channel", slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", description: "Invites users to a channel by their user IDs." }, - { name: "Invite to Workspace", slug: "SLACK_INVITE_USER_TO_WORKSPACE", description: "Invites a user to a workspace and channels via email." }, - { name: "Join Conversation", slug: "SLACK_JOIN_AN_EXISTING_CONVERSATION", description: "Joins a conversation by channel ID." }, - { name: "Leave Conversation", slug: "SLACK_LEAVE_A_CONVERSATION", description: "Leaves a conversation." }, - { name: "List All Channels", slug: "SLACK_LIST_ALL_CHANNELS", description: "Lists all conversations with various filters." }, - { name: "List All Users", slug: "SLACK_LIST_ALL_USERS", description: "Retrieves a paginated list of all users in the workspace." }, - { name: "List User Group Members", slug: "SLACK_LIST_ALL_USERS_IN_A_USER_GROUP", description: "Lists all user IDs within a group." }, - { name: "List Conversations", slug: "SLACK_LIST_CONVERSATIONS", description: "Retrieves conversations accessible to a specific user." }, - { name: "List Files", slug: "SLACK_LIST_FILES_WITH_FILTERS_IN_SLACK", description: "Lists files and metadata with filtering options." }, - { name: "List Reminders", slug: "SLACK_LIST_REMINDERS", description: "Lists all reminders for the authenticated user." }, - { name: "List Remote Files", slug: "SLACK_LIST_REMOTE_FILES", description: "Retrieves info about a team's remote files." }, - { name: "List Scheduled Messages", slug: "SLACK_LIST_SCHEDULED_MESSAGES", description: "Lists pending scheduled messages." }, - { name: "List Pinned Items", slug: "SLACK_LISTS_PINNED_ITEMS_IN_A_CHANNEL", description: "Retrieves all messages/files pinned to a channel." }, - { name: "List Starred Items", slug: "SLACK_LIST_STARRED_ITEMS", description: "Lists items starred by the user." }, - { name: "List Custom Emojis", slug: "SLACK_LIST_TEAM_CUSTOM_EMOJIS", description: "Lists all workspace custom emojis and their URLs." }, - { name: "List User Groups", slug: "SLACK_LIST_USER_GROUPS_FOR_TEAM_WITH_OPTIONS", description: "Lists user-created and default user groups." }, - { name: "List User Reactions", slug: "SLACK_LIST_USER_REACTIONS", description: "Lists all reactions added by a specific user." }, - { name: "List Admin Users", slug: "SLACK_LIST_WORKSPACE_USERS", description: "Retrieves a paginated list of workspace administrators." }, - { name: "Set User Presence", slug: "SLACK_MANUALLY_SET_USER_PRESENCE", description: "Manually overrides automated presence status." }, - { name: "Mark Reminder Complete", slug: "SLACK_MARK_REMINDER_AS_COMPLETE", description: "Marks a reminder as complete (deprecated by Slack in March 2023)." }, - { name: "Open DM", slug: "SLACK_OPEN_DM", description: "Opens/resumes a DM or MPDM." }, - { name: "Pin Item", slug: "SLACK_PINS_AN_ITEM_TO_A_CHANNEL", description: "Pins a message to a channel." }, - { name: "Remove Remote File", slug: "SLACK_REMOVE_A_REMOTE_FILE", description: "Removes a reference to an external file." }, - { name: "Remove Star", slug: "SLACK_REMOVE_A_STAR_FROM_AN_ITEM", description: "Unstars an item." }, - { name: "Remove from Channel", slug: "SLACK_REMOVE_A_USER_FROM_A_CONVERSATION", description: "Removes a specified user from a conversation." }, - { name: "Remove Call Participants", slug: "SLACK_REMOVE_CALL_PARTICIPANTS", description: "Registers the removal of participants from a call." }, - { name: "Remove Reaction", slug: "SLACK_REMOVE_REACTION_FROM_ITEM", description: "Removes an emoji reaction from an item." }, - { name: "Rename Conversation", slug: "SLACK_RENAME_A_CONVERSATION", description: "Renames a channel ID/Conversation." }, - { name: "Rename Emoji", slug: "SLACK_RENAME_AN_EMOJI", description: "Renames a custom emoji." }, - { name: "Rename Channel", slug: "SLACK_RENAME_A_SLACK_CHANNEL", description: "Renames a public or private channel." }, - { name: "Retrieve Identity", slug: "SLACK_RETRIEVE_A_USER_S_IDENTITY_DETAILS", description: "Retrieves basic user/team identity details." }, - { name: "Retrieve Call Info", slug: "SLACK_RETRIEVE_CALL_INFORMATION", description: "Retrieves a snapshot of a call's status." }, - { name: "Retrieve Conversation Info", slug: "SLACK_RETRIEVE_CONVERSATION_INFORMATION", description: "Retrieves metadata for a specific conversation." }, - { name: "Get Conversation Members", slug: "SLACK_RETRIEVE_CONVERSATION_MEMBERS_LIST", description: "Lists active user IDs in a conversation." }, - { name: "Retrieve User DND", slug: "SLACK_RETRIEVE_CURRENT_USER_DND_STATUS", description: "Retrieves DND status for a user." }, - { name: "Retrieve File Details", slug: "SLACK_RETRIEVE_DETAILED_INFORMATION_ABOUT_A_FILE", description: "Retrieves metadata and comments for a file." }, - { name: "Retrieve User Details", slug: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", description: "Retrieves comprehensive info for a specific user ID." }, - { name: "Get Message Permalink", slug: "SLACK_RETRIEVE_MESSAGE_PERMALINK_URL", description: "Gets the permalink URL for a specific message." }, - { name: "Retrieve Team Profile", slug: "SLACK_RETRIEVE_TEAM_PROFILE_DETAILS", description: "Retrieves the profile field structure for a team." }, - { name: "Retrieve User Profile", slug: "SLACK_RETRIEVE_USER_PROFILE_INFORMATION", description: "Retrieves specific profile info for a user." }, - { name: "Revoke Public File", slug: "SLACK_REVOKE_PUBLIC_SHARING_ACCESS_FOR_A_FILE", description: "Revokes a file's public sharing URL." }, - { name: "Schedule Message", slug: "SLACK_SCHEDULE_MESSAGE", description: "Schedules a message for a future time (up to 120 days)." }, - { name: "Search Messages", slug: "SLACK_SEARCH_MESSAGES", description: "Workspace-wide message search with advanced filters." }, - { name: "Send Ephemeral", slug: "SLACK_SEND_EPHEMERAL_MESSAGE", description: "Sends a message visible only to a specific user." }, - { name: "Send Message", slug: "SLACK_SEND_MESSAGE", description: "Posts a message to a channel, DM, or group." }, - { name: "Set Conversation Purpose", slug: "SLACK_SET_A_CONVERSATION_S_PURPOSE", description: "Updates the purpose description of a channel." }, - { name: "Set DND Duration", slug: "SLACK_SET_DND_DURATION", description: "Turns on DND or changes its current duration." }, - { name: "Set Profile Photo", slug: "SLACK_SET_PROFILE_PHOTO", description: "Sets the user's profile image with cropping." }, - { name: "Set Read Cursor", slug: "SLACK_SET_READ_CURSOR_IN_A_CONVERSATION", description: "Marks a specific timestamp as read." }, - { name: "Set User Profile", slug: "SLACK_SET_SLACK_USER_PROFILE_INFORMATION", description: "Updates individual or multiple user profile fields." }, - { name: "Set Conversation Topic", slug: "SLACK_SET_THE_TOPIC_OF_A_CONVERSATION", description: "Updates the topic of a conversation." }, - { name: "Share Me Message", slug: "SLACK_SHARE_A_ME_MESSAGE_IN_A_CHANNEL", description: "Sends a third-person user action message (/me)." }, - { name: "Share Remote File", slug: "SLACK_SHARE_REMOTE_FILE_IN_CHANNELS", description: "Shares a registered remote file into channels." }, - { name: "Start Call", slug: "SLACK_START_CALL", description: "Registers a new call for third-party integration." }, - { name: "Start RTM Session", slug: "SLACK_START_REAL_TIME_MESSAGING_SESSION", description: "Initiates a real-time messaging WebSocket session." }, - { name: "Unarchive Channel", slug: "SLACK_UNARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Unarchives a specific channel." }, - { name: "Unarchive Conversation", slug: "SLACK_UNARCHIVE_CHANNEL", description: "Reverses archival for a conversation." }, - { name: "Unpin Item", slug: "SLACK_UNPIN_ITEM_FROM_CHANNEL", description: "Unpins a message from a channel." }, - { name: "Update User Group", slug: "SLACK_UPDATE_AN_EXISTING_SLACK_USER_GROUP", description: "Updates name, handle, or channels for a user group." }, - { name: "Update Remote File", slug: "SLACK_UPDATES_AN_EXISTING_REMOTE_FILE", description: "Updates metadata for a remote file reference." }, - { name: "Update Message", slug: "SLACK_UPDATES_A_SLACK_MESSAGE", description: "Modifies the content of an existing message." }, - { name: "Update Call Info", slug: "SLACK_UPDATE_SLACK_CALL_INFORMATION", description: "Updates call title or join URLs." }, - { name: "Update Group Members", slug: "SLACK_UPDATE_USER_GROUP_MEMBERS", description: "Replaces the member list of a user group." }, - { name: "Upload File", slug: "SLACK_UPLOAD_OR_CREATE_A_FILE_IN_SLACK", description: "Uploads content or binary files to Slack." }, -]; - -export const slackToolCatalogMarkdown = slackToolCatalog - .map((tool) => `- ${tool.name} (${tool.slug}) - ${tool.description}`) - .join("\n"); diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index feb41a7f..33e426c1 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -12,9 +12,6 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js"; import * as workspace from "../../workspace/workspace.js"; import { IAgentsRepo } from "../../agents/repo.js"; import { WorkDir } from "../../config/config.js"; -import { composioAccountsRepo } from "../../composio/repo.js"; -import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js"; -import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js"; import type { ToolContext } from "./exec-tool.js"; import { generateText } from "ai"; import { createProvider } from "../../models/models.js"; @@ -36,232 +33,6 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({ isAvailable: z.custom<() => Promise>().optional(), })); -type SlackToolHint = { - search?: string; - patterns: string[]; - fallbackSlugs?: string[]; - preferSlugIncludes?: string[]; - excludePatterns?: string[]; - minScore?: number; -}; - -const slackToolHints: Record = { - sendMessage: { - search: "message", - patterns: ["send", "message", "channel"], - fallbackSlugs: [ - "SLACK_SEND_MESSAGE", - "SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL", - "SLACK_SEND_A_MESSAGE", - ], - }, - listConversations: { - search: "conversation", - patterns: ["list", "conversation", "channel"], - fallbackSlugs: [ - "SLACK_LIST_CONVERSATIONS", - "SLACK_LIST_ALL_CHANNELS", - "SLACK_LIST_ALL_SLACK_TEAM_CHANNELS_WITH_VARIOUS_FILTERS", - "SLACK_LIST_CHANNELS", - "SLACK_LIST_CHANNEL", - ], - preferSlugIncludes: ["list", "conversation"], - minScore: 2, - }, - getConversationHistory: { - search: "history", - patterns: ["history", "conversation", "message"], - fallbackSlugs: [ - "SLACK_FETCH_CONVERSATION_HISTORY", - "SLACK_FETCHES_CONVERSATION_HISTORY", - "SLACK_GET_CONVERSATION_HISTORY", - "SLACK_GET_CHANNEL_HISTORY", - ], - preferSlugIncludes: ["history"], - minScore: 2, - }, - listUsers: { - search: "user", - patterns: ["list", "user"], - fallbackSlugs: [ - "SLACK_LIST_ALL_USERS", - "SLACK_LIST_ALL_SLACK_TEAM_USERS_WITH_PAGINATION", - "SLACK_LIST_USERS", - "SLACK_GET_USERS", - "SLACK_USERS_LIST", - ], - preferSlugIncludes: ["list", "user"], - excludePatterns: ["find", "by name", "by email", "by_email", "by_name", "lookup", "profile", "info"], - minScore: 2, - }, - getUserInfo: { - search: "user", - patterns: ["user", "info", "profile"], - fallbackSlugs: [ - "SLACK_GET_USER_INFO", - "SLACK_GET_USER", - "SLACK_USER_INFO", - ], - preferSlugIncludes: ["user", "info"], - minScore: 1, - }, - searchMessages: { - search: "search", - patterns: ["search", "message"], - fallbackSlugs: [ - "SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY", - "SLACK_SEARCH_MESSAGES", - "SLACK_SEARCH_MESSAGE", - ], - preferSlugIncludes: ["search"], - minScore: 1, - }, -}; - -const slackToolSlugCache = new Map(); - -const slackToolSlugOverrides: Partial> = { - sendMessage: "SLACK_SEND_MESSAGE", - listConversations: "SLACK_LIST_CONVERSATIONS", - getConversationHistory: "SLACK_FETCH_CONVERSATION_HISTORY", - listUsers: "SLACK_LIST_ALL_USERS", - getUserInfo: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", - searchMessages: "SLACK_SEARCH_MESSAGES", -}; - -const compactObject = (input: Record) => - Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)); - -type SlackToolResult = { success: boolean; data?: unknown; error?: string }; - -/** Helper to execute a Slack tool with consistent account validation and error handling */ -async function executeSlackTool( - hintKey: keyof typeof slackToolHints, - params: Record -): Promise { - const account = composioAccountsRepo.getAccount('slack'); - if (!account || account.status !== 'ACTIVE') { - return { success: false, error: 'Slack is not connected' }; - } - try { - const toolSlug = await resolveSlackToolSlug(hintKey); - return await executeComposioAction(toolSlug, account.id, compactObject(params)); - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } -} - -const normalizeSlackTool = (tool: { slug: string; name?: string; description?: string }) => - `${tool.slug} ${tool.name || ""} ${tool.description || ""}`.toLowerCase(); - -const scoreSlackTool = (tool: { slug: string; name?: string; description?: string }, patterns: string[]) => { - const slug = tool.slug.toLowerCase(); - const name = (tool.name || "").toLowerCase(); - const description = (tool.description || "").toLowerCase(); - - let score = 0; - for (const pattern of patterns) { - const needle = pattern.toLowerCase(); - if (slug.includes(needle)) score += 3; - if (name.includes(needle)) score += 2; - if (description.includes(needle)) score += 1; - } - return score; -}; - -const pickSlackTool = ( - tools: Array<{ slug: string; name?: string; description?: string }>, - hint: SlackToolHint, -) => { - let candidates = tools; - - if (hint.excludePatterns && hint.excludePatterns.length > 0) { - candidates = candidates.filter((tool) => { - const haystack = normalizeSlackTool(tool); - return !hint.excludePatterns!.some((pattern) => haystack.includes(pattern.toLowerCase())); - }); - } - - if (hint.preferSlugIncludes && hint.preferSlugIncludes.length > 0) { - const preferred = candidates.filter((tool) => - hint.preferSlugIncludes!.every((pattern) => tool.slug.toLowerCase().includes(pattern.toLowerCase())) - ); - if (preferred.length > 0) { - candidates = preferred; - } - } - - let best: { slug: string; name?: string; description?: string } | null = null; - let bestScore = 0; - - for (const tool of candidates) { - const score = scoreSlackTool(tool, hint.patterns); - if (score > bestScore) { - bestScore = score; - best = tool; - } - } - - if (!best || (hint.minScore !== undefined && bestScore < hint.minScore)) { - return null; - } - - return best; -}; - -const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => { - const cached = slackToolSlugCache.get(hintKey); - if (cached) return cached; - - const hint = slackToolHints[hintKey]; - - const override = slackToolSlugOverrides[hintKey]; - if (override && slackToolCatalog.some((tool) => tool.slug === override)) { - slackToolSlugCache.set(hintKey, override); - return override; - } - const resolveFromTools = (tools: Array<{ slug: string; name?: string; description?: string }>) => { - if (hint.fallbackSlugs && hint.fallbackSlugs.length > 0) { - const fallbackSet = new Set(hint.fallbackSlugs.map((slug) => slug.toLowerCase())); - const fallback = tools.find((tool) => fallbackSet.has(tool.slug.toLowerCase())); - if (fallback) return fallback.slug; - } - - const best = pickSlackTool(tools, hint); - return best?.slug || null; - }; - - const initialTools = slackToolCatalog; - - if (!initialTools.length) { - throw new Error("No Slack tools returned from Composio"); - } - - const initialSlug = resolveFromTools(initialTools); - if (initialSlug) { - slackToolSlugCache.set(hintKey, initialSlug); - return initialSlug; - } - - const allSlug = resolveFromTools(slackToolCatalog); - - if (!allSlug) { - const fallback = await listToolkitTools("slack", hint.search || null); - const fallbackSlug = resolveFromTools(fallback.items || []); - if (!fallbackSlug) { - throw new Error(`Unable to resolve Slack tool for ${hintKey}. Try slack-listAvailableTools.`); - } - slackToolSlugCache.set(hintKey, fallbackSlug); - return fallbackSlug; - } - - slackToolSlugCache.set(hintKey, allSlug); - return allSlug; -}; - const LLMPARSE_MIME_TYPES: Record = { '.pdf': 'application/pdf', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', @@ -1109,164 +880,6 @@ export const BuiltinTools: z.infer = { }, }, - // ============================================================================ - // Slack Tools (via Composio) - // ============================================================================ - - 'slack-checkConnection': { - description: 'Check if Slack is connected and ready to use. Use this before other Slack operations.', - inputSchema: z.object({}), - execute: async () => { - if (!isComposioConfigured()) { - return { - connected: false, - error: 'Composio is not configured. Please set up your Composio API key first.', - }; - } - const account = composioAccountsRepo.getAccount('slack'); - if (!account || account.status !== 'ACTIVE') { - return { - connected: false, - error: 'Slack is not connected. Please connect Slack from the settings.', - }; - } - return { - connected: true, - accountId: account.id, - }; - }, - }, - - 'slack-listAvailableTools': { - description: 'List available Slack tools from Composio. Use this to discover the correct tool slugs before executing actions. Call this first if other Slack tools return errors.', - inputSchema: z.object({ - search: z.string().optional().describe('Optional search query to filter tools (e.g., "message", "channel", "user")'), - }), - execute: async ({ search }: { search?: string }) => { - if (!isComposioConfigured()) { - return { success: false, error: 'Composio is not configured' }; - } - - try { - const result = await listToolkitTools('slack', search || null); - return { - success: true, - tools: result.items, - count: result.items.length, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }, - }, - - 'slack-executeAction': { - description: 'Execute a Slack action by its Composio tool slug. Use slack-listAvailableTools first to discover correct slugs. Pass the exact slug and the required input parameters.', - inputSchema: z.object({ - toolSlug: z.string().describe('The exact Composio tool slug (e.g., "SLACKBOT_SEND_A_MESSAGE_TO_A_SLACK_CHANNEL")'), - input: z.record(z.string(), z.unknown()).describe('Input parameters for the tool (check the tool description for required fields)'), - }), - execute: async ({ toolSlug, input }: { toolSlug: string; input: Record }) => { - const account = composioAccountsRepo.getAccount('slack'); - if (!account || account.status !== 'ACTIVE') { - return { success: false, error: 'Slack is not connected' }; - } - - try { - const result = await executeComposioAction(toolSlug, account.id, input); - return result; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }, - }, - - 'slack-sendMessage': { - description: 'Send a message to a Slack channel or user. Requires channel ID (starts with C for channels, D for DMs) or user ID.', - inputSchema: z.object({ - channel: z.string().describe('Channel ID (e.g., C01234567) or user ID (e.g., U01234567) to send the message to'), - text: z.string().describe('The message text to send'), - }), - execute: async ({ channel, text }: { channel: string; text: string }) => { - return executeSlackTool("sendMessage", { channel, text }); - }, - }, - - 'slack-listChannels': { - description: 'List Slack channels the user has access to. Returns channel IDs and names.', - inputSchema: z.object({ - types: z.string().optional().describe('Comma-separated channel types: public_channel, private_channel, mpim, im (default: public_channel,private_channel)'), - limit: z.number().optional().describe('Maximum number of channels to return (default: 100)'), - }), - execute: async ({ types, limit }: { types?: string; limit?: number }) => { - return executeSlackTool("listConversations", { - types: types || "public_channel,private_channel", - limit: limit ?? 100, - }); - }, - }, - - 'slack-getChannelHistory': { - description: 'Get recent messages from a Slack channel. Returns message history with timestamps and user IDs.', - inputSchema: z.object({ - channel: z.string().describe('Channel ID to get history from (e.g., C01234567)'), - limit: z.number().optional().describe('Maximum number of messages to return (default: 20, max: 100)'), - }), - execute: async ({ channel, limit }: { channel: string; limit?: number }) => { - return executeSlackTool("getConversationHistory", { - channel, - limit: limit !== undefined ? Math.min(limit, 100) : 20, - }); - }, - }, - - 'slack-listUsers': { - description: 'List users in the Slack workspace. Returns user IDs, names, and profile info.', - inputSchema: z.object({ - limit: z.number().optional().describe('Maximum number of users to return (default: 100)'), - }), - execute: async ({ limit }: { limit?: number }) => { - return executeSlackTool("listUsers", { limit: limit ?? 100 }); - }, - }, - - 'slack-getUserInfo': { - description: 'Get detailed information about a specific Slack user by their user ID.', - inputSchema: z.object({ - user: z.string().describe('User ID to get info for (e.g., U01234567)'), - }), - execute: async ({ user }: { user: string }) => { - return executeSlackTool("getUserInfo", { user }); - }, - }, - - 'slack-searchMessages': { - description: 'Search for messages in Slack. Find messages containing specific text across channels.', - inputSchema: z.object({ - query: z.string().describe('Search query text'), - count: z.number().optional().describe('Maximum number of results (default: 20)'), - }), - execute: async ({ query, count }: { query: string; count?: number }) => { - return executeSlackTool("searchMessages", { query, count: count ?? 20 }); - }, - }, - - 'slack-getDirectMessages': { - description: 'List direct message (DM) channels. Returns IDs of DM conversations with other users.', - inputSchema: z.object({ - limit: z.number().optional().describe('Maximum number of DM channels to return (default: 50)'), - }), - execute: async ({ limit }: { limit?: number }) => { - return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 }); - }, - }, - // ============================================================================ // Web Search (Brave Search API) // ============================================================================ diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 947f49a0..611bde45 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -4,6 +4,7 @@ import { getSecurityAllowList } from '../../config/security.js'; import { getExecutionShell } from '../assistant/runtime-context.js'; const execPromise = promisify(exec); + const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n|`|\$\(|\(|\))/; const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=.*/; const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'time', 'command']); diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index d02ca7e6..d7a9b9a0 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -14,6 +14,7 @@ import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/re import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js"; import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js"; import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; +import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js"; const container = createContainer({ injectionMode: InjectionMode.PROXY, @@ -37,6 +38,7 @@ container.register({ granolaConfigRepo: asClass(FSGranolaConfigRepo).singleton(), agentScheduleRepo: asClass(FSAgentScheduleRepo).singleton(), agentScheduleStateRepo: asClass(FSAgentScheduleStateRepo).singleton(), + slackConfigRepo: asClass(FSSlackConfigRepo).singleton(), }); export default container; \ No newline at end of file diff --git a/apps/x/packages/core/src/slack/repo.ts b/apps/x/packages/core/src/slack/repo.ts new file mode 100644 index 00000000..2e29b57a --- /dev/null +++ b/apps/x/packages/core/src/slack/repo.ts @@ -0,0 +1,41 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import { SlackConfig } from './types.js'; + +export interface ISlackConfigRepo { + getConfig(): Promise; + setConfig(config: SlackConfig): Promise; +} + +export class FSSlackConfigRepo implements ISlackConfigRepo { + private readonly configPath = path.join(WorkDir, 'config', 'slack.json'); + private readonly defaultConfig: SlackConfig = { enabled: false, workspaces: [] }; + + constructor() { + this.ensureConfigFile(); + } + + private async ensureConfigFile(): Promise { + try { + await fs.access(this.configPath); + } catch { + await fs.writeFile(this.configPath, JSON.stringify(this.defaultConfig, null, 2)); + } + } + + async getConfig(): Promise { + try { + const content = await fs.readFile(this.configPath, 'utf8'); + const parsed = JSON.parse(content); + return SlackConfig.parse(parsed); + } catch { + return this.defaultConfig; + } + } + + async setConfig(config: SlackConfig): Promise { + const validated = SlackConfig.parse(config); + await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2)); + } +} diff --git a/apps/x/packages/core/src/slack/types.ts b/apps/x/packages/core/src/slack/types.ts new file mode 100644 index 00000000..64917084 --- /dev/null +++ b/apps/x/packages/core/src/slack/types.ts @@ -0,0 +1,13 @@ +import z from "zod"; + +export const SlackWorkspace = z.object({ + url: z.string(), + name: z.string(), +}); +export type SlackWorkspace = z.infer; + +export const SlackConfig = z.object({ + enabled: z.boolean(), + workspaces: z.array(SlackWorkspace).default([]), +}); +export type SlackConfig = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c0bbbebe..2dc268f7 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -271,6 +271,29 @@ const ipcSchemas = { success: z.literal(true), }), }, + 'slack:getConfig': { + req: z.null(), + res: z.object({ + enabled: z.boolean(), + workspaces: z.array(z.object({ url: z.string(), name: z.string() })), + }), + }, + 'slack:setConfig': { + req: z.object({ + enabled: z.boolean(), + workspaces: z.array(z.object({ url: z.string(), name: z.string() })), + }), + res: z.object({ + success: z.literal(true), + }), + }, + 'slack:listWorkspaces': { + req: z.null(), + res: z.object({ + workspaces: z.array(z.object({ url: z.string(), name: z.string() })), + error: z.string().optional(), + }), + }, 'onboarding:getStatus': { req: z.null(), res: z.object({