CtrlK
BlogDocsLog inGet started
Tessl Logo

catalan-adobe/slack-cdp

Control Slack via CDP or headless API tokens. Navigate channels, read/send messages, search conversations, check unreads, and manage status. Two modes: CDP (Slack desktop with --remote-debugging-port) for full UI control, or headless (xoxp/xoxb token) for data operations without Slack running. Triggers on: slack, read slack, search slack, slack unreads, send slack message, slack status, navigate slack, check slack, slack messages, go to channel, slack DM.

97

Quality

97%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Critical

Do not install without reviewing

Overview
Quality
Evals
Security
Files

slack-cdp.jsscripts/

#!/usr/bin/env node
'use strict';

// --- Constants ---
const API_BASE = 'https://app.slack.com/api';
const DEFAULT_PORT = 9222;
const DEFAULT_TIMEOUT = 8000;
const NAV_SETTLE_MS = 2000; // Time for channel content to load after navigation

// --- Arg parsing (follows cdp.js convention) ---
function parseArgs(argv) {
  const flags = { port: DEFAULT_PORT, channel: null, limit: null };
  const positional = [];
  const raw = argv.slice(2);
  for (let i = 0; i < raw.length; i++) {
    switch (raw[i]) {
      case '--port': flags.port = parseInt(raw[++i], 10); break;
      case '--channel': flags.channel = raw[++i]; break;
      case '--limit': flags.limit = parseInt(raw[++i], 10); break;
      default: positional.push(raw[i]);
    }
  }
  return { command: positional[0], args: positional.slice(1), ...flags };
}

function die(msg) {
  console.error(`Error: ${msg}`);
  process.exit(1);
}

const wait = (ms) => new Promise((r) => setTimeout(r, ms));

// --- CDP connection (Slack-specific target discovery) ---
async function findSlackTarget(port) {
  let res;
  try {
    res = await fetch(`http://localhost:${port}/json`);
  } catch {
    die(
      `Cannot connect to CDP on port ${port}.\n` +
      `Start Slack with: /Applications/Slack.app/Contents/MacOS/Slack --remote-debugging-port=${port}`
    );
  }
  const targets = await res.json();
  const slack = targets.find(
    (t) => t.type === 'page' && t.url?.includes('app.slack.com')
  );
  if (!slack) die('No Slack page target found. Is Slack running?');
  return slack;
}

async function connectSlack(port) {
  const target = await findSlackTarget(port);
  const ws = new WebSocket(target.webSocketDebuggerUrl);
  await new Promise((resolve, reject) => {
    ws.onopen = resolve;
    ws.onerror = () => reject(new Error('WebSocket connection failed'));
  });
  return ws;
}

let nextId = 0;
function send(ws, method, params = {}, timeout = DEFAULT_TIMEOUT) {
  const id = ++nextId;
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      ws.close();
      reject(new Error(`Timeout after ${timeout}ms: ${method}`));
    }, timeout);
    const handler = (e) => {
      const msg = JSON.parse(e.data);
      if (msg.id === id) {
        ws.removeEventListener('message', handler);
        clearTimeout(timer);
        if (msg.error) reject(new Error(`CDP ${method}: ${msg.error.message}`));
        else resolve(msg.result);
      }
    };
    ws.addEventListener('message', handler);
    ws.send(JSON.stringify({ id, method, params }));
  });
}

// --- Slack API via renderer eval ---
// Params are double-encoded to prevent code injection: JSON.stringify twice
// produces a JS string literal that the renderer JSON.parse's back safely.
async function slackEval(ws, apiMethod, params = {}) {
  const safeParams = JSON.stringify(JSON.stringify(params));
  const result = await send(ws, 'Runtime.evaluate', {
    expression: `(async () => {
      const cfg = JSON.parse(localStorage.localConfig_v2);
      const pathTeam = window.location.pathname.split('/')[2];
      const teamId = (pathTeam && cfg.teams[pathTeam]) ? pathTeam : cfg.lastActiveTeamId;
      const tk = cfg.teams[teamId].token;
      const p = JSON.parse(${safeParams});
      const body = new URLSearchParams(Object.assign({ token: tk }, p));
      const resp = await fetch('${API_BASE}/${apiMethod}', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: body.toString(),
        credentials: 'include'
      });
      return JSON.stringify(await resp.json());
    })()`,
    awaitPromise: true,
    returnByValue: true,
  });
  if (result.exceptionDetails) {
    die(`API error (${apiMethod}): ${result.exceptionDetails.text}`);
  }
  return JSON.parse(result.result?.value || '{"ok":false,"error":"empty"}');
}

// --- DOM eval helper ---
async function domEval(ws, expression) {
  const result = await send(ws, 'Runtime.evaluate', {
    expression: `(() => { ${expression} })()`,
    returnByValue: true,
  });
  if (result.exceptionDetails) {
    die(`Eval error: ${result.exceptionDetails.text}`);
  }
  return result.result?.value;
}

// --- Key input helpers ---
async function sendKey(ws, key, code, vk, modifiers = 0) {
  await send(ws, 'Input.dispatchKeyEvent', {
    type: 'rawKeyDown', key, code,
    windowsVirtualKeyCode: vk, modifiers,
  });
  await send(ws, 'Input.dispatchKeyEvent', {
    type: 'keyUp', key, code,
    windowsVirtualKeyCode: vk, modifiers,
  });
}

async function insertText(ws, text) {
  await send(ws, 'Input.insertText', { text });
}

// --- Helpers ---
async function getCurrentChannel(ws) {
  const path = await domEval(ws, `return window.location.pathname;`);
  const parts = path.split('/');
  const candidate = parts[3] || '';
  if (/^[CDG][A-Z0-9]+$/.test(candidate)) return candidate;
  die('Not in a channel view. Navigate to a channel first, or use --channel <id>.');
}

// --- Commands ---

async function cmdConnect(port) {
  const ws = await connectSlack(port);
  const auth = await slackEval(ws, 'auth.test');
  ws.close();
  if (!auth.ok) die(`Auth failed: ${auth.error}`);
  console.log(`Connected to: Slack (${auth.team})`);
  console.log(`User: ${auth.user} — ${auth.user_id}`);
  console.log(`Team: ${auth.team} — ${auth.team_id}`);
  console.log(`Enterprise: ${auth.is_enterprise_install ? 'yes' : 'no'}`);
}

async function cmdWhoami(port) {
  const ws = await connectSlack(port);
  const auth = await slackEval(ws, 'auth.test');
  if (!auth.ok) { ws.close(); die(`Auth failed: ${auth.error}`); }
  const user = await slackEval(ws, 'users.info', { user: auth.user_id });
  ws.close();
  const u = user.user;
  const status = u.profile?.status_emoji
    ? `${u.profile.status_emoji} ${u.profile.status_text || ''}`
    : '(none)';
  console.log(`User: ${u.real_name} (@${u.name}) — ${u.id}`);
  console.log(`Team: ${auth.team} — ${auth.team_id}`);
  console.log(`Enterprise: ${auth.is_enterprise_install ? 'yes' : 'no'}`);
  console.log(`Status: ${status}`);
}

async function cmdWhere(port) {
  const ws = await connectSlack(port);
  const info = await domEval(ws, `
    const path = window.location.pathname;
    const parts = path.split('/');
    const tab = document.querySelector('[role=tab][aria-selected=true]');
    return JSON.stringify({
      title: document.title.replace(/ - Slack$/, ''),
      channel: parts[3] || null,
      tab: tab ? tab.textContent.trim() : null,
      path: path,
    });
  `);
  ws.close();
  const d = JSON.parse(info);
  console.log(`Title: ${d.title}`);
  if (d.channel) console.log(`Channel: ${d.channel}`);
  if (d.tab) console.log(`Tab: ${d.tab} (selected)`);
  console.log(`URL: ${d.path}`);
}

async function cmdNavigate(query, port) {
  if (!query) die('Usage: slack-cdp.js navigate <query>');
  const ws = await connectSlack(port);
  try {
    await sendKey(ws, 'k', 'KeyK', 75, 4);
    await wait(600);    // Wait for quick-switcher to open
    await insertText(ws, query);
    await wait(1500);   // Wait for search results to populate
    await sendKey(ws, 'Enter', 'Enter', 13);
    await wait(NAV_SETTLE_MS);
    const title = await domEval(ws, `return document.title.replace(/ - Slack$/, '');`);
    console.log(`Navigated to: ${title}`);
  } finally {
    ws.close();
  }
}

async function cmdSearch(query, limit, port) {
  if (!query) die('Usage: slack-cdp.js search <query>');
  const ws = await connectSlack(port);
  const data = await slackEval(ws, 'search.messages', {
    query, count: String(limit || 5),
  });
  ws.close();
  if (!data.ok) die(`Search failed: ${data.error}`);
  const total = data.messages?.total || 0;
  const matches = data.messages?.matches || [];
  console.log(`Search: "${query}" — ${total} results (showing ${matches.length})`);
  console.log('');
  for (const m of matches) {
    const chan = m.channel?.name ? `#${m.channel.name}` : m.channel?.id || '?';
    const user = m.username || m.user || '?';
    const date = m.ts
      ? new Date(parseFloat(m.ts) * 1000).toISOString().slice(0, 10)
      : '?';
    const text = (m.text || '').replace(/\n/g, ' ').slice(0, 120);
    console.log(`  [${chan}] @${user} (${date}): ${text}`);
  }
}

async function cmdRead(channelFlag, limit, port) {
  const ws = await connectSlack(port);
  const channel = channelFlag || await getCurrentChannel(ws);
  const data = await slackEval(ws, 'conversations.history', {
    channel, limit: String(limit || 10),
  });
  ws.close();
  if (!data.ok) {
    if (data.error === 'enterprise_is_restricted') {
      die('conversations.history is restricted. Use search or navigate to the channel.');
    }
    die(`Read failed: ${data.error}`);
  }
  const msgs = (data.messages || []).reverse();
  console.log(`${channel} — ${msgs.length} messages`);
  console.log('');
  for (const m of msgs) {
    const ts = m.ts
      ? new Date(parseFloat(m.ts) * 1000).toLocaleString()
      : '?';
    const user = m.user || m.username || m.bot_id || '?';
    const text = (m.text || '').replace(/\n/g, ' ').slice(0, 200);
    console.log(`  [${ts}] ${user}: ${text}`);
  }
}

