Unified Agent + reusable Go agent core.
0
fork

Configure Feed

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

feat: add slack working message updates

Lyric 17bee38c 10f5ae6b

+544 -39
+59 -12
internal/channelruntime/slack/runtime.go
··· 433 433 h = nil 434 434 } 435 435 runtimecore.MarkTaskRunning(daemonStore, job.TaskID) 436 + workingMessage := startSlackWorkingMessage(workerCtx, logger, api, job) 436 437 runCtx, cancel := context.WithTimeout(workerCtx, taskTimeout) 437 438 final, _, loadedSkills, reaction, runErr := runSlackTask( 438 439 runCtx, ··· 480 481 }) 481 482 errorText := "error: " + displayErr 482 483 errorCorrelationID := fmt.Sprintf("slack:error:%s:%s", job.ChannelID, job.MessageTS) 484 + if updated, updateErr := workingMessage.Update(workerCtx, errorText); updated { 485 + if updateErr == nil { 486 + callSlackDirectOutboundHook(workerCtx, logger, hooks, job, errorText, errorCorrelationID) 487 + return 488 + } 489 + logger.Warn("slack_working_message_update_error", "channel", busruntime.ChannelSlack, "channel_id", job.ChannelID, "message_ts", job.MessageTS, "error", updateErr.Error()) 490 + callErrorHook(workerCtx, logger, hooks, ErrorEvent{ 491 + Stage: ErrorStagePublishErrorReply, 492 + ConversationKey: job.ConversationKey, 493 + TeamID: job.TeamID, 494 + ChannelID: job.ChannelID, 495 + MessageTS: job.MessageTS, 496 + Err: updateErr, 497 + }) 498 + } 483 499 _, err := publishSlackBusOutbound( 484 500 workerCtx, 485 501 inprocBus, ··· 510 526 return 511 527 } 512 528 outCorrelationID := fmt.Sprintf("slack:message:%s:%s", job.ChannelID, job.MessageTS) 513 - _, err := publishSlackBusOutbound( 514 - workerCtx, 515 - inprocBus, 516 - job.TeamID, 517 - job.ChannelID, 518 - outText, 519 - job.ThreadTS, 520 - outCorrelationID, 521 - ) 522 - if err != nil { 523 - logger.Warn("slack_bus_publish_error", "channel", busruntime.ChannelSlack, "channel_id", job.ChannelID, "bus_error_code", busErrorCodeString(err), "error", err.Error()) 529 + deliveredByUpdate := false 530 + if updated, updateErr := workingMessage.Update(workerCtx, outText); updated { 531 + if updateErr == nil { 532 + callSlackDirectOutboundHook(workerCtx, logger, hooks, job, outText, outCorrelationID) 533 + deliveredByUpdate = true 534 + } else { 535 + logger.Warn("slack_working_message_update_error", "channel", busruntime.ChannelSlack, "channel_id", job.ChannelID, "message_ts", job.MessageTS, "error", updateErr.Error()) 536 + callErrorHook(workerCtx, logger, hooks, ErrorEvent{ 537 + Stage: ErrorStagePublishOutbound, 538 + ConversationKey: job.ConversationKey, 539 + TeamID: job.TeamID, 540 + ChannelID: job.ChannelID, 541 + MessageTS: job.MessageTS, 542 + Err: updateErr, 543 + }) 544 + } 545 + } 546 + if !deliveredByUpdate { 547 + _, err := publishSlackBusOutbound( 548 + workerCtx, 549 + inprocBus, 550 + job.TeamID, 551 + job.ChannelID, 552 + outText, 553 + job.ThreadTS, 554 + outCorrelationID, 555 + ) 556 + if err != nil { 557 + logger.Warn("slack_bus_publish_error", "channel", busruntime.ChannelSlack, "channel_id", job.ChannelID, "bus_error_code", busErrorCodeString(err), "error", err.Error()) 558 + callErrorHook(workerCtx, logger, hooks, ErrorEvent{ 559 + Stage: ErrorStagePublishOutbound, 560 + ConversationKey: job.ConversationKey, 561 + TeamID: job.TeamID, 562 + ChannelID: job.ChannelID, 563 + MessageTS: job.MessageTS, 564 + Err: err, 565 + }) 566 + } 567 + } 568 + } else if workerCtx.Err() == nil { 569 + if updated, updateErr := workingMessage.Update(workerCtx, slackDoneMessageText); updated && updateErr != nil { 570 + logger.Warn("slack_working_message_update_error", "channel", busruntime.ChannelSlack, "channel_id", job.ChannelID, "message_ts", job.MessageTS, "error", updateErr.Error()) 524 571 callErrorHook(workerCtx, logger, hooks, ErrorEvent{ 525 572 Stage: ErrorStagePublishOutbound, 526 573 ConversationKey: job.ConversationKey, 527 574 TeamID: job.TeamID, 528 575 ChannelID: job.ChannelID, 529 576 MessageTS: job.MessageTS, 530 - Err: err, 577 + Err: updateErr, 531 578 }) 532 579 } 533 580 }
+137
internal/channelruntime/slack/runtime_working.go
··· 1 + package slack 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "strings" 7 + "sync" 8 + "time" 9 + ) 10 + 11 + const ( 12 + slackWorkingMessageText = "working..." 13 + slackDoneMessageText = "done." 14 + slackWorkingMessageDelay = 1200 * time.Millisecond 15 + slackWorkingMessagePostTimeout = 5 * time.Second 16 + slackWorkingMessageUpdateTimeout = 5 * time.Second 17 + ) 18 + 19 + type slackWorkingMessage struct { 20 + api *slackAPI 21 + logger *slog.Logger 22 + channelID string 23 + threadTS string 24 + messageTS string 25 + 26 + stopOnce sync.Once 27 + stop chan struct{} 28 + result chan slackWorkingMessagePostResult 29 + } 30 + 31 + type slackWorkingMessagePostResult struct { 32 + ref slackMessageRef 33 + err error 34 + } 35 + 36 + func startSlackWorkingMessage(ctx context.Context, logger *slog.Logger, api *slackAPI, job slackJob) *slackWorkingMessage { 37 + return startSlackWorkingMessageWithDelay(ctx, logger, api, job, slackWorkingMessageDelay) 38 + } 39 + 40 + func startSlackWorkingMessageWithDelay(ctx context.Context, logger *slog.Logger, api *slackAPI, job slackJob, delay time.Duration) *slackWorkingMessage { 41 + if ctx == nil { 42 + ctx = context.Background() 43 + } 44 + channelID := strings.TrimSpace(job.ChannelID) 45 + if api == nil || channelID == "" { 46 + return nil 47 + } 48 + w := &slackWorkingMessage{ 49 + api: api, 50 + logger: logger, 51 + channelID: channelID, 52 + threadTS: strings.TrimSpace(job.ThreadTS), 53 + messageTS: strings.TrimSpace(job.MessageTS), 54 + stop: make(chan struct{}), 55 + result: make(chan slackWorkingMessagePostResult, 1), 56 + } 57 + go w.run(ctx, delay) 58 + return w 59 + } 60 + 61 + func (w *slackWorkingMessage) run(ctx context.Context, delay time.Duration) { 62 + defer close(w.result) 63 + if delay > 0 { 64 + timer := time.NewTimer(delay) 65 + select { 66 + case <-timer.C: 67 + case <-w.stop: 68 + timer.Stop() 69 + return 70 + case <-ctx.Done(): 71 + timer.Stop() 72 + return 73 + } 74 + } 75 + 76 + postCtx, cancel := context.WithTimeout(ctx, slackWorkingMessagePostTimeout) 77 + defer cancel() 78 + ref, err := w.api.postMessageWithResult(postCtx, w.channelID, slackWorkingMessageText, w.threadTS) 79 + w.result <- slackWorkingMessagePostResult{ref: ref, err: err} 80 + if err != nil && w.logger != nil { 81 + w.logger.Warn("slack_working_message_post_error", 82 + "channel_id", w.channelID, 83 + "message_ts", w.messageTS, 84 + "error", err.Error(), 85 + ) 86 + } 87 + } 88 + 89 + func (w *slackWorkingMessage) Update(ctx context.Context, text string) (bool, error) { 90 + if w == nil { 91 + return false, nil 92 + } 93 + w.stopOnce.Do(func() { 94 + close(w.stop) 95 + }) 96 + 97 + text = strings.TrimSpace(text) 98 + if text == "" { 99 + text = slackDoneMessageText 100 + } 101 + 102 + result, ok := <-w.result 103 + if !ok || result.err != nil || strings.TrimSpace(result.ref.MessageTS) == "" { 104 + return false, nil 105 + } 106 + ref := result.ref 107 + if strings.TrimSpace(ref.ChannelID) == "" { 108 + ref.ChannelID = w.channelID 109 + } 110 + if ctx == nil { 111 + ctx = context.Background() 112 + } 113 + updateCtx, cancel := context.WithTimeout(ctx, slackWorkingMessageUpdateTimeout) 114 + defer cancel() 115 + return true, w.api.updateMessage(updateCtx, ref.ChannelID, ref.MessageTS, text) 116 + } 117 + 118 + func callSlackDirectOutboundHook(ctx context.Context, logger *slog.Logger, hooks Hooks, job slackJob, text, correlationID string) { 119 + if hooks.OnOutbound == nil { 120 + return 121 + } 122 + conversationKey := strings.TrimSpace(job.ConversationKey) 123 + if conversationKey == "" { 124 + if key, err := buildSlackConversationKey(job.TeamID, job.ChannelID); err == nil { 125 + conversationKey = key 126 + } 127 + } 128 + callOutboundHook(ctx, logger, hooks, OutboundEvent{ 129 + ConversationKey: conversationKey, 130 + TeamID: strings.TrimSpace(job.TeamID), 131 + ChannelID: strings.TrimSpace(job.ChannelID), 132 + ThreadTS: strings.TrimSpace(job.ThreadTS), 133 + Text: strings.TrimSpace(text), 134 + CorrelationID: strings.TrimSpace(correlationID), 135 + Kind: slackOutboundKind(correlationID), 136 + }) 137 + }
+109
internal/channelruntime/slack/runtime_working_test.go
··· 1 + package slack 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "sync" 10 + "testing" 11 + "time" 12 + ) 13 + 14 + func TestSlackWorkingMessageSkipsPostBeforeDelay(t *testing.T) { 15 + var callCount int 16 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 + callCount++ 18 + _ = json.NewEncoder(w).Encode(map[string]any{ 19 + "ok": true, 20 + "channel": "C123", 21 + "ts": "1739667601.000200", 22 + }) 23 + })) 24 + defer server.Close() 25 + 26 + api := newSlackAPI(server.Client(), server.URL, "xoxb-test", "xapp-test") 27 + working := startSlackWorkingMessageWithDelay(context.Background(), nil, api, slackJob{ 28 + ChannelID: "C123", 29 + ThreadTS: "1739667600.000100", 30 + MessageTS: "1739667600.000100", 31 + }, time.Hour) 32 + updated, err := working.Update(context.Background(), "done") 33 + if err != nil { 34 + t.Fatalf("Update() error = %v", err) 35 + } 36 + if updated { 37 + t.Fatalf("updated = true, want false") 38 + } 39 + if callCount != 0 { 40 + t.Fatalf("call count = %d, want 0", callCount) 41 + } 42 + } 43 + 44 + func TestSlackWorkingMessageUpdatesPostedMessage(t *testing.T) { 45 + var ( 46 + mu sync.Mutex 47 + paths []string 48 + finalMsg string 49 + ) 50 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 51 + mu.Lock() 52 + paths = append(paths, r.URL.Path) 53 + mu.Unlock() 54 + 55 + switch r.URL.Path { 56 + case "/chat.postMessage": 57 + var payload map[string]any 58 + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 59 + t.Fatalf("decode post payload: %v", err) 60 + } 61 + if got := strings.TrimSpace(payload["text"].(string)); got != slackWorkingMessageText { 62 + t.Fatalf("post text = %q, want %q", got, slackWorkingMessageText) 63 + } 64 + _ = json.NewEncoder(w).Encode(map[string]any{ 65 + "ok": true, 66 + "channel": "C123", 67 + "ts": "1739667601.000200", 68 + }) 69 + case "/chat.update": 70 + var payload map[string]any 71 + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 72 + t.Fatalf("decode update payload: %v", err) 73 + } 74 + finalMsg = strings.TrimSpace(payload["text"].(string)) 75 + if got := strings.TrimSpace(payload["channel"].(string)); got != "C123" { 76 + t.Fatalf("update channel = %q, want C123", got) 77 + } 78 + if got := strings.TrimSpace(payload["ts"].(string)); got != "1739667601.000200" { 79 + t.Fatalf("update ts = %q, want 1739667601.000200", got) 80 + } 81 + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) 82 + default: 83 + t.Fatalf("unexpected path: %s", r.URL.Path) 84 + } 85 + })) 86 + defer server.Close() 87 + 88 + api := newSlackAPI(server.Client(), server.URL, "xoxb-test", "xapp-test") 89 + working := startSlackWorkingMessageWithDelay(context.Background(), nil, api, slackJob{ 90 + ChannelID: "C123", 91 + ThreadTS: "1739667600.000100", 92 + MessageTS: "1739667600.000100", 93 + }, 0) 94 + updated, err := working.Update(context.Background(), "final result") 95 + if err != nil { 96 + t.Fatalf("Update() error = %v", err) 97 + } 98 + if !updated { 99 + t.Fatalf("updated = false, want true") 100 + } 101 + if finalMsg != "final result" { 102 + t.Fatalf("final message = %q, want final result", finalMsg) 103 + } 104 + mu.Lock() 105 + defer mu.Unlock() 106 + if len(paths) != 2 || paths[0] != "/chat.postMessage" || paths[1] != "/chat.update" { 107 + t.Fatalf("paths = %#v, want post then update", paths) 108 + } 109 + }
+18
internal/channelruntime/slack/slack_api.go
··· 301 301 Error string `json:"error,omitempty"` 302 302 } 303 303 304 + type slackMessageRef = slackclient.MessageRef 305 + 304 306 type slackGetUploadURLExternalResponse struct { 305 307 OK bool `json:"ok"` 306 308 Error string `json:"error,omitempty"` ··· 358 360 func (api *slackAPI) postMessage(ctx context.Context, channelID, text, threadTS string) error { 359 361 client := slackclient.New(api.http, api.baseURL, api.botToken) 360 362 return client.PostMessage(ctx, channelID, text, threadTS) 363 + } 364 + 365 + func (api *slackAPI) postMessageWithResult(ctx context.Context, channelID, text, threadTS string) (slackMessageRef, error) { 366 + if api == nil { 367 + return slackMessageRef{}, fmt.Errorf("slack api is not initialized") 368 + } 369 + client := slackclient.New(api.http, api.baseURL, api.botToken) 370 + return client.PostMessageWithResult(ctx, channelID, text, threadTS) 371 + } 372 + 373 + func (api *slackAPI) updateMessage(ctx context.Context, channelID, messageTS, text string) error { 374 + if api == nil { 375 + return fmt.Errorf("slack api is not initialized") 376 + } 377 + client := slackclient.New(api.http, api.baseURL, api.botToken) 378 + return client.UpdateMessage(ctx, channelID, messageTS, text) 361 379 } 362 380 363 381 func (api *slackAPI) addReaction(ctx context.Context, channelID, messageTS, emoji string) error {
+94
internal/channelruntime/slack/slack_api_test.go
··· 237 237 }) 238 238 } 239 239 240 + func TestSlackAPIPostMessageWithResult(t *testing.T) { 241 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 242 + if r.URL.Path != "/chat.postMessage" { 243 + t.Fatalf("path = %q, want %q", r.URL.Path, "/chat.postMessage") 244 + } 245 + if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "Bearer xoxb-test" { 246 + t.Fatalf("authorization = %q", got) 247 + } 248 + var payload map[string]any 249 + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 250 + t.Fatalf("decode payload: %v", err) 251 + } 252 + if got := strings.TrimSpace(payload["channel"].(string)); got != "C123" { 253 + t.Fatalf("channel = %q, want %q", got, "C123") 254 + } 255 + if got := strings.TrimSpace(payload["text"].(string)); got != "working..." { 256 + t.Fatalf("text = %q, want %q", got, "working...") 257 + } 258 + if got := strings.TrimSpace(payload["thread_ts"].(string)); got != "1739667600.000100" { 259 + t.Fatalf("thread_ts = %q, want %q", got, "1739667600.000100") 260 + } 261 + _ = json.NewEncoder(w).Encode(map[string]any{ 262 + "ok": true, 263 + "channel": "C123", 264 + "ts": "1739667601.000200", 265 + }) 266 + })) 267 + defer server.Close() 268 + 269 + api := newSlackAPI(server.Client(), server.URL, "xoxb-test", "xapp-test") 270 + ref, err := api.postMessageWithResult(context.Background(), "C123", "working...", "1739667600.000100") 271 + if err != nil { 272 + t.Fatalf("postMessageWithResult() error = %v", err) 273 + } 274 + if ref.ChannelID != "C123" { 275 + t.Fatalf("channel_id = %q, want C123", ref.ChannelID) 276 + } 277 + if ref.MessageTS != "1739667601.000200" { 278 + t.Fatalf("message_ts = %q, want 1739667601.000200", ref.MessageTS) 279 + } 280 + } 281 + 282 + func TestSlackAPIUpdateMessage(t *testing.T) { 283 + t.Run("ok", func(t *testing.T) { 284 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 285 + if r.URL.Path != "/chat.update" { 286 + t.Fatalf("path = %q, want %q", r.URL.Path, "/chat.update") 287 + } 288 + if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "Bearer xoxb-test" { 289 + t.Fatalf("authorization = %q", got) 290 + } 291 + var payload map[string]any 292 + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { 293 + t.Fatalf("decode payload: %v", err) 294 + } 295 + if got := strings.TrimSpace(payload["channel"].(string)); got != "C123" { 296 + t.Fatalf("channel = %q, want %q", got, "C123") 297 + } 298 + if got := strings.TrimSpace(payload["ts"].(string)); got != "1739667601.000200" { 299 + t.Fatalf("ts = %q, want %q", got, "1739667601.000200") 300 + } 301 + if got := strings.TrimSpace(payload["text"].(string)); got != "done" { 302 + t.Fatalf("text = %q, want %q", got, "done") 303 + } 304 + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) 305 + })) 306 + defer server.Close() 307 + 308 + api := newSlackAPI(server.Client(), server.URL, "xoxb-test", "xapp-test") 309 + if err := api.updateMessage(context.Background(), "C123", "1739667601.000200", "done"); err != nil { 310 + t.Fatalf("updateMessage() error = %v", err) 311 + } 312 + }) 313 + 314 + t.Run("slack error", func(t *testing.T) { 315 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 316 + _ = json.NewEncoder(w).Encode(map[string]any{ 317 + "ok": false, 318 + "error": "message_not_found", 319 + }) 320 + })) 321 + defer server.Close() 322 + 323 + api := newSlackAPI(server.Client(), server.URL, "xoxb-test", "xapp-test") 324 + err := api.updateMessage(context.Background(), "C123", "1739667601.000200", "done") 325 + if err == nil { 326 + t.Fatalf("expected error") 327 + } 328 + if !strings.Contains(err.Error(), "message_not_found") { 329 + t.Fatalf("error = %v, want message_not_found", err) 330 + } 331 + }) 332 + } 333 + 240 334 func TestSlackAPIUploadFile(t *testing.T) { 241 335 t.Run("ok", func(t *testing.T) { 242 336 var gotFileContent string
+127 -27
internal/slackclient/post_message.go
··· 20 20 botToken string 21 21 } 22 22 23 + type MessageRef struct { 24 + ChannelID string 25 + MessageTS string 26 + } 27 + 23 28 func New(httpClient *http.Client, baseURL, botToken string) *Client { 24 29 if httpClient == nil { 25 30 httpClient = &http.Client{Timeout: 30 * time.Second} ··· 36 41 } 37 42 38 43 func (c *Client) PostMessage(ctx context.Context, channelID, text, threadTS string) error { 44 + _, err := c.postMessage(ctx, channelID, text, threadTS, false) 45 + return err 46 + } 47 + 48 + func (c *Client) PostMessageWithResult(ctx context.Context, channelID, text, threadTS string) (MessageRef, error) { 49 + return c.postMessage(ctx, channelID, text, threadTS, true) 50 + } 51 + 52 + func (c *Client) postMessage(ctx context.Context, channelID, text, threadTS string, requireMessageTS bool) (MessageRef, error) { 39 53 if c == nil || c.http == nil { 40 - return fmt.Errorf("slack client is not initialized") 54 + return MessageRef{}, fmt.Errorf("slack client is not initialized") 41 55 } 42 - token := strings.TrimSpace(c.botToken) 43 - if token == "" { 44 - return fmt.Errorf("slack token is required") 56 + if strings.TrimSpace(c.botToken) == "" { 57 + return MessageRef{}, fmt.Errorf("slack token is required") 45 58 } 46 59 channelID = strings.TrimSpace(channelID) 47 60 text = strings.TrimSpace(text) 48 61 threadTS = strings.TrimSpace(threadTS) 49 62 if channelID == "" { 50 - return fmt.Errorf("channel_id is required") 63 + return MessageRef{}, fmt.Errorf("channel_id is required") 51 64 } 52 65 if text == "" { 53 - return fmt.Errorf("text is required") 66 + return MessageRef{}, fmt.Errorf("text is required") 54 67 } 55 68 56 69 type requestBody struct { ··· 59 72 ThreadTS string `json:"thread_ts,omitempty"` 60 73 } 61 74 type responseBody struct { 62 - OK bool `json:"ok"` 63 - Error string `json:"error,omitempty"` 75 + OK bool `json:"ok"` 76 + Error string `json:"error,omitempty"` 77 + Channel string `json:"channel,omitempty"` 78 + TS string `json:"ts,omitempty"` 64 79 } 65 80 66 81 payload := requestBody{ ··· 68 83 Text: text, 69 84 ThreadTS: threadTS, 70 85 } 86 + var out responseBody 87 + body, status, err := c.postJSONWithRetry(ctx, "/chat.postMessage", payload) 88 + if err != nil { 89 + return MessageRef{}, err 90 + } 91 + if status < 200 || status >= 300 { 92 + return MessageRef{}, fmt.Errorf("slack chat.postMessage http %d", status) 93 + } 94 + if err := json.Unmarshal(body, &out); err != nil { 95 + return MessageRef{}, err 96 + } 97 + if !out.OK { 98 + code := strings.TrimSpace(out.Error) 99 + if code == "" { 100 + code = "unknown_error" 101 + } 102 + return MessageRef{}, fmt.Errorf("slack chat.postMessage failed: %s", code) 103 + } 104 + ref := MessageRef{ 105 + ChannelID: strings.TrimSpace(out.Channel), 106 + MessageTS: strings.TrimSpace(out.TS), 107 + } 108 + if ref.ChannelID == "" { 109 + ref.ChannelID = channelID 110 + } 111 + if requireMessageTS && ref.MessageTS == "" { 112 + return MessageRef{}, fmt.Errorf("slack chat.postMessage returned empty ts") 113 + } 114 + return ref, nil 115 + } 116 + 117 + func (c *Client) UpdateMessage(ctx context.Context, channelID, messageTS, text string) error { 118 + if c == nil || c.http == nil { 119 + return fmt.Errorf("slack client is not initialized") 120 + } 121 + if strings.TrimSpace(c.botToken) == "" { 122 + return fmt.Errorf("slack token is required") 123 + } 124 + channelID = strings.TrimSpace(channelID) 125 + messageTS = strings.TrimSpace(messageTS) 126 + text = strings.TrimSpace(text) 127 + if channelID == "" { 128 + return fmt.Errorf("channel_id is required") 129 + } 130 + if messageTS == "" { 131 + return fmt.Errorf("message_ts is required") 132 + } 133 + if text == "" { 134 + return fmt.Errorf("text is required") 135 + } 136 + 137 + type requestBody struct { 138 + Channel string `json:"channel"` 139 + TS string `json:"ts"` 140 + Text string `json:"text"` 141 + } 142 + type responseBody struct { 143 + OK bool `json:"ok"` 144 + Error string `json:"error,omitempty"` 145 + } 146 + 147 + payload := requestBody{ 148 + Channel: channelID, 149 + TS: messageTS, 150 + Text: text, 151 + } 152 + var out responseBody 153 + body, status, err := c.postJSONWithRetry(ctx, "/chat.update", payload) 154 + if err != nil { 155 + return err 156 + } 157 + if status < 200 || status >= 300 { 158 + return fmt.Errorf("slack chat.update http %d", status) 159 + } 160 + if err := json.Unmarshal(body, &out); err != nil { 161 + return err 162 + } 163 + if !out.OK { 164 + code := strings.TrimSpace(out.Error) 165 + if code == "" { 166 + code = "unknown_error" 167 + } 168 + return fmt.Errorf("slack chat.update failed: %s", code) 169 + } 170 + return nil 171 + } 172 + 173 + func (c *Client) postJSONWithRetry(ctx context.Context, path string, payload any) ([]byte, int, error) { 174 + token := strings.TrimSpace(c.botToken) 71 175 const maxAttempts = 3 72 176 var lastErr error 177 + var lastBody []byte 178 + var lastStatus int 73 179 for attempt := 1; attempt <= maxAttempts; attempt++ { 74 180 bodyRaw, err := json.Marshal(payload) 75 181 if err != nil { 76 - return fmt.Errorf("marshal slack payload: %w", err) 182 + return nil, 0, fmt.Errorf("marshal slack payload: %w", err) 77 183 } 78 - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/chat.postMessage", bytes.NewReader(bodyRaw)) 184 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(bodyRaw)) 79 185 if err != nil { 80 - return err 186 + return nil, 0, err 81 187 } 82 188 req.Header.Set("Authorization", "Bearer "+token) 83 189 req.Header.Set("Content-Type", "application/json") ··· 94 200 _ = resp.Body.Close() 95 201 if readErr != nil { 96 202 lastErr = readErr 203 + } else if status >= 200 && status < 300 { 204 + return respRaw, status, nil 97 205 } else { 98 - var out responseBody 99 - if parseErr := json.Unmarshal(respRaw, &out); parseErr != nil { 100 - lastErr = parseErr 101 - } else if status < 200 || status >= 300 { 102 - lastErr = fmt.Errorf("slack chat.postMessage http %d", status) 103 - } else if out.OK { 104 - return nil 105 - } else { 106 - code := strings.TrimSpace(out.Error) 107 - if code == "" { 108 - code = "unknown_error" 109 - } 110 - lastErr = fmt.Errorf("slack chat.postMessage failed: %s", code) 111 - } 206 + lastBody = respRaw 207 + lastStatus = status 208 + lastErr = nil 112 209 } 113 210 } 114 211 ··· 123 220 break 124 221 } 125 222 if err := sleepWithContext(ctx, wait); err != nil { 126 - return err 223 + return nil, status, err 127 224 } 128 225 } 129 - return lastErr 226 + if lastErr != nil { 227 + return lastBody, lastStatus, lastErr 228 + } 229 + return lastBody, lastStatus, nil 130 230 } 131 231 132 232 func retryDelay(status int, headers http.Header, attempt int) (time.Duration, bool) {