From 34db83dc322090376ff49fbfdbce677127194d58 Mon Sep 17 00:00:00 2001 From: "LocalAI [bot]" <139863280+localai-bot@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:59:37 +0100 Subject: [PATCH] fix(slack): correct thread timestamp and improve file upload handling (#447) Root cause: uploadJobResultFiles was passing msgTs (placeholder reply timestamp) as thread_ts for file uploads. Slack's API rejects this - it requires the parent thread timestamp, not a reply's timestamp. Changes: 1. Use 'ts' (thread root timestamp) instead of 'msgTs' in replyToUpdateMessage 2. Fix type handling in attachmentsFromMetadataOnly to handle []interface{} 3. Download and upload generated images (e.g., DALL-E) as files instead of just adding link attachments, to preserve temporary URLs 4. Remove dead code: generateAttachmentsFromJobResponse was never called This matches Telegram connector behavior where files are properly uploaded rather than just referenced by URL. Co-authored-by: localai-bot --- services/connectors/slack.go | 77 +++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 27 deletions(-) diff --git a/services/connectors/slack.go b/services/connectors/slack.go index 45f6824..5d5499a 100644 --- a/services/connectors/slack.go +++ b/services/connectors/slack.go @@ -4,7 +4,9 @@ import ( "bytes" "encoding/base64" "fmt" + "io" "log" + "net/http" "os" "path/filepath" "strings" @@ -176,25 +178,21 @@ func attachmentsFromMetadataOnly(metadata map[string]interface{}) (attachments [ return nil } if urls, exists := metadata[actions.MetadataUrls]; exists { - if sl, ok := urls.([]string); ok { - for _, url := range xstrings.UniqueSlice(sl) { - attachments = append(attachments, slack.Attachment{ - Title: "URL", - TitleLink: url, - Text: url, - }) - } + for _, url := range xstrings.UniqueSlice(stringSliceFromMetadata(urls)) { + attachments = append(attachments, slack.Attachment{ + Title: "URL", + TitleLink: url, + Text: url, + }) } } if imagesUrls, exists := metadata[actions.MetadataImages]; exists { - if sl, ok := imagesUrls.([]string); ok { - for _, url := range xstrings.UniqueSlice(sl) { - attachments = append(attachments, slack.Attachment{ - Title: "Image", - TitleLink: url, - ImageURL: url, - }) - } + for _, url := range xstrings.UniqueSlice(stringSliceFromMetadata(imagesUrls)) { + attachments = append(attachments, slack.Attachment{ + Title: "Image", + TitleLink: url, + ImageURL: url, + }) } } return attachments @@ -295,6 +293,39 @@ func uploadFilesFromMetadata(metadata map[string]interface{}, api *slack.Client, } } } + // Handle generated images (download from URL and upload as file, so temporary URLs like DALL-E are preserved) + if imageUrls, exists := metadata[actions.MetadataImages]; exists { + sl := stringSliceFromMetadata(imageUrls) + for _, imgURL := range xstrings.UniqueSlice(sl) { + resp, err := http.Get(imgURL) + if err != nil { + xlog.Error("Error downloading image for Slack upload", "url", imgURL, "error", err) + continue + } + data, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + xlog.Error("Error reading image body for Slack upload", "url", imgURL, "error", err) + continue + } + if len(data) == 0 { + xlog.Error("Empty image body for Slack upload", "url", imgURL) + continue + } + _, err = api.UploadFileV2(slack.UploadFileV2Parameters{ + Reader: bytes.NewReader(data), + FileSize: len(data), + ThreadTimestamp: threadTs, + Channel: channelID, + Filename: "image.png", + Title: "Generated image", + InitialComment: "Generated image", + }) + if err != nil { + xlog.Error("Slack UploadFileV2 failed for image", "error", err, "url", imgURL) + } + } + } } // attachmentsAndUploadsFromMetadata returns link/image attachments and uploads files (songs, PDFs) @@ -329,16 +360,6 @@ func uploadJobResultFiles(res *types.JobResult, api *slack.Client, channelID, th } } -func generateAttachmentsFromJobResponse(j *types.JobResult, api *slack.Client, channelID, ts string) (attachments []slack.Attachment) { - if j == nil { - return nil - } - for _, state := range j.State { - attachments = append(attachments, attachmentsAndUploadsFromMetadata(state.Metadata, api, channelID, ts)...) - } - return attachments -} - // ImageData represents a single image with its metadata type ImageData struct { Data []byte @@ -641,7 +662,9 @@ func replyWithPostMessage(finalResponse string, api *slack.Client, ev *slackeven func replyToUpdateMessage(finalResponse string, api *slack.Client, ev *slackevents.AppMentionEvent, msgTs string, ts string, postMessageParams slack.PostMessageParameters, res *types.JobResult) { attachments := attachmentsFromJobResponseOnly(res) - uploadJobResultFiles(res, api, ev.Channel, msgTs) + // Use the thread root timestamp (ts), not the placeholder reply timestamp (msgTs). + // Slack API: "Never use a reply's ts value; use its parent instead." + uploadJobResultFiles(res, api, ev.Channel, ts) if len(finalResponse) > 3000 { messages := xstrings.SplitParagraph(finalResponse, 3000)