Skip to content

Commit 2f2ee95

Browse files
loafoeclaude
andcommitted
fix(slack_webhook): address PR review feedback
- Add 30s timeout to HTTP client to prevent hanging connections - Fix splitText to work with runes (not bytes) for proper UTF-8 handling - Chunk large tables to respect Slack's 3000 char limit per block - Fix data races in tests using atomic counters and channels - Add proper error classification assertions (ErrTemporary vs ErrSendFailed) - Use targetName instead of msg.ChatID in log messages Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 26e4aec commit 2f2ee95

2 files changed

Lines changed: 39 additions & 21 deletions

File tree

pkg/channels/slack_webhook/slack_webhook.go

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/url"
1010
"sort"
1111
"strings"
12+
"time"
1213

1314
"github.com/sipeed/picoclaw/pkg/bus"
1415
"github.com/sipeed/picoclaw/pkg/channels"
@@ -67,7 +68,9 @@ func NewSlackWebhookChannel(
6768
BaseChannel: base,
6869
bc: bc,
6970
config: cfg,
70-
client: &http.Client{},
71+
client: &http.Client{
72+
Timeout: 30 * time.Second,
73+
},
7174
}, nil
7275
}
7376

@@ -134,22 +137,22 @@ func (c *SlackWebhookChannel) Send(ctx context.Context, msg bus.OutboundMessage)
134137
resp, err := c.client.Do(req)
135138
if err != nil {
136139
logger.ErrorCF("slack_webhook", "Failed to send message", map[string]any{
137-
"target": msg.ChatID,
140+
"target": targetName,
138141
})
139142
return nil, fmt.Errorf("slack_webhook: send failed: %w", channels.ClassifyNetError(err))
140143
}
141144
defer resp.Body.Close()
142145

143146
if resp.StatusCode >= 400 {
144147
logger.ErrorCF("slack_webhook", "Slack API error", map[string]any{
145-
"target": msg.ChatID,
148+
"target": targetName,
146149
"status": resp.StatusCode,
147150
})
148151
return nil, fmt.Errorf("slack_webhook: send failed: %w", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("status %d", resp.StatusCode)))
149152
}
150153

151154
logger.DebugCF("slack_webhook", "Message sent successfully", map[string]any{
152-
"target": msg.ChatID,
155+
"target": targetName,
153156
})
154157

155158
return nil, nil
@@ -184,7 +187,9 @@ func (c *SlackWebhookChannel) buildBlocks(content string) []map[string]any {
184187
for _, seg := range segments {
185188
if seg.isTable {
186189
tableText := renderTable(seg.content)
187-
blocks = append(blocks, c.textSection(tableText))
190+
for _, chunk := range splitText(tableText, maxTextBlockLength) {
191+
blocks = append(blocks, c.textSection(chunk))
192+
}
188193
} else {
189194
text := strings.TrimSpace(seg.content)
190195
if text == "" {
@@ -215,23 +220,25 @@ func (c *SlackWebhookChannel) textSection(text string) map[string]any {
215220
}
216221

217222
func splitText(text string, maxLen int) []string {
218-
if len(text) <= maxLen {
223+
runes := []rune(text)
224+
if len(runes) <= maxLen {
219225
return []string{text}
220226
}
221227

222228
var chunks []string
223-
for len(text) > maxLen {
229+
for len(runes) > maxLen {
224230
splitAt := maxLen
225-
if idx := strings.LastIndex(text[:maxLen], "\n"); idx > 0 {
226-
splitAt = idx + 1
227-
} else if idx := strings.LastIndex(text[:maxLen], " "); idx > 0 {
228-
splitAt = idx + 1
231+
window := string(runes[:maxLen])
232+
if idx := strings.LastIndex(window, "\n"); idx > 0 {
233+
splitAt = len([]rune(window[:idx])) + 1
234+
} else if idx := strings.LastIndex(window, " "); idx > 0 {
235+
splitAt = len([]rune(window[:idx])) + 1
229236
}
230-
chunks = append(chunks, text[:splitAt])
231-
text = text[splitAt:]
237+
chunks = append(chunks, string(runes[:splitAt]))
238+
runes = runes[splitAt:]
232239
}
233-
if len(text) > 0 {
234-
chunks = append(chunks, text)
240+
if len(runes) > 0 {
241+
chunks = append(chunks, string(runes))
235242
}
236243
return chunks
237244
}

pkg/channels/slack_webhook/slack_webhook_test.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,18 @@ package slackwebhook
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"io"
78
"net/http"
89
"net/http/httptest"
10+
"sync/atomic"
911
"testing"
1012

1113
"github.com/stretchr/testify/assert"
1214
"github.com/stretchr/testify/require"
1315

1416
"github.com/sipeed/picoclaw/pkg/bus"
17+
"github.com/sipeed/picoclaw/pkg/channels"
1518
"github.com/sipeed/picoclaw/pkg/config"
1619
)
1720

@@ -79,10 +82,12 @@ func TestNewSlackWebhookChannel_Validation(t *testing.T) {
7982
}
8083

8184
func TestSlackWebhookChannel_Send(t *testing.T) {
82-
var receivedPayload map[string]any
85+
payloadCh := make(chan map[string]any, 1)
8386
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8487
body, _ := io.ReadAll(r.Body)
85-
json.Unmarshal(body, &receivedPayload)
88+
var payload map[string]any
89+
json.Unmarshal(body, &payload)
90+
payloadCh <- payload
8691
w.WriteHeader(http.StatusOK)
8792
}))
8893
defer server.Close()
@@ -115,6 +120,7 @@ func TestSlackWebhookChannel_Send(t *testing.T) {
115120
require.NoError(t, err)
116121

117122
// Verify payload structure
123+
receivedPayload := <-payloadCh
118124
assert.Equal(t, "TestBot", receivedPayload["username"])
119125
assert.Equal(t, ":test:", receivedPayload["icon_emoji"])
120126
blocks, ok := receivedPayload["blocks"].([]any)
@@ -123,9 +129,9 @@ func TestSlackWebhookChannel_Send(t *testing.T) {
123129
}
124130

125131
func TestSlackWebhookChannel_FallbackToDefault(t *testing.T) {
126-
var requestCount int
132+
var requestCount atomic.Int32
127133
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128-
requestCount++
134+
requestCount.Add(1)
129135
w.WriteHeader(http.StatusOK)
130136
}))
131137
defer server.Close()
@@ -149,7 +155,7 @@ func TestSlackWebhookChannel_FallbackToDefault(t *testing.T) {
149155
ChatID: "unknown_target",
150156
})
151157
require.NoError(t, err)
152-
assert.Equal(t, 1, requestCount)
158+
assert.Equal(t, int32(1), requestCount.Load())
153159
}
154160

155161
func TestSlackWebhookChannel_ErrorClassification(t *testing.T) {
@@ -188,7 +194,12 @@ func TestSlackWebhookChannel_ErrorClassification(t *testing.T) {
188194

189195
_, err := ch.Send(context.Background(), bus.OutboundMessage{Content: "Test"})
190196
require.Error(t, err)
191-
// Error classification is internal; just verify we got an error
197+
198+
if tt.expectTemp {
199+
assert.True(t, errors.Is(err, channels.ErrTemporary), "expected temporary error for %d", tt.statusCode)
200+
} else {
201+
assert.True(t, errors.Is(err, channels.ErrSendFailed), "expected permanent error for %d", tt.statusCode)
202+
}
192203
})
193204
}
194205
}

0 commit comments

Comments
 (0)