feat: sync durable moderation and appeals

This commit is contained in:
iamxvbaba 2026-07-24 11:56:59 +08:00
parent e1a95c7318
commit 9f467f4be7
140 changed files with 13730 additions and 316 deletions

View file

@ -16,6 +16,12 @@ import (
// registerAccount 注册 account.* RPC handler。
func (r *Router) registerAccount(d *tlprofile.Dispatcher) {
registerRPC[*tg.AccountReportPeerRequest](d, tlprofile.SemanticMethodAccountReportPeer, func(ctx context.Context, req *tg.AccountReportPeerRequest) (any, error) {
return r.onAccountReportPeer(ctx, req)
})
registerRPC[*tg.AccountReportProfilePhotoRequest](d, tlprofile.SemanticMethodAccountReportProfilePhoto, func(ctx context.Context, req *tg.AccountReportProfilePhotoRequest) (any, error) {
return r.onAccountReportProfilePhoto(ctx, req)
})
registerRPC[*tg.AccountDeleteAccountRequest](d, tlprofile.SemanticMethodAccountDeleteAccount, func(ctx context.Context, req *tg.AccountDeleteAccountRequest) (any, error) {
return r.onAccountDeleteAccount(ctx, req)
})
@ -867,7 +873,27 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
if r.deps.Privacy == nil {
return &tg.AccountPrivacyRules{Rules: tgPrivacyRules(rules), Users: []tg.UserClass{}, Chats: []tg.ChatClass{}}, nil
}
saved, err := r.deps.Privacy.SetRules(ctx, userID, domainKey, rules)
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
var (
saved domain.PrivacyRules
event domain.UpdateEvent
durableWrite bool
)
if durable, ok := r.deps.Privacy.(PrivacyDurableService); ok {
saved, event, durableWrite, err = durable.SetRulesWithUpdate(
ctx,
userID,
domainKey,
rules,
int(r.clock.Now().Unix()),
rawAuthKeyIDForOrigin(ctx),
sessionID,
)
}
if err == nil && !durableWrite {
saved, err = r.deps.Privacy.SetRules(ctx, userID, domainKey, rules)
}
if err != nil {
return nil, privacyErr(err)
}
@ -876,14 +902,36 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
return nil, err
}
r.invalidateRPCProjectionForUser(userID)
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
Key: tgPrivacyKey(saved.Key),
Rules: tgPrivacyRules(saved.Rules),
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
})
if durableWrite {
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
} else if updates, ok := r.deps.Updates.(PrivacyUpdatesService); ok {
event, _, recordErr := updates.RecordPrivacy(
ctx, authKeyID, userID, saved, rawAuthKeyIDForOrigin(ctx), sessionID,
)
if recordErr != nil {
return nil, internalErr()
}
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
} else {
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
Key: tgPrivacyKey(saved.Key),
Rules: tgPrivacyRules(saved.Rules),
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
})
}
if domainKey == domain.PrivacyKeyStatusTimestamp {
r.pushStatusPrivacyRefresh(ctx, userID)
}
return out, nil
}
@ -908,10 +956,11 @@ func (r *Router) onAccountSetAccountTTL(ctx context.Context, ttl tg.AccountDaysT
return false, tgerr400("TTL_DAYS_INVALID")
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetAccountTTL(ctx, userID, ttl.Days); err != nil {
saved, err := svc.SetAccountTTL(ctx, userID, ttl.Days)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -938,7 +987,7 @@ func (r *Router) onAccountSetGlobalPrivacySettings(ctx context.Context, settings
if err != nil {
return nil, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
return tgGlobalPrivacySettings(saved.GlobalPrivacy), nil
}
return &settings, nil
@ -965,10 +1014,11 @@ func (r *Router) onAccountSetContentSettings(ctx context.Context, req *tg.Accoun
return false, inputRequestInvalidErr()
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled); err != nil {
saved, err := svc.SetSensitiveContent(ctx, userID, req.SensitiveEnabled)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}
@ -991,10 +1041,11 @@ func (r *Router) onAccountSetContactSignUpNotification(ctx context.Context, sile
return false, internalErr()
}
if svc, ok := r.accountSettingsSvc(); ok {
if _, err := svc.SetContactSignUpSilent(ctx, userID, silent); err != nil {
saved, err := svc.SetContactSignUpSilent(ctx, userID, silent)
if err != nil {
return false, internalErr()
}
r.accountSettings.Delete(userID)
r.accountSettings.Store(userID, saved)
}
return true, nil
}

View file

@ -0,0 +1,108 @@
package rpc
import (
"context"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
)
func (r *Router) onAccountReportPeer(ctx context.Context, req *tg.AccountReportPeerRequest) (bool, error) {
if req == nil || req.Reason == nil {
return false, inputRequestInvalidErr()
}
if !utf8.ValidString(req.Message) || utf8.RuneCountInString(req.Message) > domain.MaxModerationCommentRunes {
return false, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
reason, ok := moderationReasonFromReportReason(req.Reason)
if !ok {
return false, tgerr.New(400, "REASON_INVALID")
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportPeer(
ctx, userID, domain.ModerationSourceAccountPeer, target,
reason, string(reason), req.Message, r.clock.Now(),
); err != nil {
return false, moderationReportError(err)
}
return true, nil
}
func (r *Router) onAccountReportProfilePhoto(ctx context.Context, req *tg.AccountReportProfilePhotoRequest) (bool, error) {
if req == nil || req.Reason == nil {
return false, inputRequestInvalidErr()
}
if !utf8.ValidString(req.Message) || utf8.RuneCountInString(req.Message) > domain.MaxModerationCommentRunes {
return false, limitInvalidErr()
}
photo, ok := req.PhotoID.(*tg.InputPhoto)
if !ok || photo == nil || photo.ID <= 0 {
return false, photoInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
reason, ok := moderationReasonFromReportReason(req.Reason)
if !ok {
return false, tgerr.New(400, "REASON_INVALID")
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportProfilePhoto(ctx, domain.ModerationProfilePhotoReportRequest{
ReporterUserID: userID, Target: target, PhotoID: photo.ID,
AccessHash: photo.AccessHash, FileReference: append([]byte(nil), photo.FileReference...),
Reason: reason, Comment: req.Message, CreatedAt: r.clock.Now(),
}); err != nil {
if err == domain.ErrModerationEvidenceNotFound {
return false, photoInvalidErr()
}
return false, moderationReportError(err)
}
return true, nil
}
func moderationReasonFromReportReason(reason tg.ReportReasonClass) (domain.ModerationReason, bool) {
switch reason.(type) {
case *tg.InputReportReasonSpam:
return domain.ModerationReasonSpam, true
case *tg.InputReportReasonViolence:
return domain.ModerationReasonViolence, true
case *tg.InputReportReasonPornography:
return domain.ModerationReasonPornography, true
case *tg.InputReportReasonChildAbuse:
return domain.ModerationReasonChildAbuse, true
case *tg.InputReportReasonOther:
return domain.ModerationReasonOther, true
case *tg.InputReportReasonCopyright:
return domain.ModerationReasonCopyright, true
case *tg.InputReportReasonGeoIrrelevant:
return domain.ModerationReasonGeoIrrelevant, true
case *tg.InputReportReasonFake:
return domain.ModerationReasonFake, true
case *tg.InputReportReasonIllegalDrugs:
return domain.ModerationReasonIllegalDrugs, true
case *tg.InputReportReasonPersonalDetails:
return domain.ModerationReasonPersonalDetails, true
default:
return "", false
}
}

View file

@ -0,0 +1,71 @@
package rpc
import (
"context"
"testing"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appmoderation "telesrv/internal/app/moderation"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
func TestAccountReportPeerPersistsImmutableSnapshot(t *testing.T) {
ctx := context.Background()
users := memory.NewUserStore()
reporter, err := users.Create(ctx, domain.User{
AccessHash: 101, Phone: "15550005001", FirstName: "Reporter",
})
if err != nil {
t.Fatal(err)
}
target, err := users.Create(ctx, domain.User{
AccessHash: 202, Phone: "15550005002", FirstName: "Target",
Username: "reported_target", About: "original bio",
})
if err != nil {
t.Fatal(err)
}
userService := appusers.NewService(users)
reports := memory.NewModerationReportStore()
router := New(Config{}, Deps{
Users: userService,
Moderation: appmoderation.NewService(
reports, appmoderation.WithPeerReaders(userService, nil),
),
}, zaptest.NewLogger(t), clock.System)
ok, err := router.onAccountReportPeer(
WithUserID(ctx, reporter.ID),
&tg.AccountReportPeerRequest{
Peer: &tg.InputPeerUser{
UserID: target.ID, AccessHash: target.AccessHash,
},
Reason: &tg.InputReportReasonFake{},
Message: "This profile impersonates someone.",
},
)
if err != nil || !ok {
t.Fatalf("report peer ok=%v err=%v", ok, err)
}
stored := reports.Reports()
if len(stored) != 1 ||
stored[0].ReporterUserID != reporter.ID ||
stored[0].Target != (domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}) ||
stored[0].Reason != domain.ModerationReasonFake ||
len(stored[0].Items) != 1 ||
stored[0].Items[0].Kind != domain.ModerationItemPeer {
t.Fatalf("stored report=%+v", stored)
}
if _, err := users.UpdateProfile(ctx, target.ID, "Changed", "", "changed later"); err != nil {
t.Fatal(err)
}
again, found, err := reports.GetModerationReport(ctx, stored[0].ID)
if err != nil || !found ||
string(again.Items[0].Evidence) != string(stored[0].Items[0].Evidence) {
t.Fatalf("immutable snapshot=%+v found=%v err=%v", again, found, err)
}
}

View file

@ -45,6 +45,46 @@ func (c *accountSettingsCache) Delete(userID int64) {
c.cache.Invalidate(userID)
}
func (c *accountSettingsCache) Store(userID int64, settings domain.AccountSettings) {
if c == nil || userID == 0 {
return
}
c.cache.Store(userID, settings)
}
type accountSettingsBatchReader interface {
GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error)
}
func (c *accountSettingsCache) getOrLoadBatch(
ctx context.Context,
userIDs []int64,
svc accountSettingsService,
) (map[int64]domain.AccountSettings, error) {
if len(userIDs) == 0 {
return map[int64]domain.AccountSettings{}, nil
}
return c.cache.GetOrLoadBatch(
ctx,
userIDs,
func(int64) (int64, bool) { return 0, true },
func(ctx context.Context, missing []int64) (map[int64]domain.AccountSettings, error) {
if batch, ok := svc.(accountSettingsBatchReader); ok {
return batch.GetAccountSettingsBatch(ctx, missing)
}
out := make(map[int64]domain.AccountSettings, len(missing))
for _, userID := range missing {
settings, err := svc.GetAccountSettings(ctx, userID)
if err != nil {
return nil, err
}
out[userID] = settings
}
return out, nil
},
)
}
// cachedAccountSettings 取(缓存的)账号单例设置;服务未接通返回默认。
func (r *Router) cachedAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, error) {
svc, ok := r.accountSettingsSvc()

View file

@ -64,6 +64,9 @@ func (r *Router) registerAuth(d *tlprofile.Dispatcher) {
registerRPC[*tg.AuthSendCodeRequest](d, tlprofile.SemanticMethodAuthSendCode, func(ctx context.Context, layerRequest *tg.AuthSendCodeRequest) (any, error) {
return r.onAuthSendCode(ctx, layerRequest)
})
registerRPC[*tg.AuthReportMissingCodeRequest](d, tlprofile.SemanticMethodAuthReportMissingCode, func(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (any, error) {
return r.onAuthReportMissingCode(ctx, req)
})
registerRPC[*tg.AuthResendCodeRequest](d, tlprofile.SemanticMethodAuthResendCode, func(ctx context.Context, layerRequest *tg.AuthResendCodeRequest) (any, error) {
return r.onAuthResendCode(ctx, layerRequest)
})
@ -376,6 +379,36 @@ func (r *Router) onAuthSendCode(ctx context.Context, req *tg.AuthSendCodeRequest
return r.tgSentCodeForHash(ctx, hash)
}
func (r *Router) onAuthReportMissingCode(ctx context.Context, req *tg.AuthReportMissingCodeRequest) (bool, error) {
if req == nil || r.deps.AuthDeliveryReports == nil {
return false, inputRequestInvalidErr()
}
authKeyID, authKeyOK := AuthKeyIDFrom(ctx)
sessionID, sessionOK := SessionIDFrom(ctx)
if !authKeyOK || authKeyID == ([8]byte{}) || !sessionOK || sessionID == 0 {
return false, internalErr()
}
clientType := string(ClientTypeFrom(ctx))
if _, _, err := r.deps.AuthDeliveryReports.ReportMissingCode(ctx, domain.AuthMissingCodeReportRequest{
AuthKeyID: authKeyID, SessionID: sessionID, ClientType: clientType,
Phone: req.PhoneNumber, PhoneCodeHash: req.PhoneCodeHash,
MNC: req.Mnc, CreatedAt: r.clock.Now(),
}); err != nil {
switch {
case errors.Is(err, domain.ErrPhoneCodeExpired):
return false, phoneCodeExpiredErr()
case errors.Is(err, domain.ErrPhoneCodeInvalid),
errors.Is(err, domain.ErrAuthDeliveryReportInvalid):
return false, phoneCodeInvalidErr()
case errors.Is(err, domain.ErrAuthDeliveryRateLimited):
return false, floodWaitErr(60)
default:
return false, internalErr()
}
}
return true, nil
}
func tgSentCode(hash string) tg.AuthSentCodeClass {
return tgSentCodeWithLength(hash, devCodeLength)
}

View file

@ -29,6 +29,10 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
return nil, err
}
memberIDs = createChatInviteMemberIDs(memberIDs, userID)
memberIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, memberIDs)
if err != nil {
return nil, internalErr()
}
date := int(r.clock.Now().Unix())
r.log.Debug("messages.createChat resolved users",
zap.Int("input_users", len(req.Users)),
@ -84,7 +88,7 @@ func (r *Router) onMessagesCreateChat(ctx context.Context, req *tg.MessagesCreat
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, inviteRes, cache)
})
}
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil
}
func (r *Router) onMessagesMigrateChat(ctx context.Context, chatID int64) (tg.UpdatesClass, error) {

View file

@ -311,7 +311,27 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
if err != nil {
return nil, err
}
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, int(r.clock.Now().Unix()))
// Authorize before evaluating target privacy, otherwise a non-admin could
// probe whether a target permits invites.
view, err := r.deps.Channels.ResolveChannel(ctx, userID, channelID)
if err != nil {
return nil, channelInviteErr(err)
}
if !view.Self.CanInviteUsers(view.Channel) {
return nil, channelInviteErr(domain.ErrChannelAdminRequired)
}
userIDs, missingInvitees, err := r.filterChatInvitePrivacy(ctx, userID, userIDs)
if err != nil {
return nil, internalErr()
}
date := int(r.clock.Now().Unix())
if len(userIDs) == 0 {
return &tg.MessagesInvitedUsers{
Updates: emptyInvitedUsersUpdates(date),
MissingInvitees: missingInvitees,
}, nil
}
res, err := r.deps.Channels.InviteToChannel(ctx, userID, channelID, userIDs, date)
if err != nil {
return nil, channelInviteErr(err)
}
@ -322,7 +342,7 @@ func (r *Router) onChannelsInviteToChannel(ctx context.Context, req *tg.Channels
r.pushChannelUpdates(ctx, userID, res.Channel.ID, res.Recipients, func(viewerUserID int64) *tg.Updates {
return r.channelOperationUpdatesWithPeerCache(ctx, viewerUserID, res, cache)
})
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: []tg.MissingInvitee{}}, nil
return &tg.MessagesInvitedUsers{Updates: updates, MissingInvitees: missingInvitees}, nil
}
func (r *Router) onChannelsJoinChannel(ctx context.Context, input tg.InputChannelClass) (tg.MessagesChatInviteJoinResultClass, error) {

View file

@ -350,6 +350,24 @@ func (r *Router) onChannelsDeleteMessages(ctx context.Context, req *tg.ChannelsD
return &tg.MessagesAffectedMessages{Pts: res.Channel.Pts, PtsCount: 0}, nil
}
// NotifyModerationChannelDeletion performs the online accelerator for a
// server-authority deletion already committed by the moderation action worker.
// Durable channel update events remain the offline recovery source.
func (r *Router) NotifyModerationChannelDeletion(ctx context.Context, res domain.DeleteChannelMessagesResult) {
if r == nil || res.Event.Pts == 0 {
return
}
r.enqueueChannelFanout(ctx, channelFanoutMembers, domain.OfficialSystemUserID, res.Channel.ID, res.Event.Pts, res.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, res.Channel, res.Event)
})
for _, cascade := range res.DiscussionDeletes {
cascade := cascade
r.enqueueChannelFanout(ctx, channelFanoutMembers, domain.OfficialSystemUserID, cascade.Channel.ID, cascade.Event.Pts, cascade.Recipients, func(_ context.Context, viewerUserID int64) *tg.Updates {
return r.channelDeleteMessagesUpdates(viewerUserID, cascade.Channel, cascade.Event)
})
}
}
func (r *Router) onChannelsDeleteHistory(ctx context.Context, req *tg.ChannelsDeleteHistoryRequest) (tg.UpdatesClass, error) {
if r.deps.Channels == nil {
return &tg.Updates{Date: int(r.clock.Now().Unix())}, nil

View file

@ -3,7 +3,10 @@ package rpc
import (
"github.com/iamxvbaba/td/tg"
"strings"
apptelemetry "telesrv/internal/app/clienttelemetry"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
"testing"
"time"
)
@ -11,6 +14,14 @@ import (
func TestTDesktopPassiveChannelStubs(t *testing.T) {
f := newRPCChannelFixture(t)
r := f.router
moderationReports := memory.NewModerationReportStore()
telemetryEvents := memory.NewClientTelemetryStore()
r.deps.ClientTelemetry = apptelemetry.NewService(telemetryEvents)
r.deps.Moderation = appmoderation.NewService(
moderationReports,
appmoderation.WithMessageReaders(nil, r.deps.Channels),
appmoderation.WithPeerReaders(r.deps.Users, r.deps.Channels),
)
owner := f.user(41, "15550002101", "Owner")
friend := f.user(42, "15550002102", "Friend")
invited := f.user(43, "15550002103", "Invited")
@ -470,6 +481,9 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if _, ok := reported.(*tg.ReportResultReported); !ok {
t.Fatalf("messages.report spam = %#v, want reported", reported)
}
if got := moderationReports.Reports(); len(got) != 2 {
t.Fatalf("moderation reports = %+v, want peer-spam and message reports", got)
}
if _, err := r.onMessagesReport(ownerCtx, &tg.MessagesReportRequest{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -477,13 +491,6 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err == nil || !strings.Contains(err.Error(), "OPTION_INVALID") {
t.Fatalf("messages.report invalid option err = %v, want OPTION_INVALID", err)
}
if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{
Peer: inputPeerChannel(channel),
ID: 1,
ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
}); err != nil || !ok {
t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err)
}
if ok, err := r.onMessagesReportMessagesDelivery(ownerCtx, &tg.MessagesReportMessagesDeliveryRequest{
Peer: inputPeerChannel(channel),
ID: []int{1},
@ -503,11 +510,45 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
}); err != nil || !ok {
t.Fatalf("messages.reportReadMetrics = ok %v err %v, want true nil", ok, err)
}
if events := telemetryEvents.Events(); len(events) != 2 ||
events[0].Kind != domain.ClientTelemetryMessageDelivery ||
events[1].Kind != domain.ClientTelemetryReadMetrics ||
len(moderationReports.Reports()) != 2 {
t.Fatalf("telemetry=%+v moderation=%+v, want separate durable streams",
events, moderationReports.Reports())
}
if ok, err := r.onMessagesReportMusicListen(ownerCtx, &tg.MessagesReportMusicListenRequest{
ID: &tg.InputDocument{ID: 1, AccessHash: 2},
ListenedDuration: 1,
}); err != nil || !ok {
t.Fatalf("messages.reportMusicListen = ok %v err %v, want true nil", ok, err)
}); err == nil || ok || !strings.Contains(err.Error(), "DOCUMENT_INVALID") {
t.Fatalf("messages.reportMusicListen = ok %v err %v, want DOCUMENT_INVALID", ok, err)
}
if _, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("unseen-ad"),
}); err == nil || !strings.Contains(err.Error(), "RANDOM_ID_INVALID") {
t.Fatalf("unseen sponsored report err=%v, want RANDOM_ID_INVALID", err)
}
impression, err := domain.NewSponsoredMessageImpression(
owner.ID, []byte("ad"),
domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID},
0, []byte(`{"schema_version":1,"text":"test sponsored message"}`),
time.Now().UTC(), time.Now().UTC().Add(time.Hour),
)
if err != nil {
t.Fatal(err)
}
if _, _, err := moderationReports.CreateSponsoredMessageImpression(ownerCtx, impression); err != nil {
t.Fatal(err)
}
sponsoredOptions, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("ad"),
})
if err != nil {
t.Fatalf("messages.reportSponsoredMessage options: %v", err)
}
if choices, ok := sponsoredOptions.(*tg.ChannelsSponsoredMessageReportResultChooseOption); !ok ||
len(choices.Options) == 0 {
t.Fatalf("sponsored options=%#v, want chooseOption", sponsoredOptions)
}
sponsoredReport, err := r.onMessagesReportSponsoredMessage(ownerCtx, &tg.MessagesReportSponsoredMessageRequest{
RandomID: []byte("ad"),
@ -577,6 +618,13 @@ func TestTDesktopPassiveChannelStubs(t *testing.T) {
if _, err := r.onMessagesSendReaction(friendCtx, friendReactionReq); err != nil {
t.Fatalf("messages.sendReaction by friend: %v", err)
}
if ok, err := r.onMessagesReportReaction(ownerCtx, &tg.MessagesReportReactionRequest{
Peer: inputPeerChannel(channel),
ID: viewedID,
ReactionPeer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash},
}); err != nil || !ok {
t.Fatalf("messages.reportReaction = ok %v err %v, want true nil", ok, err)
}
unreadReactions, err := r.onMessagesGetUnreadReactions(ownerCtx, &tg.MessagesGetUnreadReactionsRequest{
Peer: inputPeerChannel(channel),
Limit: 10,

View file

@ -206,9 +206,21 @@ func (r *Router) onChannelsReportAntiSpamFalsePositive(ctx context.Context, req
if req.MsgID <= 0 || req.MsgID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
if _, _, err := r.channelChangeInfoView(ctx, req.Channel); err != nil {
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if !view.Channel.Megagroup || view.Channel.Broadcast {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportAntiSpamFalsePositive(
ctx, userID, view.Channel.ID, req.MsgID, r.clock.Now(),
); err != nil {
return false, moderationReportError(err)
}
return true, nil
}

View file

@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/iamxvbaba/td/clock"
"github.com/iamxvbaba/td/tg"
@ -12,6 +13,7 @@ import (
appchannels "telesrv/internal/app/channels"
appdialogs "telesrv/internal/app/dialogs"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/app/readmodel"
appusers "telesrv/internal/app/users"
"telesrv/internal/domain"
@ -598,9 +600,17 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 61, Phone: "15550002301", FirstName: "Owner"})
requester, _ := userStore.Create(ctx, domain.User{AccessHash: 62, Phone: "15550002302", FirstName: "Requester"})
channelStore := memory.NewChannelStore()
moderationStore := memory.NewModerationReportStore()
userService := appusers.NewService(userStore)
channelService := appchannels.NewService(channelStore)
r := New(Config{}, Deps{
Users: appusers.NewService(userStore),
Channels: appchannels.NewService(channelStore),
Users: userService,
Channels: channelService,
Moderation: appmoderation.NewService(
moderationStore,
appmoderation.WithMessageReaders(nil, channelService),
appmoderation.WithPeerReaders(userService, channelService),
),
}, zaptest.NewLogger(t), clock.System)
created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
Title: "Public Team",
@ -621,6 +631,23 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
t.Fatalf("send seed message: %v", err)
}
msgID := sent.(*tg.Updates).Updates[0].(*tg.UpdateMessageID).ID
if ok, err := r.onChannelsReportAntiSpamFalsePositive(
WithUserID(ctx, owner.ID),
&tg.ChannelsReportAntiSpamFalsePositiveRequest{Channel: input, MsgID: msgID},
); err == nil || ok || !strings.Contains(err.Error(), "MESSAGE_ID_INVALID") {
t.Fatalf("report anti-spam without native decision = ok %v err %v, want MESSAGE_ID_INVALID", ok, err)
}
antiSpamDecision, err := domain.NewChannelAntiSpamDecision(
channel.ID, msgID, owner.ID,
[]byte(`{"schema_version":1,"source":"native_antispam"}`),
time.Now().UTC(),
)
if err != nil {
t.Fatal(err)
}
if _, _, err := moderationStore.CreateChannelAntiSpamDecision(ctx, antiSpamDecision); err != nil {
t.Fatal(err)
}
okUsername, err := r.onChannelsCheckUsername(WithUserID(ctx, owner.ID), &tg.ChannelsCheckUsernameRequest{
Channel: input,

View file

@ -18,6 +18,12 @@ func validateEmptyChannelStickerSet(stickerset tg.InputStickerSetClass) error {
}
func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsReportSpamRequest) (bool, error) {
if req == nil {
return false, inputRequestInvalidErr()
}
if len(req.ID) == 0 {
return false, tgerr.New(400, "MESSAGE_ID_REQUIRED")
}
if len(req.ID) > maxChannelReportMessageIDs {
return false, limitInvalidErr()
}
@ -26,12 +32,30 @@ func (r *Router) onChannelsReportSpam(ctx context.Context, req *tg.ChannelsRepor
return false, messageIDInvalidErr()
}
}
if _, _, err := r.channelView(ctx, req.Channel); err != nil {
userID, view, err := r.channelChangeInfoView(ctx, req.Channel)
if err != nil {
return false, err
}
if peer, ok := r.domainPeerFromInputPeer(0, req.Participant); !ok || peer.Type != domain.PeerTypeUser || peer.ID == 0 {
if !view.Channel.Megagroup {
return false, channelInvalidErr(domain.ErrChannelInvalid)
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Participant)
if err != nil {
return false, err
}
if peer.Type != domain.PeerTypeUser || peer.ID == 0 {
return false, peerIDInvalidErr()
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportChannelSpam(ctx, domain.ModerationChannelSpamReportRequest{
ReporterUserID: userID, ChannelID: view.Channel.ID,
ParticipantUserID: peer.ID, MessageIDs: req.ID,
CreatedAt: r.clock.Now(),
}); err != nil {
return false, moderationReportError(err)
}
return true, nil
}

View file

@ -540,6 +540,7 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
}
}
}
statusVisible := r.statusTimestampVisibleToViewer(ctx, contactUserIDs, userID)
seen = make(map[int64]struct{}, len(list.Contacts))
for _, contact := range list.Contacts {
id := contact.User.ID
@ -555,9 +556,19 @@ func (r *Router) onContactsGetStatuses(ctx context.Context) ([]tg.ContactStatus,
u.LastSeenAt = current.LastSeenAt
u.Status = current.Status
}
status := u.Status
if statusVisible[id] {
status = r.userPresenceStatusForUser(u)
} else {
switch status.Kind {
case domain.UserStatusRecently, domain.UserStatusLastWeek, domain.UserStatusLastMonth, domain.UserStatusEmpty:
default:
status = domain.ApproximateUserStatus(u.LastSeenAt, int(r.clock.Now().Unix()))
}
}
out = append(out, tg.ContactStatus{
UserID: id,
Status: tgUserStatus(r.userPresenceStatusForUser(u)),
Status: tgUserStatus(status),
})
}
return out, nil

View file

@ -543,16 +543,6 @@ func tgChannel(viewerUserID int64, ch domain.Channel, self *domain.ChannelMember
return out
}
// channelAboutWithModerationWarning decorates the projected channel/supergroup
// About with the scam/fake warning when set (group vs channel wording).
func channelAboutWithModerationWarning(ch domain.Channel) string {
scamText, fakeText := defaultScamWarningChannel, defaultFakeWarningChannel
if ch.Megagroup && !ch.Broadcast {
scamText, fakeText = defaultScamWarningGroup, defaultFakeWarningGroup
}
return aboutWithModerationWarning(ch.About, scamText, fakeText, ch.Scam, ch.Fake)
}
func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.ChannelFull {
ch := view.Channel
full := &tg.ChannelFull{
@ -563,13 +553,15 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
CanSetUsername: view.Self.Role == domain.ChannelRoleCreator,
CanDeleteChannel: view.Self.Role == domain.ChannelRoleCreator,
ID: ch.ID,
About: channelAboutWithModerationWarning(ch),
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,
ChatPhoto: tgChannelChatPhotoFull(ch),
NotifySettings: *tdesktop.NotifySettings(),
Pts: ch.Pts,
// Official clients render localized warnings from scam/fake flags.
// About remains the owner's unmodified description.
About: ch.About,
ReadInboxMaxID: view.Dialog.ReadInboxMaxID,
ReadOutboxMaxID: view.Dialog.ReadOutboxMaxID,
UnreadCount: view.Dialog.UnreadCount,
ChatPhoto: tgChannelChatPhotoFull(ch),
NotifySettings: *tdesktop.NotifySettings(),
Pts: ch.Pts,
}
if ch.ParticipantsCount > 0 {
full.SetParticipantsCount(ch.ParticipantsCount)

View file

@ -1,81 +0,0 @@
package rpc
import (
"strings"
"sync/atomic"
)
// Scam/fake profile warnings surfaced in the full-profile About text.
//
// Telegram Desktop only ships the SCAM/FAKE badge strings and renders no
// warning paragraph, while iOS/Android show a localized warning. To make the
// warning visible on every client, the server injects it into the projected
// getFullUser/getFullChannel About field. Injection is non-destructive: the
// stored bio/description is never overwritten, only the response is decorated,
// so clearing the flag restores the original text and the warning survives the
// owner editing their bio/description (it is re-applied from the flag on every
// read).
//
// The text is server-provided (clients cannot localize it). Operators override
// it via TELESRV_SCAM_WARNING / TELESRV_FAKE_WARNING; when unset the built-in
// per-peer-type English defaults are used. scam takes precedence over fake.
const (
defaultScamWarningUser = "\u26A0\uFE0F Warning: Many users reported this account as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningUser = "\u26A0\uFE0F Warning: Many users reported that this account impersonates a famous person or organization."
defaultScamWarningChannel = "\u26A0\uFE0F Warning: Many users reported this channel as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningChannel = "\u26A0\uFE0F Warning: Many users reported that this channel impersonates a famous person or organization."
defaultScamWarningGroup = "\u26A0\uFE0F Warning: Many users reported this group as a scam. Please be careful, especially if it asks you for money."
defaultFakeWarningGroup = "\u26A0\uFE0F Warning: Many users reported that this group impersonates a famous person or organization."
)
// moderationWarningOverrides holds the operator-configured texts. They are set
// once at startup (SetModerationWarnings) before any request is served, and
// read on the hot path; atomic.Pointer keeps that race-free without locking.
var moderationWarningOverrides atomic.Pointer[moderationWarningConfig]
type moderationWarningConfig struct {
scam string
fake string
}
// SetModerationWarnings installs operator overrides for the scam/fake profile
// warnings. Empty strings keep the built-in per-peer-type defaults. A single
// override applies to every peer type (user/channel/group).
func SetModerationWarnings(scam, fake string) {
moderationWarningOverrides.Store(&moderationWarningConfig{
scam: strings.TrimSpace(scam),
fake: strings.TrimSpace(fake),
})
}
func moderationOverride() moderationWarningConfig {
if cfg := moderationWarningOverrides.Load(); cfg != nil {
return *cfg
}
return moderationWarningConfig{}
}
// aboutWithModerationWarning prepends the scam/fake warning to a profile About.
// It returns about unchanged when neither flag is set. The operator override
// wins over the per-type default; scam wins over fake when both are set.
func aboutWithModerationWarning(about, scamDefault, fakeDefault string, scam, fake bool) string {
override := moderationOverride()
warning := ""
switch {
case scam:
if warning = override.scam; warning == "" {
warning = scamDefault
}
case fake:
if warning = override.fake; warning == "" {
warning = fakeDefault
}
}
if warning == "" {
return about
}
if about = strings.TrimSpace(about); about == "" {
return warning
}
return warning + "\n\n" + about
}

View file

@ -242,6 +242,14 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
return nil
}
return &tg.UpdateUserEmojiStatus{UserID: event.UserID, EmojiStatus: tgUserEmojiStatusValue(event.EmojiStatus)}
case domain.UpdateEventPrivacy:
if event.Privacy.OwnerUserID == 0 || event.Privacy.Key == "" || len(event.Privacy.Rules) == 0 {
return nil
}
return &tg.UpdatePrivacy{
Key: tgPrivacyKey(event.Privacy.Key),
Rules: tgPrivacyRules(event.Privacy.Rules),
}
case domain.UpdateEventChannelState:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil

View file

@ -21,6 +21,8 @@ func tgSelfUser(u domain.User) *tg.User {
Phone: u.Phone,
Self: true,
Verified: u.Verified,
Scam: u.Scam,
Fake: u.Fake,
Support: u.Support,
Contact: u.Contact,
MutualContact: u.Mutual,

View file

@ -47,6 +47,14 @@ type AuthService interface {
ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
}
type AuthDeliveryReportService interface {
ReportMissingCode(ctx context.Context, req domain.AuthMissingCodeReportRequest) (domain.AuthDeliveryReport, bool, error)
}
type ClientTelemetryService interface {
Record(ctx context.Context, userID int64, kind domain.ClientTelemetryKind, peer domain.Peer, subjectIDs []int64, payload any, createdAt time.Time) (domain.ClientTelemetryEvent, bool, error)
}
// SessionBinder 抽象登录后 session 与 user 的在线绑定。
//
// MTProto session 的完整身份是 raw auth_key_id + session_id。所有定位单个 session
@ -470,6 +478,26 @@ type UserEmojiStatusUpdatesService interface {
RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// PrivacyUpdatesService is the fallback durable extension for stores that do
// not support the atomic privacy+event write boundary (mainly memory tests).
type PrivacyUpdatesService interface {
RecordPrivacy(ctx context.Context, stateAuthKeyID [8]byte, userID int64, rules domain.PrivacyRules, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// PrivacyDurableService is implemented by the production privacy service. Its
// successful path commits rules+pts+event+dispatch in one transaction.
type PrivacyDurableService interface {
SetRulesWithUpdate(
ctx context.Context,
ownerUserID int64,
key domain.PrivacyKey,
rules []domain.PrivacyRule,
date int,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (saved domain.PrivacyRules, event domain.UpdateEvent, durable bool, err error)
}
// ContactsService 抽象通讯录查询。
type ContactsService interface {
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)
@ -669,6 +697,7 @@ type ChannelsService interface {
VoteMessagePoll(ctx context.Context, userID int64, req domain.VoteChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
CloseMessagePoll(ctx context.Context, userID int64, req domain.CloseChannelMessagePollRequest) (domain.ChannelMessagePollResult, error)
ListMessageReactions(ctx context.Context, userID int64, req domain.ChannelMessageReactionsListRequest) (domain.ChannelMessageReactionsList, error)
FindMessageReaction(ctx context.Context, userID int64, req domain.ChannelMessageReactionLookupRequest) (domain.ChannelMessageReactionLookup, bool, error)
TopReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
RecentReactions(ctx context.Context, userID int64, limit int) ([]domain.MessageReaction, error)
ClearRecentReactions(ctx context.Context, userID int64) error
@ -875,9 +904,28 @@ type EphemeralService interface {
ReportTarget(ctx context.Context, userID int64, device domain.EphemeralDevice, peer domain.Peer, id int) (domain.EphemeralMessage, error)
}
// ModerationService accepts only final report choices. Implementations must
// validate and snapshot referenced evidence, then durably commit the immutable
// submission before returning success.
type ModerationService interface {
ReportPeer(ctx context.Context, reporterUserID int64, source domain.ModerationReportSource, target domain.Peer, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error)
ReportMessages(ctx context.Context, req domain.ModerationMessageReportRequest) (domain.ModerationReport, bool, error)
ReportProfilePhoto(ctx context.Context, req domain.ModerationProfilePhotoReportRequest) (domain.ModerationReport, bool, error)
ReportChannelSpam(ctx context.Context, req domain.ModerationChannelSpamReportRequest) (domain.ModerationReport, bool, error)
ReportReaction(ctx context.Context, req domain.ModerationReactionReportRequest) (domain.ModerationReport, bool, error)
ReportEncryptedSpam(ctx context.Context, reporterUserID int64, chat domain.SecretChat, createdAt time.Time) (domain.ModerationReport, bool, error)
ReportStories(ctx context.Context, req domain.ModerationStoryReportRequest) (domain.ModerationReport, bool, error)
ReportEphemeral(ctx context.Context, reporterUserID int64, target domain.EphemeralMessage, reason domain.ModerationReason, option, comment string, createdAt time.Time) (domain.ModerationReport, bool, error)
SponsoredImpression(ctx context.Context, userID int64, randomID []byte, now time.Time) (domain.SponsoredMessageImpression, error)
ReportSponsored(ctx context.Context, userID int64, randomID []byte, reason domain.ModerationReason, option string, now time.Time) (domain.ModerationReport, bool, error)
ReportAntiSpamFalsePositive(ctx context.Context, reporterUserID, channelID int64, messageID int, now time.Time) (domain.ModerationReport, bool, error)
}
// Deps 按业务域注入服务接口。各域的 handler 注册见对应文件auth.go / users.go / updates.go
type Deps struct {
Auth AuthService
Auth AuthService
AuthDeliveryReports AuthDeliveryReportService
ClientTelemetry ClientTelemetryService
// AuthKeySessionLayers is the protocol-only durable ordering boundary for
// explicit invokeWithLayer evidence. Production must wire the same auth-key
// store used by the MTProto edge; nil is reserved for isolated router tests.
@ -889,7 +937,7 @@ type Deps struct {
AICompose AIComposeService
Ephemeral EphemeralService
EphemeralPush store.EphemeralPushBroker
EphemeralReports store.EphemeralReportStore
Moderation ModerationService
Users UsersService
TelegramLogin TelegramLoginService
Updates UpdatesService

View file

@ -93,12 +93,24 @@ func (r *Router) onMessagesReceivedQueue(ctx context.Context, maxQts int) ([]int
return []int64{}, nil
}
// onMessagesReportEncryptedSpam 纯记录:服务端不自动 discard、不拉黑、不产生 update
// discard/block 由客户端独立 RPC 完成。P1 接受并回 true。
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, _ tg.InputEncryptedChat) (bool, error) {
if _, err := r.secretChatRequireUser(ctx); err != nil {
// onMessagesReportEncryptedSpam persists an immutable chat-metadata snapshot;
// the server remains unable to inspect encrypted message plaintext. Reporting
// does not discard or block the chat and emits no update.
func (r *Router) onMessagesReportEncryptedSpam(ctx context.Context, peer tg.InputEncryptedChat) (bool, error) {
if r.deps.SecretChats == nil || r.deps.Moderation == nil {
return false, notImplementedErr()
}
userID, err := r.secretChatRequireUser(ctx)
if err != nil {
return false, err
}
chat, _, _, err := r.resolveSecretChatPeer(ctx, userID, peer)
if err != nil {
return false, err
}
if _, _, err := r.deps.Moderation.ReportEncryptedSpam(ctx, userID, chat, r.clock.Now()); err != nil {
return false, moderationReportError(err)
}
return true, nil
}

View file

@ -6,8 +6,10 @@ import (
"github.com/iamxvbaba/td/tg"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/postresponse"
"telesrv/internal/store/memory"
)
// acceptChat 跑完 request→accept返回 normal 态密聊 id 与 participant 视角 access_hash。
@ -46,6 +48,33 @@ func encNewMessagePayload(t *testing.T, rec phonePushRecord) *tg.UpdateNewEncryp
return upd
}
func TestReportEncryptedSpamPersistsMetadataOnly(t *testing.T) {
f := newEncryptedFixture(t)
chatID, participantAccessHash := f.acceptChat(t)
reports := memory.NewModerationReportStore()
f.router.deps.Moderation = appmoderation.NewService(reports)
ok, err := f.router.onMessagesReportEncryptedSpam(
f.participantCtx(),
tg.InputEncryptedChat{ChatID: chatID, AccessHash: participantAccessHash},
)
if err != nil || !ok {
t.Fatalf("report encrypted spam ok=%v err=%v", ok, err)
}
stored := reports.Reports()
if len(stored) != 1 ||
stored[0].Source != domain.ModerationSourceEncryptedSpam ||
stored[0].ReporterUserID != f.participant.ID ||
stored[0].Target != (domain.Peer{Type: domain.PeerTypeUser, ID: f.admin.ID}) ||
len(stored[0].Items) != 1 ||
stored[0].Items[0].Kind != domain.ModerationItemEncryptedChat {
t.Fatalf("stored encrypted report=%+v", stored)
}
if string(stored[0].Items[0].Evidence) == "" ||
string(stored[0].Items[0].Evidence) == "plaintext" {
t.Fatalf("encrypted metadata evidence=%s", stored[0].Items[0].Evidence)
}
}
func TestSendEncryptedRPCFlow(t *testing.T) {
f := newEncryptedFixture(t)
chatID, _ := f.acceptChat(t)

View file

@ -245,13 +245,18 @@ func (r *Router) onEphemeralReportMessage(ctx context.Context, request *tg.Ephem
if _, final := result.(*tg.ReportResultReported); !final {
return result, nil
}
if r.deps.EphemeralReports == nil {
reason, ok := moderationReasonForReportOption(string(request.Option))
if !ok {
return nil, tgerr.New(400, "OPTION_INVALID")
}
if r.deps.Moderation == nil {
return nil, internalErr()
}
report := domain.NewEphemeralAbuseReport(userID, string(request.Option), request.Message, target, r.clock.Now())
if _, err := r.deps.EphemeralReports.CreateEphemeralReport(ctx, report); err != nil {
if _, _, err := r.deps.Moderation.ReportEphemeral(
ctx, userID, target, reason, string(request.Option), request.Message, r.clock.Now(),
); err != nil {
r.log.Warn("persist ephemeral abuse report", zap.Int64("reporter_user_id", userID), zap.Int64("channel_id", peer.ID), zap.Int("ephemeral_message_id", request.ID), zap.Error(err))
return nil, internalErr()
return nil, moderationReportError(err)
}
return result, nil
}

View file

@ -2,6 +2,7 @@ package rpc
import (
"context"
"strings"
"testing"
"time"
@ -9,6 +10,7 @@ import (
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap/zaptest"
appmoderation "telesrv/internal/app/moderation"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -49,13 +51,14 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
OriginDevice: domain.EphemeralDevice{UserID: userID, BusinessAuthKeyID: authKey, SessionID: 99},
PayloadHash: [32]byte{9}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(domain.EphemeralMessageRetention),
}
reports := memory.NewEphemeralReportStore()
reports := memory.NewModerationReportStore()
moderation := appmoderation.NewService(reports)
ephemeral := &ephemeralReportService{target: target}
channels := &ephemeralReportChannels{view: domain.ChannelView{
Channel: domain.Channel{ID: channelID, AccessHash: 42, Megagroup: true},
Self: domain.ChannelMember{ChannelID: channelID, UserID: userID, Status: domain.ChannelMemberActive},
}}
router := New(Config{}, Deps{Ephemeral: ephemeral, EphemeralReports: reports, Channels: channels}, zaptest.NewLogger(t), clock.System)
router := New(Config{}, Deps{Ephemeral: ephemeral, Moderation: moderation, Channels: channels}, zaptest.NewLogger(t), clock.System)
ctx := WithSessionID(WithAuthKeyID(WithUserID(context.Background(), userID), authKey), 99)
request := &tg.EphemeralReportMessageRequest{
Peer: &tg.InputPeerChannel{ChannelID: channelID, AccessHash: 42}, ID: target.ID,
@ -87,7 +90,9 @@ func TestEphemeralReportPersistsOnlyFinalIdempotentEvidence(t *testing.T) {
}
}
stored := reports.Reports()
if len(stored) != 1 || stored[0].Evidence.Content.Message != "abuse" || stored[0].Comment != "evidence comment" {
if len(stored) != 1 || stored[0].Source != domain.ModerationSourceEphemeral ||
stored[0].Comment != "evidence comment" || len(stored[0].Items) != 1 ||
!strings.Contains(string(stored[0].Items[0].Evidence), `"Message":"abuse"`) {
t.Fatalf("reports=%+v", stored)
}
if ephemeral.calls != 4 {

View file

@ -382,6 +382,9 @@ func callProtocolFlagsInvalidErr() error {
func userIsBlockedErr() error { return tgerr.New(400, "USER_IS_BLOCKED") }
func userPrivacyRestrictedErr() error { return tgerr.New(403, "USER_PRIVACY_RESTRICTED") }
func chatSendVoicesForbiddenErr() error {
return tgerr.New(403, "CHAT_SEND_VOICES_FORBIDDEN")
}
// signalingDataInvalidErr 表示 phone.sendSignalingData 载荷超限或非法。
func signalingDataInvalidErr() error { return tgerr.New(400, "DATA_INVALID") }

View file

@ -18,7 +18,7 @@ const (
maxReactionVector = 16
maxReactionListOffset = 128
maxReportOptionLength = 32
maxReportCommentLength = 1024
maxReportCommentLength = domain.MaxModerationCommentRunes
maxReportRandomIDLength = 128
maxReadMetrics = 100
maxBusinessConnIDLength = 128

View file

@ -14,24 +14,52 @@ func reportResultForOption(option string) (tg.ReportResultClass, error) {
return &tg.ReportResultChooseOption{
Title: "Report",
Options: []tg.MessageReportOption{
{Text: "Spam", Option: []byte("spam")},
{Text: "Scam or spam", Option: []byte("spam")},
{Text: "Violence", Option: []byte("violence")},
{Text: "Illegal goods", Option: []byte("illegal_goods")},
{Text: "Pornography", Option: []byte("pornography")},
{Text: "Child abuse", Option: []byte("child_abuse")},
{Text: "Personal data", Option: []byte("personal_data")},
{Text: "Illegal drugs", Option: []byte("illegal_drugs")},
{Text: "Personal details", Option: []byte("personal_details")},
{Text: "Copyright", Option: []byte("copyright")},
{Text: "Fake or impersonation", Option: []byte("fake")},
{Text: "Other", Option: []byte("other")},
},
}, nil
case "other":
return &tg.ReportResultAddComment{Optional: false, Option: []byte("other:comment")}, nil
case "spam", "violence", "illegal_goods", "child_abuse", "personal_data", "copyright", "other:comment":
case "spam", "violence", "pornography", "child_abuse", "illegal_drugs",
"personal_details", "copyright", "fake", "other:comment":
return &tg.ReportResultReported{}, nil
default:
return nil, tgerr.New(400, "OPTION_INVALID")
}
}
func moderationReasonForReportOption(option string) (domain.ModerationReason, bool) {
switch option {
case "spam":
return domain.ModerationReasonSpam, true
case "violence":
return domain.ModerationReasonViolence, true
case "pornography":
return domain.ModerationReasonPornography, true
case "child_abuse":
return domain.ModerationReasonChildAbuse, true
case "illegal_drugs":
return domain.ModerationReasonIllegalDrugs, true
case "personal_details":
return domain.ModerationReasonPersonalDetails, true
case "copyright":
return domain.ModerationReasonCopyright, true
case "fake":
return domain.ModerationReasonFake, true
case "other:comment":
return domain.ModerationReasonOther, true
default:
return "", false
}
}
func (r *Router) inputPeerForDomainPeer(ctx context.Context, currentUserID int64, peer domain.Peer) tg.InputPeerClass {
switch peer.Type {
case domain.PeerTypeUser:

View file

@ -2,20 +2,43 @@ package rpc
import (
"context"
"errors"
"sort"
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"telesrv/internal/domain"
"unicode/utf8"
)
type readMetricTelemetry struct {
MessageID int `json:"message_id"`
ViewID int64 `json:"view_id"`
TimeInViewMS int `json:"time_in_view_ms"`
ActiveTimeInViewMS int `json:"active_time_in_view_ms"`
HeightToViewportRatioPermille int `json:"height_to_viewport_ratio_permille"`
SeenRangeRatioPermille int `json:"seen_range_ratio_permille"`
}
func (r *Router) onMessagesReportSpam(ctx context.Context, peer tg.InputPeerClass) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer); err != nil {
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, peer)
if err != nil {
return false, err
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportPeer(
ctx, userID, domain.ModerationSourceMessagesSpam, target,
domain.ModerationReasonSpam, "spam", "", r.clock.Now(),
); err != nil {
return false, moderationReportError(err)
}
return true, nil
}
@ -24,11 +47,12 @@ func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportReq
if err != nil {
return nil, internalErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if len(req.ID) == 0 {
return nil, tgerr.New(400, "MESSAGE_REQUIRED")
return nil, tgerr.New(400, "MESSAGE_ID_REQUIRED")
}
if len(req.ID) > maxGetMessagesIDs || len(req.Option) > maxReportOptionLength || utf8.RuneCountInString(req.Message) > maxReportCommentLength {
return nil, limitInvalidErr()
@ -38,7 +62,28 @@ func (r *Router) onMessagesReport(ctx context.Context, req *tg.MessagesReportReq
return nil, messageIDInvalidErr()
}
}
return reportResultForOption(string(req.Option))
result, err := reportResultForOption(string(req.Option))
if err != nil {
return nil, err
}
if _, final := result.(*tg.ReportResultReported); !final {
return result, nil
}
reason, ok := moderationReasonForReportOption(string(req.Option))
if !ok {
return nil, tgerr.New(400, "OPTION_INVALID")
}
if r.deps.Moderation == nil {
return nil, internalErr()
}
if _, _, err := r.deps.Moderation.ReportMessages(ctx, domain.ModerationMessageReportRequest{
ReporterUserID: userID, Target: target, MessageIDs: req.ID,
Reason: reason, Option: string(req.Option), Comment: req.Message,
CreatedAt: r.clock.Now(),
}); err != nil {
return nil, moderationReportError(err)
}
return result, nil
}
func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesReportReactionRequest) (bool, error) {
@ -49,21 +94,50 @@ func (r *Router) onMessagesReportReaction(ctx context.Context, req *tg.MessagesR
if req.ID <= 0 || req.ID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
target, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer); err != nil {
reactor, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.ReactionPeer)
if err != nil {
return false, err
}
if reactor.Type != domain.PeerTypeUser || reactor.ID <= 0 {
return false, peerIDInvalidErr()
}
if r.deps.Moderation == nil {
return false, internalErr()
}
if _, _, err := r.deps.Moderation.ReportReaction(ctx, domain.ModerationReactionReportRequest{
ReporterUserID: userID, Target: target, MessageID: req.ID,
ReactorUserID: reactor.ID, CreatedAt: r.clock.Now(),
}); err != nil {
return false, moderationReportError(err)
}
return true, nil
}
func moderationReportError(err error) error {
switch {
case errors.Is(err, domain.ErrModerationEvidenceNotFound):
return messageIDInvalidErr()
case errors.Is(err, domain.ErrModerationPermissionDenied):
return tgerr.New(403, "CHAT_ADMIN_REQUIRED")
case errors.Is(err, domain.ErrModerationRateLimited):
return floodWaitErr(60)
case errors.Is(err, domain.ErrModerationReportInvalid):
return inputRequestInvalidErr()
default:
return internalErr()
}
}
func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.MessagesReportMessagesDeliveryRequest) (bool, error) {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if len(req.ID) > maxGetMessagesIDs {
if len(req.ID) == 0 || len(req.ID) > maxGetMessagesIDs {
return false, limitInvalidErr()
}
for _, msgID := range req.ID {
@ -71,9 +145,26 @@ func (r *Router) onMessagesReportMessagesDelivery(ctx context.Context, req *tg.M
return false, messageIDInvalidErr()
}
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.validateTelemetryMessageIDs(ctx, userID, peer, req.ID); err != nil {
return false, err
}
ids := messageIDs64(req.ID)
if r.deps.ClientTelemetry == nil {
return false, internalErr()
}
if _, _, err := r.deps.ClientTelemetry.Record(
ctx, userID, domain.ClientTelemetryMessageDelivery, peer, ids,
struct {
Push bool `json:"push"`
}{Push: req.Push},
r.clock.Now(),
); err != nil {
return false, clientTelemetryError(err)
}
return true, nil
}
@ -82,46 +173,210 @@ func (r *Router) onMessagesReportReadMetrics(ctx context.Context, req *tg.Messag
if err != nil {
return false, internalErr()
}
if len(req.Metrics) > maxReadMetrics {
if len(req.Metrics) == 0 || len(req.Metrics) > maxReadMetrics {
return false, limitInvalidErr()
}
ids := make([]int, 0, len(req.Metrics))
payload := make([]readMetricTelemetry, 0, len(req.Metrics))
for _, metric := range req.Metrics {
if metric.MsgID <= 0 || metric.MsgID > domain.MaxMessageBoxID {
return false, messageIDInvalidErr()
}
if metric.TimeInViewMs < 0 || metric.ActiveTimeInViewMs < 0 || metric.HeightToViewportRatioPermille < 0 || metric.SeenRangeRatioPermille < 0 {
if metric.ViewID == 0 || metric.TimeInViewMs < 0 ||
metric.TimeInViewMs > 24*60*60*1000 ||
metric.ActiveTimeInViewMs < 0 ||
metric.ActiveTimeInViewMs > metric.TimeInViewMs ||
metric.HeightToViewportRatioPermille < 0 ||
metric.HeightToViewportRatioPermille > 1_000_000 ||
metric.SeenRangeRatioPermille < 0 ||
metric.SeenRangeRatioPermille > 1000 {
return false, limitInvalidErr()
}
ids = append(ids, metric.MsgID)
payload = append(payload, readMetricTelemetry{
MessageID: metric.MsgID, ViewID: metric.ViewID,
TimeInViewMS: metric.TimeInViewMs,
ActiveTimeInViewMS: metric.ActiveTimeInViewMs,
HeightToViewportRatioPermille: metric.HeightToViewportRatioPermille,
SeenRangeRatioPermille: metric.SeenRangeRatioPermille,
})
}
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil {
sort.Slice(payload, func(i, j int) bool {
return payload[i].MessageID < payload[j].MessageID
})
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return false, err
}
if err := r.validateTelemetryMessageIDs(ctx, userID, peer, ids); err != nil {
return false, err
}
if r.deps.ClientTelemetry == nil {
return false, internalErr()
}
if _, _, err := r.deps.ClientTelemetry.Record(
ctx, userID, domain.ClientTelemetryReadMetrics, peer,
messageIDs64(ids),
struct {
Metrics []readMetricTelemetry `json:"metrics"`
}{Metrics: payload},
r.clock.Now(),
); err != nil {
return false, clientTelemetryError(err)
}
return true, nil
}
func (r *Router) onMessagesReportMusicListen(ctx context.Context, req *tg.MessagesReportMusicListenRequest) (bool, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return false, internalErr()
}
if req.ID == nil {
return false, tgerr.New(400, "DOCUMENT_INVALID")
}
if req.ListenedDuration < 0 {
if req.ListenedDuration < 0 || req.ListenedDuration > 24*60*60 {
return false, limitInvalidErr()
}
document, err := r.musicDocumentFromInput(ctx, req.ID)
if err != nil {
return false, err
}
if r.deps.ClientTelemetry == nil {
return false, internalErr()
}
if _, _, err := r.deps.ClientTelemetry.Record(
ctx, userID, domain.ClientTelemetryMusicListen, domain.Peer{},
[]int64{document.ID},
struct {
ListenedDuration int `json:"listened_duration"`
}{ListenedDuration: req.ListenedDuration},
r.clock.Now(),
); err != nil {
return false, clientTelemetryError(err)
}
return true, nil
}
func (r *Router) onMessagesReportSponsoredMessage(ctx context.Context, req *tg.MessagesReportSponsoredMessageRequest) (tg.ChannelsSponsoredMessageReportResultClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if len(req.RandomID) == 0 || len(req.RandomID) > maxReportRandomIDLength || len(req.Option) > maxReportOptionLength {
return nil, limitInvalidErr()
}
if r.deps.Moderation == nil {
return nil, internalErr()
}
if _, err := r.deps.Moderation.SponsoredImpression(
ctx, userID, req.RandomID, r.clock.Now(),
); err != nil {
if errors.Is(err, domain.ErrModerationImpressionExpired) ||
errors.Is(err, domain.ErrModerationEvidenceNotFound) {
return nil, tgerr.New(400, "RANDOM_ID_INVALID")
}
return nil, internalErr()
}
option := string(req.Option)
if option == "" {
return &tg.ChannelsSponsoredMessageReportResultChooseOption{
Title: "Report sponsored message",
Options: []tg.SponsoredMessageReportOption{
{Text: "Scam or spam", Option: []byte("spam")},
{Text: "Violence", Option: []byte("violence")},
{Text: "Pornography", Option: []byte("pornography")},
{Text: "Child abuse", Option: []byte("child_abuse")},
{Text: "Illegal drugs", Option: []byte("illegal_drugs")},
{Text: "Personal details", Option: []byte("personal_details")},
{Text: "Fake or impersonation", Option: []byte("fake")},
{Text: "Other", Option: []byte("other")},
},
}, nil
}
reason, ok := moderationReasonForReportOption(option)
if option == "other" {
reason, ok = domain.ModerationReasonOther, true
}
if !ok {
return nil, tgerr.New(400, "OPTION_INVALID")
}
if _, _, err := r.deps.Moderation.ReportSponsored(
ctx, userID, req.RandomID, reason, option, r.clock.Now(),
); err != nil {
if errors.Is(err, domain.ErrModerationImpressionExpired) ||
errors.Is(err, domain.ErrModerationEvidenceNotFound) {
return nil, tgerr.New(400, "RANDOM_ID_INVALID")
}
return nil, moderationReportError(err)
}
return &tg.ChannelsSponsoredMessageReportResultReported{}, nil
}
func (r *Router) validateTelemetryMessageIDs(ctx context.Context, userID int64, peer domain.Peer, ids []int) error {
if len(ids) == 0 || len(ids) > domain.MaxGetMessageIDs {
return limitInvalidErr()
}
needed := make(map[int]struct{}, len(ids))
for _, id := range ids {
if id <= 0 || id > domain.MaxMessageBoxID {
return messageIDInvalidErr()
}
if _, duplicate := needed[id]; duplicate {
return messageIDInvalidErr()
}
needed[id] = struct{}{}
}
switch peer.Type {
case domain.PeerTypeUser:
if r.deps.Messages == nil {
return internalErr()
}
list, err := r.deps.Messages.GetMessages(ctx, userID, ids)
if err != nil {
return internalErr()
}
for _, message := range list.Messages {
if message.Peer == peer {
delete(needed, message.ID)
}
}
case domain.PeerTypeChannel:
if r.deps.Channels == nil {
return internalErr()
}
history, err := r.deps.Channels.GetMessages(ctx, userID, peer.ID, ids)
if err != nil {
return internalErr()
}
for _, message := range history.Messages {
delete(needed, message.ID)
}
default:
return peerIDInvalidErr()
}
if len(needed) != 0 {
return messageIDInvalidErr()
}
return nil
}
func messageIDs64(ids []int) []int64 {
out := make([]int64, len(ids))
for i, id := range ids {
out[i] = int64(id)
}
return out
}
func clientTelemetryError(err error) error {
switch {
case errors.Is(err, domain.ErrClientTelemetryRateLimited):
return floodWaitErr(60)
case errors.Is(err, domain.ErrClientTelemetryInvalid):
return inputRequestInvalidErr()
default:
return internalErr()
}
}
func (r *Router) onMessagesGetSponsoredMessages(ctx context.Context, req *tg.MessagesGetSponsoredMessagesRequest) (tg.MessagesSponsoredMessagesClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()

View file

@ -0,0 +1,71 @@
package rpc
import (
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func TestModerationFlagsProjectToSelfUserAndWire(t *testing.T) {
for _, test := range []struct {
name string
user domain.User
scam bool
fake bool
}{
{name: "scam", user: domain.User{ID: 1, Scam: true}, scam: true},
{name: "fake", user: domain.User{ID: 2, Fake: true}, fake: true},
} {
t.Run(test.name, func(t *testing.T) {
projected := tgSelfUser(test.user)
if projected.Scam != test.scam || projected.Fake != test.fake ||
!projected.Self {
t.Fatalf("projected self=%+v", projected)
}
var wire bin.Buffer
if err := projected.Encode(&wire); err != nil {
t.Fatal(err)
}
var decoded tg.User
input := bin.Buffer{Buf: append([]byte(nil), wire.Buf...)}
if err := decoded.Decode(&input); err != nil {
t.Fatal(err)
}
if decoded.Scam != test.scam || decoded.Fake != test.fake ||
!decoded.Self {
t.Fatalf("decoded self=%+v", decoded)
}
})
}
}
func TestModerationFlagsProjectToChannelWithoutMutatingAbout(t *testing.T) {
channel := domain.Channel{
ID: 10, AccessHash: 20, CreatorUserID: 1,
Title: "Reported channel", About: "Owner description",
Broadcast: true, Scam: true,
}
projected := tgChannel(2, channel, nil)
if !projected.Scam || projected.Fake {
t.Fatalf("projected channel=%+v", projected)
}
var wire bin.Buffer
if err := projected.Encode(&wire); err != nil {
t.Fatal(err)
}
var decoded tg.Channel
input := bin.Buffer{Buf: append([]byte(nil), wire.Buf...)}
if err := decoded.Decode(&input); err != nil {
t.Fatal(err)
}
if !decoded.Scam || decoded.Fake {
t.Fatalf("decoded channel=%+v", decoded)
}
full := tgChannelFull(domain.ChannelView{Channel: channel})
if full.About != channel.About {
t.Fatalf("about=%q, want unmodified %q", full.About, channel.About)
}
}

View file

@ -38,10 +38,15 @@ func (r *Router) sendStarGiftTransferForm(ctx context.Context, userID, formID in
return nil, starsErr(err)
}
}
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
if err != nil {
return nil, err
}
result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID,
Ref: domain.SavedStarGiftRef{Owner: target.Owner, MsgID: target.MsgID, SavedID: target.SavedID}, To: to,
ChargeStars: target.TransferStars, FormID: formID, CommandKey: fmt.Sprintf("paid-transfer:%d:%d", target.ID, formID),
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
Date: int(r.clock.Now().Unix()), RecipientUnsaved: recipientUnsaved,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
if err != nil {
return nil, starGiftLifecycleErr(err)
}
@ -105,9 +110,14 @@ func (r *Router) sendStarGiftResaleForm(ctx context.Context, userID, formID int6
return nil, starsErr(err)
}
}
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
if err != nil {
return nil, err
}
result, err := r.deps.Gifts.PurchaseResale(ctx, domain.StarGiftResalePurchaseRequest{BuyerUserID: userID,
Slug: gift.Slug, To: to, Amount: amount, FormID: formID, CommandKey: fmt.Sprintf("resale:%d:%d", gift.ID, formID),
Date: int(r.clock.Now().Unix()), OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
Date: int(r.clock.Now().Unix()), RecipientUnsaved: recipientUnsaved,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
if err != nil {
return nil, starGiftLifecycleErr(err)
}
@ -566,10 +576,15 @@ func (r *Router) onPaymentsTransferStarGift(ctx context.Context, req *tg.Payment
if err != nil {
return nil, err
}
recipientUnsaved, err := r.starGiftRecipientUnsaved(ctx, userID, to)
if err != nil {
return nil, err
}
now := int(r.clock.Now().Unix())
result, err := r.deps.Gifts.Transfer(ctx, domain.StarGiftTransferRequest{ActorUserID: userID, Ref: ref, To: to,
CommandKey: fmt.Sprintf("free:%s:%d:%s:%s:%d", ref.Owner.Type, ref.Owner.ID, starGiftRefValue(ref), to.Type, to.ID),
Date: now, OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
Date: now, RecipientUnsaved: recipientUnsaved,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)})
if err != nil {
return nil, starGiftLifecycleErr(err)
}

View file

@ -230,11 +230,16 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
ChargeStars: gift.Stars + upgradeStars, FormID: req.FormID, CommandKey: fmt.Sprintf("purchase:%d", req.FormID), Date: now,
OriginAuthKeyID: rawAuthKeyIDForOrigin(ctx), OriginSessionID: sessionIDOrZero(ctx)}
recipientBlocked := false
recipientUnsaved := false
if peer.Type == domain.PeerTypeUser {
recipientBlocked, err = r.peerBlocksUser(ctx, userID, peer.ID)
if err != nil {
return nil, internalErr()
}
recipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, userID, peer)
if err != nil {
return nil, err
}
}
if capability, ok := r.deps.Gifts.(interface{ AtomicPurchaseConfigured() bool }); ok && !capability.AtomicPurchaseConfigured() {
if err := r.deps.Gifts.ValidatePurchaseForm(ctx, purchaseReq); err != nil {
@ -249,6 +254,7 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
return nil, starsErr(err)
}
purchaseReq.RecipientBlocked = recipientBlocked
purchaseReq.RecipientUnsaved = recipientUnsaved
result, err := r.deps.Gifts.Purchase(ctx, purchaseReq)
if err != nil {
return nil, starGiftLifecycleErr(err)
@ -364,6 +370,10 @@ func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, i
}
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string, prepaidUpgradeStars int64) (domain.SavedStarGiftRef, *tg.Updates, error) {
unsaved, err := r.starGiftRecipientUnsaved(ctx, senderID, domain.Peer{Type: domain.PeerTypeUser, ID: recipientID})
if err != nil {
return domain.SavedStarGiftRef{}, nil, err
}
prepaidUpgradeHash := ""
if prepaidUpgradeStars == 0 && gift.UpgradeStars > 0 && gift.UpgradeIssued < gift.UpgradeTotal {
var token [32]byte
@ -386,7 +396,7 @@ func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID i
MsgID: send.RecipientMessage.ID,
Date: send.RecipientMessage.Date,
NameHidden: hideName,
Unsaved: false,
Unsaved: unsaved,
ConvertStars: gift.ConvertStars,
PrepaidUpgradeStars: prepaidUpgradeStars,
PrepaidUpgradeHash: prepaidUpgradeHash,

View file

@ -90,6 +90,10 @@ func (r *Router) adminGrantUpgradedStarGift(ctx context.Context, senderID int64,
grant.SenderID = senderID
grant.Date = int(r.clock.Now().Unix())
grant.RecipientBlocked = recipientBlocked
grant.RecipientUnsaved, err = r.starGiftRecipientUnsaved(ctx, senderID, grant.Recipient)
if err != nil {
return err
}
if _, err := granter.GrantUnique(ctx, grant); err != nil {
return err
}

View file

@ -306,6 +306,17 @@ func (r *Router) userPresenceStatusForUser(u domain.User) domain.UserStatus {
if userID == 0 {
return domain.UserStatus{Kind: domain.UserStatusRecently}
}
// The app projector marks a privacy-hidden exact timestamp with a coarse
// status and clears LastSeenAt. Never overlay the process presence tracker
// after that boundary, or every users/dialogs/history response would undo
// StatusTimestamp privacy.
switch u.Status.Kind {
case domain.UserStatusRecently,
domain.UserStatusLastWeek,
domain.UserStatusLastMonth,
domain.UserStatusEmpty:
return u.Status
}
now := int(r.clock.Now().Unix())
if status, ok := r.presence.statusFor(userID, now); ok {
return status
@ -676,10 +687,15 @@ func (r *Router) pushUserStatus(ctx context.Context, userID int64, status domain
// contacts + dialog 对端 ∩ 在线」一次性算出onlineRelevantPeerIDs2 次查询 + 内存过滤),
// 而非遍历全部在线候选逐个 GetPeerDialogs旧 onlinePrivateDialogPeerIDs 的 O(在线数) N+1
// 断连/sweeper 风暴下 O(M×512) 串行 PG。私聊 dialog 双向建行,两种算法得到同一集合。
for _, recipientID := range r.onlineRelevantPeerIDs(ctx, userID) {
recipients := r.onlineRelevantPeerIDs(ctx, userID)
visible := r.statusTimestampVisibleToViewers(ctx, userID, recipients)
for _, recipientID := range recipients {
if recipientID == userID {
continue
}
if !visible[recipientID] {
continue
}
r.pushUserMessageTransient(ctx, recipientID, "push user status", update)
}
}
@ -690,7 +706,11 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use
return
}
updates := make([]tg.UpdateClass, 0, len(peerIDs))
visible := r.statusTimestampVisibleToViewer(ctx, peerIDs, userID)
for _, peerID := range peerIDs {
if !visible[peerID] {
continue
}
status := r.userPresenceStatus(peerID)
if status.Kind != domain.UserStatusOnline {
continue
@ -710,6 +730,99 @@ func (r *Router) pushOnlinePeerStatusesToCurrentSession(ctx context.Context, use
})
}
type batchPrivacyEvaluator interface {
CanSeeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64, keys []domain.PrivacyKey) (map[int64]map[domain.PrivacyKey]bool, error)
}
type matrixPrivacyEvaluator interface {
CanSeeMatrix(ctx context.Context, ownerUserIDs, viewerUserIDs []int64, keys []domain.PrivacyKey) (map[int64]map[int64]map[domain.PrivacyKey]bool, error)
}
func (r *Router) statusTimestampVisibleToViewer(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) map[int64]bool {
out := make(map[int64]bool, len(ownerUserIDs))
if r.deps.Privacy == nil {
for _, ownerID := range ownerUserIDs {
out[ownerID] = true
}
return out
}
if batch, ok := r.deps.Privacy.(batchPrivacyEvaluator); ok {
visibility, err := batch.CanSeeBatch(ctx, ownerUserIDs, viewerUserID, []domain.PrivacyKey{domain.PrivacyKeyStatusTimestamp})
if err != nil {
r.log.Warn("evaluate status privacy batch", zap.Int64("viewer_user_id", viewerUserID), zap.Error(err))
return out
}
for _, ownerID := range ownerUserIDs {
out[ownerID] = visibility[ownerID][domain.PrivacyKeyStatusTimestamp]
}
return out
}
for _, ownerID := range ownerUserIDs {
allowed, err := r.deps.Privacy.CanSee(ctx, ownerID, viewerUserID, domain.PrivacyKeyStatusTimestamp)
if err == nil {
out[ownerID] = allowed
}
}
return out
}
func (r *Router) statusTimestampVisibleToViewers(ctx context.Context, ownerUserID int64, viewerUserIDs []int64) map[int64]bool {
out := make(map[int64]bool, len(viewerUserIDs))
if r.deps.Privacy == nil {
for _, viewerID := range viewerUserIDs {
out[viewerID] = true
}
return out
}
if matrix, ok := r.deps.Privacy.(matrixPrivacyEvaluator); ok {
visibility, err := matrix.CanSeeMatrix(ctx, []int64{ownerUserID}, viewerUserIDs, []domain.PrivacyKey{domain.PrivacyKeyStatusTimestamp})
if err != nil {
r.log.Warn("evaluate status privacy matrix", zap.Int64("owner_user_id", ownerUserID), zap.Error(err))
return out
}
for _, viewerID := range viewerUserIDs {
out[viewerID] = visibility[ownerUserID][viewerID][domain.PrivacyKeyStatusTimestamp]
}
return out
}
for _, viewerID := range viewerUserIDs {
allowed, err := r.deps.Privacy.CanSee(ctx, ownerUserID, viewerID, domain.PrivacyKeyStatusTimestamp)
if err == nil {
out[viewerID] = allowed
}
}
return out
}
// pushStatusPrivacyRefresh immediately replaces stale exact statuses on other
// online accounts after StatusTimestamp changes. Allowed viewers receive the
// live value; denied viewers receive only a coarse bucket.
func (r *Router) pushStatusPrivacyRefresh(ctx context.Context, ownerUserID int64) {
recipients := r.onlineRelevantPeerIDs(ctx, ownerUserID)
visible := r.statusTimestampVisibleToViewers(ctx, ownerUserID, recipients)
exact := r.userPresenceStatus(ownerUserID)
if r.deps.Users != nil {
if owner, found, err := r.deps.Users.ByID(ctx, ownerUserID, ownerUserID); err == nil && found {
exact = r.userPresenceStatusForUser(owner)
}
}
coarse := domain.ApproximateUserStatus(exact.WasOnline, int(r.clock.Now().Unix()))
for _, recipientID := range recipients {
if recipientID == 0 || recipientID == ownerUserID {
continue
}
status := coarse
if visible[recipientID] {
status = exact
}
r.pushUserMessageTransient(ctx, recipientID, "push status privacy refresh", &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdateUserStatus{UserID: ownerUserID, Status: tgUserStatus(status)}},
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
}
}
// presenceCandidateCacheTTL 是 presence fan-out 候选集(联系人 私聊对端)的缓存有效期;
// lastSeenPersistDebounce 是在线续期写 last_seen 的去抖窗口。
const (

View file

@ -0,0 +1,28 @@
package rpc
import (
"context"
"telesrv/internal/domain"
)
// starGiftRecipientUnsaved evaluates privacyKeyStarGiftsAutoSave at the
// ownership-write boundary. The rule does not reject the gift: it decides
// whether an incoming user gift is displayed immediately (unsaved=false) or
// waits for the recipient's approval (unsaved=true).
func (r *Router) starGiftRecipientUnsaved(ctx context.Context, senderUserID int64, recipient domain.Peer) (bool, error) {
if recipient.Type != domain.PeerTypeUser || recipient.ID == 0 ||
senderUserID == 0 || senderUserID == recipient.ID || r.deps.Privacy == nil {
return false, nil
}
allowed, err := r.deps.Privacy.CanSee(
ctx,
recipient.ID,
senderUserID,
domain.PrivacyKeyStarGiftsAutoSave,
)
if err != nil {
return false, internalErr()
}
return !allowed, nil
}

View file

@ -0,0 +1,59 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
// filterChatInvitePrivacy evaluates the whole target vector through the
// privacy read model before any membership write. The returned slices preserve
// request order.
func (r *Router) filterChatInvitePrivacy(ctx context.Context, inviterUserID int64, targetUserIDs []int64) ([]int64, []tg.MissingInvitee, error) {
if len(targetUserIDs) == 0 {
return nil, nil, nil
}
if r.deps.Privacy == nil {
return append([]int64(nil), targetUserIDs...), nil, nil
}
visible := make(map[int64]bool, len(targetUserIDs))
if batch, ok := r.deps.Privacy.(batchPrivacyEvaluator); ok {
matrix, err := batch.CanSeeBatch(ctx, targetUserIDs, inviterUserID, []domain.PrivacyKey{domain.PrivacyKeyChatInvite})
if err != nil {
return nil, nil, err
}
for _, targetUserID := range targetUserIDs {
visible[targetUserID] = matrix[targetUserID][domain.PrivacyKeyChatInvite]
}
} else {
for _, targetUserID := range targetUserIDs {
allowed, err := r.deps.Privacy.CanSee(ctx, targetUserID, inviterUserID, domain.PrivacyKeyChatInvite)
if err != nil {
return nil, nil, err
}
visible[targetUserID] = allowed
}
}
allowed := make([]int64, 0, len(targetUserIDs))
missing := make([]tg.MissingInvitee, 0)
for _, targetUserID := range targetUserIDs {
if visible[targetUserID] {
allowed = append(allowed, targetUserID)
continue
}
missing = append(missing, tg.MissingInvitee{UserID: targetUserID})
}
return allowed, missing, nil
}
func emptyInvitedUsersUpdates(date int) *tg.Updates {
return &tg.Updates{
Updates: []tg.UpdateClass{},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: date,
Seq: 0,
}
}

View file

@ -0,0 +1,83 @@
package rpc
import (
"context"
"github.com/iamxvbaba/td/tg"
"telesrv/internal/domain"
)
func (r *Router) ensureVoiceMessagesAllowed(ctx context.Context, senderUserID int64, peer domain.Peer, voiceOrRound bool) error {
if !voiceOrRound || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == senderUserID || r.deps.Privacy == nil {
return nil
}
allowed, err := r.deps.Privacy.CanSee(ctx, peer.ID, senderUserID, domain.PrivacyKeyVoiceMessages)
if err != nil {
return internalErr()
}
if !allowed {
return chatSendVoicesForbiddenErr()
}
return nil
}
// preflightVoiceOrRound inspects upload attributes and referenced documents
// before resolveInputMedia materializes any uploaded blob. Referenced document
// misses are loaded in one bounded read-model batch.
func (r *Router) preflightVoiceOrRound(ctx context.Context, inputs []tg.InputMediaClass) (bool, error) {
documentIDs := make([]int64, 0)
seen := make(map[int64]struct{})
for _, input := range inputs {
switch media := input.(type) {
case *tg.InputMediaUploadedDocument:
if documentAttributesVoiceOrRound(domainDocumentAttributes(media.Attributes)) {
return true, nil
}
case *tg.InputMediaDocument:
ids, ok := inputDocumentCandidateIDs(media.ID)
if !ok {
continue // resolveInputMedia retains MEDIA_INVALID precedence.
}
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
documentIDs = append(documentIDs, id)
}
}
}
if len(documentIDs) == 0 || r.deps.Files == nil {
return false, nil
}
documents, err := r.deps.Files.GetDocuments(ctx, documentIDs)
if err != nil {
return false, internalErr()
}
for _, document := range documents {
if documentAttributesVoiceOrRound(document.Attributes) {
return true, nil
}
}
return false, nil
}
func documentAttributesVoiceOrRound(attributes []domain.DocumentAttribute) bool {
for _, attribute := range attributes {
switch attribute.Kind {
case domain.DocAttrAudio:
if attribute.Voice {
return true
}
case domain.DocAttrVideo:
if attribute.RoundMessage {
return true
}
}
}
return false
}

View file

@ -127,6 +127,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.ensureVoiceMessagesAllowed(ctx, userID, peer, p.media != nil && p.media.HasUnreadPayload()); err != nil {
return nil, false, err
}
if r.deps.Users != nil && peer.ID != userID {
if _, found, err := r.deps.Users.ByID(ctx, userID, peer.ID); err != nil {
return nil, false, internalErr()
@ -375,6 +378,13 @@ func (r *Router) onMessagesSendMedia(ctx context.Context, req *tg.MessagesSendMe
if err != nil {
return nil, err
}
voiceOrRound, err := r.preflightVoiceOrRound(ctx, []tg.InputMediaClass{req.Media})
if err != nil {
return nil, err
}
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, voiceOrRound); err != nil {
return nil, err
}
media, err := r.resolveInputMedia(ctx, userID, req.Media)
if err != nil {
return nil, err
@ -501,6 +511,19 @@ func (r *Router) onMessagesSendMultiMedia(ctx context.Context, req *tg.MessagesS
if err != nil {
return nil, err
}
pendingMedia := make([]tg.InputMediaClass, 0, absentCount)
for i, item := range req.MultiMedia {
if !replays[i].found {
pendingMedia = append(pendingMedia, item.Media)
}
}
voiceOrRound, err := r.preflightVoiceOrRound(ctx, pendingMedia)
if err != nil {
return nil, err
}
if err := r.ensureVoiceMessagesAllowed(ctx, userID, peer, voiceOrRound); err != nil {
return nil, err
}
// 必须在 resolveInputMedia 或发送任何 item 之前原子预留:首次请求若在第 N 条
// 失败,客户端只重试失败子集时仍从已绑定 random_id 恢复整包 grouped_id。

View file

@ -11,6 +11,7 @@ import (
"unicode/utf8"
"github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/compat/tdesktop"
@ -591,17 +592,33 @@ func (r *Router) onStoriesReport(ctx context.Context, req *tg.StoriesReportReque
if err != nil {
return nil, err
}
if r.deps.Stories != nil && userID != 0 {
ids := uniqueStoryIDs(req.ID)
list, err := r.deps.Stories.GetStoriesByID(ctx, userID, peer, ids, int(r.clock.Now().Unix()))
if err != nil {
return nil, storyErr(err)
}
if len(list.Stories) != len(ids) {
result, err := reportResultForOption(string(req.Option))
if err != nil {
return nil, err
}
if _, final := result.(*tg.ReportResultReported); !final {
// Option discovery is non-mutating, but still follows the peer/access
// validation above so malformed targets cannot bypass normal errors.
return result, nil
}
reason, ok := moderationReasonForReportOption(string(req.Option))
if !ok {
return nil, tgerr.New(400, "OPTION_INVALID")
}
if r.deps.Moderation == nil {
return nil, internalErr()
}
if _, _, err := r.deps.Moderation.ReportStories(ctx, domain.ModerationStoryReportRequest{
ReporterUserID: userID, Target: peer, StoryIDs: uniqueStoryIDs(req.ID),
Reason: reason, Option: string(req.Option), Comment: req.Message,
CreatedAt: r.clock.Now(),
}); err != nil {
if errors.Is(err, domain.ErrModerationEvidenceNotFound) {
return nil, storyIDInvalidErr()
}
return nil, moderationReportError(err)
}
return reportResultForOption(string(req.Option))
return result, nil
}
func validateStoriesReportRequest(req *tg.StoriesReportRequest) error {

View file

@ -14,6 +14,7 @@ import (
appchannels "telesrv/internal/app/channels"
appcontacts "telesrv/internal/app/contacts"
appmoderation "telesrv/internal/app/moderation"
appprivacy "telesrv/internal/app/privacy"
appstories "telesrv/internal/app/stories"
appupdates "telesrv/internal/app/updates"
@ -5866,6 +5867,7 @@ func TestStoriesGetStoriesArchiveReturnsOwnerExpiredStories(t *testing.T) {
func TestStoriesLongtailCompatHandlers(t *testing.T) {
ctx := context.Background()
storyStore := memory.NewStoryStore()
reportStore := memory.NewModerationReportStore()
ownerID := int64(9311)
owner := domain.Peer{Type: domain.PeerTypeUser, ID: ownerID}
if _, err := storyStore.UpsertStory(ctx, domain.UpsertStoryRequest{Story: domain.Story{
@ -5877,8 +5879,10 @@ func TestStoriesLongtailCompatHandlers(t *testing.T) {
}}); err != nil {
t.Fatalf("upsert story: %v", err)
}
storyService := appstories.NewService(storyStore)
r := New(Config{}, Deps{
Stories: appstories.NewService(storyStore),
Stories: storyService,
Moderation: appmoderation.NewService(reportStore, appmoderation.WithStoryReader(storyService)),
}, zaptest.NewLogger(t), fixedClock{now: time.Unix(1700000100, 0)})
reqCtx := WithUserID(ctx, ownerID)
@ -6034,6 +6038,9 @@ func TestStoriesLongtailCompatHandlers(t *testing.T) {
if _, ok := reported.(*tg.ReportResultReported); !ok {
t.Fatalf("report spam = %T %+v, want reported", reported, reported)
}
if got := reportStore.Reports(); len(got) != 1 || got[0].Source != domain.ModerationSourceStory {
t.Fatalf("story moderation reports = %+v, want one persisted story report", got)
}
if _, err := r.onStoriesReport(reqCtx, &tg.StoriesReportRequest{
Peer: &tg.InputPeerSelf{},
ID: []int{99},

View file

@ -247,11 +247,6 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
about = ""
}
}
// Surface the scam/fake warning to other viewers (never to the account
// itself), non-destructively over the projected About.
if u.ID != currentUserID {
about = aboutWithModerationWarning(about, defaultScamWarningUser, defaultFakeWarningUser, u.Scam, u.Fake)
}
full := tg.UserFull{
ID: u.ID,
About: about,
@ -261,7 +256,7 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
// 通话入口:客户端不见 phone_calls_available=true 不显示通话按钮P1 前置项)。
// phone_calls_private 标记对端禁 P2Pp2p_allowed 真值在通话确认时另行计算)。
if !u.Bot && u.ID != currentUserID {
callsAllowed, p2pAllowed := true, true
callsAllowed, p2pAllowed, voiceAllowed := true, true, true
if r.deps.Privacy != nil {
allowed, err := r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyPhoneCall)
if err != nil {
@ -273,10 +268,16 @@ func (r *Router) buildUserFullProjection(ctx context.Context, currentUserID int6
return tg.UserFull{}, internalErr()
}
p2pAllowed = allowed
allowed, err = r.deps.Privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyVoiceMessages)
if err != nil {
return tg.UserFull{}, internalErr()
}
voiceAllowed = allowed
}
full.PhoneCallsAvailable = callsAllowed
full.VideoCallsAvailable = callsAllowed
full.PhoneCallsPrivate = !p2pAllowed
full.VoiceMessagesForbidden = !voiceAllowed
}
if u.Bot {
full.SetBotInfo(r.tgBotInfo(ctx, u))
@ -446,13 +447,97 @@ func (r *Router) onUsersGetRequirementsToContact(ctx context.Context, ids []tg.I
if len(ids) > maxRequirementsToContactUsers {
return nil, limitInvalidErr()
}
out := make([]tg.RequirementToContactClass, 0, len(ids))
for range ids {
out = append(out, &tg.RequirementToContactEmpty{})
currentUserID, authorized, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
if !authorized || r.deps.Users == nil {
out := make([]tg.RequirementToContactClass, len(ids))
for i := range out {
out[i] = &tg.RequirementToContactEmpty{}
}
return out, nil
}
type requirementInput struct {
userID int64
accessHash int64
self bool
}
inputs := make([]requirementInput, len(ids))
targetIDs := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for i, input := range ids {
switch value := input.(type) {
case *tg.InputUserSelf:
inputs[i] = requirementInput{userID: currentUserID, self: true}
case *tg.InputUser:
if value == nil || value.UserID == 0 {
continue
}
inputs[i] = requirementInput{userID: value.UserID, accessHash: value.AccessHash}
if _, ok := seen[value.UserID]; !ok {
seen[value.UserID] = struct{}{}
targetIDs = append(targetIDs, value.UserID)
}
}
}
usersByID := make(map[int64]domain.User, len(targetIDs))
if len(targetIDs) > 0 {
list, err := r.deps.Users.ByIDs(ctx, currentUserID, targetIDs)
if err != nil && !errors.Is(err, users.ErrNotAuthorized) {
return nil, internalErr()
}
for _, user := range list {
usersByID[user.ID] = user
}
}
validTargetIDs := make([]int64, 0, len(targetIDs))
for i, input := range inputs {
if input.self {
continue
}
user, ok := usersByID[input.userID]
if !ok || input.accessHash != 0 && input.accessHash != user.AccessHash {
inputs[i].userID = 0
continue
}
validTargetIDs = append(validTargetIDs, user.ID)
}
settingsByUser := make(map[int64]domain.AccountSettings, len(validTargetIDs))
if svc, ok := r.accountSettingsSvc(); ok {
settingsByUser, err = r.accountSettings.getOrLoadBatch(ctx, validTargetIDs, svc)
if err != nil {
return nil, internalErr()
}
}
freeByUser := make(map[int64]bool, len(validTargetIDs))
if evaluator, ok := r.deps.Privacy.(contactRequirementPrivacyEvaluator); ok {
freeByUser, err = evaluator.CanContactForFreeBatch(ctx, validTargetIDs, currentUserID)
if err != nil {
return nil, internalErr()
}
}
out := make([]tg.RequirementToContactClass, len(inputs))
for i, input := range inputs {
out[i] = &tg.RequirementToContactEmpty{}
if input.userID == 0 || input.self || input.userID == currentUserID || freeByUser[input.userID] {
continue
}
global := settingsByUser[input.userID].GlobalPrivacy
switch {
case global.NoncontactPeersPaidStars > 0:
out[i] = &tg.RequirementToContactPaidMessages{StarsAmount: global.NoncontactPeersPaidStars}
case global.NewNoncontactPeersRequirePremium:
out[i] = &tg.RequirementToContactPremium{}
}
}
return out, nil
}
type contactRequirementPrivacyEvaluator interface {
CanContactForFreeBatch(ctx context.Context, ownerUserIDs []int64, viewerUserID int64) (map[int64]bool, error)
}
func (r *Router) onUsersGetSavedMusic(ctx context.Context, req *tg.UsersGetSavedMusicRequest) (tg.UsersSavedMusicClass, error) {
if req == nil || req.Offset < 0 || req.Limit < 0 || req.Limit > maxSavedMusicLimit {
return nil, limitInvalidErr()