code icon Code

Send to Slack

Send a message to Slack, or upload a file with a comment

Source Code

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

const [channelId, message, filePath = "", blocksPath = "", title = ""] =
  process.argv.slice(2);

// 1. Validate channelId (required always)
if (!channelId) {
  console.error("Error: channelId is required");
  process.exit(1);
}

// 2. Validate filePath if provided (error if doesn't exist)
if (filePath && filePath.trim()) {
  if (!fs.existsSync(filePath)) {
    console.error(`Error: File not found: ${filePath}`);
    process.exit(1);
  }
}

// 3. Determine mode: upload if filePath provided
const isUploadMode = filePath && filePath.trim();

// 4. Validate message (required for text messages, optional for uploads)
if (!message && !isUploadMode) {
  console.error("Error: message is required for text messages");
  process.exit(1);
}

if (isUploadMode) {
  // ============================================================
  // FILE UPLOAD FLOW
  // ============================================================
  console.log(`Uploading file to ${channelId}...`);
  console.log(`  File: ${filePath}`);

  try {
    const fileBuffer = fs.readFileSync(filePath);
    const fileName = path.basename(filePath);
    const fileSize = fileBuffer.length;

    console.log(`  Size: ${fileSize} bytes`);

    // Step 1: Get upload URL from Slack
    const uploadUrlFormData = new FormData();
    uploadUrlFormData.append("filename", fileName);
    uploadUrlFormData.append("length", fileSize.toString());

    const uploadUrlResponse = await fetch(
      "https://slack.com/api/files.getUploadURLExternal",
      {
        method: "POST",
        headers: {
          Authorization: "Bearer PLACEHOLDER_TOKEN",
        },
        body: uploadUrlFormData,
      }
    );

    const uploadUrlData = await uploadUrlResponse.json();

    if (!uploadUrlData.ok) {
      console.error(`Slack API error: ${uploadUrlData.error}`);
      if (uploadUrlData.error === "missing_scope") {
        console.error("  The app needs files:write scope permission");
      }
      throw new Error(`Failed to get upload URL: ${uploadUrlData.error}`);
    }

    const { upload_url, file_id } = uploadUrlData;
    console.log(`  Got upload URL for file_id: ${file_id}`);

    // Step 2: Upload file contents to the provided URL
    const uploadResponse = await fetch(upload_url, {
      method: "POST",
      headers: {
        "Content-Type": "application/octet-stream",
      },
      body: fileBuffer,
    });

    if (!uploadResponse.ok) {
      throw new Error(
        `Failed to upload file contents: ${uploadResponse.statusText}`
      );
    }

    console.log("  File contents uploaded");

    // Step 3: Complete the upload and share to channel
    const completeFormData = new FormData();

    const fileData = { id: file_id };
    if (title && title.trim()) {
      fileData.title = title;
    }

    completeFormData.append("files", JSON.stringify([fileData]));
    completeFormData.append("channel_id", channelId);

    // Use message as initial_comment for uploads
    if (message && message.trim()) {
      completeFormData.append("initial_comment", message);
    }

    const completeResponse = await fetch(
      "https://slack.com/api/files.completeUploadExternal",
      {
        method: "POST",
        headers: {
          Authorization: "Bearer PLACEHOLDER_TOKEN",
        },
        body: completeFormData,
      }
    );

    const completeData = await completeResponse.json();

    if (!completeData.ok) {
      console.error(`Slack API error: ${completeData.error}`);
      if (completeData.error === "channel_not_found") {
        console.error("  The channel ID may be invalid or the bot lacks access");
      }
      throw new Error(`Failed to complete upload: ${completeData.error}`);
    }

    const uploadedFile = completeData.files?.[0];

    console.log(`\nāœ“ File uploaded successfully`);
    console.log(`  File ID: ${uploadedFile?.id}`);
    console.log(`  Name: ${uploadedFile?.name}`);
    console.log(`  Permalink: ${uploadedFile?.permalink}`);

    console.log(
      JSON.stringify({
        success: true,
        mode: "upload",
        fileId: uploadedFile?.id,
        fileName: uploadedFile?.name,
        permalink: uploadedFile?.permalink,
      })
    );
  } catch (error) {
    console.error("Failed to upload file:", error.message);
    throw error;
  }
} else {
  // ============================================================
  // MESSAGE FLOW
  // ============================================================

  // Extract JSON from content - handles both raw JSON and markdown with code blocks
  function extractBlocksJson(content) {
    const trimmed = content.trim();

    // If it starts with { or [, it's raw JSON
    if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
      return JSON.parse(trimmed);
    }

    // Otherwise, look for JSON in markdown code blocks
    const codeBlockMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
    if (codeBlockMatch) {
      const codeContent = codeBlockMatch[1].trim();
      if (codeContent.startsWith("{") || codeContent.startsWith("[")) {
        return JSON.parse(codeContent);
      }
    }

    // Try to find any JSON object in the content
    const jsonMatch = trimmed.match(/(\{[\s\S]*"blocks"[\s\S]*\})/);
    if (jsonMatch) {
      return JSON.parse(jsonMatch[1]);
    }

    throw new Error("No valid Block Kit JSON found in file");
  }

  console.log(`Sending message to ${channelId}...`);

  try {
    const body = {
      channel: channelId,
      text: message,
      as_user: true,
    };

    // Load blocks from file if provided
    if (blocksPath && blocksPath.trim() && blocksPath !== "undefined") {
      try {
        const fileContent = fs.readFileSync(blocksPath, "utf-8");
        const parsed = extractBlocksJson(fileContent);
        body.blocks = Array.isArray(parsed) ? parsed : parsed.blocks;
        console.log(
          `  Including ${body.blocks.length} Block Kit blocks from ${blocksPath}`
        );
      } catch (err) {
        if (err.code === "ENOENT") {
          console.error(`Warning: Blocks file not found: ${blocksPath}`);
        } else {
          console.error(`Warning: Failed to parse blocks: ${err.message}`);
        }
        console.log("  Sending plain text only");
      }
    }

    const res = await fetch("https://slack.com/api/chat.postMessage", {
      method: "POST",
      headers: {
        Authorization: "Bearer PLACEHOLDER_TOKEN",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (!res.ok) {
      throw new Error(`Slack API HTTP error: ${res.status}`);
    }

    const data = await res.json();

    if (!data.ok) {
      console.error(`Slack API error: ${data.error}`);
      if (data.error === "channel_not_found") {
        console.error("  The channel ID may be invalid or the bot lacks access");
      } else if (data.error === "not_in_channel") {
        console.error("  The bot needs to be added to this channel first");
      } else if (data.error === "invalid_blocks") {
        console.error("  The Block Kit JSON is malformed");
        console.error("  Test at: https://app.slack.com/block-kit-builder");
      }
      throw new Error(`Slack API error: ${data.error}`);
    }

    const timestamp = data.ts;
    const sentChannel = data.channel;

    console.log(`\nāœ“ Message sent successfully`);
    console.log(`  Channel: ${sentChannel}`);
    console.log(`  Timestamp: ${timestamp}`);
    console.log(
      `  Time: ${new Date(parseFloat(timestamp) * 1000).toISOString()}`
    );
    console.log(
      `  Format: ${
        body.blocks ? `Block Kit (${body.blocks.length} blocks)` : "Plain text"
      }`
    );

    console.log(
      JSON.stringify({
        success: true,
        mode: "message",
        channel: sentChannel,
        ts: timestamp,
        hasBlocks: !!body.blocks,
      })
    );
  } catch (error) {
    console.error("Failed to send message:", error.message);
    throw error;
  }
}