feat: sync privacy read-model rules

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent 9f467f4be7
commit 5d5883a3d1
20 changed files with 1011 additions and 20 deletions

View file

@ -0,0 +1,122 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appprivacy "telesrv/internal/app/privacy"
appupdates "telesrv/internal/app/updates"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountPrivacyAllKeysRoundTripAndRecordDifferenceEvents(t *testing.T) {
ctx := context.Background()
const userID int64 = 8101
authKeyID := [8]byte{8, 1}
sessionID := int64(81)
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
events := memory.NewUpdateEventStore()
updates := appupdates.NewService(memory.NewUpdateStateStore(), events)
router := New(Config{}, Deps{
Privacy: privacy,
Updates: updates,
Sessions: &captureSessions{},
}, zaptest.NewLogger(t), clock.System)
requestCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, userID), authKeyID), sessionID)
keys := []struct {
name string
input tg.InputPrivacyKeyClass
domain domain.PrivacyKey
wire func(tg.PrivacyKeyClass) bool
}{
{"status_timestamp", &tg.InputPrivacyKeyStatusTimestamp{}, domain.PrivacyKeyStatusTimestamp, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyStatusTimestamp); return ok }},
{"chat_invite", &tg.InputPrivacyKeyChatInvite{}, domain.PrivacyKeyChatInvite, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyChatInvite); return ok }},
{"phone_call", &tg.InputPrivacyKeyPhoneCall{}, domain.PrivacyKeyPhoneCall, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneCall); return ok }},
{"phone_p2p", &tg.InputPrivacyKeyPhoneP2P{}, domain.PrivacyKeyPhoneP2P, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneP2P); return ok }},
{"forwards", &tg.InputPrivacyKeyForwards{}, domain.PrivacyKeyForwards, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyForwards); return ok }},
{"profile_photo", &tg.InputPrivacyKeyProfilePhoto{}, domain.PrivacyKeyProfilePhoto, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyProfilePhoto); return ok }},
{"phone_number", &tg.InputPrivacyKeyPhoneNumber{}, domain.PrivacyKeyPhoneNumber, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyPhoneNumber); return ok }},
{"added_by_phone", &tg.InputPrivacyKeyAddedByPhone{}, domain.PrivacyKeyAddedByPhone, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyAddedByPhone); return ok }},
{"voice_messages", &tg.InputPrivacyKeyVoiceMessages{}, domain.PrivacyKeyVoiceMessages, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyVoiceMessages); return ok }},
{"about", &tg.InputPrivacyKeyAbout{}, domain.PrivacyKeyAbout, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyAbout); return ok }},
{"birthday", &tg.InputPrivacyKeyBirthday{}, domain.PrivacyKeyBirthday, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyBirthday); return ok }},
{"star_gifts_auto_save", &tg.InputPrivacyKeyStarGiftsAutoSave{}, domain.PrivacyKeyStarGiftsAutoSave, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyStarGiftsAutoSave); return ok }},
{"no_paid_messages", &tg.InputPrivacyKeyNoPaidMessages{}, domain.PrivacyKeyNoPaidMessages, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeyNoPaidMessages); return ok }},
{"saved_music", &tg.InputPrivacyKeySavedMusic{}, domain.PrivacyKeySavedMusic, func(v tg.PrivacyKeyClass) bool { _, ok := v.(*tg.PrivacyKeySavedMusic); return ok }},
}
for _, test := range keys {
t.Run(test.name, func(t *testing.T) {
gotKey, ok := domainPrivacyKeyFromInput(test.input)
if !ok || gotKey != test.domain {
t.Fatalf("input key maps to %q/%v, want %q/true", gotKey, ok, test.domain)
}
if !test.wire(tgPrivacyKey(test.domain)) {
t.Fatalf("domain key %q projected as %T", test.domain, tgPrivacyKey(test.domain))
}
set, err := router.onAccountSetPrivacy(requestCtx, &tg.AccountSetPrivacyRequest{
Key: test.input,
Rules: []tg.InputPrivacyRuleClass{&tg.InputPrivacyValueDisallowAll{}},
})
if err != nil {
t.Fatalf("setPrivacy: %v", err)
}
if len(set.Rules) != 1 {
t.Fatalf("setPrivacy rules=%d, want 1", len(set.Rules))
}
if _, ok := set.Rules[0].(*tg.PrivacyValueDisallowAll); !ok {
t.Fatalf("setPrivacy rule=%T, want disallowAll", set.Rules[0])
}
get, err := router.onAccountGetPrivacy(requestCtx, test.input)
if err != nil {
t.Fatalf("getPrivacy: %v", err)
}
if len(get.Rules) != 1 {
t.Fatalf("getPrivacy rules=%d, want 1", len(get.Rules))
}
if _, ok := get.Rules[0].(*tg.PrivacyValueDisallowAll); !ok {
t.Fatalf("getPrivacy rule=%T, want disallowAll", get.Rules[0])
}
})
}
recorded, err := events.ListAfter(ctx, userID, 0, 100)
if err != nil {
t.Fatalf("list privacy events: %v", err)
}
if len(recorded) != len(keys) {
t.Fatalf("privacy events=%d, want %d", len(recorded), len(keys))
}
for i, event := range recorded {
if event.Type != domain.UpdateEventPrivacy ||
event.Privacy.OwnerUserID != userID ||
event.Privacy.Key != keys[i].domain ||
event.PtsCount != 1 {
t.Fatalf("event[%d]=%+v, want durable privacy snapshot for %q", i, event, keys[i].domain)
}
}
difference, err := updates.GetDifference(ctx, [8]byte{8, 2}, userID, domain.UpdateState{})
if err != nil {
t.Fatalf("getDifference: %v", err)
}
wireDifference, ok := tgUpdatesDifference(userID, difference).(*tg.UpdatesDifference)
if !ok || len(wireDifference.OtherUpdates) != len(keys) {
t.Fatalf("wire difference=%T updates=%d, want %d privacy updates", wireDifference, len(wireDifference.OtherUpdates), len(keys))
}
for i, update := range wireDifference.OtherUpdates {
privacyUpdate, ok := update.(*tg.UpdatePrivacy)
if !ok {
t.Fatalf("difference update[%d]=%T, want updatePrivacy", i, update)
}
if !keys[i].wire(privacyUpdate.Key) {
t.Fatalf("difference update[%d] key=%T, want %q", i, privacyUpdate.Key, keys[i].domain)
}
}
}

