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

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