fix: sync stars topup and paid reaction settings
This commit is contained in:
parent
4e18d31e16
commit
1f11b0fd1b
12 changed files with 506 additions and 24 deletions
|
|
@ -27,6 +27,9 @@ const tdesktopClient = "tdesktop"
|
||||||
// !premium_purchase_blocked 耦合在同一 flag——置 true 会同时隐藏送礼入口。star gift
|
// !premium_purchase_blocked 耦合在同一 flag——置 true 会同时隐藏送礼入口。star gift
|
||||||
// 已实现(Stars 账本),故必须 false 才能送礼;副作用是 premium 购买 UI 重现,但 premium
|
// 已实现(Stars 账本),故必须 false 才能送礼;副作用是 premium 购买 UI 重现,但 premium
|
||||||
// 已自动授予(0094)、购买流走 stub getPaymentForm 优雅报错,可接受。
|
// 已自动授予(0094)、购买流走 stub getPaymentForm 优雅报错,可接受。
|
||||||
|
// - stars_purchase_blocked=false 必须显式下发:DrKLO 缺省 starsLocked=true,余额不足
|
||||||
|
// 送礼时若缺此 key 会走 showNoSupportDialog,误弹「所在国家无法购买星星」。该 flag 只解
|
||||||
|
// 客户端充值/购买入口,实际 gift 扣款仍由 Stars 账本余额与 BALANCE_TOO_LOW 约束。
|
||||||
// - stargifts_blocked=false 必须显式下发:DrKLO MessagesController:1752 缺省 stargiftsBlocked=
|
// - stargifts_blocked=false 必须显式下发:DrKLO MessagesController:1752 缺省 stargiftsBlocked=
|
||||||
// true(屏蔽),GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。
|
// true(屏蔽),GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。
|
||||||
// - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium
|
// - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium
|
||||||
|
|
@ -47,9 +50,9 @@ const tdesktopClient = "tdesktop"
|
||||||
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
||||||
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
|
// - aicompose_tone_* 与 domain/app/ai 默认值一致:TDesktop/DrKLO 创建/预览 tone 时
|
||||||
// 直接读取这些 key 做本地输入限制和示例数量。
|
// 直接读取这些 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,"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,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"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,"stars_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,"chatlist_update_period":3600,"chatlist_invites_limit_default":3,"chatlist_invites_limit_premium":20,"chatlists_joined_limit_default":2,"chatlists_joined_limit_premium":20,"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 = 21 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
const defaultAppConfigHash = 22 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||||
|
|
||||||
// Service 提供客户端启动配置与国家区号目录。
|
// Service 提供客户端启动配置与国家区号目录。
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAppConfigPremiumKeys 断言 premium 相关 key 完整下发且 hash 已递增:
|
// TestAppConfigPremiumKeys 断言 premium / Stars 相关 key 完整下发且 hash 已递增:
|
||||||
// premium_purchase_blocked 必须显式为 false——客户端把 star gift「Send a Gift」入口与
|
// premium_purchase_blocked 必须显式为 false——客户端把 star gift「Send a Gift」入口与
|
||||||
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
|
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
|
||||||
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
|
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
|
||||||
|
|
@ -25,6 +25,10 @@ func TestAppConfigPremiumKeys(t *testing.T) {
|
||||||
if blocked, ok := decoded["premium_purchase_blocked"].(bool); !ok || blocked {
|
if blocked, ok := decoded["premium_purchase_blocked"].(bool); !ok || blocked {
|
||||||
t.Fatalf("premium_purchase_blocked = %v, want false (star gift 送礼入口耦合此 flag)", decoded["premium_purchase_blocked"])
|
t.Fatalf("premium_purchase_blocked = %v, want false (star gift 送礼入口耦合此 flag)", decoded["premium_purchase_blocked"])
|
||||||
}
|
}
|
||||||
|
// DrKLO 缺省 starsLocked=true;缺 key 时余额不足送礼会误弹「所在国家无法购买星星」。
|
||||||
|
if blocked, ok := decoded["stars_purchase_blocked"].(bool); !ok || blocked {
|
||||||
|
t.Fatalf("stars_purchase_blocked = %v, want false (DrKLO starsPurchaseAvailable 据此解锁充值入口)", decoded["stars_purchase_blocked"])
|
||||||
|
}
|
||||||
// DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格,必须显式下发 false。
|
// DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格,必须显式下发 false。
|
||||||
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
|
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
|
||||||
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
|
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,9 @@ func TestBroadcastChannelAcceptsFullReactionCatalog(t *testing.T) {
|
||||||
if fullChannel.GetPaidReactionsAvailable() {
|
if fullChannel.GetPaidReactionsAvailable() {
|
||||||
t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag")
|
t.Fatalf("full channel paid reactions = true, want false without paid_enabled flag")
|
||||||
}
|
}
|
||||||
|
if !fullChannel.GetPaidMediaAllowed() {
|
||||||
|
t.Fatalf("broadcast full channel paid_media_allowed = false, want true for Android paid reaction editor")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
||||||
|
|
@ -253,6 +256,159 @@ func TestSetChatAvailableReactionsPreservesOptionalFlags(t *testing.T) {
|
||||||
if fullChannel.GetPaidReactionsAvailable() {
|
if fullChannel.GetPaidReactionsAvailable() {
|
||||||
t.Fatalf("paid reactions after explicit false = true, want false")
|
t.Fatalf("paid reactions after explicit false = true, want false")
|
||||||
}
|
}
|
||||||
|
if !fullChannel.GetPaidMediaAllowed() {
|
||||||
|
t.Fatalf("broadcast paid_media_allowed after paid disable = false, want capability preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetChatAvailableReactionsStripsTDesktopPaidSentinel(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
userStore := memory.NewUserStore()
|
||||||
|
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002204", 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: "TDesktop Paid Sentinel",
|
||||||
|
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}
|
||||||
|
|
||||||
|
tdesktopReq := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||||
|
Peer: peer,
|
||||||
|
AvailableReactions: &tg.ChatReactionsSome{Reactions: []tg.ReactionClass{
|
||||||
|
&tg.ReactionPaid{},
|
||||||
|
&tg.ReactionEmoji{Emoticon: "\U0001f44d"},
|
||||||
|
&tg.ReactionEmoji{Emoticon: "\u2764"},
|
||||||
|
&tg.ReactionPaid{},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
tdesktopReq.SetReactionsLimit(11)
|
||||||
|
tdesktopReq.SetPaidEnabled(true)
|
||||||
|
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), tdesktopReq); err != nil {
|
||||||
|
t.Fatalf("set TDesktop paid sentinel reactions: %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 TDesktop sentinel set: %v", err)
|
||||||
|
}
|
||||||
|
fullChannel := full.FullChat.(*tg.ChannelFull)
|
||||||
|
if !fullChannel.GetPaidReactionsAvailable() {
|
||||||
|
t.Fatalf("paid reactions after TDesktop sentinel set = false, want true")
|
||||||
|
}
|
||||||
|
some := mustChannelFullSomeReactions(t, full)
|
||||||
|
if len(some.Reactions) != 2 {
|
||||||
|
t.Fatalf("stored reactions after stripping sentinel = %d, want 2", len(some.Reactions))
|
||||||
|
}
|
||||||
|
for i, reaction := range some.Reactions {
|
||||||
|
if _, ok := reaction.(*tg.ReactionPaid); ok {
|
||||||
|
t.Fatalf("stored reaction[%d] = reactionPaid, want paid state only in paid_reactions_available", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fullCatalog := make([]tg.ReactionClass, 0, domain.MaxChannelReactionTypes+1)
|
||||||
|
fullCatalog = append(fullCatalog, &tg.ReactionPaid{})
|
||||||
|
for i := 0; i < domain.MaxChannelReactionTypes; i++ {
|
||||||
|
fullCatalog = append(fullCatalog, &tg.ReactionEmoji{Emoticon: fmt.Sprintf("r%03d", i)})
|
||||||
|
}
|
||||||
|
maxReq := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||||
|
Peer: peer,
|
||||||
|
AvailableReactions: &tg.ChatReactionsSome{Reactions: fullCatalog},
|
||||||
|
}
|
||||||
|
maxReq.SetPaidEnabled(true)
|
||||||
|
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), maxReq); err != nil {
|
||||||
|
t.Fatalf("set max normal reactions plus paid sentinel: %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 max sentinel set: %v", err)
|
||||||
|
}
|
||||||
|
some = mustChannelFullSomeReactions(t, full)
|
||||||
|
if len(some.Reactions) != domain.MaxChannelReactionTypes {
|
||||||
|
t.Fatalf("stored reactions after max sentinel set = %d, want %d", len(some.Reactions), domain.MaxChannelReactionTypes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelFullPaidReactionCapabilityOnlyBroadcast(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
userStore := memory.NewUserStore()
|
||||||
|
owner, _ := userStore.Create(ctx, domain.User{AccessHash: 101, Phone: "15550002202", FirstName: "Owner"})
|
||||||
|
channelStore := memory.NewChannelStore()
|
||||||
|
r := New(Config{}, Deps{
|
||||||
|
Users: appusers.NewService(userStore),
|
||||||
|
Channels: appchannels.NewService(channelStore),
|
||||||
|
}, zaptest.NewLogger(t), clock.System)
|
||||||
|
|
||||||
|
broadcastCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||||
|
Title: "Paid Reaction Broadcast",
|
||||||
|
Broadcast: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create broadcast channel: %v", err)
|
||||||
|
}
|
||||||
|
broadcast := broadcastCreated.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||||
|
broadcastFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get broadcast full channel: %v", err)
|
||||||
|
}
|
||||||
|
broadcastFullChannel := broadcastFull.FullChat.(*tg.ChannelFull)
|
||||||
|
if !broadcastFullChannel.GetPaidMediaAllowed() {
|
||||||
|
t.Fatalf("broadcast paid_media_allowed = false, want true")
|
||||||
|
}
|
||||||
|
if broadcastFullChannel.GetPaidReactionsAvailable() {
|
||||||
|
t.Fatalf("broadcast paid_reactions_available = true before paid_enabled, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
enablePaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||||
|
Peer: &tg.InputPeerChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash},
|
||||||
|
AvailableReactions: &tg.ChatReactionsAll{},
|
||||||
|
}
|
||||||
|
enablePaid.SetPaidEnabled(true)
|
||||||
|
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enablePaid); err != nil {
|
||||||
|
t.Fatalf("enable broadcast paid reactions: %v", err)
|
||||||
|
}
|
||||||
|
broadcastFull, err = r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: broadcast.ID, AccessHash: broadcast.AccessHash})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get broadcast full channel after paid enable: %v", err)
|
||||||
|
}
|
||||||
|
broadcastFullChannel = broadcastFull.FullChat.(*tg.ChannelFull)
|
||||||
|
if !broadcastFullChannel.GetPaidMediaAllowed() || !broadcastFullChannel.GetPaidReactionsAvailable() {
|
||||||
|
t.Fatalf("broadcast flags after enable: paid_media_allowed=%v paid_reactions_available=%v, want both true",
|
||||||
|
broadcastFullChannel.GetPaidMediaAllowed(), broadcastFullChannel.GetPaidReactionsAvailable())
|
||||||
|
}
|
||||||
|
|
||||||
|
megaCreated, err := r.onChannelsCreateChannel(WithUserID(ctx, owner.ID), &tg.ChannelsCreateChannelRequest{
|
||||||
|
Title: "Paid Reaction Mega",
|
||||||
|
Megagroup: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create megagroup: %v", err)
|
||||||
|
}
|
||||||
|
mega := megaCreated.(*tg.Updates).Chats[0].(*tg.Channel)
|
||||||
|
enableMegaPaid := &tg.MessagesSetChatAvailableReactionsRequest{
|
||||||
|
Peer: &tg.InputPeerChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash},
|
||||||
|
AvailableReactions: &tg.ChatReactionsAll{},
|
||||||
|
}
|
||||||
|
enableMegaPaid.SetPaidEnabled(true)
|
||||||
|
if _, err := r.onMessagesSetChatAvailableReactions(WithUserID(ctx, owner.ID), enableMegaPaid); err != nil {
|
||||||
|
t.Fatalf("set megagroup paid_enabled request: %v", err)
|
||||||
|
}
|
||||||
|
megaFull, err := r.onChannelsGetFullChannel(WithUserID(ctx, owner.ID), &tg.InputChannel{ChannelID: mega.ID, AccessHash: mega.AccessHash})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get megagroup full channel: %v", err)
|
||||||
|
}
|
||||||
|
megaFullChannel := megaFull.FullChat.(*tg.ChannelFull)
|
||||||
|
if megaFullChannel.GetPaidMediaAllowed() || megaFullChannel.GetPaidReactionsAvailable() {
|
||||||
|
t.Fatalf("megagroup flags: paid_media_allowed=%v paid_reactions_available=%v, want both false",
|
||||||
|
megaFullChannel.GetPaidMediaAllowed(), megaFullChannel.GetPaidReactionsAvailable())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) {
|
func TestAndroidChannelReactionEditorProjectsDefaultEmojiAsDocuments(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -107,12 +107,16 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
|
||||||
policy.Type = domain.ChannelReactionPolicyAll
|
policy.Type = domain.ChannelReactionPolicyAll
|
||||||
policy.AllowCustom = reactions.AllowCustom
|
policy.AllowCustom = reactions.AllowCustom
|
||||||
case *tg.ChatReactionsSome:
|
case *tg.ChatReactionsSome:
|
||||||
if len(reactions.Reactions) > domain.MaxChannelReactionTypes {
|
|
||||||
return domain.ChannelReactionPolicy{}, limitInvalidErr()
|
|
||||||
}
|
|
||||||
policy.Type = domain.ChannelReactionPolicySome
|
policy.Type = domain.ChannelReactionPolicySome
|
||||||
seen := make(map[string]struct{}, len(reactions.Reactions))
|
seen := make(map[string]struct{}, len(reactions.Reactions))
|
||||||
for _, reaction := range reactions.Reactions {
|
for _, reaction := range reactions.Reactions {
|
||||||
|
// TDesktop models the paid toggle as a pseudo reaction in the
|
||||||
|
// selector and may submit reactionPaid in chatReactionsSome. The
|
||||||
|
// durable paid state is carried by paid_enabled, not the normal
|
||||||
|
// reaction whitelist.
|
||||||
|
if paidReactionSentinel(reaction) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
parsed, err := domainMessageReactionFromTL(reaction)
|
parsed, err := domainMessageReactionFromTL(reaction)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
|
return domain.ChannelReactionPolicy{}, tgerr400("REACTION_INVALID")
|
||||||
|
|
@ -122,6 +126,9 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
|
||||||
if _, ok := seen[key]; ok {
|
if _, ok := seen[key]; ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if len(seen) >= domain.MaxChannelReactionTypes {
|
||||||
|
return domain.ChannelReactionPolicy{}, limitInvalidErr()
|
||||||
|
}
|
||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
switch parsed.Type {
|
switch parsed.Type {
|
||||||
case domain.MessageReactionEmoji:
|
case domain.MessageReactionEmoji:
|
||||||
|
|
@ -135,3 +142,8 @@ func domainChannelReactionPolicy(req *tg.MessagesSetChatAvailableReactionsReques
|
||||||
}
|
}
|
||||||
return policy, nil
|
return policy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func paidReactionSentinel(reaction tg.ReactionClass) bool {
|
||||||
|
paid, ok := reaction.(*tg.ReactionPaid)
|
||||||
|
return ok && paid != nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -569,15 +569,19 @@ func tgChannelFull(view domain.ChannelView, publicBaseURL ...string) *tg.Channel
|
||||||
if ch.ReactionPolicy.Limit > 0 {
|
if ch.ReactionPolicy.Limit > 0 {
|
||||||
full.SetReactionsLimit(ch.ReactionPolicy.Limit)
|
full.SetReactionsLimit(ch.ReactionPolicy.Limit)
|
||||||
}
|
}
|
||||||
|
if ch.Broadcast && !ch.Megagroup {
|
||||||
|
// Android uses paid_media_allowed as the capability gate for showing the
|
||||||
|
// paid-reaction setting; paid_reactions_available below remains the saved
|
||||||
|
// on/off state.
|
||||||
|
full.SetPaidMediaAllowed(true)
|
||||||
|
full.SetStargiftsAvailable(true)
|
||||||
|
}
|
||||||
// paid_reactions_available reflects the saved chat policy, not mere broadcast
|
// paid_reactions_available reflects the saved chat policy, not mere broadcast
|
||||||
// capability. Android counts this flag as an extra available reaction in the
|
// capability. Android counts this flag as an extra available reaction in the
|
||||||
// settings row, so advertising it without paid_enabled corrupts the UI count.
|
// settings row, so advertising it without paid_enabled corrupts the UI count.
|
||||||
if ch.ReactionPolicy.PaidEnabled {
|
if ch.Broadcast && !ch.Megagroup && ch.ReactionPolicy.PaidEnabled {
|
||||||
full.SetPaidReactionsAvailable(true)
|
full.SetPaidReactionsAvailable(true)
|
||||||
}
|
}
|
||||||
if ch.Broadcast && !ch.Megagroup {
|
|
||||||
full.SetStargiftsAvailable(true)
|
|
||||||
}
|
|
||||||
return full
|
return full
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,10 @@ func balanceTooLowErr() error { return tgerr.New(400, "BALANCE_TOO_LOW") }
|
||||||
|
|
||||||
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }
|
func starsAmountInvalidErr() error { return tgerr.New(400, "STARS_AMOUNT_INVALID") }
|
||||||
|
|
||||||
|
func starsFormAmountMismatchErr() error { return tgerr.New(406, "STARS_FORM_AMOUNT_MISMATCH") }
|
||||||
|
|
||||||
|
func formIDEmptyErr() error { return tgerr.New(400, "FORM_ID_EMPTY") }
|
||||||
|
|
||||||
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
|
func suggestedPostPeerInvalidErr() error { return tgerr.New(400, "SUGGESTED_POST_PEER_INVALID") }
|
||||||
|
|
||||||
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }
|
func storyIDInvalidErr() error { return tgerr.New(400, "STORY_ID_INVALID") }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import (
|
||||||
// gift/auction/revenue 第一阶段兼容桩。
|
// gift/auction/revenue 第一阶段兼容桩。
|
||||||
func (r *Router) registerPayments(d *tg.ServerDispatcher) {
|
func (r *Router) registerPayments(d *tg.ServerDispatcher) {
|
||||||
d.OnPaymentsGetStarsTopupOptions(func(ctx context.Context) ([]tg.StarsTopupOption, error) {
|
d.OnPaymentsGetStarsTopupOptions(func(ctx context.Context) ([]tg.StarsTopupOption, error) {
|
||||||
return []tg.StarsTopupOption{}, nil
|
return devStarsTopupOptions(), nil
|
||||||
})
|
})
|
||||||
// premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框
|
// premium 订阅赠送 telesrv 不实现(无支付流),返回空选项。关键作用:TDesktop 送礼框
|
||||||
// ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置
|
// ShowStarGiftBox 的 ready() 门控要求 getPremiumGiftCodeOptions 成功返回(on_next)才置
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,14 @@ import (
|
||||||
|
|
||||||
func starGiftInvalidErr() error { return tgerr.New(400, "STARGIFT_INVALID") }
|
func starGiftInvalidErr() error { return tgerr.New(400, "STARGIFT_INVALID") }
|
||||||
|
|
||||||
|
func devStarsTopupOptions() []tg.StarsTopupOption {
|
||||||
|
return []tg.StarsTopupOption{
|
||||||
|
{Stars: 1000, Currency: "USD", Amount: 99},
|
||||||
|
{Stars: 2500, Currency: "USD", Amount: 199},
|
||||||
|
{Stars: 5000, Currency: "USD", Amount: 399},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。
|
// onPaymentsGetStarGifts 返回可购买礼物目录(hash 命中返回 NotModified)。
|
||||||
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
|
func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.PaymentsStarGiftsClass, error) {
|
||||||
if r.deps.Gifts == nil {
|
if r.deps.Gifts == nil {
|
||||||
|
|
@ -41,13 +49,35 @@ func (r *Router) onPaymentsGetStarGifts(ctx context.Context, hash int) (tg.Payme
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// onPaymentsGetPaymentForm 仅处理 inputInvoiceStarGift:返回 paymentFormStarGift。
|
// onPaymentsGetPaymentForm 处理 Stars 专用 invoice:
|
||||||
|
// - inputInvoiceStarGift 返回 paymentFormStarGift。
|
||||||
|
// - inputInvoiceStars(inputStorePaymentStarsTopup) 返回 paymentFormStars。
|
||||||
|
//
|
||||||
// 崩溃约束:star gift invoice 必须返 paymentFormStarGift#b425cfe1(TDesktop 单分支 match),
|
// 崩溃约束:star gift invoice 必须返 paymentFormStarGift#b425cfe1(TDesktop 单分支 match),
|
||||||
// Invoice.Prices 必须非空(DrKLO/TDesktop 读 prices.front())。
|
// Stars 表单 Invoice.Prices 必须非空且 Currency=XTR(DrKLO/TDesktop 读 prices.front())。
|
||||||
func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) {
|
func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsGetPaymentFormRequest) (tg.PaymentsPaymentFormClass, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, inputRequestInvalidErr()
|
return nil, inputRequestInvalidErr()
|
||||||
}
|
}
|
||||||
|
userID, _, err := r.currentUserID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, internalErr()
|
||||||
|
}
|
||||||
|
|
||||||
|
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
|
||||||
|
purpose, ok := starsTopupPurpose(inv)
|
||||||
|
if !ok {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
if r.deps.Stars == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
if _, _, err := r.validateStarsTopupPurpose(ctx, userID, purpose); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.starsTopupPaymentForm(userID, purpose), nil
|
||||||
|
}
|
||||||
|
|
||||||
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, notImplementedErr()
|
return nil, notImplementedErr()
|
||||||
|
|
@ -55,10 +85,6 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
||||||
if r.deps.Gifts == nil {
|
if r.deps.Gifts == nil {
|
||||||
return nil, notImplementedErr()
|
return nil, notImplementedErr()
|
||||||
}
|
}
|
||||||
userID, _, err := r.currentUserID(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, internalErr()
|
|
||||||
}
|
|
||||||
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil {
|
if _, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -75,21 +101,29 @@ func (r *Router) onPaymentsGetPaymentForm(ctx context.Context, req *tg.PaymentsG
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// onPaymentsSendStarsForm 仅处理 inputInvoiceStarGift:Debit→投递/记账,
|
// onPaymentsSendStarsForm 处理 star gift 与 Stars topup:
|
||||||
// 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。失败补偿退款。
|
// - star gift: Debit→投递/记账,失败补偿退款。
|
||||||
|
// - topup: 校验测试包白名单→Credit 本地账本。
|
||||||
|
//
|
||||||
|
// 返回 paymentResult{updates}(含 updateStarsBalance;用户礼物还含私聊服务消息)。
|
||||||
// 崩溃约束:必须返回合法 paymentResult{非空 Updates}(DrKLO 强转)。
|
// 崩溃约束:必须返回合法 paymentResult{非空 Updates}(DrKLO 强转)。
|
||||||
func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSendStarsFormRequest) (tg.PaymentsPaymentResultClass, error) {
|
func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSendStarsFormRequest) (tg.PaymentsPaymentResultClass, error) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return nil, inputRequestInvalidErr()
|
return nil, inputRequestInvalidErr()
|
||||||
}
|
}
|
||||||
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
|
||||||
if !ok {
|
|
||||||
return nil, notImplementedErr()
|
|
||||||
}
|
|
||||||
userID, _, err := r.currentUserID(ctx)
|
userID, _, err := r.currentUserID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, internalErr()
|
return nil, internalErr()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if inv, ok := req.Invoice.(*tg.InputInvoiceStars); ok {
|
||||||
|
return r.sendStarsTopupForm(ctx, userID, req.FormID, inv)
|
||||||
|
}
|
||||||
|
|
||||||
|
inv, ok := req.Invoice.(*tg.InputInvoiceStarGift)
|
||||||
|
if !ok {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
|
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, inv.Peer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -149,6 +183,83 @@ func (r *Router) onPaymentsSendStarsForm(ctx context.Context, req *tg.PaymentsSe
|
||||||
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
return &tg.PaymentsPaymentResult{Updates: updates}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func starsTopupPurpose(inv *tg.InputInvoiceStars) (*tg.InputStorePaymentStarsTopup, bool) {
|
||||||
|
if inv == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
purpose, ok := inv.Purpose.(*tg.InputStorePaymentStarsTopup)
|
||||||
|
return purpose, ok && purpose != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) validateStarsTopupPurpose(ctx context.Context, userID int64, purpose *tg.InputStorePaymentStarsTopup) (tg.StarsTopupOption, domain.Peer, error) {
|
||||||
|
if purpose == nil || purpose.Stars <= 0 || purpose.Amount <= 0 || purpose.Currency == "" {
|
||||||
|
return tg.StarsTopupOption{}, domain.Peer{}, starsAmountInvalidErr()
|
||||||
|
}
|
||||||
|
var matched tg.StarsTopupOption
|
||||||
|
found := false
|
||||||
|
for _, opt := range devStarsTopupOptions() {
|
||||||
|
if opt.Stars == purpose.Stars && opt.Currency == purpose.Currency && opt.Amount == purpose.Amount {
|
||||||
|
matched = opt
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return tg.StarsTopupOption{}, domain.Peer{}, starsFormAmountMismatchErr()
|
||||||
|
}
|
||||||
|
peer := domain.Peer{}
|
||||||
|
if purpose.SpendPurposePeer != nil {
|
||||||
|
var err error
|
||||||
|
peer, err = r.checkedDomainPeerFromInputPeer(ctx, userID, purpose.SpendPurposePeer)
|
||||||
|
if err != nil {
|
||||||
|
return tg.StarsTopupOption{}, domain.Peer{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched, peer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) starsTopupPaymentForm(userID int64, purpose *tg.InputStorePaymentStarsTopup) *tg.PaymentsPaymentFormStars {
|
||||||
|
return &tg.PaymentsPaymentFormStars{
|
||||||
|
FormID: starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount),
|
||||||
|
BotID: domain.OfficialSystemUserID,
|
||||||
|
Title: "Telegram Stars",
|
||||||
|
Description: "telesrv dev Stars top-up",
|
||||||
|
Invoice: tg.Invoice{
|
||||||
|
Currency: "XTR",
|
||||||
|
Prices: []tg.LabeledPrice{{Label: "Telegram Stars", Amount: purpose.Stars}},
|
||||||
|
},
|
||||||
|
Users: tgUsersForViewer(userID, []domain.User{domain.OfficialSystemUser()}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) sendStarsTopupForm(ctx context.Context, userID, formID int64, inv *tg.InputInvoiceStars) (tg.PaymentsPaymentResultClass, error) {
|
||||||
|
purpose, ok := starsTopupPurpose(inv)
|
||||||
|
if !ok {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
if formID == 0 {
|
||||||
|
return nil, formIDEmptyErr()
|
||||||
|
}
|
||||||
|
if r.deps.Stars == nil {
|
||||||
|
return nil, notImplementedErr()
|
||||||
|
}
|
||||||
|
_, peer, err := r.validateStarsTopupPurpose(ctx, userID, purpose)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if formID != starsTopupFormID(userID, purpose.Stars, purpose.Currency, purpose.Amount) {
|
||||||
|
return nil, starsFormAmountMismatchErr()
|
||||||
|
}
|
||||||
|
if _, err := r.deps.Stars.GetBalance(ctx, userID); err != nil {
|
||||||
|
return nil, starsErr(err)
|
||||||
|
}
|
||||||
|
balance, err := r.deps.Stars.Credit(ctx, userID, purpose.Stars, domain.StarsReasonTopup, peer, "Stars top-up", "telesrv dev purchase")
|
||||||
|
if err != nil {
|
||||||
|
return nil, starsErr(err)
|
||||||
|
}
|
||||||
|
return &tg.PaymentsPaymentResult{Updates: starsBalanceUpdates(balance.Balance, r.clock.Now().Unix())}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
|
func (r *Router) sendStarGiftToUser(ctx context.Context, senderID, recipientID int64, gift domain.StarGift, hideName bool, message string) (*tg.Updates, error) {
|
||||||
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
|
// 2. 投递礼物服务消息到收礼人私聊(双盒 + 推送)。
|
||||||
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message)
|
send, err := r.deliverStarGift(ctx, senderID, recipientID, gift, hideName, message)
|
||||||
|
|
@ -668,6 +779,29 @@ func starGiftFormID(userID, giftID int64) int64 {
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func starsTopupFormID(userID, stars int64, currency string, amount int64) int64 {
|
||||||
|
id := userID*0x9e3779b1 ^ (stars << 7) ^ (amount << 13) ^ 0x5354415253
|
||||||
|
for _, ch := range currency {
|
||||||
|
id = id*131 + int64(ch)
|
||||||
|
}
|
||||||
|
if id < 0 {
|
||||||
|
id = ^id
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
id = 0x5354
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func starsBalanceUpdates(balance int64, unixDate int64) *tg.Updates {
|
||||||
|
return &tg.Updates{
|
||||||
|
Updates: []tg.UpdateClass{&tg.UpdateStarsBalance{Balance: &tg.StarsAmount{Amount: balance}}},
|
||||||
|
Users: []tg.UserClass{},
|
||||||
|
Chats: []tg.ChatClass{},
|
||||||
|
Date: int(unixDate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func giftPriceLabel(g domain.StarGift) string {
|
func giftPriceLabel(g domain.StarGift) string {
|
||||||
if g.Title != "" {
|
if g.Title != "" {
|
||||||
return g.Title
|
return g.Title
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/gotd/td/clock"
|
"github.com/gotd/td/clock"
|
||||||
"github.com/gotd/td/tg"
|
"github.com/gotd/td/tg"
|
||||||
|
"github.com/gotd/td/tgerr"
|
||||||
"go.uber.org/zap/zaptest"
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
appchannels "telesrv/internal/app/channels"
|
appchannels "telesrv/internal/app/channels"
|
||||||
|
|
@ -383,3 +384,95 @@ func TestStarGiftInsufficientBalance(t *testing.T) {
|
||||||
t.Fatalf("sender balance = %d, want 1000 unchanged", bal.Balance)
|
t.Fatalf("sender balance = %d, want 1000 unchanged", bal.Balance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStarsTopupInvoiceFallbackCreditsBalance(t *testing.T) {
|
||||||
|
r, sender, _, _ := starGiftTestRouter(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
senderCtx := WithUserID(ctx, sender.ID)
|
||||||
|
opt := devStarsTopupOptions()[1]
|
||||||
|
inv := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
|
||||||
|
Stars: opt.Stars,
|
||||||
|
Currency: opt.Currency,
|
||||||
|
Amount: opt.Amount,
|
||||||
|
}}
|
||||||
|
|
||||||
|
formRes, err := r.onPaymentsGetPaymentForm(senderCtx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPaymentForm topup: %v", err)
|
||||||
|
}
|
||||||
|
form, ok := formRes.(*tg.PaymentsPaymentFormStars)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("form = %T, want *tg.PaymentsPaymentFormStars", formRes)
|
||||||
|
}
|
||||||
|
if form.FormID != starsTopupFormID(sender.ID, opt.Stars, opt.Currency, opt.Amount) {
|
||||||
|
t.Fatalf("form id = %d, want deterministic topup id", form.FormID)
|
||||||
|
}
|
||||||
|
if form.BotID != domain.OfficialSystemUserID || len(form.Users) != 1 {
|
||||||
|
t.Fatalf("form bot/users = %d/%d, want official system user", form.BotID, len(form.Users))
|
||||||
|
}
|
||||||
|
if form.Invoice.Currency != "XTR" || len(form.Invoice.Prices) != 1 || form.Invoice.Prices[0].Amount != opt.Stars {
|
||||||
|
t.Fatalf("form invoice = %+v, want XTR + 1 price %d", form.Invoice, opt.Stars)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID + 1, Invoice: inv}); !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||||
|
t.Fatalf("sendStarsForm bad form err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||||
|
}
|
||||||
|
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 1000 {
|
||||||
|
t.Fatalf("balance after bad form = %d, want 1000 unchanged", bal.Balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
payRes, err := r.onPaymentsSendStarsForm(senderCtx, &tg.PaymentsSendStarsFormRequest{FormID: form.FormID, Invoice: inv})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sendStarsForm topup: %v", err)
|
||||||
|
}
|
||||||
|
pay, ok := payRes.(*tg.PaymentsPaymentResult)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("pay result = %T, want *tg.PaymentsPaymentResult", payRes)
|
||||||
|
}
|
||||||
|
updates, ok := pay.Updates.(*tg.Updates)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("pay updates = %T, want *tg.Updates", pay.Updates)
|
||||||
|
}
|
||||||
|
foundBalance := false
|
||||||
|
for _, up := range updates.Updates {
|
||||||
|
if balance, ok := up.(*tg.UpdateStarsBalance); ok {
|
||||||
|
foundBalance = true
|
||||||
|
if amt, ok := balance.Balance.(*tg.StarsAmount); !ok || amt.Amount != 3500 {
|
||||||
|
t.Fatalf("updateStarsBalance = %#v, want 3500", balance.Balance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundBalance {
|
||||||
|
t.Fatalf("payment updates missing updateStarsBalance: %#v", updates.Updates)
|
||||||
|
}
|
||||||
|
if bal, _ := r.deps.Stars.GetBalance(ctx, sender.ID); bal.Balance != 3500 {
|
||||||
|
t.Fatalf("balance after topup = %d, want 3500", bal.Balance)
|
||||||
|
}
|
||||||
|
page, err := r.deps.Stars.ListTransactions(ctx, sender.ID, "", 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list transactions: %v", err)
|
||||||
|
}
|
||||||
|
hasTopup := false
|
||||||
|
for _, tx := range page.Transactions {
|
||||||
|
if tx.Reason == domain.StarsReasonTopup && tx.Amount == opt.Stars {
|
||||||
|
hasTopup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasTopup {
|
||||||
|
t.Fatalf("transactions missing topup %d: %+v", opt.Stars, page.Transactions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStarsTopupRejectsUnlistedAmount(t *testing.T) {
|
||||||
|
r, sender, _, _ := starGiftTestRouter(t)
|
||||||
|
ctx := WithUserID(context.Background(), sender.ID)
|
||||||
|
inv := &tg.InputInvoiceStars{Purpose: &tg.InputStorePaymentStarsTopup{
|
||||||
|
Stars: 2501,
|
||||||
|
Currency: "USD",
|
||||||
|
Amount: 199,
|
||||||
|
}}
|
||||||
|
_, err := r.onPaymentsGetPaymentForm(ctx, &tg.PaymentsGetPaymentFormRequest{Invoice: inv})
|
||||||
|
if !tgerr.Is(err, "STARS_FORM_AMOUNT_MISMATCH") {
|
||||||
|
t.Fatalf("getPaymentForm unlisted err = %v, want STARS_FORM_AMOUNT_MISMATCH", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,71 @@ WHERE channel_id = $1 AND message_id = $2 AND reaction_type = $3`, env.channelID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChannelStoreSetAvailableReactionsRefreshesRowCache(t *testing.T) {
|
||||||
|
pool := testPool(t) // 未设 DSN 会 t.Skip
|
||||||
|
ctx := context.Background()
|
||||||
|
suffix := randomSuffix(t)
|
||||||
|
users := NewUserStore(pool)
|
||||||
|
owner, err := users.Create(ctx, domain.User{AccessHash: 94, Phone: "+1892" + suffix + "04", FirstName: "PolicyCacheOwner"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create owner: %v", err)
|
||||||
|
}
|
||||||
|
var channelID int64
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if channelID != 0 {
|
||||||
|
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||||
|
}
|
||||||
|
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
rowCache := NewChannelRowCache(100)
|
||||||
|
channels := NewChannelStore(pool, WithChannelRowCache(rowCache))
|
||||||
|
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||||
|
CreatorUserID: owner.ID,
|
||||||
|
Title: "Reaction Policy Cache " + suffix,
|
||||||
|
Broadcast: true,
|
||||||
|
Date: 1700001010,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create channel: %v", err)
|
||||||
|
}
|
||||||
|
channelID = created.Channel.ID
|
||||||
|
|
||||||
|
if _, err := channels.GetChannel(ctx, owner.ID, channelID); err != nil {
|
||||||
|
t.Fatalf("warm GetChannel: %v", err)
|
||||||
|
}
|
||||||
|
cached, ok := rowCache.get(channelID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("GetChannel should warm row cache")
|
||||||
|
}
|
||||||
|
if cached.ReactionPolicy.PaidEnabled {
|
||||||
|
t.Fatalf("cached paid enabled before update = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := channels.SetAvailableReactions(ctx, owner.ID, channelID, domain.ChannelReactionPolicy{
|
||||||
|
Type: domain.ChannelReactionPolicyAll,
|
||||||
|
AllowCustom: true,
|
||||||
|
Limit: 9,
|
||||||
|
PaidEnabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("set paid reaction policy: %v", err)
|
||||||
|
}
|
||||||
|
view, err := channels.GetChannel(ctx, owner.ID, channelID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get channel after set policy: %v", err)
|
||||||
|
}
|
||||||
|
if !view.Channel.ReactionPolicy.PaidEnabled || view.Channel.ReactionPolicy.Limit != 9 || !view.Channel.ReactionPolicy.AllowCustom {
|
||||||
|
t.Fatalf("view policy after set = %+v, want paid+limit+allow_custom from write-through cache", view.Channel.ReactionPolicy)
|
||||||
|
}
|
||||||
|
cached, ok = rowCache.get(channelID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("row cache missing after set policy")
|
||||||
|
}
|
||||||
|
if !cached.ReactionPolicy.PaidEnabled || cached.ReactionPolicy.Limit != 9 || !cached.ReactionPolicy.AllowCustom {
|
||||||
|
t.Fatalf("cached policy after set = %+v, want paid+limit+allow_custom", cached.ReactionPolicy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChannelStoreUniqueReactionsLimitOnlyBlocksNewKinds(t *testing.T) {
|
func TestChannelStoreUniqueReactionsLimitOnlyBlocksNewKinds(t *testing.T) {
|
||||||
env := newReactionPolicyTestEnv(t, false)
|
env := newReactionPolicyTestEnv(t, false)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
|
||||||
|
|
@ -36,5 +36,8 @@ func (s *ChannelStore) SetAvailableReactions(ctx context.Context, userID, channe
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return domain.Channel{}, err
|
return domain.Channel{}, err
|
||||||
}
|
}
|
||||||
|
if s.rowCache != nil {
|
||||||
|
s.rowCache.put(channel)
|
||||||
|
}
|
||||||
return channel, nil
|
return channel, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,11 @@ if ($schema) {
|
||||||
$parts = $schema -split "\|", 2
|
$parts = $schema -split "\|", 2
|
||||||
Write-Host "schema_version=$($parts[0])"
|
Write-Host "schema_version=$($parts[0])"
|
||||||
Write-Host "schema_dirty=$($parts[1])"
|
Write-Host "schema_dirty=$($parts[1])"
|
||||||
if ($parts.Count -gt 1 -and $parts[1] -eq "f") {
|
$schemaDirty = ""
|
||||||
|
if ($parts.Count -gt 1) {
|
||||||
|
$schemaDirty = $parts[1].Trim().ToLowerInvariant()
|
||||||
|
}
|
||||||
|
if ($schemaDirty -in @("f", "false", "0")) {
|
||||||
Write-Ok "schema is clean"
|
Write-Ok "schema is clean"
|
||||||
} else {
|
} else {
|
||||||
Add-Failure "schema_migrations is dirty"
|
Add-Failure "schema_migrations is dirty"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue