diff --git a/internal/client/client.go b/internal/client/client.go index 8ebbee8..b581f58 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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 diff --git a/internal/cmd/files/download.go b/internal/cmd/files/download.go new file mode 100644 index 0000000..6b5e940 --- /dev/null +++ b/internal/cmd/files/download.go @@ -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 ", + 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: ./)") + + 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 "" +} diff --git a/internal/cmd/files/download_test.go b/internal/cmd/files/download_test.go new file mode 100644 index 0000000..11f8da1 --- /dev/null +++ b/internal/cmd/files/download_test.go @@ -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)) +} diff --git a/internal/cmd/files/files.go b/internal/cmd/files/files.go new file mode 100644 index 0000000..741f139 --- /dev/null +++ b/internal/cmd/files/files.go @@ -0,0 +1,15 @@ +package files + +import "github.com/spf13/cobra" + +// NewCmd returns the files command group +func NewCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "files", + Short: "Manage Slack files", + } + + cmd.AddCommand(newDownloadCmd()) + + return cmd +} diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 3230468..6c360e5 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -9,6 +9,7 @@ import ( "github.com/open-cli-collective/slack-chat-api/internal/client" "github.com/open-cli-collective/slack-chat-api/internal/cmd/channels" "github.com/open-cli-collective/slack-chat-api/internal/cmd/config" + "github.com/open-cli-collective/slack-chat-api/internal/cmd/files" "github.com/open-cli-collective/slack-chat-api/internal/cmd/initcmd" "github.com/open-cli-collective/slack-chat-api/internal/cmd/messages" "github.com/open-cli-collective/slack-chat-api/internal/cmd/search" @@ -84,5 +85,6 @@ func init() { rootCmd.AddCommand(workspace.NewCmd()) rootCmd.AddCommand(whoami.NewCmd()) rootCmd.AddCommand(config.NewCmd()) + rootCmd.AddCommand(files.NewCmd()) rootCmd.AddCommand(initcmd.NewCmd()) }