Changeset 0.23.0 (#153)
This commit is contained in:
@@ -10,47 +10,45 @@ import (
|
||||
|
||||
// Mention represents a parsed @mention token in message content.
|
||||
type Mention struct {
|
||||
Raw string // "@claude-3-opus" as written (includes @)
|
||||
Name string // "claude-3-opus" (normalized, no @)
|
||||
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.
|
||||
//
|
||||
// Rules:
|
||||
// - @ followed by one or more non-whitespace characters
|
||||
// - Greedy: @claude-3-opus-20240229 matches the full token
|
||||
// - Resolution: case-insensitive match against display_name with
|
||||
// hyphens/spaces normalized (e.g. "claude 3 opus" matches "Claude-3-Opus")
|
||||
// - Longest-match-first when display names overlap
|
||||
// - Unresolved mentions are included with Resolved = nil
|
||||
// - @ must be at start of content or preceded by whitespace
|
||||
// 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 lookup: normalized display_name → *ChannelModel
|
||||
// Sort roster by display_name length descending for longest-match-first
|
||||
// Build handle lookup: handle → *ChannelModel
|
||||
// Also build display name lookup for backward compat
|
||||
type entry struct {
|
||||
normalized string
|
||||
handle string // normalized handle
|
||||
normalized string // normalized display_name
|
||||
model models.ChannelModel
|
||||
}
|
||||
entries := make([]entry, 0, len(roster))
|
||||
for _, cm := range roster {
|
||||
if cm.DisplayName == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry{
|
||||
normalized: normalize(cm.DisplayName),
|
||||
model: cm,
|
||||
})
|
||||
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].normalized) > len(entries[j].normalized)
|
||||
return len(entries[i].handle) > len(entries[j].handle)
|
||||
})
|
||||
|
||||
// Extract @tokens
|
||||
@@ -80,29 +78,86 @@ func Parse(content string, roster []models.ChannelModel) []Mention {
|
||||
continue // bare @ with no token
|
||||
}
|
||||
|
||||
// Strip trailing punctuation (commas, periods, etc.)
|
||||
// Strip trailing punctuation
|
||||
tokenEnd := i
|
||||
for tokenEnd > tokenStart && isMentionTrailingPunct(content[tokenEnd-1]) {
|
||||
tokenEnd--
|
||||
}
|
||||
if tokenEnd == tokenStart {
|
||||
continue // all punctuation
|
||||
continue
|
||||
}
|
||||
|
||||
raw := content[start:tokenEnd]
|
||||
name := content[tokenStart:tokenEnd]
|
||||
normalizedName := normalize(name)
|
||||
|
||||
// Try to resolve against roster (longest match first)
|
||||
// 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 normalizedName == entries[idx].normalized {
|
||||
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,
|
||||
@@ -133,6 +188,16 @@ func ResolvedModels(mentions []Mention) []models.ChannelModel {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user