Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,51 @@ func (c *Client) CompleteUploadExternal(files []CompleteUploadExternalFile, chan
return err
}

// --- File Info & Download Methods ---

// GetFileInfo returns metadata for a file by ID
func (c *Client) GetFileInfo(fileID string) (*File, error) {
params := url.Values{}
params.Set("file", fileID)

body, err := c.get("files.info", params)
if err != nil {
return nil, err
}

var result struct {
File File `json:"file"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}

return &result.File, nil
}

// DownloadFile downloads a file from a Slack private URL to the given writer
func (c *Client) DownloadFile(downloadURL string, w io.Writer) error {
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return err
}

req.Header.Set("Authorization", "Bearer "+c.token)

resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}

_, err = io.Copy(w, resp.Body)
return err
}

// --- Search Methods (require user token) ---

// SearchMessages searches for messages matching a query
Expand Down
120 changes: 120 additions & 0 deletions internal/cmd/files/download.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package files

import (
"fmt"
"os"
"path/filepath"
"regexp"

"github.com/spf13/cobra"

"github.com/open-cli-collective/slack-chat-api/internal/client"
"github.com/open-cli-collective/slack-chat-api/internal/output"
)

// fileIDPattern matches Slack file IDs (e.g. F0AHF3NUSQK)
var fileIDPattern = regexp.MustCompile(`^F[A-Z0-9]+$`)

// urlFileIDPattern extracts file IDs from Slack URLs
// Matches patterns like /files/U.../F0AHF3NUSQK/... or /files-pri/T...-F0AHF3NUSQK/...
var urlFileIDPattern = regexp.MustCompile(`[/-](F[A-Z0-9]+)[/]`)

type downloadOptions struct {
outputPath string
}

func newDownloadCmd() *cobra.Command {
opts := &downloadOptions{}

cmd := &cobra.Command{
Use: "download <file-id-or-url>",
Short: "Download a Slack file",
Long: `Download a file from Slack by file ID or URL.

Accepts a file ID (e.g. F0AHF3NUSQK) or a Slack file URL
(url_private, url_private_download, or permalink).

By default saves to the current directory using the file's original name.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runDownload(args[0], opts, nil)
},
}

cmd.Flags().StringVarP(&opts.outputPath, "output", "O", "", "Output file path (default: ./<filename>)")

return cmd
}

func runDownload(input string, opts *downloadOptions, c *client.Client) error {
fileID := resolveFileID(input)
if fileID == "" {
return fmt.Errorf("could not resolve file ID from %q — provide a file ID (e.g. F0AHF3NUSQK) or Slack file URL", input)
}

if c == nil {
var err error
c, err = client.New()
if err != nil {
return err
}
}

info, err := c.GetFileInfo(fileID)
if err != nil {
return err
}

downloadURL := info.URLPrivateDownload
if downloadURL == "" {
downloadURL = info.URLPrivate
}
if downloadURL == "" {
return fmt.Errorf("no download URL available for file %s", fileID)
}

destPath := opts.outputPath
if destPath == "" {
destPath = info.Name
}

if output.IsJSON() {
return output.PrintJSON(map[string]interface{}{
"file_id": info.ID,
"name": info.Name,
"size": info.Size,
"path": destPath,
})
}

f, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("creating output file: %w", err)
}
defer f.Close()

if err := c.DownloadFile(downloadURL, f); err != nil {
// Clean up partial file on error
_ = os.Remove(destPath)
return fmt.Errorf("downloading file: %w", err)
}

absPath, _ := filepath.Abs(destPath)
output.Printf("Downloaded %s (%d bytes) to %s\n", info.Name, info.Size, absPath)

return nil
}

// resolveFileID extracts a Slack file ID from a raw ID or URL
func resolveFileID(input string) string {
if fileIDPattern.MatchString(input) {
return input
}

matches := urlFileIDPattern.FindStringSubmatch(input)
if len(matches) >= 2 {
return matches[1]
}

return ""
}
226 changes: 226 additions & 0 deletions internal/cmd/files/download_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
package files

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/open-cli-collective/slack-chat-api/internal/client"
"github.com/open-cli-collective/slack-chat-api/internal/output"
)

