diff --git a/electron/main.js b/electron/main.js index fc30398d..9cbea494 100644 --- a/electron/main.js +++ b/electron/main.js @@ -529,3 +529,93 @@ ipcMain.handle('open-external', (_event, url) => { shell.openExternal(url); } }); + +ipcMain.handle('connect-slack', async () => { + const win = new BrowserWindow({ + width: 900, + height: 750, + title: 'Sign in to Slack', + parent: mainWindow || undefined, + modal: false, + autoHideMenuBar: true, + webPreferences: { + partition: 'persist:slack-auth', + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + + // Override the global window-open handler so new tabs/windows from Slack + // (e.g. workspace redirects) navigate this popup instead of getting + // hijacked into a dashboard browser card. + win.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://') || url.startsWith('https://')) { + win.loadURL(url).catch(() => {}); + } + return { action: 'deny' }; + }); + + // Block slack:// deep-link attempts (they'd try to launch the native app + // and fail). Slack always falls through to a web URL after the deep link + // fails, so just swallow these. + win.webContents.on('will-navigate', (event, url) => { + if (url.startsWith('slack://')) { + event.preventDefault(); + } + }); + + try { + await win.loadURL('https://app.slack.com/signin'); + } catch (err) { + if (!win.isDestroyed()) win.close(); + throw new Error(`Failed to load Slack: ${err.message}`); + } + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn, value) => { + if (settled) return; + settled = true; + clearInterval(pollInterval); + clearTimeout(timeoutHandle); + if (!win.isDestroyed()) win.close(); + fn(value); + }; + + win.on('closed', () => { + if (!settled) { + settled = true; + clearInterval(pollInterval); + clearTimeout(timeoutHandle); + reject(new Error('Sign-in window was closed')); + } + }); + + const pollInterval = setInterval(async () => { + if (win.isDestroyed()) return; + try { + const token = await win.webContents.executeJavaScript( + '(() => { try { return window.boot_data && window.boot_data.api_token; } catch(e) { return null; } })()' + ); + if (typeof token === 'string' && token.startsWith('xoxc-')) { + const cookies = await win.webContents.session.cookies.get({ url: 'https://slack.com' }); + const dCookie = cookies.find((c) => c.name === 'd'); + if (dCookie && dCookie.value) { + // The d cookie value may or may not already include the xoxd- prefix + // depending on how Slack encodes it. Normalize it. + const raw = decodeURIComponent(dCookie.value); + const cookie = raw.startsWith('xoxd-') ? raw : `xoxd-${raw}`; + finish(resolve, { token, cookie }); + } + } + } catch (_) { + // page navigating, ignore + } + }, 1000); + + const timeoutHandle = setTimeout(() => { + finish(reject, new Error('Sign-in timed out after 10 minutes')); + }, 10 * 60 * 1000); + }); +}); diff --git a/electron/preload.js b/electron/preload.js index 534c540f..6731cdfb 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -12,6 +12,7 @@ const { contextBridge, ipcRenderer } = require('electron'); getAppVersion: () => ipcRenderer.invoke('get-app-version'), openExternal: (url) => ipcRenderer.invoke('open-external', url), + connectSlack: () => ipcRenderer.invoke('connect-slack'), capturePage: (rect) => ipcRenderer.invoke('capture-page', rect), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), checkForUpdates: () => ipcRenderer.invoke('check-for-updates'), diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index 497ff1ec..cbb36456 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -233,6 +233,28 @@ const INTEGRATIONS: Integration[] = [ ), authType: 'oauth2', }, + { + id: 'slack', + name: 'Slack', + description: 'Search messages, send messages, read channels, DMs, and threads in Slack workspaces.', + mcp_config: { type: 'stdio', command: 'npx', args: ['-y', 'slack-mcp-server@latest', '--transport', 'stdio'], env: { SLACK_MCP_ADD_MESSAGE_TOOL: 'true' } }, + color: '#4A154B', + website: 'https://github.com/korotovsky/slack-mcp-server', + icon: ( + + + + + + + ), + connectLabel: 'Connect Slack', + connectInstructions: 'On macOS with Chrome, tokens are auto-extracted. Otherwise: open app.slack.com in Chrome → F12 → Console → type `JSON.stringify({token: boot_data.api_token, cookie: document.cookie.match(/d=([^;]+)/)?.[1]})` → copy the result.', + credentialFields: [ + { key: 'SLACK_MCP_XOXC_TOKEN', label: 'Slack Token (xoxc-...)', placeholder: 'Auto-detected via Sign in, or paste xoxc- token' }, + { key: 'SLACK_MCP_XOXD_TOKEN', label: 'Slack Cookie (xoxd-...)', placeholder: 'Auto-detected via Sign in, or paste xoxd- cookie' }, + ], + }, ]; const CATEGORY_ORDER = ['filesystem', 'system', 'search', 'interaction', 'agents', 'planning', 'scheduling']; @@ -972,6 +994,37 @@ const Tools: React.FC = () => { } }; + const handleSlackAutoConnect = async () => { + if (!credDialogToolId || !credDialogIntegration) return; + const slackBridge = (window as any).openswarm?.connectSlack; + if (!slackBridge) { + setSnackbar({ open: true, message: 'Slack auto-connect requires the desktop app', severity: 'error' }); + return; + } + setCredDialogSaving(true); + try { + const { token, cookie } = await slackBridge(); + const creds = { SLACK_MCP_XOXC_TOKEN: token, SLACK_MCP_XOXD_TOKEN: cookie }; + const result = await dispatch(updateTool({ + id: credDialogToolId, + credentials: creds, + auth_type: 'env_vars', + auth_status: 'connected', + })); + if (updateTool.fulfilled.match(result)) { + setCredDialogOpen(false); + setSnackbar({ open: true, message: 'Slack connected! Re-discovering actions…' }); + dispatch(discoverTools(credDialogToolId)); + } else { + setSnackbar({ open: true, message: 'Failed to save Slack credentials', severity: 'error' }); + } + } catch (err: any) { + setSnackbar({ open: true, message: err?.message || 'Slack sign-in cancelled', severity: 'error' }); + } finally { + setCredDialogSaving(false); + } + }; + const handleDisconnectIntegration = async (toolId: string, integration: Integration) => { if (integration.authType === 'oauth2') { // Revoke the token on Google's side (fire-and-forget) @@ -2237,35 +2290,43 @@ const Tools: React.FC = () => { {credDialogIntegration?.connectLabel || 'Connect'} - {credDialogIntegration?.connectInstructions && ( + {credDialogIntegration?.id === 'slack' ? ( - {credDialogIntegration.connectInstructions} + Click Sign in with Slack below — a Slack window will open. Sign in normally and the window will close automatically once you reach your workspace. + ) : ( + <> + {credDialogIntegration?.connectInstructions && ( + + {credDialogIntegration.connectInstructions} + + )} + {(credDialogIntegration?.credentialFields || []).map((field) => ( + setCredDialogValues((prev) => ({ ...prev, [field.key]: e.target.value }))} + fullWidth + size="small" + helperText={field.helpText} + sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page, fontFamily: c.font.mono, fontSize: '0.85rem' } }} + /> + ))} + )} - {(credDialogIntegration?.credentialFields || []).map((field) => ( - setCredDialogValues((prev) => ({ ...prev, [field.key]: e.target.value }))} - fullWidth - size="small" - helperText={field.helpText} - sx={{ '& .MuiOutlinedInput-root': { bgcolor: c.bg.page, fontFamily: c.font.mono, fontSize: '0.85rem' } }} - /> - ))}