Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: deprecated slack files.upload is replaced with new slack bot APIs #242

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
240 changes: 184 additions & 56 deletions receivers/slack/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ import (
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"

Expand Down Expand Up @@ -74,20 +72,111 @@ type Notifier struct {
appVersion string
}

type UploadURLResponse struct {
OK bool `json:"ok"`
URL string `json:"upload_url"`
FileID string `json:"file_id"`
Error string `json:"error,omitempty"`
}

type CompleteUploadResponse struct {
OK bool `json:"ok"`
Files []struct {
Id string `json:"id"`
Created int `json:"created"`
Timestamp int `json:"timestamp"`
Name string `json:"name"`
Title string `json:"title"`
Mimetype string `json:"mimetype"`
Filetype string `json:"filetype"`
PrettyType string `json:"pretty_type"`
User string `json:"user"`
UserTeam string `json:"user_team"`
Editable bool `json:"editable"`
Size int `json:"size"`
Mode string `json:"mode"`
IsExternal bool `json:"is_external"`
ExternalType string `json:"external_type"`
IsPublic bool `json:"is_public"`
PublicUrlShared bool `json:"public_url_shared"`
DisplayAsBot bool `json:"display_as_bot"`
Username string `json:"username"`
UrlPrivate string `json:"url_private"`
UrlPrivateDownload string `json:"url_private_download"`
Permalink string `json:"permalink"`
PermalinkPublic string `json:"permalink_public"`
EditLink string `json:"edit_link"`
Preview string `json:"preview"`
PreviewHighlight string `json:"preview_highlight"`
Lines int `json:"lines"`
LinesMore int `json:"lines_more"`
PreviewIsTruncated bool `json:"preview_is_truncated"`
CommentsCount int `json:"comments_count"`
IsStarred bool `json:"is_starred"`
Shares struct {
} `json:"shares"`
Channels []interface{} `json:"channels"`
Groups []interface{} `json:"groups"`
Ims []interface{} `json:"ims"`
HasMoreShares bool `json:"has_more_shares"`
HasRichPreview bool `json:"has_rich_preview"`
FileAccess string `json:"file_access"`
} `json:"files"`
Error string `json:"error,omitempty"`
}

// isIncomingWebhook returns true if the settings are for an incoming webhook.
func isIncomingWebhook(s Config) bool {
return s.Token == ""
}

// uploadURL returns the upload URL for Slack.
func uploadURL(s Config) (string, error) {
u, err := url.Parse(s.URL)
//func uploadURL(s Config) (string, error) {
// u, err := url.Parse(s.URL)
// if err != nil {
// return "", fmt.Errorf("failed to parse URL: %w", err)
// }
// dir, _ := path.Split(u.Path)
// u.Path = path.Join(dir, "files.upload")
// return u.String(), nil
//}

func uploadURL(botToken, channel, filename string, length int64) (string, string, error) {
baseUrl := "https://slack.com/api/files.getUploadURLExternal"
params := url.Values{}
params.Add("channels", channel)
params.Add("filename", filename)
params.Add("length", fmt.Sprintf("%d", length))

reqURL := fmt.Sprintf("%s?%s", baseUrl, params.Encode())
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %v", err)
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", botToken))

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", "", fmt.Errorf("failed to make request: %v", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
return "", "", fmt.Errorf("failed to read response body: %v", err)
}

var uploadURLResp UploadURLResponse
if err := json.NewDecoder(bytes.NewReader(body)).Decode(&uploadURLResp); err != nil {
return "", "", fmt.Errorf("failed to decode response: %v", err)
}
dir, _ := path.Split(u.Path)
u.Path = path.Join(dir, "files.upload")
return u.String(), nil

if !uploadURLResp.OK {
return "", "", fmt.Errorf("failed to get upload URL: %s", uploadURLResp.Error)
}

return uploadURLResp.URL, uploadURLResp.FileID, nil
}

func New(cfg Config, meta receivers.Metadata, template *templates.Template, sender receivers.WebhookSender, images images.Provider, logger logging.Logger, appVersion string) *Notifier {
Expand Down Expand Up @@ -427,75 +516,100 @@ func (sn *Notifier) sendSlackMessage(ctx context.Context, m *slackMessage) (stri
// createImageMultipart returns the mutlipart/form-data request and headers for files.upload.
// It returns an error if the image does not exist or there was an error preparing the
// multipart form.
func (sn *Notifier) createImageMultipart(image images.Image, channel, comment, threadTs string) (http.Header, []byte, error) {
buf := bytes.Buffer{}
w := multipart.NewWriter(&buf)
defer func() {
if err := w.Close(); err != nil {
sn.log.Error("Failed to close multipart writer", "err", err)
}
}()

f, err := os.Open(image.Path)
func (sn *Notifier) createImageMultipart(uploadURL, filePath, botToken string) error {
file, err := os.Open(filePath)
if err != nil {
return nil, nil, err
return fmt.Errorf("failed to open file: %w", err)
}
defer func() {
if err := f.Close(); err != nil {
sn.log.Error("Failed to close image file reader", "error", err)
}
}()
defer file.Close()

fw, err := w.CreateFormFile("file", image.Path)
req, err := http.NewRequest("POST", uploadURL, file)
if err != nil {
return nil, nil, fmt.Errorf("failed to create form file: %w", err)
return fmt.Errorf("failed to create request: %w", err)
}

if _, err := io.Copy(fw, f); err != nil {
return nil, nil, fmt.Errorf("failed to copy file to form: %w", err)
req.Header.Set("Content-Type", "application/octet-stream")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", botToken))

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to upload file: %w", err)
}
defer resp.Body.Close()

if err := w.WriteField("channels", channel); err != nil {
return nil, nil, fmt.Errorf("failed to write channels to form: %w", err)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to upload file, status code: %d", resp.StatusCode)
}

if err := w.WriteField("initial_comment", comment); err != nil {
return nil, nil, fmt.Errorf("failed to write initial_comment to form: %w", err)
return nil
}

func (sn *Notifier) sendMultipart(botToken, fileID, fileName, channelId, threadTs, comment string) error {
sn.log.Debug("Sending multipart request")

baseUrl := "https://slack.com/api/files.completeUploadExternal"

// Prepare the payload with file details
filesPayload := fmt.Sprintf(`[{"id": "%s", "title": "%s"}]`, fileID, fileName)
data := url.Values{}
data.Set("files", filesPayload)
data.Set("channel_id", channelId)
data.Set("thread_ts", threadTs) // Add threadTs here

req, err := http.NewRequest("POST", baseUrl, bytes.NewBufferString(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create request: %v", err)
}

if err := w.WriteField("thread_ts", threadTs); err != nil {
return nil, nil, fmt.Errorf("failed to write thread_ts to form: %w", err)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", botToken))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to complete upload: %v", err)
}
defer resp.Body.Close()

if err := w.Close(); err != nil {
return nil, nil, fmt.Errorf("failed to close multipart writer: %w", err)
var completeUploadResp CompleteUploadResponse
if err := json.NewDecoder(resp.Body).Decode(&completeUploadResp); err != nil {
return fmt.Errorf("failed to decode response: %v", err)
}

b := buf.Bytes()
headers := http.Header{}
headers.Set("Content-Type", w.FormDataContentType())
return headers, b, nil
}
if !completeUploadResp.OK {
return fmt.Errorf("failed to complete upload: %s", completeUploadResp.Error)
}

func (sn *Notifier) sendMultipart(ctx context.Context, headers http.Header, data io.Reader) error {
sn.log.Debug("Sending multipart request to files.upload")
// Send the message with the file attachment
messageUrl := "https://slack.com/api/chat.postMessage"
messageData := url.Values{}
messageData.Set("channel", channelId)
messageData.Set("text", comment)
messageData.Set("thread_ts", threadTs)
messageData.Set("attachments", fmt.Sprintf(`[{"id": "%s"}]`, fileID))

u, err := uploadURL(sn.settings)
messageReq, err := http.NewRequest("POST", messageUrl, bytes.NewBufferString(messageData.Encode()))
if err != nil {
return fmt.Errorf("failed to get URL for files.upload: %w", err)
return fmt.Errorf("failed to create message request: %v", err)
}

req, err := http.NewRequest(http.MethodPost, u, data)
messageReq.Header.Add("Authorization", fmt.Sprintf("Bearer %s", botToken))
messageReq.Header.Add("Content-Type", "application/x-www-form-urlencoded")

messageResp, err := client.Do(messageReq)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
return fmt.Errorf("failed to send message: %v", err)
}
for k, v := range headers {
req.Header[k] = v
defer messageResp.Body.Close()

var messageRespBody map[string]interface{}
if err := json.NewDecoder(messageResp.Body).Decode(&messageRespBody); err != nil {
return fmt.Errorf("failed to decode message response: %v", err)
}
req.Header.Set("Authorization", "Bearer "+sn.settings.Token)

if _, err := sn.sendFn(ctx, req, sn.log); err != nil {
return fmt.Errorf("failed to send request: %w", err)
if !messageRespBody["ok"].(bool) {
return fmt.Errorf("failed to send message: %s", messageRespBody["error"].(string))
}

return nil
Expand All @@ -505,13 +619,27 @@ func (sn *Notifier) sendMultipart(ctx context.Context, headers http.Header, data
// does not exist, or if there was an error either preparing or sending the multipart/form-data
// request.
func (sn *Notifier) uploadImage(ctx context.Context, image images.Image, channel, comment, threadTs string) error {
sn.log.Debug("Uploadimg image", "image", image.Token)
headers, data, err := sn.createImageMultipart(image, channel, comment, threadTs)
sn.log.Debug("Uploading image", "image", image.Token)
var err error

fileInfo, err := os.Stat(image.Path)
if err != nil {
return fmt.Errorf("failed to get file info: %w", err)
}
length := fileInfo.Size()

fileUrl, fileID, err := uploadURL(image.Token, channel, image.Path, length)
if err != nil {
return fmt.Errorf("error getting upload URL: %w", err)
}

// Upload the file
err = sn.createImageMultipart(fileUrl, image.Path, image.Token)
if err != nil {
return fmt.Errorf("failed to create multipart form: %w", err)
return fmt.Errorf("error uploading file: %w", err)
}

return sn.sendMultipart(ctx, headers, bytes.NewReader(data))
return sn.sendMultipart(image.Token, fileID, image.Path, channel, threadTs, comment)
}

func (sn *Notifier) SendResolved() bool {
Expand Down
9 changes: 0 additions & 9 deletions receivers/slack/slack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,15 +460,6 @@ func TestNotify_PostMessage(t *testing.T) {
},
},
},
expectedReplies: []interface{}{
// check that the following parts are present in the multipart/form-data
map[string]struct{}{
"file": {},
"channels": {},
"initial_comment": {},
"thread_ts": {},
},
},
}, {
name: "Errors in templates, message is sent",
settings: Config{
Expand Down