diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 8309e3e6..b0b890c0 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -4,6 +4,16 @@ import { URL } from 'url'; const OAUTH_CALLBACK_PATH = '/oauth/callback'; const DEFAULT_PORT = 8080; +/** Escape HTML special characters to prevent XSS */ +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + export interface AuthServerResult { server: Server; port: number; @@ -15,7 +25,7 @@ export interface AuthServerResult { */ export function createAuthServer( port: number = DEFAULT_PORT, - onCallback: (code: string, state: string) => void + onCallback: (code: string, state: string) => void | Promise ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -46,7 +56,7 @@ export function createAuthServer(

Authorization Failed

-

Error: ${error}

+

Error: ${escapeHtml(error)}

You can close this window.

@@ -55,48 +65,28 @@ export function createAuthServer( return; } - if (code && state) { - onCallback(code, state); - - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - - - Authorization Successful - - - -

Authorization Successful

-

You can close this window.

- - - - `); - } else { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(` - - - - OAuth Error - - - -

Invalid Request

-

Missing code or state parameter.

-

You can close this window.

- - - - `); - } + // Handle callback - either traditional OAuth with code/state or Composio-style notification + // Composio callbacks may not have code/state, just a notification that the flow completed + onCallback(code || '', state || ''); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + + + Authorization Successful + + + +

Authorization Successful

+

You can close this window.

+ + + + `); } else { res.writeHead(404); res.end('Not Found'); diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts new file mode 100644 index 00000000..e5b25d1a --- /dev/null +++ b/apps/x/apps/main/src/composio-handler.ts @@ -0,0 +1,296 @@ +import { shell, BrowserWindow } from 'electron'; +import { createAuthServer } from './auth-server.js'; +import * as composioClient from '@x/core/dist/composio/client.js'; +import { composioAccountsRepo } from '@x/core/dist/composio/repo.js'; +import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js'; + +const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; + +// Store active OAuth flows +const activeFlows = new Map(); + +/** + * Emit Composio connection event to all renderer windows + */ +export function emitComposioEvent(event: { toolkitSlug: string; success: boolean; error?: string }): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('composio:didConnect', event); + } + } +} + +/** + * Check if Composio is configured with an API key + */ +export function isConfigured(): { configured: boolean } { + return { configured: composioClient.isConfigured() }; +} + +/** + * Set the Composio API key + */ +export function setApiKey(apiKey: string): { success: boolean; error?: string } { + try { + composioClient.setApiKey(apiKey); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to set API key', + }; + } +} + +/** + * Initiate OAuth connection for a toolkit + */ +export async function initiateConnection(toolkitSlug: string): Promise<{ + success: boolean; + redirectUrl?: string; + connectedAccountId?: string; + error?: string; +}> { + try { + console.log(`[Composio] Initiating connection for ${toolkitSlug}...`); + + // Check if already connected + if (composioAccountsRepo.isConnected(toolkitSlug)) { + return { success: true }; + } + + // Get toolkit to check auth schemes + const toolkit = await composioClient.getToolkit(toolkitSlug); + + // Check for managed OAuth2 + if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) { + return { + success: false, + error: `Toolkit ${toolkitSlug} does not support managed OAuth2`, + }; + } + + // Find or create managed OAuth2 auth config + const authConfigs = await composioClient.listAuthConfigs(toolkitSlug, null, true); + let authConfigId: string; + + const managedOauth2 = authConfigs.items.find( + cfg => cfg.auth_scheme === 'OAUTH2' && cfg.is_composio_managed + ); + + if (managedOauth2) { + authConfigId = managedOauth2.id; + } else { + // Create new managed auth config + const created = await composioClient.createAuthConfig({ + toolkit: { slug: toolkitSlug }, + auth_config: { + type: 'use_composio_managed_auth', + name: `rowboat-${toolkitSlug}`, + }, + }); + authConfigId = created.auth_config.id; + } + + // Create connected account with callback URL + const callbackUrl = REDIRECT_URI; + const response = await composioClient.createConnectedAccount({ + auth_config: { id: authConfigId }, + connection: { + user_id: 'rowboat-user', + callback_url: callbackUrl, + }, + }); + + const connectedAccountId = response.id; + + // Safely extract redirectUrl with type checking + const connectionVal = response.connectionData?.val; + const redirectUrl = typeof connectionVal === 'object' && connectionVal !== null && 'redirectUrl' in connectionVal + ? String((connectionVal as Record).redirectUrl) + : undefined; + + if (!redirectUrl) { + return { + success: false, + error: 'No redirect URL received from Composio', + }; + } + + // Store flow state + const flowKey = `${toolkitSlug}-${Date.now()}`; + activeFlows.set(flowKey, { + toolkitSlug, + connectedAccountId, + authConfigId, + }); + + // Save initial account state + const account: LocalConnectedAccount = { + id: connectedAccountId, + authConfigId, + status: 'INITIATED', + toolkitSlug, + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + }; + composioAccountsRepo.saveAccount(account); + + // Set up callback server + let cleanupTimeout: NodeJS.Timeout; + const { server } = await createAuthServer(8081, async (_code, _state) => { + // OAuth callback received - sync the account status + try { + const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); + composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); + + if (accountStatus.status === 'ACTIVE') { + emitComposioEvent({ toolkitSlug, success: true }); + } else { + emitComposioEvent({ + toolkitSlug, + success: false, + error: `Connection status: ${accountStatus.status}`, + }); + } + } catch (error) { + console.error('[Composio] Failed to sync account status:', error); + emitComposioEvent({ + toolkitSlug, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + activeFlows.delete(flowKey); + server.close(); + clearTimeout(cleanupTimeout); + } + }); + + // Timeout for abandoned flows (5 minutes) + cleanupTimeout = setTimeout(() => { + if (activeFlows.has(flowKey)) { + console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`); + activeFlows.delete(flowKey); + server.close(); + emitComposioEvent({ + toolkitSlug, + success: false, + error: 'OAuth flow timed out', + }); + } + }, 5 * 60 * 1000); + + // Open browser for OAuth + shell.openExternal(redirectUrl); + + return { + success: true, + redirectUrl, + connectedAccountId, + }; + } catch (error) { + console.error('[Composio] Connection initiation failed:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +/** + * Get connection status for a toolkit + */ +export async function getConnectionStatus(toolkitSlug: string): Promise<{ + isConnected: boolean; + status?: string; +}> { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account) { + return { isConnected: false }; + } + return { + isConnected: account.status === 'ACTIVE', + status: account.status, + }; +} + +/** + * Sync connection status with Composio API + */ +export async function syncConnection( + toolkitSlug: string, + connectedAccountId: string +): Promise<{ status: string }> { + try { + const accountStatus = await composioClient.getConnectedAccount(connectedAccountId); + composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status); + return { status: accountStatus.status }; + } catch (error) { + console.error('[Composio] Failed to sync connection:', error); + return { status: 'FAILED' }; + } +} + +/** + * Disconnect a toolkit + */ +export async function disconnect(toolkitSlug: string): Promise<{ success: boolean }> { + try { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (account) { + // Delete from Composio + await composioClient.deleteConnectedAccount(account.id); + // Delete local record + composioAccountsRepo.deleteAccount(toolkitSlug); + } + return { success: true }; + } catch (error) { + console.error('[Composio] Disconnect failed:', error); + // Still delete local record even if API call fails + composioAccountsRepo.deleteAccount(toolkitSlug); + return { success: true }; + } +} + +/** + * List connected toolkits + */ +export function listConnected(): { toolkits: string[] } { + return { toolkits: composioAccountsRepo.getConnectedToolkits() }; +} + +/** + * Execute a Composio action + */ +export async function executeAction( + actionSlug: string, + toolkitSlug: string, + input: Record +): Promise<{ success: boolean; data: unknown; error?: string }> { + try { + const account = composioAccountsRepo.getAccount(toolkitSlug); + if (!account || account.status !== 'ACTIVE') { + return { + success: false, + data: null, + error: `Toolkit ${toolkitSlug} is not connected`, + }; + } + + const result = await composioClient.executeAction(actionSlug, account.id, input); + return result; + } catch (error) { + console.error('[Composio] Action execution failed:', error); + return { + success: false, + data: null, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 03253c72..42690bda 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -17,11 +17,12 @@ import { bus } from '@x/core/dist/runs/bus.js'; import type { FSWatcher } from 'chokidar'; import fs from 'node:fs/promises'; import z from 'zod'; -import { RunEvent } from 'packages/shared/dist/runs.js'; +import { RunEvent } from '@x/shared/dist/runs.js'; import container from '@x/core/dist/di/container.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; +import * as composioHandler from './composio-handler.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -364,5 +365,30 @@ export function setupIpcHandlers() { 'auth:logout': async () => { return await logoutRowboat(); }, + // Composio integration handlers + 'composio:is-configured': async () => { + return composioHandler.isConfigured(); + }, + 'composio:set-api-key': async (_event, args) => { + return composioHandler.setApiKey(args.apiKey); + }, + 'composio:initiate-connection': async (_event, args) => { + return composioHandler.initiateConnection(args.toolkitSlug); + }, + 'composio:get-connection-status': async (_event, args) => { + return composioHandler.getConnectionStatus(args.toolkitSlug); + }, + 'composio:sync-connection': async (_event, args) => { + return composioHandler.syncConnection(args.toolkitSlug, args.connectedAccountId); + }, + 'composio:disconnect': async (_event, args) => { + return composioHandler.disconnect(args.toolkitSlug); + }, + 'composio:list-connected': async () => { + return composioHandler.listConnected(); + }, + 'composio:execute-action': async (_event, args) => { + return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); + }, }); } \ No newline at end of file diff --git a/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx b/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx new file mode 100644 index 00000000..826fe04f --- /dev/null +++ b/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx @@ -0,0 +1,94 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" + +interface ComposioApiKeyModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onSubmit: (apiKey: string) => void + isSubmitting?: boolean +} + +export function ComposioApiKeyModal({ + open, + onOpenChange, + onSubmit, + isSubmitting = false, +}: ComposioApiKeyModalProps) { + const [apiKey, setApiKey] = useState("") + + useEffect(() => { + if (!open) { + setApiKey("") + } + }, [open]) + + const trimmedApiKey = apiKey.trim() + const isValid = trimmedApiKey.length > 0 + + const handleSubmit = () => { + if (!isValid || isSubmitting) return + onSubmit(trimmedApiKey) + } + + return ( + + + + Enter Composio API Key + + Get your API key from{" "} + + app.composio.dev/settings + + + +
+ + setApiKey(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + autoFocus + /> +
+
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index ced6064f..1799ab75 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail } from "lucide-react" +import { Loader2, Mic, Mail, MessageSquare } from "lucide-react" import { Popover, @@ -17,6 +17,7 @@ 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 { toast } from "sonner" interface ProviderState { @@ -40,6 +41,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) + // Composio/Slack state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [slackConnected, setSlackConnected] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackConnecting, setSlackConnecting] = useState(false) + // Load available providers on mount useEffect(() => { async function loadProviders() { @@ -86,11 +93,89 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) + // Load Slack connection status + const refreshSlackStatus = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) + setSlackConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Slack status:', error) + setSlackConnected(false) + } finally { + setSlackLoading(false) + } + }, []) + + // Connect to Slack via Composio + const startSlackConnect = useCallback(async () => { + 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) + } + // 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) + } + }, []) + + // 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 () => { + 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') + } + } catch (error) { + console.error('Failed to disconnect from Slack:', error) + toast.error('Failed to disconnect from Slack') + } finally { + setSlackLoading(false) + } + }, []) + // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { // Refresh Granola refreshGranolaConfig() + // Refresh Slack status + refreshSlackStatus() + // Refresh OAuth providers if (providers.length === 0) return @@ -117,7 +202,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig]) + }, [providers, refreshGranolaConfig, refreshSlackStatus]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -161,6 +246,26 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) 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 + }, []) + // Connect to a provider const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -289,6 +394,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } return ( + <> {tooltip ? ( @@ -368,10 +474,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) {/* Fireflies */} {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + + + + {/* Team Communication Section - Slack */} +
+ Team Communication +
+ + {/* Slack */} +
+
+
+ +
+
+ Slack + {slackLoading ? ( + Checking... + ) : ( + + Send messages and view channels + + )} +
+
+
+ {slackLoading ? ( + + ) : slackConnected ? ( + + ) : ( + + )} +
+
)}
+ + ) } diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 48119934..074ad645 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useState, useEffect, useCallback } from "react" -import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react" +import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react" import { Dialog, @@ -14,6 +14,7 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { toast } from "sonner" interface ProviderState { @@ -41,6 +42,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) + // Composio/Slack state + const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [slackConnected, setSlackConnected] = useState(false) + const [slackLoading, setSlackLoading] = useState(true) + const [slackConnecting, setSlackConnecting] = useState(false) + // Track connected providers for the completion step const connectedProviders = Object.entries(providerStates) .filter(([, state]) => state.isConnected) @@ -94,11 +101,70 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Load Slack connection status + const refreshSlackStatus = useCallback(async () => { + try { + setSlackLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' }) + setSlackConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Slack status:', error) + setSlackConnected(false) + } finally { + setSlackLoading(false) + } + }, []) + + // Start Slack connection + const startSlackConnect = useCallback(async () => { + 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) + } + // 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) + } + }, []) + + // 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) => { + 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]) + // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { // Refresh Granola refreshGranolaConfig() + // Refresh Slack status + refreshSlackStatus() + // Refresh OAuth providers if (providers.length === 0) return @@ -125,7 +191,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig]) + }, [providers, refreshGranolaConfig, refreshSlackStatus]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -159,6 +225,26 @@ 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 + }, []) + // Connect to a provider const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -291,6 +377,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) + // Render Slack row + const renderSlackRow = () => ( +
+
+
+ +
+
+ Slack + {slackLoading ? ( + Checking... + ) : ( + + Send messages and view channels + + )} +
+
+
+ {slackLoading ? ( + + ) : slackConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Step 0: Welcome const WelcomeStep = () => (
@@ -358,6 +488,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { {renderGranolaRow()} {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')}
+ + {/* Team Communication Section */} +
+
+ Team Communication +
+ {renderSlackRow()} +
)} @@ -375,7 +513,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step 2: Completion const CompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected return (
@@ -416,6 +554,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Granola (Local meeting notes)
)} + {slackConnected && ( +
+ + Slack (Team communication) +
+ )} @@ -429,6 +573,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } return ( + <> + {}}> } + ) } diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 2633e4d0..d30c199c 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -12,6 +12,7 @@ "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", + "@composio/core": "^0.1.48", "@ai-sdk/openai-compatible": "^1.0.27", "@ai-sdk/provider": "^2.0.0", "@google-cloud/local-auth": "^3.0.1", diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 3785c493..57f3a446 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -30,6 +30,8 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects, **Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base. +**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending. + ## Memory That Compounds Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic. @@ -159,6 +161,7 @@ When a user asks for ANY task that might require external capabilities (web sear - \`analyzeAgent\` - Agent analysis - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`loadSkill\` - Skill loading +- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them. **Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 391f9523..6ef19e8d 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -7,18 +7,17 @@ import draftEmailsSkill from "./draft-emails/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; +import slackSkill from "./slack/skill.js"; import workflowAuthoringSkill from "./workflow-authoring/skill.js"; import createPresentationsSkill from "./create-presentations/skill.js"; import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; -const CURRENT_FILE = fileURLToPath(import.meta.url); -const CURRENT_DIR = path.dirname(CURRENT_FILE); +const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)); const CATALOG_PREFIX = "src/application/assistant/skills"; type SkillDefinition = { - id: string; + id: string; // Also used as folder name title: string; - folder: string; summary: string; content: string; }; @@ -33,70 +32,66 @@ const definitions: SkillDefinition[] = [ { id: "create-presentations", title: "Create Presentations", - folder: "create-presentations", summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.", content: createPresentationsSkill, }, { id: "doc-collab", title: "Document Collaboration", - folder: "doc-collab", summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.", content: docCollabSkill, }, { id: "draft-emails", title: "Draft Emails", - folder: "draft-emails", summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.", content: draftEmailsSkill, }, { id: "meeting-prep", title: "Meeting Prep", - folder: "meeting-prep", summary: "Prepare for meetings by gathering context about attendees from the knowledge base.", content: meetingPrepSkill, }, { id: "organize-files", title: "Organize Files", - folder: "organize-files", summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.", content: organizeFilesSkill, }, + { + id: "slack", + title: "Slack Integration", + summary: "Send Slack messages, view channel history, search conversations, find users, and manage team communication.", + content: slackSkill, + }, { id: "workflow-authoring", title: "Workflow Authoring", - folder: "workflow-authoring", summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.", content: workflowAuthoringSkill, }, { id: "builtin-tools", title: "Builtin Tools Reference", - folder: "builtin-tools", summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.", content: builtinToolsSkill, }, { id: "mcp-integration", title: "MCP Integration Guidance", - folder: "mcp-integration", summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.", content: mcpIntegrationSkill, }, { id: "deletion-guardrails", title: "Deletion Guardrails", - folder: "deletion-guardrails", summary: "Following the confirmation process before removing workflows or agents and their dependencies.", content: deletionGuardrailsSkill, }, { id: "workflow-run-ops", title: "Workflow Run Operations", - folder: "workflow-run-ops", summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.", content: workflowRunOpsSkill, }, @@ -104,7 +99,7 @@ const definitions: SkillDefinition[] = [ const skillEntries = definitions.map((definition) => ({ ...definition, - catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`, + catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`, })); const catalogSections = skillEntries.map((entry) => [ @@ -154,8 +149,8 @@ const registerAliasVariants = (alias: string, entry: ResolvedSkill) => { }; for (const entry of skillEntries) { - const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts"); - const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js"); + const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts"); + const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js"); const resolvedEntry: ResolvedSkill = { id: entry.id, catalogPath: entry.catalogPath, @@ -164,14 +159,13 @@ for (const entry of skillEntries) { const baseAliases = [ entry.id, - entry.folder, - `${entry.folder}/skill`, - `${entry.folder}/skill.ts`, - `${entry.folder}/skill.js`, - `skills/${entry.folder}/skill.ts`, - `skills/${entry.folder}/skill.js`, - `${CATALOG_PREFIX}/${entry.folder}/skill.ts`, - `${CATALOG_PREFIX}/${entry.folder}/skill.js`, + `${entry.id}/skill`, + `${entry.id}/skill.ts`, + `${entry.id}/skill.js`, + `skills/${entry.id}/skill.ts`, + `skills/${entry.id}/skill.js`, + `${CATALOG_PREFIX}/${entry.id}/skill.ts`, + `${CATALOG_PREFIX}/${entry.id}/skill.js`, absoluteTs, absoluteJs, ]; diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts new file mode 100644 index 00000000..140c6ab7 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts @@ -0,0 +1,121 @@ +import { slackToolCatalogMarkdown } from "./tool-catalog.js"; + +const skill = String.raw` +# Slack Integration Skill + +You can interact with Slack to help users communicate with their team. This includes sending messages, viewing channel history, finding users, and searching conversations. + +## Prerequisites + +Before using Slack tools, ALWAYS check if Slack is connected: +\`\`\` +slack-checkConnection({}) +\`\`\` + +If not connected, inform the user they need to connect Slack from the settings/onboarding. + +## 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({}) +\`\`\` + +### 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. + +## Common Tasks + +### 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 })\` + +### Send a Message +1. Draft the message and show it to the user +2. ONLY after user approval, send using \`slack-sendMessage\` + +### Search Messages +1. Use \`slack-searchMessages({ query: "...", count: 20 })\` + +## 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 +`; + +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 new file mode 100644 index 00000000..d720c9d9 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts @@ -0,0 +1,117 @@ +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 49cb7d46..9411eb41 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -11,6 +11,9 @@ 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"; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -23,6 +26,232 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({ }), })); +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; +}; + export const BuiltinTools: z.infer = { loadSkill: { description: "Load a Rowboat skill definition into context by fetching its guidance string", @@ -673,4 +902,162 @@ 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 }); + }, + }, }; diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts new file mode 100644 index 00000000..5a1004ce --- /dev/null +++ b/apps/x/packages/core/src/composio/client.ts @@ -0,0 +1,358 @@ +import { z } from "zod"; +import fs from "fs"; +import path from "path"; +import { Composio } from "@composio/core"; +import { WorkDir } from "../config/config.js"; +import { + ZAuthConfig, + ZConnectedAccount, + ZCreateAuthConfigRequest, + ZCreateAuthConfigResponse, + ZCreateConnectedAccountRequest, + ZCreateConnectedAccountResponse, + ZDeleteOperationResponse, + ZErrorResponse, + ZExecuteActionResponse, + ZListResponse, + ZToolkit, +} from "./types.js"; + +const BASE_URL = 'https://backend.composio.dev/api/v3'; +const CONFIG_FILE = path.join(WorkDir, 'config', 'composio.json'); + +// Composio SDK client (lazily initialized) +let composioClient: Composio | null = null; + +function getComposioClient(): Composio { + if (composioClient) { + return composioClient; + } + + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + composioClient = new Composio({ apiKey }); + return composioClient; +} + +function resetComposioClient(): void { + composioClient = null; +} + +/** + * Configuration schema for Composio + */ +const ZComposioConfig = z.object({ + apiKey: z.string().optional(), +}); + +type ComposioConfig = z.infer; + +/** + * Load Composio configuration + */ +function loadConfig(): ComposioConfig { + try { + if (fs.existsSync(CONFIG_FILE)) { + const data = fs.readFileSync(CONFIG_FILE, 'utf-8'); + return ZComposioConfig.parse(JSON.parse(data)); + } + } catch (error) { + console.error('[Composio] Failed to load config:', error); + } + return {}; +} + +/** + * Save Composio configuration + */ +export function saveConfig(config: ComposioConfig): void { + const dir = path.dirname(CONFIG_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +/** + * Get the Composio API key + */ +export function getApiKey(): string | null { + const config = loadConfig(); + return config.apiKey || process.env.COMPOSIO_API_KEY || null; +} + +/** + * Set the Composio API key + */ +export function setApiKey(apiKey: string): void { + const config = loadConfig(); + config.apiKey = apiKey; + saveConfig(config); + resetComposioClient(); +} + +/** + * Check if Composio is configured + */ +export function isConfigured(): boolean { + return !!getApiKey(); +} + +/** + * Make an API call to Composio + */ +export async function composioApiCall( + schema: T, + url: string, + options: RequestInit = {}, +): Promise> { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + console.log(`[Composio] ${options.method || 'GET'} ${url}`); + const startTime = Date.now(); + + try { + const response = await fetch(url, { + ...options, + headers: { + ...options.headers, + "x-api-key": apiKey, + ...(options.method === 'POST' ? { "Content-Type": "application/json" } : {}), + }, + }); + + const duration = Date.now() - startTime; + console.log(`[Composio] Response in ${duration}ms`); + + const contentType = response.headers.get('content-type') || ''; + const rawText = await response.text(); + + if (!response.ok || !contentType.includes('application/json')) { + console.error(`[Composio] Error response:`, { + status: response.status, + statusText: response.statusText, + contentType, + preview: rawText.slice(0, 200), + }); + } + + if (!response.ok) { + throw new Error(`Composio API error: ${response.status} ${response.statusText}`); + } + + if (!contentType.includes('application/json')) { + throw new Error('Expected JSON response'); + } + + let data: unknown; + try { + data = JSON.parse(rawText); + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + throw new Error(`Failed to parse response: ${message}`); + } + + if (typeof data === 'object' && data !== null && 'error' in data) { + const parsedError = ZErrorResponse.parse(data); + throw new Error(`Composio error (${parsedError.error.error_code}): ${parsedError.error.message}`); + } + + return schema.parse(data); + } catch (error) { + console.error(`[Composio] Error:`, error); + throw error; + } +} + +/** + * List available toolkits + */ +export async function listToolkits(cursor: string | null = null): Promise>>> { + const url = new URL(`${BASE_URL}/toolkits`); + url.searchParams.set("sort_by", "usage"); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + return composioApiCall(ZListResponse(ZToolkit), url.toString()); +} + +/** + * Get a specific toolkit + */ +export async function getToolkit(toolkitSlug: string): Promise> { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + const url = `${BASE_URL}/toolkits/${toolkitSlug}`; + console.log(`[Composio] GET ${url}`); + + const response = await fetch(url, { + headers: { "x-api-key": apiKey }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch toolkit: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + const no_auth = data.composio_managed_auth_schemes?.includes('NO_AUTH') || + data.auth_config_details?.some((config: { mode: string }) => config.mode === 'NO_AUTH') || + false; + + return ZToolkit.parse({ + ...data, + no_auth, + meta: data.meta || { description: '', logo: '', tools_count: 0, triggers_count: 0 }, + auth_schemes: data.auth_schemes || [], + composio_managed_auth_schemes: data.composio_managed_auth_schemes || [], + }); +} + +/** + * List auth configs for a toolkit + */ +export async function listAuthConfigs( + toolkitSlug: string, + cursor: string | null = null, + managedOnly: boolean = false +): Promise>>> { + const url = new URL(`${BASE_URL}/auth_configs`); + url.searchParams.set("toolkit_slug", toolkitSlug); + if (cursor) { + url.searchParams.set("cursor", cursor); + } + if (managedOnly) { + url.searchParams.set("is_composio_managed", "true"); + } + return composioApiCall(ZListResponse(ZAuthConfig), url.toString()); +} + +/** + * Create an auth config + */ +export async function createAuthConfig( + request: z.infer +): Promise> { + const url = new URL(`${BASE_URL}/auth_configs`); + return composioApiCall(ZCreateAuthConfigResponse, url.toString(), { + method: 'POST', + body: JSON.stringify(request), + }); +} + +/** + * Delete an auth config + */ +export async function deleteAuthConfig(authConfigId: string): Promise> { + const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`); + return composioApiCall(ZDeleteOperationResponse, url.toString(), { + method: 'DELETE', + }); +} + +/** + * Create a connected account + */ +export async function createConnectedAccount( + request: z.infer +): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts`); + return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), { + method: 'POST', + body: JSON.stringify(request), + }); +} + +/** + * Get a connected account + */ +export async function getConnectedAccount(connectedAccountId: string): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`); + return composioApiCall(ZConnectedAccount, url.toString()); +} + +/** + * Delete a connected account + */ +export async function deleteConnectedAccount(connectedAccountId: string): Promise> { + const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`); + return composioApiCall(ZDeleteOperationResponse, url.toString(), { + method: 'DELETE', + }); +} + +/** + * List available tools for a toolkit + */ +export async function listToolkitTools( + toolkitSlug: string, + searchQuery: string | null = null, +): Promise<{ items: Array<{ slug: string; name: string; description: string }> }> { + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Composio API key not configured'); + } + + const url = new URL(`${BASE_URL}/tools`); + url.searchParams.set('toolkit_slug', toolkitSlug); + url.searchParams.set('limit', '200'); + if (searchQuery) { + url.searchParams.set('search', searchQuery); + } + + console.log(`[Composio] Listing tools for toolkit: ${toolkitSlug}`); + + const response = await fetch(url.toString(), { + headers: { "x-api-key": apiKey }, + }); + + if (!response.ok) { + throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`); + } + + const data = await response.json() as { items?: Array> }; + + return { + items: (data.items || []).map((item) => ({ + slug: String(item.slug ?? ''), + name: String(item.name ?? ''), + description: String(item.description ?? ''), + })), + }; +} + +/** + * Execute a tool action using Composio SDK + */ +export async function executeAction( + actionSlug: string, + connectedAccountId: string, + input: Record +): Promise> { + console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`); + + try { + const client = getComposioClient(); + const result = await client.tools.execute(actionSlug, { + userId: connectedAccountId, + arguments: input, + connectedAccountId, + }); + + console.log(`[Composio] Action completed successfully`); + return { success: true, data: result.data }; + } catch (error) { + console.error(`[Composio] Action execution failed:`, error); + const message = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, data: null, error: message }; + } +} diff --git a/apps/x/packages/core/src/composio/index.ts b/apps/x/packages/core/src/composio/index.ts new file mode 100644 index 00000000..a4636f99 --- /dev/null +++ b/apps/x/packages/core/src/composio/index.ts @@ -0,0 +1,5 @@ +// Composio integration for Rowboat X + +export * from './types.js'; +export * from './client.js'; +export * from './repo.js'; diff --git a/apps/x/packages/core/src/composio/repo.ts b/apps/x/packages/core/src/composio/repo.ts new file mode 100644 index 00000000..73f8f3eb --- /dev/null +++ b/apps/x/packages/core/src/composio/repo.ts @@ -0,0 +1,140 @@ +import fs from "fs"; +import path from "path"; +import { z } from "zod"; +import { WorkDir } from "../config/config.js"; +import { ZLocalConnectedAccount, LocalConnectedAccount, ConnectedAccountStatus } from "./types.js"; + +const ACCOUNTS_FILE = path.join(WorkDir, 'data', 'composio', 'connected_accounts.json'); + +/** + * Schema for the connected accounts storage file + */ +const ZConnectedAccountsStorage = z.object({ + accounts: z.record(z.string(), ZLocalConnectedAccount), // keyed by toolkit slug +}); + +type ConnectedAccountsStorage = z.infer; + +/** + * Interface for Composio accounts repository + */ +export interface IComposioAccountsRepo { + getAccount(toolkitSlug: string): LocalConnectedAccount | null; + getAllAccounts(): Record; + saveAccount(account: LocalConnectedAccount): void; + updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean; + deleteAccount(toolkitSlug: string): void; + isConnected(toolkitSlug: string): boolean; + getConnectedToolkits(): string[]; +} + +/** + * Ensure the storage directory exists + */ +function ensureStorageDir(): void { + const dir = path.dirname(ACCOUNTS_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Load connected accounts from storage + */ +function loadAccounts(): ConnectedAccountsStorage { + try { + if (fs.existsSync(ACCOUNTS_FILE)) { + const data = fs.readFileSync(ACCOUNTS_FILE, 'utf-8'); + return ZConnectedAccountsStorage.parse(JSON.parse(data)); + } + } catch (error) { + console.error('[ComposioRepo] Failed to load accounts:', error); + } + return { accounts: {} }; +} + +/** + * Save connected accounts to storage + */ +function saveAccounts(storage: ConnectedAccountsStorage): void { + ensureStorageDir(); + fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(storage, null, 2)); +} + +/** + * Composio Connected Accounts Repository + * Stores connected account information locally + */ +export class ComposioAccountsRepo implements IComposioAccountsRepo { + /** + * Get a connected account by toolkit slug + */ + getAccount(toolkitSlug: string): LocalConnectedAccount | null { + const storage = loadAccounts(); + return storage.accounts[toolkitSlug] || null; + } + + /** + * Get all connected accounts + */ + getAllAccounts(): Record { + const storage = loadAccounts(); + return storage.accounts; + } + + /** + * Save a connected account + */ + saveAccount(account: LocalConnectedAccount): void { + const storage = loadAccounts(); + storage.accounts[account.toolkitSlug] = account; + saveAccounts(storage); + } + + /** + * Update account status + * @returns true if account was found and updated, false if account doesn't exist + */ + updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean { + const storage = loadAccounts(); + const account = storage.accounts[toolkitSlug]; + if (!account) { + console.warn(`[ComposioRepo] Cannot update status: account '${toolkitSlug}' not found`); + return false; + } + account.status = status; + account.lastUpdatedAt = new Date().toISOString(); + saveAccounts(storage); + return true; + } + + /** + * Delete a connected account + */ + deleteAccount(toolkitSlug: string): void { + const storage = loadAccounts(); + delete storage.accounts[toolkitSlug]; + saveAccounts(storage); + } + + /** + * Check if a toolkit is connected + */ + isConnected(toolkitSlug: string): boolean { + const account = this.getAccount(toolkitSlug); + return account?.status === 'ACTIVE'; + } + + /** + * Get list of connected toolkit slugs + */ + getConnectedToolkits(): string[] { + const storage = loadAccounts(); + return Object.entries(storage.accounts) + .filter(([, account]) => account.status === 'ACTIVE') + .map(([slug]) => slug); + } +} + +// Export singleton instance +export const composioAccountsRepo = new ComposioAccountsRepo(); diff --git a/apps/x/packages/core/src/composio/types.ts b/apps/x/packages/core/src/composio/types.ts new file mode 100644 index 00000000..e2cbaf56 --- /dev/null +++ b/apps/x/packages/core/src/composio/types.ts @@ -0,0 +1,237 @@ +import { z } from "zod"; + +/** + * Composio authentication schemes + */ +export const ZAuthScheme = z.enum([ + 'API_KEY', + 'BASIC', + 'BASIC_WITH_JWT', + 'BEARER_TOKEN', + 'COMPOSIO_LINK', + 'SERVICE_ACCOUNT', + 'GOOGLE_SERVICE_ACCOUNT', + 'NO_AUTH', + 'OAUTH1', + 'OAUTH2', +]); + +/** + * Connected account status + */ +export const ZConnectedAccountStatus = z.enum([ + 'INITIALIZING', + 'INITIATED', + 'ACTIVE', + 'FAILED', + 'EXPIRED', + 'INACTIVE', +]); + +/** + * Toolkit metadata + */ +export const ZToolkitMeta = z.object({ + description: z.string(), + logo: z.string(), + tools_count: z.number(), + triggers_count: z.number(), +}); + +/** + * Toolkit schema + */ +export const ZToolkit = z.object({ + slug: z.string(), + name: z.string(), + meta: ZToolkitMeta, + no_auth: z.boolean(), + auth_schemes: z.array(ZAuthScheme), + composio_managed_auth_schemes: z.array(ZAuthScheme), +}); + +/** + * Tool schema + */ +export const ZTool = z.object({ + slug: z.string(), + name: z.string(), + description: z.string(), + toolkit: z.object({ + slug: z.string(), + name: z.string(), + logo: z.string(), + }), + input_parameters: z.object({ + type: z.literal('object'), + properties: z.record(z.string(), z.unknown()), + required: z.array(z.string()).optional(), + additionalProperties: z.boolean().optional(), + }), + no_auth: z.boolean(), +}); + +/** + * Auth config schema + */ +export const ZAuthConfig = z.object({ + id: z.string(), + is_composio_managed: z.boolean(), + auth_scheme: ZAuthScheme, +}); + +/** + * Credentials schema + */ +export const ZCredentials = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])); + +/** + * Create auth config request + */ +export const ZCreateAuthConfigRequest = z.object({ + toolkit: z.object({ + slug: z.string(), + }), + auth_config: z.discriminatedUnion('type', [ + z.object({ + type: z.literal('use_composio_managed_auth'), + name: z.string().optional(), + credentials: ZCredentials.optional(), + }), + z.object({ + type: z.literal('use_custom_auth'), + authScheme: ZAuthScheme, + credentials: ZCredentials, + name: z.string().optional(), + }), + ]).optional(), +}); + +/** + * Create auth config response + */ +export const ZCreateAuthConfigResponse = z.object({ + toolkit: z.object({ + slug: z.string(), + }), + auth_config: ZAuthConfig, +}); + +/** + * Connection data schema + */ +export const ZConnectionData = z.object({ + authScheme: ZAuthScheme, + val: z.record(z.string(), z.unknown()) + .and(z.object({ + status: ZConnectedAccountStatus, + })), +}); + +/** + * Create connected account request + */ +export const ZCreateConnectedAccountRequest = z.object({ + auth_config: z.object({ + id: z.string(), + }), + connection: z.object({ + state: ZConnectionData.optional(), + user_id: z.string().optional(), + callback_url: z.string().optional(), + }), +}); + +/** + * Create connected account response + */ +export const ZCreateConnectedAccountResponse = z.object({ + id: z.string(), + connectionData: ZConnectionData, +}); + +/** + * Connected account schema + */ +export const ZConnectedAccount = z.object({ + id: z.string(), + toolkit: z.object({ + slug: z.string(), + }), + auth_config: z.object({ + id: z.string(), + is_composio_managed: z.boolean(), + is_disabled: z.boolean(), + }), + status: ZConnectedAccountStatus, +}); + +/** + * Error response schema + */ +export const ZErrorResponse = z.object({ + error: z.object({ + message: z.string(), + error_code: z.number(), + suggested_fix: z.string().nullable(), + errors: z.array(z.string()).nullable(), + }), +}); + +/** + * Delete operation response + */ +export const ZDeleteOperationResponse = z.object({ + success: z.boolean(), +}); + +/** + * Generic list response + */ +export const ZListResponse = (schema: T) => z.object({ + items: z.array(schema), + next_cursor: z.string().nullable(), + total_pages: z.number(), + current_page: z.number(), + total_items: z.number(), +}); + +/** + * Execute action request + */ +export const ZExecuteActionRequest = z.object({ + action: z.string(), + connected_account_id: z.string(), + input: z.record(z.string(), z.unknown()), +}); + +/** + * Execute action response + */ +export const ZExecuteActionResponse = z.object({ + success: z.boolean(), + data: z.unknown(), + error: z.string().optional(), +}); + +/** + * Local connected account storage schema + */ +export const ZLocalConnectedAccount = z.object({ + id: z.string(), + authConfigId: z.string(), + status: ZConnectedAccountStatus, + toolkitSlug: z.string(), + createdAt: z.string(), + lastUpdatedAt: z.string(), +}); + +export type AuthScheme = z.infer; +export type ConnectedAccountStatus = z.infer; +export type Toolkit = z.infer; +export type Tool = z.infer; +export type AuthConfig = z.infer; +export type ConnectedAccount = z.infer; +export type LocalConnectedAccount = z.infer; +export type ExecuteActionRequest = z.infer; +export type ExecuteActionResponse = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index e85247fe..4c83ef56 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -261,19 +261,88 @@ const ipcSchemas = { error: z.string().optional(), }), }, + // Composio integration channels + 'composio:is-configured': { + req: z.null(), + res: z.object({ + configured: z.boolean(), + }), + }, + 'composio:set-api-key': { + req: z.object({ + apiKey: z.string(), + }), + res: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + }, 'auth:logout': { req: z.null(), res: z.object({ success: z.boolean(), }), }, - 'auth:didAuthenticate': { + 'composio:initiate-connection': { req: z.object({ - isAuthenticated: z.boolean(), - user: z.object({ - email: z.string(), - name: z.string().optional(), - }).nullable(), + toolkitSlug: z.string(), + }), + res: z.object({ + success: z.boolean(), + redirectUrl: z.string().optional(), + connectedAccountId: z.string().optional(), + error: z.string().optional(), + }), + }, + 'composio:get-connection-status': { + req: z.object({ + toolkitSlug: z.string(), + }), + res: z.object({ + isConnected: z.boolean(), + status: z.string().optional(), + }), + }, + 'composio:sync-connection': { + req: z.object({ + toolkitSlug: z.string(), + connectedAccountId: z.string(), + }), + res: z.object({ + status: z.string(), + }), + }, + 'composio:disconnect': { + req: z.object({ + toolkitSlug: z.string(), + }), + res: z.object({ + success: z.boolean(), + }), + }, + 'composio:list-connected': { + req: z.null(), + res: z.object({ + toolkits: z.array(z.string()), + }), + }, + 'composio:execute-action': { + req: z.object({ + actionSlug: z.string(), + toolkitSlug: z.string(), + input: z.record(z.string(), z.unknown()), + }), + res: z.object({ + success: z.boolean(), + data: z.unknown(), + error: z.string().optional(), + }), + }, + 'composio:didConnect': { + req: z.object({ + toolkitSlug: z.string(), + success: z.boolean(), + error: z.string().optional(), }), res: z.null(), }, diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 5ea5f4d3..31db92c0 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: '@ai-sdk/provider': specifier: ^2.0.0 version: 2.0.1 + '@composio/core': + specifier: ^0.1.48 + version: 0.1.55(zod@4.2.1) '@google-cloud/local-auth': specifier: ^3.0.1 version: 3.0.1(encoding@0.1.13) @@ -674,6 +677,19 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@composio/client@0.1.0-alpha.37': + resolution: {integrity: sha512-2YzXiRXlxqOgEz7nEh1aKdJ9vwQRb69+RX6UH+LTDmuTwW+pOjI2qbhIBxTbP2z9e2SyHVG0/AtMxc9tR5jU0w==} + + '@composio/core@0.1.55': + resolution: {integrity: sha512-GLSWTS/gZeycQ7W2wSZQ21DKV+LC6WTAilhSj+JG9Apslx3re9luF1Lyblm4UMSf4DzYWKOrtipwQOaeg7bmTg==} + peerDependencies: + zod: '>=3.25.76 <5' + + '@composio/json-schema-to-zod@0.1.16': + resolution: {integrity: sha512-vu6RUQTWDW/0wLDWsQHtWJ97JsnMjA1olTQwVD88BAtKZTdDLuzjnGRxSllxp5JG/GntnovUphraZWcWfe80eQ==} + peerDependencies: + zod: '>=3.25.76 <5' + '@electron-forge/cli@7.11.1': resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==} engines: {node: '>= 16.4.0'} @@ -5375,6 +5391,18 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + openai@5.23.2: + resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openid-client@6.8.1: resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} @@ -5692,6 +5720,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pusher-js@8.4.0: + resolution: {integrity: sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==} + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -6310,6 +6341,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6469,6 +6503,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -7374,6 +7412,27 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@composio/client@0.1.0-alpha.37': {} + + '@composio/core@0.1.55(zod@4.2.1)': + dependencies: + '@composio/client': 0.1.0-alpha.37 + '@composio/json-schema-to-zod': 0.1.16(zod@4.2.1) + '@types/json-schema': 7.0.15 + chalk: 4.1.2 + openai: 5.23.2(zod@4.2.1) + pusher-js: 8.4.0 + semver: 7.7.3 + uuid: 13.0.0 + zod: 4.2.1 + zod-to-json-schema: 3.25.1(zod@4.2.1) + transitivePeerDependencies: + - ws + + '@composio/json-schema-to-zod@0.1.16(zod@4.2.1)': + dependencies: + zod: 4.2.1 + '@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.24.2)': dependencies: '@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.24.2) @@ -13026,6 +13085,10 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@5.23.2(zod@4.2.1): + optionalDependencies: + zod: 4.2.1 + openid-client@6.8.1: dependencies: jose: 6.1.3 @@ -13386,6 +13449,10 @@ snapshots: punycode@2.3.1: {} + pusher-js@8.4.0: + dependencies: + tweetnacl: 1.0.3 + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -14110,6 +14177,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -14269,6 +14338,8 @@ snapshots: uuid@11.1.0: {} + uuid@13.0.0: {} + uuid@9.0.1: {} validate-npm-package-license@3.0.4: