added messages templates

This commit is contained in:
onysd 2026-09-01 14:40:06 +03:00
parent e8dc967e6a
commit 7cd1f64d0d
29 changed files with 1266 additions and 84 deletions

View file

@ -12,7 +12,7 @@ func TestServiceIdentityAndLoginMessageUseOwpenGramBrand(t *testing.T) {
if serviceUser.FirstName != "OwpenGram" || serviceUser.Username != "" {
t.Fatalf("service user = %+v, want OwpenGram identity with no username", serviceUser)
}
message, err := OfficialLoginCodeMessage(42, "12345", 1)
message, err := OfficialLoginCodeMessage(42, "", "12345", 1)
if err != nil {
t.Fatalf("build login message: %v", err)
}

View file

@ -1,19 +1,87 @@
package domain
import (
"errors"
"fmt"
"math"
"strings"
"telesrv/internal/branding"
)
func officialLoginCodeMessageTemplate() string {
return `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
// loginCodeTemplateCodePlaceholder marks where the actual OTP code is
// substituted into a (possibly admin-edited) login-code message template.
// Unlike the old hardcoded "Login code: %s" format, the template body is no
// longer fixed, so the substituted code's bold MessageEntity offset/length
// must be computed dynamically from wherever the placeholder actually lands
// -- see OfficialLoginCodeMessage. It must appear exactly once in any
// template that reaches OfficialLoginCodeMessage (see
// ValidateLoginCodeMessageTemplate): zero occurrences would silently drop
// the code from the message entirely, and two-or-more is ambiguous about
// which occurrence is "the" code.
const loginCodeTemplateCodePlaceholder = "{{code}}"
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
// DefaultLoginCodeMessageTemplate is the built-in, final-fallback copy for
// the 777000 login-code delivery message. It is sent for every login code
// regardless of delivery channel (phone SMS or email) -- see the
// LoginCodeDeliveryStore implementations in internal/store/{postgres,memory},
// which all pass the same code through unconditionally, and
// internal/app/auth's recordLoginMessage (the new-account bootstrap path).
// Unlike DefaultWelcomeMessage{Phone,Email}Template there is only one
// template: the message never varies by channel. Supports {{server_name}}
// (see RenderWelcomeMessageTemplate) and requires the {{code}} placeholder
// exactly once (see ValidateLoginCodeMessageTemplate).
const DefaultLoginCodeMessageTemplate = `Login code: {{code}}. Do not give this code to anyone, even if they say they are from {{server_name}}!
This code can be used to log in to your {{server_name}} account. We never ask it for anything else.
If you didn't request this code by trying to log in on another device, simply ignore this message.`
// ErrLoginCodeMessageTemplateMissingCode is returned when a candidate
// login-code message template does not contain the {{code}} placeholder
// exactly once -- see ValidateLoginCodeMessageTemplate. The admin-API layer
// (cmd/telesrv-admin) must reject a save with this error outright rather
// than silently accepting it: a template with zero {{code}} occurrences
// would never deliver the actual OTP to the user at all.
var ErrLoginCodeMessageTemplateMissingCode = errors.New("login code message template must contain the {{code}} placeholder exactly once")
// ValidateLoginCodeMessageTemplate requires the {{code}} placeholder to
// appear exactly once. Zero occurrences is a functional break (the OTP
// itself would never reach the user), and two-or-more is ambiguous (which
// occurrence gets the bold entity and the substitution?) -- both are
// rejected outright, never silently patched around by e.g. appending the
// code somewhere the admin didn't put it.
func ValidateLoginCodeMessageTemplate(template string) error {
if strings.Count(template, loginCodeTemplateCodePlaceholder) != 1 {
return ErrLoginCodeMessageTemplateMissingCode
}
return nil
}
// ResolveLoginCodeMessageTemplate picks the final template body, in order:
// an explicit admin-panel override (panelOverride, as stored raw in
// identity.Info -- empty means "not configured"), then an explicit env-var
// default (envDefault, empty means "not configured"), then the compiled-in
// DefaultLoginCodeMessageTemplate. It is a pure function so the precedence
// logic can be unit-tested without touching the identity store or config --
// those live in internal/app/auth, which resolves this fresh on every
// login-code delivery (never cached) so an admin-panel edit takes effect
// immediately, mirroring ResolveWelcomeMessageTemplate. Unlike that
// resolver there is no per-method branching: every login code, regardless
// of delivery channel, uses the same template.
//
// This does not itself validate the {{code}} placeholder -- callers that
// persist an override (the admin API) must call
// ValidateLoginCodeMessageTemplate before saving. OfficialLoginCodeMessage
// re-validates whatever it resolves to anyway, as defense in depth against
// an invalid value that reached here some other way (a hand-edited
// identity.json, an out-of-band env var change).
func ResolveLoginCodeMessageTemplate(panelOverride, envDefault string) string {
if t := strings.TrimSpace(panelOverride); t != "" {
return panelOverride
}
if t := strings.TrimSpace(envDefault); t != "" {
return envDefault
}
return DefaultLoginCodeMessageTemplate
}
// LoginCodeDeliveryRequest describes one durable 777000 login-code delivery.
@ -23,7 +91,15 @@ type LoginCodeDeliveryRequest struct {
UserID int64
PhoneCodeHash string
Code string
Date int
// Template is the already-resolved login-code message template (see
// ResolveLoginCodeMessageTemplate) -- resolving it requires the identity
// store and config, both of which live above internal/store, so callers
// (internal/app/auth) do that and pass the final template text in here,
// the same division of responsibility OfficialWelcomeMessage's body
// parameter uses. Empty falls back to DefaultLoginCodeMessageTemplate
// (see OfficialLoginCodeMessage).
Template string
Date int
// ExpiresAt is the unix second after which the compact idempotency receipt
// may be reclaimed. It must cover the corresponding code's usable lifetime.
ExpiresAt int64
@ -39,12 +115,33 @@ type LoginCodeDeliveryResult struct {
// OfficialLoginCodeMessage builds the account-visible incoming message from
// Telegram's official notification account. Persistence assigns ID, UID and
// Pts atomically.
func OfficialLoginCodeMessage(userID int64, code string, date int) (Message, error) {
//
// template is rendered ({{server_name}} substituted, then {{code}} replaced
// with the actual code) and the resulting bold MessageEntity is positioned
// dynamically from wherever {{code}} actually landed after substitution --
// never assumed from a fixed prefix, since template is admin-editable (see
// ValidateLoginCodeMessageTemplate). A template that is empty or fails
// validation falls back to DefaultLoginCodeMessageTemplate instead of ever
// shipping a message with no code in it.
func OfficialLoginCodeMessage(userID int64, template, code string, date int) (Message, error) {
if userID <= 0 || IsSystemUserID(userID) || strings.TrimSpace(code) == "" || len(code) > 64 || date < 0 || date > math.MaxInt32 {
return Message{}, fmt.Errorf("%w: user=%d code_length=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, len(code), date)
}
body := fmt.Sprintf(officialLoginCodeMessageTemplate(), code)
codeOffset := len("Login code: ")
if strings.TrimSpace(template) == "" || ValidateLoginCodeMessageTemplate(template) != nil {
template = DefaultLoginCodeMessageTemplate
}
rendered := RenderWelcomeMessageTemplate(template)
idx := strings.Index(rendered, loginCodeTemplateCodePlaceholder)
if idx < 0 {
// Unreachable in practice: template was just validated (or is the
// compiled-in default) to contain the placeholder exactly once, and
// {{server_name}} substitution cannot remove or relocate an
// unrelated placeholder. Guarded anyway rather than ever ship a
// message silently missing its code.
rendered = DefaultLoginCodeMessageTemplate
idx = strings.Index(rendered, loginCodeTemplateCodePlaceholder)
}
body := rendered[:idx] + code + rendered[idx+len(loginCodeTemplateCodePlaceholder):]
return Message{
OwnerUserID: userID,
Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
@ -52,8 +149,7 @@ func OfficialLoginCodeMessage(userID int64, code string, date int) (Message, err
Date: date,
Body: body,
Entities: []MessageEntity{
{Type: MessageEntityBold, Offset: 0, Length: len("Login code:")},
{Type: MessageEntityBold, Offset: codeOffset, Length: len(code)},
{Type: MessageEntityBold, Offset: automaticEntityUTF16Length(rendered[:idx]), Length: automaticEntityUTF16Length(code)},
},
}, nil
}

View file

@ -0,0 +1,132 @@
package domain
import (
"errors"
"strings"
"testing"
)
func TestValidateLoginCodeMessageTemplate(t *testing.T) {
if err := ValidateLoginCodeMessageTemplate("Your code is {{code}}."); err != nil {
t.Fatalf("exactly one {{code}} should be valid, got %v", err)
}
if err := ValidateLoginCodeMessageTemplate("No placeholder here."); !errors.Is(err, ErrLoginCodeMessageTemplateMissingCode) {
t.Fatalf("zero occurrences should be rejected, got %v", err)
}
if err := ValidateLoginCodeMessageTemplate("{{code}} and again {{code}}."); !errors.Is(err, ErrLoginCodeMessageTemplateMissingCode) {
t.Fatalf("two occurrences should be rejected, got %v", err)
}
if err := ValidateLoginCodeMessageTemplate(""); !errors.Is(err, ErrLoginCodeMessageTemplateMissingCode) {
t.Fatalf("empty template should be rejected, got %v", err)
}
}
func TestResolveLoginCodeMessageTemplatePrecedence(t *testing.T) {
const panel = "panel override {{code}}"
const env = "env default {{code}}"
if got := ResolveLoginCodeMessageTemplate(panel, env); got != panel {
t.Fatalf("panel override should win, got %q", got)
}
if got := ResolveLoginCodeMessageTemplate("", env); got != env {
t.Fatalf("env default should win when panel unset, got %q", got)
}
if got := ResolveLoginCodeMessageTemplate(" ", env); got != env {
t.Fatalf("whitespace-only panel override should be treated as unset, got %q", got)
}
if got := ResolveLoginCodeMessageTemplate("", ""); got != DefaultLoginCodeMessageTemplate {
t.Fatalf("built-in default should be the final fallback, got %q", got)
}
}
func TestOfficialLoginCodeMessageDynamicEntityOffset(t *testing.T) {
SetOfficialSystemUserDisplayName("")
defer SetOfficialSystemUserDisplayName("")
// {{code}} is nowhere near a fixed prefix here -- it sits at the end of
// a sentence, after other text -- proving the entity offset is computed
// from where the placeholder actually landed, not assumed from a
// hardcoded "Login code: " prefix the way the old %s-based
// implementation did.
template := "Please do not share your one-time code, which is: {{code}} -- thanks!"
msg, err := OfficialLoginCodeMessage(7, template, "998877", 1700000000)
if err != nil {
t.Fatalf("OfficialLoginCodeMessage: %v", err)
}
wantBody := "Please do not share your one-time code, which is: 998877 -- thanks!"
if msg.Body != wantBody {
t.Fatalf("body = %q, want %q", msg.Body, wantBody)
}
if len(msg.Entities) != 1 {
t.Fatalf("expected exactly one entity, got %d: %+v", len(msg.Entities), msg.Entities)
}
entity := msg.Entities[0]
if entity.Type != MessageEntityBold {
t.Fatalf("expected bold entity, got %v", entity.Type)
}
wantOffset := automaticEntityUTF16Length("Please do not share your one-time code, which is: ")
if entity.Offset != wantOffset {
t.Fatalf("offset = %d, want %d", entity.Offset, wantOffset)
}
if entity.Length != automaticEntityUTF16Length("998877") {
t.Fatalf("length = %d, want %d", entity.Length, automaticEntityUTF16Length("998877"))
}
}
func TestOfficialLoginCodeMessageOffsetShiftsWithServerNameSubstitution(t *testing.T) {
SetOfficialSystemUserDisplayName("A Much Longer Custom Server Name")
defer SetOfficialSystemUserDisplayName("")
// {{server_name}} is substituted BEFORE {{code}}'s position is located,
// so a longer server name shifts the code's offset. If the offset math
// were still relying on a fixed/original position (e.g. computed
// against the raw un-substituted template), this would land on the
// wrong text.
template := "Server {{server_name}} says your code is {{code}}."
msg, err := OfficialLoginCodeMessage(7, template, "42", 1)
if err != nil {
t.Fatalf("OfficialLoginCodeMessage: %v", err)
}
wantBody := "Server A Much Longer Custom Server Name says your code is 42."
if msg.Body != wantBody {
t.Fatalf("body = %q, want %q", msg.Body, wantBody)
}
wantOffset := automaticEntityUTF16Length("Server A Much Longer Custom Server Name says your code is ")
if len(msg.Entities) != 1 || msg.Entities[0].Offset != wantOffset {
t.Fatalf("entities = %+v, want single bold entity at offset %d", msg.Entities, wantOffset)
}
}
func TestOfficialLoginCodeMessageFallsBackWhenTemplateInvalid(t *testing.T) {
SetOfficialSystemUserDisplayName("")
defer SetOfficialSystemUserDisplayName("")
// Defense in depth: a template that somehow reaches here without
// {{code}} (or with it more than once) must never ship a message
// silently missing the actual OTP -- it falls back to the compiled-in
// default instead.
for _, template := range []string{"", "no placeholder", "{{code}} twice {{code}}"} {
msg, err := OfficialLoginCodeMessage(7, template, "13579", 1)
if err != nil {
t.Fatalf("template %q: OfficialLoginCodeMessage: %v", template, err)
}
if !strings.Contains(msg.Body, "13579") {
t.Fatalf("template %q: fallback body missing code: %q", template, msg.Body)
}
if len(msg.Entities) != 1 || msg.Entities[0].Length != automaticEntityUTF16Length("13579") {
t.Fatalf("template %q: unexpected entities: %+v", template, msg.Entities)
}
}
}
func TestOfficialLoginCodeMessageValidation(t *testing.T) {
if _, err := OfficialLoginCodeMessage(0, "{{code}}", "12345", 1); !errors.Is(err, ErrLoginCodeDeliveryInvalid) {
t.Fatalf("expected invalid user id to be rejected, got %v", err)
}
if _, err := OfficialLoginCodeMessage(7, "{{code}}", "", 1); !errors.Is(err, ErrLoginCodeDeliveryInvalid) {
t.Fatalf("expected empty code to be rejected, got %v", err)
}
if _, err := OfficialLoginCodeMessage(OfficialSystemUserID, "{{code}}", "12345", 1); !errors.Is(err, ErrLoginCodeDeliveryInvalid) {
t.Fatalf("expected system user id to be rejected, got %v", err)
}
}

View file

@ -0,0 +1,70 @@
package domain
import "strings"
// LoginMethod distinguishes the two sign-in channels the login-welcome
// message template can be customized per: phone (SMS/app-code) and email
// (email-signup accounts, see SignInMethodLabel). There is no third axis --
// no signup-vs-signin distinction, no 2FA-vs-not distinction -- because
// recordWelcomeMessage's callers never carry more than this.
type LoginMethod string
const (
LoginMethodPhone LoginMethod = "phone"
LoginMethodEmail LoginMethod = "email"
)
// LoginMethodFromLabel maps SignInMethodLabel's human-readable string back
// to a LoginMethod, so callers that already computed the label (for the
// {{...}} template's own historical "via %s" wording) don't need to
// recompute it from the User a second time.
func LoginMethodFromLabel(label string) LoginMethod {
if label == "email" {
return LoginMethodEmail
}
return LoginMethodPhone
}
// DefaultWelcomeMessagePhoneTemplate and DefaultWelcomeMessageEmailTemplate
// are the built-in, final-fallback copy for the login-notification message
// sent from the official system account (777000) on every completed
// sign-in. They are deliberately separate strings (not one template with a
// substituted method name) so each reads naturally in its own channel.
//
// {{server_name}} is replaced with the server's current effective display
// name (see ResolveWelcomeMessageTemplate / RenderWelcomeMessageTemplate).
const (
DefaultWelcomeMessagePhoneTemplate = "👋 Welcome to {{server_name}}!\n\nA new sign-in to your account was just completed using your phone number.\n\nIf this was you, no action is needed. If it wasn't, please revoke this session immediately from Settings → Privacy and Security → Active Sessions."
DefaultWelcomeMessageEmailTemplate = "👋 Welcome to {{server_name}}!\n\nA new sign-in to your account was just completed using your email address.\n\nIf this was you, no action is needed. If it wasn't, please revoke this session immediately from Settings → Privacy and Security → Active Sessions."
)
// ResolveWelcomeMessageTemplate picks the final template body for the given
// login method, in order: an explicit admin-panel override (panelOverride,
// as stored raw in identity.Info -- empty means "not configured"), then an
// explicit env-var default (envDefault, empty means "not configured"), then
// the compiled-in default for that method. It is a pure function so the
// precedence logic can be unit-tested without touching the identity store
// or config -- those live in internal/app/auth, which calls this on every
// recordWelcomeMessage invocation (never cached) so an admin-panel edit
// takes effect immediately.
func ResolveWelcomeMessageTemplate(method LoginMethod, panelOverride, envDefault string) string {
if t := strings.TrimSpace(panelOverride); t != "" {
return panelOverride
}
if t := strings.TrimSpace(envDefault); t != "" {
return envDefault
}
if method == LoginMethodEmail {
return DefaultWelcomeMessageEmailTemplate
}
return DefaultWelcomeMessagePhoneTemplate
}
// RenderWelcomeMessageTemplate substitutes the {{server_name}} placeholder
// in template with the server's current effective display name. It is a
// literal, single-placeholder replacement -- no templating engine, since
// there's exactly one substitution to make.
func RenderWelcomeMessageTemplate(template string) string {
return strings.ReplaceAll(template, "{{server_name}}", officialSystemDisplayName())
}

View file

@ -0,0 +1,57 @@
package domain
import "testing"
func TestResolveWelcomeMessageTemplatePrecedence(t *testing.T) {
const panel = "panel override"
const env = "env default"
if got := ResolveWelcomeMessageTemplate(LoginMethodPhone, panel, env); got != panel {
t.Fatalf("panel override should win, got %q", got)
}
if got := ResolveWelcomeMessageTemplate(LoginMethodPhone, "", env); got != env {
t.Fatalf("env default should win when panel unset, got %q", got)
}
if got := ResolveWelcomeMessageTemplate(LoginMethodPhone, " ", env); got != env {
t.Fatalf("whitespace-only panel override should be treated as unset, got %q", got)
}
if got := ResolveWelcomeMessageTemplate(LoginMethodPhone, "", ""); got != DefaultWelcomeMessagePhoneTemplate {
t.Fatalf("built-in phone default should be the final fallback, got %q", got)
}
if got := ResolveWelcomeMessageTemplate(LoginMethodEmail, "", ""); got != DefaultWelcomeMessageEmailTemplate {
t.Fatalf("built-in email default should be the final fallback, got %q", got)
}
}
func TestLoginMethodFromLabel(t *testing.T) {
if LoginMethodFromLabel("email") != LoginMethodEmail {
t.Fatal("expected email label to map to LoginMethodEmail")
}
if LoginMethodFromLabel("phone number") != LoginMethodPhone {
t.Fatal("expected phone label to map to LoginMethodPhone")
}
if LoginMethodFromLabel("") != LoginMethodPhone {
t.Fatal("expected unknown label to default to LoginMethodPhone")
}
}
func TestRenderWelcomeMessageTemplateSubstitutesServerName(t *testing.T) {
SetOfficialSystemUserDisplayName("")
defer SetOfficialSystemUserDisplayName("")
got := RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
if got != "Hello from OwpenGram!" {
t.Fatalf("expected default branding.ProductName substitution, got %q", got)
}
SetOfficialSystemUserDisplayName("Custom Server")
got = RenderWelcomeMessageTemplate("Hello from {{server_name}}!")
if got != "Hello from Custom Server!" {
t.Fatalf("expected custom display name substitution, got %q", got)
}
// No placeholder present -- must be a no-op.
if got := RenderWelcomeMessageTemplate("no placeholder here"); got != "no placeholder here" {
t.Fatalf("expected no-op when placeholder absent, got %q", got)
}
}

View file

@ -124,6 +124,19 @@ func SetOfficialSystemUserDisplayName(name string) {
officialSystemUserDisplayName = strings.TrimSpace(name)
}
// officialSystemDisplayName returns the official system account's current
// effective display name: the operator's custom override if set via
// SetOfficialSystemUserDisplayName, else branding.ProductName. Shared by
// OfficialSystemUser (777000's FirstName) and the login-welcome-message
// {{server_name}} placeholder (see login_welcome_template.go) so both stay
// consistent with each other.
func officialSystemDisplayName() string {
if officialSystemUserDisplayName != "" {
return officialSystemUserDisplayName
}
return branding.ProductName
}
// botFatherPhotoDCID/Stripped 由 files.Service.SeedBotFatherAvatar 在启动时
// 通过 SetBotFatherAvatar 写入一次;写入前 BotFatherUser() 不带头像PhotoID==0
var (
@ -189,15 +202,11 @@ func SetVerifyBotAvatar(dcID int, stripped []byte) {
// config.ReservedUsernames (which it now is, by default, precisely because
// nothing keeps another account from claiming it once this one has none).
func OfficialSystemUser() User {
name := branding.ProductName
if officialSystemUserDisplayName != "" {
name = officialSystemUserDisplayName
}
u := User{
ID: OfficialSystemUserID,
AccessHash: 6599886787491911851,
Phone: "42777",
FirstName: name,
FirstName: officialSystemDisplayName(),
Verified: true,
Support: true,
}

View file

@ -10,8 +10,6 @@ import (
"time"
)
const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just signed in via %s.\n\nIf this wasn't you, revoke this session from \"Settings > Privacy and Security > Active sessions\" immediately."
// OfficialWelcomeMessage builds the account-visible incoming message sent
// from the official system account on every completed sign-in (SignUp and
// every subsequent SignIn/SignInWithEmail), regardless of delivery channel.
@ -19,17 +17,23 @@ const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just s
// to send unconditionally — it exists to give the account owner (and, on a
// self-hosted single-admin server, that's usually also "the admin") a
// visible record of every session start.
func OfficialWelcomeMessage(userID int64, method string, date int) (Message, error) {
method = strings.TrimSpace(method)
if userID <= 0 || IsSystemUserID(userID) || method == "" || date < 0 || date > math.MaxInt32 {
return Message{}, fmt.Errorf("%w: user=%d method=%q date=%d", ErrLoginCodeDeliveryInvalid, userID, method, date)
//
// body is the already-resolved, already-{{server_name}}-substituted message
// text (see ResolveWelcomeMessageTemplate / RenderWelcomeMessageTemplate in
// login_welcome_template.go) -- resolving it requires the identity store and
// config, both of which live above this package, so callers (internal/app/auth)
// do that and pass the final text in here.
func OfficialWelcomeMessage(userID int64, body string, date int) (Message, error) {
body = strings.TrimSpace(body)
if userID <= 0 || IsSystemUserID(userID) || body == "" || date < 0 || date > math.MaxInt32 {
return Message{}, fmt.Errorf("%w: user=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, date)
}
return Message{
OwnerUserID: userID,
Peer: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
From: Peer{Type: PeerTypeUser, ID: OfficialSystemUserID},
Date: date,
Body: fmt.Sprintf(officialWelcomeMessageTemplate, method),
Body: body,
}, nil
}