View file

@ -10,9 +10,10 @@ import (
const (
accountSettingsCacheMaxEntries = 4096
// accountSettingsCacheTTL 兜底跨实例失效;同实例 Set 即时失效。设置页连续调
// getGlobalPrivacy/getAccountTTL/getContentSettings/getContactSignUp 时只查一次 PG。
accountSettingsCacheTTL = 60 * time.Second
// accountSettingsCacheTTL is only the lost-notification safety net. Normal
// consistency comes from account_settings read-model notifications; local
// writes update the cached value directly.
accountSettingsCacheTTL = 24 * time.Hour
)
// accountSettingsCache 缓存 userID→AccountSettings,避免设置页 4 个 get handler 各查
@ -52,6 +53,38 @@ func (c *accountSettingsCache) Store(userID int64, settings domain.AccountSettin
c.cache.Store(userID, settings)
}
func (c *accountSettingsCache) Flush() {
if c == nil {
return
}
c.cache.Flush()
}
// InvalidateAccountSettingsReadModel is called by the shared PostgreSQL
// read-model listener. Router owns this cache, so exposing the invalidation on
// Router keeps store/postgres independent from the RPC package.
func (r *Router) InvalidateAccountSettingsReadModel(userID int64) {
if r == nil || r.accountSettings == nil {
return
}
r.accountSettings.Delete(userID)
}
func (r *Router) FlushAccountSettingsReadModel() {
if r == nil || r.accountSettings == nil {
return
}
r.accountSettings.Flush()
}
func (r *Router) WarmAccountSettingsReadModel(ctx context.Context, userID int64) error {
if r == nil || userID == 0 {
return nil
}
_, err := r.cachedAccountSettings(ctx, userID)
return err
}
type accountSettingsBatchReader interface {
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
}

View file

@ -8,6 +8,7 @@ import (
"go.uber.org/zap/zaptest"
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appprivacy "telesrv/internal/app/privacy"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
@ -119,6 +120,99 @@ func TestMessagesCreateChatCreatesMegagroupAndDialogsRPC(t *testing.T) {
}
}
func TestMessagesCreateChatFiltersPrivacyBeforeMembershipWritesRPC(t *testing.T) {
ctx := context.Background()
userStore := memory.NewUserStore()
owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550001031", FirstName: "Owner"})
if err != nil {
t.Fatalf("create owner: %v", err)
}
allowed, err := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550001032", FirstName: "Allowed"})
if err != nil {
t.Fatalf("create allowed user: %v", err)
}
denied, err := userStore.Create(ctx, domain.User{AccessHash: 33, Phone: "15550001033", FirstName: "Denied"})
if err != nil {
t.Fatalf("create denied user: %v", err)
}
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, denied.ID, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set denied invite privacy: %v", err)
}
channelStore := memory.NewChannelStore()
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Dialogs: appdialogs.NewService(memory.NewDialogStore(), channelStore),
Privacy: privacy,
}, zaptest.NewLogger(t), clock.System)
invited, err := r.onMessagesCreateChat(WithUserID(ctx, owner.ID), &tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{
&tg.InputUser{UserID: allowed.ID, AccessHash: allowed.AccessHash},
&tg.InputUser{UserID: denied.ID, AccessHash: denied.AccessHash},
},
Title: "Privacy Group",
})
if err != nil {
t.Fatalf("create chat: %v", err)
}
if len(invited.MissingInvitees) != 1 || invited.MissingInvitees[0].UserID != denied.ID {
t.Fatalf("missing invitees = %+v, want denied user %d", invited.MissingInvitees, denied.ID)
}
updates, ok := invited.Updates.(*tg.Updates)
if !ok {
t.Fatalf("updates = %T, want *tg.Updates", invited.Updates)
}
var channel *tg.Channel
for _, chat := range updates.Chats {
if candidate, ok := chat.(*tg.Channel); ok {
channel = candidate
break
}
}
if channel == nil || channel.ParticipantsCount != 2 {
t.Fatalf("channel = %#v, want owner + allowed only", channel)
}
for _, update := range updates.Updates {
newMessage, ok := update.(*tg.UpdateNewChannelMessage)
if !ok {
continue
}
service, ok := newMessage.Message.(*tg.MessageService)
if !ok {
continue
}
add, ok := service.Action.(*tg.MessageActionChatAddUser)
if !ok {
continue
}
if len(add.Users) != 1 || add.Users[0] != allowed.ID {
t.Fatalf("invite action users = %v, want allowed user %d only", add.Users, allowed.ID)
}
}
participants, err := r.onChannelsGetParticipants(WithUserID(ctx, owner.ID), &tg.ChannelsGetParticipantsRequest{
Channel: &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash},
Filter: &tg.ChannelParticipantsRecent{},
Limit: 10,
})
if err != nil {
t.Fatalf("get participants: %v", err)
}
list := participants.(*tg.ChannelsChannelParticipants)
if list.Count != 2 {
t.Fatalf("participant count = %d, want 2", list.Count)
}
for _, user := range list.Users {
if got, ok := user.(*tg.User); ok && got.ID == denied.ID {
t.Fatalf("denied user %d was written as a member", denied.ID)
}
}
}
func TestMessagesCreateChatCreatesOwnerOnlyMegagroupRPC(t *testing.T) {
tests := []struct {
name string

View file

@ -110,6 +110,14 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages
if err != nil {
return nil, err
}
if toPeer.Type == domain.PeerTypeUser {
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
if err := r.ensurePrivateContactAllowed(ctx, userID, toPeer.ID, req.AllowPaidStars, len(absentIndexes)); err != nil {
return nil, err
}
}
absentIDs := make([]int, len(absentIndexes))
absentRandomIDs := make([]int64, len(absentIndexes))
for i, originalIndex := range absentIndexes {

View file

@ -412,7 +412,7 @@ func forwardMessagesUnsupportedOptionErr(req *tg.MessagesForwardMessagesRequest)
return mediaInvalidErr()
case req.AllowPaidStars < 0:
return starsAmountInvalidErr()
case req.AllowPaidStars > 0 || req.AllowPaidFloodskip:
case req.AllowPaidFloodskip:
return paymentUnsupportedErr()
case !req.SuggestedPost.Zero():
return suggestedPostPeerInvalidErr()

View file

@ -141,10 +141,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
}
return updates, nil
}
if req.AllowPaidStars > 0 {
sendErr = paymentUnsupportedErr()
return nil, sendErr
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
if err != nil {
sendErr = err
@ -227,6 +223,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
richMessage: richMessage,
allowPaidStars: req.AllowPaidStars,
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
if err != nil {
sendErr = err
@ -249,6 +246,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend
replyMarkup: replyMarkup,
richMessage: richMessage,
effect: req.Effect,
allowPaidStars: req.AllowPaidStars,
})
duplicate = dup
if err != nil {

View file

@ -392,15 +392,6 @@ func TestMessagesSendMessageUnsupportedOptionErrors(t *testing.T) {
}(),
want: "STARS_AMOUNT_INVALID",
},
{
name: "paid stars",
req: func() *tg.MessagesSendMessageRequest {
req := base()
req.SetAllowPaidStars(1)
return req
}(),
want: "PAYMENT_UNSUPPORTED",
},
{
name: "paid floodskip",
req: func() *tg.MessagesSendMessageRequest {
@ -428,3 +419,27 @@ func TestMessagesSendMessageUnsupportedOptionErrors(t *testing.T) {
})
}
}
func TestMessagesSendMessageAllowsUnusedPaidAuthorizationForFreeRecipient(t *testing.T) {
const (
senderID = int64(1000000001)
recipientID = int64(1000000002)
)
messages := &captureMessages{}
r := New(Config{}, Deps{
Messages: messages,
Users: mapUsersService{users: map[int64]domain.User{
senderID: {ID: senderID, FirstName: "Sender"},
recipientID: {ID: recipientID, FirstName: "Recipient"},
}},
}, zaptest.NewLogger(t), clock.System)
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: recipientID},
Message: "free",
RandomID: 457,
}
req.SetAllowPaidStars(10)
if _, err := r.onMessagesSendMessage(WithUserID(context.Background(), senderID), req); err != nil {
t.Fatalf("free recipient with unused authorization: %v", err)
}
}

View file

@ -13,6 +13,7 @@ import (
"github.com/iamxvbaba/td/tlprofile"
appchannels "telesrv/internal/app/channels"
appmessages "telesrv/internal/app/messages"
appprivacy "telesrv/internal/app/privacy"
appstargifts "telesrv/internal/app/stargifts"
appstars "telesrv/internal/app/stars"
appusers "telesrv/internal/app/users"
@ -983,6 +984,72 @@ func TestStarGiftSaga(t *testing.T) {
}
}
func TestStarGiftAutoSavePrivacyCreatesPendingGiftUntilRecipientApproves(t *testing.T) {
r, sender, recipient, gift := starGiftTestRouter(t)
ctx := context.Background()
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, recipient.ID, domain.PrivacyKeyStarGiftsAutoSave, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set gift auto-save privacy: %v", err)
}
r.deps.Privacy = privacy
senderCtx := WithUserID(ctx, sender.ID)
recipientCtx := WithUserID(ctx, recipient.ID)
invoice := &tg.InputInvoiceStarGift{
Peer: &tg.InputPeerUser{UserID: recipient.ID, AccessHash: recipient.AccessHash},
GiftID: gift.ID,
}
formClass, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: invoice})
if err != nil {
t.Fatalf("get gift form: %v", err)
}
form := formClass.(*tg.PaymentsPaymentFormStarGift)
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{
FormID: form.FormID,
Invoice: invoice,
}); err != nil {
t.Fatalf("send gift: %v", err)
}
all, err := r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{
Peer: &tg.InputPeerSelf{},
Limit: 10,
})
if err != nil || all.Count != 1 || len(all.Gifts) != 1 {
t.Fatalf("all saved gifts count=%d len=%d err=%v, want pending gift", all.Count, len(all.Gifts), err)
}
if !all.Gifts[0].Unsaved {
t.Fatal("privacy-denied incoming gift must be pending (unsaved=true), not rejected")
}
msgID, ok := all.Gifts[0].GetMsgID()
if !ok || msgID == 0 {
t.Fatalf("pending gift msg_id=%d present=%v", msgID, ok)
}
visible, err := r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{
Peer: &tg.InputPeerSelf{},
ExcludeUnsaved: true,
Limit: 10,
})
if err != nil || visible.Count != 0 || len(visible.Gifts) != 0 {
t.Fatalf("exclude-unsaved count=%d len=%d err=%v, want hidden pending gift", visible.Count, len(visible.Gifts), err)
}
if ok, err := r.onPaymentsSaveStarGift(recipientCtx, &tg.PaymentsSaveStarGiftRequest{
Stargift: &tg.InputSavedStarGiftUser{MsgID: msgID},
}); err != nil || !ok {
t.Fatalf("recipient approve gift = %v err=%v", ok, err)
}
visible, err = r.onPaymentsGetSavedStarGifts(recipientCtx, &tg.PaymentsGetSavedStarGiftsRequest{
Peer: &tg.InputPeerSelf{},
ExcludeUnsaved: true,
Limit: 10,
})
if err != nil || visible.Count != 1 || len(visible.Gifts) != 1 || visible.Gifts[0].Unsaved {
t.Fatalf("approved visible gifts=%+v err=%v, want one saved gift", visible, err)
}
}
// 频道 star gift saga:channel peer 能付款发送,但不生成频道历史消息;
// saved gift 用 inputSavedStarGiftChat.saved_id 定位,Recent Actions 用 admin log 快照承载。
func TestStarGiftChannelSaga(t *testing.T) {

View file

@ -0,0 +1,137 @@
package rpc
import (
"context"
"math"
"github.com/iamxvbaba/td/tg"
)
type privateContactRequirement struct {
paidStars int64
requirePremium bool
}
type privacyContactFreeEvaluator interface {
CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error)
}
type privacyViewerPremiumEvaluator interface {
ViewerIsPremium(ctx context.Context, viewerUserID int64) (bool, error)
}
func applyPrivateContactRestrictionToUser(user *tg.User, restriction privateContactRequirement) {
if user == nil {
return
}
user.SetContactRequirePremium(restriction.requirePremium)
if restriction.paidStars > 0 {
user.SetSendPaidMessagesStars(restriction.paidStars)
} else {
user.Flags2.Unset(15)
user.SendPaidMessagesStars = 0
}
}
func applyPrivateContactRestrictionToUserFull(full *tg.UserFull, restriction privateContactRequirement) {
if full == nil {
return
}
full.SetContactRequirePremium(restriction.requirePremium)
if restriction.paidStars > 0 {
full.SetSendPaidMessagesStars(restriction.paidStars)
} else {
full.Flags2.Unset(14)
full.SendPaidMessagesStars = 0
}
}
// privateContactRequirementFor returns the recipient's current restriction
// from two in-memory read models:
// - account settings: base premium/paid requirement;
// - privacy/contact facts: contacts and PrivacyKeyNoPaidMessages exceptions.
//
// PostgreSQL is only a bounded cache-miss loader. Local writes are
// write-through and cross-instance changes invalidate + prewarm both models.
func (r *Router) privateContactRestrictionFor(
ctx context.Context,
senderUserID, recipientUserID int64,
) (privateContactRequirement, error) {
if r == nil || senderUserID == 0 || recipientUserID == 0 || senderUserID == recipientUserID {
return privateContactRequirement{}, nil
}
if evaluator, ok := r.deps.Privacy.(privacyContactFreeEvaluator); ok {
free, err := evaluator.CanContactForFreeBatch(ctx, []int64{recipientUserID}, senderUserID)
if err != nil {
return privateContactRequirement{}, internalErr()
}
if free[recipientUserID] {
return privateContactRequirement{}, nil
}
}
settings, err := r.cachedAccountSettings(ctx, recipientUserID)
if err != nil {
return privateContactRequirement{}, internalErr()
}
global := settings.GlobalPrivacy
if global.NoncontactPeersPaidStars > 0 {
return privateContactRequirement{paidStars: global.NoncontactPeersPaidStars}, nil
}
return privateContactRequirement{requirePremium: global.NewNoncontactPeersRequirePremium}, nil
}
func (r *Router) viewerIsPremiumForPrivacy(ctx context.Context, viewerUserID int64) (bool, error) {
var err error
premium := false
if evaluator, ok := r.deps.Privacy.(privacyViewerPremiumEvaluator); ok {
premium, err = evaluator.ViewerIsPremium(ctx, viewerUserID)
if err != nil {
return false, internalErr()
}
} else if r.deps.Users != nil {
user, found, loadErr := r.deps.Users.ByID(ctx, viewerUserID, viewerUserID)
if loadErr != nil {
return false, internalErr()
}
premium = found && user.PremiumActiveAt(r.clock.Now().Unix())
}
return premium, nil
}
func (r *Router) ensurePrivateContactAllowed(
ctx context.Context,
senderUserID, recipientUserID, allowPaidStars int64,
messageCount int,
) error {
if allowPaidStars < 0 || messageCount < 1 {
return starsAmountInvalidErr()
}
requirement, err := r.privateContactRestrictionFor(ctx, senderUserID, recipientUserID)
if err != nil {
return err
}
if requirement.requirePremium {
premium, err := r.viewerIsPremiumForPrivacy(ctx, senderUserID)
if err != nil {
return err
}
if !premium {
return premiumAccountRequiredErr()
}
return nil
}
if requirement.paidStars <= 0 {
return nil
}
if requirement.paidStars > math.MaxInt64/int64(messageCount) {
return starsAmountInvalidErr()
}
required := requirement.paidStars * int64(messageCount)
if allowPaidStars < required {
return allowPaymentRequiredErr(required)
}
// The privacy gate and no-paid exception are complete here. The separate
// private paid-message ledger is not part of the current message store yet;
// never accept an authorization without an atomic debit.
return paymentUnsupportedErr()
}

View file

@ -0,0 +1,166 @@
package rpc
import (
"context"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appaccount "telesrv/internal/app/account"
appprivacy "telesrv/internal/app/privacy"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func newPrivateRequirementRouter(t *testing.T, senderPremium bool) (*Router, *appaccount.Service, *appprivacy.Service, *memory.ContactStore, domain.User, domain.User) {
t.Helper()
ctx := context.Background()
usersStore := memory.NewUserStore()
aliceInput := domain.User{AccessHash: 11, Phone: "15550008101", FirstName: "Alice"}
if senderPremium {
aliceInput.PremiumUntil = int(time.Now().Add(time.Hour).Unix())
}
alice, err := usersStore.Create(ctx, aliceInput)
if err != nil {
t.Fatalf("create Alice: %v", err)
}
bob, err := usersStore.Create(ctx, domain.User{AccessHash: 22, Phone: "15550008102", FirstName: "Bob"})
if err != nil {
t.Fatalf("create Bob: %v", err)
}
users := appusers.NewService(usersStore)
contacts := memory.NewContactStore()
privacy := appprivacy.NewService(memory.NewPrivacyStore(), contacts).ConfigureReadModels(users, nil)
settingsStore := memory.NewPasswordStore()
account := appaccount.NewService(settingsStore, appaccount.WithAccountSettings(settingsStore))
router := New(Config{}, Deps{
Account: account,
Privacy: privacy,
Users: users,
Messages: &captureMessages{},
}, zaptest.NewLogger(t), clock.System)
return router, account, privacy, contacts, alice, bob
}
func TestPrivateContactRequirementUsesNoPaidMessagesReadModel(t *testing.T) {
ctx := context.Background()
r, account, privacy, contacts, alice, bob := newPrivateRequirementRouter(t, false)
if _, err := account.SetGlobalPrivacy(ctx, bob.ID, domain.GlobalPrivacy{NoncontactPeersPaidStars: 7}); err != nil {
t.Fatalf("set Bob paid requirement: %v", err)
}
full, err := r.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{
UserID: bob.ID, AccessHash: bob.AccessHash,
})
if err != nil {
t.Fatalf("get Bob full user: %v", err)
}
if stars, ok := full.FullUser.GetSendPaidMessagesStars(); !ok || stars != 7 {
t.Fatalf("full user paid stars = %d, %v; want 7, true", stars, ok)
}
projected, ok := full.Users[0].(*tg.User)
if !ok {
t.Fatalf("projected user = %T, want *tg.User", full.Users[0])
}
if stars, ok := projected.GetSendPaidMessagesStars(); !ok || stars != 7 {
t.Fatalf("user paid stars = %d, %v; want 7, true", stars, ok)
}
requirements, err := r.onUsersGetRequirementsToContact(WithUserID(ctx, alice.ID), []tg.InputUserClass{
&tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash},
})
if err != nil {
t.Fatalf("get requirements: %v", err)
}
if paid, ok := requirements[0].(*tg.RequirementToContactPaidMessages); !ok || paid.StarsAmount != 7 {
t.Fatalf("requirement = %#v, want paid 7", requirements[0])
}
send := func(randomID int64, allow int64) error {
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Message: "hello",
RandomID: randomID,
}
if allow != 0 {
req.SetAllowPaidStars(allow)
}
_, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), req)
return err
}
if err := send(81001, 0); err == nil || !strings.Contains(err.Error(), "ALLOW_PAYMENT_REQUIRED") || !strings.Contains(err.Error(), "(7)") {
t.Fatalf("non-exempt send err = %v, want ALLOW_PAYMENT_REQUIRED 7", err)
}
if err := send(81002, 7); err == nil || !strings.Contains(err.Error(), "PAYMENT_UNSUPPORTED") {
t.Fatalf("authorized paid send err = %v, want explicit unsupported ledger", err)
}
if _, err := privacy.SetRules(ctx, bob.ID, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{{
Kind: domain.PrivacyRuleAllowUsers,
UserIDs: []int64{alice.ID},
}, {
Kind: domain.PrivacyRuleDisallowAll,
}}); err != nil {
t.Fatalf("allow Alice in NoPaidMessages: %v", err)
}
if err := send(81003, 0); err != nil {
t.Fatalf("explicit no-paid exception send: %v", err)
}
full, err = r.onUsersGetFullUser(WithUserID(ctx, alice.ID), &tg.InputUser{
UserID: bob.ID, AccessHash: bob.AccessHash,
})
if err != nil {
t.Fatalf("get exempt Bob full user: %v", err)
}
if _, ok := full.FullUser.GetSendPaidMessagesStars(); ok || full.FullUser.ContactRequirePremium {
t.Fatalf("exempt full user still carries contact restriction: %+v", full.FullUser)
}
if _, err := privacy.SetRules(ctx, bob.ID, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("clear explicit exception: %v", err)
}
if _, err := contacts.Upsert(ctx, bob.ID, domain.ContactInput{
ContactUserID: alice.ID,
FirstName: "Alice",
}); err != nil {
t.Fatalf("Bob add Alice contact: %v", err)
}
if err := send(81004, 0); err != nil {
t.Fatalf("recipient contact must be free: %v", err)
}
}
func TestPrivateContactRequirementPremiumGateUsesViewerFactsReadModel(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
senderPremium bool
wantErr bool
}{
{name: "non-premium blocked", wantErr: true},
{name: "premium allowed", senderPremium: true},
} {
t.Run(tc.name, func(t *testing.T) {
r, account, _, _, alice, bob := newPrivateRequirementRouter(t, tc.senderPremium)
if _, err := account.SetGlobalPrivacy(ctx, bob.ID, domain.GlobalPrivacy{NewNoncontactPeersRequirePremium: true}); err != nil {
t.Fatalf("set premium requirement: %v", err)
}
_, err := r.onMessagesSendMessage(WithUserID(ctx, alice.ID), &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerUser{UserID: bob.ID, AccessHash: bob.AccessHash},
Message: "hello",
RandomID: 82001,
})
if tc.wantErr {
if err == nil || !strings.Contains(err.Error(), "PREMIUM_ACCOUNT_REQUIRED") {
t.Fatalf("send err = %v, want PREMIUM_ACCOUNT_REQUIRED", err)
}
} else if err != nil {
t.Fatalf("premium sender should pass: %v", err)
}
})
}
}

View file

@ -47,7 +47,8 @@ type outgoingSend struct {
groupedID int64
// effect 是消息特效 id(私聊专属,0 表无特效)。调用方已对 catalog 校验合法性;
// 频道侧忽略(官方群/频道不渲染特效)。
effect int64
effect int64
allowPaidStars int64
}
// sendOutgoing 把一条出站消息落地到私聊或频道,返回 *tg.Updates、是否重复、错误。
@ -127,6 +128,9 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee
if r.deps.Messages == nil {
return nil, false, peerIDInvalidErr()
}
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, p.allowPaidStars, 1); 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
}
@ -357,7 +361,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
ClearDraft: req.ClearDraft,
})
}
if req.AllowPaidStars > 0 || req.AllowPaidFloodskip {
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
replay, err := r.lookupOutgoingReplay(ctx, userID, peer, req.RandomID, idempotencyFingerprint)
@ -417,6 +421,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
replyToInput: req.ReplyTo,
sendAsInput: req.SendAs,
clearDraft: req.ClearDraft,
allowPaidStars: req.AllowPaidStars,
}, req.ScheduleDate, req.ScheduleRepeatPeriod)
}
updates, _, err := r.sendOutgoing(ctx, userID, peer, outgoingSend{
@ -433,6 +438,7 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
clearDraft: req.ClearDraft,
replyMarkup: replyMarkup,
effect: req.Effect,
allowPaidStars: req.AllowPaidStars,
})
if err != nil {
return nil, err
@ -452,6 +458,12 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
if len(req.MultiMedia) == 0 || len(req.MultiMedia) > maxSendMultiMediaItems {
return nil, limitInvalidErr()
}
if req.AllowPaidStars < 0 {
return nil, starsAmountInvalidErr()
}
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
peer, ok := r.domainPeerFromInputPeer(userID, req.Peer)
if !ok || peer.ID == 0 {
return nil, peerIDInvalidErr()
@ -511,6 +523,14 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
if err != nil {
return nil, err
}
if peer.Type == domain.PeerTypeUser {
if req.AllowPaidFloodskip {
return nil, paymentUnsupportedErr()
}
if err := r.ensurePrivateContactAllowed(ctx, userID, peer.ID, req.AllowPaidStars, absentCount); err != nil {
return nil, err
}
}
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
for i, item := range req.MultiMedia {
if !replays[i].found {
@ -571,6 +591,7 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
sendAsInput: req.SendAs,
clearDraft: clearDraftPending,
groupedID: groupedID,
allowPaidStars: req.AllowPaidStars,
}
var result tg.UpdatesClass
duplicate := false

View file

@ -14,6 +14,7 @@ import (
appmessages "telesrv/internal/app/messages"
apppolls "telesrv/internal/app/polls"
appprivacy "telesrv/internal/app/privacy"
appstories "telesrv/internal/app/stories"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
@ -36,6 +37,8 @@ type fakeFiles struct {
resolveWebPageFn func(string) (domain.MessageWebPage, error)
lookupWebPageFn func(string) (domain.MessageWebPage, bool)
webPagePreviewOn bool
getDocumentsCalls int
createUploadCalls int
}
type fakeProfilePhotoKey struct {
@ -78,6 +81,7 @@ func (f *fakeFiles) AvailableEffects(context.Context) ([]domain.AvailableEffect,
return append([]domain.AvailableEffect(nil), f.effects...), hash & 0x7fffffff, nil
}
func (f *fakeFiles) GetDocuments(_ context.Context, ids []int64) ([]domain.Document, error) {
f.getDocumentsCalls++
out := make([]domain.Document, 0, len(ids))
for _, id := range ids {
if d, ok := f.docs[id]; ok {
@ -564,6 +568,7 @@ func fakeAvatarStaticSizes() []domain.PhotoSize {
}
}
func (f *fakeFiles) CreateDocumentFromUpload(_ context.Context, _ domain.UploadedFileRef, spec domain.DocumentSpec) (domain.Document, error) {
f.createUploadCalls++
return domain.Document{ID: 888, AccessHash: 8, DCID: 2, MimeType: spec.MimeType, Attributes: spec.Attributes}, nil
}
func (f *fakeFiles) CreateDocumentFromBytes(_ context.Context, data []byte, spec domain.DocumentSpec) (domain.Document, error) {
@ -837,6 +842,73 @@ func TestSendMediaPrivateSticker(t *testing.T) {
}
}
func TestSendMediaVoicePrivacyPreflightsBeforeUploadMaterialization(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
files := r.deps.Files.(*fakeFiles)
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, friend.ID, domain.PrivacyKeyVoiceMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set voice privacy: %v", err)
}
r.deps.Privacy = privacy
_, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaUploadedDocument{
File: &tg.InputFile{ID: 7001, Parts: 1, Name: "voice.ogg"},
MimeType: "audio/ogg",
Attributes: []tg.DocumentAttributeClass{&tg.DocumentAttributeAudio{Voice: true, Duration: 1}},
},
RandomID: 7001,
})
if err == nil || !tgerr.Is(err, "CHAT_SEND_VOICES_FORBIDDEN") {
t.Fatalf("send uploaded voice err=%v, want CHAT_SEND_VOICES_FORBIDDEN", err)
}
if files.createUploadCalls != 0 {
t.Fatalf("uploaded voice materialized %d documents before privacy rejection", files.createUploadCalls)
}
if files.getDocumentsCalls != 0 {
t.Fatalf("uploaded voice unexpectedly loaded documents: calls=%d", files.getDocumentsCalls)
}
}
func TestSendMediaVoicePrivacyBatchesReferencedDocumentPreflight(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)
files := r.deps.Files.(*fakeFiles)
files.docs[7011] = domain.Document{
ID: 7011,
AccessHash: 71,
Attributes: []domain.DocumentAttribute{{Kind: domain.DocAttrVideo, RoundMessage: true}},
}
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := privacy.SetRules(ctx, friend.ID, domain.PrivacyKeyVoiceMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set voice privacy: %v", err)
}
r.deps.Privacy = privacy
_, err := r.onMessagesSendMedia(WithUserID(ctx, owner.ID), &tg.MessagesSendMediaRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
Media: &tg.InputMediaDocument{ID: &tg.InputDocument{
ID: 7011, AccessHash: 71,
}},
RandomID: 7011,
})
if err == nil || !tgerr.Is(err, "CHAT_SEND_VOICES_FORBIDDEN") {
t.Fatalf("send referenced round video err=%v, want CHAT_SEND_VOICES_FORBIDDEN", err)
}
if files.getDocumentsCalls != 1 {
t.Fatalf("referenced document preflight loads=%d, want one bounded batch", files.getDocumentsCalls)
}
if files.createUploadCalls != 0 {
t.Fatalf("referenced document path unexpectedly materialized upload: calls=%d", files.createUploadCalls)
}
}
func TestSendMultiMediaPartialFailureSubsetRetryKeepsReservedGroupedID(t *testing.T) {
ctx := context.Background()
r, owner, friend := newMediaTestRouter(t)

View file

@ -154,6 +154,11 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
if err := r.applyBotCanEditToUser(ctx, currentUserID, u, user); err != nil {
return nil, err
}
contactRestriction, err := r.privateContactRestrictionFor(ctx, currentUserID, u.ID)
if err != nil {
return nil, err
}
applyPrivateContactRestrictionToUser(user, contactRestriction)
r.applyStoryMaxIDsToPeerObjects(ctx, currentUserID, []tg.UserClass{user}, nil)
loadEpoch := r.userFullProjectionCache.LoadEpoch()
if full, ok := r.userFullProjectionCache.Lookup(currentUserID, u.ID); ok {
@ -165,6 +170,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
FullUser: full,
@ -185,6 +191,7 @@ func (r *Router) onUsersGetFullUser(ctx context.Context, id tg.InputUserClass) (
}
r.applyStoriesPinnedAvailableToUserFull(ctx, currentUserID, u.ID, &full)
r.applyNotifySettingsToUserFull(ctx, currentUserID, u.ID, &full)
applyPrivateContactRestrictionToUserFull(&full, contactRestriction)
chats := r.applyPersonalChannelToUserFull(ctx, currentUserID, u.PersonalChannelID, &full)
return &tg.UsersUserFull{
FullUser: full,