feat(19-04): add users.handle, HandleGenerator and user.Created

Generate a unique mention handle on API register and admin create, and fire user.Created from both paths so listeners run in the same write.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-17 21:44:59 +02:00
parent 569768e657
commit 15b19c7021
12 changed files with 349 additions and 19 deletions

160
classes/handle_generator.go Normal file
View File

@@ -0,0 +1,160 @@
package classes
import (
"context"
"errors"
"strconv"
"strings"
"unicode"
"git.golem15.com/golem15/sm-user-plugin/models"
"gorm.io/gorm"
)
const (
handleMinLen = 2
handleMaxLen = 255
)
// HandleGenerator ports the PHP ChatChannels HandleGenerator. The emitted
// charset [a-z0-9._-] is a strict subset of MentionParser [A-Za-z0-9._-],
// length 2–255, so every generated handle is matchable as an @-token.
type HandleGenerator struct{}
// Slug transliterates name to ASCII, lowercases, drops every rune outside
// [a-z0-9._-], then trims leading/trailing `._-`. A result shorter than 2
// is not a valid base (Generate falls back to user{id}).
func (HandleGenerator) Slug(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
b.WriteString(foldHandleRune(r))
}
return strings.Trim(b.String(), "._-")
}
// Generate returns a unique handle for name. The base is Slug(name); if
// that is shorter than 2 it falls back to "user{fallbackID}" (or "user"
// when no id is known). The smallest free integer suffix is then appended
// until isTaken returns false: first "James" → "james", second → "james1".
func (g HandleGenerator) Generate(name string, isTaken func(string) bool, fallbackID *uint) string {
base := g.Slug(name)
if len(base) < handleMinLen {
if fallbackID != nil {
base = "user" + strconv.FormatUint(uint64(*fallbackID), 10)
} else {
base = "user"
}
}
base = clipHandle(base)
candidate := base
i := 0
for isTaken != nil && isTaken(candidate) {
i++
candidate = clipHandle(base + strconv.Itoa(i))
}
return candidate
}
// AssignHandle writes a unique handle onto user when it has none, using
// the user's display name and id. It is idempotent when handle is already
// set. The write uses db (the create transaction when there is one).
func AssignHandle(ctx context.Context, db *gorm.DB, user *models.User) error {
if user == nil {
return errors.New("golem15.user: a handle needs a user")
}
if user.Handle != nil && strings.TrimSpace(*user.Handle) != "" {
return nil
}
if db == nil {
return errors.New("golem15.user: a handle needs a database")
}
name := ""
if user.Name != nil {
name = *user.Name
}
var fallback *uint
if user.ID != 0 {
id := user.ID
fallback = &id
}
gen := HandleGenerator{}
for attempt := 0; attempt < 8; attempt++ {
handle := gen.Generate(name, func(candidate string) bool {
var n int64
q := db.WithContext(ctx).Model(&models.User{}).Where("handle = ?", candidate)
if user.ID != 0 {
q = q.Where("id <> ?", user.ID)
}
if err := q.Count(&n).Error; err != nil {
return true
}
return n > 0
}, fallback)
user.Handle = &handle
res := db.WithContext(ctx).Model(user).Where("id = ?", user.ID).Update("handle", handle)
if res.Error == nil {
return nil
}
if !isUniqueHandleConflict(res.Error) {
return res.Error
}
}
return errors.New("golem15.user: could not allocate a unique handle")
}
func clipHandle(s string) string {
if len(s) <= handleMaxLen {
return s
}
return s[:handleMaxLen]
}
func isUniqueHandleConflict(err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "users_handle_unique") || strings.Contains(msg, "duplicate key")
}
// foldHandleRune maps r into the handle charset [a-z0-9._-] or "".
func foldHandleRune(r rune) string {
r = unicode.ToLower(r)
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
return string(r)
}
if mapped, ok := latinHandleFold[r]; ok {
return mapped
}
return ""
}
// latinHandleFold covers Latin-1 / Latin Extended-A letters PHP Str::ascii
// would emit for the names we generate handles from (Polish, French, German).
var latinHandleFold = map[rune]string{
'à': "a", 'á': "a", 'â': "a", 'ã': "a", 'ä': "a", 'å': "a", 'ą': "a", 'ā': "a",
'æ': "ae",
'ç': "c", 'ć': "c", 'č': "c",
'ď': "d", 'đ': "d", 'ð': "d",
'è': "e", 'é': "e", 'ê': "e", 'ë': "e", 'ę': "e", 'ē': "e", 'ě': "e",
'ì': "i", 'í': "i", 'î': "i", 'ï': "i", 'ī': "i",
'ł': "l",
'ñ': "n", 'ń': "n", 'ň': "n",
'ò': "o", 'ó': "o", 'ô': "o", 'õ': "o", 'ö': "o", 'ø': "o", 'ō': "o",
'œ': "oe",
'ř': "r",
'ś': "s", 'š': "s", 'ş': "s", 'ß': "ss",
'ť': "t", 'þ': "th",
'ù': "u", 'ú': "u", 'û': "u", 'ü': "u", 'ū': "u",
'ý': "y", 'ÿ': "y",
'ź': "z", 'ż': "z", 'ž': "z",
}