mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-27 01:16:23 +02:00
feat: slack integration with composio
Allow users to ask copilot to use Slack on their behalf via Composio integration. Adds composio client, OAuth flow, slack skill with tool catalog, and UI for connecting Slack in onboarding and connectors popover. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bbe82c124d
commit
aa2a830f23
18 changed files with 2309 additions and 81 deletions
|
|
@ -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, '"')
|
||||
.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<void>
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
|
|
@ -46,7 +56,7 @@ export function createAuthServer(
|
|||
</head>
|
||||
<body>
|
||||
<h1 class="error">Authorization Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
<p>Error: ${escapeHtml(error)}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
|
|
@ -55,48 +65,28 @@ export function createAuthServer(
|
|||
return;
|
||||
}
|
||||
|
||||
if (code && state) {
|
||||
onCallback(code, state);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.success { color: #2e7d32; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">Authorization Successful</h1>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Error</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.error { color: #d32f2f; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">Invalid Request</h1>
|
||||
<p>Missing code or state parameter.</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
// 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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.success { color: #2e7d32; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">Authorization Successful</h1>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
|
|
|
|||
296
apps/x/apps/main/src/composio-handler.ts
Normal file
296
apps/x/apps/main/src/composio-handler.ts
Normal file
|
|
@ -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<string, {
|
||||
toolkitSlug: string;
|
||||
connectedAccountId: string;
|
||||
authConfigId: string;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 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<string, unknown>).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<string, unknown>
|
||||
): 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enter Composio API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://app.composio.dev/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
app.composio.dev/settings
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="composio-api-key">
|
||||
API Key
|
||||
</label>
|
||||
<Input
|
||||
id="composio-api-key"
|
||||
type="password"
|
||||
placeholder="Enter your Composio API key"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{tooltip ? (
|
||||
<Tooltip open={open ? false : undefined}>
|
||||
|
|
@ -368,10 +474,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
|
||||
{/* Fireflies */}
|
||||
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Team Communication Section - Slack */}
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{slackLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : slackConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisconnectSlack}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectSlack}
|
||||
disabled={slackConnecting}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{slackConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ComposioApiKeyModal
|
||||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={slackConnecting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>
|
||||
)
|
||||
|
||||
// Render Slack row
|
||||
const renderSlackRow = () => (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{slackLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : slackConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectSlack}
|
||||
disabled={slackConnecting}
|
||||
>
|
||||
{slackConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Step 0: Welcome
|
||||
const WelcomeStep = () => (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
|
|
@ -358,6 +488,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
{renderGranolaRow()}
|
||||
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
|
||||
</div>
|
||||
|
||||
{/* Team Communication Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
|
||||
</div>
|
||||
{renderSlackRow()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
|
|
@ -416,6 +554,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
<span>Granola (Local meeting notes)</span>
|
||||
</div>
|
||||
)}
|
||||
{slackConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
<span>Slack (Team communication)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -429,6 +573,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ComposioApiKeyModal
|
||||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={slackConnecting}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
|
||||
|
|
@ -442,5 +593,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
{currentStep === 2 && <CompletionStep />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue