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
}