feat: sync public links and phone change updates

This commit is contained in:
A 2026-07-10 22:01:44 +08:00
parent 41c7f1d018
commit da04c0fa6a
53 changed files with 3029 additions and 111 deletions

View file

@ -19,6 +19,8 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) {
d.OnAccountUnregisterDevice(func(ctx context.Context, req *tg.AccountUnregisterDeviceRequest) (bool, error) {
return true, nil
})
d.OnAccountSendChangePhoneCode(r.onAccountSendChangePhoneCode)
d.OnAccountChangePhone(r.onAccountChangePhone)
d.OnAccountCheckUsername(r.onAccountCheckUsername)
d.OnAccountUpdateProfile(r.onAccountUpdateProfile)
d.OnAccountUpdateUsername(r.onAccountUpdateUsername)

View file

@ -0,0 +1,89 @@
package rpc
import (
"context"
"go.uber.org/zap"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
type phoneChangeEventConfirmer interface {
ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID int64, event domain.UpdateEvent) error
}
type phoneChangeReliableDispatchReporter interface {
PhoneChangeUsesReliableDispatch() bool
}
func (r *Router) onAccountSendChangePhoneCode(ctx context.Context, req *tg.AccountSendChangePhoneCodeRequest) (tg.AuthSentCodeClass, error) {
userID, found, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !found || r.deps.Account == nil {
return nil, authKeyUnregisteredErr()
}
authKeyID, ok := AuthKeyIDFrom(ctx)
if !ok || authKeyID == ([8]byte{}) {
return nil, authKeyUnregisteredErr()
}
sessionID, _ := SessionIDFrom(ctx)
hash, delivery, err := r.deps.Account.SendChangePhoneCode(ctx, userID, authKeyID, sessionID, req.PhoneNumber)
if err != nil {
return nil, phoneChangeErr(err)
}
return tgSMSSentCode(hash, delivery.Length), nil
}
func (r *Router) onAccountChangePhone(ctx context.Context, req *tg.AccountChangePhoneRequest) (tg.UserClass, error) {
userID, found, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !found || r.deps.Account == nil {
return nil, authKeyUnregisteredErr()
}
authKeyID, ok := AuthKeyIDFrom(ctx)
if !ok || authKeyID == ([8]byte{}) {
return nil, authKeyUnregisteredErr()
}
sessionID, _ := SessionIDFrom(ctx)
result, err := r.deps.Account.ChangePhone(
ctx,
userID,
authKeyID,
sessionID,
req.PhoneNumber,
req.PhoneCodeHash,
req.PhoneCode,
int(r.clock.Now().Unix()),
)
if err != nil {
return nil, phoneChangeErr(err)
}
if result.User.ID == 0 {
return nil, internalErr()
}
r.invalidateRPCProjectionForUser(result.User.ID)
if result.Event.Pts > 0 {
if confirmer, ok := r.deps.Updates.(phoneChangeEventConfirmer); ok {
if err := confirmer.ConfirmEvent(ctx, authKeyID, userID, result.Event); err != nil {
// user/event/outbox 已原子提交,不能把已成功改号伪装成失败;当前
// session 仍会收到 pts 簿记,设备水位存储可由后续 getDifference 自愈。
r.log.Warn("confirm phone change event", zap.Int64("user_id", userID), zap.Int("pts", result.Event.Pts), zap.Error(err))
}
}
reliable := false
if reporter, ok := r.deps.Account.(phoneChangeReliableDispatchReporter); ok {
reliable = reporter.PhoneChangeUsesReliableDispatch()
}
if !reliable {
r.pushUserUpdates(ctx, userID, tgUpdateForOutboxEvent(result.Event))
}
r.bookkeepAuxPtsForCurrentSession(ctx, result.Event)
}
return r.tgSelfUser(result.User), nil
}

View file

@ -0,0 +1,128 @@
package rpc
import (
"context"
"testing"
"time"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountChangePhoneRPCReturnsSelfPushesOthersAndReplaysDifference(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
auths := memory.NewAuthorizationStore()
codes := memory.NewCodeStore()
events := memory.NewUpdateEventStore()
user, err := users.Create(ctx, domain.User{AccessHash: 401, Phone: "15550013001", FirstName: "Alice"})
if err != nil {
t.Fatalf("create user: %v", err)
}
authKeyID := [8]byte{4, 3, 2, 1}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: user.ID, CreatedAt: time.Now().Add(-48 * time.Hour)}); err != nil {
t.Fatalf("bind auth: %v", err)
}
accountSvc := appaccount.NewService(
memory.NewPasswordStore(),
appaccount.WithUsers(users),
appaccount.WithPhoneChange(memory.NewPhoneChangeStore(users, events), auths, codes, nil, "12345", time.Minute, 5),
)
sessions := &captureSessions{}
r := New(Config{}, Deps{Account: accountSvc, Sessions: sessions}, zaptest.NewLogger(t), clock.System)
reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 77)
sentClass, err := r.onAccountSendChangePhoneCode(reqCtx, &tg.AccountSendChangePhoneCodeRequest{PhoneNumber: "+1 555 001 3002"})
if err != nil {
t.Fatalf("send change phone code: %v", err)
}
sent, ok := sentClass.(*tg.AuthSentCode)
if !ok {
t.Fatalf("sent code = %T", sentClass)
}
if _, ok := sent.Type.(*tg.AuthSentCodeTypeSMS); !ok || sent.PhoneCodeHash == "" {
t.Fatalf("sent code type/hash = %T/%q", sent.Type, sent.PhoneCodeHash)
}
userClass, err := r.onAccountChangePhone(reqCtx, &tg.AccountChangePhoneRequest{
PhoneNumber: "15550013002",
PhoneCodeHash: sent.PhoneCodeHash,
PhoneCode: "12345",
})
if err != nil {
t.Fatalf("change phone: %v", err)
}
self, ok := userClass.(*tg.User)
if !ok || self.ID != user.ID || self.Phone != "15550013002" {
t.Fatalf("returned self = %T %+v", userClass, userClass)
}
otherPush, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(otherPush.Updates) != 2 {
t.Fatalf("other-session push = %T %+v", sessions.lastUserPush(), sessions.lastUserPush())
}
phoneUpdate, ok := otherPush.Updates[0].(*tg.UpdateUserPhone)
if !ok || phoneUpdate.UserID != user.ID || phoneUpdate.Phone != "15550013002" {
t.Fatalf("phone update = %T %+v", otherPush.Updates[0], otherPush.Updates[0])
}
if _, ok := otherPush.Updates[1].(*tg.UpdateDeleteMessages); !ok {
t.Fatalf("pts bookkeeping = %T", otherPush.Updates[1])
}
currentPush, ok := sessions.snapshot().message.(*tg.Updates)
if !ok || len(currentPush.Updates) != 1 {
t.Fatalf("current-session bookkeeping = %T %+v", sessions.snapshot().message, sessions.snapshot().message)
}
if _, ok := currentPush.Updates[0].(*tg.UpdateDeleteMessages); !ok {
t.Fatalf("current bookkeeping update = %T", currentPush.Updates[0])
}
updateSvc := appupdates.NewService(memory.NewUpdateStateStore(), events)
diff, err := updateSvc.GetDifference(ctx, [8]byte{9}, user.ID, domain.UpdateState{Pts: 0})
if err != nil {
t.Fatalf("get difference: %v", err)
}
tgDiff, ok := tgUpdatesDifference(user.ID, diff).(*tg.UpdatesDifference)
if !ok || len(tgDiff.OtherUpdates) != 1 {
t.Fatalf("difference = %T %+v", tgUpdatesDifference(user.ID, diff), tgUpdatesDifference(user.ID, diff))
}
replayed, ok := tgDiff.OtherUpdates[0].(*tg.UpdateUserPhone)
if !ok || replayed.UserID != user.ID || replayed.Phone != "15550013002" {
t.Fatalf("replayed update = %T %+v", tgDiff.OtherUpdates[0], tgDiff.OtherUpdates[0])
}
}
func TestAccountChangePhoneRPCMapsCodeAndOccupiedErrors(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
auths := memory.NewAuthorizationStore()
codes := memory.NewCodeStore()
events := memory.NewUpdateEventStore()
user, _ := users.Create(ctx, domain.User{AccessHash: 411, Phone: "15550013101", FirstName: "Alice"})
occupied, _ := users.Create(ctx, domain.User{AccessHash: 412, Phone: "15550013102", FirstName: "Bob"})
authKeyID := [8]byte{5, 4, 3, 2}
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: user.ID}); err != nil {
t.Fatalf("bind auth: %v", err)
}
accountSvc := appaccount.NewService(memory.NewPasswordStore(),
appaccount.WithUsers(users),
appaccount.WithPhoneChange(memory.NewPhoneChangeStore(users, events), auths, codes, nil, "12345", time.Minute, 5))
r := New(Config{}, Deps{Account: accountSvc}, zaptest.NewLogger(t), clock.System)
reqCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, user.ID), authKeyID), 88)
if _, err := r.onAccountSendChangePhoneCode(reqCtx, &tg.AccountSendChangePhoneCodeRequest{PhoneNumber: occupied.Phone}); err == nil {
t.Fatal("occupied phone unexpectedly accepted")
} else {
assertPhoneRPCErr(t, err, "PHONE_NUMBER_OCCUPIED")
}
if _, err := r.onAccountChangePhone(reqCtx, &tg.AccountChangePhoneRequest{PhoneNumber: "15550013103"}); err == nil {
t.Fatal("empty code unexpectedly accepted")
} else {
assertPhoneRPCErr(t, err, "PHONE_CODE_EMPTY")
}
}

