Skip to content

Commit f8a6abf

Browse files
authored
Merge pull request #4 from Nullify-Platform/feat/file-upload-download
Add file upload, download, and info commands
2 parents 44ca7f5 + f0211d5 commit f8a6abf

4 files changed

Lines changed: 326 additions & 0 deletions

File tree

cmd/file.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
8+
"github.com/nullify/slack-cli/internal/output"
9+
"github.com/nullify/slack-cli/internal/slack"
10+
"github.com/nullify/slack-cli/internal/urlparse"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var fileCmd = &cobra.Command{
15+
Use: "file",
16+
Short: "Inspect, upload, and download Slack file attachments",
17+
}
18+
19+
var fileInfoCmd = &cobra.Command{
20+
Use: "info <file-id>",
21+
Short: "Fetch metadata for a Slack file",
22+
Args: cobra.ExactArgs(1),
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
client, err := getClient()
25+
if err != nil {
26+
return err
27+
}
28+
info, err := slack.GetFileInfo(cmd.Context(), client, args[0])
29+
if err != nil {
30+
return err
31+
}
32+
return output.PrintJSON(info)
33+
},
34+
}
35+
36+
var fileDownloadCmd = &cobra.Command{
37+
Use: "download <file-id>",
38+
Short: "Download a Slack file to stdout or --output <path>",
39+
Long: `Download a Slack file attachment using its file ID.
40+
41+
The file content is written to stdout by default, or to a file with --output.
42+
File metadata (name, mimetype, size) is printed to stderr so stdout stays clean for piping.
43+
44+
Examples:
45+
slack-cli file download F12345678 > image.png
46+
slack-cli file download F12345678 --output image.png`,
47+
Args: cobra.ExactArgs(1),
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
outputPath, _ := cmd.Flags().GetString("output")
50+
51+
client, err := getClient()
52+
if err != nil {
53+
return err
54+
}
55+
56+
info, err := slack.GetFileInfo(cmd.Context(), client, args[0])
57+
if err != nil {
58+
return err
59+
}
60+
if info.URLPrivate == "" {
61+
return fmt.Errorf("file %s has no downloadable URL (it may be a snippet or external file)", args[0])
62+
}
63+
64+
fmt.Fprintf(os.Stderr, "downloading: %s (%s, %d bytes)\n", info.Name, info.Mimetype, info.Size)
65+
66+
body, _, err := client.Download(cmd.Context(), info.URLPrivate)
67+
if err != nil {
68+
return err
69+
}
70+
defer body.Close()
71+
72+
var dst io.Writer = os.Stdout
73+
if outputPath != "" {
74+
f, err := os.Create(outputPath)
75+
if err != nil {
76+
return fmt.Errorf("creating output file: %w", err)
77+
}
78+
defer f.Close()
79+
dst = f
80+
}
81+
82+
if _, err := io.Copy(dst, body); err != nil {
83+
return fmt.Errorf("writing file content: %w", err)
84+
}
85+
86+
if outputPath != "" {
87+
fmt.Fprintf(os.Stderr, "saved to: %s\n", outputPath)
88+
}
89+
return nil
90+
},
91+
}
92+
93+
var fileUploadCmd = &cobra.Command{
94+
Use: "upload <channel> <file-path>",
95+
Short: "Upload a file to a Slack channel or thread",
96+
Long: `Upload a local file to a Slack channel using the files.getUploadURLExternal API.
97+
98+
The channel can be a channel ID (C...), channel name (#general), or user ID for DMs.
99+
100+
Examples:
101+
slack-cli file upload C01234ABC ./report.pdf --message "Here is the report"
102+
slack-cli file upload "#general" ./image.png --thread-ts 1234567890.123456
103+
slack-cli file upload C01234ABC ./data.csv --title "Q1 Data" --message "See attached"`,
104+
Args: cobra.ExactArgs(2),
105+
RunE: func(cmd *cobra.Command, args []string) error {
106+
message, _ := cmd.Flags().GetString("message")
107+
threadTS, _ := cmd.Flags().GetString("thread-ts")
108+
title, _ := cmd.Flags().GetString("title")
109+
filename, _ := cmd.Flags().GetString("filename")
110+
111+
client, err := getClient()
112+
if err != nil {
113+
return err
114+
}
115+
116+
target := urlparse.ParseMsgTarget(args[0])
117+
channelID, err := resolveTargetToChannel(cmd, client, target)
118+
if err != nil {
119+
return err
120+
}
121+
122+
fmt.Fprintf(os.Stderr, "uploading: %s\n", args[1])
123+
124+
info, err := slack.UploadFile(cmd.Context(), client, slack.UploadOpts{
125+
FilePath: args[1],
126+
ChannelID: channelID,
127+
Filename: filename,
128+
Title: title,
129+
Message: message,
130+
ThreadTS: threadTS,
131+
})
132+
if err != nil {
133+
return err
134+
}
135+
136+
return output.PrintJSON(info)
137+
},
138+
}
139+
140+
func init() {
141+
rootCmd.AddCommand(fileCmd)
142+
fileCmd.AddCommand(fileInfoCmd, fileDownloadCmd, fileUploadCmd)
143+
144+
fileDownloadCmd.Flags().String("output", "", "Write file content to this path instead of stdout")
145+
146+
fileUploadCmd.Flags().String("message", "", "Optional message to post alongside the file")
147+
fileUploadCmd.Flags().String("thread-ts", "", "Thread root ts to upload into a thread")
148+
fileUploadCmd.Flags().String("title", "", "Display title for the file (defaults to filename)")
149+
fileUploadCmd.Flags().String("filename", "", "Override the filename sent to Slack (defaults to basename of file-path)")
150+
}

