code icon Code

Fetch Slack Channel History

Fetch message history from a Slack channel with user resolution

Source Code

import fs from "fs";
import path from "path";

const [channelId, outputPath, limit = "100", daysBack = "7"] = process.argv.slice(2);

if (!channelId || !outputPath) {
  console.error("Usage: slack.core.history <channelId> <outputPath> [limit] [daysBack]");
  process.exit(1);
}

const maxMessages = Math.min(parseInt(limit) || 100, 500);
const daysBackNum = Math.min(parseInt(daysBack) || 7, 90);
const oldestTs = Math.floor((Date.now() - daysBackNum * 24 * 60 * 60 * 1000) / 1000);

console.log(`Fetching history from ${channelId} (last ${daysBackNum} days, max ${maxMessages} messages)...`);

try {
  // Fetch channel info and users in parallel
  const [channelInfo, userMap] = await Promise.all([
    (async () => {
      const params = new URLSearchParams({ channel: channelId });
      const res = await fetch("https://slack.com/api/conversations.info?" + params, {
        headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
      });
      const data = await res.json();
      if (!data.ok) return { id: channelId, name: null };
      const c = data.channel;
      return {
        id: c.id,
        name: c.name || null,
        isPrivate: c.is_private,
        isIm: c.is_im,
        isMpim: c.is_mpim,
      };
    })(),

    (async () => {
      const users = new Map();
      let cursor = null;

      do {
        const params = new URLSearchParams({ limit: "200" });
        if (cursor) params.set("cursor", cursor);

        const res = await fetch("https://slack.com/api/users.list?" + params, {
          headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
        });
        const data = await res.json();
        if (!data.ok) break;

        for (const u of data.members || []) {
          users.set(u.id, {
            id: u.id,
            name: u.profile?.display_name || u.real_name || u.name,
            realName: u.real_name,
          });
        }
        cursor = data.response_metadata?.next_cursor;
      } while (cursor);

      return users;
    })(),
  ]);

  console.log(`  Channel: ${channelInfo.name ? "#" + channelInfo.name : channelId}`);
  console.log(`  ${userMap.size} users loaded`);

  // Fetch messages with pagination
  const messages = [];
  let cursor = null;

  while (messages.length < maxMessages) {
    const remaining = maxMessages - messages.length;
    const params = new URLSearchParams({
      channel: channelId,
      limit: Math.min(remaining, 100).toString(),
      oldest: oldestTs.toString(),
    });
    if (cursor) params.set("cursor", cursor);

    const res = await fetch("https://slack.com/api/conversations.history?" + params, {
      headers: { Authorization: "Bearer PLACEHOLDER_TOKEN" },
    });
    const data = await res.json();

    if (!data.ok) throw new Error(`conversations.history failed: ${data.error}`);
    messages.push(...(data.messages || []));
    cursor = data.response_metadata?.next_cursor;

    if (!cursor || data.messages?.length === 0) break;
  }

  console.log(`  Fetched ${messages.length} messages`);

  // Transform messages with user resolution
  const transformed = messages.slice(0, maxMessages).map(msg => {
    const user = userMap.get(msg.user) || { name: msg.user || "unknown" };
    const ts = parseFloat(msg.ts);
    const date = new Date(ts * 1000);

    return {
      ts: msg.ts,
      userId: msg.user,
      userName: user.name,
      text: msg.text,
      threadTs: msg.thread_ts,
      replyCount: msg.reply_count,
      reactions: msg.reactions?.map(r => ({ name: r.name, count: r.count })),
      attachments: msg.attachments?.length || 0,
      blocks: msg.blocks?.length || 0,
      timestamp: date.toISOString(),
      hour: date.getHours(),
      dayOfWeek: date.getDay(),
      subtype: msg.subtype,
    };
  });

  // Filter out system messages for stats
  const userMessages = transformed.filter(m => !m.subtype && m.userId);

  // Compute stats
  const userCounts = {};
  for (const m of userMessages) {
    userCounts[m.userName] = (userCounts[m.userName] || 0) + 1;
  }
  const topPosters = Object.entries(userCounts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
    .map(([name, count]) => ({ name, count }));

  // Write output
  const dir = path.dirname(outputPath);
  if (dir && dir !== ".") fs.mkdirSync(dir, { recursive: true });

  const output = {
    channel: channelInfo,
    fetchedAt: new Date().toISOString(),
    daysBack: daysBackNum,
    count: transformed.length,
    userMessageCount: userMessages.length,
    topPosters,
    messages: transformed,
  };

  fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));

  console.log(`\n✓ Messages written to: ${outputPath}`);

  if (userMessages.length > 0) {
    const oldest = transformed[transformed.length - 1];
    const newest = transformed[0];
    console.log(`  Time range: ${oldest.timestamp.split("T")[0]} to ${newest.timestamp.split("T")[0]}`);
    console.log(`  Top posters:`);
    topPosters.forEach(p => console.log(`    - ${p.name}: ${p.count} messages`));
  }

  console.log(JSON.stringify({
    success: true,
    outputPath,
    channel: channelInfo.name || channelId,
    count: transformed.length,
    userMessageCount: userMessages.length,
  }));
} catch (error) {
  console.error("Failed:", error.message);
  throw error;
}