From b7ada2f3c35f5d4b36987d13b4b277dffdd240d3 Mon Sep 17 00:00:00 2001 From: TheAchiever6823 <61914223+ShawnMadadha@users.noreply.github.com> Date: Thu, 14 May 2026 16:54:17 -0700 Subject: [PATCH] feat(tools): desktop Instagram OAuth via MCP CLI bridge --- electron/main.js | 84 +++++++++++++++ electron/preload.js | 3 + frontend/src/app/pages/Tools/Tools.tsx | 135 ++++++++++++++++++++++++- 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/electron/main.js b/electron/main.js index 6101908b..1dd6554c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -352,6 +352,62 @@ function getBundledNodePath() { return fs.existsSync(candidate) ? candidate : null; } +/** + * Run instagram-mcp-buddy connect|logout non-interactively. Prefers the workspace + * build at ../instagram-mcp/dist/index.js when present (dev / source tree); + * otherwise `npx -y instagram-mcp-buddy` so packaged apps work without shipping + * the subfolder. Inherits a shell-expanded PATH on macOS (see getShellPath). + * + * @param {'connect'|'logout'} subcommand + * @param {Record} [extraEnv] merged into child env (e.g. INSTAGRAM_MCP_ENABLE_* from MCP config) + * @returns {Promise>} + */ +function runInstagramBuddyCli(subcommand, extraEnv = {}) { + return new Promise((resolve, reject) => { + const repoRoot = path.join(__dirname, '..'); + const localScript = path.join(repoRoot, 'instagram-mcp', 'dist', 'index.js'); + const pathMerged = [getShellPath(), process.env.PATH || ''].filter(Boolean).join(path.delimiter); + const env = { ...process.env, ...extraEnv, PATH: pathMerged }; + + let cmd; + let args; + let shell = false; + if (fs.existsSync(localScript)) { + const nodeExe = getBundledNodePath() || (process.platform === 'win32' ? 'node.exe' : 'node'); + cmd = nodeExe; + args = [localScript, subcommand]; + } else { + cmd = 'npx'; + args = ['-y', 'instagram-mcp-buddy', subcommand]; + if (process.platform === 'win32') shell = true; + } + + const child = spawn(cmd, args, { env, cwd: repoRoot, windowsHide: true, shell }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => { out += d.toString(); }); + child.stderr.on('data', (d) => { err += d.toString(); }); + child.on('error', (e) => reject(e)); + child.on('close', (code) => { + if (code !== 0) { + const msg = (err || '').trim() || (out || '').trim() || `instagram-mcp-buddy ${subcommand} exited ${code}`; + reject(new Error(msg)); + return; + } + const line = out.trim().split(/\r?\n/).filter(Boolean).pop(); + if (!line) { + reject(new Error(`instagram-mcp-buddy ${subcommand}: empty stdout`)); + return; + } + try { + resolve(JSON.parse(line)); + } catch (e) { + reject(new Error(`instagram-mcp-buddy ${subcommand}: invalid JSON: ${line.slice(0, 200)}`)); + } + }); + }); +} + // Polls /api/health/check until the backend answers 200, or the spawned // python process exits non-zero (real failure). Never times out by wall // clock — on a cold-Defender Windows install this can take several @@ -1476,3 +1532,31 @@ ipcMain.handle('connect-slack', async () => { }, 10 * 60 * 1000); }); }); + +function normalizeEnvRecord(mcpEnv) { + const extra = {}; + if (mcpEnv && typeof mcpEnv === 'object' && !Array.isArray(mcpEnv)) { + for (const [k, v] of Object.entries(mcpEnv)) { + if (v !== undefined && v !== null) extra[k] = String(v); + } + } + return extra; +} + +ipcMain.handle('instagram-connect', async (_event, mcpEnv) => { + try { + const parsed = await runInstagramBuddyCli('connect', normalizeEnvRecord(mcpEnv)); + return { ok: true, ...parsed }; + } catch (err) { + return { ok: false, error: err?.message || String(err) }; + } +}); + +ipcMain.handle('instagram-logout', async (_event, mcpEnv) => { + try { + const parsed = await runInstagramBuddyCli('logout', normalizeEnvRecord(mcpEnv)); + return { ok: true, ...parsed }; + } catch (err) { + return { ok: false, error: err?.message || String(err) }; + } +}); diff --git a/electron/preload.js b/electron/preload.js index a92abb21..cd9eaeae 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -28,6 +28,9 @@ const { contextBridge, ipcRenderer } = require('electron'); // the cloud can credit the affiliate. Resolves to {} if no state yet. getInstallState: () => ipcRenderer.invoke('get-install-state'), connectSlack: () => ipcRenderer.invoke('connect-slack'), + /** Desktop Instagram OAuth via instagram-mcp-buddy CLI (keychain). Optional env merged from tool mcp_config.env */ + instagramConnect: (mcpEnv) => ipcRenderer.invoke('instagram-connect', mcpEnv), + instagramLogout: (mcpEnv) => ipcRenderer.invoke('instagram-logout', mcpEnv), sendCdpCommand: (wcId, method, params) => ipcRenderer.invoke('send-cdp-command', wcId, method, params), cdpCacheSet: (wcId, indexMap) => ipcRenderer.invoke('cdp-cache-set', wcId, indexMap), cdpCacheGet: (wcId) => ipcRenderer.invoke('cdp-cache-get', wcId), diff --git a/frontend/src/app/pages/Tools/Tools.tsx b/frontend/src/app/pages/Tools/Tools.tsx index 40ac1a3c..9bb837dd 100644 --- a/frontend/src/app/pages/Tools/Tools.tsx +++ b/frontend/src/app/pages/Tools/Tools.tsx @@ -143,6 +143,43 @@ const INTEGRATIONS: Integration[] = [ ), }, + { + id: 'instagram', + name: 'Instagram', + description: + 'Insights, publishing, comments, DMs, hashtags, mentions, and discovery for Business/Creator accounts. On the desktop app, use Sign in with Instagram below (or open an agent chat and run instagram_connect). A browser opens for Meta OAuth; the token stays in your OS keychain. In the browser-only app, connect from chat. Requires Instagram Professional.', + mcp_config: { + type: 'stdio', + command: 'npx', + args: ['-y', 'instagram-mcp-buddy'], + env: { + INSTAGRAM_MCP_ENABLE_PUBLISHING: 'true', + INSTAGRAM_MCP_ENABLE_COMMENTMODERATION: 'true', + INSTAGRAM_MCP_ENABLE_MESSAGING: 'true', + }, + }, + color: '#E4405F', + website: 'https://www.npmjs.com/package/instagram-mcp-buddy', + connectLabel: 'Sign-in steps', + connectInstructions: + '1) Enable Instagram here. 2) Desktop: click Sign in with Instagram (or use agent chat / instagram_connect). 3) Complete login in the browser. 4) Optional: instagram_status lists granted scopes. Publishing/DMs require those permissions on your Meta app; OpenSwarm enables feature flags in mcp_config.env so agents see the full surface.', + icon: ( + + + + + + + + + + + + + + + ), + }, { id: 'google-workspace', name: 'Google Workspace', @@ -516,7 +553,7 @@ const Tools: React.FC = () => { // browse the long tail. const CURATED_MCP_NAMES = useMemo(() => new Set([ 'google-workspace', 'microsoft-365', 'slack', 'discord', - 'notion', 'airtable', 'hubspot', 'reddit', 'youtube', + 'notion', 'airtable', 'hubspot', 'reddit', 'youtube', 'instagram', ]), []); const regServers = useMemo(() => { if (regSource !== 'curated') return regServersRaw; @@ -558,6 +595,9 @@ const Tools: React.FC = () => { const [credDialogValues, setCredDialogValues] = useState>({}); const [credDialogSaving, setCredDialogSaving] = useState(false); + /** Instagram desktop CLI (Electron); null when idle */ + const [instagramConnectBusy, setInstagramConnectBusy] = useState(null); + const getInstalledIntegration = useCallback((integration: Integration): ToolDefinition | undefined => { return allTools.find((t) => t.name === integration.name); }, [allTools]); @@ -988,6 +1028,74 @@ const Tools: React.FC = () => { setSnackbar({ open: true, message: 'Disconnected from Microsoft 365' }); }; + const handleInstagramConnect = async (toolId: string) => { + const tool = items[toolId]; + const bridge = (window as unknown as { openswarm?: { instagramConnect?: (env?: Record) => Promise<{ ok?: boolean; error?: string; username?: string }> } }).openswarm?.instagramConnect; + if (!bridge) { + setSnackbar({ + open: true, + message: 'Instagram sign-in here needs the OpenSwarm desktop app. In the browser, open an agent chat and run instagram_connect (or say “connect Instagram”).', + severity: 'error', + }); + return; + } + setInstagramConnectBusy(toolId); + try { + const rawEnv = tool?.mcp_config?.env; + const mcpEnv: Record = {}; + if (rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv)) { + for (const [k, v] of Object.entries(rawEnv)) { + if (v !== undefined && v !== null) mcpEnv[k] = String(v); + } + } + const result = await bridge(mcpEnv); + if (!result?.ok) { + setSnackbar({ open: true, message: result?.error || 'Instagram sign-in failed', severity: 'error' }); + return; + } + const username = typeof result.username === 'string' ? result.username : ''; + const label = username ? `@${username}` : ''; + await dispatch(updateTool({ id: toolId, auth_status: 'connected', connected_account_email: label })); + setSnackbar({ open: true, message: username ? `Instagram connected as @${username}` : 'Instagram connected — discovering actions…' }); + setExpandedToolId(toolId); + dispatch(discoverTools(toolId)); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setSnackbar({ open: true, message: msg || 'Instagram sign-in failed', severity: 'error' }); + } finally { + setInstagramConnectBusy(null); + } + }; + + const handleInstagramDisconnect = async (toolId: string) => { + const tool = items[toolId]; + const bridge = (window as unknown as { openswarm?: { instagramLogout?: (env?: Record) => Promise<{ ok?: boolean; error?: string }> } }).openswarm?.instagramLogout; + const rawEnv = tool?.mcp_config?.env; + const mcpEnv: Record = {}; + if (rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv)) { + for (const [k, v] of Object.entries(rawEnv)) { + if (v !== undefined && v !== null) mcpEnv[k] = String(v); + } + } + let keychainDetail = ''; + if (bridge) { + try { + const r = await bridge(mcpEnv); + if (r && r.ok === false) keychainDetail = r.error || 'logout CLI reported failure'; + } catch (e) { + keychainDetail = e instanceof Error ? e.message : String(e); + } + } + await dispatch(updateTool({ id: toolId, auth_status: 'configured', connected_account_email: '' })); + if (!bridge) { + setSnackbar({ open: true, message: 'OpenSwarm disconnected Instagram. Use the desktop app or run instagram-mcp-buddy logout to clear the keychain token.', severity: 'warning' }); + } else if (keychainDetail) { + setSnackbar({ open: true, message: `OpenSwarm disconnected Instagram. Keychain logout: ${keychainDetail}`, severity: 'warning' }); + } else { + setSnackbar({ open: true, message: 'Instagram disconnected' }); + } + }; + const openCredentialsDialog = (toolId: string, integration: Integration) => { const tool = items[toolId]; const existing = tool?.credentials || {}; @@ -1647,6 +1755,10 @@ const Tools: React.FC = () => { ig?.id === 'youtube' || tool.name?.toLowerCase() === 'youtube' || (tool.command || '').toLowerCase().includes('youtube'); + const isInstagram = + ig?.id === 'instagram' || + tool.name?.toLowerCase() === 'instagram' || + (tool.command || '').toLowerCase().includes('instagram'); return ( { Connect Microsoft 365 )} + {!isDisabled && isInstagram && tool.auth_status !== 'connected' && ( + + )} {!isDisabled && ig?.credentialFields && tool.auth_status !== 'connected' && ( )} {!isDisabled && ig && tool.auth_status === 'connected' && ( - + } label={tool.connected_account_email ? `Connected · ${tool.connected_account_email}` : 'Connected'} size="small" - onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code') ? (e: React.SyntheticEvent) => { e.stopPropagation(); ig.authType === 'device_code' ? handleM365Disconnect(tool.id) : handleDisconnectIntegration(tool.id, ig); } : undefined} + onDelete={(ig.credentialFields || ig.authType === 'oauth2' || ig.authType === 'device_code' || isInstagram) ? (e: React.SyntheticEvent) => { + e.stopPropagation(); + if (ig.authType === 'device_code') handleM365Disconnect(tool.id); + else if (isInstagram) void handleInstagramDisconnect(tool.id); + else handleDisconnectIntegration(tool.id, ig); + } : undefined} onClick={(e) => e.stopPropagation()} sx={{ bgcolor: c.status.successBg, color: c.status.success, fontSize: '0.7rem', height: 22, '& .MuiChip-icon': { color: c.status.success }, '& .MuiChip-deleteIcon': { color: c.status.success, '&:hover': { color: c.status.error } }, flexShrink: 0 }} />