diff --git a/receivers/slack/slack.go b/receivers/slack/slack.go index a90278e3..dd108b3e 100644 --- a/receivers/slack/slack.go +++ b/receivers/slack/slack.go @@ -8,12 +8,10 @@ import ( "errors" "fmt" "io" - "mime/multipart" "net" "net/http" "net/url" "os" - "path" "strings" "time" @@ -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 { @@ -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 @@ -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 { diff --git a/receivers/slack/slack_test.go b/receivers/slack/slack_test.go index bd0f8b85..a5b9207d 100644 --- a/receivers/slack/slack_test.go +++ b/receivers/slack/slack_test.go @@ -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{