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

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

View file

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