Merge branch 'feat/account-spam-restriction'
This commit is contained in:
commit
fff8b431ab
32 changed files with 750 additions and 44 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS account_message_restrictions;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'
|
||||
));
|
||||
|
|
@ -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'
|
||||
));
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -578,6 +578,8 @@ func (f *fakeBotService) AdminExportBotToken(_ context.Context, botUserID int64)
|
|||
type fakeRestrictionStore struct {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type Service struct {
|
|||
photos userprojection.ProfilePhotoProvider
|
||||
privacy phonePrivacyService
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
restrictions userprojection.AccountRestrictionProvider
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
cache *contactListReadModelCache
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type Service struct {
|
|||
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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
type captureModerationAdmin struct {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
restrictions AccountRestrictionProvider
|
||||
versions store.ReadModelVersionStore
|
||||
|
||||
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,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 字节等价。
|
||||
|
|
@ -60,6 +67,7 @@ type Projector struct {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ type Service struct {
|
|||
photos ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
restrictions userprojection.AccountRestrictionProvider
|
||||
projector *userprojection.Projector
|
||||
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||
// while true, ResolveUsername never resolves @marksbot
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -161,6 +164,7 @@ type ModerationSanctionFamily string
|
|||
const (
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 在进程内存里),只发普通业务请求,置位
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue