This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/mentions/parser.go
2026-03-05 22:40:26 +00:00

221 lines
5.6 KiB
Go

package mentions
import (
"sort"
"strings"
"unicode"
"git.gobha.me/xcaliber/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, " ")
}