admin: add account spam restriction (join/message gate)

Adds a narrower spam sanction alongside the existing account freeze: a
restricted account keeps every existing membership and conversation, but
cannot join new channels/groups (public join or invite link) and cannot
start a new conversation with a non-contact. Reachable both as a standalone
admin action and as a decision on a reported user's moderation case, with
the same idempotent-supersession and appeal wiring freeze already has.
This commit is contained in:
Astra 2026-09-16 14:03:15 +01:00
parent 3ca8ef1a16
commit f33e25af8d
32 changed files with 750 additions and 44 deletions

View file

@ -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

View file

@ -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),
)
}

View file

@ -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 启用服务端内置 botBotFather对私聊消息的自动应答。
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

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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。可选实现了它的 evaluatorprivacy.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

View file

@ -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
}