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:
parent
19ab9667ee
commit
8213fcd7b0
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user