owpengram-server/internal/app/help/service.go

192 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package help
import (
"context"
"encoding/json"
"hash/crc32"
"sync"
"telesrv/internal/domain"
"telesrv/internal/seed/catalog"
"telesrv/internal/store"
)
const tdesktopClient = "tdesktop"
// 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 优雅报错,可接受。
// - stars_purchase_blocked=false 必须显式下发DrKLO 缺省 starsLocked=true余额不足
// 送礼时若缺此 key 会走 showNoSupportDialog误弹「所在国家无法购买星星」。该 flag 只解
// 客户端充值/购买入口,实际 gift 扣款仍由 Stars 账本余额与 BALANCE_TOO_LOW 约束。
// - stargifts_blocked=false 必须显式下发DrKLO MessagesController:1752 缺省 stargiftsBlocked=
// 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 模板。
// - *_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 todochatlist_update_period 与 chatlist 双档限额
// 对齐 shared folders 最小真实实现。story 配额/商业化 key 不下发
// 功能全族未实现下发会诱导客户端走进未实现路径。stories_stealth_* 是客户端
// 隐身模式本地 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,"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 = 22 // 默认 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
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, opts ...Option) *Service {
s := &Service{appConfigs: appConfigs, countries: countries}
for _, opt := range opts {
if opt != nil {
opt(s)
}
}
return s
}
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 + `}`)
}
token, err := json.Marshal(mapboxToken)
if err != nil {
return []byte(tdesktopDefaultAppConfigBase + `}`)
}
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
}
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) {
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{
{
ISO2: "US",
DefaultName: "United States",
CountryCodes: []domain.CountryCode{
{CountryCode: "1", Prefixes: []string{""}, Patterns: []string{"XXX XXX XXXX"}},
},
},
{
ISO2: "CN",
DefaultName: "China",
CountryCodes: []domain.CountryCode{
{CountryCode: "86", Prefixes: []string{""}, Patterns: []string{"XXX XXXX XXXX"}},
},
},
},
}
}