diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 816ba4b..2c79cda 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -207,10 +207,11 @@ type provisionSeed struct { // telegramSeed derives the create-time seed from Telegram launch fields: a // supported preferred language from languageCode (an ISO-639 code, possibly -// region-tagged like "ru-RU"), and a display name sanitized from firstName or, -// failing that, username (sanitizeDisplayName strips disallowed characters to the -// editable format). When neither yields any letters, it falls back to a generated -// placeholder in the seeded language (placeholderDisplayName). +// region-tagged like "ru-RU"), and a display name. The name precedence is the real +// name (firstName, sanitized to the editable format) → the @username taken verbatim +// (already a valid handle, only trimmed and length-capped, never character-stripped) +// → a generated placeholder in the seeded language (placeholderDisplayName), reached +// only when firstName has no usable letters and no username is set. func telegramSeed(languageCode, username, firstName string) provisionSeed { var seed provisionSeed if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { @@ -218,7 +219,13 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed { } name := sanitizeDisplayName(firstName) if name == "" { - name = sanitizeDisplayName(username) + // The real name yielded nothing usable: fall back to the @username verbatim + // (Telegram guarantees a valid handle), only trimmed and capped to the column + // width — never character-stripped like the real name. + name = strings.TrimSpace(username) + if r := []rune(name); len(r) > maxDisplayName { + name = strings.TrimRight(string(r[:maxDisplayName]), " ") + } } if name == "" { name = placeholderDisplayName(seed.preferredLanguage) diff --git a/backend/internal/account/provision_test.go b/backend/internal/account/provision_test.go index afa08a9..ea63490 100644 --- a/backend/internal/account/provision_test.go +++ b/backend/internal/account/provision_test.go @@ -9,8 +9,9 @@ import ( // TestTelegramSeed covers the pure mapping from Telegram launch fields to the // create-time account seed: supported-language detection (bare and region-tagged), -// the first-name / username display-name precedence, and the sanitization that -// strips disallowed characters (emoji, digits, punctuation) to the editable format. +// the real-name → @username (verbatim) → placeholder display-name precedence, and +// the sanitization of the real name (emoji, digits, punctuation stripped to the +// editable format). The username, when used, is kept verbatim. func TestTelegramSeed(t *testing.T) { cases := map[string]struct { languageCode, username, firstName string @@ -28,6 +29,7 @@ func TestTelegramSeed(t *testing.T) { "punct to space": {"en", "user", "John❤Doe", "en", "John Doe"}, "digits dropped": {"ru", "user", "Маша123", "ru", "Маша"}, "garbage to username": {"en", "good", "123!@#", "en", "good"}, + "username verbatim": {"en", "co_ol99", "🎮🎮", "en", "co_ol99"}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -49,10 +51,10 @@ func TestTelegramSeedPlaceholder(t *testing.T) { languageCode, username, firstName string wantRe string }{ - "en empty": {"en", "", "", `^Player-\d{5}$`}, - "ru empty": {"ru", "", "", `^Игрок-\d{5}$`}, - "default en": {"fr", "", "", `^Player-\d{5}$`}, - "both garbage": {"ru", "123", "!!!", `^Игрок-\d{5}$`}, + "en empty": {"en", "", "", `^Player-\d{5}$`}, + "ru empty": {"ru", "", "", `^Игрок-\d{5}$`}, + "default en": {"fr", "", "", `^Player-\d{5}$`}, + "name garbage, no username": {"ru", "", "!!!", `^Игрок-\d{5}$`}, } for name, tc := range cases { t.Run(name, func(t *testing.T) {