Compare commits

..

2 commits

Author SHA1 Message Date
fff8b431ab Merge branch 'feat/account-spam-restriction'
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Admin web build (push) Waiting to run
CI / Grammy store bot (push) Waiting to run
CI / Docker main topology smoke (push) Waiting to run
2026-09-16 14:03:19 +01:00
f33e25af8d 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.
2026-09-16 14:03:15 +01:00
32 changed files with 750 additions and 44 deletions

View file

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

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS account_message_restrictions;

View file

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

View file

@ -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'
));

View file

@ -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'
));

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

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

View file

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

View file

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

View file

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

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

@ -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 启用服务端内置 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

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

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

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 字节等价。
@ -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

View file

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

View file

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

View file

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

View file

@ -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 报告用户在 nowUnix 秒)时刻是否为有效会员。
// bot 永不为会员(官方语义;授予路径同样排除 bot这里是双保险
func (u User) PremiumActiveAt(now int64) bool {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 在进程内存里),只发普通业务请求,置位

View file

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

View file

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

View file

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

View file

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