From 7fa721a25e5dee233617be574b2507da70a314d5 Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 14:17:49 +0800 Subject: [PATCH] fix: sync Android channel compatibility fixes --- internal/app/help/service.go | 7 +- internal/app/help/service_premium_test.go | 1 + internal/compat/tdesktop/defaults.go | 4 +- internal/rpc/channels_core.go | 12 +- internal/rpc/channels_legacy_chat.go | 10 +- .../rpc/channels_legacy_settings_rpc_test.go | 252 +++++++++++++++++- internal/rpc/channels_read_reactions.go | 19 +- internal/rpc/convert_channels_core.go | 8 +- internal/rpc/messages_history.go | 5 + internal/rpc/messages_history_rpc_test.go | 55 ++++ .../rpc/messages_reactions_policy_rpc_test.go | 28 ++ internal/rpc/messages_reactions_send.go | 1 + internal/rpc/messages_register.go | 21 ++ internal/rpc/reactions_android_compat.go | 156 +++++++++++ internal/rpc/router.go | 1 + internal/rpc/rpc_projection_cache.go | 18 +- 16 files changed, 566 insertions(+), 32 deletions(-) create mode 100644 internal/rpc/reactions_android_compat.go diff --git a/internal/app/help/service.go b/internal/app/help/service.go index cdc4a394..f5e3f0c7 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -31,6 +31,9 @@ const tdesktopClient = "tdesktop" // true(屏蔽),GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。 // - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium // 联动:premium 用户可在同一消息放 3 个 reaction,服务端档位必须 ≥ 该宣告值。 +// - boosts_channel_level_max=100 必须显式下发:DrKLO 的频道自定义 reaction 编辑页 +// 用它作为可选 reaction 个数的本地 LengthFilter 上限。缺 key 会保留旧偏好值;实测 +// 旧值为 4 时,频道已选 4 个 reaction 后继续点新 emoji 会被客户端本地静默挡掉。 // - dialog_filters_enabled=true 必须显式下发:TDesktop settings_main.cpp:394 据此(或账号 // 已有文件夹)才在 Settings 显示「Folders」入口,缺 key → 新账号看不到文件夹管理、无法 // 建文件夹/采纳 getSuggestedDialogFilters 模板。 @@ -43,9 +46,9 @@ const tdesktopClient = "tdesktop" // 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。 // - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时 // 直接读取这些 key 做本地输入限制和示例数量。 -const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000` +const tdesktopDefaultAppConfigBase = `{"chat_read_mark_expire_period":604800,"chat_read_mark_size_threshold":50,"pm_read_date_expire_period":604800,"quote_length_max":1024,"telegram_antispam_group_size_min":200,"telegram_antispam_user_id":"5434988373","forum_upgrade_participants_min":2,"reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_user_max_premium":3,"reactions_in_chat_max":3,"boosts_channel_level_max":100,"upload_markup_video":true,"emojies_send_dice":["🎲","🎯","🏀","⚽","⚽️","🎳","🎰"],"premium_purchase_blocked":false,"stargifts_blocked":false,"stories_stealth_future_period":1500,"stories_stealth_past_period":300,"stories_stealth_cooldown_period":10800,"quick_replies_limit":100,"quick_reply_messages_limit":20,"business_chat_links_limit":100,"dialog_filters_enabled":true,"about_length_limit_default":70,"about_length_limit_premium":140,"caption_length_limit_default":1024,"caption_length_limit_premium":4096,"channels_limit_default":500,"channels_limit_premium":1000,"channels_public_limit_default":10,"channels_public_limit_premium":20,"dialog_filters_limit_default":10,"dialog_filters_limit_premium":20,"dialog_filters_chats_limit_default":100,"dialog_filters_chats_limit_premium":200,"dialogs_pinned_limit_default":5,"dialogs_pinned_limit_premium":10,"dialogs_folder_pinned_limit_default":100,"dialogs_folder_pinned_limit_premium":200,"saved_dialogs_pinned_limit_default":5,"saved_dialogs_pinned_limit_premium":100,"saved_gifs_limit_default":200,"saved_gifs_limit_premium":400,"stickers_faved_limit_default":5,"stickers_faved_limit_premium":10,"recommended_channels_limit_default":10,"recommended_channels_limit_premium":100,"aicompose_tone_examples_num":3,"aicompose_tone_title_length_max":12,"aicompose_tone_prompt_length_max":1024,"aicompose_tone_saved_limit_default":5,"aicompose_tone_saved_limit_premium":20,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000` -const defaultAppConfigHash = 18 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 +const defaultAppConfigHash = 19 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 // Service 提供客户端启动配置与国家区号目录。 // diff --git a/internal/app/help/service_premium_test.go b/internal/app/help/service_premium_test.go index 717e8bbc..49d640e5 100644 --- a/internal/app/help/service_premium_test.go +++ b/internal/app/help/service_premium_test.go @@ -32,6 +32,7 @@ func TestAppConfigPremiumKeys(t *testing.T) { wantNumbers := map[string]float64{ "reactions_user_max_default": 1, "reactions_user_max_premium": 3, + "boosts_channel_level_max": 100, "about_length_limit_default": 70, "about_length_limit_premium": 140, "dialogs_pinned_limit_default": 5, diff --git a/internal/compat/tdesktop/defaults.go b/internal/compat/tdesktop/defaults.go index caf08b98..f89c8d72 100644 --- a/internal/compat/tdesktop/defaults.go +++ b/internal/compat/tdesktop/defaults.go @@ -7,7 +7,7 @@ import ( ) const ( - appConfigHash = 13 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 + appConfigHash = 14 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 countriesListHash = 1 timezonesListHash = 1 ) @@ -38,6 +38,8 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject { // stargifts_blocked=false:DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格。 {Key: "stargifts_blocked", Value: &tg.JSONBool{Value: false}}, {Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}}, + // DrKLO 频道自定义 reaction 编辑页用它作为可选 reaction 数量上限。 + {Key: "boosts_channel_level_max", Value: &tg.JSONNumber{Value: 100}}, // dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。 {Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}}, {Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}}, diff --git a/internal/rpc/channels_core.go b/internal/rpc/channels_core.go index 6cfa7a25..8fb78696 100644 --- a/internal/rpc/channels_core.go +++ b/internal/rpc/channels_core.go @@ -121,6 +121,7 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha r.applyStarGiftsCountToChannelFull(ctx, ref.ID, &full) r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, ref.ID, &full) r.applyNotifySettingsToChannelFull(ctx, userID, ref.ID, &full) + r.applyAndroidChannelReactionEditorCompat(ctx, &full, cached.canChangeInfo) chats := append([]tg.ChatClass(nil), cached.chats...) r.trackChannelInterest(ctx, userID, ref.ID) r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats) @@ -153,14 +154,17 @@ func (r *Router) onChannelsGetFullChannel(ctx context.Context, input tg.InputCha // 当前频道默认已由 tgChannelFull 处理;外部频道默认(以自己拥有的别的频道身份发言)需在此投影并 // 带上该频道对象,否则客户端拿不到默认 chip。 r.applyForeignDefaultSendAsToFull(ctx, userID, view, full, &chats) + canChangeInfo := channelMemberCanChangeInfo(view.Self) r.channelFullProjectionCache.StoreIfEpoch(userID, view.Channel.ID, channelFullProjection{ - accessHash: view.Channel.AccessHash, - full: *full, - chats: append([]tg.ChatClass(nil), chats...), - userIDs: userIDs, + accessHash: view.Channel.AccessHash, + canChangeInfo: canChangeInfo, + full: *full, + chats: append([]tg.ChatClass(nil), chats...), + userIDs: userIDs, }, loadEpoch) r.applyStoriesPinnedAvailableToChannelFull(ctx, userID, view.Channel.ID, full) r.applyNotifySettingsToChannelFull(ctx, userID, view.Channel.ID, full) + r.applyAndroidChannelReactionEditorCompat(ctx, full, canChangeInfo) r.applyStoryMaxIDsToPeerObjects(ctx, userID, nil, chats) return &tg.MessagesChatFull{ FullChat: full, diff --git a/internal/rpc/channels_legacy_chat.go b/internal/rpc/channels_legacy_chat.go index a5a98ed9..346e135a 100644 --- a/internal/rpc/channels_legacy_chat.go +++ b/internal/rpc/channels_legacy_chat.go @@ -609,6 +609,9 @@ func (r *Router) onMessagesSetChatAvailableReactions(ctx context.Context, req *t if r.deps.Channels == nil { return nil, notImplementedErr() } + if req == nil { + return nil, tgerr400("REACTION_INVALID") + } userID, _, err := r.currentUserID(ctx) if err != nil { return nil, internalErr() @@ -617,7 +620,12 @@ func (r *Router) onMessagesSetChatAvailableReactions(ctx context.Context, req *t if err != nil { return nil, err } - policy, err := domainChannelReactionPolicy(req) + current, err := r.deps.Channels.GetChannelForChangeInfo(ctx, userID, channelID) + if err != nil { + return nil, channelAdminErr(err) + } + _, defaultReactionDocuments := r.availableReactionDocumentMaps(ctx) + policy, err := domainChannelReactionPolicy(req, current.Channel.ReactionPolicy, defaultReactionDocuments) if err != nil { return nil, err } diff --git a/internal/rpc/channels_legacy_settings_rpc_test.go b/internal/rpc/channels_legacy_settings_rpc_test.go index 0425e905..4d9eff2b 100644 --- a/internal/rpc/channels_legacy_settings_rpc_test.go +++ b/internal/rpc/channels_legacy_settings_rpc_test.go @@ -51,11 +51,12 @@ func TestLegacyChannelSettingsRPC(t *testing.T) { t.Fatalf("private set chat theme updates = %+v, want empty compat ack", privateTheme) } - reactionUpdates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + setReactionsReq := &tg.MessagesSetChatAvailableReactionsRequest{ Peer: peer, AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{&tg.ReactionEmoji{Emoticon: "\U0001f44d"}}}, - ReactionsLimit: 8, - }) + } + setReactionsReq.SetReactionsLimit(8) + reactionUpdates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), setReactionsReq) if err != nil { t.Fatalf("set available reactions: %v", err) } @@ -148,11 +149,12 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) { for i := 0; i < catalogSize; i++ { reactions = append(reactions, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%02d", i)}) } - updates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + setCatalogReq := &tg.MessagesSetChatAvailableReactionsRequest{ Peer: peer, AvailableReactions: &tg.ChatReactionsSome{Reactions: reactions}, - ReactionsLimit: 11, - }) + } + setCatalogReq.SetReactionsLimit(11) + updates, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), setCatalogReq) if err != nil { t.Fatalf("set full-catalog reactions on broadcast channel: %v", err) } @@ -173,4 +175,242 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) { if !ok || len(some.Reactions) != catalogSize { t.Fatalf("full channel reactions = %#v, want %d explicit reactions", stored, catalogSize) } + if fullChannel.GetPaidReactionsAvailable() { + t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag") + } +} + +func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002201", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + }, zaptest.NewLogger(t), clock.System) + + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Broadcast Optional Reactions", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + initial := &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsAll{AllowCustom: true}, + } + initial.SetReactionsLimit(7) + initial.SetPaidEnabled(true) + if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), initial); err != nil { + t.Fatalf("set initial reaction policy: %v", err) + } + + omitOptional := &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{ + &tg.ReactionEmoji{Emoticon: "\U0001f44d"}, + }}, + } + if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), omitOptional); err != nil { + t.Fatalf("set reaction policy without optional flags: %v", err) + } + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after omitted flags: %v", err) + } + fullChannel := full.FullChat.(*tg.ChannelFull) + if fullChannel.ReactionsLimit != 7 { + t.Fatalf("reactions limit after omitted flag = %d, want preserved 7", fullChannel.ReactionsLimit) + } + if !fullChannel.GetPaidReactionsAvailable() { + t.Fatalf("paid reactions after omitted flag = false, want preserved true") + } + + disablePaid := &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{ + &tg.ReactionEmoji{Emoticon: "\U0001f44d"}, + &tg.ReactionEmoji{Emoticon: "\u2764"}, + }}, + } + disablePaid.SetPaidEnabled(false) + if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), disablePaid); err != nil { + t.Fatalf("disable paid reactions without limit flag: %v", err) + } + full, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel after paid disable: %v", err) + } + fullChannel = full.FullChat.(*tg.ChannelFull) + if fullChannel.ReactionsLimit != 7 { + t.Fatalf("reactions limit after paid-only update = %d, want preserved 7", fullChannel.ReactionsLimit) + } + if fullChannel.GetPaidReactionsAvailable() { + t.Fatalf("paid reactions after explicit false = true, want false") + } +} + +func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 111, Phone: "15550002211", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + files := &fakeFiles{reactions: []domain.AvailableReaction{ + {Reaction: "\U0001f44d", ActivateAnimationID: 7101}, + {Reaction: "\U0001f525", ActivateAnimationID: 7102}, + }} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Files: files, + }, zaptest.NewLogger(t), clock.System) + + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Android Reaction Projection", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{ + &tg.ReactionEmoji{Emoticon: "\U0001f44d"}, + &tg.ReactionEmoji{Emoticon: "\U0001f525"}, + }}, + }); err != nil { + t.Fatalf("set reaction policy: %v", err) + } + + desktopFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get desktop full channel: %v", err) + } + desktopSome := mustChannelFullSomeReactions(t, desktopFull) + if emoji, ok := desktopSome.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { + t.Fatalf("desktop reaction[0] = %T %+v, want reactionEmoji thumbs up", desktopSome.Reactions[0], desktopSome.Reactions[0]) + } + + androidCtx := WithClientInfo(WithUserID(ctx, owner.ID), ClientInfo{Type: ClientTypeAndroid, AppVersion: "12.8.1"}) + androidFull, err := r.onChannelsGetFullChannel(androidCtx, &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get android full channel: %v", err) + } + androidSome := mustChannelFullSomeReactions(t, androidFull) + if doc, ok := androidSome.Reactions[0].(*tg.ReactionCustomEmoji); !ok || doc.DocumentID != 7101 { + t.Fatalf("android reaction[0] = %T %+v, want reactionCustomEmoji 7101", androidSome.Reactions[0], androidSome.Reactions[0]) + } + if doc, ok := androidSome.Reactions[1].(*tg.ReactionCustomEmoji); !ok || doc.DocumentID != 7102 { + t.Fatalf("android reaction[1] = %T %+v, want reactionCustomEmoji 7102", androidSome.Reactions[1], androidSome.Reactions[1]) + } +} + +func TestSetChatAvailableReactionsNormalizesDefaultReactionDocuments(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 112, Phone: "15550002212", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + files := &fakeFiles{reactions: []domain.AvailableReaction{ + {Reaction: "\U0001f44d", ActivateAnimationID: 7201}, + }} + r := New(Config{}, Deps{ + Users: appusers.NewService(userStore), + Channels: appchannels.NewService(channelStore), + Files: files, + }, zaptest.NewLogger(t), clock.System) + + created, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{ + Title: "Android Reaction Save", + Broadcast: true, + }) + if err != nil { + t.Fatalf("create broadcast channel: %v", err) + } + channel := created.(*tg.Updates).Chats[0].(*tg.Channel) + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), &tg.MessagesSetChatAvailableReactionsRequest{ + Peer: peer, + AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{ + &tg.ReactionCustomEmoji{DocumentID: 7201}, + }}, + }); err != nil { + t.Fatalf("set reaction policy with default document id: %v", err) + } + + full, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}) + if err != nil { + t.Fatalf("get full channel: %v", err) + } + some := mustChannelFullSomeReactions(t, full) + if emoji, ok := some.Reactions[0].(*tg.ReactionEmoji); !ok || emoji.Emoticon != "\U0001f44d" { + t.Fatalf("stored reaction[0] = %T %+v, want normalized reactionEmoji thumbs up", some.Reactions[0], some.Reactions[0]) + } +} + +func TestAvailableReactionDocumentMapsAreCached(t *testing.T) { + ctx := context.Background() + files := &countingAvailableReactionFiles{fakeFiles: &fakeFiles{reactions: []domain.AvailableReaction{ + {Reaction: "\U0001f44d", ActivateAnimationID: 7301}, + }}} + r := &Router{deps: Deps{Files: files}} + + emojiToDoc, docToEmoji := r.availableReactionDocumentMaps(ctx) + if got := emojiToDoc["\U0001f44d"]; got != 7301 { + t.Fatalf("emoji->document map = %d, want 7301", got) + } + if got := docToEmoji[7301]; got != "\U0001f44d" { + t.Fatalf("document->emoji map = %q, want thumbs up", got) + } + + files.fakeFiles.reactions = append(files.fakeFiles.reactions, domain.AvailableReaction{ + Reaction: "\U0001f525", + ActivateAnimationID: 7302, + }) + emojiToDoc, docToEmoji = r.availableReactionDocumentMaps(ctx) + if files.calls != 1 { + t.Fatalf("ListAvailableReactions calls = %d, want 1 cached load", files.calls) + } + if got := emojiToDoc["\U0001f525"]; got != 0 { + t.Fatalf("cached emoji->document map unexpectedly saw later catalog mutation: %d", got) + } + if got := docToEmoji[7302]; got != "" { + t.Fatalf("cached document->emoji map unexpectedly saw later catalog mutation: %q", got) + } +} + +type countingAvailableReactionFiles struct { + *fakeFiles + calls int +} + +func (f *countingAvailableReactionFiles) ListAvailableReactions(ctx context.Context) ([]domain.AvailableReaction, error) { + f.calls++ + return f.fakeFiles.ListAvailableReactions(ctx) +} + +func mustChannelFullSomeReactions(t *testing.T, full *tg.MessagesChatFull) *tg.ChatReactionsSome { + t.Helper() + channelFull, ok := full.FullChat.(*tg.ChannelFull) + if !ok { + t.Fatalf("full chat = %T, want *tg.ChannelFull", full.FullChat) + } + reactions, ok := channelFull.GetAvailableReactions() + if !ok { + t.Fatalf("channel full reactions missing") + } + some, ok := reactions.(*tg.ChatReactionsSome) + if !ok { + t.Fatalf("channel full reactions = %T %+v, want *tg.ChatReactionsSome", reactions, reactions) + } + if len(some.Reactions) == 0 { + t.Fatalf("channel full reactions empty") + } + return some } diff --git a/internal/rpc/channels_read_reactions.go b/internal/rpc/channels_read_reactions.go index 10542538..9ab9c277 100644 --- a/internal/rpc/channels_read_reactions.go +++ b/internal/rpc/channels_read_reactions.go @@ -83,16 +83,22 @@ func (r *Router) onChannelsReadHistory(ctx context.Context, req *tg.ChannelsRead return true, nil } -func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsRequest) (domain.ChannelReactionPolicy, error) { +func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsRequest, current domain.ChannelReactionPolicy, defaultReactionDocuments map[int64]string) (domain.ChannelReactionPolicy, error) { if req == nil || req.AvailableReactions == nil { return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") } - if req.ReactionsLimit < 0 || req.ReactionsLimit > domain.MaxChannelReactionsLimit { - return domain.ChannelReactionPolicy{}, limitInvalidErr() - } policy := domain.ChannelReactionPolicy{ - Limit: req.ReactionsLimit, - PaidEnabled: req.PaidEnabled, + Limit: current.Limit, + PaidEnabled: current.PaidEnabled, + } + if limit, ok := req.GetReactionsLimit(); ok { + if limit < 0 || limit > domain.MaxChannelReactionsLimit { + return domain.ChannelReactionPolicy{}, limitInvalidErr() + } + policy.Limit = limit + } + if paidEnabled, ok := req.GetPaidEnabled(); ok { + policy.PaidEnabled = paidEnabled } switch reactions := req.AvailableReactions.(type) { case *tg.ChatReactionsNone: @@ -111,6 +117,7 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques if err != nil { return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID") } + parsed = normalizeDefaultReactionDocument(parsed, defaultReactionDocuments) key := parsed.Key() if _, ok := seen[key]; ok { continue diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index 429d0f93..21313f26 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -546,10 +546,10 @@ func tgChannelFull(view domain.ChannelView) *tg.ChannelFull { if ch.ReactionPolicy.Limit > 0 { full.SetReactionsLimit(ch.ReactionPolicy.Limit) } - // 付费 reaction(Stars)是广播频道默认能力——官方语义下 channelFull.paid_reactions_available - // 对广播频道恒真,客户端据此显示星按钮;megagroup 不支持。与 store 侧 - // AddChannelMessagePaidReaction 仅广播频道的门槛一致。显式 PaidEnabled 也保留。 - if (ch.Broadcast && !ch.Megagroup) || ch.ReactionPolicy.PaidEnabled { + // paid_reactions_available reflects the saved chat policy, not mere broadcast + // capability. Android counts this flag as an extra available reaction in the + // settings row, so advertising it without paid_enabled corrupts the UI count. + if ch.ReactionPolicy.PaidEnabled { full.SetPaidReactionsAvailable(true) } if ch.Broadcast && !ch.Megagroup { diff --git a/internal/rpc/messages_history.go b/internal/rpc/messages_history.go index 30509257..7dca9c94 100644 --- a/internal/rpc/messages_history.go +++ b/internal/rpc/messages_history.go @@ -765,6 +765,11 @@ func messagesSearchFilterMusic(filter tg.MessagesFilterClass) bool { return ok } +func messagesSearchFilterChatPhotos(filter tg.MessagesFilterClass) bool { + _, ok := filter.(*tg.InputMessagesFilterChatPhotos) + return ok +} + func searchFilterNeedsMediaStore(filter tg.MessagesFilterClass) bool { switch filter.(type) { case nil, *tg.InputMessagesFilterEmpty: diff --git a/internal/rpc/messages_history_rpc_test.go b/internal/rpc/messages_history_rpc_test.go index 6068426b..ae0947a8 100644 --- a/internal/rpc/messages_history_rpc_test.go +++ b/internal/rpc/messages_history_rpc_test.go @@ -154,6 +154,61 @@ func TestMessagesSearchChannelPeerReturnsSingleCopyMessages(t *testing.T) { } } +func TestMessagesSearchChatPhotosDoesNotReturnOrdinaryChannelHistory(t *testing.T) { + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 93500, Phone: "15550093500", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + channelService := appchannels.NewService(channelStore) + r := New(Config{}, Deps{ + Channels: channelService, + }, zaptest.NewLogger(t), clock.System) + created, err := channelService.CreateChannel(ctx, owner.ID, domain.CreateChannelRequest{ + Title: "Chat Photos Count Guard", Megagroup: true, Date: 1700034000, + }) + if err != nil { + t.Fatalf("create channel: %v", err) + } + channel := created.Channel + for i := 0; i < 7; i++ { + if _, err := channelService.SendMessage(ctx, owner.ID, domain.SendChannelMessageRequest{ + ChannelID: channel.ID, + RandomID: int64(1700034000 + i), + Message: "ordinary channel message", + Date: 1700034001 + i, + }); err != nil { + t.Fatalf("send ordinary message %d: %v", i, err) + } + } + + req := &tg.MessagesSearchRequest{ + Peer: &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}, + Filter: &tg.InputMessagesFilterChatPhotos{}, + Limit: 80, + } + var in bin.Buffer + if err := req.Encode(&in); err != nil { + t.Fatalf("encode chat photos search: %v", err) + } + enc, err := r.Dispatch(WithUserID(ctx, owner.ID), [8]byte{}, 0, &in) + if err != nil { + t.Fatalf("dispatch chat photos search: %v", err) + } + if box, ok := enc.(*tg.MessagesMessagesBox); ok { + enc = box.Messages + } + got, ok := enc.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("chat photos search result = %T, want messages.channelMessages", enc) + } + if got.Count != 0 || len(got.Messages) != 0 { + t.Fatalf("chat photos search = count %d messages %d, want explicit empty stub", got.Count, len(got.Messages)) + } + if len(got.Chats) != 1 { + t.Fatalf("chat photos search chats = %d, want current channel projection", len(got.Chats)) + } +} + func TestMessagesGetSearchCountersUsesMediaCategoryCounts(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() diff --git a/internal/rpc/messages_reactions_policy_rpc_test.go b/internal/rpc/messages_reactions_policy_rpc_test.go index 4ecf73b2..81bc845d 100644 --- a/internal/rpc/messages_reactions_policy_rpc_test.go +++ b/internal/rpc/messages_reactions_policy_rpc_test.go @@ -195,6 +195,34 @@ func TestSendReactionAllowsCustomEmojiFromChannelPolicy(t *testing.T) { } } +func TestSendReactionNormalizesDefaultReactionDocumentID(t *testing.T) { + f := newReactionPolicyFixture(t, false) + f.router.deps.Files = &fakeFiles{reactions: []domain.AvailableReaction{ + {Reaction: "\U0001f44d", ActivateAnimationID: 8801}, + }} + ctx := context.Background() + + if _, err := f.channelSvc.SetAvailableReactions(ctx, f.ownerID, f.channel.ID, domain.ChannelReactionPolicy{ + Type: domain.ChannelReactionPolicySome, + Emoticons: []string{"\U0001f44d"}, + }); err != nil { + t.Fatalf("set emoji whitelist policy: %v", err) + } + + updates, err := f.sendTLReactions(t, f.memberID, &tg.ReactionCustomEmoji{DocumentID: 8801}) + if err != nil { + t.Fatalf("send default reaction by document id: %v", err) + } + update := reactionUpdateFromUpdates(t, updates) + if len(update.Reactions.Results) != 1 { + t.Fatalf("reaction results = %+v, want one normalized emoji result", update.Reactions.Results) + } + emoji, ok := update.Reactions.Results[0].Reaction.(*tg.ReactionEmoji) + if !ok || emoji.Emoticon != "\U0001f44d" { + t.Fatalf("normalized reaction = %T %+v, want reactionEmoji thumbs up", update.Reactions.Results[0].Reaction, update.Reactions.Results[0].Reaction) + } +} + func TestSendReactionEnforcesUniqueReactionsLimit(t *testing.T) { f := newReactionPolicyFixture(t, false) ctx := context.Background() diff --git a/internal/rpc/messages_reactions_send.go b/internal/rpc/messages_reactions_send.go index c3932317..016fbdbe 100644 --- a/internal/rpc/messages_reactions_send.go +++ b/internal/rpc/messages_reactions_send.go @@ -23,6 +23,7 @@ func (r *Router) onMessagesSendReaction(ctx context.Context, req *tg.MessagesSen if err != nil { return nil, err } + reactions = r.normalizeDefaultReactionDocuments(ctx, reactions) // 官方语义(reactions_user_max_default/premium):向量尾部是最新选择, // 超出每用户上限丢弃旧的而非报错;premium viewer 用 premium 档(appConfig // reactions_user_max_premium=3),否则客户端允许的多 reaction 会被静默裁剪。 diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index 64e2b7fe..3c06529e 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -363,6 +363,21 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) { if isLegacyInputPeerChat(req.Peer) { return &tg.MessagesMessages{}, nil } + if messagesSearchFilterChatPhotos(req.Filter) { + view, err := r.resolveInputPeerChannelView(ctx, userID, req.Peer, filter.Peer.ID) + if err != nil { + return nil, channelInvalidErr(err) + } + out := &tg.MessagesChannelMessages{ + Pts: view.Channel.Pts, + Count: 0, + Messages: []tg.MessageClass{}, + Chats: []tg.ChatClass{tgChannelChatForView(userID, view)}, + Users: []tg.UserClass{}, + } + r.applyStoryMaxIDsToMessages(ctx, userID, out) + return out, nil + } if searchFilterNeedsMediaStore(req.Filter) { if mediaSearchCountOnlyRequest(req) { view, err := r.resolveInputPeerChannelView(ctx, userID, req.Peer, filter.Peer.ID) @@ -445,6 +460,12 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) { } return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil } + if messagesSearchFilterChatPhotos(req.Filter) { + if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer); err != nil { + return nil, err + } + return r.tgMessagesMessages(ctx, userID, domain.MessageList{}), nil + } if r.deps.Messages == nil { return messagesNotModifiedOrEmpty(req.Hash), nil } diff --git a/internal/rpc/reactions_android_compat.go b/internal/rpc/reactions_android_compat.go new file mode 100644 index 00000000..4bafedaa --- /dev/null +++ b/internal/rpc/reactions_android_compat.go @@ -0,0 +1,156 @@ +package rpc + +import ( + "context" + "strings" + "sync" + + "github.com/gotd/td/tg" + + "telesrv/internal/domain" +) + +// availableReactionDocumentMapCache caches the global reaction catalog mapping. +// Returned maps are shared and must be treated as read-only. +type availableReactionDocumentMapCache struct { + mu sync.RWMutex + loaded bool + emojiToDocumentID map[string]int64 + documentIDToEmoji map[int64]string +} + +func channelMemberCanChangeInfo(member domain.ChannelMember) bool { + return member.Role == domain.ChannelRoleCreator || + (member.Role == domain.ChannelRoleAdmin && member.AdminRights.ChangeInfo) +} + +func (r *Router) applyAndroidChannelReactionEditorCompat(ctx context.Context, full *tg.ChannelFull, canChangeInfo bool) { + if full == nil || !canChangeInfo || ClientTypeFrom(ctx) != ClientTypeAndroid { + return + } + raw, ok := full.GetAvailableReactions() + if !ok { + return + } + some, ok := raw.(*tg.ChatReactionsSome) + if !ok || len(some.Reactions) == 0 { + return + } + emojiToDocumentID, _ := r.availableReactionDocumentMaps(ctx) + if len(emojiToDocumentID) == 0 { + return + } + out := &tg.ChatReactionsSome{Reactions: make([]tg.ReactionClass, 0, len(some.Reactions))} + changed := false + for _, reaction := range some.Reactions { + if emoji, ok := reaction.(*tg.ReactionEmoji); ok && emoji != nil { + if documentID := emojiToDocumentID[strings.TrimSpace(emoji.Emoticon)]; documentID > 0 { + out.Reactions = append(out.Reactions, &tg.ReactionCustomEmoji{DocumentID: documentID}) + changed = true + continue + } + } + out.Reactions = append(out.Reactions, reaction) + } + if changed { + full.SetAvailableReactions(out) + } +} + +func (r *Router) normalizeDefaultReactionDocuments(ctx context.Context, reactions []domain.MessageReaction) []domain.MessageReaction { + needsCatalog := false + for _, reaction := range reactions { + if reaction.Type == domain.MessageReactionCustomEmoji && reaction.DocumentID > 0 { + needsCatalog = true + break + } + } + if !needsCatalog { + return reactions + } + _, documentIDToEmoji := r.availableReactionDocumentMaps(ctx) + if len(documentIDToEmoji) == 0 { + return reactions + } + out := make([]domain.MessageReaction, 0, len(reactions)) + seen := make(map[string]struct{}, len(reactions)) + for _, reaction := range reactions { + normalized := normalizeDefaultReactionDocument(reaction, documentIDToEmoji) + key := normalized.Key() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, normalized) + } + return out +} + +func normalizeDefaultReactionDocument(reaction domain.MessageReaction, documentIDToEmoji map[int64]string) domain.MessageReaction { + if reaction.Type != domain.MessageReactionCustomEmoji || reaction.DocumentID <= 0 { + return reaction + } + if emoticon := documentIDToEmoji[reaction.DocumentID]; emoticon != "" { + return domain.MessageReaction{Type: domain.MessageReactionEmoji, Emoticon: emoticon} + } + return reaction +} + +func (r *Router) availableReactionDocumentMaps(ctx context.Context) (map[string]int64, map[int64]string) { + if r == nil || r.deps.Files == nil { + return nil, nil + } + return r.availableReactionDocuments.get(ctx, r.deps.Files) +} + +func (c *availableReactionDocumentMapCache) get(ctx context.Context, files FilesService) (map[string]int64, map[int64]string) { + if files == nil { + return nil, nil + } + c.mu.RLock() + if c.loaded { + emojiToDocumentID, documentIDToEmoji := c.emojiToDocumentID, c.documentIDToEmoji + c.mu.RUnlock() + return emojiToDocumentID, documentIDToEmoji + } + c.mu.RUnlock() + + c.mu.Lock() + defer c.mu.Unlock() + if c.loaded { + return c.emojiToDocumentID, c.documentIDToEmoji + } + catalog, err := files.ListAvailableReactions(ctx) + if err != nil { + return nil, nil + } + c.emojiToDocumentID, c.documentIDToEmoji = buildAvailableReactionDocumentMaps(catalog) + c.loaded = true + return c.emojiToDocumentID, c.documentIDToEmoji +} + +func (c *availableReactionDocumentMapCache) clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.loaded = false + c.emojiToDocumentID = nil + c.documentIDToEmoji = nil +} + +func buildAvailableReactionDocumentMaps(catalog []domain.AvailableReaction) (map[string]int64, map[int64]string) { + emojiToDocumentID := make(map[string]int64, len(catalog)) + documentIDToEmoji := make(map[int64]string, len(catalog)) + for _, item := range catalog { + emoticon := strings.TrimSpace(item.Reaction) + if item.Inactive || emoticon == "" || item.ActivateAnimationID <= 0 { + continue + } + if _, exists := emojiToDocumentID[emoticon]; !exists { + emojiToDocumentID[emoticon] = item.ActivateAnimationID + } + if _, exists := documentIDToEmoji[item.ActivateAnimationID]; !exists { + documentIDToEmoji[item.ActivateAnimationID] = emoticon + } + } + return emojiToDocumentID, documentIDToEmoji +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go index 49a3af9e..b1f8a74f 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -125,6 +125,7 @@ type Router struct { userFullProjectionCache *userFullProjectionCache peerSettingsProjectionCache *peerSettingsProjectionCache channelFullProjectionCache *channelFullProjectionCache + availableReactionDocuments availableReactionDocumentMapCache emojiStickers *emojiStickerIndex notifySettings *notifySettingsCache stickerCatalog *stickerCatalogCache diff --git a/internal/rpc/rpc_projection_cache.go b/internal/rpc/rpc_projection_cache.go index 2cc284f5..e1b70de2 100644 --- a/internal/rpc/rpc_projection_cache.go +++ b/internal/rpc/rpc_projection_cache.go @@ -126,10 +126,11 @@ type channelFullProjectionKey struct { } type channelFullProjection struct { - accessHash int64 - full tg.ChannelFull - chats []tg.ChatClass - userIDs []int64 + accessHash int64 + canChangeInfo bool + full tg.ChannelFull + chats []tg.ChatClass + userIDs []int64 } type channelFullProjectionCache struct { @@ -180,10 +181,11 @@ func (c *channelFullProjectionCache) DeletePair(viewerUserID, channelID int64) { func cloneChannelFullProjection(in channelFullProjection) channelFullProjection { return channelFullProjection{ - accessHash: in.accessHash, - full: cloneChannelFull(in.full), - chats: cloneChatClasses(in.chats), - userIDs: cloneInt64s(in.userIDs), + accessHash: in.accessHash, + canChangeInfo: in.canChangeInfo, + full: cloneChannelFull(in.full), + chats: cloneChatClasses(in.chats), + userIDs: cloneInt64s(in.userIDs), } }