chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -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 tokenmaps/geo=聊天附件选点(地图瓦片/地理编码bmaps/bgeo=business 位置。
// 该 token 必须来自运行期配置;未配置时不下发 tdesktop_config_map避免公共源码携带第三方 token。
// premium 相关 keydocs/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 todochatlists 和 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 confighash 命中时返回 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 confighash 命中时返回 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"}},
},
},
},

View 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)
}
}
}