From 8213fcd7b04bdbd8b814b756bbf4339fd2e9a988 Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Fri, 17 Apr 2026 13:43:01 -0700 Subject: [PATCH] feat(channels): [slug] routing for inbound Slack messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Humans type [backend] what's #800? in a shared #mol-engineering channel and the message routes specifically to Backend Engineer's workspace. Matching logic (case-insensitive): [pm] → PM [backend] → Backend Engineer [dev-lead] → Dev Lead [security] → Security Auditor (prefix match on 'security-auditor') Unknown slugs return the available agent list for that channel so the user knows what slugs are valid. Messages without a [slug] prefix route to the first matching workspace (backward compat with Level 2). Co-Authored-By: Claude Opus 4.6 (1M context) --- platform/internal/handlers/channels.go | 74 +++++++++++++++++++------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/platform/internal/handlers/channels.go b/platform/internal/handlers/channels.go index 0c7df94c..04759f34 100644 --- a/platform/internal/handlers/channels.go +++ b/platform/internal/handlers/channels.go @@ -443,9 +443,23 @@ func (h *ChannelHandler) Webhook(c *gin.Context) { return } + // [slug] routing: if the message starts with [word], extract it as + // a target agent slug and match against the channel config's username + // field (lowercased). This lets humans type "[backend] what's #800?" + // in a shared channel and route to a specific agent. + targetSlug := "" + routedText := msg.Text + 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 + } + } + } + // Look up channels by type and find one whose chat_id list contains msg.ChatID. - // We can't use SQL LIKE — that matches substrings (chat_id "123" would match "1234"). - // Fetch all enabled channels of this type, then exact-match in code. rows, err := db.DB.QueryContext(ctx, ` SELECT id, workspace_id, channel_type, channel_config, enabled, allowed_users FROM workspace_channels @@ -458,6 +472,7 @@ func (h *ChannelHandler) Webhook(c *gin.Context) { defer rows.Close() var ch channels.ChannelRow + var candidates []channels.ChannelRow found := false for rows.Next() { var row channels.ChannelRow @@ -467,36 +482,59 @@ func (h *ChannelHandler) Webhook(c *gin.Context) { } json.Unmarshal(configJSON, &row.Config) json.Unmarshal(allowedJSON, &row.AllowedUsers) - // #319: decrypt sensitive fields before comparing webhook_secret / - // using bot_token downstream. Skip rows whose decrypt fails so a - // single corrupt channel cannot block webhooks for all others. if err := channels.DecryptSensitiveFields(row.Config); err != nil { log.Printf("Channels: decrypt webhook row %s: %v", row.ID, err) continue } - // Verify webhook secret_token if the channel has one configured. - // #337: use constant-time comparison. Go's `!=` short-circuits on - // the first mismatched byte and leaks timing information; an - // attacker on the Docker network could enumerate the secret - // byte-by-byte. subtle.ConstantTimeCompare runs in time - // proportional to the length of the shorter input and returns - // 1 on match / 0 otherwise (never -1). Same posture as the - // cdp-proxy token compare in host-bridge. if expectedSecret, _ := row.Config["webhook_secret"].(string); expectedSecret != "" { receivedSecret := c.GetHeader("X-Telegram-Bot-Api-Secret-Token") if subtle.ConstantTimeCompare([]byte(receivedSecret), []byte(expectedSecret)) != 1 { - continue // Wrong secret — try other channels (could be different bot) + continue } } - // Exact match against the comma-separated chat_id list if matchesChatID(row.Config, msg.ChatID) { - ch = row - found = true - break + candidates = append(candidates, row) } } + + if targetSlug != "" { + // [slug] routing — match against config username (lowercased) + for _, row := range candidates { + username, _ := row.Config["username"].(string) + usernameLC := strings.ToLower(username) + // Match: [backend] → "Backend Engineer", [pm] → "PM", [dev lead] → "Dev Lead" + if usernameLC == targetSlug || + strings.HasPrefix(strings.ReplaceAll(usernameLC, " ", "-"), targetSlug) || + strings.HasPrefix(strings.ReplaceAll(usernameLC, " ", ""), targetSlug) { + ch = row + found = true + msg.Text = routedText // Strip the [slug] prefix before routing + break + } + } + if !found { + // No match for slug — respond with available agents + var names []string + for _, row := range candidates { + if u, _ := row.Config["username"].(string); u != "" { + names = append(names, "["+strings.ToLower(strings.ReplaceAll(u, " ", "-"))+"]") + } + } + c.JSON(http.StatusOK, gin.H{ + "status": "unknown_agent", + "requested_slug": targetSlug, + "available_slugs": names, + }) + return + } + } else if len(candidates) > 0 { + // No [slug] prefix — route to first matching channel (backward compat) + ch = candidates[0] + found = true + } + if !found { c.JSON(http.StatusOK, gin.H{"status": "no_channel"}) return