221 lines
5.6 KiB
Go
221 lines
5.6 KiB
Go
package mentions
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"chat-switchboard/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, " ")
|
|
}
|