feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
108
internal/rpc/account_reports.go
Normal file
108
internal/rpc/account_reports.go
Normal 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
|
||||
}
|
||||
}
|
||||
71
internal/rpc/account_reports_rpc_test.go
Normal file
71
internal/rpc/account_reports_rpc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const (
|
|||
maxReactionVector = 16
|
||||
maxReactionListOffset = 128
|
||||
maxReportOptionLength = 32
|
||||
maxReportCommentLength = 1024
|
||||
maxReportCommentLength = domain.MaxModerationCommentRunes
|
||||
maxReportRandomIDLength = 128
|
||||
maxReadMetrics = 100
|
||||
maxBusinessConnIDLength = 128
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
71
internal/rpc/moderation_flags_projection_test.go
Normal file
71
internal/rpc/moderation_flags_projection_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 对端 ∩ 在线」一次性算出(onlineRelevantPeerIDs,2 次查询 + 内存过滤),
|
||||
// 而非遍历全部在线候选逐个 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 (
|
||||
|
|
|
|||
28
internal/rpc/privacy_gifts.go
Normal file
28
internal/rpc/privacy_gifts.go
Normal 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
|
||||
}
|
||||
59
internal/rpc/privacy_invites.go
Normal file
59
internal/rpc/privacy_invites.go
Normal 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,
|
||||
}
|
||||
}
|
||||
83
internal/rpc/privacy_voice.go
Normal file
83
internal/rpc/privacy_voice.go
Normal 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
|
||||
}
|
||||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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 标记对端禁 P2P(p2p_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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue