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

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