diff --git a/platform/internal/channels/manager.go b/platform/internal/channels/manager.go index 7aff16a4..dc07b207 100644 --- a/platform/internal/channels/manager.go +++ b/platform/internal/channels/manager.go @@ -449,9 +449,10 @@ func (m *Manager) BroadcastToWorkspaceChannels(ctx context.Context, workspaceID, if text == "" || db.DB == nil { return } - // Truncate to keep Slack messages digestible - if len(text) > 500 { - text = text[:497] + "..." + // Truncate to keep Slack messages digestible (rune-safe for CJK/emoji) + runes := []rune(text) + if len(runes) > 500 { + text = string(runes[:497]) + "..." } rows, err := db.DB.QueryContext(ctx, ` SELECT id FROM workspace_channels diff --git a/platform/internal/channels/slack.go b/platform/internal/channels/slack.go index 6c47b892..2ecfd086 100644 --- a/platform/internal/channels/slack.go +++ b/platform/internal/channels/slack.go @@ -19,6 +19,8 @@ const ( slackHTTPTimeout = 10 * time.Second ) +var slackHTTPClient = &http.Client{Timeout: slackHTTPTimeout} + // SlackAdapter implements ChannelAdapter for Slack Incoming Webhooks. // // Outbound messages are sent via Slack Incoming Webhooks (the simple, @@ -101,14 +103,12 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Authorization", "Bearer "+botToken) - client := &http.Client{Timeout: slackHTTPTimeout} - resp, err := client.Do(req) + resp, err := slackHTTPClient.Do(req) if err != nil { return fmt.Errorf("slack: send: %w", err) } - defer resp.Body.Close() - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() var result struct { OK bool `json:"ok"` Error string `json:"error"` @@ -140,12 +140,10 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: slackHTTPTimeout} - resp, err := client.Do(req) + resp, err := slackHTTPClient.Do(req) if err != nil { return fmt.Errorf("slack: send: %w", err) } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) @@ -206,27 +204,34 @@ func (s *SlackAdapter) ParseWebhook(c *gin.Context, _ map[string]interface{}) (* var payload struct { Type string `json:"type"` - Challenge string `json:"challenge"` // url_verification + Challenge string `json:"challenge"` Event struct { Type string `json:"type"` User string `json:"user"` Text string `json:"text"` Channel string `json:"channel"` Ts string `json:"ts"` + BotID string `json:"bot_id"` + Subtype string `json:"subtype"` } `json:"event"` } if err := json.Unmarshal(body, &payload); err != nil { return nil, fmt.Errorf("slack: parse event: %w", err) } - // url_verification handshake — no message, respond via the handler layer + // url_verification handshake — respond with challenge directly if payload.Type == "url_verification" { - log.Printf("Channels: Slack url_verification challenge (not handled by ParseWebhook)") + c.JSON(200, gin.H{"challenge": payload.Challenge}) return nil, nil } + // Ignore bot messages to prevent echo loops. Our own auto-posts + // via chat.postMessage fire Events API callbacks with bot_id set. + if payload.Event.BotID != "" || payload.Event.Subtype == "bot_message" { + return nil, nil + } if payload.Event.Type != "message" || payload.Event.Text == "" { - return nil, nil // Ignore non-message events + return nil, nil } text = payload.Event.Text diff --git a/platform/internal/handlers/channels.go b/platform/internal/handlers/channels.go index 04759f34..df9a3815 100644 --- a/platform/internal/handlers/channels.go +++ b/platform/internal/handlers/channels.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "os" + "regexp" "strings" "github.com/gin-gonic/gin" @@ -449,12 +450,16 @@ func (h *ChannelHandler) Webhook(c *gin.Context) { // in a shared channel and route to a specific agent. targetSlug := "" routedText := msg.Text + validSlugRe := regexp.MustCompile(`^[a-zA-Z0-9 _-]+$`) if len(msg.Text) > 2 && msg.Text[0] == '[' { if idx := strings.Index(msg.Text, "]"); idx > 1 && idx < 40 { - targetSlug = strings.ToLower(strings.TrimSpace(msg.Text[1:idx])) - routedText = strings.TrimSpace(msg.Text[idx+1:]) - if routedText == "" { - routedText = msg.Text // Don't send empty — keep original + candidate := strings.ToLower(strings.TrimSpace(msg.Text[1:idx])) + if validSlugRe.MatchString(candidate) { + targetSlug = candidate + routedText = strings.TrimSpace(msg.Text[idx+1:]) + if routedText == "" { + routedText = msg.Text + } } } } @@ -543,7 +548,9 @@ func (h *ChannelHandler) Webhook(c *gin.Context) { // Process asynchronously — don't block the webhook response go func() { bgCtx := context.Background() - _ = h.manager.HandleInbound(bgCtx, ch, msg) + if err := h.manager.HandleInbound(bgCtx, ch, msg); err != nil { + log.Printf("Channels: async HandleInbound error for workspace %s: %v", ch.WorkspaceID[:12], err) + } }() c.JSON(http.StatusOK, gin.H{"status": "accepted"}) diff --git a/platform/internal/scheduler/scheduler.go b/platform/internal/scheduler/scheduler.go index ae7a023b..815892f5 100644 --- a/platform/internal/scheduler/scheduler.go +++ b/platform/internal/scheduler/scheduler.go @@ -379,7 +379,11 @@ func (s *Scheduler) fireSchedule(ctx context.Context, sched scheduleRow) { if s.channels != nil && lastStatus == "ok" && !isEmpty { summary := s.extractResponseSummary(respBody) if summary != "" { - go s.channels.BroadcastToWorkspaceChannels(ctx, sched.WorkspaceID, summary) + go func(wsID, text string) { + postCtx, postCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer postCancel() + s.channels.BroadcastToWorkspaceChannels(postCtx, wsID, text) + }(sched.WorkspaceID, summary) } } }