feat(channels): [slug] routing for inbound Slack messages

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) <noreply@anthropic.com>
This commit is contained in:
rabbitblood 2026-04-17 13:43:01 -07:00
parent 19ab9667ee
commit 8213fcd7b0

View File

@ -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