async function cmdSend(channelArg, message, port) {
  if (!message) die('Usage: slack-cdp.js send <channel|current> <message>');
  const ws = await connectSlack(port);
  const channel = channelArg === 'current'
    ? await getCurrentChannel(ws)
    : channelArg;
  const data = await slackEval(ws, 'chat.postMessage', {
    channel, text: message,
  });
  ws.close();
  if (!data.ok) die(`Send failed: ${data.error}`);
  console.log(`Sent to ${data.channel} (ts: ${data.ts})`);
}

async function cmdUnread(port) {
  const ws = await connectSlack(port);
  const data = await slackEval(ws, 'client.counts');
  if (data.ok && data.channels) {
    ws.close();
    const unread = data.channels
      .filter((c) => c.has_unreads || c.mention_count > 0)
      .sort((a, b) => (b.mention_count || 0) - (a.mention_count || 0));
    if (unread.length === 0) { console.log('No unread channels.'); return; }
    console.log('Unread channels:');
    for (const c of unread) {
      const count = c.mention_count || 'new';
      console.log(`  ${c.name || c.id} (${count})`);
    }
    return;
  }
  // Fallback: read sidebar DOM (returns display names, not channel IDs)
  if (!data.ok) {
    console.error(`Warning: client.counts failed (${data.error}), falling back to sidebar DOM`);
  }
  const sidebarText = await domEval(ws, `
    const items = [...document.querySelectorAll('.p-channel_sidebar__channel--unread')];
    return JSON.stringify(items.map(el => el.textContent.trim().substring(0, 60)));
  `);
  ws.close();
  const items = JSON.parse(sidebarText || '[]');
  if (items.length === 0) { console.log('No unread channels.'); return; }
  console.log('Unread channels:');
  for (const item of items) console.log(`  ${item}`);
}

async function cmdStatus(statusArg, port) {
  const ws = await connectSlack(port);
  if (!statusArg) {
    const auth = await slackEval(ws, 'auth.test');
    const user = await slackEval(ws, 'users.info', { user: auth.user_id });
    ws.close();
    const p = user.user?.profile;
    if (p?.status_emoji || p?.status_text) {
      console.log(`Status: ${p.status_emoji || ''} ${p.status_text || ''}`.trim());
      if (p.status_expiration) {
        const exp = new Date(p.status_expiration * 1000).toLocaleString();
        console.log(`Expires: ${exp}`);
      }
    } else {
      console.log('Status: (none)');
    }
    return;
  }
  const emojiMatch = statusArg.match(/^(:[\w+-]+:)\s*(.*)/);
  const emoji = emojiMatch ? emojiMatch[1] : '';
  const text = emojiMatch ? emojiMatch[2] : statusArg;
  const data = await slackEval(ws, 'users.profile.set', {
    profile: JSON.stringify({
      status_text: text,
      status_emoji: emoji,
    }),
  });
  ws.close();
  if (!data.ok) die(`Status update failed: ${data.error}`);
  console.log(`Status set: ${emoji} ${text}`.trim());
}

// --- Main ---
async function main() {
  const opts = parseArgs(process.argv);
  if (!opts.command) {
    console.error([
      'Usage: slack-cdp.js <command> [args] [--port N]',
      '',
      'Commands:',
      '  connect                 Verify connection and auth',
      '  navigate <query>        Switch channel/DM via Cmd+K',
      '  read [--channel ID]     Read recent messages',
      '  send <channel> <msg>    Send a message',
      '  search <query>          Search messages',
      '  whoami                  Current user info',
      '  unread                  List unread channels',
      '  status [":emoji: text"] Get or set status',
      '  where                   Current view info',
    ].join('\n'));
    process.exit(0);
  }

  const cmds = {
    connect: () => cmdConnect(opts.port),
    whoami: () => cmdWhoami(opts.port),
    where: () => cmdWhere(opts.port),
    navigate: () => cmdNavigate(opts.args[0], opts.port),
    search: () => cmdSearch(opts.args[0], opts.limit, opts.port),
    read: () => cmdRead(opts.channel, opts.limit, opts.port),
    send: () => cmdSend(opts.args[0], opts.args.slice(1).join(' '), opts.port),
    unread: () => cmdUnread(opts.port),
    status: () => cmdStatus(opts.args.join(' ') || null, opts.port),
  };

  const fn = cmds[opts.command];
  if (!fn) die(`Unknown command: ${opts.command}`);
  await fn();
}

main().catch((err) => die(err.message));

scripts

slack-cdp.js

SKILL.md

tile.json