mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 05:22:22 +02:00
[eric] add Slack MCP integration with Electron-based auto token capture
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<path d="M5.04 15.16a2.53 2.53 0 0 1-2.52 2.53A2.53 2.53 0 0 1 0 15.16a2.53 2.53 0 0 1 2.52-2.52h2.52v2.52zm1.27 0a2.53 2.53 0 0 1 2.52-2.52 2.53 2.53 0 0 1 2.53 2.52v6.32A2.53 2.53 0 0 1 8.83 24a2.53 2.53 0 0 1-2.52-2.52v-6.32z" fill="#E01E5A"/>
|
||||
<path d="M8.83 5.04a2.53 2.53 0 0 1-2.52-2.52A2.53 2.53 0 0 1 8.83 0a2.53 2.53 0 0 1 2.53 2.52v2.52H8.83zm0 1.27a2.53 2.53 0 0 1 2.53 2.52 2.53 2.53 0 0 1-2.53 2.53H2.52A2.53 2.53 0 0 1 0 8.83a2.53 2.53 0 0 1 2.52-2.52h6.31z" fill="#36C5F0"/>
|
||||
<path d="M18.96 8.83a2.53 2.53 0 0 1 2.52-2.52A2.53 2.53 0 0 1 24 8.83a2.53 2.53 0 0 1-2.52 2.53h-2.52V8.83zm-1.27 0a2.53 2.53 0 0 1-2.52 2.53 2.53 2.53 0 0 1-2.53-2.53V2.52A2.53 2.53 0 0 1 15.17 0a2.53 2.53 0 0 1 2.52 2.52v6.31z" fill="#2EB67D"/>
|
||||
<path d="M15.17 18.96a2.53 2.53 0 0 1 2.52 2.52A2.53 2.53 0 0 1 15.17 24a2.53 2.53 0 0 1-2.53-2.52v-2.52h2.53zm0-1.27a2.53 2.53 0 0 1-2.53-2.52 2.53 2.53 0 0 1 2.53-2.53h6.31A2.53 2.53 0 0 1 24 15.17a2.53 2.53 0 0 1-2.52 2.52h-6.31z" fill="#ECB22E"/>
|
||||
</svg>
|
||||
),
|
||||
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'}
|
||||
</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '8px !important' }}>
|
||||
{credDialogIntegration?.connectInstructions && (
|
||||
{credDialogIntegration?.id === 'slack' ? (
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.5, bgcolor: c.bg.secondary, px: 2, py: 1.5, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
|
||||
{credDialogIntegration.connectInstructions}
|
||||
Click <strong>Sign in with Slack</strong> below — a Slack window will open. Sign in normally and the window will close automatically once you reach your workspace.
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{credDialogIntegration?.connectInstructions && (
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.5, bgcolor: c.bg.secondary, px: 2, py: 1.5, borderRadius: 2, border: `1px solid ${c.border.subtle}` }}>
|
||||
{credDialogIntegration.connectInstructions}
|
||||
</Typography>
|
||||
)}
|
||||
{(credDialogIntegration?.credentialFields || []).map((field) => (
|
||||
<TextField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={credDialogValues[field.key] || ''}
|
||||
onChange={(e) => 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) => (
|
||||
<TextField
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={credDialogValues[field.key] || ''}
|
||||
onChange={(e) => 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' } }}
|
||||
/>
|
||||
))}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setCredDialogOpen(false)} sx={{ color: c.text.tertiary, textTransform: 'none' }}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCredentialsSave}
|
||||
disabled={credDialogSaving || (credDialogIntegration?.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim())}
|
||||
onClick={credDialogIntegration?.id === 'slack' ? handleSlackAutoConnect : handleCredentialsSave}
|
||||
disabled={credDialogSaving || (credDialogIntegration?.id !== 'slack' && (credDialogIntegration?.credentialFields || []).some((f) => !credDialogValues[f.key]?.trim()))}
|
||||
startIcon={credDialogSaving ? <CircularProgress size={14} /> : <LinkIcon sx={{ fontSize: 14 }} />}
|
||||
sx={{ bgcolor: credDialogIntegration?.color || c.accent.primary, '&:hover': { bgcolor: credDialogIntegration?.color || c.accent.pressed, filter: 'brightness(0.9)' }, textTransform: 'none', borderRadius: 2 }}
|
||||
>
|
||||
Connect
|
||||
{credDialogIntegration?.id === 'slack' ? (credDialogSaving ? 'Waiting for sign-in…' : 'Sign in with Slack') : 'Connect'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user