View file

@ -267,6 +267,16 @@ func tgSentCodeWithLength(hash string, length int) tg.AuthSentCodeClass {
}
}
func tgSMSSentCode(hash string, length int) tg.AuthSentCodeClass {
if length <= 0 {
length = devCodeLength
}
return &tg.AuthSentCode{
Type: &tg.AuthSentCodeTypeSMS{Length: length},
PhoneCodeHash: hash,
}
}
func tgEmailSentCode(hash, emailPattern string, length int) tg.AuthSentCodeClass {
if length <= 0 {
length = devCodeLength
@ -303,6 +313,8 @@ func (r *Router) tgSentCodeForHash(ctx context.Context, hash string) (tg.AuthSen
return nil, signInErr(auth.ErrCodeExpired)
}
switch delivery.Kind {
case domain.AuthCodeDeliverySMS:
return tgSMSSentCode(hash, delivery.Length), nil
case domain.AuthCodeDeliveryEmail:
return tgEmailSentCode(hash, delivery.EmailPattern, delivery.Length), nil
case domain.AuthCodeDeliveryEmailSetupRequired:
@ -361,7 +373,16 @@ func (r *Router) finishAuthSignIn(ctx context.Context, u domain.User, loginMessa
}
func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeRequest) (tg.AuthSentCodeClass, error) {
hash, err := r.deps.Auth.ResendCode(ctx, req.PhoneNumber, req.PhoneCodeHash)
var hash string
var err error
if scoped, ok := r.deps.Auth.(interface {
ResendCodeForAuthKey(context.Context, [8]byte, string, string) (string, error)
}); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
hash, err = scoped.ResendCodeForAuthKey(ctx, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
} else {
hash, err = r.deps.Auth.ResendCode(ctx, req.PhoneNumber, req.PhoneCodeHash)
}
if err != nil {
return nil, signInErr(err)
}
@ -369,7 +390,16 @@ func (r *Router) onAuthResendCode(ctx context.Context, req *tg.AuthResendCodeReq
}
func (r *Router) onAuthCancelCode(ctx context.Context, req *tg.AuthCancelCodeRequest) (bool, error) {
if err := r.deps.Auth.CancelCode(ctx, req.PhoneNumber, req.PhoneCodeHash); err != nil {
var err error
if scoped, ok := r.deps.Auth.(interface {
CancelCodeForAuthKey(context.Context, [8]byte, string, string) error
}); ok {
authKeyID, _ := AuthKeyIDFrom(ctx)
err = scoped.CancelCodeForAuthKey(ctx, authKeyID, req.PhoneNumber, req.PhoneCodeHash)
} else {
err = r.deps.Auth.CancelCode(ctx, req.PhoneNumber, req.PhoneCodeHash)
}
if err != nil {
return false, signInErr(err)
}
return true, nil

View file

@ -232,6 +232,11 @@ func tgChannelUpdate(viewerUserID int64, event domain.ChannelUpdateEvent) tg.Upd
func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
switch event.Type {
case domain.UpdateEventUserPhone:
if event.UserID == 0 || event.Phone == "" {
return nil
}
return &tg.UpdateUserPhone{UserID: event.UserID, Phone: event.Phone}
case domain.UpdateEventChannelState:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil

View file

@ -254,6 +254,8 @@ type UserPremiumStatusService interface {
// AccountService 抽象账号设置查询。
type AccountService interface {
SendChangePhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error)
ChangePhone(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error)
GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error)
GetPasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck) (domain.PrivatePasswordSettings, error)
UpdatePasswordSettings(ctx context.Context, userID int64, check domain.PasswordCheck, input domain.PasswordInputSettings) error

View file

@ -295,8 +295,35 @@ func floodWaitErr(seconds int) error {
// phoneNumberInvalidErr 表示手机号为空或格式非法auth.sendCode/signIn/signUp
func phoneNumberInvalidErr() error { return tgerr.New(406, "PHONE_NUMBER_INVALID") }
func phoneNumberOccupiedErr() error { return tgerr.New(400, "PHONE_NUMBER_OCCUPIED") }
func phoneCodeEmptyErr() error { return tgerr.New(400, "PHONE_CODE_EMPTY") }
func phoneCodeInvalidErr() error { return tgerr.New(400, "PHONE_CODE_INVALID") }
func phoneCodeExpiredErr() error { return tgerr.New(400, "PHONE_CODE_EXPIRED") }
func phoneChangeErr(err error) error {
switch {
case errors.Is(err, domain.ErrPhoneNumberInvalid):
return phoneNumberInvalidErr()
case errors.Is(err, domain.ErrPhoneNumberOccupied):
return phoneNumberOccupiedErr()
case errors.Is(err, domain.ErrPhoneCodeEmpty):
return phoneCodeEmptyErr()
case errors.Is(err, domain.ErrPhoneCodeInvalid):
return phoneCodeInvalidErr()
case errors.Is(err, domain.ErrPhoneCodeExpired):
return phoneCodeExpiredErr()
case errors.Is(err, domain.ErrPhoneChangeAuthInvalid):
return authKeyUnregisteredErr()
case errors.Is(err, domain.ErrPhoneChangeForbidden):
return tgerr.New(400, "BOT_METHOD_INVALID")
default:
return internalErr()
}
}
// authKeyUnregisteredErr 表示请求要求登录态而当前连接未授权。
func authKeyUnregisteredErr() error { return tgerr.New(401, "AUTH_KEY_UNREGISTERED") }
func botMethodInvalidErr() error { return tgerr.New(400, "BOT_METHOD_INVALID") }
// 私聊通话phone.*)错误;触发点见 internal/rpc/phone_calls.go 与 app/phone 错误映射。
func callPeerInvalidErr() error { return tgerr.New(400, "CALL_PEER_INVALID") }

View file

@ -6,6 +6,7 @@ import (
"github.com/gotd/td/tg"
androidcompat "telesrv/internal/compat/android"
"telesrv/internal/compat/tdesktop"
)
@ -69,9 +70,31 @@ func (r *Router) registerHelp(d *tg.ServerDispatcher) {
d.OnHelpGetDeepLinkInfo(func(ctx context.Context, path string) (tg.HelpDeepLinkInfoClass, error) {
return &tg.HelpDeepLinkInfoEmpty{}, nil
})
d.OnHelpDismissSuggestion(r.onHelpDismissSuggestion)
d.OnHelpGetPremiumPromo(r.onHelpGetPremiumPromo)
}
// onHelpDismissSuggestion 为 DrKLO 改号成功后的 suggestion 清理提供有界兼容。
// Android 会先把 suggestion 从本地状态删除,再发送该 RPC且 generic 500 会被
// 连接层持续重试。当前 server 不发布 pending suggestions故非空 dismissal
// 无需持久化,幂等 BoolTrue 即为完整的当前边界语义。
func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismissSuggestionRequest) (bool, error) {
userID, found, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if !found {
return false, authKeyUnregisteredErr()
}
if r.userIsBot(ctx, userID) {
return false, botMethodInvalidErr()
}
if req == nil {
return false, nil
}
return androidcompat.DismissSuggestion(req.Suggestion), nil
}
// onHelpGetPremiumPromo 返回最小真实的 Premium 状态页数据:状态文案按 viewer
// 的会员有效期生成videos/period_options 留空——购买入口已被 appConfig
// premium_purchase_blocked=true 关闭,订阅价格 UI 不会消费这些字段TDesktop

View file

@ -0,0 +1,56 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/clock"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"go.uber.org/zap/zaptest"
)
func TestHelpDismissSuggestionAndroidChangePhone(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
ctx := WithUserID(context.Background(), 42)
req := &tg.HelpDismissSuggestionRequest{
Peer: &tg.InputPeerEmpty{},
Suggestion: "VALIDATE_PHONE_NUMBER",
}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode: %v", err)
}
enc, err := r.Dispatch(ctx, [8]byte{1, 2, 3}, 77, &in)
if err != nil {
t.Fatalf("dispatch: %v", err)
}
box, ok := enc.(*tg.BoolBox)
if !ok {
t.Fatalf("response = %T, want *tg.BoolBox", enc)
}
if _, ok := box.Bool.(*tg.BoolTrue); !ok {
t.Fatalf("bool response = %T, want BoolTrue", box.Bool)
}
}
func TestHelpDismissSuggestionRequiresAuthorization(t *testing.T) {
r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System)
req := &tg.HelpDismissSuggestionRequest{Peer: &tg.InputPeerEmpty{}, Suggestion: "VALIDATE_PHONE_NUMBER"}
var in bin.Buffer
if err := req.Encode(&in); err != nil {
t.Fatalf("encode: %v", err)
}
if _, err := r.Dispatch(context.Background(), [8]byte{1}, 77, &in); !tgerr.Is(err, "AUTH_KEY_UNREGISTERED") {
t.Fatalf("unauthorized err = %v", err)
}
}
func TestHelpDismissSuggestionEmptyIsFalse(t *testing.T) {
r := &Router{}
ok, err := r.onHelpDismissSuggestion(WithUserID(context.Background(), 42), &tg.HelpDismissSuggestionRequest{Peer: &tg.InputPeerEmpty{}})
if err != nil || ok {
t.Fatalf("empty suggestion result=%v err=%v", ok, err)
}
}