func TestResolveFileID(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"bare file ID", "F0AHF3NUSQK", "F0AHF3NUSQK"},
{"short file ID", "F123", "F123"},
{"url_private", "https://files.slack.com/files-pri/TNVBX1L3S-F0AHF3NUSQK/image.png", "F0AHF3NUSQK"},
{"url_private_download", "https://files.slack.com/files-pri/TNVBX1L3S-F0AHF3NUSQK/download/image.png", "F0AHF3NUSQK"},
{"permalink", "https://signalft.slack.com/files/U099RPJJFRS/F0AHF3NUSQK/image.png", "F0AHF3NUSQK"},
{"invalid input", "not-a-file-id", ""},
{"empty", "", ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := resolveFileID(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

func TestRunDownload_Success(t *testing.T) {
fileContent := "hello world file content"

downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Contains(t, r.Header.Get("Authorization"), "Bearer ")
w.Write([]byte(fileContent))
}))
defer downloadServer.Close()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/files.info":
assert.Equal(t, "F123ABC", r.URL.Query().Get("file"))
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"file": map[string]interface{}{
"id": "F123ABC",
"name": "test-file.txt",
"size": len(fileContent),
"url_private_download": downloadServer.URL + "/download/test-file.txt",
},
})
default:
t.Errorf("unexpected request to %s", r.URL.Path)
}
}))
defer server.Close()

c := client.NewWithConfig(server.URL, "test-token", nil)
destPath := filepath.Join(t.TempDir(), "downloaded.txt")
opts := &downloadOptions{outputPath: destPath}

err := runDownload("F123ABC", opts, c)
require.NoError(t, err)

data, err := os.ReadFile(destPath)
require.NoError(t, err)
assert.Equal(t, fileContent, string(data))
}

func TestRunDownload_FromURL(t *testing.T) {
downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("image data"))
}))
defer downloadServer.Close()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/files.info":
assert.Equal(t, "F0AHF3NUSQK", r.URL.Query().Get("file"))
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"file": map[string]interface{}{
"id": "F0AHF3NUSQK",
"name": "screenshot.png",
"size": 10,
"url_private_download": downloadServer.URL + "/download/screenshot.png",
},
})
}
}))
defer server.Close()

c := client.NewWithConfig(server.URL, "test-token", nil)
destPath := filepath.Join(t.TempDir(), "screenshot.png")
opts := &downloadOptions{outputPath: destPath}

err := runDownload("https://files.slack.com/files-pri/TNVBX1L3S-F0AHF3NUSQK/image.png", opts, c)
require.NoError(t, err)

data, err := os.ReadFile(destPath)
require.NoError(t, err)
assert.Equal(t, "image data", string(data))
}

func TestRunDownload_DefaultFilename(t *testing.T) {
downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("content"))
}))
defer downloadServer.Close()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"file": map[string]interface{}{
"id": "F123",
"name": "report.pdf",
"size": 7,
"url_private_download": downloadServer.URL + "/download",
},
})
}))
defer server.Close()

// Change to temp dir so default filename goes there
origDir, _ := os.Getwd()
tmpDir := t.TempDir()
os.Chdir(tmpDir)
defer os.Chdir(origDir)

c := client.NewWithConfig(server.URL, "test-token", nil)
opts := &downloadOptions{} // no output path — should use filename from API

err := runDownload("F123", opts, c)
require.NoError(t, err)

data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
require.NoError(t, err)
assert.Equal(t, "content", string(data))
}

func TestRunDownload_InvalidFileID(t *testing.T) {
opts := &downloadOptions{}
err := runDownload("not-a-file", opts, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "could not resolve file ID")
}

func TestRunDownload_JSONOutput(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"file": map[string]interface{}{
"id": "F123",
"name": "doc.txt",
"size": 42,
"url_private_download": "https://files.slack.com/download",
},
})
}))
defer server.Close()

c := client.NewWithConfig(server.URL, "test-token", nil)
opts := &downloadOptions{outputPath: "/tmp/doc.txt"}

output.OutputFormat = output.FormatJSON
defer func() { output.OutputFormat = output.FormatText }()

var buf strings.Builder
output.Writer = &buf
defer func() { output.Writer = os.Stdout }()

err := runDownload("F123", opts, c)
require.NoError(t, err)

var result map[string]interface{}
err = json.Unmarshal([]byte(buf.String()), &result)
require.NoError(t, err)

assert.Equal(t, "F123", result["file_id"])
assert.Equal(t, "doc.txt", result["name"])
assert.Equal(t, "/tmp/doc.txt", result["path"])
}

func TestRunDownload_FallsBackToURLPrivate(t *testing.T) {
downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("content"))
}))
defer downloadServer.Close()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"file": map[string]interface{}{
"id": "F123",
"name": "file.txt",
"size": 7,
"url_private": downloadServer.URL + "/file.txt",
// no url_private_download
},
})
}))
defer server.Close()

c := client.NewWithConfig(server.URL, "test-token", nil)
destPath := filepath.Join(t.TempDir(), "file.txt")
opts := &downloadOptions{outputPath: destPath}

err := runDownload("F123", opts, c)
require.NoError(t, err)

data, err := os.ReadFile(destPath)
require.NoError(t, err)
assert.Equal(t, "content", string(data))
}
Loading