chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
|
|
@ -2,58 +2,165 @@ package help
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/crc32"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/seed/catalog"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const tdesktopClient = "tdesktop"
|
||||
const tdesktopDefaultAppConfig = `{"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","reactions_default":{"_":"reactionEmoji","emoticon":"👍"},"reactions_uniq_max":11,"reactions_user_max_default":1,"reactions_in_chat_max":3}`
|
||||
|
||||
// upload_markup_video=true 即官方默认(emoji/sticker 头像由客户端本地渲染 mp4 后随
|
||||
// markup 一起上传)。显式下发是为了把曾收到过 false 的客户端持久化配置洗回默认——
|
||||
// 客户端对缺失的 key 会保留本地旧值,仅删除 key 无法恢复。
|
||||
// emojies_send_dice 是客户端把「单 emoji 消息」转成 InputMediaDice 的白名单;
|
||||
// 必须与 rpc 层 diceValueSides 的取值表保持同步(⚽ 用裸码点,客户端会自行匹配变体形态)。
|
||||
// tdesktop_config_map 是 TDesktop 位置选点器(WebView+Mapbox GL)与 business 位置设置的
|
||||
// Mapbox access token:maps/geo=聊天附件选点(地图瓦片/地理编码),bmaps/bgeo=business 位置。
|
||||
// 该 token 必须来自运行期配置;未配置时不下发 tdesktop_config_map,避免公共源码携带第三方 token。
|
||||
// premium 相关 key(docs/premium-module.md):
|
||||
// - premium_purchase_blocked=false 必须显式下发:DrKLO(ProfileActivity:12059)与 TDesktop
|
||||
// (window_peer_menu.cpp:1549)把「Send a Gift」(star gift)入口与 premiumCanBuy()=
|
||||
// !premium_purchase_blocked 耦合在同一 flag——置 true 会同时隐藏送礼入口。star gift
|
||||
// 已实现(Stars 账本),故必须 false 才能送礼;副作用是 premium 购买 UI 重现,但 premium
|
||||
// 已自动授予(0094)、购买流走 stub getPaymentForm 优雅报错,可接受。
|
||||
// - stargifts_blocked=false 必须显式下发:DrKLO MessagesController:1752 缺省 stargiftsBlocked=
|
||||
// true(屏蔽),GiftSheet:967 据此隐藏整个 star gift 送礼网格——缺 key 则送礼选择器恒空。
|
||||
// - reactions_user_max_premium=3 与服务端 domain.MaxMessageReactionsPerUserPremium
|
||||
// 联动:premium 用户可在同一消息放 3 个 reaction,服务端档位必须 ≥ 该宣告值。
|
||||
// - dialog_filters_enabled=true 必须显式下发:TDesktop settings_main.cpp:394 据此(或账号
|
||||
// 已有文件夹)才在 Settings 显示「Folders」入口,缺 key → 新账号看不到文件夹管理、无法
|
||||
// 建文件夹/采纳 getSuggestedDialogFilters 模板。
|
||||
// - *_limit_default/_premium 双档限额对齐官方值;其中 about/dialogs_pinned/
|
||||
// dialogs_folder_pinned 有服务端 enforcement 双档,其余(channels/saved_gifs/
|
||||
// stickers_faved/dialog_filters/caption/fileparts 等)服务端为宽兜底或未 enforce,
|
||||
// 客户端按 self premium flag 自限。bots_create_limit 故意不下发(服务端统一 20,
|
||||
// 见 compatibility-matrix todo);chatlists 和 story 配额/商业化 key 不下发
|
||||
// (功能全族未实现,下发会诱导客户端走进未实现路径)。stories_stealth_* 是客户端
|
||||
// 隐身模式本地 UI/乐观状态用的时间常量,与当前 bounded stealth update stub 保持一致。
|
||||
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,"upload_max_fileparts_default":4000,"upload_max_fileparts_premium":8000`
|
||||
|
||||
const defaultAppConfigHash = 17 // 默认 app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
|
||||
// Service 提供客户端启动配置与国家区号目录。
|
||||
//
|
||||
// app config 与国家区号属「启动后基本不变」的参考目录:运行期无写入(UpsertAppConfig/
|
||||
// UpsertCountries 仅 seed/迁移用),故各加载一次缓存进内存,之后所有 RPC 走内存、不再查库
|
||||
// (登录页/启动配置是高频握手路径)。运维改库需重启生效。timezones/emoji 等其余目录走
|
||||
// internal/seed/catalog(go:embed 一次解析),本就在内存。
|
||||
type Service struct {
|
||||
appConfigs store.AppConfigStore
|
||||
countries store.CountryStore
|
||||
appConfigs store.AppConfigStore
|
||||
countries store.CountryStore
|
||||
mapboxToken string
|
||||
|
||||
appConfigOnce sync.Once
|
||||
appConfigCache domain.AppConfig
|
||||
countriesOnce sync.Once
|
||||
countriesCache domain.CountriesList
|
||||
}
|
||||
|
||||
// Option 配置 help 服务运行期默认目录。
|
||||
type Option func(*Service)
|
||||
|
||||
// WithMapboxToken 设置 TDesktop appConfig 与地图缩略图代理共用的 Mapbox token。
|
||||
func WithMapboxToken(token string) Option {
|
||||
return func(s *Service) {
|
||||
s.mapboxToken = token
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建 help 服务。
|
||||
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore) *Service {
|
||||
return &Service{appConfigs: appConfigs, countries: countries}
|
||||
func NewService(appConfigs store.AppConfigStore, countries store.CountryStore, opts ...Option) *Service {
|
||||
s := &Service{appConfigs: appConfigs, countries: countries}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(s)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。
|
||||
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
|
||||
if s == nil || s.appConfigs == nil {
|
||||
cfg := domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)}
|
||||
return cfg, hash == cfg.Hash, nil
|
||||
func defaultAppConfig(mapboxToken string) domain.AppConfig {
|
||||
jsonBytes := defaultAppConfigJSON(mapboxToken)
|
||||
return domain.AppConfig{Client: tdesktopClient, Hash: defaultAppConfigHashFor(mapboxToken), JSON: jsonBytes}
|
||||
}
|
||||
|
||||
func defaultAppConfigJSON(mapboxToken string) []byte {
|
||||
if mapboxToken == "" {
|
||||
return []byte(tdesktopDefaultAppConfigBase + `}`)
|
||||
}
|
||||
cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient)
|
||||
token, err := json.Marshal(mapboxToken)
|
||||
if err != nil {
|
||||
return domain.AppConfig{}, false, err
|
||||
return []byte(tdesktopDefaultAppConfigBase + `}`)
|
||||
}
|
||||
if !found {
|
||||
cfg = domain.AppConfig{Client: tdesktopClient, Hash: 5, JSON: []byte(tdesktopDefaultAppConfig)}
|
||||
tokenJSON := string(token)
|
||||
return []byte(tdesktopDefaultAppConfigBase + `,"tdesktop_config_map":{"maps":` + tokenJSON + `,"geo":` + tokenJSON + `,"bmaps":` + tokenJSON + `,"bgeo":` + tokenJSON + `}}`)
|
||||
}
|
||||
|
||||
func defaultAppConfigHashFor(mapboxToken string) int {
|
||||
if mapboxToken == "" {
|
||||
return defaultAppConfigHash
|
||||
}
|
||||
return defaultAppConfigHash + 1 + int(crc32.ChecksumIEEE([]byte(mapboxToken))&0x3fffffff)
|
||||
}
|
||||
|
||||
// GetAppConfig 返回 TDesktop app config,hash 命中时返回 notModified。首次调用加载一次后缓存。
|
||||
func (s *Service) GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error) {
|
||||
cfg := s.loadAppConfig(ctx)
|
||||
return cfg, hash != 0 && hash == cfg.Hash, nil
|
||||
}
|
||||
|
||||
// GetCountries 返回国家区号目录,hash 命中时返回 notModified。
|
||||
func (s *Service) loadAppConfig(ctx context.Context) domain.AppConfig {
|
||||
if s == nil {
|
||||
return defaultAppConfig("")
|
||||
}
|
||||
defaultCfg := defaultAppConfig(s.mapboxToken)
|
||||
s.appConfigOnce.Do(func() {
|
||||
if s.appConfigs == nil {
|
||||
s.appConfigCache = defaultCfg
|
||||
return
|
||||
}
|
||||
cfg, found, err := s.appConfigs.GetAppConfig(ctx, tdesktopClient)
|
||||
// DB 行允许运维覆盖,但 hash 落后于代码默认值时视为陈旧(历史 seed 残留),
|
||||
// 以默认值为准——否则新增配置 key 永远被旧行遮蔽(曾导致 emojies_send_dice 未下发)。
|
||||
// 读失败也回退默认值(默认值恒有效),不让一次瞬时 DB 抖动污染整个进程生命周期的缓存。
|
||||
if err != nil || !found || cfg.Hash < defaultAppConfigHash {
|
||||
cfg = defaultCfg
|
||||
}
|
||||
s.appConfigCache = cfg
|
||||
})
|
||||
return s.appConfigCache
|
||||
}
|
||||
|
||||
// GetCountries 返回国家区号目录,hash 命中时返回 notModified。首次调用加载一次后缓存。
|
||||
func (s *Service) GetCountries(ctx context.Context, langCode string, hash int) (domain.CountriesList, bool, error) {
|
||||
if s == nil || s.countries == nil {
|
||||
list := defaultCountries()
|
||||
return list, hash != 0 && hash == list.Hash, nil
|
||||
}
|
||||
list, err := s.countries.ListCountries(ctx, langCode)
|
||||
if err != nil {
|
||||
return domain.CountriesList{}, false, err
|
||||
}
|
||||
if len(list.Countries) == 0 {
|
||||
list = defaultCountries()
|
||||
}
|
||||
list := s.loadCountries(ctx)
|
||||
return list, hash != 0 && hash == list.Hash, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadCountries(ctx context.Context) domain.CountriesList {
|
||||
if s == nil || s.countries == nil {
|
||||
return defaultCountries()
|
||||
}
|
||||
s.countriesOnce.Do(func() {
|
||||
list, err := s.countries.ListCountries(ctx, "")
|
||||
if err != nil || len(list.Countries) == 0 {
|
||||
list = defaultCountries()
|
||||
}
|
||||
s.countriesCache = list
|
||||
})
|
||||
return s.countriesCache
|
||||
}
|
||||
|
||||
// defaultCountries 返回内置国家区号目录:优先用 catalog 固化的官方全量(~235 国),
|
||||
// 未 seed 时回退最小集(US/CN)。countries 表通常为空,故这就是生产实际下发的目录。
|
||||
func defaultCountries() domain.CountriesList {
|
||||
if c := catalog.Countries(); len(c.Countries) > 0 {
|
||||
return c
|
||||
}
|
||||
return domain.CountriesList{
|
||||
Hash: 1,
|
||||
Countries: []domain.Country{
|
||||
|
|
@ -61,14 +168,14 @@ func defaultCountries() domain.CountriesList {
|
|||
ISO2: "US",
|
||||
DefaultName: "United States",
|
||||
CountryCodes: []domain.CountryCode{
|
||||
{CountryCode: "1", Prefixes: []string{"1"}},
|
||||
{CountryCode: "1", Prefixes: []string{""}, Patterns: []string{"XXX XXX XXXX"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
ISO2: "CN",
|
||||
DefaultName: "China",
|
||||
CountryCodes: []domain.CountryCode{
|
||||
{CountryCode: "86", Prefixes: []string{"86"}},
|
||||
{CountryCode: "86", Prefixes: []string{""}, Patterns: []string{"XXX XXXX XXXX"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
108
internal/app/help/service_premium_test.go
Normal file
108
internal/app/help/service_premium_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package help
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAppConfigPremiumKeys 断言 premium 相关 key 完整下发且 hash 已递增:
|
||||
// premium_purchase_blocked 必须显式为 false——客户端把 star gift「Send a Gift」入口与
|
||||
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口;
|
||||
// reactions_user_max_premium 必须与服务端 enforcement 档位一致。
|
||||
func TestAppConfigPremiumKeys(t *testing.T) {
|
||||
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
|
||||
if err != nil || notModified {
|
||||
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
|
||||
}
|
||||
if cfg.Hash != defaultAppConfigHash || cfg.Hash < 10 {
|
||||
t.Fatalf("hash = %d, want defaultAppConfigHash(≥10)", cfg.Hash)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
|
||||
t.Fatalf("app config json invalid: %v", err)
|
||||
}
|
||||
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"])
|
||||
}
|
||||
// DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格,必须显式下发 false。
|
||||
if blocked, ok := decoded["stargifts_blocked"].(bool); !ok || blocked {
|
||||
t.Fatalf("stargifts_blocked = %v, want false (DrKLO GiftSheet 据此隐藏礼物网格)", decoded["stargifts_blocked"])
|
||||
}
|
||||
wantNumbers := map[string]float64{
|
||||
"reactions_user_max_default": 1,
|
||||
"reactions_user_max_premium": 3,
|
||||
"about_length_limit_default": 70,
|
||||
"about_length_limit_premium": 140,
|
||||
"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,
|
||||
"caption_length_limit_default": 1024,
|
||||
"caption_length_limit_premium": 4096,
|
||||
"channels_limit_default": 500,
|
||||
"channels_limit_premium": 1000,
|
||||
"dialog_filters_limit_default": 10,
|
||||
"dialog_filters_limit_premium": 20,
|
||||
"upload_max_fileparts_default": 4000,
|
||||
"upload_max_fileparts_premium": 8000,
|
||||
"stories_stealth_future_period": 1500,
|
||||
"stories_stealth_past_period": 300,
|
||||
"stories_stealth_cooldown_period": 10800,
|
||||
}
|
||||
for key, want := range wantNumbers {
|
||||
got, ok := decoded[key].(float64)
|
||||
if !ok || got != want {
|
||||
t.Errorf("appConfig[%q] = %v, want %v", key, decoded[key], want)
|
||||
}
|
||||
}
|
||||
// 未实现功能族的 key 不得下发(诱导客户端进入未实现路径)。
|
||||
for _, forbidden := range []string{"chatlists_joined_limit_default", "stories_sent_weekly_limit_default", "premium_bot_username", "premium_invoice_slug"} {
|
||||
if _, ok := decoded[forbidden]; ok {
|
||||
t.Errorf("appConfig 不应包含 %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigOmitsMapboxTokenByDefault(t *testing.T) {
|
||||
cfg, notModified, err := (*Service)(nil).GetAppConfig(context.Background(), 0)
|
||||
if err != nil || notModified {
|
||||
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
|
||||
t.Fatalf("app config json invalid: %v", err)
|
||||
}
|
||||
if _, ok := decoded["tdesktop_config_map"]; ok {
|
||||
t.Fatal("tdesktop_config_map present without configured Mapbox token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppConfigUsesConfiguredMapboxTokenAndHash(t *testing.T) {
|
||||
svc := NewService(nil, nil, WithMapboxToken("pk.test-token"))
|
||||
cfg, notModified, err := svc.GetAppConfig(context.Background(), 0)
|
||||
if err != nil || notModified {
|
||||
t.Fatalf("GetAppConfig = notModified %v err %v", notModified, err)
|
||||
}
|
||||
if cfg.Hash == defaultAppConfigHash {
|
||||
t.Fatalf("hash = %d, want token-specific hash", cfg.Hash)
|
||||
}
|
||||
if _, notModified, err := svc.GetAppConfig(context.Background(), cfg.Hash); err != nil || !notModified {
|
||||
t.Fatalf("GetAppConfig(hash) = notModified %v err %v, want notModified", notModified, err)
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal(cfg.JSON, &decoded); err != nil {
|
||||
t.Fatalf("app config json invalid: %v", err)
|
||||
}
|
||||
configMap, ok := decoded["tdesktop_config_map"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("tdesktop_config_map = %T, want object", decoded["tdesktop_config_map"])
|
||||
}
|
||||
for _, key := range []string{"maps", "geo", "bmaps", "bgeo"} {
|
||||
if got := configMap[key]; got != "pk.test-token" {
|
||||
t.Fatalf("tdesktop_config_map[%q] = %v, want token", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue