From 7fa721a25e5dee233617be574b2507da70a314d5 Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 14:17:49 +0800 Subject: [PATCH 1/8] 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), } } From e54d56da64fc7dab636e985dea858016b5cd0e3a Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 14:24:50 +0800 Subject: [PATCH 2/8] chore: add public test RSA keys --- data/server_rsa.pem | 27 +++++++++++++++++++++++++++ data/server_rsa.pub | 8 ++++++++ 2 files changed, 35 insertions(+) create mode 100644 data/server_rsa.pem create mode 100644 data/server_rsa.pub diff --git a/data/server_rsa.pem b/data/server_rsa.pem new file mode 100644 index 00000000..aa8675a4 --- /dev/null +++ b/data/server_rsa.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAxF//0M0+/5PzgdNagTX+J+dJgr75ZCTuiG8i4x7YwmJF+jiO +GCjm7X7BLCaMc1+hOZYDL3+Gvle/AKykW1qouaCJMVx/H+2l8LFXLelZ2PLawTb8 +A7BlTqWzL3db5BugMNWziL9TuhR8In1bwKY07QVpR9in5zjAsAGLBk+mGt0DnVyM +f1Xop2lLCFNmm0F4ykcAeaLCCIPbGWddliLY8xEEhI4GO2l1U3kZMwIOdOnAGJFt +gUAoTe+FHR6F1s9adCVZB1teL/hf9R+WmekJwygVz0MYEH7y6U49T45+/W7OF6X6 +g0W0j1uSSrsY4qN7twxbTad9zdGZ7ys+9v+PuQIDAQABAoIBAAjuqO8xds0fSLM+ +Dt7TuuTLw288CpIAkA0KqRaVn5xv5ej0y5nTunVRD65XbooM7oN1DF4LufBMg3af +Y7Z4EDapU7Q4Fdw47hRdrK5878ZLfaHOQ3ZTfrguF1FwZ3CfxRCTl9//e+p55g+X +jiXcKYoideB7vV9qGHGpEE7QLqkIEinMEWNaB8utctH7TXdWa4pyfIGiPrHMN2zH +hF40Ib7nJA6khtzsNCDSD9XnblENEod1E5gRsjQ9fGshdBtpDsXrLBFL7KMDDoQm +czrt/KxlZMpbtOfz6ua5eKEBBTtNutmVcp1q9y+cQpPZznYiRTL+OJzVgfuHOO/0 +ea2j/wECgYEA6I6f+ELo8AP7Q4W0qssuXKvHEF/REEfx2Lueh0lrUhVR3IgjakFc +bTRlLEQK8QNbDAmNMuHrEZnjmGu7ny3zN94slC/ycI5pd9hQGVwO22er9o2+M9w2 +JpB4Qf8c8SK/pHQN9+jOtBxV2drbfe0oUoKEtYyD5cbtjFT3yfeA2CECgYEA2Cuo +S841kVppx1KwpL4i1fgGQPykaKrD4oGZrYgv2FxW55i/q5FcYL6fE37JaM1po/RB +tnHg35Nang/yuZXz6Eb4K0CyL+8LuhCIb6PxDvqyCXbkXTwHSVRAnGEMSN3za/7F +FCJF1ABWg8jlKRdA/lRwqo8P8ZgBeBMqpKZh5JkCgYBhCImk2743z2F6tgJBNU/d +6OrBYUlpIqu9g+NMjYzTDgQ/ISqtviHjivYf9zAdignmRuH8dhlQGcbGJUV+0Hxo +NjKhjjP5VOKa183sFuY4E9TDpjbTirGpe6RB3UFlN9uAsc/WPfRpYF1N7iyhKWAm +tUDMQ5oROOSLJjTRt4yyIQKBgQCTfy5lEv2wAP399+j5b5a7YnF59CiGFkZ0DbP7 +GNp0iYTun2Xgvd1HUamfFrp8nPQA38/amdczFgsVoJIgmTWEfrAkaw9p73ST72X4 +rubzLpE+LfZhu2zJVwiC6yEDsxW90WdNdpkor1ZYs0HRiMFbB+icJ+NcGDigYouN +Ls3KuQKBgQCTAxVM7zNPgsffudZP7FMny20LMR6+FQo7xRz5ElYaCVgXM1NH4K/r +yFkOCGhON6I/MUNBPxWLEuZBRfyrly3bp3j7R6/hLX4fNyw9B3GQuVyCHD2D4tod +6J5Vo/EseqpeeKa6Cv/Y2HVB0kfGw1VCC/gM01L8ued3hsr2LD2kCw== +-----END RSA PRIVATE KEY----- diff --git a/data/server_rsa.pub b/data/server_rsa.pub new file mode 100644 index 00000000..afe012cf --- /dev/null +++ b/data/server_rsa.pub @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAxF//0M0+/5PzgdNagTX+J+dJgr75ZCTuiG8i4x7YwmJF+jiOGCjm +7X7BLCaMc1+hOZYDL3+Gvle/AKykW1qouaCJMVx/H+2l8LFXLelZ2PLawTb8A7Bl +TqWzL3db5BugMNWziL9TuhR8In1bwKY07QVpR9in5zjAsAGLBk+mGt0DnVyMf1Xo +p2lLCFNmm0F4ykcAeaLCCIPbGWddliLY8xEEhI4GO2l1U3kZMwIOdOnAGJFtgUAo +Te+FHR6F1s9adCVZB1teL/hf9R+WmekJwygVz0MYEH7y6U49T45+/W7OF6X6g0W0 +j1uSSrsY4qN7twxbTad9zdGZ7ys+9v+PuQIDAQAB +-----END RSA PUBLIC KEY----- From 7bb7e116035912c4a94b4d06fc705f0c464771ff Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 14:25:56 +0800 Subject: [PATCH 3/8] chore: add public Android test RSA key --- data/server_rsa_android.pub | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 data/server_rsa_android.pub diff --git a/data/server_rsa_android.pub b/data/server_rsa_android.pub new file mode 100644 index 00000000..afe012cf --- /dev/null +++ b/data/server_rsa_android.pub @@ -0,0 +1,8 @@ +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAxF//0M0+/5PzgdNagTX+J+dJgr75ZCTuiG8i4x7YwmJF+jiOGCjm +7X7BLCaMc1+hOZYDL3+Gvle/AKykW1qouaCJMVx/H+2l8LFXLelZ2PLawTb8A7Bl +TqWzL3db5BugMNWziL9TuhR8In1bwKY07QVpR9in5zjAsAGLBk+mGt0DnVyMf1Xo +p2lLCFNmm0F4ykcAeaLCCIPbGWddliLY8xEEhI4GO2l1U3kZMwIOdOnAGJFtgUAo +Te+FHR6F1s9adCVZB1teL/hf9R+WmekJwygVz0MYEH7y6U49T45+/W7OF6X6g0W0 +j1uSSrsY4qN7twxbTad9zdGZ7ys+9v+PuQIDAQAB +-----END RSA PUBLIC KEY----- From 4d3bbeabd81cdee3bd23f9ea10a7856f3d9a9a1e Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 21:14:41 +0800 Subject: [PATCH 4/8] fix: sync WebA labels and forward fixes --- data/langpack/weba/weba_en_v12000002.strings | 66 ++++++++++++ internal/app/langpack/service.go | 104 +++++++++++++++++-- internal/app/langpack/service_test.go | 81 +++++++++++++++ internal/rpc/langpack.go | 10 +- internal/rpc/langpack_compat_test.go | 5 + internal/rpc/messages_constants.go | 6 +- internal/rpc/messages_forward.go | 3 + internal/rpc/messages_forward_rpc_test.go | 70 +++++++++++++ internal/rpc/messages_send.go | 3 + internal/rpc/messages_send_rpc_test.go | 31 ++++++ internal/rpc/messages_todos_rpc_test.go | 53 ++++++++++ 11 files changed, 422 insertions(+), 10 deletions(-) create mode 100644 data/langpack/weba/weba_en_v12000002.strings create mode 100644 internal/app/langpack/service_test.go diff --git a/data/langpack/weba/weba_en_v12000002.strings b/data/langpack/weba/weba_en_v12000002.strings new file mode 100644 index 00000000..9e4f4330 --- /dev/null +++ b/data/langpack/weba/weba_en_v12000002.strings @@ -0,0 +1,66 @@ +"AccDescrMentionDown" = "Go to next mention"; +"AccDescrPageDown" = "Go to bottom"; +"AccDescrPollVoteDown" = "Go to next unread poll vote"; +"AccDescrReactionMentionDown" = "Go to next unread reactions"; +"ArchivedChats" = "Archived Chats"; +"MenuArchivedChats" = "Archived Chats"; +"NewMessageTitle" = "New Message"; +"NewChannel" = "New Channel"; +"NewGroup" = "New Group"; +"MessageUnsupported" = "Unsupported message"; + +"AttachTodo" = "Checklist"; +"TitleNewToDoList" = "New Checklist"; +"TitleEditToDoList" = "Edit Checklist"; +"TitleAppendToDoList" = "Add Task"; +"TitleToDoList" = "Checklist"; +"TitleTask" = "Task"; +"TitleAddTask" = "Add a task"; +"AllowOthersAddTasks" = "Allow Others to Add Tasks"; +"AriaToDoCancel" = "Cancel checklist creation"; +"TitleGroupToDoList" = "Group Checklist"; +"TitleUserToDoList" = "{peer}'s Checklist"; +"TitleYourToDoList" = "Your Checklist"; +"DescriptionCompletedToDoTasks" = "{number} of {count} completed"; +"MessageActionTodoCompletionsAsDone" = "{peer} marked \"{task}\" as done"; +"MessageActionTodoCompletionsAsDoneYou" = "You marked \"{task}\" as done"; +"MessageActionTodoCompletionsAsDoneMultiple" = "{peer} marked {tasks} as done"; +"MessageActionTodoCompletionsAsDoneMultipleYou" = "You marked {tasks} as done"; +"MessageActionTodoCompletionsAsNotDone" = "{peer} marked \"{task}\" as not done"; +"MessageActionTodoCompletionsAsNotDoneYou" = "You marked \"{task}\" as not done"; +"MessageActionTodoCompletionsAsNotDoneMultiple" = "{peer} marked {tasks} as not done"; +"MessageActionTodoCompletionsAsNotDoneMultipleYou" = "You marked {tasks} as not done"; +"MessageActionTodoTaskCount#one" = "{count} task"; +"MessageActionTodoTaskCount#other" = "{count} tasks"; +"MenuButtonAppendTodoList" = "Add a Task"; +"MessageActionAppendTodo" = "{peer} added a new task \"{task}\" to {list}"; +"MessageActionAppendTodoYou" = "You added a new task \"{task}\" to {list}"; +"MessageActionAppendTodoMultiple" = "{peer} added {tasks} to {list}"; +"MessageActionAppendTodoMultipleYou" = "You added {tasks} to {list}"; +"SubscribeToTelegramPremiumForToggleTask" = "Subscribe to **Telegram Premium** to toggle tasks"; +"SubscribeToTelegramPremiumForCreateToDo" = "Subscribe to **Telegram Premium** to create Checklists"; +"SubscribeToTelegramPremiumForAppendToDo" = "Subscribe to **Telegram Premium** to add tasks"; +"HintTodoListTasksCount2#one" = "You can add {count} more task"; +"HintTodoListTasksCount2#other" = "You can add {count} more tasks"; +"ToDoListErrorChooseTitle" = "Please enter a title."; +"ToDoListErrorChooseTasks" = "Please enter at least one task."; +"PremiumPreviewTodo" = "Checklists"; +"PremiumPreviewTodoDescription" = "Plan, assign, and complete tasks - seamlessly and efficiently."; + +"Weekday.ShortSunday" = "Sun"; +"Weekday.ShortMonday" = "Mon"; +"Weekday.ShortTuesday" = "Tue"; +"Weekday.ShortWednesday" = "Wed"; +"Weekday.ShortThursday" = "Thu"; +"Weekday.ShortFriday" = "Fri"; +"Weekday.ShortSaturday" = "Sat"; + +"Weekday.Sunday" = "Sunday"; +"Weekday.Monday" = "Monday"; +"Weekday.Tuesday" = "Tuesday"; +"Weekday.Wednesday" = "Wednesday"; +"Weekday.Thursday" = "Thursday"; +"Weekday.Friday" = "Friday"; +"Weekday.Saturday" = "Saturday"; +"Weekday.Today" = "Today"; +"Weekday.Yesterday" = "Yesterday"; diff --git a/internal/app/langpack/service.go b/internal/app/langpack/service.go index 1161ed35..5a216188 100644 --- a/internal/app/langpack/service.go +++ b/internal/app/langpack/service.go @@ -2,6 +2,7 @@ package langpack import ( "context" + "strings" "telesrv/internal/domain" "telesrv/internal/store" @@ -24,18 +25,41 @@ func (s *Service) GetLangPack(ctx context.Context, langPack, langCode string) (d // GetDifference 返回从 fromVersion 到当前版本的语言包差异。 func (s *Service) GetDifference(ctx context.Context, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + packName := normalizePack(langPack) + code := normalizeCode(langCode) if s == nil || s.packs == nil { - return domain.LangPack{LangPack: langPack, LangCode: langCode, FromVersion: fromVersion}, nil + return domain.LangPack{LangPack: packName, LangCode: code, FromVersion: fromVersion}, nil } - return s.packs.GetPack(ctx, normalizePack(langPack), normalizeCode(langCode), fromVersion) + pack, err := s.packs.GetPack(ctx, packName, code, fromVersion) + if err != nil { + return domain.LangPack{}, err + } + return s.overlayWebAStrings(ctx, pack, packName, code, fromVersion) } // GetStrings 返回指定 key 的语言包字符串。 func (s *Service) GetStrings(ctx context.Context, langPack, langCode string, keys []string) (domain.LangPack, error) { + packName := normalizePack(langPack) + code := normalizeCode(langCode) if s == nil || s.packs == nil { - return domain.LangPack{LangPack: langPack, LangCode: langCode}, nil + return domain.LangPack{LangPack: packName, LangCode: code}, nil } - return s.packs.GetStrings(ctx, normalizePack(langPack), normalizeCode(langCode), keys) + pack, err := s.packs.GetStrings(ctx, packName, code, keys) + if err != nil { + return domain.LangPack{}, err + } + if len(keys) == 0 { + return s.overlayWebAStrings(ctx, pack, packName, code, 0) + } + missing := missingLangPackKeys(keys, pack.Strings) + if len(missing) == 0 || !shouldOverlayWebA(packName) { + return pack, nil + } + overlay, err := s.packs.GetStrings(ctx, "weba", code, missing) + if err != nil { + return domain.LangPack{}, err + } + return mergeMissingLangPackStrings(pack, overlay), nil } func normalizePack(langPack string) string { @@ -46,8 +70,76 @@ func normalizePack(langPack string) string { } func normalizeCode(langCode string) string { - if langCode == "" { + code := strings.ToLower(strings.TrimSpace(langCode)) + if code == "" { return "en" } - return langCode + return strings.TrimSuffix(code, "-raw") +} + +func shouldOverlayWebA(langPack string) bool { + switch strings.ToLower(langPack) { + case "android", "ios", "tdesktop", "macos": + return true + default: + return false + } +} + +func (s *Service) overlayWebAStrings(ctx context.Context, pack domain.LangPack, langPack, langCode string, fromVersion int) (domain.LangPack, error) { + if fromVersion != 0 || !shouldOverlayWebA(langPack) { + return pack, nil + } + overlay, err := s.packs.GetPack(ctx, "weba", langCode, fromVersion) + if err != nil { + return domain.LangPack{}, err + } + return mergeMissingLangPackStrings(pack, overlay), nil +} + +func mergeMissingLangPackStrings(pack, overlay domain.LangPack) domain.LangPack { + if len(overlay.Strings) == 0 { + return pack + } + if pack.LangCode == "" { + pack.LangCode = overlay.LangCode + } + if overlay.Version > pack.Version { + pack.Version = overlay.Version + } + seen := make(map[string]struct{}, len(pack.Strings)+len(overlay.Strings)) + for _, item := range pack.Strings { + seen[item.Key] = struct{}{} + } + for _, item := range overlay.Strings { + if _, ok := seen[item.Key]; ok { + continue + } + pack.Strings = append(pack.Strings, item) + seen[item.Key] = struct{}{} + } + return pack +} + +func missingLangPackKeys(keys []string, strings []domain.LangPackString) []string { + if len(keys) == 0 { + return nil + } + have := make(map[string]struct{}, len(strings)) + for _, item := range strings { + have[item.Key] = struct{}{} + } + missing := make([]string, 0) + seenMissing := make(map[string]struct{}, len(keys)) + for _, key := range keys { + if _, ok := have[key]; ok { + continue + } + if _, ok := seenMissing[key]; ok { + continue + } + missing = append(missing, key) + seenMissing[key] = struct{}{} + } + return missing } diff --git a/internal/app/langpack/service_test.go b/internal/app/langpack/service_test.go new file mode 100644 index 00000000..1c220ed4 --- /dev/null +++ b/internal/app/langpack/service_test.go @@ -0,0 +1,81 @@ +package langpack + +import ( + "context" + "testing" + + "telesrv/internal/domain" + "telesrv/internal/store/memory" +) + +func TestServiceNormalizesWebARawLangCode(t *testing.T) { + ctx := context.Background() + packs := memory.NewLangPackStore() + svc := NewService(packs) + seed := domain.LangPack{ + LangPack: "android", + LangCode: "en", + Version: 7, + Strings: []domain.LangPackString{ + {Key: "LogOutTitle", Value: "Log Out"}, + {Key: "NewMessageTitle", Value: "New Message"}, + }, + } + if err := packs.UpsertPack(ctx, seed); err != nil { + t.Fatalf("seed langpack: %v", err) + } + webASeed := domain.LangPack{ + LangPack: "weba", + LangCode: "en", + Version: 12, + Strings: []domain.LangPackString{ + {Key: "AccDescrPollVoteDown", Value: "Go to next unread poll vote"}, + {Key: "NewMessageTitle", Value: "New Message from WebA"}, + }, + } + if err := packs.UpsertPack(ctx, webASeed); err != nil { + t.Fatalf("seed weba langpack: %v", err) + } + + pack, err := svc.GetLangPack(ctx, "android", "EN-raw") + if err != nil { + t.Fatalf("get langpack: %v", err) + } + if pack.LangCode != "en" || pack.Version != webASeed.Version || len(pack.Strings) != len(seed.Strings)+1 { + t.Fatalf("pack = %+v, want normalized en pack", pack) + } + if got := stringValue(pack.Strings, "AccDescrPollVoteDown"); got != "Go to next unread poll vote" { + t.Fatalf("AccDescrPollVoteDown = %q, want WebA fallback", got) + } + if got := stringValue(pack.Strings, "NewMessageTitle"); got != "New Message" { + t.Fatalf("NewMessageTitle = %q, want source pack to keep precedence", got) + } + + selected, err := svc.GetStrings(ctx, "android", "en-raw", []string{"LogOutTitle", "AccDescrPollVoteDown"}) + if err != nil { + t.Fatalf("get strings: %v", err) + } + if got := stringValue(selected.Strings, "LogOutTitle"); got != "Log Out" { + t.Fatalf("LogOutTitle = %q, want source pack value", got) + } + if got := stringValue(selected.Strings, "AccDescrPollVoteDown"); got != "Go to next unread poll vote" { + t.Fatalf("AccDescrPollVoteDown = %q, want WebA fallback", got) + } + + notModified, err := svc.GetDifference(ctx, "android", "en-raw", seed.Version) + if err != nil { + t.Fatalf("get difference: %v", err) + } + if notModified.LangCode != "en" || len(notModified.Strings) != 0 { + t.Fatalf("difference = %+v, want normalized not-modified source pack", notModified) + } +} + +func stringValue(strings []domain.LangPackString, key string) string { + for _, item := range strings { + if item.Key == key { + return item.Value + } + } + return "" +} diff --git a/internal/rpc/langpack.go b/internal/rpc/langpack.go index fc0846a4..09dc677a 100644 --- a/internal/rpc/langpack.go +++ b/internal/rpc/langpack.go @@ -72,7 +72,7 @@ func (r *Router) langpackLanguage(ctx context.Context, langPack, langCode string langCode = "en" } } - langCode = strings.ToLower(langCode) + langCode = normalizeLangpackCode(langCode) languages := r.langpackLanguages(ctx, langPack) for _, lang := range languages { if strings.ToLower(lang.LangCode) == langCode { @@ -150,3 +150,11 @@ func langPackFromClient(ctx context.Context) string { } return "tdesktop" } + +func normalizeLangpackCode(langCode string) string { + code := strings.ToLower(strings.TrimSpace(langCode)) + if code == "" { + return "en" + } + return strings.TrimSuffix(code, "-raw") +} diff --git a/internal/rpc/langpack_compat_test.go b/internal/rpc/langpack_compat_test.go index cc080f54..b5ad1c16 100644 --- a/internal/rpc/langpack_compat_test.go +++ b/internal/rpc/langpack_compat_test.go @@ -68,6 +68,11 @@ func TestLangpackGetLanguage(t *testing.T) { if lang.LangCode != "zh-hans" || lang.PluralCode != "zh" { t.Fatalf("language = %+v, want zh-hans", lang) } + + raw := r.langpackLanguage(context.Background(), "tdesktop", "en-raw") + if raw.LangCode != "en" { + t.Fatalf("language(en-raw) = %+v, want en", raw) + } } func TestLangpackAndroidPersianLanguage(t *testing.T) { diff --git a/internal/rpc/messages_constants.go b/internal/rpc/messages_constants.go index ad9ec030..2d50bffa 100644 --- a/internal/rpc/messages_constants.go +++ b/internal/rpc/messages_constants.go @@ -30,9 +30,9 @@ const ( maxPollVotesOffsetLength = 128 maxTodoItems = 30 maxTodoTitleLength = 200 - // maxTodoItemID 是清单项 id 的防御上限:协议只要求列表内唯一正整数(客户端通常 - // 顺序分配),不能用条目数上限当 id 边界,否则非顺序分配的合法 id 被误拒。 - maxTodoItemID = 1 << 16 + // maxTodoItemID 是清单项 id 的防御上限:协议字段是 int32,WebA 会用 8 位左右 + // 的稀疏本地 id,不能用条目数上限或顺序分配假设当 id 边界。 + maxTodoItemID = 1<<31 - 1 maxVenueTitleLength = 256 maxVenueAddressLength = 512 maxVenueProviderLength = 64 diff --git a/internal/rpc/messages_forward.go b/internal/rpc/messages_forward.go index 7c38b98d..6fe26f0b 100644 --- a/internal/rpc/messages_forward.go +++ b/internal/rpc/messages_forward.go @@ -30,6 +30,9 @@ func (r *Router) onMessagesForwardMessages(ctx context.Context, req *tg.Messages if !topMsgIDSet && req.TopMsgID != 0 { topMsgID, topMsgIDSet = req.TopMsgID, true } + if topMsgIDSet && topMsgID == -1 { + topMsgID, topMsgIDSet = 0, false + } if topMsgIDSet && (topMsgID < 0 || topMsgID > domain.MaxMessageBoxID) { return nil, replyMessageIDInvalidErr() } diff --git a/internal/rpc/messages_forward_rpc_test.go b/internal/rpc/messages_forward_rpc_test.go index 6e0429b3..82be8a42 100644 --- a/internal/rpc/messages_forward_rpc_test.go +++ b/internal/rpc/messages_forward_rpc_test.go @@ -91,6 +91,52 @@ func TestMessagesForwardMessagesRecordsRequestAndReturnsUpdates(t *testing.T) { } } +func TestMessagesForwardMessagesTreatsMainThreadTopMsgSentinelAsAbsent(t *testing.T) { + const ( + ownerID = int64(1000000101) + fromID = int64(1000000102) + toID = int64(1000000103) + ) + ctx := context.Background() + messages := &captureMessages{list: domain.MessageList{Messages: []domain.Message{ + { + ID: 8, + OwnerUserID: ownerID, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: fromID}, + From: domain.Peer{Type: domain.PeerTypeUser, ID: fromID}, + Date: 1700000108, + Body: "main thread source", + }, + }}} + r := New(Config{}, Deps{ + Messages: messages, + Users: mapUsersService{users: map[int64]domain.User{ + ownerID: {ID: ownerID, FirstName: "Owner"}, + fromID: {ID: fromID, FirstName: "From"}, + toID: {ID: toID, FirstName: "To"}, + }}, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: fromID}, + ToPeer: &tg.InputPeerUser{UserID: toID}, + ID: []int{8}, + RandomID: []int64{8001}, + } + req.SetTopMsgID(-1) + + updatesClass, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), req) + if err != nil { + t.Fatalf("forward with main thread top_msg_id sentinel: %v", err) + } + if messages.sendReq.ReplyTo != nil { + t.Fatalf("reply = %+v, want nil for main thread sentinel", messages.sendReq.ReplyTo) + } + updates, ok := updatesClass.(*tg.Updates) + if !ok || len(updates.Updates) != 2 { + t.Fatalf("updates = %T %+v, want updateMessageID + updateNewMessage", updatesClass, updatesClass) + } +} + func TestMessagesForwardMessagesLoadsPrivateSourcesInSingleBatch(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() @@ -312,6 +358,30 @@ func TestMessagesForwardMessagesInputPeerEmptyRejectsBadIDsBeforeLookup(t *testi } } +func TestMessagesForwardMessagesRejectsOtherNegativeTopMsgID(t *testing.T) { + const ownerID = int64(1780243210) + ctx := context.Background() + messages := &captureMessages{} + r := New(Config{}, Deps{ + Messages: messages, + }, zaptest.NewLogger(t), clock.System) + req := &tg.MessagesForwardMessagesRequest{ + FromPeer: &tg.InputPeerUser{UserID: 1780243211}, + ToPeer: &tg.InputPeerUser{UserID: 1780243212}, + ID: []int{1}, + RandomID: []int64{10001}, + } + req.SetTopMsgID(-2) + + _, err := r.onMessagesForwardMessages(WithUserID(ctx, ownerID), req) + if err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("forward negative top_msg_id err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } + if messages.getMessagesCalls != 0 { + t.Fatalf("GetMessages calls = %d, want no source lookup for invalid top_msg_id", messages.getMessagesCalls) + } +} + func TestMessagesForwardMessagesNormalizesAndroidDuplicateIDRetry(t *testing.T) { const ( ownerID = int64(1780243210) diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index e93de0c3..ed74deca 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -201,6 +201,9 @@ func (r *Router) messageReplyFromInput(ctx context.Context, userID int64, peer d return nil, inputConstructorInvalidErr() } } + if reply.Zero() { + return nil, nil + } if _, ok := reply.GetMonoforumPeerID(); ok { return nil, replyToMonoforumPeerInvalidErr() } diff --git a/internal/rpc/messages_send_rpc_test.go b/internal/rpc/messages_send_rpc_test.go index 639584ff..bbd76bdb 100644 --- a/internal/rpc/messages_send_rpc_test.go +++ b/internal/rpc/messages_send_rpc_test.go @@ -244,6 +244,37 @@ func TestMessageReplyFromInputStorySucceedsAndProjectsStoryHeader(t *testing.T) } } +func TestMessageReplyFromInputEmptyMessageIsAbsent(t *testing.T) { + const userID = int64(1000000001) + ctx := WithUserID(context.Background(), userID) + r := New(Config{}, Deps{}, zaptest.NewLogger(t), clock.System) + peer := domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002} + + reply, err := r.messageReplyFromInput(ctx, userID, peer, &tg.InputReplyToMessage{}) + if err != nil { + t.Fatalf("empty reply err = %v, want nil", err) + } + if reply != nil { + t.Fatalf("empty reply = %+v, want nil", reply) + } + + topicReply := &tg.InputReplyToMessage{} + topicReply.SetTopMsgID(123) + reply, err = r.messageReplyFromInput(ctx, userID, peer, topicReply) + if err != nil { + t.Fatalf("topic-only reply err = %v, want nil", err) + } + if reply == nil || reply.MessageID != 0 || reply.TopMessageID != 123 { + t.Fatalf("topic-only reply = %+v, want top_msg_id=123", reply) + } + + quoteOnly := &tg.InputReplyToMessage{} + quoteOnly.SetQuoteText("orphan quote") + if _, err := r.messageReplyFromInput(ctx, userID, peer, quoteOnly); err == nil || !strings.Contains(err.Error(), "REPLY_MESSAGE_ID_INVALID") { + t.Fatalf("quote-only reply err = %v, want REPLY_MESSAGE_ID_INVALID", err) + } +} + func TestMessageReplyFromInputUnsupportedShapesReturnExplicitErrors(t *testing.T) { const userID = int64(1000000001) ctx := WithUserID(context.Background(), userID) diff --git a/internal/rpc/messages_todos_rpc_test.go b/internal/rpc/messages_todos_rpc_test.go index ea397707..a561cf51 100644 --- a/internal/rpc/messages_todos_rpc_test.go +++ b/internal/rpc/messages_todos_rpc_test.go @@ -53,6 +53,59 @@ func TestSendMediaTodoEcho(t *testing.T) { } } +func TestSendMediaTodoWithEmptyReplyToTreatsReplyAsAbsent(t *testing.T) { + r, owner, friend := newMediaTestRouter(t) + req := &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaTodo{Todo: tg.TodoList{ + Title: tg.TextWithEntities{Text: "web checklist", Entities: []tg.MessageEntityClass{}}, + List: []tg.TodoItem{ + {ID: 1, Title: tg.TextWithEntities{Text: "send item", Entities: []tg.MessageEntityClass{}}}, + }, + }}, + RandomID: 7005, + } + req.SetReplyTo(&tg.InputReplyToMessage{}) + + updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), owner.ID), req) + if err != nil { + t.Fatalf("sendMedia todo with empty reply: %v", err) + } + msg := newMessageFromUpdates(t, updates) + if msg.ReplyTo != nil { + t.Fatalf("reply_to = %T, want nil for empty input reply", msg.ReplyTo) + } + if media, ok := msg.Media.(*tg.MessageMediaToDo); !ok || media.Todo.Title.Text != "web checklist" { + t.Fatalf("media = %#v, want todo checklist", msg.Media) + } +} + +func TestSendMediaTodoAcceptsSparseLargeItemIDs(t *testing.T) { + r, owner, friend := newMediaTestRouter(t) + updates, err := r.onMessagesSendMedia(WithUserID(context.Background(), owner.ID), &tg.MessagesSendMediaRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + Media: &tg.InputMediaTodo{Todo: tg.TodoList{ + Title: tg.TextWithEntities{Text: "web sparse ids", Entities: []tg.MessageEntityClass{}}, + List: []tg.TodoItem{ + {ID: 56151290, Title: tg.TextWithEntities{Text: "first", Entities: []tg.MessageEntityClass{}}}, + {ID: 56151305, Title: tg.TextWithEntities{Text: "second", Entities: []tg.MessageEntityClass{}}}, + }, + }}, + RandomID: 7006, + }) + if err != nil { + t.Fatalf("sendMedia todo sparse ids: %v", err) + } + msg := newMessageFromUpdates(t, updates) + media, ok := msg.Media.(*tg.MessageMediaToDo) + if !ok { + t.Fatalf("media = %T, want MessageMediaToDo", msg.Media) + } + if got := media.Todo.List[0].ID; got != 56151290 { + t.Fatalf("first todo id = %d, want 56151290", got) + } +} + func TestToggleTodoCompletedAndAppend(t *testing.T) { ctx := context.Background() r, owner, friend := newMediaTestRouter(t) From 7c9d8dda162d3c2e0b773454f33cc81c06d4eba3 Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 21:15:35 +0800 Subject: [PATCH 5/8] feat: sync rich text message support --- deploy/migrations/0001_init.up.sql | 5 +- .../0050_rich_message_content_checks.down.sql | 20 + .../0050_rich_message_content_checks.up.sql | 22 + ...1_scheduled_messages_rich_message.down.sql | 2 + ...051_scheduled_messages_rich_message.up.sql | 2 + internal/app/dialogs/service.go | 44 ++ internal/app/help/service.go | 4 +- internal/app/help/service_premium_test.go | 3 + internal/compat/tdesktop/defaults.go | 4 +- .../compat/tdesktop/startup_stubs_test.go | 7 + internal/domain/channel.go | 6 + internal/domain/channel_banned_rights.go | 2 +- internal/domain/dialog.go | 4 +- internal/domain/message.go | 24 +- internal/rpc/convert_channels_core.go | 3 + internal/rpc/convert_dialogs.go | 3 + internal/rpc/convert_messages.go | 4 +- internal/rpc/convert_rich_message.go | 93 ++- internal/rpc/messages_dialogs.go | 13 +- internal/rpc/messages_edit.go | 28 +- .../rpc/messages_rich_message_rpc_test.go | 575 +++++++++++++++++- internal/rpc/messages_scheduled.go | 35 +- internal/rpc/messages_send.go | 9 +- internal/rpc/send_media.go | 2 +- internal/store/memory/channel_message_edit.go | 20 +- .../store/memory/channel_message_helpers.go | 1 + internal/store/memory/channel_message_send.go | 4 +- internal/store/memory/dialogs.go | 1 + internal/store/memory/message_edit.go | 16 +- internal/store/memory/message_helpers.go | 8 + internal/store/memory/message_test.go | 41 ++ .../store/postgres/channel_message_edit.go | 30 +- .../store/postgres/channel_message_helpers.go | 14 +- .../store/postgres/channel_message_send.go | 14 +- internal/store/postgres/channel_store.go | 2 +- internal/store/postgres/media_codec.go | 8 + internal/store/postgres/message_edit.go | 30 +- internal/store/postgres/queries/message.sql | 8 + internal/store/postgres/scheduled_message.go | 55 +- .../scheduled_message_integration_test.go | 68 +++ .../store/postgres/sqlcgen/message.sql.go | 24 +- 41 files changed, 1164 insertions(+), 94 deletions(-) create mode 100644 deploy/migrations/0050_rich_message_content_checks.down.sql create mode 100644 deploy/migrations/0050_rich_message_content_checks.up.sql create mode 100644 deploy/migrations/0051_scheduled_messages_rich_message.down.sql create mode 100644 deploy/migrations/0051_scheduled_messages_rich_message.up.sql diff --git a/deploy/migrations/0001_init.up.sql b/deploy/migrations/0001_init.up.sql index 489ff02a..da9643a3 100644 --- a/deploy/migrations/0001_init.up.sql +++ b/deploy/migrations/0001_init.up.sql @@ -1759,7 +1759,7 @@ CREATE TABLE public.channel_messages ( reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, from_boosts_applied integer DEFAULT 0 NOT NULL, rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, - CONSTRAINT channel_messages_content_check CHECK (((body <> ''::text) OR (action <> '{}'::jsonb) OR (media <> '{}'::jsonb))), + CONSTRAINT channel_messages_content_check CHECK (((body <> ''::text) OR (action <> '{}'::jsonb) OR (media <> '{}'::jsonb) OR (rich_message <> '{}'::jsonb))), CONSTRAINT channel_messages_peer_type_check CHECK ((((from_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])) AND ((send_as_peer_type IS NULL) OR ((send_as_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))) AND (((reply_to_peer_type)::text = ''::text) OR ((reply_to_peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text]))))) ); @@ -2472,7 +2472,7 @@ CREATE TABLE public.private_messages ( reply_markup jsonb DEFAULT '{}'::jsonb NOT NULL, via_bot_id bigint DEFAULT 0 NOT NULL, rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, - CONSTRAINT private_messages_nonempty_body CHECK (((body <> ''::text) OR (media <> '{}'::jsonb))) + CONSTRAINT private_messages_nonempty_body CHECK (((body <> ''::text) OR (media <> '{}'::jsonb) OR (rich_message <> '{}'::jsonb))) ); @@ -2596,6 +2596,7 @@ CREATE TABLE public.scheduled_messages ( body text DEFAULT ''::text NOT NULL, entities jsonb DEFAULT '[]'::jsonb NOT NULL, media jsonb DEFAULT '{}'::jsonb NOT NULL, + rich_message jsonb DEFAULT '{}'::jsonb NOT NULL, silent boolean DEFAULT false NOT NULL, noforwards boolean DEFAULT false NOT NULL, reply_to_msg_id integer DEFAULT 0 NOT NULL, diff --git a/deploy/migrations/0050_rich_message_content_checks.down.sql b/deploy/migrations/0050_rich_message_content_checks.down.sql new file mode 100644 index 00000000..a46b38aa --- /dev/null +++ b/deploy/migrations/0050_rich_message_content_checks.down.sql @@ -0,0 +1,20 @@ +ALTER TABLE public.channel_messages + DROP CONSTRAINT IF EXISTS channel_messages_content_check; + +ALTER TABLE public.channel_messages + ADD CONSTRAINT channel_messages_content_check + CHECK ( + body <> ''::text + OR action <> '{}'::jsonb + OR media <> '{}'::jsonb + ); + +ALTER TABLE public.private_messages + DROP CONSTRAINT IF EXISTS private_messages_nonempty_body; + +ALTER TABLE public.private_messages + ADD CONSTRAINT private_messages_nonempty_body + CHECK ( + body <> ''::text + OR media <> '{}'::jsonb + ); diff --git a/deploy/migrations/0050_rich_message_content_checks.up.sql b/deploy/migrations/0050_rich_message_content_checks.up.sql new file mode 100644 index 00000000..36f82d80 --- /dev/null +++ b/deploy/migrations/0050_rich_message_content_checks.up.sql @@ -0,0 +1,22 @@ +ALTER TABLE public.channel_messages + DROP CONSTRAINT IF EXISTS channel_messages_content_check; + +ALTER TABLE public.channel_messages + ADD CONSTRAINT channel_messages_content_check + CHECK ( + body <> ''::text + OR action <> '{}'::jsonb + OR media <> '{}'::jsonb + OR rich_message <> '{}'::jsonb + ); + +ALTER TABLE public.private_messages + DROP CONSTRAINT IF EXISTS private_messages_nonempty_body; + +ALTER TABLE public.private_messages + ADD CONSTRAINT private_messages_nonempty_body + CHECK ( + body <> ''::text + OR media <> '{}'::jsonb + OR rich_message <> '{}'::jsonb + ); diff --git a/deploy/migrations/0051_scheduled_messages_rich_message.down.sql b/deploy/migrations/0051_scheduled_messages_rich_message.down.sql new file mode 100644 index 00000000..38eeae61 --- /dev/null +++ b/deploy/migrations/0051_scheduled_messages_rich_message.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.scheduled_messages + DROP COLUMN IF EXISTS rich_message; diff --git a/deploy/migrations/0051_scheduled_messages_rich_message.up.sql b/deploy/migrations/0051_scheduled_messages_rich_message.up.sql new file mode 100644 index 00000000..347651ec --- /dev/null +++ b/deploy/migrations/0051_scheduled_messages_rich_message.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.scheduled_messages + ADD COLUMN IF NOT EXISTS rich_message jsonb DEFAULT '{}'::jsonb NOT NULL; diff --git a/internal/app/dialogs/service.go b/internal/app/dialogs/service.go index 224d4631..7cce2420 100644 --- a/internal/app/dialogs/service.go +++ b/internal/app/dialogs/service.go @@ -891,6 +891,7 @@ func cloneDraft(draft domain.DialogDraft) domain.DialogDraft { webpage := *draft.WebPage draft.WebPage = &webpage } + draft.RichMessage = cloneRichMessage(draft.RichMessage) return draft } @@ -927,10 +928,53 @@ func dialogHashWithDrafts(base int64, dialogs []domain.Dialog) int64 { if d.Draft.WebPage != nil { _, _ = h.Write([]byte(d.Draft.WebPage.URL)) } + writeDraftRichHash(h, buf[:], d.Draft.RichMessage) } return int64(h.Sum64()) } +func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage { + if m == nil { + return nil + } + clone := *m + clone.Blocks = append([]byte(nil), m.Blocks...) + clone.Photos = append([]domain.Photo(nil), m.Photos...) + clone.Documents = append([]domain.Document(nil), m.Documents...) + return &clone +} + +func writeDraftRichHash(h interface{ Write([]byte) (int, error) }, buf []byte, rich *domain.MessageRichMessage) { + if rich.IsZero() { + return + } + if rich.Rtl { + buf[0] = 1 + } else { + buf[0] = 0 + } + if rich.Part { + buf[1] = 1 + } else { + buf[1] = 0 + } + binary.LittleEndian.PutUint64(buf[2:10], uint64(len(rich.Blocks))) + binary.LittleEndian.PutUint64(buf[10:18], uint64(len(rich.Photos))) + binary.LittleEndian.PutUint64(buf[18:26], uint64(len(rich.Documents))) + _, _ = h.Write(buf[:26]) + _, _ = h.Write(rich.Blocks) + for _, photo := range rich.Photos { + binary.LittleEndian.PutUint64(buf[:8], uint64(photo.ID)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(photo.AccessHash)) + _, _ = h.Write(buf[:16]) + } + for _, document := range rich.Documents { + binary.LittleEndian.PutUint64(buf[:8], uint64(document.ID)) + binary.LittleEndian.PutUint64(buf[8:16], uint64(document.AccessHash)) + _, _ = h.Write(buf[:16]) + } +} + func mergeDialogLists(out, in domain.DialogList) domain.DialogList { out.Dialogs = append(out.Dialogs, in.Dialogs...) out.Messages = append(out.Messages, in.Messages...) diff --git a/internal/app/help/service.go b/internal/app/help/service.go index f5e3f0c7..211d4aa5 100644 --- a/internal/app/help/service.go +++ b/internal/app/help/service.go @@ -46,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,"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 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,"rich_message_posting":"enabled","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 = 19 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 +const defaultAppConfigHash = 20 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。 // Service 提供客户端启动配置与国家区号目录。 // diff --git a/internal/app/help/service_premium_test.go b/internal/app/help/service_premium_test.go index 49d640e5..91cdaf92 100644 --- a/internal/app/help/service_premium_test.go +++ b/internal/app/help/service_premium_test.go @@ -29,6 +29,9 @@ func TestAppConfigPremiumKeys(t *testing.T) { if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked { t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"]) } + if posting, ok := decoded["rich_message_posting"].(string); !ok || posting != "enabled" { + t.Fatalf("rich_message_posting = %v, want enabled (TDesktop 富文本编辑入口默认打开)", decoded["rich_message_posting"]) + } wantNumbers := map[string]float64{ "reactions_user_max_default": 1, "reactions_user_max_premium": 3, diff --git a/internal/compat/tdesktop/defaults.go b/internal/compat/tdesktop/defaults.go index f89c8d72..cde07a13 100644 --- a/internal/compat/tdesktop/defaults.go +++ b/internal/compat/tdesktop/defaults.go @@ -7,7 +7,7 @@ import ( ) const ( - appConfigHash = 14 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 + appConfigHash = 15 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。 countriesListHash = 1 timezonesListHash = 1 ) @@ -40,6 +40,8 @@ func readMarkAppConfig(mapboxToken string) *tg.JSONObject { {Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}}, // DrKLO 频道自定义 reaction 编辑页用它作为可选 reaction 数量上限。 {Key: "boosts_channel_level_max", Value: &tg.JSONNumber{Value: 100}}, + // TDesktop 富文本编辑入口:官方默认缺省 disabled,显式 enabled 才显示/允许进入编辑器。 + {Key: "rich_message_posting", Value: &tg.JSONString{Value: "enabled"}}, // 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/compat/tdesktop/startup_stubs_test.go b/internal/compat/tdesktop/startup_stubs_test.go index 631336ed..613e1358 100644 --- a/internal/compat/tdesktop/startup_stubs_test.go +++ b/internal/compat/tdesktop/startup_stubs_test.go @@ -39,11 +39,15 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) { t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got) } values := make(map[string]float64) + strings := make(map[string]string) if object, ok := got.Config.(*tg.JSONObject); ok && object != nil { for _, entry := range object.Value { if number, ok := entry.Value.(*tg.JSONNumber); ok { values[entry.Key] = number.Value } + if str, ok := entry.Value.(*tg.JSONString); ok { + strings[entry.Key] = str.Value + } } } want := map[string]float64{ @@ -56,6 +60,9 @@ func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) { t.Fatalf("AppConfig[%q] = %v, want %v", key, values[key], expected) } } + if strings["rich_message_posting"] != "enabled" { + t.Fatalf("AppConfig[rich_message_posting] = %q, want enabled", strings["rich_message_posting"]) + } if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok { t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash)) } diff --git a/internal/domain/channel.go b/internal/domain/channel.go index 0516a692..7db19cd9 100644 --- a/internal/domain/channel.go +++ b/internal/domain/channel.go @@ -602,6 +602,8 @@ type ChannelMessage struct { Reactions *ChannelMessageReactions Action *ChannelMessageAction Media *MessageMedia + // RichMessage 是 Layer 227 富文本消息(richMessage)快照,可选;普通消息恒 nil。 + RichMessage *MessageRichMessage // FromBoostsApplied 是发送时的 sender boost 数快照(message.from_boosts_applied)。 FromBoostsApplied int TTLPeriod int @@ -1373,6 +1375,7 @@ type SendChannelMessageRequest struct { // GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。 GroupedID int64 ReplyMarkup *MessageReplyMarkup + RichMessage *MessageRichMessage SendAs *Peer Action *ChannelMessageAction Date int @@ -1615,6 +1618,9 @@ type EditChannelMessageRequest struct { // SetReplyMarkup 置位时替换 reply_markup(ReplyMarkup 为 nil/空 = 清空键盘)。 SetReplyMarkup bool ReplyMarkup *MessageReplyMarkup + // SetRichMessage 置位时替换 rich_message(RichMessage nil/空 = 清空富文本)。 + SetRichMessage bool + RichMessage *MessageRichMessage // ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。 ViaBotEditBotID int64 // AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的 diff --git a/internal/domain/channel_banned_rights.go b/internal/domain/channel_banned_rights.go index 7c7538cb..22792faf 100644 --- a/internal/domain/channel_banned_rights.go +++ b/internal/domain/channel_banned_rights.go @@ -9,7 +9,7 @@ func ChannelBannedRightsBlockMessage(req SendChannelMessageRequest, channel Chan return false } if req.Media.IsZero() { - if strings.TrimSpace(req.Message) == "" { + if strings.TrimSpace(req.Message) == "" && req.RichMessage.IsZero() { return false } return channelBannedRightsBlockWithBoost(channel, member.BannedRights.SendPlain, channel.DefaultBannedRights.SendPlain, selfBoostsApplied) diff --git a/internal/domain/dialog.go b/internal/domain/dialog.go index ae5a516f..302c6770 100644 --- a/internal/domain/dialog.go +++ b/internal/domain/dialog.go @@ -113,6 +113,7 @@ type DialogDraft struct { ReplyTo *MessageReply WebPage *DialogDraftWebPage Effect int64 + RichMessage *MessageRichMessage } // Empty reports whether this draft should clear the cloud draft slot. @@ -124,7 +125,8 @@ func (d DialogDraft) Empty() bool { len(d.Entities) == 0 && (d.ReplyTo == nil || replyOnlyTopic) && d.WebPage == nil && - d.Effect == 0 + d.Effect == 0 && + d.RichMessage.IsZero() } // DialogArchiveSummary 聚合归档(folder_id=1)状态,供主列表 getDialogs diff --git a/internal/domain/message.go b/internal/domain/message.go index f75c54be..3bbfb56b 100644 --- a/internal/domain/message.go +++ b/internal/domain/message.go @@ -418,6 +418,10 @@ type EditMessageRequest struct { // 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位(P3)。 SetReplyMarkup bool ReplyMarkup *MessageReplyMarkup + // SetRichMessage 置位时替换 rich_message(RichMessage nil/空 = 清空富文本)。 + // TDesktop EditRichMessage 只带 f_rich_message,不带 f_message;store 不能把它误判为空编辑。 + SetRichMessage bool + RichMessage *MessageRichMessage // ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。 ViaBotEditBotID int64 // AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过 @@ -598,6 +602,7 @@ type ScheduledMessage struct { Message string Entities []MessageEntity Media *MessageMedia + RichMessage *MessageRichMessage Silent bool NoForwards bool ReplyTo *MessageReply @@ -626,6 +631,7 @@ type ScheduleMessageRequest struct { Message string Entities []MessageEntity Media *MessageMedia + RichMessage *MessageRichMessage Silent bool NoForwards bool ReplyTo *MessageReply @@ -639,14 +645,16 @@ type ScheduleMessageRequest struct { // EditScheduledMessageRequest updates one pending scheduled message before it // enters normal history. type EditScheduledMessageRequest struct { - OwnerUserID int64 - Peer Peer - ID int - SetMessage bool - Message string - Entities []MessageEntity - ScheduleDate int - Date int + OwnerUserID int64 + Peer Peer + ID int + SetMessage bool + Message string + Entities []MessageEntity + SetRichMessage bool + RichMessage *MessageRichMessage + ScheduleDate int + Date int } // ScheduledMessageFilter selects scheduled messages for one owner/peer. diff --git a/internal/rpc/convert_channels_core.go b/internal/rpc/convert_channels_core.go index 21313f26..e819a59e 100644 --- a/internal/rpc/convert_channels_core.go +++ b/internal/rpc/convert_channels_core.go @@ -156,6 +156,9 @@ func tgChannelMessage(viewerUserID int64, m domain.ChannelMessage) tg.MessageCla if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { msg.SetReplyMarkup(markup) } + if rich := mustTGRichMessage(m.RichMessage); rich != nil { + msg.SetRichMessage(*rich) + } if replies := tgChannelMessageReplies(m.Replies); replies != nil { msg.SetReplies(*replies) } diff --git a/internal/rpc/convert_dialogs.go b/internal/rpc/convert_dialogs.go index 31d36ea1..6e927257 100644 --- a/internal/rpc/convert_dialogs.go +++ b/internal/rpc/convert_dialogs.go @@ -162,6 +162,9 @@ func tgDialogDraft(d domain.DialogDraft) tg.DraftMessageClass { Date: d.Date, Effect: d.Effect, } + if rich := mustTGRichMessage(d.RichMessage); rich != nil { + out.SetRichMessage(*rich) + } return out } diff --git a/internal/rpc/convert_messages.go b/internal/rpc/convert_messages.go index 73ab6a40..8374624a 100644 --- a/internal/rpc/convert_messages.go +++ b/internal/rpc/convert_messages.go @@ -116,9 +116,7 @@ func tgMessage(m domain.Message) tg.MessageClass { if markup := tgReplyMarkup(m.ReplyMarkup); markup != nil { msg.SetReplyMarkup(markup) } - // rich_message(Layer 227 富文本消息):best-effort 投影;blocks 解码失败则略过 - // (tgMessage 无 error 返回,corrupt blob 不应拖垮整条消息投影)。 - if rich, err := tgRichMessage(m.RichMessage); err == nil && rich != nil { + if rich := mustTGRichMessage(m.RichMessage); rich != nil { msg.SetRichMessage(*rich) } if m.TTLPeriod > 0 { diff --git a/internal/rpc/convert_rich_message.go b/internal/rpc/convert_rich_message.go index f7260551..a9265d4a 100644 --- a/internal/rpc/convert_rich_message.go +++ b/internal/rpc/convert_rich_message.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "strconv" "github.com/gotd/td/bin" "github.com/gotd/td/tg" @@ -50,6 +51,81 @@ func decodeRichBlocks(data []byte) ([]tg.PageBlockClass, error) { return out, nil } +func normalizeRichBlocksForClients(blocks []tg.PageBlockClass) { + for _, block := range blocks { + normalizeRichBlockForClients(block) + } +} + +func normalizeRichBlockForClients(block tg.PageBlockClass) { + switch b := block.(type) { + case *tg.PageBlockList: + for _, item := range b.Items { + if item, ok := item.(*tg.PageListItemBlocks); ok { + normalizeRichBlocksForClients(item.Blocks) + } + } + case *tg.PageBlockCover: + normalizeRichBlockForClients(b.Cover) + case *tg.PageBlockEmbedPost: + normalizeRichBlocksForClients(b.Blocks) + case *tg.PageBlockCollage: + normalizeRichBlocksForClients(b.Items) + case *tg.PageBlockSlideshow: + normalizeRichBlocksForClients(b.Items) + case *tg.PageBlockOrderedList: + normalizeOrderedListForClients(b) + case *tg.PageBlockDetails: + normalizeRichBlocksForClients(b.Blocks) + case *tg.PageBlockBlockquoteBlocks: + normalizeRichBlocksForClients(b.Blocks) + } +} + +func normalizeOrderedListForClients(list *tg.PageBlockOrderedList) { + if list == nil { + return + } + reversed := list.Reversed || list.Flags.Has(2) + current := 1 + if list.Flags.Has(0) || list.Start != 0 { + current = list.Start + } else if reversed { + current = len(list.Items) + } + step := 1 + if reversed { + step = -1 + } + for _, item := range list.Items { + value := current + switch i := item.(type) { + case *tg.PageListOrderedItemText: + if v, ok := i.GetValue(); ok || i.Value != 0 { + value = v + if !ok { + value = i.Value + } + } + if num, ok := i.GetNum(); !ok || num == "" { + i.SetNum(strconv.Itoa(value)) + } + case *tg.PageListOrderedItemBlocks: + if v, ok := i.GetValue(); ok || i.Value != 0 { + value = v + if !ok { + value = i.Value + } + } + if num, ok := i.GetNum(); !ok || num == "" { + i.SetNum(strconv.Itoa(value)) + } + normalizeRichBlocksForClients(i.Blocks) + } + current = value + step + } +} + // domainRichMessageFromInput 把入站 tg.InputRichMessageClass 解析为 domain 快照: // 序列化 blocks + 按 id 解析内嵌 photos/documents(复用 sendMedia 同款媒体解析)。 // 返回 nil 表示无富文本载荷。Phase 1 仅认 *tg.InputRichMessage。 @@ -62,9 +138,16 @@ func (r *Router) domainRichMessageFromInput(ctx context.Context, input tg.InputR // Phase 1:HTML/Markdown 变体需服务端解析为 PageBlock,尚未支持。 return nil, mediaInvalidErr() } - if r.deps.Files == nil { + if len(in.Blocks) == 0 { + if len(in.Photos) == 0 && len(in.Documents) == 0 { + return nil, nil + } + return nil, mediaInvalidErr() + } + if (len(in.Photos) > 0 || len(in.Documents) > 0) && r.deps.Files == nil { return nil, notImplementedErr() } + normalizeRichBlocksForClients(in.Blocks) blocks, err := encodeRichBlocks(in.Blocks) if err != nil { return nil, err @@ -132,3 +215,11 @@ func tgRichMessage(m *domain.MessageRichMessage) (*tg.RichMessage, error) { } return out, nil } + +func mustTGRichMessage(m *domain.MessageRichMessage) *tg.RichMessage { + out, err := tgRichMessage(m) + if err != nil { + panic("invalid stored rich_message: " + err.Error()) + } + return out +} diff --git a/internal/rpc/messages_dialogs.go b/internal/rpc/messages_dialogs.go index 5d4043e4..8b7d8b70 100644 --- a/internal/rpc/messages_dialogs.go +++ b/internal/rpc/messages_dialogs.go @@ -43,7 +43,7 @@ func (r *Router) onMessagesSaveDraft(ctx context.Context, req *tg.MessagesSaveDr } update := &tg.UpdateDraftMessage{ Peer: peerTL, - Draft: tgDraftMessageFromSaveDraft(req, date), + Draft: tgDialogDraft(draft), } if draft.TopMessageID > 0 { update.SetTopMsgID(draft.TopMessageID) @@ -170,6 +170,13 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee if err != nil { return domain.DialogDraft{}, err } + var richMessage *domain.MessageRichMessage + if req.RichMessage != nil { + richMessage, err = r.domainRichMessageFromInput(ctx, req.RichMessage) + if err != nil { + return domain.DialogDraft{}, err + } + } topMessageID := 0 if replyTo != nil && peer.Type == domain.PeerTypeChannel && replyTo.TopMessageID > 0 { topMessageID = replyTo.TopMessageID @@ -185,6 +192,7 @@ func (r *Router) dialogDraftFromSaveDraft(ctx context.Context, userID int64, pee ReplyTo: replyTo, WebPage: webpage, Effect: req.Effect, + RichMessage: richMessage, }, nil } @@ -234,7 +242,8 @@ func saveDraftIsEmpty(req *tg.MessagesSaveDraftRequest) bool { len(req.Entities) == 0 && draftInputMedia(req.Media) == nil && req.Effect == 0 && - req.SuggestedPost.Zero() + req.SuggestedPost.Zero() && + req.RichMessage == nil } func (r *Router) usersForDraftUpdate(ctx context.Context, userID int64, peer domain.Peer) []tg.UserClass { diff --git a/internal/rpc/messages_edit.go b/internal/rpc/messages_edit.go index d7b2522b..88e28265 100644 --- a/internal/rpc/messages_edit.go +++ b/internal/rpc/messages_edit.go @@ -29,11 +29,22 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit if len(entities) > maxMessageEntityCount { return nil, entitiesTooLongErr() } + } else { + entities = nil + } + var richMessage *domain.MessageRichMessage + hasRichMessage := req.RichMessage != nil + if hasRichMessage { + var richErr error + richMessage, richErr = r.domainRichMessageFromInput(ctx, req.RichMessage) + if richErr != nil { + return nil, richErr + } + } + if hasMessage && richMessage == nil { // 编辑后的文本同样补服务端自动实体(url/@mention/#hashtag/bot command),与发送一致; // 覆盖频道/私聊编辑与各自的定时编辑分支(editScheduledMessage 仅由本处调用)。 entities = augmentAutoEntities(message, entities) - } else { - entities = nil } userID, _, err := r.currentUserID(ctx) if err != nil { @@ -50,7 +61,7 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit if media, ok := req.GetMedia(); ok && !editMessageMediaCanDegradeToText(media) { return nil, mediaInvalidErr() } - return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, scheduleDate) + return r.editScheduledMessage(ctx, userID, peer, req.ID, message, hasMessage, entities, richMessage, hasRichMessage, scheduleDate) } if media, ok := req.GetMedia(); ok { // 关闭 poll 走 editMessage + InputMediaPoll(closed)(TDesktop "Stop poll" 路径)。 @@ -65,14 +76,15 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit return nil, mediaInvalidErr() } } - if !hasMessage { + _, hasMediaForContent := req.GetMedia() + if !hasMessage && !hasRichMessage && !hasMediaForContent { return nil, messageEmptyErr() } - if message == "" { + if message == "" && richMessage == nil { // 编辑媒体消息时 message="" 是合法的清空 caption;当前文本-only // 编辑模型由 store 层校验目标消息(无媒体的纯文本消息清空仍会 // 落 MESSAGE_EMPTY),RPC 层不再一刀切拒绝。 - if _, hasMedia := req.GetMedia(); !hasMedia { + if !hasMediaForContent { return nil, messageEmptyErr() } } @@ -107,6 +119,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit Message: message, Entities: domainMessageEntitiesForViewer(userID, entities), MentionUserIDs: mentionUserIDs, + SetRichMessage: hasRichMessage, + RichMessage: richMessage, EditDate: int(r.clock.Now().Unix()), }) if err != nil { @@ -141,6 +155,8 @@ func (r *Router) onMessagesEditMessage(ctx context.Context, req *tg.MessagesEdit OriginSessionID: sessionID, SetReplyMarkup: setReplyMarkup, ReplyMarkup: replyMarkup, + SetRichMessage: hasRichMessage, + RichMessage: richMessage, }) if err != nil { return nil, messageEditErr(err) diff --git a/internal/rpc/messages_rich_message_rpc_test.go b/internal/rpc/messages_rich_message_rpc_test.go index f2539760..e9c7043e 100644 --- a/internal/rpc/messages_rich_message_rpc_test.go +++ b/internal/rpc/messages_rich_message_rpc_test.go @@ -4,19 +4,149 @@ import ( "context" "testing" + "github.com/gotd/td/clock" "github.com/gotd/td/tg" + "go.uber.org/zap/zaptest" + appchannels "telesrv/internal/app/channels" + appdialogs "telesrv/internal/app/dialogs" + appusers "telesrv/internal/app/users" "telesrv/internal/domain" + "telesrv/internal/store/memory" ) // richTextBlocks 构造一组纯文本 IV 页面块,用于富文本往返断言。 func richTextBlocks() []tg.PageBlockClass { + return richTextBlocksWith("Rich Title", "First paragraph.") +} + +func richTextBlocksWith(title, paragraph string) []tg.PageBlockClass { return []tg.PageBlockClass{ - &tg.PageBlockTitle{Text: &tg.TextPlain{Text: "Rich Title"}}, - &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "First paragraph."}}, + &tg.PageBlockTitle{Text: &tg.TextPlain{Text: title}}, + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: paragraph}}, } } +func richEmptyCaption() tg.PageCaption { + return tg.PageCaption{ + Text: &tg.TextEmpty{}, + Credit: &tg.TextEmpty{}, + } +} + +func richOrderedListWithoutNums() []tg.PageBlockClass { + return []tg.PageBlockClass{ + &tg.PageBlockOrderedList{ + Items: []tg.PageListOrderedItemClass{ + &tg.PageListOrderedItemText{Text: &tg.TextPlain{Text: "one"}}, + &tg.PageListOrderedItemBlocks{ + Blocks: []tg.PageBlockClass{ + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "two"}}, + }, + }, + }, + }, + } +} + +func richNestedOrderedListBlock() tg.PageBlockClass { + return richOrderedListWithoutNums()[0] +} + +func assertOrderedListNums(t *testing.T, label string, blocks []tg.PageBlockClass, want ...string) { + t.Helper() + if len(blocks) != 1 { + t.Fatalf("%s: blocks = %d, want 1", label, len(blocks)) + } + list, ok := blocks[0].(*tg.PageBlockOrderedList) + if !ok { + t.Fatalf("%s: block[0] = %T, want *tg.PageBlockOrderedList", label, blocks[0]) + } + if len(list.Items) != len(want) { + t.Fatalf("%s: items = %d, want %d", label, len(list.Items), len(want)) + } + for idx, item := range list.Items { + var ( + num string + ok bool + ) + switch i := item.(type) { + case *tg.PageListOrderedItemText: + num, ok = i.GetNum() + case *tg.PageListOrderedItemBlocks: + num, ok = i.GetNum() + default: + t.Fatalf("%s: item[%d] = %T, want ordered text/blocks", label, idx, item) + } + if !ok || num != want[idx] { + t.Fatalf("%s: item[%d].num = %q, ok=%v, want %q", label, idx, num, ok, want[idx]) + } + } +} + +func collectOrderedListNums(blocks []tg.PageBlockClass) []string { + var nums []string + var walk func(tg.PageBlockClass) + walk = func(block tg.PageBlockClass) { + switch b := block.(type) { + case *tg.PageBlockList: + for _, item := range b.Items { + if item, ok := item.(*tg.PageListItemBlocks); ok { + for _, child := range item.Blocks { + walk(child) + } + } + } + case *tg.PageBlockCover: + walk(b.Cover) + case *tg.PageBlockEmbedPost: + for _, child := range b.Blocks { + walk(child) + } + case *tg.PageBlockCollage: + for _, child := range b.Items { + walk(child) + } + case *tg.PageBlockSlideshow: + for _, child := range b.Items { + walk(child) + } + case *tg.PageBlockDetails: + for _, child := range b.Blocks { + walk(child) + } + case *tg.PageBlockBlockquoteBlocks: + for _, child := range b.Blocks { + walk(child) + } + case *tg.PageBlockOrderedList: + for _, item := range b.Items { + switch i := item.(type) { + case *tg.PageListOrderedItemText: + if num, ok := i.GetNum(); ok { + nums = append(nums, num) + } else { + nums = append(nums, "") + } + case *tg.PageListOrderedItemBlocks: + if num, ok := i.GetNum(); ok { + nums = append(nums, num) + } else { + nums = append(nums, "") + } + for _, child := range i.Blocks { + walk(child) + } + } + } + } + } + for _, block := range blocks { + walk(block) + } + return nums +} + // assertRichTextBlocks 校验投影出的 RichMessage 携带 richTextBlocks 的两个块(标题+段落)。 func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) { t.Helper() @@ -42,6 +172,198 @@ func assertRichTextBlocks(t *testing.T, label string, rich tg.RichMessage) { } } +func assertRichTitle(t *testing.T, label string, rich tg.RichMessage, want string) { + t.Helper() + if len(rich.Blocks) == 0 { + t.Fatalf("%s: missing rich blocks", label) + } + title, ok := rich.Blocks[0].(*tg.PageBlockTitle) + if !ok { + t.Fatalf("%s: block[0] = %T, want *tg.PageBlockTitle", label, rich.Blocks[0]) + } + if tp, ok := title.Text.(*tg.TextPlain); !ok || tp.Text != want { + t.Fatalf("%s: title text = %+v, want plain %q", label, title.Text, want) + } +} + +func TestRichMessageOrderedListNumsNormalized(t *testing.T) { + ctx := context.Background() + r := &Router{} + + rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{ + Blocks: richOrderedListWithoutNums(), + }) + if err != nil { + t.Fatalf("domain rich message: %v", err) + } + got, err := tgRichMessage(rich) + if err != nil { + t.Fatalf("tg rich message: %v", err) + } + assertOrderedListNums(t, "new input", got.Blocks, "1", "2") +} + +func TestRichMessageRejectsResourcesWithoutBlocks(t *testing.T) { + ctx := context.Background() + r := &Router{} + + rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{}) + if err != nil { + t.Fatalf("empty input rich message: %v", err) + } + if rich != nil { + t.Fatalf("empty input rich message = %+v, want nil", rich) + } + if _, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{ + Photos: []tg.InputPhotoClass{&tg.InputPhoto{ID: 1, AccessHash: 2}}, + }); err == nil { + t.Fatalf("orphan rich photos without blocks accepted") + } + if _, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{ + Documents: []tg.InputDocumentClass{&tg.InputDocument{ID: 1, AccessHash: 2}}, + }); err == nil { + t.Fatalf("orphan rich documents without blocks accepted") + } +} + +func TestRichMessageNormalizesNestedOrderedListContainers(t *testing.T) { + ctx := context.Background() + r := &Router{} + caption := richEmptyCaption() + blocks := []tg.PageBlockClass{ + &tg.PageBlockList{Items: []tg.PageListItemClass{ + &tg.PageListItemBlocks{Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()}}, + }}, + &tg.PageBlockCover{Cover: richNestedOrderedListBlock()}, + &tg.PageBlockEmbedPost{ + URL: "https://example.test/post", + Author: "author", + Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()}, + Caption: caption, + WebpageID: 1, + }, + &tg.PageBlockCollage{Items: []tg.PageBlockClass{richNestedOrderedListBlock()}, Caption: caption}, + &tg.PageBlockSlideshow{Items: []tg.PageBlockClass{richNestedOrderedListBlock()}, Caption: caption}, + &tg.PageBlockDetails{ + Title: &tg.TextPlain{Text: "details"}, + Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()}, + }, + &tg.PageBlockBlockquoteBlocks{ + Blocks: []tg.PageBlockClass{richNestedOrderedListBlock()}, + Caption: &tg.TextEmpty{}, + }, + } + rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks}) + if err != nil { + t.Fatalf("domain rich message: %v", err) + } + got, err := tgRichMessage(rich) + if err != nil { + t.Fatalf("tg rich message: %v", err) + } + nums := collectOrderedListNums(got.Blocks) + want := []string{"1", "2", "1", "2", "1", "2", "1", "2", "1", "2", "1", "2", "1", "2"} + if len(nums) != len(want) { + t.Fatalf("ordered nums = %v, want %v", nums, want) + } + for i := range want { + if nums[i] != want[i] { + t.Fatalf("ordered nums = %v, want %v", nums, want) + } + } +} + +func TestRichMessageBlockFormatsEncodeDecode(t *testing.T) { + caption := richEmptyCaption() + blocks := []tg.PageBlockClass{ + &tg.PageBlockTitle{Text: &tg.TextPlain{Text: "title"}}, + &tg.PageBlockSubtitle{Text: &tg.TextPlain{Text: "subtitle"}}, + &tg.PageBlockAuthorDate{Author: &tg.TextPlain{Text: "author"}, PublishedDate: 1}, + &tg.PageBlockHeader{Text: &tg.TextPlain{Text: "header"}}, + &tg.PageBlockSubheader{Text: &tg.TextPlain{Text: "subheader"}}, + &tg.PageBlockParagraph{Text: &tg.TextConcat{Texts: []tg.RichTextClass{ + &tg.TextPlain{Text: "plain"}, + &tg.TextBold{Text: &tg.TextPlain{Text: "bold"}}, + &tg.TextItalic{Text: &tg.TextPlain{Text: "italic"}}, + &tg.TextUnderline{Text: &tg.TextPlain{Text: "underline"}}, + &tg.TextStrike{Text: &tg.TextPlain{Text: "strike"}}, + &tg.TextFixed{Text: &tg.TextPlain{Text: "fixed"}}, + &tg.TextSpoiler{Text: &tg.TextPlain{Text: "spoiler"}}, + &tg.TextURL{Text: &tg.TextPlain{Text: "url"}, URL: "https://example.test"}, + &tg.TextEmail{Text: &tg.TextPlain{Text: "email"}, Email: "a@example.test"}, + &tg.TextPhone{Text: &tg.TextPlain{Text: "phone"}, Phone: "+10000000000"}, + &tg.TextMath{Source: "x"}, + }}}, + &tg.PageBlockPreformatted{Text: &tg.TextPlain{Text: "pre"}, Language: "go"}, + &tg.PageBlockFooter{Text: &tg.TextPlain{Text: "footer"}}, + &tg.PageBlockDivider{}, + &tg.PageBlockAnchor{Name: "anchor"}, + &tg.PageBlockList{Items: []tg.PageListItemClass{ + &tg.PageListItemText{Text: &tg.TextPlain{Text: "item"}}, + &tg.PageListItemBlocks{Blocks: []tg.PageBlockClass{ + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "nested"}}, + }}, + }}, + &tg.PageBlockBlockquote{Text: &tg.TextPlain{Text: "quote"}, Caption: &tg.TextEmpty{}}, + &tg.PageBlockPullquote{Text: &tg.TextPlain{Text: "pull"}, Caption: &tg.TextEmpty{}}, + &tg.PageBlockPhoto{PhotoID: 1, Caption: caption}, + &tg.PageBlockVideo{VideoID: 2, Caption: caption}, + &tg.PageBlockCover{Cover: &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "cover"}}}, + &tg.PageBlockEmbedPost{ + URL: "https://example.test/post", + Author: "author", + Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "post"}}}, + Caption: caption, + WebpageID: 3, + }, + &tg.PageBlockCollage{Items: []tg.PageBlockClass{&tg.PageBlockPhoto{PhotoID: 4, Caption: caption}}, Caption: caption}, + &tg.PageBlockSlideshow{Items: []tg.PageBlockClass{&tg.PageBlockVideo{VideoID: 5, Caption: caption}}, Caption: caption}, + &tg.PageBlockAudio{AudioID: 6, Caption: caption}, + &tg.PageBlockKicker{Text: &tg.TextPlain{Text: "kicker"}}, + &tg.PageBlockTable{Title: &tg.TextPlain{Text: "table"}}, + &tg.PageBlockOrderedList{Items: []tg.PageListOrderedItemClass{ + &tg.PageListOrderedItemText{Text: &tg.TextPlain{Text: "one"}}, + }}, + &tg.PageBlockDetails{Title: &tg.TextPlain{Text: "details"}, Blocks: []tg.PageBlockClass{ + &tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "inside"}}, + }}, + &tg.PageBlockRelatedArticles{Title: &tg.TextPlain{Text: "related"}, Articles: []tg.PageRelatedArticle{ + {URL: "https://example.test/a", WebpageID: 7}, + }}, + &tg.PageBlockMap{Geo: &tg.GeoPointEmpty{}, Zoom: 13, W: 64, H: 64, Caption: caption}, + &tg.PageBlockHeading1{Text: &tg.TextPlain{Text: "h1"}}, + &tg.PageBlockHeading2{Text: &tg.TextPlain{Text: "h2"}}, + &tg.PageBlockHeading3{Text: &tg.TextPlain{Text: "h3"}}, + &tg.PageBlockHeading4{Text: &tg.TextPlain{Text: "h4"}}, + &tg.PageBlockHeading5{Text: &tg.TextPlain{Text: "h5"}}, + &tg.PageBlockHeading6{Text: &tg.TextPlain{Text: "h6"}}, + &tg.PageBlockMath{Source: "x^2"}, + &tg.PageBlockThinking{Text: &tg.TextPlain{Text: "thinking"}}, + &tg.PageBlockBlockquoteBlocks{ + Blocks: []tg.PageBlockClass{&tg.PageBlockParagraph{Text: &tg.TextPlain{Text: "blocks"}}}, + Caption: &tg.TextEmpty{}, + }, + &tg.PageBlockUnsupported{}, + } + ctx := context.Background() + r := &Router{} + rich, err := r.domainRichMessageFromInput(ctx, &tg.InputRichMessage{Blocks: blocks}) + if err != nil { + t.Fatalf("domain rich message: %v", err) + } + got, err := tgRichMessage(rich) + if err != nil { + t.Fatalf("tg rich message: %v", err) + } + if len(got.Blocks) != len(blocks) { + t.Fatalf("blocks = %d, want %d", len(got.Blocks), len(blocks)) + } + nums := collectOrderedListNums(got.Blocks) + if len(nums) != 1 || nums[0] != "1" { + t.Fatalf("ordered nums = %v, want [1]", nums) + } +} + // TestSendMessageRichMessageTextBlocksRoundTrip 验证 Layer 227 富文本(inputRichMessage 的 // blocks 形态)经 send → 发送方 echo / getMessages / getRichMessage 全链路原样往返。 func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) { @@ -95,6 +417,154 @@ func TestSendMessageRichMessageTextBlocksRoundTrip(t *testing.T) { assertRichTextBlocks(t, "getRichMessage", rich) } +// TestSendMessageRichOnlyTextBlocksRoundTrip 覆盖 TDesktop rich editor 的真实发送形态: +// messages.sendMessage 带 f_rich_message,但 message:string 为空。 +func TestSendMessageRichOnlyTextBlocksRoundTrip(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + RandomID: 7101, + RichMessage: &tg.InputRichMessage{ + Rtl: true, + Blocks: richTextBlocks(), + }, + }) + if err != nil { + t.Fatalf("send rich-only message: %v", err) + } + echo := newMessageFromUpdates(t, updates) + if echo.Message != "" { + t.Fatalf("rich-only echo message = %q, want empty fallback text", echo.Message) + } + rich, ok := echo.GetRichMessage() + if !ok { + t.Fatalf("rich-only echo missing rich message") + } + assertRichTextBlocks(t, "rich-only echo", rich) +} + +func TestEditMessageRichOnlyPrivateRoundTrip(t *testing.T) { + ctx := context.Background() + r, owner, friend := newMediaTestRouter(t) + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + RandomID: 7102, + RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()}, + }) + if err != nil { + t.Fatalf("send rich-only message: %v", err) + } + msg := newMessageFromUpdates(t, updates) + + editReq := &tg.MessagesEditMessageRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + ID: msg.ID, + } + editReq.SetRichMessage(&tg.InputRichMessage{ + Rtl: true, + Blocks: richTextBlocksWith("Edited Title", "Edited paragraph."), + }) + edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq) + if err != nil { + t.Fatalf("edit rich-only private message: %v", err) + } + editedMsg := editMessageFromUpdates(t, edited) + rich, ok := editedMsg.GetRichMessage() + if !ok { + t.Fatalf("edited private message missing rich message") + } + assertRichTitle(t, "edited private", rich, "Edited Title") +} + +func TestChannelRichMessageSendEditHistoryRoundTrip(t *testing.T) { + ctx := context.Background() + r, owner, channel := newRichChannelTestRouter(t) + peer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash} + + updates, err := r.onMessagesSendMessage(WithUserID(ctx, owner.ID), &tg.MessagesSendMessageRequest{ + Peer: peer, + RandomID: 7201, + RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()}, + }) + if err != nil { + t.Fatalf("send channel rich-only message: %v", err) + } + echo := newMessageFromUpdates(t, updates) + rich, ok := echo.GetRichMessage() + if !ok { + t.Fatalf("channel echo missing rich message") + } + assertRichTextBlocks(t, "channel echo", rich) + + historyList, err := r.deps.Channels.GetHistory(ctx, owner.ID, domain.ChannelHistoryFilter{ + ChannelID: channel.ID, + Limit: 10, + }) + if err != nil { + t.Fatalf("channel get history: %v", err) + } + history := r.tgChannelHistoryMessages(WithUserID(ctx, owner.ID), owner.ID, historyList) + stored := singleChannelStoredMessage(t, history) + rich, ok = stored.GetRichMessage() + if !ok { + t.Fatalf("channel history missing rich message") + } + assertRichTextBlocks(t, "channel history", rich) + + editReq := &tg.MessagesEditMessageRequest{Peer: peer, ID: echo.ID} + editReq.SetRichMessage(&tg.InputRichMessage{ + Rtl: true, + Blocks: richTextBlocksWith("Edited Channel", "Edited channel paragraph."), + }) + edited, err := r.onMessagesEditMessage(WithUserID(ctx, owner.ID), editReq) + if err != nil { + t.Fatalf("edit channel rich-only message: %v", err) + } + editedMsg := editChannelMessageFromUpdates(t, edited) + rich, ok = editedMsg.GetRichMessage() + if !ok { + t.Fatalf("edited channel message missing rich message") + } + assertRichTitle(t, "edited channel", rich, "Edited Channel") +} + +func TestSaveDraftRichMessageRoundTrip(t *testing.T) { + ctx := context.Background() + r, owner, friend := newRichDraftTestRouter(t) + + ok, err := r.onMessagesSaveDraft(WithUserID(ctx, owner.ID), &tg.MessagesSaveDraftRequest{ + Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, + RichMessage: &tg.InputRichMessage{Rtl: true, Blocks: richTextBlocks()}, + }) + if err != nil || !ok { + t.Fatalf("save rich draft = %v, %v", ok, err) + } + got, err := r.onMessagesGetAllDrafts(WithUserID(ctx, owner.ID)) + if err != nil { + t.Fatalf("get all drafts: %v", err) + } + updates := got.(*tg.Updates) + if len(updates.Updates) != 1 { + t.Fatalf("draft updates = %+v, want one", updates.Updates) + } + update, ok := updates.Updates[0].(*tg.UpdateDraftMessage) + if !ok { + t.Fatalf("draft update = %T", updates.Updates[0]) + } + draft, ok := update.Draft.(*tg.DraftMessage) + if !ok { + t.Fatalf("draft = %T, want *tg.DraftMessage", update.Draft) + } + rich, ok := draft.GetRichMessage() + if !ok { + t.Fatalf("draft missing rich message") + } + assertRichTextBlocks(t, "draft", rich) +} + // TestGetRichMessageWrongPeerReturnsEmpty 验证 getRichMessage 的 peer 校验:用不匹配的 peer // 拉取应返回 messageEmpty(不跨会话泄漏)。 func TestGetRichMessageWrongPeerReturnsEmpty(t *testing.T) { @@ -182,3 +652,104 @@ func singleStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message } return msg } + +func singleChannelStoredMessage(t *testing.T, res tg.MessagesMessagesClass) *tg.Message { + t.Helper() + box, ok := res.(*tg.MessagesChannelMessages) + if !ok { + t.Fatalf("channel messages = %T %+v, want messages.channelMessages", res, res) + } + var got *tg.Message + for _, item := range box.Messages { + msg, ok := item.(*tg.Message) + if !ok { + continue + } + if got != nil { + t.Fatalf("channel messages = %+v, want one regular message", box.Messages) + } + got = msg + } + if got == nil { + t.Fatalf("channel messages = %+v, want one regular message", box.Messages) + } + return got +} + +func editMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message { + t.Helper() + upd, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, u := range upd.Updates { + if edit, ok := u.(*tg.UpdateEditMessage); ok { + msg, ok := edit.Message.(*tg.Message) + if !ok { + t.Fatalf("edit message = %T, want *tg.Message", edit.Message) + } + return msg + } + } + t.Fatal("no updateEditMessage found") + return nil +} + +func editChannelMessageFromUpdates(t *testing.T, updates tg.UpdatesClass) *tg.Message { + t.Helper() + upd, ok := updates.(*tg.Updates) + if !ok { + t.Fatalf("updates = %T, want *tg.Updates", updates) + } + for _, u := range upd.Updates { + if edit, ok := u.(*tg.UpdateEditChannelMessage); ok { + msg, ok := edit.Message.(*tg.Message) + if !ok { + t.Fatalf("edit channel message = %T, want *tg.Message", edit.Message) + } + return msg + } + } + t.Fatal("no updateEditChannelMessage found") + return nil +} + +func newRichChannelTestRouter(t *testing.T) (*Router, domain.User, domain.Channel) { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 21, Phone: "15550009101", FirstName: "Owner"}) + channelStore := memory.NewChannelStore() + channelSvc := appchannels.NewService(channelStore) + created, err := channelSvc.CreateMegagroupFromCreateChat(ctx, owner.ID, domain.CreateChannelRequest{ + CreatorUserID: owner.ID, + Title: "Rich Channel", + Date: 1700000000, + }) + if err != nil { + t.Fatalf("create rich channel: %v", err) + } + dialogStore := memory.NewDialogStore() + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Channels: channelSvc, + Dialogs: appdialogs.NewService(dialogStore, channelStore), + Files: &fakeFiles{docs: map[int64]domain.Document{}, photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), clock.System) + return r, owner, created.Channel +} + +func newRichDraftTestRouter(t *testing.T) (*Router, domain.User, domain.User) { + t.Helper() + ctx := context.Background() + userStore := memory.NewUserStore() + owner, _ := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550009201", FirstName: "Owner"}) + friend, _ := userStore.Create(ctx, domain.User{AccessHash: 32, Phone: "15550009202", FirstName: "Friend"}) + dialogStore := memory.NewDialogStore() + r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{ + Users: appusers.NewService(userStore), + Dialogs: appdialogs.NewService(dialogStore, memory.NewChannelStore()), + Files: &fakeFiles{docs: map[int64]domain.Document{}, photos: map[int64]domain.Photo{}}, + }, zaptest.NewLogger(t), clock.System) + return r, owner, friend +} diff --git a/internal/rpc/messages_scheduled.go b/internal/rpc/messages_scheduled.go index 78f44904..072c5078 100644 --- a/internal/rpc/messages_scheduled.go +++ b/internal/rpc/messages_scheduled.go @@ -193,6 +193,7 @@ func (r *Router) scheduleOutgoing(ctx context.Context, userID int64, peer domain Message: p.message, Entities: domainMessageEntitiesForViewer(userID, p.entities), Media: p.media, + RichMessage: p.richMessage, Silent: p.silent, NoForwards: p.noforwards, ReplyTo: replyTo, @@ -227,12 +228,13 @@ func (r *Router) sendClaimedScheduledMessages(ctx context.Context, userID int64, sentIDs := make([]int, 0, len(claimed)) for _, scheduled := range claimed { updates, _, err := r.sendOutgoing(ctx, userID, scheduled.Peer, outgoingSend{ - randomID: scheduled.RandomID, - message: scheduled.Message, - entities: tgInputMessageEntities(scheduled.Entities), - media: scheduled.Media, - silent: scheduled.Silent, - noforwards: scheduled.NoForwards, + randomID: scheduled.RandomID, + message: scheduled.Message, + entities: tgInputMessageEntities(scheduled.Entities), + media: scheduled.Media, + richMessage: scheduled.RichMessage, + silent: scheduled.Silent, + noforwards: scheduled.NoForwards, }) if err != nil { if scheduledSvc, ok := r.deps.Messages.(scheduledMessagesService); ok { @@ -340,6 +342,7 @@ func scheduledMessageAsDomainMessage(msg domain.ScheduledMessage, viewerUserID i ReplyTo: msg.ReplyTo, Forward: msg.Forward, Media: msg.Media, + RichMessage: msg.RichMessage, } } @@ -427,7 +430,7 @@ func (r *Router) scheduleForwardMessages(ctx context.Context, userID int64, from return updates, nil } -func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, scheduleDate int) (tg.UpdatesClass, error) { +func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer domain.Peer, id int, message string, setMessage bool, entities []tg.MessageEntityClass, richMessage *domain.MessageRichMessage, setRichMessage bool, scheduleDate int) (tg.UpdatesClass, error) { if r.deps.Messages == nil { return nil, messageIDInvalidErr() } @@ -440,14 +443,16 @@ func (r *Router) editScheduledMessage(ctx context.Context, userID int64, peer do return nil, scheduleDateInvalidErr() } msg, err := scheduledSvc.EditScheduledMessage(ctx, userID, domain.EditScheduledMessageRequest{ - OwnerUserID: userID, - Peer: peer, - ID: id, - SetMessage: setMessage, - Message: message, - Entities: domainMessageEntitiesForViewer(userID, entities), - ScheduleDate: scheduleDate, - Date: now, + OwnerUserID: userID, + Peer: peer, + ID: id, + SetMessage: setMessage, + Message: message, + Entities: domainMessageEntitiesForViewer(userID, entities), + SetRichMessage: setRichMessage, + RichMessage: richMessage, + ScheduleDate: scheduleDate, + Date: now, }) if err != nil { return nil, messageEditErr(err) diff --git a/internal/rpc/messages_send.go b/internal/rpc/messages_send.go index ed74deca..46b7bbf4 100644 --- a/internal/rpc/messages_send.go +++ b/internal/rpc/messages_send.go @@ -16,10 +16,6 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend defer func() { r.metrics().MessageSend(r.clock.Now().Sub(start), duplicate, sendErr) }() - if req.Message == "" { - sendErr = messageEmptyErr() - return nil, messageEmptyErr() - } if utf8.RuneCountInString(req.Message) > maxSendMessageTextLength { sendErr = messageTooLongErr() return nil, sendErr @@ -101,6 +97,10 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend return nil, sendErr } } + if req.Message == "" && richMessage == nil { + sendErr = messageEmptyErr() + return nil, sendErr + } // 自动实体高亮:客户端未带 url/@mention/#hashtag/bot command 等「可自动识别」实体时,服务端 // 检测原文补充(官方服务端行为),否则 @username/链接等不渲染为可点蓝色。富文本走独立结构,不处理。 if richMessage == nil { @@ -123,6 +123,7 @@ func (r *Router) onMessagesSendMessage(ctx context.Context, req *tg.MessagesSend replyToInput: req.ReplyTo, sendAsInput: req.SendAs, clearDraft: req.ClearDraft, + richMessage: richMessage, }, req.ScheduleDate, req.ScheduleRepeatPeriod) if err != nil { sendErr = err diff --git a/internal/rpc/send_media.go b/internal/rpc/send_media.go index 65285a1b..8670cf80 100644 --- a/internal/rpc/send_media.go +++ b/internal/rpc/send_media.go @@ -39,7 +39,6 @@ type outgoingSend struct { replyMarkup *domain.MessageReplyMarkup viaBotID int64 // richMessage 是 Layer 227 富文本消息快照(已解析内嵌媒体;普通消息恒 nil)。 - // Phase 1 仅接入私聊;频道侧留 Phase 2。 richMessage *domain.MessageRichMessage // groupedID 是相册分组 id:sendMultiMedia 同组各条共享一个非零值(客户端据此渲染 // 成一个相册组);单条发送恒 0。 @@ -92,6 +91,7 @@ func (r *Router) sendOutgoing(ctx context.Context, userID int64, peer domain.Pee ViaBotID: p.viaBotID, GroupedID: p.groupedID, ReplyMarkup: p.replyMarkup, + RichMessage: p.richMessage, SendAs: sendAs, Date: int(r.clock.Now().Unix()), }) diff --git a/internal/store/memory/channel_message_edit.go b/internal/store/memory/channel_message_edit.go index 17cb1810..db3862d3 100644 --- a/internal/store/memory/channel_message_edit.go +++ b/internal/store/memory/channel_message_edit.go @@ -9,8 +9,7 @@ import ( ) func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { - // 空文本只在媒体替换(live location 续报/停止)时合法。 - if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) { + if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } s.mu.Lock() @@ -26,6 +25,17 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan } prevMsg := s.messages[req.ChannelID][idx] msg := prevMsg + finalMedia := msg.Media + if req.Media != nil { + finalMedia = req.Media + } + finalRich := msg.RichMessage + if req.SetRichMessage { + finalRich = req.RichMessage + } + if strings.TrimSpace(req.Message) == "" && finalMedia.IsZero() && finalRich.IsZero() { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } // WebPageResolve:频道链接预览就地替换(服务端内部,幂等守卫即授权)。只换 media、 // 不碰 body/entities/edit_date,事件为 channel_web_page。 if req.WebPageResolve { @@ -67,7 +77,8 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan if !viaBotEdit && msg.SenderUserID != req.UserID && !canEditChannelMessage(member) && !participantTodoEdit { return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired } - if req.Media == nil && !req.SetReplyMarkup && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { + richChanged := req.SetRichMessage && !richMessagesEqual(msg.RichMessage, req.RichMessage) + if req.Media == nil && !req.SetReplyMarkup && !richChanged && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified } pts := s.nextChannelPtsLocked(req.ChannelID) @@ -80,6 +91,9 @@ func (s *ChannelStore) EditChannelMessage(_ context.Context, req domain.EditChan if req.SetReplyMarkup { msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) } + if req.SetRichMessage { + msg.RichMessage = cloneRichMessage(req.RichMessage) + } msg.EditDate = req.EditDate msg.Pts = pts s.messages[req.ChannelID][idx] = msg diff --git a/internal/store/memory/channel_message_helpers.go b/internal/store/memory/channel_message_helpers.go index 5ed0cbc1..f2aab8db 100644 --- a/internal/store/memory/channel_message_helpers.go +++ b/internal/store/memory/channel_message_helpers.go @@ -30,6 +30,7 @@ func cloneChannelMessage(in domain.ChannelMessage) domain.ChannelMessage { in.ReplyTo = cloneMessageReply(in.ReplyTo) in.Forward = cloneMessageForward(in.Forward) in.ReplyMarkup = cloneReplyMarkup(in.ReplyMarkup) + in.RichMessage = cloneRichMessage(in.RichMessage) in.Discussion = cloneChannelDiscussionRef(in.Discussion) in.Replies = cloneChannelMessageReplies(in.Replies) in.Reactions = cloneChannelMessageReactionsPtr(in.Reactions) diff --git a/internal/store/memory/channel_message_send.go b/internal/store/memory/channel_message_send.go index 1d812f52..5604de10 100644 --- a/internal/store/memory/channel_message_send.go +++ b/internal/store/memory/channel_message_send.go @@ -11,7 +11,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan if req.UserID == 0 || req.ChannelID == 0 { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } - if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() { + if strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero() { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } s.mu.Lock() @@ -83,6 +83,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan NoForwards: req.NoForwards || channel.NoForwards || linked.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), + RichMessage: cloneRichMessage(req.RichMessage), Forward: &domain.MessageForward{From: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, Date: req.Date, ChannelPost: msgID, SavedFrom: domain.Peer{Type: domain.PeerTypeChannel, ID: channel.ID}, SavedFromMsgID: msgID}, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, @@ -131,6 +132,7 @@ func (s *ChannelStore) SendChannelMessage(_ context.Context, req domain.SendChan Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, + RichMessage: cloneRichMessage(req.RichMessage), ReplyTo: replyTo, Forward: cloneMessageForward(req.Forward), ViaBotID: req.ViaBotID, diff --git a/internal/store/memory/dialogs.go b/internal/store/memory/dialogs.go index bbba34be..aa153cd5 100644 --- a/internal/store/memory/dialogs.go +++ b/internal/store/memory/dialogs.go @@ -840,6 +840,7 @@ func cloneDialogDraft(draft domain.DialogDraft) domain.DialogDraft { webpage := *draft.WebPage draft.WebPage = &webpage } + draft.RichMessage = cloneRichMessage(draft.RichMessage) return draft } diff --git a/internal/store/memory/message_edit.go b/internal/store/memory/message_edit.go index fbc37dd0..b8900a58 100644 --- a/internal/store/memory/message_edit.go +++ b/internal/store/memory/message_edit.go @@ -41,10 +41,19 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ return res, domain.ErrMessageNotModified } } - if req.Message == "" && req.Media == nil && target.Media.IsZero() { + finalMedia := target.Media + if req.Media != nil { + finalMedia = req.Media + } + finalRich := target.RichMessage + if req.SetRichMessage { + finalRich = req.RichMessage + } + if req.Message == "" && finalMedia.IsZero() && finalRich.IsZero() { return res, domain.ErrMessageEmpty } - if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && target.HideEdited == req.HideEdited && equalMessageEntities(target.Entities, req.Entities) { + richChanged := req.SetRichMessage && !richMessagesEqual(target.RichMessage, req.RichMessage) + if req.Media == nil && !req.SetReplyMarkup && !richChanged && target.Body == req.Message && target.HideEdited == req.HideEdited && equalMessageEntities(target.Entities, req.Entities) { return res, domain.ErrMessageNotModified } messageSenderID := target.From.ID @@ -73,6 +82,9 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ // 替换 markup(nil/空 = 清空键盘);双盒一致。 msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup) } + if req.SetRichMessage { + msg.RichMessage = cloneRichMessage(req.RichMessage) + } msg.EditDate = req.EditDate msg.HideEdited = req.HideEdited msg.Pts = s.nextPtsLocked(userID) diff --git a/internal/store/memory/message_helpers.go b/internal/store/memory/message_helpers.go index ce2b2ca2..24455764 100644 --- a/internal/store/memory/message_helpers.go +++ b/internal/store/memory/message_helpers.go @@ -3,6 +3,7 @@ package memory import ( "encoding/binary" "hash/fnv" + "reflect" "sort" "telesrv/internal/domain" ) @@ -118,6 +119,13 @@ func cloneRichMessage(m *domain.MessageRichMessage) *domain.MessageRichMessage { return &clone } +func richMessagesEqual(a, b *domain.MessageRichMessage) bool { + if a.IsZero() && b.IsZero() { + return true + } + return reflect.DeepEqual(a, b) +} + func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply { if reply == nil { return nil diff --git a/internal/store/memory/message_test.go b/internal/store/memory/message_test.go index 452bd9c8..e913c82a 100644 --- a/internal/store/memory/message_test.go +++ b/internal/store/memory/message_test.go @@ -9,6 +9,12 @@ import ( "telesrv/internal/domain" ) +var validRichMessageBlocks = []byte{ + 0x15, 0xc4, 0xb5, 0x1c, // vector + 0x01, 0x00, 0x00, 0x00, // count + 0x8a, 0x7e, 0x56, 0x13, // pageBlockUnsupported +} + func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { ctx := context.Background() dialogs := NewDialogStore() @@ -84,6 +90,41 @@ func TestMessageStoreSendPrivateTextCreatesBothOwnerBoxes(t *testing.T) { } } +func TestMessageStoreEditRichOnlyMessageUsesFinalContentState(t *testing.T) { + ctx := context.Background() + messages := NewMessageStore() + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: 1000000001, + RecipientUserID: 1000000002, + RandomID: 201, + Date: 1700000200, + RichMessage: &domain.MessageRichMessage{Blocks: validRichMessageBlocks}, + }) + if err != nil { + t.Fatalf("SendPrivateText rich-only: %v", err) + } + + edited, err := messages.EditMessage(ctx, domain.EditMessageRequest{ + OwnerUserID: 1000000001, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 1000000002}, + ID: sent.SenderMessage.ID, + Message: "", + HideEdited: true, + EditDate: 1700000210, + }) + if err != nil { + t.Fatalf("EditMessage rich-only hide-edit: %v", err) + } + if len(edited.Edited) != 2 { + t.Fatalf("edited boxes = %d, want 2", len(edited.Edited)) + } + for _, item := range edited.Edited { + if item.Message.RichMessage.IsZero() { + t.Fatalf("edited rich-only box for user %d lost rich message: %+v", item.UserID, item.Message) + } + } +} + func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { ctx := context.Background() messages := NewMessageStore() diff --git a/internal/store/postgres/channel_message_edit.go b/internal/store/postgres/channel_message_edit.go index f5794272..ef1a6384 100644 --- a/internal/store/postgres/channel_message_edit.go +++ b/internal/store/postgres/channel_message_edit.go @@ -12,8 +12,7 @@ import ( ) func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditChannelMessageRequest) (domain.EditChannelMessageResult, error) { - // 空文本只在媒体替换(live location 续报/停止)时合法。 - if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 || (strings.TrimSpace(req.Message) == "" && req.Media == nil) { + if req.UserID == 0 || req.ChannelID == 0 || req.ID <= 0 { return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid } beginner, ok := s.db.(txBeginner) @@ -31,6 +30,10 @@ func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditCh if err != nil { return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit reply markup: %w", err) } + richMessageJSON, err := encodeRichMessage(req.RichMessage) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("encode channel edit rich message: %w", err) + } tx, err := beginner.Begin(ctx) if err != nil { return domain.EditChannelMessageResult{}, fmt.Errorf("begin edit channel message: %w", err) @@ -52,6 +55,17 @@ func (s *ChannelStore) EditChannelMessage(ctx context.Context, req domain.EditCh if msg.Deleted || msg.Action != nil { return domain.EditChannelMessageResult{}, domain.ErrMessageIDInvalid } + finalMedia := msg.Media + if req.Media != nil { + finalMedia = req.Media + } + finalRich := msg.RichMessage + if req.SetRichMessage { + finalRich = req.RichMessage + } + if strings.TrimSpace(req.Message) == "" && finalMedia.IsZero() && finalRich.IsZero() { + return domain.EditChannelMessageResult{}, domain.ErrChannelInvalid + } if req.WebPageResolve { // 频道链接预览就地替换:只换 media(不碰 body/entities/edit_date)+ reserve 频道 pts + // channel_web_page 事件。幂等守卫:仅当前 media 仍是匹配 id 的 pending 占位才换。 @@ -110,7 +124,8 @@ WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.ID, mediaJSON, pts); err if !canWriteEdit { return domain.EditChannelMessageResult{}, domain.ErrMessageAuthorRequired } - if req.Media == nil && !req.SetReplyMarkup && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { + richChanged := req.SetRichMessage && !richMessagesEqual(msg.RichMessage, req.RichMessage) + if req.Media == nil && !req.SetReplyMarkup && !richChanged && msg.Body == req.Message && sameMessageEntities(msg.Entities, req.Entities) { return domain.EditChannelMessageResult{}, domain.ErrMessageNotModified } ptsCount := 1 @@ -130,9 +145,10 @@ SET body = $4, edit_date = $6, pts = $7, reply_markup = CASE WHEN $9 THEN $10::jsonb ELSE reply_markup END, + rich_message = CASE WHEN $11 THEN $12::jsonb ELSE rich_message END, updated_at = now() WHERE channel_id = $1 AND id = $2 AND NOT deleted AND (sender_user_id = $3 OR $8)`, - req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, editPts, canWriteEdit, req.SetReplyMarkup, string(replyMarkupJSON)); err != nil { + req.ChannelID, req.ID, req.UserID, req.Message, entities, req.EditDate, editPts, canWriteEdit, req.SetReplyMarkup, string(replyMarkupJSON), req.SetRichMessage, string(richMessageJSON)); err != nil { return domain.EditChannelMessageResult{}, fmt.Errorf("update channel edit: %w", err) } if req.Media != nil { @@ -160,6 +176,12 @@ WHERE channel_id = $1 AND id = $2`, req.ChannelID, req.ID, mediaJSON); err != ni return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit reply markup: %w", err) } } + if req.SetRichMessage { + msg.RichMessage, err = decodeRichMessage(string(richMessageJSON)) + if err != nil { + return domain.EditChannelMessageResult{}, fmt.Errorf("decode channel edit rich message: %w", err) + } + } msg.EditDate = req.EditDate msg.Pts = editPts event := domain.ChannelUpdateEvent{ diff --git a/internal/store/postgres/channel_message_helpers.go b/internal/store/postgres/channel_message_helpers.go index 2d6c4374..93452fd1 100644 --- a/internal/store/postgres/channel_message_helpers.go +++ b/internal/store/postgres/channel_message_helpers.go @@ -30,6 +30,7 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { var entities, reply, forward, action string var mediaJSON string var replyMarkupJSON string + var richMessageJSON string var savedPeerType string var savedPeerID int64 if err := row.Scan( @@ -37,7 +38,7 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, - &replyMarkupJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, + &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, ); err != nil { return domain.ChannelMessage{}, err } @@ -65,6 +66,10 @@ func scanChannelMessage(row rowScanner) (domain.ChannelMessage, error) { if err != nil { return domain.ChannelMessage{}, err } + msg.RichMessage, err = decodeRichMessage(richMessageJSON) + if err != nil { + return domain.ChannelMessage{}, err + } return msg, nil } @@ -82,6 +87,7 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er var count int var mediaJSON string var replyMarkupJSON string + var richMessageJSON string var savedPeerType string var savedPeerID int64 if err := row.Scan( @@ -89,7 +95,7 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er &sendAsType, &sendAsID, &msg.Date, &msg.EditDate, &msg.Post, &msg.Silent, &msg.NoForwards, &msg.Body, &entities, &reply, &replyMsgID, &replyPeerType, &replyPeerID, &replyTopID, &forward, &discussionChannelID, &discussionMessageID, &action, &msg.Pts, &msg.Deleted, &mediaJSON, - &replyMarkupJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count, + &replyMarkupJSON, &richMessageJSON, &msg.TTLPeriod, &msg.ExpiresAt, &msg.ViewsCount, &msg.PostAuthor, &msg.Pinned, &msg.ViaBotID, &msg.GroupedID, &msg.FromBoostsApplied, &savedPeerType, &savedPeerID, &count, ); err != nil { return domain.ChannelMessage{}, 0, err } @@ -117,6 +123,10 @@ func scanChannelMessageWithCount(row rowScanner) (domain.ChannelMessage, int, er if err != nil { return domain.ChannelMessage{}, 0, err } + msg.RichMessage, err = decodeRichMessage(richMessageJSON) + if err != nil { + return domain.ChannelMessage{}, 0, err + } return msg, count, nil } diff --git a/internal/store/postgres/channel_message_send.go b/internal/store/postgres/channel_message_send.go index 75f727c3..8109a4f6 100644 --- a/internal/store/postgres/channel_message_send.go +++ b/internal/store/postgres/channel_message_send.go @@ -11,7 +11,7 @@ import ( ) func (s *ChannelStore) SendChannelMessage(ctx context.Context, req domain.SendChannelMessageRequest) (domain.SendChannelMessageResult, error) { - if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero()) { + if req.UserID == 0 || req.ChannelID == 0 || (strings.TrimSpace(req.Message) == "" && req.Action == nil && req.Media.IsZero() && req.RichMessage.IsZero()) { return domain.SendChannelMessageResult{}, domain.ErrChannelInvalid } if req.Date == 0 { @@ -123,6 +123,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, + RichMessage: req.RichMessage, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, ReplyMarkup: req.ReplyMarkup, @@ -180,6 +181,7 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, + RichMessage: req.RichMessage, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, ReplyMarkup: req.ReplyMarkup, @@ -422,6 +424,10 @@ func insertChannelMessageTx(ctx context.Context, tx pgx.Tx, msg domain.ChannelMe if err != nil { return err } + richMessage, err := encodeRichMessage(msg.RichMessage) + if err != nil { + return err + } var sendAsType sql.NullString var sendAsID sql.NullInt64 if msg.SendAs != nil && msg.SendAs.ID != 0 { @@ -450,12 +456,12 @@ INSERT INTO channel_messages ( channel_id, id, random_id, sender_user_id, from_peer_type, from_peer_id, send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, entities, reply_to, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, - fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id -) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36)`, + fwd_from, discussion_channel_id, discussion_message_id, action, pts, deleted, media, reply_markup, rich_message, ttl_period, expires_at, post_author, via_bot_id, from_boosts_applied, grouped_id, saved_peer_type, saved_peer_id +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37)`, msg.ChannelID, msg.ID, msg.RandomID, msg.SenderUserID, string(msg.From.Type), msg.From.ID, sendAsType, sendAsID, msg.Date, msg.EditDate, msg.Post, msg.Silent, msg.NoForwards, msg.Body, entities, reply, replyMsgID, replyPeerType, replyPeerID, replyTopID, - forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID); err != nil { + forward, discussionChannelID, discussionMessageID, action, msg.Pts, msg.Deleted, media, replyMarkup, richMessage, msg.TTLPeriod, msg.ExpiresAt, msg.PostAuthor, msg.ViaBotID, msg.FromBoostsApplied, msg.GroupedID, string(msg.SavedPeer.Type), msg.SavedPeer.ID); err != nil { return fmt.Errorf("insert channel message: %w", err) } // 共享媒体索引(迁移 0118):创建即按媒体类别建索引行,供 messages.search 媒体标签页。 diff --git a/internal/store/postgres/channel_store.go b/internal/store/postgres/channel_store.go index 92a16291..c21e2bfa 100644 --- a/internal/store/postgres/channel_store.go +++ b/internal/store/postgres/channel_store.go @@ -126,7 +126,7 @@ const channelMessageColumns = `channel_id, id, random_id, sender_user_id, from_p send_as_peer_type, send_as_peer_id, message_date, edit_date, post, silent, noforwards, body, entities::text, reply_to::text, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, fwd_from::text, discussion_channel_id, discussion_message_id, action::text, pts, deleted, media::text, -reply_markup::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id` +reply_markup::text, rich_message::text, ttl_period, expires_at, views_count, post_author, pinned, via_bot_id, grouped_id, from_boosts_applied, saved_peer_type, saved_peer_id` const channelForumTopicColumns = `channel_id, topic_id, creator_user_id, title, icon_color, icon_emoji_id, title_missing, closed, hidden, pinned, pinned_order, date, top_message_id, read_inbox_max_id, diff --git a/internal/store/postgres/media_codec.go b/internal/store/postgres/media_codec.go index 57b371ad..1ffc06f0 100644 --- a/internal/store/postgres/media_codec.go +++ b/internal/store/postgres/media_codec.go @@ -2,6 +2,7 @@ package postgres import ( "encoding/json" + "reflect" "telesrv/internal/domain" ) @@ -93,6 +94,13 @@ func decodeRichMessage(s string) (*domain.MessageRichMessage, error) { return &m, nil } +func richMessagesEqual(a, b *domain.MessageRichMessage) bool { + if a.IsZero() && b.IsZero() { + return true + } + return reflect.DeepEqual(a, b) +} + func decodePhotoSizes(s string) ([]domain.PhotoSize, error) { if s == "" || s == "[]" || s == "null" { return nil, nil diff --git a/internal/store/postgres/message_edit.go b/internal/store/postgres/message_edit.go index 3fe90643..dbcce1c0 100644 --- a/internal/store/postgres/message_edit.go +++ b/internal/store/postgres/message_edit.go @@ -54,17 +54,26 @@ func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRe PeerType: string(req.Peer.Type), PeerID: req.Peer.ID, }) - // 空文本只在目标消息携带媒体(或本次写入媒体)时合法(清空 caption); - // 纯文本消息清空会留下既无 body 也无 media 的空壳。 - if err == nil && req.Message == "" && req.Media == nil && (target.MediaJson == "" || target.MediaJson == "{}") { - return res, domain.ErrMessageEmpty - } if err != nil { if errors.Is(err, pgx.ErrNoRows) { return res, domain.ErrMessageIDInvalid } return res, fmt.Errorf("get message for edit: %w", err) } + targetRich, err := decodeRichMessage(target.RichMessageJson) + if err != nil { + return res, fmt.Errorf("decode target rich message: %w", err) + } + // 空文本只在目标消息携带媒体/rich(或本次写入媒体/rich)时合法(清空 caption)。 + if req.Message == "" && req.Media == nil && (target.MediaJson == "" || target.MediaJson == "{}") { + if req.SetRichMessage { + if req.RichMessage.IsZero() { + return res, domain.ErrMessageEmpty + } + } else if targetRich.IsZero() { + return res, domain.ErrMessageEmpty + } + } oldEntities, err := decodeMessageEntities(target.EntitiesJson) if err != nil { return res, fmt.Errorf("decode target entities: %w", err) @@ -74,13 +83,18 @@ func (s *MessageStore) EditMessage(ctx context.Context, req domain.EditMessageRe if !authorEdit && !viaBotEdit && !req.WebPageResolve && !validTodoParticipantEdit(req, target, oldEntities) { return res, domain.ErrMessageAuthorRequired } - if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && target.HideEdited == req.HideEdited && sameMessageEntities(oldEntities, req.Entities) { + richChanged := req.SetRichMessage && !richMessagesEqual(targetRich, req.RichMessage) + if req.Media == nil && !req.SetReplyMarkup && !richChanged && target.Body == req.Message && target.HideEdited == req.HideEdited && sameMessageEntities(oldEntities, req.Entities) { return res, domain.ErrMessageNotModified } replyMarkupJSON, err := encodeReplyMarkup(req.ReplyMarkup) if err != nil { return res, fmt.Errorf("encode edit reply markup: %w", err) } + richMessageJSON, err := encodeRichMessage(req.RichMessage) + if err != nil { + return res, fmt.Errorf("encode edit rich message: %w", err) + } messageSenderID := target.MessageSenderID boxes, err := qtx.ListVisibleMessageBoxesByPrivateMessage(ctx, sqlcgen.ListVisibleMessageBoxesByPrivateMessageParams{ OwnerUserIds: privateMessageOwnerIDs(req.OwnerUserID, req.Peer.ID), @@ -173,6 +187,8 @@ WHERE owner_user_id = $1 AND box_id = $2`, box.OwnerUserID, box.BoxID, int32(pts HideEdited: req.HideEdited, SetReplyMarkup: req.SetReplyMarkup, ReplyMarkupJson: replyMarkupJSON, + SetRichMessage: req.SetRichMessage, + RichMessageJson: richMessageJSON, }); err != nil { return res, fmt.Errorf("update private message edit: %w", err) } @@ -210,6 +226,8 @@ WHERE message_sender_id = $1 AND private_message_id = $2`, messageSenderID, targ Pts: int32(pts), SetReplyMarkup: req.SetReplyMarkup, ReplyMarkupJson: replyMarkupJSON, + SetRichMessage: req.SetRichMessage, + RichMessageJson: richMessageJSON, }) if err != nil { return res, fmt.Errorf("update message box edit: %w", err) diff --git a/internal/store/postgres/queries/message.sql b/internal/store/postgres/queries/message.sql index b4b34c01..dc22f777 100644 --- a/internal/store/postgres/queries/message.sql +++ b/internal/store/postgres/queries/message.sql @@ -1036,6 +1036,10 @@ SET body = sqlc.arg(body)::text, reply_markup = CASE WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb ELSE reply_markup + END, + rich_message = CASE + WHEN sqlc.arg(set_rich_message)::boolean THEN sqlc.arg(rich_message_json)::jsonb + ELSE rich_message END WHERE sender_user_id = sqlc.arg(sender_user_id)::bigint AND id = sqlc.arg(private_message_id)::bigint; @@ -1050,6 +1054,10 @@ SET body = sqlc.arg(body)::text, reply_markup = CASE WHEN sqlc.arg(set_reply_markup)::boolean THEN sqlc.arg(reply_markup_json)::jsonb ELSE reply_markup + END, + rich_message = CASE + WHEN sqlc.arg(set_rich_message)::boolean THEN sqlc.arg(rich_message_json)::jsonb + ELSE rich_message END WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint AND box_id = sqlc.arg(box_id)::int diff --git a/internal/store/postgres/scheduled_message.go b/internal/store/postgres/scheduled_message.go index a50efc90..64e7dec3 100644 --- a/internal/store/postgres/scheduled_message.go +++ b/internal/store/postgres/scheduled_message.go @@ -27,7 +27,7 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc if req.Peer.Type != domain.PeerTypeUser && req.Peer.Type != domain.PeerTypeChannel { return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: invalid peer") } - if req.Message == "" && req.Media.IsZero() { + if req.Message == "" && req.Media.IsZero() && req.RichMessage.IsZero() { return domain.ScheduledMessage{}, fmt.Errorf("create scheduled message: empty message") } if req.Date == 0 { @@ -41,6 +41,10 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc if err != nil { return domain.ScheduledMessage{}, err } + richMessage, err := encodeRichMessage(req.RichMessage) + if err != nil { + return domain.ScheduledMessage{}, err + } meta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, req.ReplyTo, req.Forward) if err != nil { return domain.ScheduledMessage{}, err @@ -87,7 +91,7 @@ func (s *MessageStore) CreateScheduledMessage(ctx context.Context, req domain.Sc if _, err := tx.Exec(ctx, ` INSERT INTO scheduled_messages ( owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date, - body, entities, media, silent, noforwards, + body, entities, media, rich_message, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, @@ -95,14 +99,14 @@ INSERT INTO scheduled_messages ( schedule_date, schedule_repeat_period, state, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, - $7, $8::jsonb, $9::jsonb, $10, $11, - $12, $13, $14, $15, - $16, $17::jsonb, $18, - $19, $20, $21, $22, - $23, $24, - $25, $26, 'pending', $27, $27 + $7, $8::jsonb, $9::jsonb, $10::jsonb, $11, $12, + $13, $14, $15, $16, + $17, $18::jsonb, $19, + $20, $21, $22, $23, + $24, $25, + $26, $27, 'pending', $28, $28 )`, req.OwnerUserID, nextID, string(req.Peer.Type), req.Peer.ID, req.RandomID, req.Date, - req.Message, entities, media, req.Silent, req.NoForwards, + req.Message, entities, media, richMessage, req.Silent, req.NoForwards, meta.ReplyToMsgID, meta.ReplyToPeerType, meta.ReplyToPeerID, meta.ReplyToTopID, meta.QuoteText, meta.QuoteEntitiesJSON, meta.QuoteOffset, meta.FwdFromPeerType, meta.FwdFromPeerID, meta.FwdFromName, meta.FwdDate, @@ -167,29 +171,38 @@ func (s *MessageStore) EditScheduledMessage(ctx context.Context, req domain.Edit } message := current.Message entities := append([]domain.MessageEntity(nil), current.Entities...) + richMessage := current.RichMessage if req.SetMessage { - if req.Message == "" && current.Media.IsZero() { - return domain.ScheduledMessage{}, domain.ErrMessageEmpty - } message = req.Message entities = append([]domain.MessageEntity(nil), req.Entities...) } + if req.SetRichMessage { + richMessage = req.RichMessage + } + if message == "" && current.Media.IsZero() && richMessage.IsZero() { + return domain.ScheduledMessage{}, domain.ErrMessageEmpty + } encodedEntities, err := encodeMessageEntities(entities) if err != nil { return domain.ScheduledMessage{}, err } + encodedRichMessage, err := encodeRichMessage(richMessage) + if err != nil { + return domain.ScheduledMessage{}, err + } row := tx.QueryRow(ctx, ` UPDATE scheduled_messages SET body = $5, entities = $6::jsonb, - schedule_date = $7, - updated_at = $8 + rich_message = $7::jsonb, + schedule_date = $8, + updated_at = $9 WHERE owner_user_id = $1 AND peer_type = $2 AND peer_id = $3 AND scheduled_id = $4 AND state = 'pending' -RETURNING `+scheduledMessageSelectColumns(), req.OwnerUserID, string(req.Peer.Type), req.Peer.ID, req.ID, message, encodedEntities, req.ScheduleDate, req.Date) +RETURNING `+scheduledMessageSelectColumns(), req.OwnerUserID, string(req.Peer.Type), req.Peer.ID, req.ID, message, encodedEntities, encodedRichMessage, req.ScheduleDate, req.Date) msg, err := scanScheduledMessage(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -472,7 +485,7 @@ LIMIT $4`, filter.OwnerUserID, string(filter.Peer.Type), filter.Peer.ID, limit) func scheduledMessageSelectColumns() string { return `owner_user_id, scheduled_id, peer_type, peer_id, random_id, message_date, -body, entities::text, media::text, silent, noforwards, +body, entities::text, media::text, rich_message::text, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, quote_text, quote_entities::text, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, @@ -486,7 +499,7 @@ func scheduledMessageSelectColumnsFor(alias string) string { } prefix := alias + "." return prefix + `owner_user_id, ` + prefix + `scheduled_id, ` + prefix + `peer_type, ` + prefix + `peer_id, ` + prefix + `random_id, ` + prefix + `message_date, -` + prefix + `body, ` + prefix + `entities::text, ` + prefix + `media::text, ` + prefix + `silent, ` + prefix + `noforwards, +` + prefix + `body, ` + prefix + `entities::text, ` + prefix + `media::text, ` + prefix + `rich_message::text, ` + prefix + `silent, ` + prefix + `noforwards, ` + prefix + `reply_to_msg_id, ` + prefix + `reply_to_peer_type, ` + prefix + `reply_to_peer_id, ` + prefix + `reply_to_top_id, ` + prefix + `quote_text, ` + prefix + `quote_entities::text, ` + prefix + `quote_offset, ` + prefix + `fwd_from_peer_type, ` + prefix + `fwd_from_peer_id, ` + prefix + `fwd_from_name, ` + prefix + `fwd_date, @@ -516,6 +529,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched peerType string entitiesJSON string mediaJSON string + richMessageJSON string replyToMsgID int32 replyToPeerType string replyToPeerID int64 @@ -533,7 +547,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched ) if err := scanner.Scan( &msg.OwnerUserID, &msg.ID, &peerType, &msg.Peer.ID, &msg.RandomID, &msg.CreatedAt, - &msg.Message, &entitiesJSON, &mediaJSON, &msg.Silent, &msg.NoForwards, + &msg.Message, &entitiesJSON, &mediaJSON, &richMessageJSON, &msg.Silent, &msg.NoForwards, &replyToMsgID, &replyToPeerType, &replyToPeerID, &replyToTopID, "eText, "eEntitiesJSON, "eOffset, &fwdFromPeerType, &fwdFromPeerID, &fwdFromName, &fwdDate, @@ -554,6 +568,11 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled media: %w", err) } msg.Media = media + richMessage, err := decodeRichMessage(richMessageJSON) + if err != nil { + return domain.ScheduledMessage{}, fmt.Errorf("decode scheduled rich message: %w", err) + } + msg.RichMessage = richMessage // scheduled_messages 不存 saved_from:到点投递经 SendPrivateText 实时 // 重算 saved 语义(self-chat 直发归 self),fwd saved 维度恒空。 _, _, reply, forward, err := messageMetadataFromFields( diff --git a/internal/store/postgres/scheduled_message_integration_test.go b/internal/store/postgres/scheduled_message_integration_test.go index 950ed0c2..9a1c0332 100644 --- a/internal/store/postgres/scheduled_message_integration_test.go +++ b/internal/store/postgres/scheduled_message_integration_test.go @@ -107,4 +107,72 @@ func TestScheduledMessageEditPreservesContentWhenMessageUnset(t *testing.T) { if !errors.Is(err, domain.ErrMessageEmpty) { t.Fatalf("empty text scheduled edit err = %v, want ErrMessageEmpty", err) } + + richInitial := &domain.MessageRichMessage{Rtl: true, Blocks: []byte{1, 2, 3, 4}} + richOnly, err := messages.CreateScheduledMessage(ctx, domain.ScheduleMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + RandomID: 7003, + RichMessage: richInitial, + ScheduleDate: 1700021600, + Date: 1700000500, + }) + if err != nil { + t.Fatalf("create rich scheduled message: %v", err) + } + if !richMessagesEqual(richOnly.RichMessage, richInitial) || richOnly.Message != "" || !richOnly.Media.IsZero() { + t.Fatalf("rich scheduled create = %+v, want rich-only content", richOnly) + } + gotRich, err := messages.GetScheduledMessages(ctx, domain.ScheduledMessageFilter{ + OwnerUserID: owner.ID, + Peer: peer, + IDs: []int{richOnly.ID}, + Limit: 10, + }) + if err != nil { + t.Fatalf("get rich scheduled message: %v", err) + } + if len(gotRich.Messages) != 1 || !richMessagesEqual(gotRich.Messages[0].RichMessage, richInitial) { + t.Fatalf("get rich scheduled = %+v, want original rich", gotRich.Messages) + } + richDateOnly, err := messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + ID: richOnly.ID, + ScheduleDate: 1700025200, + Date: 1700000600, + }) + if err != nil { + t.Fatalf("date-only edit rich scheduled message: %v", err) + } + if richDateOnly.ScheduleDate != 1700025200 || !richMessagesEqual(richDateOnly.RichMessage, richInitial) { + t.Fatalf("date-only rich scheduled edit = %+v, want original rich and new date", richDateOnly) + } + richEdited := &domain.MessageRichMessage{Part: true, Blocks: []byte{5, 6, 7}} + editedRich, err := messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + ID: richOnly.ID, + SetRichMessage: true, + RichMessage: richEdited, + ScheduleDate: 1700028800, + Date: 1700000700, + }) + if err != nil { + t.Fatalf("edit rich scheduled message: %v", err) + } + if !richMessagesEqual(editedRich.RichMessage, richEdited) || editedRich.ScheduleDate != 1700028800 { + t.Fatalf("edited rich scheduled = %+v, want replacement rich", editedRich) + } + _, err = messages.EditScheduledMessage(ctx, domain.EditScheduledMessageRequest{ + OwnerUserID: owner.ID, + Peer: peer, + ID: richOnly.ID, + SetRichMessage: true, + ScheduleDate: 1700032400, + Date: 1700000800, + }) + if !errors.Is(err, domain.ErrMessageEmpty) { + t.Fatalf("clear only rich scheduled edit err = %v, want ErrMessageEmpty", err) + } } diff --git a/internal/store/postgres/sqlcgen/message.sql.go b/internal/store/postgres/sqlcgen/message.sql.go index adb9a202..abae0092 100644 --- a/internal/store/postgres/sqlcgen/message.sql.go +++ b/internal/store/postgres/sqlcgen/message.sql.go @@ -3554,9 +3554,13 @@ SET body = $1::text, reply_markup = CASE WHEN $6::boolean THEN $7::jsonb ELSE reply_markup + END, + rich_message = CASE + WHEN $8::boolean THEN $9::jsonb + ELSE rich_message END -WHERE owner_user_id = $8::bigint - AND box_id = $9::int +WHERE owner_user_id = $10::bigint + AND box_id = $11::int AND NOT deleted RETURNING box_id, @@ -3613,6 +3617,8 @@ type UpdateMessageBoxEditParams struct { Pts int32 SetReplyMarkup bool ReplyMarkupJson []byte + SetRichMessage bool + RichMessageJson []byte OwnerUserID int64 BoxID int32 } @@ -3673,6 +3679,8 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox arg.Pts, arg.SetReplyMarkup, arg.ReplyMarkupJson, + arg.SetRichMessage, + arg.RichMessageJson, arg.OwnerUserID, arg.BoxID, ) @@ -3735,9 +3743,13 @@ SET body = $1::text, reply_markup = CASE WHEN $5::boolean THEN $6::jsonb ELSE reply_markup + END, + rich_message = CASE + WHEN $7::boolean THEN $8::jsonb + ELSE rich_message END -WHERE sender_user_id = $7::bigint - AND id = $8::bigint +WHERE sender_user_id = $9::bigint + AND id = $10::bigint ` type UpdatePrivateMessageEditParams struct { @@ -3747,6 +3759,8 @@ type UpdatePrivateMessageEditParams struct { HideEdited bool SetReplyMarkup bool ReplyMarkupJson []byte + SetRichMessage bool + RichMessageJson []byte SenderUserID int64 PrivateMessageID int64 } @@ -3759,6 +3773,8 @@ func (q *Queries) UpdatePrivateMessageEdit(ctx context.Context, arg UpdatePrivat arg.HideEdited, arg.SetReplyMarkup, arg.ReplyMarkupJson, + arg.SetRichMessage, + arg.RichMessageJson, arg.SenderUserID, arg.PrivateMessageID, ) From 8e255dc9c8dccaffb4c9a53283744436f588e770 Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 21:16:16 +0800 Subject: [PATCH 6/8] fix: accept Android negative temporary DC --- internal/mtprotoedge/exchange.go | 8 +- internal/mtprotoedge/exchange_compat.go | 399 ++++++++++++++++++++++++ internal/mtprotoedge/exchange_test.go | 53 ++++ 3 files changed, 453 insertions(+), 7 deletions(-) create mode 100644 internal/mtprotoedge/exchange_compat.go diff --git a/internal/mtprotoedge/exchange.go b/internal/mtprotoedge/exchange.go index 0e6a3584..f8ce0366 100644 --- a/internal/mtprotoedge/exchange.go +++ b/internal/mtprotoedge/exchange.go @@ -8,7 +8,6 @@ import ( "go.uber.org/zap" - "github.com/gotd/log/logzap" "github.com/gotd/td/bin" "github.com/gotd/td/crypto" "github.com/gotd/td/exchange" @@ -54,12 +53,7 @@ func (s *Server) handleExchange(ctx context.Context, conn transport.Conn, first } start := s.clock.Now() - res, err := exchange.NewExchanger(buffered, s.dc). - WithClock(s.clock). - WithRand(s.rand). - WithLogger(logzap.New(s.log.Named("exchange"))). - Server(s.key). - Run(runCtx) + res, err := s.runServerExchange(runCtx, buffered) if err != nil { // gotd v0.158:握手中读到非零 auth_key_id 帧(客户端用既有 auth key 而非重新交换) // 经类型化 UnexpectedEncryptedError 暴露并随附原始帧(旧版仅靠错误文案匹配,升级后失效)。 diff --git a/internal/mtprotoedge/exchange_compat.go b/internal/mtprotoedge/exchange_compat.go new file mode 100644 index 00000000..0430f5f8 --- /dev/null +++ b/internal/mtprotoedge/exchange_compat.go @@ -0,0 +1,399 @@ +package mtprotoedge + +import ( + "context" + crand "crypto/rand" + "encoding/hex" + "fmt" + "io" + "math/big" + "time" + + gofaster "github.com/go-faster/errors" + "go.uber.org/zap" + + "github.com/gotd/td/bin" + "github.com/gotd/td/clock" + "github.com/gotd/td/crypto" + "github.com/gotd/td/exchange" + "github.com/gotd/td/mt" + "github.com/gotd/td/proto" + "github.com/gotd/td/proto/codec" + "github.com/gotd/td/transport" +) + +// runServerExchange is a gotd server exchange compatibility shim. +// +// DrKLO Android marks media temporary auth-key exchange with a negative DC in +// p_q_inner_data_temp_dc (for example DC 2 -> -2). gotd v0.158.0 validates this +// field by exact equality and rejects that legitimate media-temp path. Keep the +// permanent-key check strict, but allow temp-key DC values whose absolute value +// matches this server DC. +func (s *Server) runServerExchange(ctx context.Context, conn transport.Conn) (exchange.ServerExchangeResult, error) { + ex := serverExchangeCompat{ + conn: conn, + clock: s.clock, + rand: s.rand, + timeout: exchange.DefaultTimeout, + key: s.key, + dc: s.dc, + log: s.log.Named("exchange"), + rng: compatServerRNG{rand: s.rand}, + } + return ex.run(ctx) +} + +type serverExchangeCompat struct { + conn transport.Conn + clock clock.Clock + rand io.Reader + timeout time.Duration + key exchange.PrivateKey + dc int + log *zap.Logger + rng compatServerRNG +} + +func (s serverExchangeCompat) run(ctx context.Context) (exchange.ServerExchangeResult, error) { + wrapKeyNotFound := func(err error) error { + return exchangeError(codec.CodeAuthKeyNotFound, err) + } + + var req compatReqPQ + b := new(bin.Buffer) + if err := s.readUnencrypted(ctx, b, &req); err != nil { + return exchange.ServerExchangeResult{}, err + } + s.log.Debug("Received client ReqPqMultiRequest") + + serverNonce, err := crypto.RandInt128(s.rand) + if err != nil { + return exchange.ServerExchangeResult{}, gofaster.Wrap(err, "generate server nonce") + } + + pq, err := s.rng.PQ() + if err != nil { + return exchange.ServerExchangeResult{}, gofaster.Wrap(err, "generate pq") + } + +SendResPQ: + s.log.Debug("Sending ResPQ", zap.String("pq", pq.String())) + if err := s.writeUnencrypted(ctx, b, &mt.ResPQ{ + Pq: pq.Bytes(), + Nonce: req.Nonce, + ServerNonce: serverNonce, + ServerPublicKeyFingerprints: []int64{ + s.key.Fingerprint(), + }, + }); err != nil { + return exchange.ServerExchangeResult{}, err + } + + var dhParams compatReqOrDH + if err := s.readUnencrypted(ctx, b, &dhParams); err != nil { + return exchange.ServerExchangeResult{}, err + } + switch dhParams.Type { + case mt.ReqPqRequestTypeID, mt.ReqPqMultiRequestTypeID: + s.log.Debug("Received ReqPQ again") + req = dhParams.Req + goto SendResPQ + default: + s.log.Debug("Received client ReqDHParamsRequest") + } + + var innerData mt.PQInnerData + { + r, err := crypto.DecodeRSAPad(dhParams.DH.EncryptedData, s.key.RSA) + if err != nil { + return exchange.ServerExchangeResult{}, wrapKeyNotFound(err) + } + b.ResetTo(r) + + d, err := mt.DecodePQInnerData(b) + if err != nil { + return exchange.ServerExchangeResult{}, err + } + if err := s.validatePQInnerDataDC(d); err != nil { + return exchange.ServerExchangeResult{}, err + } + + innerData = mt.PQInnerData{ + Pq: d.GetPq(), + P: d.GetP(), + Q: d.GetQ(), + Nonce: d.GetNonce(), + ServerNonce: d.GetServerNonce(), + NewNonce: d.GetNewNonce(), + } + } + + dhPrime, err := s.rng.DhPrime() + if err != nil { + return exchange.ServerExchangeResult{}, gofaster.Wrap(err, "generate dh_prime") + } + + g := 3 + a, ga, err := s.rng.GA(g, dhPrime) + if err != nil { + return exchange.ServerExchangeResult{}, gofaster.Wrap(err, "generate g_a") + } + + data := mt.ServerDHInnerData{ + Nonce: req.Nonce, + ServerNonce: serverNonce, + G: g, + GA: ga.Bytes(), + DhPrime: dhPrime.Bytes(), + ServerTime: int(s.clock.Now().Unix()), + } + + b.Reset() + if err := data.Encode(b); err != nil { + return exchange.ServerExchangeResult{}, err + } + + key, iv := crypto.TempAESKeys(innerData.NewNonce.BigInt(), serverNonce.BigInt()) + answer, err := crypto.EncryptExchangeAnswer(s.rand, b.Raw(), key, iv) + if err != nil { + return exchange.ServerExchangeResult{}, err + } + + s.log.Debug("Sending ServerDHParamsOk", zap.Int("g", g)) + if err := s.writeUnencrypted(ctx, b, &mt.ServerDHParamsOk{ + Nonce: req.Nonce, + ServerNonce: serverNonce, + EncryptedAnswer: answer, + }); err != nil { + return exchange.ServerExchangeResult{}, err + } + + var clientDhParams mt.SetClientDHParamsRequest + if err := s.readUnencrypted(ctx, b, &clientDhParams); err != nil { + return exchange.ServerExchangeResult{}, err + } + s.log.Debug("Received client SetClientDHParamsRequest") + + decrypted, err := crypto.DecryptExchangeAnswer(clientDhParams.EncryptedData, key, iv) + if err != nil { + err = gofaster.Wrap(err, "decrypt exchange answer") + return exchange.ServerExchangeResult{}, wrapKeyNotFound(err) + } + b.ResetTo(decrypted) + + var clientInnerData mt.ClientDHInnerData + if err := clientInnerData.Decode(b); err != nil { + return exchange.ServerExchangeResult{}, wrapKeyNotFound(err) + } + + gB := big.NewInt(0).SetBytes(clientInnerData.GB) + var authKey crypto.Key + if !crypto.FillBytes(big.NewInt(0).Exp(gB, a, dhPrime), authKey[:]) { + err := gofaster.New("auth_key is too big") + return exchange.ServerExchangeResult{}, wrapKeyNotFound(err) + } + + s.log.Debug("Sending DhGenOk") + if err := s.writeUnencrypted(ctx, b, &mt.DhGenOk{ + Nonce: req.Nonce, + ServerNonce: serverNonce, + NewNonceHash1: crypto.NonceHash1(innerData.NewNonce, authKey), + }); err != nil { + return exchange.ServerExchangeResult{}, err + } + + serverSalt := crypto.ServerSalt(innerData.NewNonce, serverNonce) + return exchange.ServerExchangeResult{ + Key: authKey.WithID(), + ServerSalt: serverSalt, + }, nil +} + +func (s serverExchangeCompat) validatePQInnerDataDC(d mt.PQInnerDataClass) error { + switch innerDataDC := d.(type) { + case *mt.PQInnerDataDC: + if innerDataDC.DC != s.dc { + return wrongDCError(s.dc, innerDataDC.DC) + } + case *mt.PQInnerDataTempDC: + if !sameDCByAbs(innerDataDC.DC, s.dc) { + return wrongDCError(s.dc, innerDataDC.DC) + } + if innerDataDC.DC < 0 { + s.log.Warn("Accepted Android media temp auth key negative DC", + zap.Int("server_dc", s.dc), + zap.Int("client_dc", innerDataDC.DC), + zap.Int("expires_in", innerDataDC.ExpiresIn)) + } + } + return nil +} + +func sameDCByAbs(got, want int) bool { + g := int64(got) + if g < 0 { + g = -g + } + return g == int64(want) +} + +func wrongDCError(want, got int) error { + return exchangeError(codec.CodeWrongDC, gofaster.Errorf("wrong DC ID, want %d, got %d", want, got)) +} + +func exchangeError(code int32, err error) error { + return &exchange.ServerExchangeError{ + Code: code, + Err: err, + } +} + +func (s serverExchangeCompat) writeUnencrypted(ctx context.Context, b *bin.Buffer, data bin.Encoder) error { + b.Reset() + if err := data.Encode(b); err != nil { + return err + } + msg := proto.UnencryptedMessage{ + MessageID: int64(proto.NewMessageID(s.clock.Now(), proto.MessageServerResponse)), + MessageData: b.Copy(), + } + b.Reset() + if err := msg.Encode(b); err != nil { + return err + } + + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + return s.conn.Send(ctx, b) +} + +func (s serverExchangeCompat) readUnencrypted(ctx context.Context, b *bin.Buffer, data bin.Decoder) error { + b.Reset() + + ctx, cancel := context.WithTimeout(ctx, s.timeout) + defer cancel() + if err := s.conn.Recv(ctx, b); err != nil { + return err + } + + var keyID [8]byte + if err := b.PeekN(keyID[:], len(keyID)); err == nil && keyID != ([8]byte{}) { + return &exchange.UnexpectedEncryptedError{ + AuthKeyID: keyID, + Frame: append([]byte(nil), b.Buf...), + } + } + + var msg proto.UnencryptedMessage + if err := msg.Decode(b); err != nil { + return err + } + if proto.MessageID(msg.MessageID).Type() != proto.MessageFromClient { + return gofaster.New("bad msg type") + } + b.ResetTo(msg.MessageData) + + return data.Decode(b) +} + +type compatReqPQ struct { + Type uint32 + Nonce bin.Int128 +} + +func (r *compatReqPQ) Decode(b *bin.Buffer) error { + var ( + legacy mt.ReqPqRequest + multi mt.ReqPqMultiRequest + ) + id, err := b.PeekID() + if err != nil { + return err + } + r.Type = id + switch id { + case legacy.TypeID(): + if err := legacy.Decode(b); err != nil { + return err + } + r.Nonce = legacy.Nonce + return nil + case multi.TypeID(): + if err := multi.Decode(b); err != nil { + return err + } + r.Nonce = multi.Nonce + return nil + default: + return bin.NewUnexpectedID(id) + } +} + +type compatReqOrDH struct { + Type uint32 + DH mt.ReqDHParamsRequest + Req compatReqPQ +} + +func (r *compatReqOrDH) Decode(b *bin.Buffer) error { + id, err := b.PeekID() + if err != nil { + return err + } + r.Type = id + switch id { + case r.DH.TypeID(): + return r.DH.Decode(b) + default: + return r.Req.Decode(b) + } +} + +type compatServerRNG struct { + rand io.Reader +} + +func (s compatServerRNG) PQ() (*big.Int, error) { + return big.NewInt(0x17ED48941A08F981), nil +} + +func (s compatServerRNG) GA(g int, dhPrime *big.Int) (a, ga *big.Int, err error) { + if err := crypto.CheckGP(g, dhPrime); err != nil { + return nil, nil, err + } + + gBig := big.NewInt(int64(g)) + one := big.NewInt(1) + dhPrimeMinusOne := big.NewInt(0).Sub(dhPrime, one) + + safetyRangeMin := big.NewInt(0).Exp(big.NewInt(2), big.NewInt(crypto.RSAKeyBits-64), nil) + safetyRangeMax := big.NewInt(0).Sub(dhPrime, safetyRangeMin) + + randMax := big.NewInt(0).SetBit(big.NewInt(0), crypto.RSAKeyBits, 1) + for { + a, err = crand.Int(s.rand, randMax) + if err != nil { + return nil, nil, err + } + + ga = big.NewInt(0).Exp(gBig, a, dhPrime) + if crypto.InRange(ga, one, dhPrimeMinusOne) && crypto.InRange(ga, safetyRangeMin, safetyRangeMax) { + return a, ga, nil + } + } +} + +func (s compatServerRNG) DhPrime() (*big.Int, error) { + data, err := hex.DecodeString("C71CAEB9C6B1C9048E6C522F70F13F73980D40238E3E21C14934D037563D930F" + + "48198A0AA7C14058229493D22530F4DBFA336F6E0AC925139543AED44CCE7C37" + + "20FD51F69458705AC68CD4FE6B6B13ABDC9746512969328454F18FAF8C595F64" + + "2477FE96BB2A941D5BCD1D4AC8CC49880708FA9B378E3C4F3A9060BEE67CF9A4" + + "A4A695811051907E162753B56B0F6B410DBA74D8A84B2A14B3144E0EF1284754" + + "FD17ED950D5965B4B9DD46582DB1178D169C6BC465B0D6FF9CA3928FEF5B9AE4" + + "E418FC15E83EBEA0F87FA9FF5EED70050DED2849F47BF959D956850CE929851F" + + "0D8115F635B105EE2E4E15D04B2454BF6F4FADF034B10403119CD8E3B92FCC5B") + if err != nil { + return nil, fmt.Errorf("decode dh_prime: %w", err) + } + return big.NewInt(0).SetBytes(data), nil +} diff --git a/internal/mtprotoedge/exchange_test.go b/internal/mtprotoedge/exchange_test.go index 9a5215a6..2d875262 100644 --- a/internal/mtprotoedge/exchange_test.go +++ b/internal/mtprotoedge/exchange_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/rsa" + "errors" "net" "testing" "time" @@ -15,6 +16,7 @@ import ( "github.com/gotd/td/exchange" "github.com/gotd/td/mt" tgproto "github.com/gotd/td/proto" + "github.com/gotd/td/proto/codec" "github.com/gotd/td/transport" "telesrv/internal/store" @@ -104,6 +106,57 @@ func TestKeyExchange(t *testing.T) { } } +func TestKeyExchangeAcceptsAndroidMediaTempNegativeDC(t *testing.T) { + const dc = 2 + addr, pub, srv := startTestServer(t, Options{DC: dc}) + conn := dialTransportOnly(t, addr) + t.Cleanup(func() { _ = conn.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + res, err := exchange.NewExchanger(conn, -dc). + WithTempMode(24 * 60 * 60). + WithRand(rand.Reader). + WithLogger(logzap.New(zaptest.NewLogger(t).Named("client"))). + Client([]exchange.PublicKey{pub}). + Run(ctx) + if err != nil { + t.Fatalf("client exchange: %v", err) + } + + var saved store.AuthKeyData + found := false + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + saved, found, _ = srv.authKeys.Get(context.Background(), res.AuthKey.ID) + if found { + break + } + time.Sleep(20 * time.Millisecond) + } + if !found { + t.Fatalf("server did not store media temp auth key %x", res.AuthKey.ID) + } + if saved.Value != [256]byte(res.AuthKey.Value) { + t.Fatal("server auth key value mismatch") + } + if saved.ServerSalt != res.ServerSalt { + t.Fatalf("server salt mismatch: server=%d client=%d", saved.ServerSalt, res.ServerSalt) + } +} + +func TestKeyExchangeRejectsWrongNegativeTempDC(t *testing.T) { + ex := serverExchangeCompat{dc: 2, log: zaptest.NewLogger(t)} + err := ex.validatePQInnerDataDC(&mt.PQInnerDataTempDC{DC: -3}) + var exErr *exchange.ServerExchangeError + if !errors.As(err, &exErr) { + t.Fatalf("err = %T %v, want ServerExchangeError", err, err) + } + if exErr.Code != codec.CodeWrongDC { + t.Fatalf("error code = %d, want %d", exErr.Code, codec.CodeWrongDC) + } +} + func TestKeyExchangeIgnoresUnencryptedMsgsAck(t *testing.T) { const dc = 2 From ca79562fd84a9a897927bd672b8b37a982ba3361 Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 21:17:21 +0800 Subject: [PATCH 7/8] chore: sync dependency updates --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 61555a13..2a557cb7 100644 --- a/go.mod +++ b/go.mod @@ -12,16 +12,16 @@ require ( github.com/gotd/tl v0.4.0 github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgx/v5 v5.9.2 - github.com/pion/datachannel v1.6.0 + github.com/pion/datachannel v1.6.2 github.com/pion/dtls/v3 v3.1.4 github.com/pion/ice/v4 v4.2.7 github.com/pion/logging v0.2.4 - github.com/pion/rtcp v1.2.16 - github.com/pion/rtp v1.10.2 - github.com/pion/sctp v1.10.0 - github.com/pion/srtp/v3 v3.0.11 + github.com/pion/rtcp v1.2.17 + github.com/pion/rtp v1.10.3 + github.com/pion/sctp v1.10.3 + github.com/pion/srtp/v3 v3.0.12 github.com/pion/transport/v4 v4.0.2 - github.com/pion/turn/v5 v5.0.9 + github.com/pion/turn/v5 v5.0.10 github.com/redis/go-redis/v9 v9.20.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 @@ -48,13 +48,13 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/ogen-go/ogen v1.22.0 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/stun/v3 v3.1.5 // indirect + github.com/pion/stun/v3 v3.1.6 // indirect github.com/refraction-networking/utls v1.8.2 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect @@ -70,7 +70,7 @@ require ( golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.46.0 // indirect + golang.org/x/tools v0.47.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index dc97d347..4296e7db 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -110,8 +110,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= -github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= -github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= +github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= @@ -122,22 +122,22 @@ github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= -github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= -github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= -github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= -github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg= -github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= -github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= -github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= -github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8= -github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= +github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= +github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU= +github.com/pion/rtp v1.10.3 h1:r5nJQdtM9Dc4ZYxtTcPPz7PIFArKJIf/DMlIUxU7+1c= +github.com/pion/rtp v1.10.3/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI= +github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= +github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= +github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= +github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM= -github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ= +github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -213,8 +213,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 2d5a6236c2e49c5135fa19f8a9f35f07d8933f28 Mon Sep 17 00:00:00 2001 From: A Date: Sat, 4 Jul 2026 23:45:27 +0800 Subject: [PATCH 8/8] fix: sync Android cursor tolerance fixes --- internal/rpc/messages_saved_dialogs.go | 22 +++- .../rpc/messages_saved_dialogs_rpc_test.go | 29 +++++ internal/rpc/stories.go | 34 +++++- internal/rpc/stories_rpc_test.go | 12 +- .../postgres/message_send_integration_test.go | 109 ++++++++++++++++++ 5 files changed, 198 insertions(+), 8 deletions(-) diff --git a/internal/rpc/messages_saved_dialogs.go b/internal/rpc/messages_saved_dialogs.go index 91cf79b4..13d068ff 100644 --- a/internal/rpc/messages_saved_dialogs.go +++ b/internal/rpc/messages_saved_dialogs.go @@ -21,9 +21,13 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages if parent, hasParent, err := r.validateSavedHistoryParentPeer(ctx, userID, req.GetParentPeer); err != nil { return nil, err } else if hasParent { - if req.Limit < 0 || req.OffsetID < 0 { + if req.Limit < 0 { return nil, limitInvalidErr() } + offsetID := req.OffsetID + if offsetID < 0 { + offsetID = 0 + } mono, isMono, err := r.resolveMonoforumForAdmin(ctx, userID, parent) if err != nil { return nil, err @@ -38,15 +42,23 @@ func (r *Router) onMessagesGetSavedDialogs(ctx context.Context, req *tg.Messages }, nil } // parent_peer = monoforum:返回该频道私信的订阅者子会话列表(管理员视角)。 - return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, req.OffsetID) + return r.monoforumSavedDialogs(ctx, userID, mono, req.Limit, offsetID) } - if req.Limit < 0 || req.OffsetID < 0 || req.OffsetDate < 0 { + if req.Limit < 0 { return nil, limitInvalidErr() } + offsetID := req.OffsetID + if offsetID < 0 { + offsetID = 0 + } + offsetDate := req.OffsetDate + if offsetDate < 0 { + offsetDate = 0 + } filter := domain.SavedDialogsFilter{ ExcludePinned: req.ExcludePinned, - OffsetID: req.OffsetID, - OffsetDate: req.OffsetDate, + OffsetID: offsetID, + OffsetDate: offsetDate, Limit: req.Limit, } if req.OffsetPeer != nil { diff --git a/internal/rpc/messages_saved_dialogs_rpc_test.go b/internal/rpc/messages_saved_dialogs_rpc_test.go index 4f75782c..345dca42 100644 --- a/internal/rpc/messages_saved_dialogs_rpc_test.go +++ b/internal/rpc/messages_saved_dialogs_rpc_test.go @@ -7,6 +7,7 @@ import ( "github.com/gotd/td/bin" "github.com/gotd/td/clock" "github.com/gotd/td/tg" + "github.com/gotd/td/tgerr" "go.uber.org/zap/zaptest" appcontacts "telesrv/internal/app/contacts" @@ -252,6 +253,34 @@ func TestMessagesGetSavedDialogsPagination(t *testing.T) { } } +// TestMessagesGetSavedDialogsNegativePendingOffset 验证 DrKLO Android self Saved +// Messages 分组里出现本地 pending 负消息 id 时,下一页请求不会被误判为 +// LIMIT_INVALID。服务端持久化 top_message 仍保持正数;负 offset 仅是客户端本地 +// 游标噪声,按首页/无 offset 处理。 +func TestMessagesGetSavedDialogsNegativePendingOffset(t *testing.T) { + ctx := context.Background() + r, _, alice, _, _, _ := savedDialogsFixture(t) + + res, err := r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{ + Limit: 20, + OffsetID: -1001, + OffsetDate: -1, + OffsetPeer: &tg.InputPeerSelf{}, + }) + if err != nil { + t.Fatalf("negative pending offset: %v", err) + } + dialogs, _, _, _, full := savedDialogPage(t, res) + if !full || len(dialogs) != 2 { + t.Fatalf("negative pending offset page = %d full %v, want first page 2 full", len(dialogs), full) + } + + _, err = r.onMessagesGetSavedDialogs(WithUserID(ctx, alice.ID), &tg.MessagesGetSavedDialogsRequest{Limit: -1}) + if !tgerr.Is(err, "LIMIT_INVALID") { + t.Fatalf("negative limit err = %v, want LIMIT_INVALID", err) + } +} + // TestMessagesGetSavedDialogsHashNotModified 验证 Android calcHash 序列命中 // 返回 savedDialogsNotModified{count}。 func TestMessagesGetSavedDialogsHashNotModified(t *testing.T) { diff --git a/internal/rpc/stories.go b/internal/rpc/stories.go index c4196e04..f81a1c4e 100644 --- a/internal/rpc/stories.go +++ b/internal/rpc/stories.go @@ -77,6 +77,13 @@ func (r *Router) onStoriesGetAllStories(ctx context.Context, req *tg.StoriesGetA now := int(r.clock.Now().Unix()) var cursor domain.StoryListCursor if next { + if digest, ok := storyAllStoriesDigestFromCompleteState(requestState, hidden); ok { + list := domain.StoryList{Hidden: hidden, Count: digest.Count, State: requestState} + if r.deps.Stories == nil || userID == 0 { + return tgStoriesAllStories(userID, list), nil + } + return r.tgStoriesAllStories(ctx, userID, list), nil + } cursor, err = storyAllStoriesCursorFromState(requestState, hidden) if err != nil { return nil, offsetInvalidErr() @@ -135,7 +142,16 @@ func validateStoriesGetAllStoriesRequest(req *tg.StoriesGetAllStoriesRequest) er next := storyAllStoriesNext(req) hidden := storyAllStoriesHidden(req) if next { - if !hasState || state == "" || !storyAllStoriesCursorStateToken(state) { + if !hasState || state == "" { + return offsetInvalidErr() + } + if storyAllStoriesCompleteState(state) { + if !storyAllStoriesCompleteStateForHidden(state, hidden) { + return offsetInvalidErr() + } + return nil + } + if !storyAllStoriesCursorStateToken(state) { return offsetInvalidErr() } if _, err := storyAllStoriesCursorFromState(state, hidden); err != nil { @@ -172,6 +188,22 @@ func storyAllStoriesCompleteState(state string) bool { return strings.HasPrefix(state, "ts1:") } +func storyAllStoriesDigestFromCompleteState(state string, hidden bool) (domain.StoryListDigest, bool) { + if !storyAllStoriesCompleteStateForHidden(state, hidden) { + return domain.StoryListDigest{}, false + } + parts := strings.Split(state, ":") + count, err := strconv.Atoi(parts[2]) + if err != nil || count < 0 { + return domain.StoryListDigest{}, false + } + hash, err := strconv.ParseUint(parts[3], 16, 64) + if err != nil { + return domain.StoryListDigest{}, false + } + return domain.StoryListDigest{Count: count, Hash: hash}, true +} + func storyAllStoriesCompleteStateForHidden(state string, hidden bool) bool { parts := strings.Split(state, ":") if len(parts) != 4 || parts[0] != "ts1" { diff --git a/internal/rpc/stories_rpc_test.go b/internal/rpc/stories_rpc_test.go index ba083e11..0cda92d5 100644 --- a/internal/rpc/stories_rpc_test.go +++ b/internal/rpc/stories_rpc_test.go @@ -298,8 +298,16 @@ func TestStoriesGetAllStoriesPaginatesByPeerState(t *testing.T) { finalAsCursor := &tg.StoriesGetAllStoriesRequest{} finalAsCursor.SetState(next.State) finalAsCursor.SetNext(true) - if _, err := r.onStoriesGetAllStories(ctx, finalAsCursor); err == nil || !tgerr.Is(err, "OFFSET_INVALID") { - t.Fatalf("final state as cursor err = %v, want OFFSET_INVALID", err) + finalAsCursorClass, err := r.onStoriesGetAllStories(reqCtx, finalAsCursor) + if err != nil { + t.Fatalf("final state as cursor: %v", err) + } + finalAsCursorPage, ok := finalAsCursorClass.(*tg.StoriesAllStories) + if !ok { + t.Fatalf("final state as cursor response = %T, want stories.allStories terminal page", finalAsCursorClass) + } + if finalAsCursorPage.HasMore || len(finalAsCursorPage.PeerStories) != 0 || finalAsCursorPage.Count != domain.MaxStoryListLimit+1 || finalAsCursorPage.State != next.State { + t.Fatalf("final state as cursor page = %+v, want empty terminal page preserving request count/state", finalAsCursorPage) } } diff --git a/internal/store/postgres/message_send_integration_test.go b/internal/store/postgres/message_send_integration_test.go index 6ce71c83..773773af 100644 --- a/internal/store/postgres/message_send_integration_test.go +++ b/internal/store/postgres/message_send_integration_test.go @@ -204,6 +204,115 @@ func TestMessageStoreWebViewDataServiceActionRoundTrip(t *testing.T) { assertWebViewData("recipient event", events[0].Message) } +func TestMessageStorePhoneCallServiceFirstMessageFeedsDialogsAndUpdates(t *testing.T) { + pool := testPool(t) + ctx := context.Background() + suffix := randomSuffix(t) + + users := NewUserStore(pool) + caller := createTestUser(t, ctx, users, "+1666"+suffix+"41", "CallFirstSender", "") + callee := createTestUser(t, ctx, users, "+1666"+suffix+"42", "CallFirstRecipient", "") + t.Cleanup(func() { + _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{caller.ID, callee.ID}) + }) + + const callID int64 = 0x1020304050607080 + messages := NewMessageStore(pool) + sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{ + SenderUserID: caller.ID, + RecipientUserID: callee.ID, + RandomID: 4041001, + Date: 1700000410, + Media: &domain.MessageMedia{ + Kind: domain.MessageMediaKindService, + ServiceAction: &domain.MessageServiceAction{ + Kind: domain.MessageServiceActionPhoneCall, + Call: &domain.MessagePhoneCallAction{ + CallID: callID, + Reason: string(domain.PhoneCallDiscardReasonMissed), + }, + }, + }, + }) + if err != nil { + t.Fatalf("SendPrivateText phone call service: %v", err) + } + if sent.SenderMessage.ID != 1 || sent.RecipientMessage.ID != 1 { + t.Fatalf("first phone call boxes = sender %d recipient %d, want both first message", sent.SenderMessage.ID, sent.RecipientMessage.ID) + } + + assertPhoneCallMessage := func(name string, msg domain.Message, ownerID, peerID, fromID int64, out bool) { + t.Helper() + if msg.OwnerUserID != ownerID || msg.Peer != (domain.Peer{Type: domain.PeerTypeUser, ID: peerID}) || + msg.From != (domain.Peer{Type: domain.PeerTypeUser, ID: fromID}) || msg.Out != out { + t.Fatalf("%s identity = owner %d peer %+v from %+v out %v", name, msg.OwnerUserID, msg.Peer, msg.From, msg.Out) + } + if msg.Media == nil || msg.Media.Kind != domain.MessageMediaKindService || + msg.Media.ServiceAction == nil || msg.Media.ServiceAction.Kind != domain.MessageServiceActionPhoneCall || + msg.Media.ServiceAction.Call == nil { + t.Fatalf("%s media = %+v, want phone_call service action", name, msg.Media) + } + call := msg.Media.ServiceAction.Call + if call.CallID != callID || call.Reason != string(domain.PhoneCallDiscardReasonMissed) || call.Duration != 0 { + t.Fatalf("%s phone call action = %+v", name, call) + } + } + assertPhoneCallMessage("sender box", sent.SenderMessage, caller.ID, callee.ID, caller.ID, true) + assertPhoneCallMessage("recipient box", sent.RecipientMessage, callee.ID, caller.ID, caller.ID, false) + + dialogs := NewDialogStore(pool) + calleeDialogs, err := dialogs.ListByUser(ctx, callee.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("callee dialogs: %v", err) + } + if len(calleeDialogs.Dialogs) != 1 || calleeDialogs.Dialogs[0].TopMessage != sent.RecipientMessage.ID || + calleeDialogs.Dialogs[0].UnreadCount != 1 || calleeDialogs.Dialogs[0].ReadInboxMaxID != 0 { + t.Fatalf("callee dialogs = %+v, want unread first call service as top", calleeDialogs.Dialogs) + } + if len(calleeDialogs.Messages) != 1 { + t.Fatalf("callee dialog messages = %+v, want top message payload", calleeDialogs.Messages) + } + assertPhoneCallMessage("callee dialog top", calleeDialogs.Messages[0], callee.ID, caller.ID, caller.ID, false) + if _, ok := findDialogUserByID(calleeDialogs.Users, caller.ID); !ok { + t.Fatalf("callee dialog users = %+v, want caller snapshot", calleeDialogs.Users) + } + + calleeHistory, err := messages.ListByUser(ctx, callee.ID, domain.MessageFilter{ + HasPeer: true, + Peer: domain.Peer{Type: domain.PeerTypeUser, ID: caller.ID}, + Limit: 10, + }) + if err != nil || len(calleeHistory.Messages) != 1 { + t.Fatalf("callee history = %+v err=%v, want one first call service", calleeHistory, err) + } + assertPhoneCallMessage("callee history", calleeHistory.Messages[0], callee.ID, caller.ID, caller.ID, false) + if _, ok := findDialogUserByID(calleeHistory.Users, caller.ID); !ok { + t.Fatalf("callee history users = %+v, want caller snapshot", calleeHistory.Users) + } + + updates := NewUpdateEventStore(pool) + calleeEvents, err := updates.ListAfter(ctx, callee.ID, 0, 10) + if err != nil || len(calleeEvents) != 1 { + t.Fatalf("callee events = %+v err=%v, want one new_message", calleeEvents, err) + } + if calleeEvents[0].Type != domain.UpdateEventNewMessage || calleeEvents[0].Pts != sent.RecipientMessage.Pts { + t.Fatalf("callee event = %+v, want new_message pts %d", calleeEvents[0], sent.RecipientMessage.Pts) + } + assertPhoneCallMessage("callee difference event", calleeEvents[0].Message, callee.ID, caller.ID, caller.ID, false) + if _, ok := findDialogUserByID(calleeEvents[0].Users, caller.ID); !ok { + t.Fatalf("callee event users = %+v, want caller snapshot", calleeEvents[0].Users) + } + + batch, err := updates.BatchByCursor(ctx, []store.EventCursor{{UserID: callee.ID, Pts: calleeEvents[0].Pts}}) + if err != nil || len(batch) != 1 { + t.Fatalf("callee batch events = %+v err=%v, want one dispatch event", batch, err) + } + assertPhoneCallMessage("callee dispatch event", batch[0].Message, callee.ID, caller.ID, caller.ID, false) + if _, ok := findDialogUserByID(batch[0].Users, caller.ID); !ok { + t.Fatalf("callee batch users = %+v, want caller snapshot", batch[0].Users) + } +} + func TestUpdateEventStorePreservesChannelForwardRefsWithoutChannelSnapshot(t *testing.T) { pool := testPool(t) ctx := context.Background()