Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: handle Slack Connect image placeholders

Lyric b5c2d262 781dc248

+181 -2
+1 -1
docs/slack.md
··· 89 89 - `message.im` 90 90 - `message.mpim` 91 91 92 - Image attachments arrive through normal message events. The runtime reads Slack file objects from those events and downloads `url_private_download` or `url_private` with the bot token. Slack requires the token used for those URLs to have `files:read`. 92 + Image attachments arrive through normal message events. The runtime reads Slack file objects from those events and downloads `url_private_download` or `url_private` with the bot token. For Slack Connect placeholder files, it calls `files.info` first to load the real file metadata. Slack requires the token used for these file APIs and URLs to have `files:read`. 93 93 94 94 After adding or changing any scope: 95 95
+7
internal/channelruntime/slack/images.go
··· 35 35 if maxBytes <= 0 { 36 36 return "", fmt.Errorf("slack image max bytes must be positive") 37 37 } 38 + if slackFileNeedsInfo(file) { 39 + resolved, err := api.fileInfo(ctx, file.ID) 40 + if err != nil { 41 + return "", err 42 + } 43 + file = resolved 44 + } 38 45 if file.Size > maxBytes { 39 46 return "", fmt.Errorf("slack image too large: %d bytes > %d bytes", file.Size, maxBytes) 40 47 }
+74
internal/channelruntime/slack/images_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 6 + "io" 5 7 "net/http" 6 8 "net/http/httptest" 7 9 "os" 8 10 "path/filepath" 11 + "strings" 9 12 "testing" 10 13 ) 11 14 ··· 44 47 } 45 48 } 46 49 50 + func TestDownloadSlackImageToCacheUsesFilesInfoForSlackConnectPlaceholder(t *testing.T) { 51 + t.Parallel() 52 + 53 + raw := []byte("png-data") 54 + var filesInfoCalled bool 55 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 56 + switch r.URL.Path { 57 + case "/files.info": 58 + filesInfoCalled = true 59 + if r.Header.Get("Authorization") != "Bearer xoxb-token" { 60 + t.Fatalf("authorization = %q, want bot token", r.Header.Get("Authorization")) 61 + } 62 + body, _ := io.ReadAll(r.Body) 63 + if !strings.Contains(string(body), "file=F333") { 64 + t.Fatalf("files.info body = %q, want file=F333", string(body)) 65 + } 66 + w.Header().Set("Content-Type", "application/json") 67 + _ = json.NewEncoder(w).Encode(map[string]any{ 68 + "ok": true, 69 + "file": map[string]any{ 70 + "id": "F333", 71 + "name": "photo.png", 72 + "mimetype": "image/png", 73 + "url_private_download": srvURL(r) + "/file", 74 + "size": len(raw), 75 + }, 76 + }) 77 + case "/file": 78 + if r.Header.Get("Authorization") != "Bearer xoxb-token" { 79 + t.Fatalf("download authorization = %q, want bot token", r.Header.Get("Authorization")) 80 + } 81 + w.Header().Set("Content-Type", "image/png") 82 + _, _ = w.Write(raw) 83 + default: 84 + t.Fatalf("unexpected path: %s", r.URL.Path) 85 + } 86 + })) 87 + defer srv.Close() 88 + 89 + api := newSlackAPI(srv.Client(), srv.URL, "xoxb-token", "xapp-token") 90 + path, err := downloadSlackImageToCache(context.Background(), api, t.TempDir(), slackEventFile{ 91 + ID: "F333", 92 + Mode: "file_access", 93 + FileAccess: "check_file_info", 94 + }, 1024*1024) 95 + if err != nil { 96 + t.Fatalf("downloadSlackImageToCache() error = %v", err) 97 + } 98 + if !filesInfoCalled { 99 + t.Fatalf("files.info was not called") 100 + } 101 + if filepath.Ext(path) != ".png" { 102 + t.Fatalf("extension = %q, want .png", filepath.Ext(path)) 103 + } 104 + got, err := os.ReadFile(path) 105 + if err != nil { 106 + t.Fatalf("read file: %v", err) 107 + } 108 + if string(got) != string(raw) { 109 + t.Fatalf("downloaded content mismatch") 110 + } 111 + } 112 + 47 113 func TestDownloadSlackImageToCacheRejectsUnknownType(t *testing.T) { 48 114 t.Parallel() 49 115 ··· 58 124 t.Fatalf("downloadSlackImageToCache() expected error") 59 125 } 60 126 } 127 + 128 + func srvURL(r *http.Request) string { 129 + scheme := "http" 130 + if r.TLS != nil { 131 + scheme = "https" 132 + } 133 + return scheme + "://" + r.Host 134 + }
+47
internal/channelruntime/slack/runtime_test.go
··· 144 144 } 145 145 } 146 146 147 + func TestParseSlackInboundEventWithSlackConnectImagePlaceholder(t *testing.T) { 148 + t.Parallel() 149 + 150 + payload, err := json.Marshal(map[string]any{ 151 + "team_id": "T111", 152 + "event_id": "Ev05", 153 + "event": map[string]any{ 154 + "type": "message", 155 + "subtype": "file_share", 156 + "user": "U111", 157 + "text": "", 158 + "channel": "C222", 159 + "channel_type": "channel", 160 + "ts": "1739667600.000100", 161 + "files": []map[string]any{ 162 + { 163 + "id": "F333", 164 + "mode": "file_access", 165 + "file_access": "check_file_info", 166 + }, 167 + }, 168 + }, 169 + }) 170 + if err != nil { 171 + t.Fatalf("json.Marshal() error = %v", err) 172 + } 173 + event, ok, err := parseSlackInboundEvent(slackSocketEnvelope{ 174 + Type: "events_api", 175 + Payload: payload, 176 + }, "U999") 177 + if err != nil { 178 + t.Fatalf("parseSlackInboundEvent() error = %v", err) 179 + } 180 + if !ok { 181 + t.Fatalf("parseSlackInboundEvent() ok=false, want true") 182 + } 183 + if len(event.ImageFiles) != 1 { 184 + t.Fatalf("image files len = %d, want 1", len(event.ImageFiles)) 185 + } 186 + if event.ImageFiles[0].ID != "F333" { 187 + t.Fatalf("image file id = %q, want F333", event.ImageFiles[0].ID) 188 + } 189 + if !slackFileNeedsInfo(event.ImageFiles[0]) { 190 + t.Fatalf("image file should require files.info") 191 + } 192 + } 193 + 147 194 func TestParseSlackInboundEventIgnoresNonImageFileShare(t *testing.T) { 148 195 t.Parallel() 149 196
+41
internal/channelruntime/slack/slack_api.go
··· 91 91 Categories json.RawMessage `json:"categories,omitempty"` 92 92 } 93 93 94 + type slackFileInfoResponse struct { 95 + OK bool `json:"ok"` 96 + Error string `json:"error,omitempty"` 97 + File slackEventFile `json:"file,omitempty"` 98 + } 99 + 94 100 var slackEmojiNameRegexp = regexp.MustCompile(`^[A-Za-z0-9_+\-]+$`) 95 101 96 102 func (api *slackAPI) authTest(ctx context.Context) (slackAuthTestResult, error) { ··· 231 237 } 232 238 sort.Strings(names) 233 239 return names, nil 240 + } 241 + 242 + func (api *slackAPI) fileInfo(ctx context.Context, fileID string) (slackEventFile, error) { 243 + if api == nil { 244 + return slackEventFile{}, fmt.Errorf("slack api is not initialized") 245 + } 246 + fileID = strings.TrimSpace(fileID) 247 + if fileID == "" { 248 + return slackEventFile{}, fmt.Errorf("slack file id is required") 249 + } 250 + body, status, _, err := api.postAuthForm(ctx, api.botToken, "/files.info", url.Values{ 251 + "file": []string{fileID}, 252 + }) 253 + if err != nil { 254 + return slackEventFile{}, err 255 + } 256 + if status < 200 || status >= 300 { 257 + return slackEventFile{}, fmt.Errorf("slack files.info http %d", status) 258 + } 259 + var out slackFileInfoResponse 260 + if err := json.Unmarshal(body, &out); err != nil { 261 + return slackEventFile{}, err 262 + } 263 + if !out.OK { 264 + code := strings.TrimSpace(out.Error) 265 + if code == "" { 266 + code = "unknown_error" 267 + } 268 + return slackEventFile{}, fmt.Errorf("slack files.info failed: %s", code) 269 + } 270 + file := out.File 271 + if strings.TrimSpace(file.ID) == "" { 272 + file.ID = fileID 273 + } 274 + return file, nil 234 275 } 235 276 236 277 func collectSlackEmojiNamesFromCategories(raw json.RawMessage, out map[string]bool) {
+11 -1
internal/channelruntime/slack/socket_events.go
··· 50 50 ID string `json:"id,omitempty"` 51 51 Name string `json:"name,omitempty"` 52 52 Title string `json:"title,omitempty"` 53 + Mode string `json:"mode,omitempty"` 54 + FileAccess string `json:"file_access,omitempty"` 53 55 Mimetype string `json:"mimetype,omitempty"` 54 56 Filetype string `json:"filetype,omitempty"` 55 57 URLPrivate string `json:"url_private,omitempty"` ··· 209 211 id := strings.TrimSpace(file.ID) 210 212 url := slackFileDownloadURL(file) 211 213 mimeType := slackFileMIMEType(file) 212 - if url == "" || !strings.HasPrefix(mimeType, "image/") { 214 + if !slackFileNeedsInfo(file) && (url == "" || !strings.HasPrefix(mimeType, "image/")) { 213 215 continue 214 216 } 215 217 key := id ··· 223 225 out = append(out, file) 224 226 } 225 227 return out 228 + } 229 + 230 + func slackFileNeedsInfo(file slackEventFile) bool { 231 + if strings.TrimSpace(file.ID) == "" { 232 + return false 233 + } 234 + return strings.EqualFold(strings.TrimSpace(file.FileAccess), "check_file_info") || 235 + strings.EqualFold(strings.TrimSpace(file.Mode), "file_access") 226 236 } 227 237 228 238 func slackFileDownloadURL(file slackEventFile) string {