From 818c9a58a31ec81fd27c516e5cd0e0e6891a9a3e Mon Sep 17 00:00:00 2001 From: onysd Date: Thu, 6 Aug 2026 15:45:32 +0300 Subject: [PATCH] disabling marksbot when third-party verifications if turned off --- cmd/telesrv-admin/filter_bots_test.go | 19 +++++++++++++ cmd/telesrv-admin/server.go | 23 +++++++++++++++ cmd/telesrv/main.go | 3 +- internal/app/contacts/service.go | 26 +++++++++++++++++ internal/app/contacts/service_test.go | 41 +++++++++++++++++++++++++++ internal/app/users/service.go | 13 +++++++++ internal/app/users/service_test.go | 34 ++++++++++++++++++++++ 7 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 cmd/telesrv-admin/filter_bots_test.go diff --git a/cmd/telesrv-admin/filter_bots_test.go b/cmd/telesrv-admin/filter_bots_test.go new file mode 100644 index 00000000..9459ddde --- /dev/null +++ b/cmd/telesrv-admin/filter_bots_test.go @@ -0,0 +1,19 @@ +package main + +import "testing" + +func TestFilterOutBot(t *testing.T) { + rows := []BotRow{{ID: 1}, {ID: 2}, {ID: 3}} + out := filterOutBot(rows, 2) + if len(out) != 2 || out[0].ID != 1 || out[1].ID != 3 { + t.Fatalf("filterOutBot = %+v, want [1, 3]", out) + } +} + +func TestFilterOutBotNoMatch(t *testing.T) { + rows := []BotRow{{ID: 1}, {ID: 3}} + out := filterOutBot(rows, 2) + if len(out) != 2 || out[0].ID != 1 || out[1].ID != 3 { + t.Fatalf("filterOutBot (no match) = %+v, want unchanged [1, 3]", out) + } +} diff --git a/cmd/telesrv-admin/server.go b/cmd/telesrv-admin/server.go index b99a2919..59d08df2 100644 --- a/cmd/telesrv-admin/server.go +++ b/cmd/telesrv-admin/server.go @@ -858,6 +858,19 @@ func (s *server) handleSetChannelAvatarAPI(w http.ResponseWriter, r *http.Reques writeCommandResultAPI(w, result, err) } +// filterOutBot drops the given bot id from a row slice in place, preserving +// order. Used to keep a hidden built-in bot out of admin listings without +// touching the underlying SQL projection. +func filterOutBot(rows []BotRow, excludeID int64) []BotRow { + out := rows[:0] + for _, row := range rows { + if row.ID != excludeID { + out = append(out, row) + } + } + return out +} + func (s *server) handleBotsAPI(w http.ResponseWriter, r *http.Request) { if s.read == nil { writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured") @@ -878,6 +891,12 @@ func (s *server) handleBotsAPI(w http.ResponseWriter, r *http.Request) { writeAPIError(w, http.StatusInternalServerError, err.Error()) return } + // @marksbot is not fully finished (see requireThirdPartyVerificationVisible): + // while third-party verification is hidden, it must not be discoverable in + // the bot list either, not just unreachable at /api/botverification/*. + if s.cfg.HideThirdPartyVerification { + rows = filterOutBot(rows, domain.VerifierBotUserID) + } nextBeforeID := int64(0) if hasMore && len(rows) > 0 { nextBeforeID = rows[len(rows)-1].ID @@ -908,6 +927,10 @@ func (s *server) handleBotDetailAPI(w http.ResponseWriter, r *http.Request) { writeAPIError(w, http.StatusBadRequest, "invalid id") return } + if s.cfg.HideThirdPartyVerification && botID == domain.VerifierBotUserID { + writeAPIError(w, http.StatusNotFound, "bot not found") + return + } detail, err := s.read.BotDetail(r.Context(), botID) if err != nil { writeAPIError(w, http.StatusInternalServerError, err.Error()) diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 249c40c9..58347dff 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -909,6 +909,7 @@ func run(logger *zap.Logger) error { contacts.WithPrivacyEvaluator(privacyService), contacts.WithAccountFreezeProvider(adminService), contacts.WithReadModelVersions(readModelVersionStore), + contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), ) if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil { return fmt.Errorf("seed langpack: %w", err) @@ -1128,7 +1129,7 @@ func run(logger *zap.Logger) error { passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins)) // 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。 themeService := themesapp.NewService(postgres.NewThemeStore(pool)) - usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService)) + usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification)) privacyService.ConfigureReadModels(usersService, channelStore) aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...) botsService.SetAIChatGenerator(aiComposeService) diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go index 607815f2..4074491a 100644 --- a/internal/app/contacts/service.go +++ b/internal/app/contacts/service.go @@ -36,6 +36,10 @@ type Service struct { projector *userprojection.Projector versions store.ReadModelVersionStore cache *contactListReadModelCache + // hideThirdPartyVerification mirrors config.HideThirdPartyVerification: + // while true, Search never returns @marksbot (domain.VerifierBotUserID), + // so the account is unreachable for a client that doesn't already know it. + hideThirdPartyVerification bool } // Option adjusts optional contacts service dependencies. @@ -55,6 +59,12 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option { return func(s *Service) { s.freezes = p } } +// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification: +// while true, Search filters @marksbot out of every result set. +func WithHideThirdPartyVerification(hidden bool) Option { + return func(s *Service) { s.hideThirdPartyVerification = hidden } +} + // WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs. func WithReadModelVersions(v store.ReadModelVersionStore) Option { return func(s *Service) { s.versions = v } @@ -317,6 +327,18 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom return out, nil } +// filterOutUserID drops the given user id from a result slice in place, +// preserving order. +func filterOutUserID(users []domain.User, excludeID int64) []domain.User { + out := users[:0] + for _, u := range users { + if u.ID != excludeID { + out = append(out, u) + } + } + return out +} + func (s *Service) Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error) { if s == nil || s.users == nil || userID == 0 { return domain.UserSearchResult{}, nil @@ -338,6 +360,10 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit if err != nil { return domain.UserSearchResult{}, err } + if s.hideThirdPartyVerification { + res.MyResults = filterOutUserID(res.MyResults, domain.VerifierBotUserID) + res.Results = filterOutUserID(res.Results, domain.VerifierBotUserID) + } if s.privacy != nil && phoneQuery != "" && len(res.MyResults)+len(res.Results) > 0 { targetIDs := make([]int64, 0, len(res.MyResults)+len(res.Results)) for _, target := range res.MyResults { diff --git a/internal/app/contacts/service_test.go b/internal/app/contacts/service_test.go index c168edbc..ed6af66d 100644 --- a/internal/app/contacts/service_test.go +++ b/internal/app/contacts/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "reflect" + "strings" "testing" privacyapp "telesrv/internal/app/privacy" @@ -644,6 +645,46 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) { } } +// marksbotSearchStore wraps memory.UserStore to fold domain.VerifierBotUser() +// into a matching Search result, since memory.UserStore.Create always assigns +// an id from its own auto-increment sequence and can never produce the fixed +// domain.VerifierBotUserID a real deployment seeds it under. +type marksbotSearchStore struct { + *memory.UserStore +} + +func (s *marksbotSearchStore) Search(ctx context.Context, currentUserID int64, query, phoneQuery string, limit int) (domain.UserSearchResult, error) { + res, err := s.UserStore.Search(ctx, currentUserID, query, phoneQuery, limit) + if err != nil { + return res, err + } + if strings.Contains(strings.ToLower(query), "marks") { + res.Results = append(res.Results, domain.VerifierBotUser()) + } + return res, nil +} + +func TestSearchHidesMarksbotWhenThirdPartyVerificationHidden(t *testing.T) { + ctx := context.Background() + users := &marksbotSearchStore{UserStore: memory.NewUserStore()} + viewer, err := users.Create(ctx, domain.User{Phone: "15550000201", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + + visible := NewService(memory.NewContactStore(), users).Configure(WithHideThirdPartyVerification(false)) + found, err := visible.Search(ctx, viewer.ID, "marksbot", 10) + if err != nil || len(found.Results) != 1 || found.Results[0].ID != domain.VerifierBotUserID { + t.Fatalf("Search (visible) = %+v err=%v, want @marksbot", found, err) + } + + hidden := NewService(memory.NewContactStore(), users).Configure(WithHideThirdPartyVerification(true)) + found, err = hidden.Search(ctx, viewer.ID, "marksbot", 10) + if err != nil || len(found.Results) != 0 { + t.Fatalf("Search (hidden) = %+v err=%v, want no results", found, err) + } +} + func TestSearchFindsOnlyActiveCollectibleUsernames(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() diff --git a/internal/app/users/service.go b/internal/app/users/service.go index 584362e4..3a28a6f4 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -27,6 +27,10 @@ type Service struct { privacy userprojection.PrivacyEvaluator freezes userprojection.AccountFreezeProvider projector *userprojection.Projector + // hideThirdPartyVerification mirrors config.HideThirdPartyVerification: + // while true, ResolveUsername never resolves @marksbot + // (domain.VerifierBotUserID), so a client cannot discover it by username. + hideThirdPartyVerification bool } type usernameAvailabilityStore interface { @@ -64,6 +68,12 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option { return func(s *Service) { s.freezes = p } } +// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification: +// while true, ResolveUsername treats @marksbot as not found. +func WithHideThirdPartyVerification(hidden bool) Option { + return func(s *Service) { s.hideThirdPartyVerification = hidden } +} + const ( minUsernameLen = 5 maxUsernameLen = 32 @@ -617,6 +627,9 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user if err != nil || !found { return u, found, err } + if s.hideThirdPartyVerification && u.ID == domain.VerifierBotUserID { + return domain.User{}, false, nil + } s.putCachedUsers(ctx, u) u, err = s.projectOne(ctx, currentUserID, u) if err != nil { diff --git a/internal/app/users/service_test.go b/internal/app/users/service_test.go index adb9ef11..a3029039 100644 --- a/internal/app/users/service_test.go +++ b/internal/app/users/service_test.go @@ -89,6 +89,40 @@ func TestServiceUsernameLifecycle(t *testing.T) { } } +// marksbotOverrideStore wraps memory.UserStore to serve domain.VerifierBotUser() +// for a fixed username lookup, since memory.UserStore.Create always assigns an +// id from its own auto-increment sequence and can never produce the fixed +// domain.VerifierBotUserID a real deployment seeds it under. +type marksbotOverrideStore struct { + *memory.UserStore +} + +func (s *marksbotOverrideStore) ByUsername(ctx context.Context, username string) (domain.User, bool, error) { + if strings.EqualFold(username, "marksbot") { + return domain.VerifierBotUser(), true, nil + } + return s.UserStore.ByUsername(ctx, username) +} + +func TestResolveUsernameHidesMarksbotWhenThirdPartyVerificationHidden(t *testing.T) { + ctx := context.Background() + store := &marksbotOverrideStore{UserStore: memory.NewUserStore()} + viewer, err := store.Create(ctx, domain.User{AccessHash: 1, Phone: "15550002001", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + + visible := NewService(store, WithHideThirdPartyVerification(false)) + if u, found, err := visible.ResolveUsername(ctx, viewer.ID, "marksbot"); err != nil || !found || u.ID != domain.VerifierBotUserID { + t.Fatalf("ResolveUsername (visible) = user %+v found %v err %v, want @marksbot", u, found, err) + } + + hidden := NewService(store, WithHideThirdPartyVerification(true)) + if _, found, err := hidden.ResolveUsername(ctx, viewer.ID, "marksbot"); err != nil || found { + t.Fatalf("ResolveUsername (hidden) found=%v err=%v, want not found", found, err) + } +} + func TestResolvePhoneHonorsAddedByPhone(t *testing.T) { ctx := context.Background() users := memory.NewUserStore()