package mentions import ( "sort" "strings" "unicode" "switchboard-core/models" ) // Mention represents a parsed @mention token in message content. type Mention struct { Raw string // "@veronica-sharpe" as written (includes @) Name string // "veronica-sharpe" (normalized, no @) Start int // byte offset in message content End int // byte offset end (exclusive) Resolved *models.ChannelModel // nil if unresolved IsAll bool // true for @all special token } // Parse extracts @mentions from message content and resolves them // against the channel's model roster. // // Resolution priority: // 1. Exact handle match (e.g. @veronica-sharpe → handle "veronica-sharpe") // 2. Prefix handle match (e.g. @veronica → only one handle starts with "veronica") // 3. Fallback: normalized display_name match (backward compat) // 4. @all is a special token that resolves to all roster entries // // @ must be at start of content or preceded by whitespace. func Parse(content string, roster []models.ChannelModel) []Mention { if len(roster) == 0 || !strings.Contains(content, "@") { return nil } // Build handle lookup: handle → *ChannelModel // Also build display name lookup for backward compat type entry struct { handle string // normalized handle normalized string // normalized display_name model models.ChannelModel } entries := make([]entry, 0, len(roster)) for _, cm := range roster { h := normalize(cm.Handle) dn := normalize(cm.DisplayName) entries = append(entries, entry{handle: h, normalized: dn, model: cm}) } // Sort by handle length descending for longest-match-first sort.Slice(entries, func(i, j int) bool { return len(entries[i].handle) > len(entries[j].handle) }) // Extract @tokens var mentions []Mention i := 0 for i < len(content) { if content[i] != '@' { i++ continue } // @ must be at start or preceded by whitespace if i > 0 && !unicode.IsSpace(rune(content[i-1])) { i++ continue } // Extract the token after @ start := i i++ // skip @ tokenStart := i for i < len(content) && !unicode.IsSpace(rune(content[i])) { i++ } if i == tokenStart { continue // bare @ with no token } // Strip trailing punctuation tokenEnd := i for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) { tokenEnd-- } if tokenEnd == tokenStart { continue } raw := content[start:tokenEnd] name := content[tokenStart:tokenEnd] normalizedName := normalize(name) // Special: @all if normalizedName == "all" { mentions = append(mentions, Mention{ Raw: raw, Name: name, Start: start, End: tokenEnd, IsAll: true, }) continue } // 1. Exact handle match var resolved *models.ChannelModel for idx := range entries { if entries[idx].handle != "" && normalizedName == entries[idx].handle { cm := entries[idx].model resolved = &cm break } } // 2. Prefix handle match (unambiguous only) if resolved == nil { var prefixMatches []int for idx := range entries { if entries[idx].handle != "" && strings.HasPrefix(entries[idx].handle, normalizedName) { prefixMatches = append(prefixMatches, idx) } } if len(prefixMatches) == 1 { cm := entries[prefixMatches[0]].model resolved = &cm } } // 3. Fallback: display_name match (backward compat) if resolved == nil { for idx := range entries { if entries[idx].normalized != "" && normalizedName == entries[idx].normalized { cm := entries[idx].model resolved = &cm break } } // Display name prefix if resolved == nil { var prefixMatches []int for idx := range entries { if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName+" ") { prefixMatches = append(prefixMatches, idx) } } if len(prefixMatches) == 0 { for idx := range entries { if entries[idx].normalized != "" && strings.HasPrefix(entries[idx].normalized, normalizedName) { prefixMatches = append(prefixMatches, idx) } } } if len(prefixMatches) == 1 { cm := entries[prefixMatches[0]].model resolved = &cm } } } mentions = append(mentions, Mention{ Raw: raw, Name: name, Start: start, End: tokenEnd, Resolved: resolved, }) } return mentions } // ResolvedModels returns deduplicated resolved models from parsed mentions. // Returns nil if no mentions resolved. func ResolvedModels(mentions []Mention) []models.ChannelModel { if len(mentions) == 0 { return nil } seen := make(map[string]bool) var result []models.ChannelModel for _, m := range mentions { if m.Resolved != nil && !seen[m.Resolved.ID] { seen[m.Resolved.ID] = true result = append(result, *m.Resolved) } } return result } // HasAll returns true if any mention is @all. func HasAll(mentions []Mention) bool { for _, m := range mentions { if m.IsAll { return true } } return false } // isMentionTrailingPunct returns true for punctuation that commonly // follows an @mention but isn't part of the name. func isMentionTrailingPunct(b byte) bool { switch b { case ',', '.', '!', '?', ':', ';', ')', ']', '}': return true } return false } // normalize converts a display name to a canonical form for matching: // lowercase, hyphens → spaces collapsed, trimmed. func normalize(s string) string { s = strings.ToLower(s) s = strings.ReplaceAll(s, "-", " ") s = strings.ReplaceAll(s, "_", " ") // Collapse multiple spaces fields := strings.Fields(s) return strings.Join(fields, " ") }