internal/api/client.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,53 @@ func (c *Client) Call(ctx context.Context, method string, params map[string]stri
132132
return nil, fmt.Errorf("max retries exceeded for %s", method)
133133
}
134134

135+
// UploadToURL POSTs raw file content to a pre-signed Slack upload URL.
136+
// The upload URL itself is authorization — no Slack auth headers are sent.
137+
func (c *Client) UploadToURL(ctx context.Context, uploadURL string, r io.Reader, size int64, contentType string) error {
138+
req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, r)
139+
if err != nil {
140+
return fmt.Errorf("building upload request: %w", err)
141+
}
142+
req.ContentLength = size
143+
req.Header.Set("Content-Type", contentType)
144+
req.Header.Set("User-Agent", userAgent)
145+
146+
resp, err := c.httpClient.Do(req)
147+
if err != nil {
148+
return fmt.Errorf("uploading file: %w", err)
149+
}
150+
body, _ := io.ReadAll(resp.Body)
151+
resp.Body.Close()
152+
if resp.StatusCode != http.StatusOK {
153+
return fmt.Errorf("upload failed with HTTP %d: %s", resp.StatusCode, string(body))
154+
}
155+
return nil
156+
}
157+
158+
// Download performs an authenticated GET on a private Slack URL (e.g. url_private)
159+
// and returns the response body. The caller must close it.
160+
func (c *Client) Download(ctx context.Context, privateURL string) (io.ReadCloser, string, error) {
161+
req, err := http.NewRequestWithContext(ctx, "GET", privateURL, nil)
162+
if err != nil {
163+
return nil, "", fmt.Errorf("building download request: %w", err)
164+
}
165+
req.Header.Set("User-Agent", userAgent)
166+
req.Header.Set("Authorization", "Bearer "+c.auth.Token)
167+
if c.auth.Mode == types.AuthBrowser {
168+
req.Header.Set("Cookie", "d="+url.QueryEscape(c.auth.Cookie))
169+
}
170+
171+
resp, err := c.httpClient.Do(req)
172+
if err != nil {
173+
return nil, "", fmt.Errorf("downloading file: %w", err)
174+
}
175+
if resp.StatusCode != http.StatusOK {
176+
resp.Body.Close()
177+
return nil, "", fmt.Errorf("download failed with HTTP %d", resp.StatusCode)
178+
}
179+
return resp.Body, resp.Header.Get("Content-Type"), nil
180+
}
181+
135182
func parseRetryAfter(header string, defaultSec int) int {
136183
if header == "" {
137184
return defaultSec

internal/slack/files.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package slack
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"mime"
8+
"os"
9+
"path/filepath"
10+
"strconv"
11+
12+
"github.com/nullify/slack-cli/internal/api"
13+
"github.com/nullify/slack-cli/internal/types"
14+
)
15+
16+
// UploadOpts controls how a file is uploaded to Slack.
17+
type UploadOpts struct {
18+
FilePath string
19+
ChannelID string
20+
Filename string // overrides the base name of FilePath
21+
Title string
22+
Message string
23+
ThreadTS string
24+
}
25+
26+
// UploadFile uploads a local file to Slack using the three-step external upload API:
27+
// files.getUploadURLExternal → POST to signed URL → files.completeUploadExternal.
28+
func UploadFile(ctx context.Context, client *api.Client, opts UploadOpts) (*types.FileInfo, error) {
29+
stat, err := os.Stat(opts.FilePath)
30+
if err != nil {
31+
return nil, fmt.Errorf("reading file: %w", err)
32+
}
33+
34+
filename := opts.Filename
35+
if filename == "" {
36+
filename = filepath.Base(opts.FilePath)
37+
}
38+
title := opts.Title
39+
if title == "" {
40+
title = filename
41+
}
42+
43+
contentType := mime.TypeByExtension(filepath.Ext(filename))
44+
if contentType == "" {
45+
contentType = "application/octet-stream"
46+
}
47+
48+
// Step 1: get a pre-signed upload URL and file ID.
49+
urlResp, err := client.Call(ctx, "files.getUploadURLExternal", map[string]string{
50+
"filename": filename,
51+
"length": strconv.FormatInt(stat.Size(), 10),
52+
})
53+
if err != nil {
54+
return nil, fmt.Errorf("files.getUploadURLExternal: %w", err)
55+
}
56+
uploadURL := api.GetString(urlResp["upload_url"])
57+
fileID := api.GetString(urlResp["file_id"])
58+
if uploadURL == "" || fileID == "" {
59+
return nil, fmt.Errorf("files.getUploadURLExternal: missing upload_url or file_id in response")
60+
}
61+
62+
// Step 2: stream file content to the pre-signed URL.
63+
f, err := os.Open(opts.FilePath)
64+
if err != nil {
65+
return nil, fmt.Errorf("opening file: %w", err)
66+
}
67+
defer f.Close()
68+
69+
if err := client.UploadToURL(ctx, uploadURL, f, stat.Size(), contentType); err != nil {
70+
return nil, err
71+
}
72+
73+
// Step 3: complete the upload, optionally sharing to a channel.
74+
filesParam, err := json.Marshal([]map[string]string{{"id": fileID, "title": title}})
75+
if err != nil {
76+
return nil, fmt.Errorf("encoding files param: %w", err)
77+
}
78+
completeParams := map[string]string{
79+
"files": string(filesParam),
80+
"channel_id": opts.ChannelID,
81+
"initial_comment": opts.Message,
82+
"thread_ts": opts.ThreadTS,
83+
}
84+
if _, err := client.Call(ctx, "files.completeUploadExternal", completeParams); err != nil {
85+
return nil, fmt.Errorf("files.completeUploadExternal: %w", err)
86+
}
87+
88+
return GetFileInfo(ctx, client, fileID)
89+
}
90+
91+
// GetFileInfo calls files.info and returns compact file metadata.
92+
func GetFileInfo(ctx context.Context, client *api.Client, fileID string) (*types.FileInfo, error) {
93+
resp, err := client.Call(ctx, "files.info", map[string]string{"file": fileID})
94+
if err != nil {
95+
return nil, fmt.Errorf("files.info: %w", err)
96+
}
97+
98+
f := api.GetMap(resp["file"])
99+
if f == nil {
100+
return nil, fmt.Errorf("files.info: missing file object in response")
101+
}
102+
103+
return &types.FileInfo{
104+
ID: api.GetStringFromMap(f, "id"),
105+
Name: api.GetStringFromMap(f, "name"),
106+
Title: api.GetStringFromMap(f, "title"),
107+
Mimetype: api.GetStringFromMap(f, "mimetype"),
108+
Filetype: api.GetStringFromMap(f, "filetype"),
109+
Size: api.GetIntFromMap(f, "size"),
110+
URLPrivate: api.GetStringFromMap(f, "url_private"),
111+
Permalink: api.GetStringFromMap(f, "permalink"),
112+
Created: int64(api.GetIntFromMap(f, "created")),
113+
User: api.GetStringFromMap(f, "user"),
114+
}, nil
115+
}

internal/types/types.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ type CompactFile struct {
8282
Size int `json:"size,omitempty"`
8383
}
8484

85+
// FileInfo is the token-efficient file metadata from files.info.
86+
type FileInfo struct {
87+
ID string `json:"id"`
88+
Name string `json:"name,omitempty"`
89+
Title string `json:"title,omitempty"`
90+
Mimetype string `json:"mimetype,omitempty"`
91+
Filetype string `json:"filetype,omitempty"`
92+
Size int `json:"size,omitempty"`
93+
URLPrivate string `json:"url_private,omitempty"`
94+
Permalink string `json:"permalink,omitempty"`
95+
Created int64 `json:"created,omitempty"`
96+
User string `json:"user,omitempty"`
97+
}
98+
8599
// CompactReaction is the token-efficient reaction format.
86100
type CompactReaction struct {
87101
Name string `json:"name"`

0 commit comments

Comments
 (0)