added messages templates
This commit is contained in:
parent
e8dc967e6a
commit
7cd1f64d0d
29 changed files with 1266 additions and 84 deletions
122
internal/app/auth/login_code_message_template_test.go
Normal file
122
internal/app/auth/login_code_message_template_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/identity"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestLoginCodeMessageTemplateResolutionPrecedence exercises
|
||||
// WithLoginCodeMessageTemplate/resolveLoginCodeMessageTemplate's precedence
|
||||
// (panel override > env default > compiled-in default) end to end through
|
||||
// SignUp's bootstrap recordLoginMessage path (phone channel, no owner/dialog
|
||||
// yet -- see service.go's rec.Channel == codeChannelPhone branch), mirroring
|
||||
// how welcome_message_test.go exercises WithLoginWelcomeMessages.
|
||||
func TestLoginCodeMessageTemplateResolutionPrecedence(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
newSvc := func(store *identity.Store, envDefault string) (*Service, *memory.MessageStore) {
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
return NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345",
|
||||
WithLoginMessages(messages, dialogs),
|
||||
WithLoginCodeMessageTemplate(store, envDefault),
|
||||
), messages
|
||||
}
|
||||
|
||||
// No override, no env default: falls back to the compiled-in default.
|
||||
t.Run("compiled-in default", func(t *testing.T) {
|
||||
svc, messages := newSvc(nil, "")
|
||||
u := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771001")
|
||||
body := codeMessageBody(t, ctx, messages, u.ID)
|
||||
if !strings.Contains(body, "Login code: 12345") {
|
||||
t.Fatalf("body = %q, want compiled-in default rendering", body)
|
||||
}
|
||||
})
|
||||
|
||||
// Env default set, no panel override: env default wins.
|
||||
t.Run("env default", func(t *testing.T) {
|
||||
svc, messages := newSvc(nil, "Env says your code is {{code}}.")
|
||||
u := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771002")
|
||||
body := codeMessageBody(t, ctx, messages, u.ID)
|
||||
if body != "Env says your code is 12345." {
|
||||
t.Fatalf("body = %q, want env-default rendering", body)
|
||||
}
|
||||
})
|
||||
|
||||
// Panel override set: wins over both the env default and the compiled-in
|
||||
// default, and is read fresh (not cached) -- see resolveLoginCodeMessageTemplate.
|
||||
t.Run("panel override wins and is read fresh", func(t *testing.T) {
|
||||
store := identity.NewStore(t.TempDir())
|
||||
svc, messages := newSvc(store, "Env says your code is {{code}}.")
|
||||
|
||||
u := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771003")
|
||||
body := codeMessageBody(t, ctx, messages, u.ID)
|
||||
if body != "Env says your code is 12345." {
|
||||
t.Fatalf("body before override = %q, want env-default rendering", body)
|
||||
}
|
||||
|
||||
if err := store.SetLoginCodeMessageTemplate("Panel says your code is {{code}}."); err != nil {
|
||||
t.Fatalf("SetLoginCodeMessageTemplate: %v", err)
|
||||
}
|
||||
u2 := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771004")
|
||||
body2 := codeMessageBody(t, ctx, messages, u2.ID)
|
||||
if body2 != "Panel says your code is 12345." {
|
||||
t.Fatalf("body after override = %q, want panel-override rendering with no restart", body2)
|
||||
}
|
||||
})
|
||||
|
||||
// A saved-but-somehow-invalid panel override (missing {{code}} --
|
||||
// bypassing the admin-API validation, e.g. a hand-edited identity.json)
|
||||
// must never reach a client with the code silently missing: defense in
|
||||
// depth falls back to the compiled-in default instead.
|
||||
t.Run("invalid panel override falls back safely", func(t *testing.T) {
|
||||
store := identity.NewStore(t.TempDir())
|
||||
if err := store.SetLoginCodeMessageTemplate("no placeholder here"); err != nil {
|
||||
t.Fatalf("SetLoginCodeMessageTemplate: %v", err)
|
||||
}
|
||||
svc, messages := newSvc(store, "")
|
||||
u := signUpPhoneForLoginCodeMessage(t, ctx, svc, "+15550771005")
|
||||
body := codeMessageBody(t, ctx, messages, u.ID)
|
||||
if !strings.Contains(body, "12345") {
|
||||
t.Fatalf("body = %q, want the code delivered via fallback despite invalid override", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func signUpPhoneForLoginCodeMessage(t *testing.T, ctx context.Context, svc *Service, phone string) domain.User {
|
||||
t.Helper()
|
||||
hash, err := svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
verifyCodeForSignUp(t, svc, phone, hash, "12345")
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Code", "Template")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// codeMessageBody finds the login-code delivery message among the user's
|
||||
// full message history (not the dialog summary, which only tracks each
|
||||
// peer's single top message -- SignUp's welcome message overwrites the
|
||||
// 777000 dialog's top message right after the login-code one is written).
|
||||
func codeMessageBody(t *testing.T, ctx context.Context, messages *memory.MessageStore, userID int64) string {
|
||||
t.Helper()
|
||||
list, err := messages.ListByUser(ctx, userID, domain.MessageFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
for _, msg := range list.Messages {
|
||||
if strings.Contains(msg.Body, "12345") {
|
||||
return msg.Body
|
||||
}
|
||||
}
|
||||
t.Fatalf("no login-code message (containing 12345) found among %d messages for user %d", len(list.Messages), userID)
|
||||
return ""
|
||||
}
|
||||
|
|
@ -17,8 +17,8 @@ import (
|
|||
"github.com/iamxvbaba/td/bin"
|
||||
mtcrypto "github.com/iamxvbaba/td/crypto"
|
||||
|
||||
"telesrv/internal/branding"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/identity"
|
||||
"telesrv/internal/otpdelivery"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
|
@ -114,6 +114,21 @@ type Service struct {
|
|||
// stickerSets/defaultStickerSetID:新注册账号默认安装的贴纸集(见 WithDefaultStickerSet)。
|
||||
stickerSets userStickerSetInstaller
|
||||
defaultStickerSetID int64
|
||||
// welcomeMessageIdentity 是 identity.Store 的共享实例,recordWelcomeMessage
|
||||
// 每次调用都重新读取(不缓存),使 admin 面板对登录通知模板的修改无需重启即可
|
||||
// 生效 -- 与 identity 包自身的设计契约一致。nil 时该来源被跳过,直接落到
|
||||
// welcomeMessage{Phone,Email}Default。
|
||||
welcomeMessageIdentity *identity.Store
|
||||
welcomeMessagePhoneDefault string
|
||||
welcomeMessageEmailDefault string
|
||||
// loginCodeMessageIdentity/loginCodeMessageEnvDefault mirror
|
||||
// welcomeMessageIdentity/welcomeMessage{Phone,Email}Default above, for
|
||||
// the 777000 login-code delivery message instead of the post-sign-in
|
||||
// welcome notification -- see WithLoginCodeMessageTemplate and
|
||||
// resolveLoginCodeMessageTemplate. There is only one env default (not
|
||||
// per-method) because the login-code message never varies by channel.
|
||||
loginCodeMessageIdentity *identity.Store
|
||||
loginCodeMessageEnvDefault string
|
||||
}
|
||||
|
||||
type loginEmailStore interface {
|
||||
|
|
@ -147,6 +162,53 @@ func WithLoginMessages(messages store.MessageStore, dialogs store.DialogStore) O
|
|||
}
|
||||
}
|
||||
|
||||
// WithLoginWelcomeMessages configures the resolution chain for the 777000
|
||||
// login-notification message's template text (see
|
||||
// domain.ResolveWelcomeMessageTemplate): store is read fresh on every
|
||||
// recordWelcomeMessage call (never cached, so admin-panel edits apply with
|
||||
// no restart), and phoneDefault/emailDefault are the config-supplied env-var
|
||||
// fallbacks (Config.WelcomeMessage{Phone,Email}Template), used whenever the
|
||||
// panel hasn't set an override. A nil store just skips that source.
|
||||
func WithLoginWelcomeMessages(store *identity.Store, phoneDefault, emailDefault string) Option {
|
||||
return func(s *Service) {
|
||||
s.welcomeMessageIdentity = store
|
||||
s.welcomeMessagePhoneDefault = phoneDefault
|
||||
s.welcomeMessageEmailDefault = emailDefault
|
||||
}
|
||||
}
|
||||
|
||||
// WithLoginCodeMessageTemplate configures the resolution chain for the
|
||||
// 777000 login-code delivery message's template text (see
|
||||
// domain.ResolveLoginCodeMessageTemplate): store is read fresh on every
|
||||
// deliverLoginCode/recordLoginMessage call (never cached, so admin-panel
|
||||
// edits apply with no restart), and envDefault is the config-supplied
|
||||
// env-var fallback (Config.LoginCodeMessageTemplate), used whenever the
|
||||
// panel hasn't set an override. A nil store just skips that source. Callers
|
||||
// normally pass the same *identity.Store instance already wired via
|
||||
// WithLoginWelcomeMessages, since both read/write the same identity.json.
|
||||
func WithLoginCodeMessageTemplate(store *identity.Store, envDefault string) Option {
|
||||
return func(s *Service) {
|
||||
s.loginCodeMessageIdentity = store
|
||||
s.loginCodeMessageEnvDefault = envDefault
|
||||
}
|
||||
}
|
||||
|
||||
// resolveLoginCodeMessageTemplate resolves the 777000 login-code message
|
||||
// template fresh on every call (never cached), mirroring
|
||||
// recordWelcomeMessage's "always read fresh" contract so an admin-panel
|
||||
// edit takes effect with no restart. Every caller of
|
||||
// domain.OfficialLoginCodeMessage in this service must go through here
|
||||
// rather than hardcoding its own copy of the template.
|
||||
func (s *Service) resolveLoginCodeMessageTemplate() string {
|
||||
panelOverride := ""
|
||||
if s.loginCodeMessageIdentity != nil {
|
||||
if info, err := s.loginCodeMessageIdentity.Get(); err == nil {
|
||||
panelOverride = info.LoginCodeMessageTemplate
|
||||
}
|
||||
}
|
||||
return domain.ResolveLoginCodeMessageTemplate(panelOverride, s.loginCodeMessageEnvDefault)
|
||||
}
|
||||
|
||||
// WithLoginCodeDelivery 注入已有账号 app-code 的 durable 投递边界。
|
||||
// 实现必须以 user_id + phone_code_hash 幂等,并原子写入 777000
|
||||
// message/dialog/user update event/dispatch outbox。
|
||||
|
|
@ -590,6 +652,7 @@ func (s *Service) deliverLoginCode(ctx context.Context, userID int64, phoneCodeH
|
|||
UserID: userID,
|
||||
PhoneCodeHash: phoneCodeHash,
|
||||
Code: code,
|
||||
Template: s.resolveLoginCodeMessageTemplate(),
|
||||
Date: int(now.Unix()),
|
||||
ExpiresAt: now.Add(s.codeTTL).Unix(),
|
||||
}); err != nil {
|
||||
|
|
@ -1579,31 +1642,24 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error
|
|||
return found && settings.HasPassword, nil
|
||||
}
|
||||
|
||||
func loginMessageTemplate() string {
|
||||
return `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
|
||||
This code can be used to log in to your ` + branding.ProductName + ` 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.`
|
||||
}
|
||||
|
||||
// recordLoginMessage writes the 777000 login-code message for the
|
||||
// bootstrap "new phone-channel account" path (see SignUp's rec.Channel ==
|
||||
// codeChannelPhone branch), where no owner/dialog exists yet so
|
||||
// WithLoginCodeDelivery's durable idempotent path cannot be used. It builds
|
||||
// the message the same way deliverLoginCode does -- via
|
||||
// domain.OfficialLoginCodeMessage with a freshly resolved template (see
|
||||
// resolveLoginCodeMessageTemplate) -- rather than keeping its own separate
|
||||
// copy of the template/entity logic, so an admin-panel edit and the
|
||||
// {{code}}-placeholder entity-offset fix apply here too.
|
||||
func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code string) (domain.Message, error) {
|
||||
if s.messages == nil || s.dialogs == nil {
|
||||
return domain.Message{}, nil
|
||||
}
|
||||
body := fmt.Sprintf(loginMessageTemplate(), code)
|
||||
codeOffset := len("Login code: ")
|
||||
msg, err := s.messages.Create(ctx, domain.Message{
|
||||
OwnerUserID: userID,
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
From: domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID},
|
||||
Date: int(time.Now().Unix()),
|
||||
Body: body,
|
||||
Entities: []domain.MessageEntity{
|
||||
{Type: domain.MessageEntityBold, Offset: 0, Length: len("Login code:")},
|
||||
{Type: domain.MessageEntityBold, Offset: codeOffset, Length: len(code)},
|
||||
},
|
||||
})
|
||||
base, err := domain.OfficialLoginCodeMessage(userID, s.resolveLoginCodeMessageTemplate(), code, int(time.Now().Unix()))
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
msg, err := s.messages.Create(ctx, base)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
|
|
@ -1626,7 +1682,24 @@ func (s *Service) recordWelcomeMessage(ctx context.Context, u domain.User) {
|
|||
if s == nil || s.messages == nil || s.dialogs == nil {
|
||||
return
|
||||
}
|
||||
msg, err := domain.OfficialWelcomeMessage(u.ID, domain.SignInMethodLabel(u), int(time.Now().Unix()))
|
||||
method := domain.LoginMethodFromLabel(domain.SignInMethodLabel(u))
|
||||
envDefault := s.welcomeMessagePhoneDefault
|
||||
if method == domain.LoginMethodEmail {
|
||||
envDefault = s.welcomeMessageEmailDefault
|
||||
}
|
||||
panelOverride := ""
|
||||
if s.welcomeMessageIdentity != nil {
|
||||
if info, err := s.welcomeMessageIdentity.Get(); err == nil {
|
||||
if method == domain.LoginMethodEmail {
|
||||
panelOverride = info.WelcomeMessageEmailTemplate
|
||||
} else {
|
||||
panelOverride = info.WelcomeMessagePhoneTemplate
|
||||
}
|
||||
}
|
||||
}
|
||||
template := domain.ResolveWelcomeMessageTemplate(method, panelOverride, envDefault)
|
||||
body := domain.RenderWelcomeMessageTemplate(template)
|
||||
msg, err := domain.OfficialWelcomeMessage(u.ID, body, int(time.Now().Unix()))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ func TestEmailSignupSignUpWritesWelcomeMessageMentioningEmail(t *testing.T) {
|
|||
if len(list.Messages) != 1 {
|
||||
t.Fatalf("messages = %+v, want exactly the welcome message (email channel skips the code-echo message)", list.Messages)
|
||||
}
|
||||
if !strings.Contains(list.Messages[0].Body, "Welcome to OwpenGram") || !strings.Contains(list.Messages[0].Body, "via email") {
|
||||
if !strings.Contains(list.Messages[0].Body, "Welcome to") || !strings.Contains(list.Messages[0].Body, "email address") {
|
||||
t.Fatalf("welcome message body = %q, want greeting mentioning email", list.Messages[0].Body)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue