diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 3e8dc394..7f55627d 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -1143,6 +1143,7 @@ func run(logger *zap.Logger) error { contacts.WithPhotoProvider(cachedPhotos), contacts.WithPrivacyEvaluator(privacyService), contacts.WithAccountFreezeProvider(userProjectionFacts), + contacts.WithAccountRestrictionProvider(userProjectionFacts), contacts.WithReadModelVersions(readModelVersionStore), contacts.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), ) @@ -1370,7 +1371,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(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames)) + usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(userProjectionFacts), users.WithAccountRestrictionProvider(userProjectionFacts), users.WithHideThirdPartyVerification(cfg.HideThirdPartyVerification), users.WithReservedUsernames(cfg.ReservedUsernames)) privacyService.ConfigureReadModels(usersService, channelStore) aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...) botsService.SetAIChatGenerator(aiComposeService) @@ -1379,6 +1380,7 @@ func run(logger *zap.Logger) error { dialogs.WithPhotoProvider(cachedPhotos), dialogs.WithPrivacyEvaluator(privacyService), dialogs.WithAccountFreezeProvider(userProjectionFacts), + dialogs.WithAccountRestrictionProvider(userProjectionFacts), dialogs.WithPremiumChecker(usersService.PremiumActive), dialogs.WithReadModelVersions(readModelVersionStore), dialogs.WithDialogHydrationCaches( @@ -1426,6 +1428,7 @@ func run(logger *zap.Logger) error { messageapp.WithPhotoProvider(cachedPhotos), messageapp.WithPrivacyEvaluator(privacyService), messageapp.WithAccountFreezeProvider(userProjectionFacts), + messageapp.WithAccountRestrictionProvider(userProjectionFacts), messageapp.WithReadModelVersions(readModelVersionStore), messageapp.WithBotResponder(botsService), messageapp.WithSendPermissionChecker(adminService), @@ -1621,6 +1624,7 @@ func run(logger *zap.Logger) error { AppUpdates: appUpdateResolver, AccountFreeze: userProjectionFacts, AccountFreezeNotifications: adminService, + AccountRestriction: userProjectionFacts, AICompose: aiComposeService, Ephemeral: ephemeralService, EphemeralPush: ephemeralStore, diff --git a/deploy/migrations/20260916000001_account_message_restriction_state.down.sql b/deploy/migrations/20260916000001_account_message_restriction_state.down.sql new file mode 100644 index 00000000..9e347109 --- /dev/null +++ b/deploy/migrations/20260916000001_account_message_restriction_state.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS account_message_restrictions; diff --git a/deploy/migrations/20260916000001_account_message_restriction_state.up.sql b/deploy/migrations/20260916000001_account_message_restriction_state.up.sql new file mode 100644 index 00000000..593896d8 --- /dev/null +++ b/deploy/migrations/20260916000001_account_message_restriction_state.up.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS account_message_restrictions ( + user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + restricted boolean NOT NULL DEFAULT false, + version bigint NOT NULL DEFAULT 0, + restricted_since timestamptz, + restricted_until timestamptz, + reason text NOT NULL DEFAULT '', + actor text NOT NULL DEFAULT '', + command_id text NOT NULL DEFAULT '', + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT account_message_restrictions_user_id_check CHECK (user_id > 0), + CONSTRAINT account_message_restrictions_shape_check CHECK ( + (restricted AND restricted_since IS NOT NULL + AND (restricted_until IS NULL OR restricted_until > restricted_since) + AND (restricted_until IS NULL OR restricted_until <= to_timestamp(2147483647))) + OR + (NOT restricted AND restricted_since IS NULL AND restricted_until IS NULL) + ) +); + +CREATE INDEX IF NOT EXISTS account_message_restrictions_restricted_idx + ON account_message_restrictions (user_id) + WHERE restricted; diff --git a/deploy/migrations/20260916000002_moderation_restrict_account_action.down.sql b/deploy/migrations/20260916000002_moderation_restrict_account_action.down.sql new file mode 100644 index 00000000..aa566c95 --- /dev/null +++ b/deploy/migrations/20260916000002_moderation_restrict_account_action.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.moderation_actions + DROP CONSTRAINT moderation_actions_kind_check; + +ALTER TABLE public.moderation_actions + ADD CONSTRAINT moderation_actions_kind_check CHECK (kind IN ( + 'mark_scam', 'mark_fake', 'clear_peer_flags', 'freeze_account', + 'unfreeze_account', 'delete_private_message', + 'delete_channel_message', 'delete_account' + )); diff --git a/deploy/migrations/20260916000002_moderation_restrict_account_action.up.sql b/deploy/migrations/20260916000002_moderation_restrict_account_action.up.sql new file mode 100644 index 00000000..12ad2e35 --- /dev/null +++ b/deploy/migrations/20260916000002_moderation_restrict_account_action.up.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.moderation_actions + DROP CONSTRAINT moderation_actions_kind_check; + +ALTER TABLE public.moderation_actions + ADD CONSTRAINT moderation_actions_kind_check CHECK (kind IN ( + 'mark_scam', 'mark_fake', 'clear_peer_flags', 'freeze_account', + 'unfreeze_account', 'restrict_account', 'unrestrict_account', + 'delete_private_message', 'delete_channel_message', 'delete_account' + )); diff --git a/internal/admin/service.go b/internal/admin/service.go index 48119837..7a0e0e88 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -24,6 +24,7 @@ import ( const ( ActionSetAccountFrozen = "account.set_frozen" + ActionSetAccountRestricted = "account.set_restricted" ActionGrantPremium = "account.grant_premium" ActionSetVerified = "account.set_verified" ActionSetUserFlags = "account.set_flags" @@ -179,6 +180,12 @@ type CommandRepository interface { type RestrictionStore interface { GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) + GetAccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) + SetAccountRestriction(ctx context.Context, restriction domain.AccountRestriction) (domain.AccountRestriction, error) +} + +type accountRestrictionBatchStore interface { + GetAccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) } type accountFreezeBatchStore interface { @@ -825,6 +832,13 @@ type SetAccountFrozenRequest struct { AppealURL string `json:"freeze_appeal_url,omitempty"` } +type SetAccountRestrictedRequest struct { + CommandMeta + UserID int64 `json:"user_id"` + Restricted bool `json:"restricted"` + Until time.Time `json:"restricted_until,omitempty"` +} + type GrantPremiumRequest struct { CommandMeta UserID int64 `json:"user_id"` @@ -1272,6 +1286,134 @@ func (s *Service) SetAccountFrozen(ctx context.Context, req SetAccountFrozenRequ }) } +// AccountRestriction returns the durable narrower spam-restriction state. A +// missing row is the only non-restricted default. +func (s *Service) AccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) { + if s == nil || s.restrictions == nil || userID == 0 { + return domain.AccountRestriction{}, false, nil + } + restriction, found, err := s.restrictions.GetAccountRestriction(ctx, userID) + if err != nil || !found { + return restriction, found, err + } + if err := validateAccountRestriction(restriction); err != nil { + return domain.AccountRestriction{}, false, fmt.Errorf("invalid durable account restriction for user %d: %w", userID, err) + } + return restriction, true, nil +} + +func (s *Service) AccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) { + out := make(map[int64]domain.AccountRestriction) + if s == nil || s.restrictions == nil || len(userIDs) == 0 { + return out, nil + } + ids := uniqueFreezeUserIDs(userIDs) + if batch, ok := s.restrictions.(accountRestrictionBatchStore); ok { + const batchSize = 1000 + for start := 0; start < len(ids); start += batchSize { + end := min(start+batchSize, len(ids)) + items, err := batch.GetAccountRestrictions(ctx, ids[start:end]) + if err != nil { + return nil, err + } + for id, restriction := range items { + if err := validateAccountRestriction(restriction); err != nil { + return nil, fmt.Errorf("invalid durable account restriction for user %d: %w", id, err) + } + if restriction.Restricted { + out[id] = restriction + } + } + } + return out, nil + } + for _, id := range ids { + restriction, found, err := s.AccountRestriction(ctx, id) + if err != nil { + return nil, err + } + if found && restriction.Restricted { + out[id] = restriction + } + } + return out, nil +} + +func validateAccountRestriction(restriction domain.AccountRestriction) error { + if !restriction.Restricted { + if !restriction.Since.IsZero() || !restriction.Until.IsZero() { + return fmt.Errorf("inactive restriction retains client-visible state") + } + return nil + } + if restriction.Since.IsZero() || restriction.Since.Unix() <= 0 { + return fmt.Errorf("active restriction has invalid since") + } + if !restriction.Until.IsZero() && (!restriction.Until.After(restriction.Since) || restriction.Until.Unix() > math.MaxInt32) { + return fmt.Errorf("active restriction has invalid until") + } + return nil +} + +func (s *Service) SetAccountRestricted(ctx context.Context, req SetAccountRestrictedRequest) (CommandResult, error) { + if req.UserID <= 0 { + return CommandResult{}, fmt.Errorf("user_id is required") + } + if s == nil || s.restrictions == nil { + return CommandResult{}, fmt.Errorf("admin restriction store is not configured") + } + now := s.now().UTC() + if req.Restricted && !req.Until.IsZero() && req.Until.Unix() > math.MaxInt32 { + return CommandResult{}, fmt.Errorf("restricted_until must be a valid int32 Unix timestamp") + } + return s.runCommand(ctx, req.CommandMeta, ActionSetAccountRestricted, req.UserID, domain.Peer{}, req, func() (CommandResult, error) { + if req.Restricted && !req.Until.IsZero() && !req.Until.After(now) { + return CommandResult{}, fmt.Errorf("restricted_until must be in the future") + } + prev, found, err := s.restrictions.GetAccountRestriction(ctx, req.UserID) + if err != nil { + return CommandResult{}, err + } + next := domain.AccountRestriction{ + UserID: req.UserID, + Restricted: req.Restricted, + Reason: req.Reason, + Actor: req.Actor, + CommandID: req.CommandID, + } + if req.Restricted { + next.Since = now + if found && prev.Restricted { + next.Since = prev.Since + } + next.Until = req.Until.UTC() + } + wouldChange := !found || prev.Restricted != next.Restricted || + !prev.Since.Equal(next.Since) || !prev.Until.Equal(next.Until) + details := map[string]any{ + "previous_restricted": found && prev.Restricted, + "new_restricted": req.Restricted, + "would_change": wouldChange, + } + if req.Restricted { + details["restricted_since"] = next.Since.Format(time.RFC3339) + if !next.Until.IsZero() { + details["restricted_until"] = next.Until.Format(time.RFC3339) + } + } + if req.DryRun { + return CommandResult{Message: "dry-run completed", Details: details}, nil + } + updated, err := s.restrictions.SetAccountRestriction(ctx, next) + if err != nil { + return CommandResult{}, err + } + details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339) + details["version"] = updated.Version + return CommandResult{Message: "account restriction updated", Details: details}, nil + }) +} + func (s *Service) GrantPremium(ctx context.Context, req GrantPremiumRequest) (CommandResult, error) { if req.UserID <= 0 { return CommandResult{}, fmt.Errorf("user_id is required") diff --git a/internal/admin/service_test.go b/internal/admin/service_test.go index 9822fab0..23949c07 100644 --- a/internal/admin/service_test.go +++ b/internal/admin/service_test.go @@ -576,8 +576,10 @@ func (f *fakeBotService) AdminExportBotToken(_ context.Context, botUserID int64) } type fakeRestrictionStore struct { - items map[int64]domain.AccountFreeze - setCalls int + items map[int64]domain.AccountFreeze + setCalls int + restrictionItems map[int64]domain.AccountRestriction + restrictionSetCall int } func (f *fakeRestrictionStore) GetAccountFreeze(_ context.Context, userID int64) (domain.AccountFreeze, bool, error) { @@ -599,6 +601,25 @@ func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.Acco return r, nil } +func (f *fakeRestrictionStore) GetAccountRestriction(_ context.Context, userID int64) (domain.AccountRestriction, bool, error) { + if f.restrictionItems == nil { + return domain.AccountRestriction{}, false, nil + } + r, ok := f.restrictionItems[userID] + return r, ok, nil +} + +func (f *fakeRestrictionStore) SetAccountRestriction(_ context.Context, r domain.AccountRestriction) (domain.AccountRestriction, error) { + if f.restrictionItems == nil { + f.restrictionItems = map[int64]domain.AccountRestriction{} + } + f.restrictionSetCall++ + r.Version = f.restrictionItems[r.UserID].Version + 1 + r.UpdatedAt = fixedNow() + f.restrictionItems[r.UserID] = r + return r, nil +} + type fakeBatchRestrictionStore struct { fakeRestrictionStore requests [][]int64 diff --git a/internal/adminapi/server.go b/internal/adminapi/server.go index 8d99b626..ced67fb3 100644 --- a/internal/adminapi/server.go +++ b/internal/adminapi/server.go @@ -41,6 +41,7 @@ type Config struct { type Service interface { AccountAvatar(ctx context.Context, userID int64) ([]byte, string, bool, error) SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) + SetAccountRestricted(ctx context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error) GrantPremium(ctx context.Context, req admin.GrantPremiumRequest) (admin.CommandResult, error) SetVerified(ctx context.Context, req admin.SetVerifiedRequest) (admin.CommandResult, error) SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) @@ -182,6 +183,7 @@ func (s *Server) routes() http.Handler { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) mux.HandleFunc("POST /v1/accounts/set-frozen", s.authenticated(s.handleSetAccountFrozen)) + mux.HandleFunc("POST /v1/accounts/set-restricted", s.authenticated(s.handleSetAccountRestricted)) mux.HandleFunc("GET /v1/accounts/{id}/avatar", s.authenticated(s.handleAccountAvatar)) mux.HandleFunc("POST /v1/accounts/grant-premium", s.authenticated(s.handleGrantPremium)) mux.HandleFunc("POST /v1/accounts/set-verified", s.authenticated(s.handleSetVerified)) @@ -309,6 +311,15 @@ func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) writeCommandResult(w, result, err) } +func (s *Server) handleSetAccountRestricted(w http.ResponseWriter, r *http.Request) { + var req admin.SetAccountRestrictedRequest + if !decodeJSON(w, r, &req) { + return + } + result, err := s.svc.SetAccountRestricted(r.Context(), req) + writeCommandResult(w, result, err) +} + func (s *Server) handleGrantPremium(w http.ResponseWriter, r *http.Request) { var req admin.GrantPremiumRequest if !decodeJSON(w, r, &req) { diff --git a/internal/adminapi/server_test.go b/internal/adminapi/server_test.go index 379ae372..5460024e 100644 --- a/internal/adminapi/server_test.go +++ b/internal/adminapi/server_test.go @@ -300,6 +300,10 @@ func (s *captureFreezeService) SetAccountFrozen(_ context.Context, req admin.Set return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil } +func (fakeService) SetAccountRestricted(_ context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error) { + return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil +} + func (fakeService) SetAccountFrozen(_ context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) { return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil } diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go index acb931a6..7f266d2e 100644 --- a/internal/app/contacts/service.go +++ b/internal/app/contacts/service.go @@ -28,14 +28,15 @@ type phonePrivacyService interface { // Service 提供通讯录查询。 type Service struct { - contacts store.ContactStore - users store.UserStore - photos userprojection.ProfilePhotoProvider - privacy phonePrivacyService - freezes userprojection.AccountFreezeProvider - projector *userprojection.Projector - versions store.ReadModelVersionStore - cache *contactListReadModelCache + contacts store.ContactStore + users store.UserStore + photos userprojection.ProfilePhotoProvider + privacy phonePrivacyService + freezes userprojection.AccountFreezeProvider + restrictions userprojection.AccountRestrictionProvider + 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. @@ -59,6 +60,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option { return func(s *Service) { s.freezes = p } } +// WithAccountRestrictionProvider injects the account spam-restriction reader. +func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option { + return func(s *Service) { s.restrictions = p } +} + // WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification: // while true, Search filters @marksbot out of every result set. func WithHideThirdPartyVerification(hidden bool) Option { @@ -101,6 +107,7 @@ func (s *Service) rebuildProjector() { userprojection.WithPhotoProvider(s.photos), userprojection.WithPrivacyEvaluator(s.privacy), userprojection.WithAccountFreezeProvider(s.freezes), + userprojection.WithAccountRestrictionProvider(s.restrictions), ) } @@ -126,6 +133,15 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do return list, false, nil } +// IsContact reports whether contactUserID is in userID's own contact list. +func (s *Service) IsContact(ctx context.Context, userID, contactUserID int64) (bool, error) { + if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 { + return false, nil + } + _, found, err := s.contacts.Get(ctx, userID, contactUserID) + return found, err +} + func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID { return domain.Contact{}, ErrContactIDInvalid diff --git a/internal/app/dialogs/service.go b/internal/app/dialogs/service.go index a1a657d5..c968ddab 100644 --- a/internal/app/dialogs/service.go +++ b/internal/app/dialogs/service.go @@ -26,6 +26,7 @@ type Service struct { photos userprojection.ProfilePhotoProvider privacy userprojection.PrivacyEvaluator freezes userprojection.AccountFreezeProvider + restrictions userprojection.AccountRestrictionProvider premium PremiumChecker projector *userprojection.Projector versions store.ReadModelVersionStore @@ -63,6 +64,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option { return func(s *Service) { s.freezes = p } } +// WithAccountRestrictionProvider injects the account spam-restriction reader. +func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option { + return func(s *Service) { s.restrictions = p } +} + // WithReadModelVersions enables durable version-token backed peer dialog caching. func WithReadModelVersions(v store.ReadModelVersionStore) Option { return func(s *Service) { s.versions = v } @@ -132,6 +138,7 @@ func (s *Service) rebuildProjector() { userprojection.WithPhotoProvider(s.photos), userprojection.WithPrivacyEvaluator(s.privacy), userprojection.WithAccountFreezeProvider(s.freezes), + userprojection.WithAccountRestrictionProvider(s.restrictions), ) } diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go index ba05fef7..08b1ce92 100644 --- a/internal/app/messages/service.go +++ b/internal/app/messages/service.go @@ -11,14 +11,15 @@ import ( // Service 提供消息历史、搜索与已读业务。 type Service struct { - messages store.MessageStore - dialogs store.DialogStore - contacts store.ContactStore - photos userprojection.ProfilePhotoProvider - privacy userprojection.PrivacyEvaluator - freezes userprojection.AccountFreezeProvider - versions store.ReadModelVersionStore - projector *userprojection.Projector + messages store.MessageStore + dialogs store.DialogStore + contacts store.ContactStore + photos userprojection.ProfilePhotoProvider + privacy userprojection.PrivacyEvaluator + freezes userprojection.AccountFreezeProvider + restrictions userprojection.AccountRestrictionProvider + versions store.ReadModelVersionStore + projector *userprojection.Projector // viewerProjectionComplete is true only when every viewer-scoped user // overlay used by the shared RPC Users service is configured here too. // A partially configured service may still project the dependencies it has, @@ -67,6 +68,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option { return func(s *Service) { s.freezes = p } } +// WithAccountRestrictionProvider injects the account spam-restriction reader. +func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option { + return func(s *Service) { s.restrictions = p } +} + // WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。 func WithBotResponder(r BotResponder) Option { return func(s *Service) { s.botResponder = r } @@ -96,6 +102,7 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ... userprojection.WithPhotoProvider(s.photos), userprojection.WithPrivacyEvaluator(s.privacy), userprojection.WithAccountFreezeProvider(s.freezes), + userprojection.WithAccountRestrictionProvider(s.restrictions), ) s.viewerProjectionComplete = s.contacts != nil && s.photos != nil && s.privacy != nil && s.freezes != nil return s diff --git a/internal/app/moderation/actions.go b/internal/app/moderation/actions.go index 595fef59..e97408b9 100644 --- a/internal/app/moderation/actions.go +++ b/internal/app/moderation/actions.go @@ -18,6 +18,7 @@ import ( type moderationAdminActions interface { SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error) + SetAccountRestricted(ctx context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error) SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error) DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error) @@ -99,6 +100,10 @@ type freezeAccountActionPayload struct { AppealURL string `json:"appeal_url,omitempty"` } +type restrictAccountActionPayload struct { + Until time.Time `json:"until,omitempty"` +} + type deletePrivateMessageActionPayload struct { OwnerUserID int64 `json:"owner_user_id"` IDs []int `json:"ids"` @@ -181,6 +186,23 @@ func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCa Until: payload.Until, AppealURL: payload.AppealURL, }) return err + case domain.ModerationActionRestrictAccount, domain.ModerationActionUnrestrictAccount: + if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser { + return domain.ErrModerationActionInvalid + } + var payload restrictAccountActionPayload + if err := decodeStrictActionPayload(action.Payload, &payload); err != nil { + return err + } + restricted := action.Kind == domain.ModerationActionRestrictAccount + if !restricted && !payload.Until.IsZero() { + return domain.ErrModerationActionInvalid + } + _, err := e.admin.SetAccountRestricted(ctx, admin.SetAccountRestrictedRequest{ + CommandMeta: meta, UserID: detail.Case.Target.ID, Restricted: restricted, + Until: payload.Until, + }) + return err case domain.ModerationActionDeletePrivateMessage: if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser { return domain.ErrModerationActionInvalid @@ -252,6 +274,7 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod seen := make(map[domain.ModerationActionKind]struct{}, len(actions)) flagActions := 0 freezeActions := 0 + restrictActions := 0 hasDeleteAccount := false for _, action := range actions { if _, duplicate := seen[action.Kind]; duplicate { @@ -278,6 +301,18 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod (!payload.Until.IsZero() || payload.AppealURL != "") { return domain.ErrModerationActionInvalid } + case domain.ModerationActionRestrictAccount, domain.ModerationActionUnrestrictAccount: + restrictActions++ + if detail.Case.Target.Type != domain.PeerTypeUser { + return domain.ErrModerationActionInvalid + } + var payload restrictAccountActionPayload + if err := decodeStrictActionPayload(action.Payload, &payload); err != nil { + return err + } + if action.Kind == domain.ModerationActionUnrestrictAccount && !payload.Until.IsZero() { + return domain.ErrModerationActionInvalid + } case domain.ModerationActionDeletePrivateMessage: var payload deletePrivateMessageActionPayload if err := decodeStrictActionPayload(action.Payload, &payload); err != nil { @@ -311,7 +346,7 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod return domain.ErrModerationActionInvalid } } - if flagActions > 1 || freezeActions > 1 || + if flagActions > 1 || freezeActions > 1 || restrictActions > 1 || (hasDeleteAccount && len(actions) != 1) { return domain.ErrModerationActionInvalid } diff --git a/internal/app/moderation/actions_test.go b/internal/app/moderation/actions_test.go index 86d0365b..1bd08f01 100644 --- a/internal/app/moderation/actions_test.go +++ b/internal/app/moderation/actions_test.go @@ -14,8 +14,9 @@ import ( ) type captureModerationAdmin struct { - userFlags []admin.SetUserFlagsRequest - frozen []admin.SetAccountFrozenRequest + userFlags []admin.SetUserFlagsRequest + frozen []admin.SetAccountFrozenRequest + restricted []admin.SetAccountRestrictedRequest } type captureModerationAccountDeleter struct { @@ -42,6 +43,11 @@ func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.S return admin.CommandResult{}, nil } +func (a *captureModerationAdmin) SetAccountRestricted(_ context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error) { + a.restricted = append(a.restricted, req) + return admin.CommandResult{}, nil +} + func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) { a.userFlags = append(a.userFlags, req) return admin.CommandResult{}, nil diff --git a/internal/app/moderation/cases.go b/internal/app/moderation/cases.go index 4f967ec3..6dd1ec68 100644 --- a/internal/app/moderation/cases.go +++ b/internal/app/moderation/cases.go @@ -134,7 +134,7 @@ func (s *Service) ReviewAppeal(ctx context.Context, request domain.ModerationDec func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error { history := append([]domain.ModerationAction(nil), detail.Actions...) sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID }) - var flagsActive, freezeActive, irreversible bool + var flagsActive, freezeActive, restrictActive, irreversible bool for _, action := range history { if action.Status != domain.ModerationActionSucceeded { continue @@ -148,6 +148,10 @@ func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []d freezeActive = true case domain.ModerationActionUnfreezeAccount: freezeActive = false + case domain.ModerationActionRestrictAccount: + restrictActive = true + case domain.ModerationActionUnrestrictAccount: + restrictActive = false case domain.ModerationActionDeletePrivateMessage, domain.ModerationActionDeleteChannelMessage, domain.ModerationActionDeleteAccount: @@ -157,13 +161,16 @@ func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []d if irreversible { return domain.ErrModerationActionInvalid } - expected := make(map[domain.ModerationActionKind]bool, 2) + expected := make(map[domain.ModerationActionKind]bool, 3) if flagsActive { expected[domain.ModerationActionClearPeerFlags] = true } if freezeActive { expected[domain.ModerationActionUnfreezeAccount] = true } + if restrictActive { + expected[domain.ModerationActionUnrestrictAccount] = true + } if len(actions) != len(expected) { return domain.ErrModerationActionInvalid } diff --git a/internal/app/userprojection/durable_user_facts.go b/internal/app/userprojection/durable_user_facts.go index 1f0a9dc6..c0375216 100644 --- a/internal/app/userprojection/durable_user_facts.go +++ b/internal/app/userprojection/durable_user_facts.go @@ -14,14 +14,21 @@ type accountFreezeFact struct { found bool } +type accountRestrictionFact struct { + value domain.AccountRestriction + found bool +} + // DurableUserProjectionFacts caches only viewer-independent durable overlays. // Contact/privacy/presence decisions remain outside and are evaluated after // these facts are loaded. type DurableUserProjectionFacts struct { - freezes AccountFreezeProvider - versions store.ReadModelVersionStore + freezes AccountFreezeProvider + restrictions AccountRestrictionProvider + versions store.ReadModelVersionStore - freezeCache *readmodelcache.Cache[int64, accountFreezeFact] + freezeCache *readmodelcache.Cache[int64, accountFreezeFact] + restrictionCache *readmodelcache.Cache[int64, accountRestrictionFact] } func NewDurableUserProjectionFacts( @@ -29,12 +36,20 @@ func NewDurableUserProjectionFacts( versions store.ReadModelVersionStore, maxEntries int, ) *DurableUserProjectionFacts { + // freezes optionally also implements AccountRestrictionProvider (the + // production admin.Service does); callers that only need freeze facts, + // such as tests, are unaffected. + restrictions, _ := freezes.(AccountRestrictionProvider) return &DurableUserProjectionFacts{ - freezes: freezes, - versions: versions, + freezes: freezes, + restrictions: restrictions, + versions: versions, freezeCache: readmodelcache.New[int64, accountFreezeFact](readmodelcache.Config[int64, accountFreezeFact]{ MaxEntries: maxEntries, }), + restrictionCache: readmodelcache.New[int64, accountRestrictionFact](readmodelcache.Config[int64, accountRestrictionFact]{ + MaxEntries: maxEntries, + }), } } @@ -94,6 +109,62 @@ func (f *DurableUserProjectionFacts) AccountFreeze(ctx context.Context, userID i return value, found, nil } +func (f *DurableUserProjectionFacts) AccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) { + out := make(map[int64]domain.AccountRestriction) + ids := uniqueDurableFactUserIDs(userIDs) + if f == nil || f.restrictions == nil || len(ids) == 0 { + return out, nil + } + // Reuses the same user_visibility read model version as freeze facts: + // SetAccountRestriction bumps it on write, so a shared hash keeps both + // caches correctly invalidated without a separate model. + hashes, err := f.factHashes(ctx, readmodel.ModelUserVisibility, ids) + if err != nil { + return nil, err + } + loaded, err := f.restrictionCache.GetOrLoadBatch(ctx, ids, + func(userID int64) (int64, bool) { + hash := hashes[userID] + return hash, f.versions != nil && hash != 0 + }, + func(ctx context.Context, missing []int64) (map[int64]accountRestrictionFact, error) { + values, err := f.restrictions.AccountRestrictions(ctx, missing) + if err != nil { + return nil, err + } + entries := make(map[int64]accountRestrictionFact, len(missing)) + for _, userID := range missing { + entry := accountRestrictionFact{} + if value, ok := values[userID]; ok { + entry = accountRestrictionFact{value: value, found: true} + } + entries[userID] = entry + } + return entries, nil + }) + if err != nil { + return nil, err + } + for userID, entry := range loaded { + if entry.found { + out[userID] = entry.value + } + } + return out, nil +} + +func (f *DurableUserProjectionFacts) AccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) { + if userID == 0 { + return domain.AccountRestriction{}, false, nil + } + items, err := f.AccountRestrictions(ctx, []int64{userID}) + if err != nil { + return domain.AccountRestriction{}, false, err + } + value, found := items[userID] + return value, found, nil +} + func (f *DurableUserProjectionFacts) factHashes(ctx context.Context, model string, userIDs []int64) (map[int64]int64, error) { out := make(map[int64]int64, len(userIDs)) if f == nil || f.versions == nil { @@ -119,9 +190,16 @@ func (f *DurableUserProjectionFacts) InvalidateAccountFreezeFact(userID int64) { } } +func (f *DurableUserProjectionFacts) InvalidateAccountRestrictionFact(userID int64) { + if f != nil && userID != 0 { + f.restrictionCache.Invalidate(userID) + } +} + func (f *DurableUserProjectionFacts) FlushUserProjectionFactReadModel() { if f != nil { f.freezeCache.Flush() + f.restrictionCache.Flush() } } diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go index e8b2aa8e..4d4d4351 100644 --- a/internal/app/userprojection/projection.go +++ b/internal/app/userprojection/projection.go @@ -31,6 +31,13 @@ type AccountFreezeProvider interface { AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) } +// AccountRestrictionProvider returns durable narrower spam-restriction facts +// for a bounded batch. Like AccountFreezeProvider, the projector only exposes +// them to viewers other than the restricted user. +type AccountRestrictionProvider interface { + AccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) +} + // BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out // 投影里 per-user 3×CanSee 的 N+1。可选:实现了它的 evaluator(privacy.Service)会被 // projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。 @@ -56,10 +63,11 @@ var privacyProjectionKeys = []domain.PrivacyKey{ // Projector builds the current viewer's user view for RPC response payloads. // It intentionally stays in app/domain types; tg.* conversion remains in rpc. type Projector struct { - contacts store.ContactStore - photos ProfilePhotoProvider - privacy PrivacyEvaluator - freezes AccountFreezeProvider + contacts store.ContactStore + photos ProfilePhotoProvider + privacy PrivacyEvaluator + freezes AccountFreezeProvider + restrictions AccountRestrictionProvider } // Option configures a Projector. @@ -85,6 +93,12 @@ func WithAccountFreezeProvider(provider AccountFreezeProvider) Option { return func(p *Projector) { p.freezes = provider } } +// WithAccountRestrictionProvider enables viewer-scoped restricted-account +// visibility (the narrower spam restriction). +func WithAccountRestrictionProvider(provider AccountRestrictionProvider) Option { + return func(p *Projector) { p.restrictions = provider } +} + // New creates a user projector. func New(opts ...Option) *Projector { p := &Projector{} @@ -100,7 +114,7 @@ func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []d if p == nil { return users, nil } - return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, viewerUserID, users) + return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, p.restrictions, viewerUserID, users) } // One applies ForViewer to a single user. @@ -149,6 +163,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users personalRefsByViewer map[int64]map[int64]domain.ProfilePhotoRef matrix map[int64]map[int64]map[domain.PrivacyKey]bool freezes map[int64]domain.AccountFreeze + restrictions map[int64]domain.AccountRestriction ) g, gctx := errgroup.WithContext(ctx) // 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用。 @@ -186,6 +201,13 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users return err }) } + if p.restrictions != nil && len(ids) > 0 { + g.Go(func() error { + var err error + restrictions, err = p.restrictions.AccountRestrictions(gctx, ids) + return err + }) + } if err := g.Wait(); err != nil { return nil, err } @@ -223,6 +245,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users } } pj = applyAccountFreezeProjection(pj, viewer, freezes[u.ID]) + pj = applyAccountRestrictionProjection(pj, viewer, restrictions[u.ID]) cache[u.ID] = pj projected[i] = pj } @@ -363,7 +386,7 @@ func One(ctx context.Context, contacts store.ContactStore, viewerUserID int64, u return projected[0], nil } -func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) { +func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, restrictionsProvider AccountRestrictionProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) { if len(users) == 0 { return users, nil } @@ -377,6 +400,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi contactsByID map[int64]domain.Contact visibility map[int64]map[domain.PrivacyKey]bool freezes map[int64]domain.AccountFreeze + restrictions map[int64]domain.AccountRestriction ) // 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性), // 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在 @@ -449,6 +473,16 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi return nil }) } + if restrictionsProvider != nil && len(ids) > 0 { + g.Go(func() error { + m, err := restrictionsProvider.AccountRestrictions(gctx, ids) + if err != nil { + return err + } + restrictions = m + return nil + }) + } if err := g.Wait(); err != nil { return nil, err } @@ -480,6 +514,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi } } projected = applyAccountFreezeProjection(projected, viewerUserID, freezes[u.ID]) + projected = applyAccountRestrictionProjection(projected, viewerUserID, restrictions[u.ID]) cache[u.ID] = projected out[i] = projected } @@ -488,6 +523,8 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze domain.AccountFreeze) domain.User { // Base users and self users must never retain a viewer-scoped restriction. + // This runs first in the projection chain, so it is the one place that + // establishes the nil baseline; applyAccountRestrictionProjection appends. user.RestrictionReasons = nil if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !freeze.Frozen { return user @@ -496,6 +533,14 @@ func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze d return user } +func applyAccountRestrictionProjection(user domain.User, viewerUserID int64, restriction domain.AccountRestriction) domain.User { + if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !restriction.Restricted { + return user + } + user.RestrictionReasons = append(user.RestrictionReasons, domain.AccountRestrictedRestrictionReasons()...) + return user +} + func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) { if privacy == nil || viewerUserID == 0 { return nil, nil diff --git a/internal/app/users/service.go b/internal/app/users/service.go index 2bfdd679..b3e7fe87 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -28,13 +28,14 @@ type ProfilePhotoProvider = userprojection.ProfilePhotoProvider // Service 提供用户查询。 type Service struct { - users store.UserStore - cache store.UserCache - contacts store.ContactStore - photos ProfilePhotoProvider - privacy userprojection.PrivacyEvaluator - freezes userprojection.AccountFreezeProvider - projector *userprojection.Projector + users store.UserStore + cache store.UserCache + contacts store.ContactStore + photos ProfilePhotoProvider + privacy userprojection.PrivacyEvaluator + freezes userprojection.AccountFreezeProvider + restrictions userprojection.AccountRestrictionProvider + projector *userprojection.Projector // hideThirdPartyVerification mirrors config.HideThirdPartyVerification: // while true, ResolveUsername never resolves @marksbot // (domain.VerifierBotUserID), so a client cannot discover it by username. @@ -97,6 +98,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option { return func(s *Service) { s.freezes = p } } +// WithAccountRestrictionProvider injects the account spam-restriction reader. +func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option { + return func(s *Service) { s.restrictions = p } +} + // WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification: // while true, ResolveUsername treats @marksbot as not found. func WithHideThirdPartyVerification(hidden bool) Option { @@ -135,6 +141,7 @@ func NewService(users store.UserStore, opts ...Option) *Service { userprojection.WithPhotoProvider(s.photos), userprojection.WithPrivacyEvaluator(s.privacy), userprojection.WithAccountFreezeProvider(s.freezes), + userprojection.WithAccountRestrictionProvider(s.restrictions), ) return s } diff --git a/internal/domain/admin.go b/internal/domain/admin.go index 70aa900a..9967a2cc 100644 --- a/internal/domain/admin.go +++ b/internal/domain/admin.go @@ -53,3 +53,21 @@ type AccountFreezeNotification struct { Frozen bool Attempts int } + +// AccountRestriction is the durable, narrower spam sanction: unlike +// AccountFreeze it does not make the account read-only. A restricted account +// keeps every existing membership and conversation; it can only not join new +// channels/groups and not start new conversations with non-contacts. Until, +// when set, is when the restriction auto-lifts; a zero Until means it stays +// in effect until an admin clears it. +type AccountRestriction struct { + UserID int64 + Restricted bool + Version int64 + Since time.Time + Until time.Time + Reason string + Actor string + CommandID string + UpdatedAt time.Time +} diff --git a/internal/domain/moderation_case.go b/internal/domain/moderation_case.go index 0173e080..4657a227 100644 --- a/internal/domain/moderation_case.go +++ b/internal/domain/moderation_case.go @@ -111,6 +111,8 @@ const ( ModerationActionClearPeerFlags ModerationActionKind = "clear_peer_flags" ModerationActionFreezeAccount ModerationActionKind = "freeze_account" ModerationActionUnfreezeAccount ModerationActionKind = "unfreeze_account" + ModerationActionRestrictAccount ModerationActionKind = "restrict_account" + ModerationActionUnrestrictAccount ModerationActionKind = "unrestrict_account" ModerationActionDeletePrivateMessage ModerationActionKind = "delete_private_message" ModerationActionDeleteChannelMessage ModerationActionKind = "delete_channel_message" ModerationActionDeleteAccount ModerationActionKind = "delete_account" @@ -121,6 +123,7 @@ func (k ModerationActionKind) Valid() bool { case ModerationActionMarkScam, ModerationActionMarkFake, ModerationActionClearPeerFlags, ModerationActionFreezeAccount, ModerationActionUnfreezeAccount, + ModerationActionRestrictAccount, ModerationActionUnrestrictAccount, ModerationActionDeletePrivateMessage, ModerationActionDeleteChannelMessage, ModerationActionDeleteAccount: @@ -159,8 +162,9 @@ func (s ModerationActionStatus) Valid() bool { type ModerationSanctionFamily string const ( - ModerationSanctionPeerFlags ModerationSanctionFamily = "peer_flags" - ModerationSanctionAccountFreeze ModerationSanctionFamily = "account_freeze" + ModerationSanctionPeerFlags ModerationSanctionFamily = "peer_flags" + ModerationSanctionAccountFreeze ModerationSanctionFamily = "account_freeze" + ModerationSanctionAccountRestrict ModerationSanctionFamily = "account_restrict" ) func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) { @@ -170,6 +174,8 @@ func (k ModerationActionKind) SanctionFamily() (ModerationSanctionFamily, bool) return ModerationSanctionPeerFlags, true case ModerationActionFreezeAccount, ModerationActionUnfreezeAccount: return ModerationSanctionAccountFreeze, true + case ModerationActionRestrictAccount, ModerationActionUnrestrictAccount: + return ModerationSanctionAccountRestrict, true default: return "", false } diff --git a/internal/domain/user.go b/internal/domain/user.go index 67412a01..3f3fb2ef 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -189,6 +189,14 @@ func AccountFrozenRestrictionReasons() []UserRestrictionReason { }} } +func AccountRestrictedRestrictionReasons() []UserRestrictionReason { + return []UserRestrictionReason{{ + Platform: "all", + Reason: "spam-restricted", + Text: "This account has been restricted for suspected spam.", + }} +} + // PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。 // bot 永不为会员(官方语义;授予路径同样排除 bot,这里是双保险)。 func (u User) PremiumActiveAt(now int64) bool { diff --git a/internal/rpc/deps.go b/internal/rpc/deps.go index 03a2a111..272fc68c 100644 --- a/internal/rpc/deps.go +++ b/internal/rpc/deps.go @@ -550,6 +550,12 @@ type AccountFreezeService interface { AccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) } +// AccountRestrictionService exposes the narrower spam-restriction fact used +// by the central RPC join gate and the private-message send path. +type AccountRestrictionService interface { + AccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) +} + // AccountFreezeNotificationService owns the durable non-PTS notification // queue. It is intentionally separate from AccountFreezeService so hot // read-only gates can use a versioned fact cache without disabling queue @@ -607,6 +613,10 @@ type UserEmojiStatusUpdatesService interface { type ContactsService interface { GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) + // IsContact reports whether peerUserID is in userID's own contact list. + // Used by the account-restriction send gate, which is a one-directional + // check unrelated to the recipient's privacy settings. + IsContact(ctx context.Context, userID, peerUserID int64) (bool, error) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) AcceptContact(ctx context.Context, userID, contactUserID int64) (domain.Contact, error) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) @@ -1149,6 +1159,7 @@ type Deps struct { AppUpdates updatecdn.Resolver AccountFreeze AccountFreezeService AccountFreezeNotifications AccountFreezeNotificationService + AccountRestriction AccountRestrictionService AICompose AIComposeService Ephemeral EphemeralService EphemeralPush store.EphemeralPushBroker diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index fae78b16..ce538f50 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -128,6 +128,14 @@ func mediaEmptyErr() error { return tgerr.New(400, "MEDIA_EMPTY") } func frozenMethodInvalidErr() error { return tgerr.New(420, "FROZEN_METHOD_INVALID") } func frozenParticipantMissingErr() error { return tgerr.New(400, "FROZEN_PARTICIPANT_MISSING") } +// restrictedMethodInvalidErr covers a spam-restricted account attempting to +// join a new channel/group (public join or private invite link). +func restrictedMethodInvalidErr() error { return tgerr.New(420, "USER_RESTRICTED") } + +// restrictedNoncontactErr covers a spam-restricted account attempting to +// start a new conversation with a peer that is not in its contacts. +func restrictedNoncontactErr() error { return tgerr.New(403, "USER_RESTRICTED_NONCONTACT") } + func photoInvalidErr() error { return tgerr.New(400, "PHOTO_INVALID") } func stickersetInvalidErr() error { return tgerr.New(406, "STICKERSET_INVALID") } diff --git a/internal/rpc/frozen_gate.go b/internal/rpc/frozen_gate.go index d373cb37..d4692757 100644 --- a/internal/rpc/frozen_gate.go +++ b/internal/rpc/frozen_gate.go @@ -77,6 +77,67 @@ func frozenMethodRequiresWriteGate(method string) bool { return true } +// restrictedAlwaysBlockedMethods covers the narrower spam restriction: unlike +// freeze it does not touch reads or existing-membership actions, it only +// blocks starting a NEW channel/group membership (public join or private +// invite link). Non-contact messaging is peer-dependent and is gated +// separately in the send path, not here. +var restrictedAlwaysBlockedMethods = map[string]struct{}{ + "channels.joinChannel": {}, + "messages.importChatInvite": {}, +} + +func (r *Router) checkRestrictedRPC(ctx context.Context, method string) error { + if r == nil || r.deps.AccountRestriction == nil { + return nil + } + if _, blocked := restrictedAlwaysBlockedMethods[method]; !blocked { + return nil + } + userID, authorized := UserIDFrom(ctx) + if !authorized || userID == 0 { + return nil + } + restriction, found, err := r.deps.AccountRestriction.AccountRestriction(ctx, userID) + if err != nil { + return internalErr() + } + if found && restriction.Restricted { + return restrictedMethodInvalidErr() + } + return nil +} + +// ensureNotRestrictedFromMessaging enforces the peer-dependent half of the +// spam restriction: a restricted sender may still message existing contacts, +// but not start a new conversation with a peer outside their own contact +// list. Unlike checkRestrictedRPC this cannot be a method-name gate, since the +// same method (messages.sendMessage) is allowed or blocked depending on who +// the peer is. +func (r *Router) ensureNotRestrictedFromMessaging(ctx context.Context, senderUserID, recipientUserID int64) error { + if r == nil || r.deps.AccountRestriction == nil || senderUserID == 0 || recipientUserID == 0 || senderUserID == recipientUserID { + return nil + } + restriction, found, err := r.deps.AccountRestriction.AccountRestriction(ctx, senderUserID) + if err != nil { + return internalErr() + } + if !found || !restriction.Restricted { + return nil + } + if r.deps.Contacts == nil { + return restrictedNoncontactErr() + } + isContact, err := r.deps.Contacts.IsContact(ctx, senderUserID, recipientUserID) + if err != nil { + return internalErr() + } + if isContact { + return nil + } + return restrictedNoncontactErr() +} + func (r *Router) checkFrozenRPC(ctx context.Context, method string) error { if r == nil || r.deps.AccountFreeze == nil || !frozenMethodRequiresWriteGate(method) { return nil diff --git a/internal/rpc/layer_dispatch.go b/internal/rpc/layer_dispatch.go index 105522bf..16b91997 100644 --- a/internal/rpc/layer_dispatch.go +++ b/internal/rpc/layer_dispatch.go @@ -309,6 +309,9 @@ func (r *Router) DispatchAdmitted( if err := r.checkFrozenRPC(ctx, method); err != nil { return nil, method, err } + if err := r.checkRestrictedRPC(ctx, method); err != nil { + return nil, method, err + } if profileKnown && profileEvidenceFresh { r.maybeMarkSessionReceivesUpdates(ctx) } diff --git a/internal/rpc/messages_forward.go b/internal/rpc/messages_forward.go index a7aaee86..968f4a28 100644 --- a/internal/rpc/messages_forward.go +++ b/internal/rpc/messages_forward.go @@ -150,6 +150,9 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages if err := r.ensurePrivateContactAllowed(ctx, userID, toPeer.ID, req.AllowPaidStars, len(absentIndexes)); err != nil { return nil, err } + if err := r.ensureNotRestrictedFromMessaging(ctx, userID, toPeer.ID); err != nil { + return nil, err + } } absentIDs := make([]int, len(absentIndexes)) absentRandomIDs := make([]int64, len(absentIndexes)) diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index a075f777..18760b87 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -62,6 +62,12 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend sendErr = peerIDInvalidErr() return nil, sendErr } + if peer.Type == domain.PeerTypeUser { + if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil { + sendErr = err + return nil, sendErr + } + } idempotencyFingerprint, err := sendMessageIdempotencyFingerprint(req) if err != nil { sendErr = internalErr() diff --git a/internal/rpc/router.go b/internal/rpc/router.go index cb5422aa..0a61e871 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -809,6 +809,9 @@ func (r *Router) dispatch(ctx context.Context, b *bin.Buffer, depth int, meta *r if err := r.checkFrozenRPC(ctx, tlTypeName(id)); err != nil { return nil, err } + if err := r.checkRestrictedRPC(ctx, tlTypeName(id)); err != nil { + return nil, err + } // 任何未包 invokeWithoutUpdates 的已登录 RPC 都把当前 session 视为 updates // 接收者。仅靠 updates.getState/getDifference 置位会漏掉 DrKLO 热恢复: // 它重连后不重建同步基线(pts 在进程内存里),只发普通业务请求,置位 diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index d4333117..6fff4318 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -131,6 +131,9 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, p.allowPaidStars, 1); err != nil { return nil, false, err } + if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil { + return nil, false, err + } if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, p.media != nil && p.media.HasUnreadPayload()); err != nil { return nil, false, err } @@ -544,6 +547,9 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, req.AllowPaidStars, absentCount); err != nil { return nil, err } + if err := r.ensureNotRestrictedFromMessaging(ctx, userID, peer.ID); err != nil { + return nil, err + } } pendingMedia := make([]tg.InputMediaClass, 0, absentCount) for i, item := range req.MultiMedia { diff --git a/internal/store/memory/moderation.go b/internal/store/memory/moderation.go index 40a3853c..10355b6f 100644 --- a/internal/store/memory/moderation.go +++ b/internal/store/memory/moderation.go @@ -954,6 +954,10 @@ func (s *ModerationReportStore) memoryCaseOwnsRemedySanctionsLocked(item domain. if latest.Kind != domain.ModerationActionFreezeAccount { return false } + case domain.ModerationActionUnrestrictAccount: + if latest.Kind != domain.ModerationActionRestrictAccount { + return false + } default: return false } @@ -968,6 +972,8 @@ func memoryModerationActionsValid(target domain.Peer, actions []domain.Moderatio domain.ModerationActionClearPeerFlags: case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount, + domain.ModerationActionRestrictAccount, + domain.ModerationActionUnrestrictAccount, domain.ModerationActionDeletePrivateMessage, domain.ModerationActionDeleteAccount: if target.Type != domain.PeerTypeUser { diff --git a/internal/store/postgres/admin.go b/internal/store/postgres/admin.go index f4641685..074967ca 100644 --- a/internal/store/postgres/admin.go +++ b/internal/store/postgres/admin.go @@ -303,6 +303,130 @@ func scanAccountFreeze(row accountFreezeScanner) (domain.AccountFreeze, error) { return r, nil } +func (s *AdminStore) GetAccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) { + row := s.db.QueryRow(ctx, ` +SELECT user_id, restricted, version, restricted_since, restricted_until, reason, actor, command_id, updated_at +FROM account_message_restrictions +WHERE user_id = $1`, userID) + r, err := scanAccountRestriction(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return domain.AccountRestriction{}, false, nil + } + return domain.AccountRestriction{}, false, fmt.Errorf("get account restriction: %w", err) + } + return r, true, nil +} + +func (s *AdminStore) GetAccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) { + out := make(map[int64]domain.AccountRestriction) + if s == nil || s.db == nil || len(userIDs) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, ` +SELECT user_id, restricted, version, restricted_since, restricted_until, reason, actor, command_id, updated_at +FROM account_message_restrictions +WHERE user_id = ANY($1::bigint[]) AND restricted = true`, userIDs) + if err != nil { + return nil, fmt.Errorf("get account restrictions: %w", err) + } + defer rows.Close() + for rows.Next() { + restriction, err := scanAccountRestriction(rows) + if err != nil { + return nil, fmt.Errorf("scan account restriction: %w", err) + } + out[restriction.UserID] = restriction + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate account restrictions: %w", err) + } + return out, nil +} + +func (s *AdminStore) SetAccountRestriction(ctx context.Context, restriction domain.AccountRestriction) (domain.AccountRestriction, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return setAccountRestrictionRow(ctx, s.db, restriction) + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.AccountRestriction{}, fmt.Errorf("begin set account restriction: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + out, err := setAccountRestrictionRow(ctx, tx, restriction) + if err != nil { + return domain.AccountRestriction{}, err + } + // Reuses the same user_visibility read model as account freeze so the RPC + // gate and user projection caches invalidate on the same signal. + if _, err := tx.Exec(ctx, `SELECT telesrv_bump_read_model_version('user_visibility', 0, 'user', $1)`, out.UserID); err != nil { + return domain.AccountRestriction{}, fmt.Errorf("bump restricted user visibility: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.AccountRestriction{}, fmt.Errorf("commit set account restriction: %w", err) + } + committed = true + return out, nil +} + +func setAccountRestrictionRow(ctx context.Context, db sqlcgen.DBTX, restriction domain.AccountRestriction) (domain.AccountRestriction, error) { + var since, until any + if restriction.Restricted { + since = restriction.Since + if !restriction.Until.IsZero() { + until = restriction.Until + } + } + row := db.QueryRow(ctx, ` +INSERT INTO account_message_restrictions ( + user_id, restricted, restricted_since, restricted_until, reason, actor, command_id, updated_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,now()) +ON CONFLICT (user_id) DO UPDATE SET + restricted = EXCLUDED.restricted, + restricted_since = EXCLUDED.restricted_since, + restricted_until = EXCLUDED.restricted_until, + reason = EXCLUDED.reason, + actor = EXCLUDED.actor, + command_id = EXCLUDED.command_id, + version = account_message_restrictions.version + 1, + updated_at = now() +RETURNING user_id, restricted, version, restricted_since, restricted_until, reason, actor, command_id, updated_at`, + restriction.UserID, restriction.Restricted, since, until, restriction.Reason, restriction.Actor, restriction.CommandID, + ) + out, err := scanAccountRestriction(row) + if err != nil { + return domain.AccountRestriction{}, fmt.Errorf("set account restriction: %w", err) + } + return out, nil +} + +func scanAccountRestriction(row accountFreezeScanner) (domain.AccountRestriction, error) { + var r domain.AccountRestriction + var since, until pgtype.Timestamptz + var updated time.Time + if err := row.Scan( + &r.UserID, &r.Restricted, &r.Version, &since, &until, + &r.Reason, &r.Actor, &r.CommandID, &updated, + ); err != nil { + return domain.AccountRestriction{}, err + } + if since.Valid { + r.Since = since.Time + } + if until.Valid { + r.Until = until.Time + } + r.UpdatedAt = updated + return r, nil +} + func enqueueAccountFreezeNotifications(ctx context.Context, tx pgx.Tx, freeze domain.AccountFreeze) error { const maxAccountFreezeNotificationAudience = 4096 _, err := tx.Exec(ctx, ` diff --git a/internal/store/postgres/moderation_cases.go b/internal/store/postgres/moderation_cases.go index e700e251..ff24ac5e 100644 --- a/internal/store/postgres/moderation_cases.go +++ b/internal/store/postgres/moderation_cases.go @@ -969,6 +969,10 @@ LIMIT 1`, string(current.Target.Type), current.Target.ID, if domain.ModerationActionKind(kind) != domain.ModerationActionFreezeAccount { return domain.ErrModerationActionConflict } + case domain.ModerationActionUnrestrictAccount: + if domain.ModerationActionKind(kind) != domain.ModerationActionRestrictAccount { + return domain.ErrModerationActionConflict + } default: return domain.ErrModerationActionConflict } @@ -989,6 +993,11 @@ func moderationSanctionKinds(family domain.ModerationSanctionFamily) []string { string(domain.ModerationActionFreezeAccount), string(domain.ModerationActionUnfreezeAccount), } + case domain.ModerationSanctionAccountRestrict: + return []string{ + string(domain.ModerationActionRestrictAccount), + string(domain.ModerationActionUnrestrictAccount), + } default: return nil } @@ -1002,6 +1011,8 @@ func validateModerationActionsForTarget(target domain.Peer, actions []domain.Mod // Both users and channels support protocol scam/fake flags. case domain.ModerationActionFreezeAccount, domain.ModerationActionUnfreezeAccount, + domain.ModerationActionRestrictAccount, + domain.ModerationActionUnrestrictAccount, domain.ModerationActionDeletePrivateMessage, domain.ModerationActionDeleteAccount: if target.Type != domain.PeerTypeUser {