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

@ -0,0 +1,281 @@
package tdesktop
import (
"sync"
"telesrv/internal/seed/appearance"
"github.com/gotd/td/tg"
)
const appearanceSeedDCID = 2
const maxPeerColorBoostLevel = 100
var peerColorOptionsCache = struct {
regularOnce sync.Once
profileOnce sync.Once
regular []tg.HelpPeerColorOption
profile []tg.HelpPeerColorOption
}{}
func seedWallPapers() []tg.WallPaperClass {
catalog := appearance.Default()
out := make([]tg.WallPaperClass, 0, len(catalog.Wallpapers))
for _, wallpaper := range catalog.Wallpapers {
out = append(out, seedWallPaper(wallpaper))
}
return out
}
// LookupWallPaper resolves a cloud wallpaper from the default seed catalog.
func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) {
if in, ok := input.(*tg.InputWallPaperNoFile); ok {
return &tg.WallPaperNoFile{ID: in.ID}, true
}
catalog := appearance.Default()
for _, wallpaper := range catalog.Wallpapers {
if inputWallPaperMatches(input, wallpaper) {
return seedWallPaper(wallpaper), true
}
}
return nil, false
}
// LookupWallPapers resolves multiple wallpapers from the default seed catalog.
func LookupWallPapers(inputs []tg.InputWallPaperClass) ([]tg.WallPaperClass, bool) {
out := make([]tg.WallPaperClass, 0, len(inputs))
for _, input := range inputs {
wallpaper, ok := LookupWallPaper(input)
if !ok {
return nil, false
}
out = append(out, wallpaper)
}
return out, true
}
func inputWallPaperMatches(input tg.InputWallPaperClass, wallpaper appearance.Wallpaper) bool {
switch in := input.(type) {
case *tg.InputWallPaper:
return in.ID == wallpaper.ID && in.AccessHash == wallpaper.AccessHash
case *tg.InputWallPaperSlug:
return in.Slug != "" && in.Slug == wallpaper.Slug
default:
return false
}
}
func seedWallPaper(in appearance.Wallpaper) tg.WallPaperClass {
if in.Type == 1 || in.Document.ID == 0 {
out := &tg.WallPaperNoFile{ID: in.ID}
out.SetDefault(in.Default)
out.SetDark(in.Dark)
out.SetSettings(seedWallPaperSettings(in.Settings))
return out
}
out := &tg.WallPaper{
ID: in.ID,
AccessHash: in.AccessHash,
Slug: in.Slug,
Document: seedDocument(in.Document),
}
out.SetDefault(in.Default)
out.SetPattern(in.Pattern)
out.SetDark(in.Dark)
out.SetSettings(seedWallPaperSettings(in.Settings))
return out
}
func seedWallPaperSettings(in appearance.WallpaperSettings) tg.WallPaperSettings {
var out tg.WallPaperSettings
out.SetBlur(in.Blur)
out.SetMotion(in.Motion)
if in.BackgroundColor != 0 {
out.SetBackgroundColor(in.BackgroundColor)
}
if in.SecondBackgroundColor != 0 {
out.SetSecondBackgroundColor(in.SecondBackgroundColor)
}
if in.ThirdBackgroundColor != 0 {
out.SetThirdBackgroundColor(in.ThirdBackgroundColor)
}
if in.FourthBackgroundColor != 0 {
out.SetFourthBackgroundColor(in.FourthBackgroundColor)
}
if in.Intensity != 0 {
out.SetIntensity(in.Intensity)
}
if in.Rotation != 0 {
out.SetRotation(in.Rotation)
}
return out
}
func seedDocument(in appearance.Document) tg.DocumentClass {
if in.ID == 0 {
return &tg.DocumentEmpty{}
}
return &tg.Document{
ID: in.ID,
AccessHash: in.AccessHash,
Date: in.Date,
MimeType: in.MimeType,
Size: in.Size,
Thumbs: seedPhotoSizes(in.Thumbs),
DCID: appearanceSeedDCID,
Attributes: seedDocumentAttributes(in.Attributes),
FileReference: nil,
}
}
func seedPhotoSizes(in []appearance.PhotoSize) []tg.PhotoSizeClass {
out := make([]tg.PhotoSizeClass, 0, len(in))
for _, size := range in {
switch size.Kind {
case "size":
if size.Type != "" && size.W > 0 && size.H > 0 && size.Size > 0 {
out = append(out, &tg.PhotoSize{Type: size.Type, W: size.W, H: size.H, Size: size.Size})
}
}
}
if len(out) == 0 {
return nil
}
return out
}
func seedDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttributeClass {
out := make([]tg.DocumentAttributeClass, 0, len(in))
for _, attr := range in {
switch attr.Kind {
case "image_size":
out = append(out, &tg.DocumentAttributeImageSize{W: attr.W, H: attr.H})
case "filename":
if attr.FileName != "" {
out = append(out, &tg.DocumentAttributeFilename{FileName: attr.FileName})
}
}
}
return out
}
func seedPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
if profile {
peerColorOptionsCache.profileOnce.Do(func() {
peerColorOptionsCache.profile = buildSeedPeerColorOptions(true)
})
return clonePeerColorOptions(peerColorOptionsCache.profile)
}
peerColorOptionsCache.regularOnce.Do(func() {
peerColorOptionsCache.regular = buildSeedPeerColorOptions(false)
})
return clonePeerColorOptions(peerColorOptionsCache.regular)
}
func buildSeedPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
catalog := appearance.Default()
source := catalog.PeerColors
if profile {
source = catalog.PeerProfileColors
}
out := make([]tg.HelpPeerColorOption, 0, len(source))
for _, color := range source {
option := tg.HelpPeerColorOption{ColorID: color.ID}
option.SetHidden(color.Hidden)
channelMin := boundedPeerColorMinLevel(color.ChannelMinLevel)
if channelMin > 0 {
option.SetChannelMinLevel(channelMin)
}
groupMin := boundedPeerColorMinLevel(color.GroupMinLevel)
if groupMin == 0 && profile {
groupMin = channelMin
}
if groupMin > 0 {
option.SetGroupMinLevel(groupMin)
}
if colors := seedPeerColorSet(color.Colors); colors != nil {
option.SetColors(colors)
}
if colors := seedPeerColorSet(color.DarkColors); colors != nil {
option.SetDarkColors(colors)
}
out = append(out, option)
}
return out
}
func clonePeerColorOptions(in []tg.HelpPeerColorOption) []tg.HelpPeerColorOption {
if len(in) == 0 {
return nil
}
out := make([]tg.HelpPeerColorOption, len(in))
for i := range in {
out[i] = in[i]
if in[i].Colors != nil {
out[i].Colors = clonePeerColorSet(in[i].Colors)
}
if in[i].DarkColors != nil {
out[i].DarkColors = clonePeerColorSet(in[i].DarkColors)
}
}
return out
}
func clonePeerColorSet(in tg.HelpPeerColorSetClass) tg.HelpPeerColorSetClass {
switch set := in.(type) {
case *tg.HelpPeerColorSet:
return &tg.HelpPeerColorSet{Colors: append([]int(nil), set.Colors...)}
case *tg.HelpPeerColorProfileSet:
return &tg.HelpPeerColorProfileSet{
PaletteColors: append([]int(nil), set.PaletteColors...),
BgColors: append([]int(nil), set.BgColors...),
StoryColors: append([]int(nil), set.StoryColors...),
}
default:
return in
}
}
func boundedPeerColorMinLevel(level int) int {
if level <= 0 {
return 0
}
if level > maxPeerColorBoostLevel {
return maxPeerColorBoostLevel
}
return level
}
func seedPeerColorID(id int, profile bool) (bool, bool) {
catalog := appearance.Default()
source := catalog.PeerColors
if profile {
source = catalog.PeerProfileColors
}
if len(source) == 0 {
return false, false
}
for _, color := range source {
if color.ID == id {
return true, true
}
}
return false, true
}
func seedPeerColorSet(in *appearance.ColorSet) tg.HelpPeerColorSetClass {
if in == nil {
return nil
}
if len(in.PaletteColors) > 0 || len(in.BgColors) > 0 || len(in.StoryColors) > 0 {
return &tg.HelpPeerColorProfileSet{
PaletteColors: append([]int(nil), in.PaletteColors...),
BgColors: append([]int(nil), in.BgColors...),
StoryColors: append([]int(nil), in.StoryColors...),
}
}
if len(in.Colors) > 0 {
return &tg.HelpPeerColorSet{Colors: append([]int(nil), in.Colors...)}
}
return nil
}

View file

@ -16,9 +16,12 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
Expires: int(now.Add(time.Hour).Unix()),
TestMode: false,
ThisDC: dc,
DCOptions: []tg.DCOption{
{ID: dc, IPAddress: ip, Port: port, Static: true},
},
// 不下发 DCOptions客户端TDesktop patch / drklo fork已写死 static DC
// 地址空列表会让客户端保留它——drklo ConnectionsManager.cpp 的 processConfig
// 在 dc_options 为空时整段跳过 replaceAddresses/saveConfig既不覆盖也不持久化。
// 服务端因此无需配置对外可达 IP换网络/部署只改客户端写死地址即可。ip/port
// 参数暂留,供未来需要显式 advertise 时改回。
DCOptions: nil,
ChatSizeMax: 200,
MegagroupSizeMax: 200000,
ForwardedCountMax: 100,
@ -31,15 +34,20 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
PushChatPeriodMs: 60000,
PushChatLimit: 2,
EditTimeLimit: 172800,
RevokeTimeLimit: 172800,
RevokePmTimeLimit: 172800,
// 官方现行 revoke 三元组:无时限 + 允许撤回对方发来的私聊消息。
// TDesktop 的私聊 "Also delete for X" 复选框要求
// revoke_pm_inbox=true 且 revoke_pm_time_limit=0x7FFFFFFF
// 否则双向删除 UI 永不出现。
RevokeTimeLimit: 2147483647,
RevokePmTimeLimit: 2147483647,
RevokePmInbox: true,
RatingEDecay: 2419200,
StickersRecentLimit: 200,
CallReceiveTimeoutMs: 20000,
CallRingTimeoutMs: 90000,
CallConnectTimeoutMs: 30000,
CallPacketTimeoutMs: 10000,
MeURLPrefix: "https://t.me/",
MeURLPrefix: "https://telesrv.net/",
CaptionLengthMax: 1024,
MessageLengthMax: 4096,
WebfileDCID: dc,
@ -49,7 +57,8 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
// NearestDC 构造 help.getNearestDc 返回值。
func NearestDC(dc int) *tg.NearestDC {
return &tg.NearestDC{
Country: "US",
// 默认国家=中国DrKLO/TDesktop 登录页据此预选区号(+86)。
Country: "CN",
ThisDC: dc,
NearestDC: dc,
}

View file

@ -1,15 +1,13 @@
package tdesktop
import (
"crypto/sha256"
"encoding/binary"
"time"
"github.com/gotd/td/tg"
"telesrv/internal/seed/catalog"
)
const (
appConfigHash = 4
appConfigHash = 12 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
countriesListHash = 1
timezonesListHash = 1
)
@ -21,76 +19,149 @@ func AppConfig(hash int) tg.HelpAppConfigClass {
}
return &tg.HelpAppConfig{
Hash: appConfigHash,
Config: readMarkAppConfig(),
Config: readMarkAppConfig(""),
}
}
func readMarkAppConfig() *tg.JSONObject {
return &tg.JSONObject{Value: []tg.JSONObjectValue{
func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
values := []tg.JSONObjectValue{
{Key: "chat_read_mark_size_threshold", Value: &tg.JSONNumber{Value: 50}},
{Key: "chat_read_mark_expire_period", Value: &tg.JSONNumber{Value: 604800}},
{Key: "pm_read_date_expire_period", Value: &tg.JSONNumber{Value: 604800}},
{Key: "quote_length_max", Value: &tg.JSONNumber{Value: 1024}},
{Key: "telegram_antispam_group_size_min", Value: &tg.JSONNumber{Value: 200}},
{Key: "telegram_antispam_user_id", Value: &tg.JSONString{Value: "5434988373"}},
}}
// premium_purchase_blocked=false客户端把 star gift「Send a Gift」入口与
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口
// (详见 app/help/service.go 主配置注释)。这里是无 HelpService 时的最小回退。
{Key: "premium_purchase_blocked", Value: &tg.JSONBool{Value: false}},
// stargifts_blocked=falseDrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格。
{Key: "stargifts_blocked", Value: &tg.JSONBool{Value: false}},
{Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}},
// dialog_filters_enabled=trueTDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。
{Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}},
{Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}},
{Key: "stories_stealth_past_period", Value: &tg.JSONNumber{Value: 300}},
{Key: "stories_stealth_cooldown_period", Value: &tg.JSONNumber{Value: 10800}},
// upload_markup_video=true 即官方默认emoji/sticker 头像由客户端本地渲染 mp4 后随
// markup 一起上传)。显式下发是为了把曾收到过 false 的客户端持久化配置洗回默认——
// 客户端对缺失的 key 会保留本地旧值,仅删除 key 无法恢复。
{Key: "upload_markup_video", Value: &tg.JSONBool{Value: true}},
// 单 emoji 消息转 InputMediaDice 的白名单;须与 rpc 层 dice 取值表同步。
{Key: "emojies_send_dice", Value: &tg.JSONArray{Value: []tg.JSONValueClass{
&tg.JSONString{Value: "\U0001F3B2"}, // 🎲
&tg.JSONString{Value: "\U0001F3AF"}, // 🎯
&tg.JSONString{Value: "\U0001F3C0"}, // 🏀
&tg.JSONString{Value: "⚽"},
&tg.JSONString{Value: "⚽️"},
&tg.JSONString{Value: "\U0001F3B3"}, // 🎳
&tg.JSONString{Value: "\U0001F3B0"}, // 🎰
}}},
}
if mapboxToken != "" {
// TDesktop 位置选点器WebView+Mapbox GL与 business 位置设置的地图 token
// 缺失时选点器地图空白(瓦片直连 api.mapbox.com不经服务端
values = append(values, tg.JSONObjectValue{Key: "tdesktop_config_map", Value: &tg.JSONObject{Value: []tg.JSONObjectValue{
{Key: "maps", Value: &tg.JSONString{Value: mapboxToken}},
{Key: "geo", Value: &tg.JSONString{Value: mapboxToken}},
{Key: "bmaps", Value: &tg.JSONString{Value: mapboxToken}},
{Key: "bgeo", Value: &tg.JSONString{Value: mapboxToken}},
}}})
}
return &tg.JSONObject{Value: values}
}
// TimezonesList returns a small non-empty timezone set for TDesktop business settings preloading.
// fallbackTimezones 是 catalog 未 seed 时的内置最小时区集。
var fallbackTimezones = []tg.Timezone{
{ID: "Etc/UTC", Name: "UTC", UtcOffset: 0},
{ID: "America/New_York", Name: "Eastern Time", UtcOffset: -5 * 60 * 60},
{ID: "America/Chicago", Name: "Central Time", UtcOffset: -6 * 60 * 60},
{ID: "America/Denver", Name: "Mountain Time", UtcOffset: -7 * 60 * 60},
{ID: "America/Los_Angeles", Name: "Pacific Time", UtcOffset: -8 * 60 * 60},
{ID: "Asia/Shanghai", Name: "China Standard Time", UtcOffset: 8 * 60 * 60},
}
// TimezonesList 返回时区目录:优先用 catalog 固化的官方全量(~419 个),未 seed 时回退内置最小集。
func TimezonesList(hash int) tg.HelpTimezonesListClass {
if hash == timezonesListHash {
seeded, h := catalog.Timezones()
src := seeded
if len(src) == 0 {
src, h = nil, timezonesListHash
}
if hash == h {
return &tg.HelpTimezonesListNotModified{}
}
return &tg.HelpTimezonesList{
Hash: timezonesListHash,
Timezones: []tg.Timezone{
{ID: "Etc/UTC", Name: "UTC", UtcOffset: 0},
{ID: "America/New_York", Name: "Eastern Time", UtcOffset: -5 * 60 * 60},
{ID: "America/Chicago", Name: "Central Time", UtcOffset: -6 * 60 * 60},
{ID: "America/Denver", Name: "Mountain Time", UtcOffset: -7 * 60 * 60},
{ID: "America/Los_Angeles", Name: "Pacific Time", UtcOffset: -8 * 60 * 60},
{ID: "Asia/Shanghai", Name: "China Standard Time", UtcOffset: 8 * 60 * 60},
},
items := fallbackTimezones
if len(src) > 0 {
items = make([]tg.Timezone, 0, len(src))
for _, t := range src {
items = append(items, tg.Timezone{ID: t.ID, Name: t.Name, UtcOffset: t.UTCOffset})
}
}
return &tg.HelpTimezonesList{Hash: h, Timezones: items}
}
// SuggestedDialogFilters 返回新建对话文件夹时的「推荐文件夹」模板(官方固定语义:
// Unread/Personal/Unmuted/Groups/Channels/Bots)。客户端据此一键创建对应规则的文件夹;
// 模板 id 仅占位,用户采纳时客户端会以新 id 调 updateDialogFilter 真正创建。
func SuggestedDialogFilters() []tg.DialogFilterSuggested {
mk := func(id int, title, desc string, set func(*tg.DialogFilter)) tg.DialogFilterSuggested {
df := &tg.DialogFilter{
ID: id,
Title: tg.TextWithEntities{Text: title, Entities: []tg.MessageEntityClass{}},
PinnedPeers: []tg.InputPeerClass{},
IncludePeers: []tg.InputPeerClass{},
ExcludePeers: []tg.InputPeerClass{},
}
set(df)
return tg.DialogFilterSuggested{Filter: df, Description: desc}
}
allTypes := func(d *tg.DialogFilter) {
d.Contacts, d.NonContacts, d.Groups, d.Broadcasts, d.Bots = true, true, true, true, true
}
return []tg.DialogFilterSuggested{
mk(2, "Unread", "New messages", func(d *tg.DialogFilter) { allTypes(d); d.ExcludeRead = true }),
mk(3, "Personal", "Personal chats", func(d *tg.DialogFilter) { d.Contacts, d.NonContacts = true, true }),
mk(4, "Unmuted", "Unmuted chats", func(d *tg.DialogFilter) { allTypes(d); d.ExcludeMuted = true }),
mk(5, "Groups", "Group chats", func(d *tg.DialogFilter) { d.Groups = true }),
mk(6, "Channels", "Channels only", func(d *tg.DialogFilter) { d.Broadcasts = true }),
mk(7, "Bots", "Bot chats", func(d *tg.DialogFilter) { d.Bots = true }),
}
}
// CountriesList returns the fallback login country list used when HelpService is absent.
// CountriesList 返回登录页国家区号目录:优先用 catalog 固化的官方全量(~235 国),未 seed
// 时回退内置最小集(US/CN)。生产路径通常经 HelpService.GetCountries → defaultCountries
// 走 catalog;此函数是 HelpService 缺省时的兜底。
func CountriesList(hash int) tg.HelpCountriesListClass {
if hash == countriesListHash {
list := catalog.Countries()
if len(list.Countries) == 0 {
if hash == countriesListHash {
return &tg.HelpCountriesListNotModified{}
}
return &tg.HelpCountriesList{
Hash: countriesListHash,
Countries: []tg.HelpCountry{
{ISO2: "US", DefaultName: "United States", CountryCodes: []tg.HelpCountryCode{{CountryCode: "1", Prefixes: []string{""}, Patterns: []string{"XXX XXX XXXX"}}}},
{ISO2: "CN", DefaultName: "China", CountryCodes: []tg.HelpCountryCode{{CountryCode: "86", Prefixes: []string{""}, Patterns: []string{"XXX XXXX XXXX"}}}},
},
}
}
if hash == list.Hash {
return &tg.HelpCountriesListNotModified{}
}
return &tg.HelpCountriesList{
Hash: countriesListHash,
Countries: []tg.HelpCountry{
{
ISO2: "US",
DefaultName: "United States",
CountryCodes: []tg.HelpCountryCode{
{CountryCode: "1", Prefixes: []string{"1"}},
},
},
{
ISO2: "CN",
DefaultName: "China",
CountryCodes: []tg.HelpCountryCode{
{CountryCode: "86", Prefixes: []string{"86"}},
},
},
},
}
}
// LoginToken returns a short-lived placeholder token for TDesktop's QR-login
// screen. First phase only supports phone login, but TDesktop expects this RPC
// to produce an auth.loginToken while the QR screen is visible.
func LoginToken(now time.Time, authKeyID [8]byte, sessionID int64) *tg.AuthLoginToken {
var seed [len(authKeyID) + 8 + 8]byte
copy(seed[:len(authKeyID)], authKeyID[:])
binary.LittleEndian.PutUint64(seed[len(authKeyID):], uint64(sessionID))
binary.LittleEndian.PutUint64(seed[len(authKeyID)+8:], uint64(now.UnixNano()))
token := sha256.Sum256(seed[:])
return &tg.AuthLoginToken{
Expires: int(now.Add(30 * time.Second).Unix()),
Token: token[:],
out := &tg.HelpCountriesList{Hash: list.Hash, Countries: make([]tg.HelpCountry, 0, len(list.Countries))}
for _, c := range list.Countries {
item := tg.HelpCountry{
Hidden: c.Hidden,
ISO2: c.ISO2,
DefaultName: c.DefaultName,
Name: c.Name,
CountryCodes: make([]tg.HelpCountryCode, 0, len(c.CountryCodes)),
}
for _, cc := range c.CountryCodes {
item.CountryCodes = append(item.CountryCodes, tg.HelpCountryCode{CountryCode: cc.CountryCode, Prefixes: cc.Prefixes, Patterns: cc.Patterns})
}
out.Countries = append(out.Countries, item)
}
return out
}

View file

@ -0,0 +1,196 @@
package tdesktop
import "telesrv/internal/domain"
// ShouldMergePinnedIntoInitialDialogs reports whether a TDesktop main-list
// messages.getDialogs request should carry pinned/archive rows inline.
//
// TDesktop starts the main list with getDialogs(exclude_pinned=true) and loads
// pinned rows through getPinnedDialogs in parallel. If the exclude_pinned result
// is rendered first, the first visible list temporarily lacks pinned/archive
// rows. Keep the workaround scoped to the first main-folder page with no hash so
// pagination and cache probes retain normal Telegram semantics.
func ShouldMergePinnedIntoInitialDialogs(filter domain.DialogFilter) bool {
if !filter.ExcludePinned || filter.Hash != 0 {
return false
}
if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID {
return false
}
if filter.OffsetID != 0 || filter.OffsetDate != 0 || filter.HasOffsetPeer {
return false
}
return true
}
// MergeInitialDialogsWithPinned prepends the pinned getPinnedDialogs view to an
// initial getDialogs(exclude_pinned=true) main-list response. The returned list
// remains a normal messages.dialogs/messages.dialogsSlice projection; only the
// first response carries extra pinned/archive rows for TDesktop startup.
func MergeInitialDialogsWithPinned(main, pinned domain.DialogList) domain.DialogList {
if pinned.ArchiveSummary == nil && len(pinned.Dialogs) == 0 {
return main
}
out := main
out.ArchiveSummary = pinned.ArchiveSummary
out.Dialogs = mergeDialogsByPeer(pinned.Dialogs, main.Dialogs)
out.Messages = mergePrivateMessages(pinned.Messages, main.Messages)
out.ChannelMessages = mergeChannelMessages(pinned.ChannelMessages, main.ChannelMessages)
out.Users = mergeUsersByID(pinned.Users, main.Users)
out.Channels = mergeChannelsByID(pinned.Channels, main.Channels)
pinnedEntries := len(pinned.Dialogs)
if pinned.ArchiveSummary != nil {
pinnedEntries++
}
mainCount := main.Count
if mainCount == 0 {
mainCount = len(main.Dialogs)
}
out.Count = mainCount + pinnedEntries
out.Hash = 0
return out
}
func mergeDialogsByPeer(first, second []domain.Dialog) []domain.Dialog {
out := make([]domain.Dialog, 0, len(first)+len(second))
seen := make(map[domain.Peer]struct{}, len(first)+len(second))
for _, d := range first {
if d.Peer.ID == 0 {
continue
}
if _, ok := seen[d.Peer]; ok {
continue
}
seen[d.Peer] = struct{}{}
out = append(out, d)
}
for _, d := range second {
if d.Peer.ID == 0 {
continue
}
if _, ok := seen[d.Peer]; ok {
continue
}
seen[d.Peer] = struct{}{}
out = append(out, d)
}
return out
}
type privateMessageKey struct {
peer domain.Peer
id int
}
func mergePrivateMessages(first, second []domain.Message) []domain.Message {
out := make([]domain.Message, 0, len(first)+len(second))
seen := make(map[privateMessageKey]struct{}, len(first)+len(second))
for _, msg := range first {
key := privateMessageKey{peer: msg.Peer, id: msg.ID}
if key.peer.ID == 0 || key.id == 0 {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, msg)
}
for _, msg := range second {
key := privateMessageKey{peer: msg.Peer, id: msg.ID}
if key.peer.ID == 0 || key.id == 0 {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, msg)
}
return out
}
type channelMessageKey struct {
channelID int64
id int
}
func mergeChannelMessages(first, second []domain.ChannelMessage) []domain.ChannelMessage {
out := make([]domain.ChannelMessage, 0, len(first)+len(second))
seen := make(map[channelMessageKey]struct{}, len(first)+len(second))
for _, msg := range first {
key := channelMessageKey{channelID: msg.ChannelID, id: msg.ID}
if key.channelID == 0 || key.id == 0 {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, msg)
}
for _, msg := range second {
key := channelMessageKey{channelID: msg.ChannelID, id: msg.ID}
if key.channelID == 0 || key.id == 0 {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, msg)
}
return out
}
func mergeUsersByID(first, second []domain.User) []domain.User {
out := make([]domain.User, 0, len(first)+len(second))
seen := make(map[int64]struct{}, len(first)+len(second))
for _, user := range first {
if user.ID == 0 {
continue
}
if _, ok := seen[user.ID]; ok {
continue
}
seen[user.ID] = struct{}{}
out = append(out, user)
}
for _, user := range second {
if user.ID == 0 {
continue
}
if _, ok := seen[user.ID]; ok {
continue
}
seen[user.ID] = struct{}{}
out = append(out, user)
}
return out
}
func mergeChannelsByID(first, second []domain.Channel) []domain.Channel {
out := make([]domain.Channel, 0, len(first)+len(second))
seen := make(map[int64]struct{}, len(first)+len(second))
for _, channel := range first {
if channel.ID == 0 {
continue
}
if _, ok := seen[channel.ID]; ok {
continue
}
seen[channel.ID] = struct{}{}
out = append(out, channel)
}
for _, channel := range second {
if channel.ID == 0 {
continue
}
if _, ok := seen[channel.ID]; ok {
continue
}
seen[channel.ID] = struct{}{}
out = append(out, channel)
}
return out
}

View file

@ -4,6 +4,9 @@ import (
"time"
"github.com/gotd/td/tg"
"telesrv/internal/seed/appearance"
"telesrv/internal/seed/catalog"
)
// NotifySettings returns default per-peer notification settings for empty first-phase accounts.
@ -23,17 +26,6 @@ func NotifySettings() *tg.PeerNotifySettings {
return settings
}
// ReactionsNotifySettings returns conservative defaults for reaction notifications.
func ReactionsNotifySettings() *tg.ReactionsNotifySettings {
return &tg.ReactionsNotifySettings{
MessagesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
StoriesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
PollVotesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
Sound: &tg.NotificationSoundDefault{},
ShowPreviews: true,
}
}
func PrivacyRules(key tg.InputPrivacyKeyClass) *tg.AccountPrivacyRules {
var rule tg.PrivacyRuleClass = &tg.PrivacyValueAllowAll{}
switch key.(type) {
@ -61,16 +53,176 @@ func Passkeys() *tg.AccountPasskeys {
return &tg.AccountPasskeys{Passkeys: []tg.Passkey{}}
}
func ContentSettings() *tg.AccountContentSettings {
return &tg.AccountContentSettings{}
}
func GlobalPrivacySettings() *tg.GlobalPrivacySettings {
return &tg.GlobalPrivacySettings{}
}
func AccountThemes() tg.AccountThemesClass {
return &tg.AccountThemesNotModified{}
const chatThemesHash int64 = 2026062501
const uniqueGiftChatThemesHash int64 = 2026061201
const wallPapersHash int64 = 2026062502
const peerColorsHash = 2026061202
const peerProfileColorsHash = 2026062801
// IsChatThemeEmoticon reports whether token is a known official chat theme emoticon.
func IsChatThemeEmoticon(token string) bool {
for _, ct := range appearance.Default().ChatThemes {
if ct.Emoticon == token {
return true
}
}
return false
}
func ChatThemes(hash int64) tg.AccountThemesClass {
if hash == chatThemesHash {
return &tg.AccountThemesNotModified{}
}
return &tg.AccountThemes{
Hash: chatThemesHash,
Themes: chatThemeList(),
}
}
// chatThemeList 用官方外观 catalog(appearancefetch 导出)的聊天主题。
func chatThemeList() []tg.Theme {
return catalogChatThemes(appearance.Default().ChatThemes)
}
// catalogChatThemes 把官方聊天主题转成 tg.Theme。官方每个主题只带 classic+tinted 两组
// settings,而 DrKLO 选择器丢弃 base-theme 设置 <4 的主题
// (MediaDataController.generateEmojiPreviewThemes),故扩展到 telesrv 宣告的 5 个 base:
// 浅色 base(classic/day/arctic)复用官方浅色设置,深色 base(tinted/night)复用官方深色设置。
func catalogChatThemes(cts []appearance.ChatTheme) []tg.Theme {
out := make([]tg.Theme, 0, len(cts))
for i, ct := range cts {
var light, dark *appearance.ThemeSettings
for j := range ct.Settings {
s := &ct.Settings[j]
if s.BaseTheme == "tinted" || s.BaseTheme == "night" {
if dark == nil {
dark = s
}
} else if light == nil {
light = s
}
}
if light == nil {
light = dark
}
if dark == nil {
dark = light
}
if light == nil {
continue
}
theme := tg.Theme{ID: ct.ID, AccessHash: ct.AccessHash, Slug: ct.Slug, Title: ct.Title}
theme.SetForChat(true)
if ct.Emoticon != "" {
theme.SetEmoticon(ct.Emoticon)
}
theme.SetInstallsCount(1)
settings := make([]tg.ThemeSettings, 0, len(chatThemeBaseThemes))
for _, b := range chatThemeBaseThemes {
src := light
if b.dark {
src = dark
}
settings = append(settings, catalogThemeSettings(*src, b.make()))
}
theme.SetSettings(settings)
if i == 0 || ct.Default {
theme.SetDefault(true)
}
out = append(out, theme)
}
return out
}
func catalogThemeSettings(s appearance.ThemeSettings, base tg.BaseThemeClass) tg.ThemeSettings {
ts := tg.ThemeSettings{BaseTheme: base, AccentColor: s.AccentColor}
if s.OutboxAccentColor != 0 {
ts.SetOutboxAccentColor(s.OutboxAccentColor)
}
if len(s.MessageColors) > 0 {
ts.SetMessageColors(append([]int(nil), s.MessageColors...))
}
if s.Wallpaper.ID != 0 || s.Wallpaper.Document.ID != 0 {
ts.SetWallpaper(seedWallPaper(s.Wallpaper))
}
return ts
}
// DefaultThemes serves the built-in chat theme catalog for account.getThemes.
//
// DrKLO's "Color theme" preview strip on the Chat Settings page
// (DefaultThemesPreviewCell) is populated exclusively from account.getThemes
// results flagged is_default=true — it does not consult account.getChatThemes.
// A fresh client has no cached emoji themes and therefore sends getThemes with
// hash 0; returning themesNotModified to that request leaves defaultEmojiThemes
// empty, so the strip stays stuck on its FlickerLoadingView shimmer forever.
// We return the same catalog as ChatThemes with every entry marked default so
// the strip resolves into real preview cards.
func DefaultThemes(hash int64) tg.AccountThemesClass {
if hash == chatThemesHash {
return &tg.AccountThemesNotModified{}
}
return &tg.AccountThemes{
Hash: chatThemesHash,
Themes: DefaultThemeList(),
}
}
// DefaultThemeList 返回内置默认主题切片(全标 is_default=true)。account.getThemes
// 把它与用户自定义主题合并(rpc 层),故单独暴露列表。
func DefaultThemeList() []tg.Theme {
themes := chatThemeList()
for i := range themes {
themes[i].SetDefault(true)
}
return themes
}
func UniqueGiftChatThemes(hash int64) tg.AccountChatThemesClass {
if hash == uniqueGiftChatThemesHash {
return &tg.AccountChatThemesNotModified{}
}
return &tg.AccountChatThemes{
Hash: uniqueGiftChatThemesHash,
Themes: []tg.ChatThemeClass{},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}
// WallPapers returns the read-only default wallpaper catalog. User wallpaper
// upload/save/install remains outside the current TDesktop compatibility scope.
func WallPapers(hash int64) tg.AccountWallPapersClass {
if hash == wallPapersHash {
return &tg.AccountWallPapersNotModified{}
}
wallpapers := seedWallPapers()
return &tg.AccountWallPapers{
Hash: wallPapersHash,
Wallpapers: wallpapers,
}
}
// chatThemeBaseThemes enumerates the base themes every emoji chat theme ships
// settings for. The client matches a chat theme's settings to whichever base
// theme the user currently runs, and DrKLO's theme picker only surfaces a chat
// theme whose settings cover at least four base themes
// (MediaDataController.generateEmojiPreviewThemes drops anything whose
// items.size() < 4, one item per ThemeSettings). dark selects the bubble/accent
// palette used for that base theme.
var chatThemeBaseThemes = []struct {
make func() tg.BaseThemeClass
dark bool
}{
{func() tg.BaseThemeClass { return &tg.BaseThemeClassic{} }, false},
{func() tg.BaseThemeClass { return &tg.BaseThemeDay{} }, false},
{func() tg.BaseThemeClass { return &tg.BaseThemeArctic{} }, false},
{func() tg.BaseThemeClass { return &tg.BaseThemeTinted{} }, true},
{func() tg.BaseThemeClass { return &tg.BaseThemeNight{} }, true},
}
func AutoDownloadSettings() *tg.AccountAutoDownloadSettings {
@ -101,10 +253,6 @@ func DefaultGroupPhotoEmojis() tg.EmojiListClass {
return &tg.EmojiList{Hash: 0, DocumentID: []int64{}}
}
func ConnectedBots() *tg.AccountConnectedBots {
return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}
}
const availableReactionsHash = 20260602
const emptyStickerSetHash = 20260602
@ -161,10 +309,7 @@ func availableReaction(reaction defaultReaction, index int) tg.AvailableReaction
}
}
func Stickers() tg.MessagesStickersClass {
return &tg.MessagesStickersNotModified{}
}
// Stickers 返回空的全量 messages.stickers。禁止回 stickersNotModifiedDrKLO 对
func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass {
if req != nil && req.Hash == emptyStickerSetHash {
return &tg.MessagesStickerSetNotModified{}
@ -206,8 +351,23 @@ func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass
}
}
func EmojiGroups() tg.MessagesEmojiGroupsClass {
return &tg.MessagesEmojiGroupsNotModified{}
// EmojiGroups 返回 emoji 分类目录(客户端 emoji 面板顶部的主题快捷标签)。优先用 catalog
// 固化的官方分组,未 seed 时回 NotModified(客户端保留本地/无分组)。icon_emoji_id 指向
// custom-emoji 文档,本地缺该文档时分类图标回退占位,不影响分组与按词搜索。
func EmojiGroups(hash int) tg.MessagesEmojiGroupsClass {
groups, h := catalog.EmojiGroups()
if len(groups) == 0 || hash == h {
return &tg.MessagesEmojiGroupsNotModified{}
}
out := make([]tg.EmojiGroupClass, 0, len(groups))
for _, g := range groups {
out = append(out, &tg.EmojiGroup{Title: g.Title, IconEmojiID: g.IconEmojiID, Emoticons: g.Emoticons})
}
return &tg.MessagesEmojiGroups{Hash: h, Groups: out}
}
func EmojiStatusGroups() tg.MessagesEmojiGroupsClass {
return &tg.MessagesEmojiGroups{Hash: 0, Groups: []tg.EmojiGroupClass{}}
}
func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass {
@ -215,11 +375,20 @@ func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass {
}
func AttachMenuBots() tg.AttachMenuBotsClass {
return &tg.AttachMenuBotsNotModified{}
return &tg.AttachMenuBots{
Hash: 0,
Bots: []tg.AttachMenuBot{},
Users: []tg.UserClass{},
}
}
func QuickReplies() tg.MessagesQuickRepliesClass {
return &tg.MessagesQuickRepliesNotModified{}
return &tg.MessagesQuickReplies{
QuickReplies: []tg.QuickReply{},
Messages: []tg.MessageClass{},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}
func TopPeers() tg.ContactsTopPeersClass {
@ -234,8 +403,93 @@ func BlockedContacts() tg.ContactsBlockedClass {
}
}
func PeerColors() tg.HelpPeerColorsClass {
return &tg.HelpPeerColorsNotModified{}
type defaultPeerColor struct {
id int
light []int
dark []int
bg []int
story []int
hidden bool
}
var defaultPeerColors = []defaultPeerColor{
{id: 0, light: []int{0xcc4d4d}, dark: []int{0xff7a7a}, bg: []int{0xffd6d6, 0xfff0f0}, story: []int{0xff7a7a, 0xf24b4b}},
{id: 1, light: []int{0xd97829}, dark: []int{0xffa15c}, bg: []int{0xffdfbd, 0xfff2e2}, story: []int{0xffa15c, 0xf07a2b}},
{id: 2, light: []int{0x8756d9}, dark: []int{0xb38cff}, bg: []int{0xe3d8ff, 0xf4eeff}, story: []int{0xb38cff, 0x8756d9}},
{id: 3, light: []int{0x3ca66b}, dark: []int{0x74d99b}, bg: []int{0xd5f5df, 0xedfff2}, story: []int{0x74d99b, 0x35a86a}},
{id: 4, light: []int{0x2d9fc6}, dark: []int{0x6fd8f5}, bg: []int{0xd4f4ff, 0xf0fbff}, story: []int{0x6fd8f5, 0x2d9fc6}},
{id: 5, light: []int{0x4a86d9}, dark: []int{0x78aef5}, bg: []int{0xd8e8ff, 0xf2f7ff}, story: []int{0x78aef5, 0x4a86d9}},
{id: 6, light: []int{0xd85b93}, dark: []int{0xff8abb}, bg: []int{0xffd7e8, 0xfff5fb}, story: []int{0xff8abb, 0xd85b93}},
{id: 7, light: []int{0x5f6a7a, 0x8c96a6}, dark: []int{0x9aa4b4, 0xc0c8d4}, bg: []int{0xe3e7ee, 0xf7f9fc}, story: []int{0x9aa4b4, 0x6f7b8d}},
}
// IsPeerColorID reports whether id is in the TDesktop-compatible peer color palette.
func IsPeerColorID(id int) bool {
if found, seeded := seedPeerColorID(id, false); seeded {
return found
}
for _, color := range defaultPeerColors {
if color.id == id {
return true
}
}
return false
}
// IsPeerProfileColorID reports whether id is in the profile background palette.
func IsPeerProfileColorID(id int) bool {
if found, seeded := seedPeerColorID(id, true); seeded {
return found
}
return IsPeerColorID(id)
}
func PeerColors(hash int) tg.HelpPeerColorsClass {
if hash == peerColorsHash {
return &tg.HelpPeerColorsNotModified{}
}
colors := seedPeerColorOptions(false)
if len(colors) == 0 {
colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors))
for _, color := range defaultPeerColors {
option := tg.HelpPeerColorOption{ColorID: color.id}
option.SetColors(&tg.HelpPeerColorSet{Colors: append([]int(nil), color.light...)})
option.SetDarkColors(&tg.HelpPeerColorSet{Colors: append([]int(nil), color.dark...)})
if color.hidden {
option.SetHidden(true)
}
colors = append(colors, option)
}
}
return &tg.HelpPeerColors{Hash: peerColorsHash, Colors: colors}
}
func PeerProfileColors(hash int) tg.HelpPeerColorsClass {
if hash == peerProfileColorsHash {
return &tg.HelpPeerColorsNotModified{}
}
colors := seedPeerColorOptions(true)
if len(colors) == 0 {
colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors))
for _, color := range defaultPeerColors {
option := tg.HelpPeerColorOption{ColorID: color.id}
option.SetColors(&tg.HelpPeerColorProfileSet{
PaletteColors: append([]int(nil), color.light...),
BgColors: append([]int(nil), color.bg...),
StoryColors: append([]int(nil), color.story...),
})
option.SetDarkColors(&tg.HelpPeerColorProfileSet{
PaletteColors: append([]int(nil), color.dark...),
BgColors: append([]int(nil), color.bg...),
StoryColors: append([]int(nil), color.story...),
})
if color.hidden {
option.SetHidden(true)
}
colors = append(colors, option)
}
}
return &tg.HelpPeerColors{Hash: peerProfileColorsHash, Colors: colors}
}
func PromoData(now time.Time) tg.HelpPromoDataClass {
@ -246,17 +500,6 @@ func TermsOfServiceUpdate(now time.Time) tg.HelpTermsOfServiceUpdateClass {
return &tg.HelpTermsOfServiceUpdateEmpty{Expires: int(now.Add(24 * time.Hour).Unix())}
}
func PremiumPromo() *tg.HelpPremiumPromo {
return &tg.HelpPremiumPromo{}
}
func AllStories() tg.StoriesAllStoriesClass {
return &tg.StoriesAllStories{
State: "",
StealthMode: tg.StoriesStealthMode{},
}
}
func StoriesArchive() *tg.StoriesStories {
return &tg.StoriesStories{}
}
@ -273,6 +516,14 @@ func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass {
return &tg.PaymentsStarGiftActiveAuctionsNotModified{}
}
func StarGifts() tg.PaymentsStarGiftsClass {
return &tg.PaymentsStarGifts{
Gifts: []tg.StarGiftClass{},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}
func SavedStarGifts() *tg.PaymentsSavedStarGifts {
return &tg.PaymentsSavedStarGifts{
Gifts: []tg.SavedStarGift{},
@ -281,6 +532,29 @@ func SavedStarGifts() *tg.PaymentsSavedStarGifts {
}
}
func StarGiftCollections() tg.PaymentsStarGiftCollectionsClass {
return &tg.PaymentsStarGiftCollections{
Collections: []tg.StarGiftCollection{},
}
}
func StarsRevenueStats(ton bool) *tg.PaymentsStarsRevenueStats {
zeroAmount := tg.StarsAmountClass(&tg.StarsAmount{})
if ton {
zeroAmount = &tg.StarsTonAmount{}
}
return &tg.PaymentsStarsRevenueStats{
RevenueGraph: &tg.StatsGraphError{Error: "Not enough data to display."},
Status: tg.StarsRevenueStatus{
CurrentBalance: zeroAmount,
AvailableBalance: zeroAmount,
OverallRevenue: zeroAmount,
WithdrawalEnabled: false,
},
UsdRate: 0.013,
}
}
func AiComposeTones() tg.AicomposeTonesClass {
return &tg.AicomposeTonesNotModified{}
}

View file

@ -1,6 +1,7 @@
package tdesktop
import (
"math"
"testing"
"github.com/gotd/td/tg"
@ -32,6 +33,48 @@ func TestTimezonesListIsNonEmptyAndHashable(t *testing.T) {
}
}
func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
got, ok := AppConfig(0).(*tg.HelpAppConfig)
if !ok || got.Hash == 0 {
t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got)
}
values := make(map[string]float64)
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
}
}
}
want := map[string]float64{
"stories_stealth_future_period": 1500,
"stories_stealth_past_period": 300,
"stories_stealth_cooldown_period": 10800,
}
for key, expected := range want {
if values[key] != expected {
t.Fatalf("AppConfig[%q] = %v, want %v", key, values[key], expected)
}
}
if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok {
t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash))
}
}
func TestFallbackAppConfigOmitsMapboxToken(t *testing.T) {
got, ok := AppConfig(0).(*tg.HelpAppConfig)
if !ok {
t.Fatalf("AppConfig(0) = %T, want modified config", got)
}
if object, ok := got.Config.(*tg.JSONObject); ok && object != nil {
for _, entry := range object.Value {
if entry.Key == "tdesktop_config_map" {
t.Fatal("fallback AppConfig leaked tdesktop_config_map without runtime token")
}
}
}
}
func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) {
got, ok := AvailableReactions(0).(*tg.MessagesAvailableReactions)
if !ok {
@ -66,6 +109,195 @@ func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) {
}
}
func TestChatThemesCatalogIsNonEmptyHashableAndHasQRBackgroundColors(t *testing.T) {
got, ok := ChatThemes(0).(*tg.AccountThemes)
if !ok {
t.Fatalf("ChatThemes(0) = %T, want modified list", got)
}
if got.Hash == 0 {
t.Fatal("ChatThemes(0).Hash = 0, want stable cache hash")
}
if len(got.Themes) == 0 {
t.Fatal("ChatThemes(0).Themes is empty")
}
for i, theme := range got.Themes {
if !theme.GetForChat() {
t.Fatalf("theme[%d].for_chat = false, want true", i)
}
if emoticon, ok := theme.GetEmoticon(); !ok || emoticon == "" || !IsChatThemeEmoticon(emoticon) {
t.Fatalf("theme[%d].emoticon = %q ok=%v, want supported token", i, emoticon, ok)
}
settings, ok := theme.GetSettings()
// DrKLO only surfaces an emoji chat theme in the picker when it ships
// settings for at least four base themes
// (MediaDataController.generateEmojiPreviewThemes drops items.size() < 4,
// one item per ThemeSettings); fewer leaves the strip stuck on its
// loading shimmer.
if !ok || len(settings) < 4 {
t.Fatalf("theme[%d].settings = %#v ok=%v, want >=4 base-theme settings", i, settings, ok)
}
var hasDay, hasNight bool
for j, setting := range settings {
if setting.BaseTheme == nil {
t.Fatalf("theme[%d].settings[%d].base_theme missing", i, j)
}
switch setting.BaseTheme.(type) {
case *tg.BaseThemeDay:
hasDay = true
case *tg.BaseThemeNight:
hasNight = true
}
// 官方聊天主题用文档型主题背景墙纸(非旧的纯渐变 WallPaperNoFile),
// 这里只要求每组设置带一个墙纸。
if wallpaper, ok := setting.GetWallpaper(); !ok || wallpaper == nil {
t.Fatalf("theme[%d].settings[%d].wallpaper = %#v ok=%v, want wallpaper", i, j, wallpaper, ok)
}
}
if !hasDay || !hasNight {
t.Fatalf("theme[%d] settings missing day/night coverage (day=%v night=%v)", i, hasDay, hasNight)
}
}
if _, ok := ChatThemes(got.Hash).(*tg.AccountThemesNotModified); !ok {
t.Fatalf("ChatThemes(hash) = %#v, want notModified", ChatThemes(got.Hash))
}
}
func TestDefaultThemesAreDefaultFlaggedForPicker(t *testing.T) {
// account.getThemes feeds DrKLO's "Color theme" preview strip
// (DefaultThemesPreviewCell), which only consumes themes flagged
// is_default=true. Returning themesNotModified to a fresh client (hash 0)
// leaves the strip stuck on a loading shimmer, so a fresh request must
// return a populated, default-flagged catalog.
got, ok := DefaultThemes(0).(*tg.AccountThemes)
if !ok {
t.Fatalf("DefaultThemes(0) = %T, want modified list", got)
}
if got.Hash == 0 {
t.Fatal("DefaultThemes(0).Hash = 0, want stable cache hash")
}
if len(got.Themes) == 0 {
t.Fatal("DefaultThemes(0).Themes is empty")
}
for i, theme := range got.Themes {
if !theme.GetDefault() {
t.Fatalf("theme[%d].is_default = false, want true so the picker shows it", i)
}
if !theme.GetForChat() {
t.Fatalf("theme[%d].for_chat = false, want true", i)
}
if settings, ok := theme.GetSettings(); !ok || len(settings) < 4 {
t.Fatalf("theme[%d].settings len=%d ok=%v, want >=4 for the picker", i, len(settings), ok)
}
}
if _, ok := DefaultThemes(got.Hash).(*tg.AccountThemesNotModified); !ok {
t.Fatalf("DefaultThemes(hash) = %#v, want notModified", DefaultThemes(got.Hash))
}
}
func TestUniqueGiftChatThemesIsEmptyHashableStub(t *testing.T) {
got, ok := UniqueGiftChatThemes(0).(*tg.AccountChatThemes)
if !ok {
t.Fatalf("UniqueGiftChatThemes(0) = %T, want modified list", got)
}
if got.Hash == 0 {
t.Fatal("UniqueGiftChatThemes(0).Hash = 0, want stable cache hash")
}
if len(got.Themes) != 0 || len(got.Chats) != 0 || len(got.Users) != 0 {
t.Fatalf("UniqueGiftChatThemes(0) = themes %d chats %d users %d, want empty vectors",
len(got.Themes), len(got.Chats), len(got.Users))
}
if _, ok := UniqueGiftChatThemes(got.Hash).(*tg.AccountChatThemesNotModified); !ok {
t.Fatalf("UniqueGiftChatThemes(hash) = %#v, want notModified", UniqueGiftChatThemes(got.Hash))
}
}
func TestWallPapersUsesOrangeFileCatalog(t *testing.T) {
got, ok := WallPapers(0).(*tg.AccountWallPapers)
if !ok {
t.Fatalf("WallPapers(0) = %T, want modified list", got)
}
if got.Hash == 0 {
t.Fatal("WallPapers(0).Hash = 0, want stable cache hash")
}
if len(got.Wallpapers) == 0 {
t.Fatal("WallPapers(0).Wallpapers is empty")
}
wallpaper, ok := got.Wallpapers[0].(*tg.WallPaper)
if !ok {
t.Fatalf("WallPapers(0).Wallpapers[0] = %T, want *tg.WallPaper", got.Wallpapers[0])
}
if wallpaper.ID == 0 || wallpaper.AccessHash == 0 || wallpaper.Slug == "" {
t.Fatalf("wallpaper identity = id %d hash %d slug %q, want seed ids", wallpaper.ID, wallpaper.AccessHash, wallpaper.Slug)
}
doc, ok := wallpaper.Document.(*tg.Document)
if !ok {
t.Fatalf("wallpaper document = %T, want *tg.Document", wallpaper.Document)
}
if doc.ID == 0 || doc.AccessHash == 0 || doc.Size == 0 || doc.MimeType == "" || doc.DCID != appearanceSeedDCID {
t.Fatalf("wallpaper document = id %d hash %d size %d mime %q dc %d, want downloadable seed document",
doc.ID, doc.AccessHash, doc.Size, doc.MimeType, doc.DCID)
}
if len(doc.Thumbs) == 0 {
t.Fatal("wallpaper document has no thumbnails")
}
if _, ok := doc.Thumbs[0].(*tg.PhotoSize); !ok {
t.Fatalf("wallpaper thumb = %T, want *tg.PhotoSize", doc.Thumbs[0])
}
if _, ok := WallPapers(got.Hash).(*tg.AccountWallPapersNotModified); !ok {
t.Fatalf("WallPapers(hash) = %#v, want notModified", WallPapers(got.Hash))
}
}
func TestLookupWallPaperByIDAndSlug(t *testing.T) {
catalog := WallPapers(0).(*tg.AccountWallPapers)
first := catalog.Wallpapers[0].(*tg.WallPaper)
byID, ok := LookupWallPaper(&tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash})
if !ok {
t.Fatal("LookupWallPaper(inputWallPaper) = false, want true")
}
if got := byID.(*tg.WallPaper).Slug; got != first.Slug {
t.Fatalf("LookupWallPaper(inputWallPaper).Slug = %q, want %q", got, first.Slug)
}
bySlug, ok := LookupWallPaper(&tg.InputWallPaperSlug{Slug: first.Slug})
if !ok {
t.Fatal("LookupWallPaper(inputWallPaperSlug) = false, want true")
}
if got := bySlug.(*tg.WallPaper).ID; got != first.ID {
t.Fatalf("LookupWallPaper(inputWallPaperSlug).ID = %d, want %d", got, first.ID)
}
if _, ok := LookupWallPaper(&tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash + 1}); ok {
t.Fatal("LookupWallPaper(wrong access hash) = true, want false")
}
multi, ok := LookupWallPapers([]tg.InputWallPaperClass{
&tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash},
&tg.InputWallPaperSlug{Slug: catalog.Wallpapers[1].(*tg.WallPaper).Slug},
})
if !ok || len(multi) != 2 {
t.Fatalf("LookupWallPapers = len %d ok %v, want 2 true", len(multi), ok)
}
}
func TestStarsRevenueStatsIsZeroBalanceCompatStub(t *testing.T) {
got := StarsRevenueStats(false)
if got == nil {
t.Fatal("StarsRevenueStats(false) = nil")
}
if _, ok := got.RevenueGraph.(*tg.StatsGraphError); !ok {
t.Fatalf("RevenueGraph = %T, want *tg.StatsGraphError", got.RevenueGraph)
}
if got.Status.WithdrawalEnabled {
t.Fatal("WithdrawalEnabled = true, want false")
}
if _, ok := got.Status.CurrentBalance.(*tg.StarsAmount); !ok {
t.Fatalf("CurrentBalance = %T, want *tg.StarsAmount", got.Status.CurrentBalance)
}
ton := StarsRevenueStats(true)
if _, ok := ton.Status.CurrentBalance.(*tg.StarsTonAmount); !ok {
t.Fatalf("TON CurrentBalance = %T, want *tg.StarsTonAmount", ton.Status.CurrentBalance)
}
}
func TestCollectibleEmojiStatusesIsEmptyModifiedList(t *testing.T) {
got, ok := CollectibleEmojiStatuses().(*tg.AccountEmojiStatuses)
if !ok {
@ -105,13 +337,169 @@ func TestEmojiProfilePhotoGroupsIsEmptyModifiedList(t *testing.T) {
}
}
func TestConnectedBotsIsEmptyList(t *testing.T) {
got := ConnectedBots()
if got == nil {
t.Fatal("ConnectedBots() = nil")
func TestEmojiStatusGroupsIsEmptyModifiedList(t *testing.T) {
got, ok := EmojiStatusGroups().(*tg.MessagesEmojiGroups)
if !ok {
t.Fatalf("EmojiStatusGroups() = %T, want empty modified list", got)
}
if len(got.ConnectedBots) != 0 || len(got.Users) != 0 {
t.Fatalf("ConnectedBots() = bots %d users %d, want empty vectors", len(got.ConnectedBots), len(got.Users))
if got.Hash != 0 {
t.Fatalf("EmojiStatusGroups().Hash = %d, want 0", got.Hash)
}
if len(got.Groups) != 0 {
t.Fatalf("EmojiStatusGroups().Groups length = %d, want 0", len(got.Groups))
}
}
func TestQuickRepliesIsEmptyModifiedList(t *testing.T) {
got, ok := QuickReplies().(*tg.MessagesQuickReplies)
if !ok {
t.Fatalf("QuickReplies() = %T, want empty modified list", got)
}
if len(got.QuickReplies) != 0 || len(got.Messages) != 0 || len(got.Chats) != 0 || len(got.Users) != 0 {
t.Fatalf("QuickReplies() = shortcuts %d messages %d chats %d users %d, want empty vectors",
len(got.QuickReplies), len(got.Messages), len(got.Chats), len(got.Users))
}
}
func TestPeerColorsAreNonEmptyHashableAccentSets(t *testing.T) {
got, ok := PeerColors(0).(*tg.HelpPeerColors)
if !ok {
t.Fatalf("PeerColors(0) = %T, want modified colors", got)
}
if got.Hash == 0 || len(got.Colors) == 0 {
t.Fatalf("PeerColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors))
}
if len(got.Colors) != 21 {
t.Fatalf("PeerColors(0).Colors length = %d, want seed palette count 21", len(got.Colors))
}
withExplicitColors := 0
for i, option := range got.Colors {
if !IsPeerColorID(option.ColorID) {
t.Fatalf("PeerColors()[%d].ColorID = %d, want supported id", i, option.ColorID)
}
colors, ok := option.GetColors()
if !ok {
if option.ColorID > 6 {
t.Fatalf("PeerColors()[%d].Colors missing for non-default id %d", i, option.ColorID)
}
continue
}
if _, ok := colors.(*tg.HelpPeerColorSet); !ok {
t.Fatalf("PeerColors()[%d].Colors = %T, want *tg.HelpPeerColorSet", i, colors)
}
withExplicitColors++
}
if withExplicitColors == 0 {
t.Fatal("PeerColors() has no explicit seed color sets")
}
if _, ok := PeerColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok {
t.Fatalf("PeerColors(hash) = %#v, want notModified", PeerColors(got.Hash))
}
}
func TestPeerProfileColorsAreNonEmptyHashableProfileSets(t *testing.T) {
got, ok := PeerProfileColors(0).(*tg.HelpPeerColors)
if !ok {
t.Fatalf("PeerProfileColors(0) = %T, want modified colors", got)
}
if got.Hash == 0 || len(got.Colors) == 0 {
t.Fatalf("PeerProfileColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors))
}
if len(got.Colors) != 16 {
t.Fatalf("PeerProfileColors(0).Colors length = %d, want seed profile palette count 16", len(got.Colors))
}
for i, option := range got.Colors {
if !IsPeerProfileColorID(option.ColorID) {
t.Fatalf("PeerProfileColors()[%d].ColorID = %d, want supported id", i, option.ColorID)
}
groupMin, ok := option.GetGroupMinLevel()
if !ok || groupMin <= 0 || groupMin > maxPeerColorBoostLevel {
t.Fatalf("PeerProfileColors()[%d].group_min_level = %d ok %v, want bounded positive level", i, groupMin, ok)
}
if channelMin, ok := option.GetChannelMinLevel(); ok && (channelMin <= 0 || channelMin > maxPeerColorBoostLevel) {
t.Fatalf("PeerProfileColors()[%d].channel_min_level = %d, want bounded positive level", i, channelMin)
}
colors, ok := option.GetColors()
if !ok {
t.Fatalf("PeerProfileColors()[%d].Colors missing", i)
}
profile, ok := colors.(*tg.HelpPeerColorProfileSet)
if !ok {
t.Fatalf("PeerProfileColors()[%d].Colors = %T, want *tg.HelpPeerColorProfileSet", i, colors)
}
if len(profile.PaletteColors) == 0 || len(profile.BgColors) == 0 || len(profile.StoryColors) != 2 {
t.Fatalf("PeerProfileColors()[%d] = palette %d bg %d story %d, want usable profile palette",
i, len(profile.PaletteColors), len(profile.BgColors), len(profile.StoryColors))
}
}
if _, ok := PeerProfileColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok {
t.Fatalf("PeerProfileColors(hash) = %#v, want notModified", PeerProfileColors(got.Hash))
}
}
func TestPeerProfileColorsKeepTDesktopBoostFeatureLevelsBounded(t *testing.T) {
got, ok := PeerProfileColors(0).(*tg.HelpPeerColors)
if !ok {
t.Fatalf("PeerProfileColors(0) = %T, want modified colors", got)
}
levels := make([]int, 0, len(got.Colors))
lowestNonZeroLevel := math.MaxInt
for _, option := range got.Colors {
level, ok := option.GetGroupMinLevel()
if !ok {
level = 0
}
levels = append(levels, level)
if level != 0 && level < lowestNonZeroLevel {
lowestNonZeroLevel = level
}
}
if lowestNonZeroLevel == math.MaxInt {
t.Fatal("profile color group levels are all zero; TDesktop would aggregate them at MaxInt in BoostBox")
}
maxFeatureLevel := 0
for _, level := range levels {
if level < lowestNonZeroLevel {
level = lowestNonZeroLevel
}
if level > maxFeatureLevel {
maxFeatureLevel = level
}
}
if maxFeatureLevel <= 0 || maxFeatureLevel > maxPeerColorBoostLevel {
t.Fatalf("TDesktop profile feature max level = %d, want 1..%d", maxFeatureLevel, maxPeerColorBoostLevel)
}
}
func TestPeerProfileColorsReturnsIndependentOptions(t *testing.T) {
first, ok := PeerProfileColors(0).(*tg.HelpPeerColors)
if !ok {
t.Fatalf("PeerProfileColors(0) first = %T, want modified colors", first)
}
second, ok := PeerProfileColors(0).(*tg.HelpPeerColors)
if !ok {
t.Fatalf("PeerProfileColors(0) second = %T, want modified colors", second)
}
if len(first.Colors) == 0 || len(second.Colors) == 0 {
t.Fatal("PeerProfileColors returned empty colors")
}
first.Colors[0].SetGroupMinLevel(maxPeerColorBoostLevel + 1)
if groupMin, ok := second.Colors[0].GetGroupMinLevel(); !ok || groupMin > maxPeerColorBoostLevel {
t.Fatalf("second PeerProfileColors group_min_level = %d ok %v, want independent bounded copy", groupMin, ok)
}
firstSet, ok := first.Colors[0].Colors.(*tg.HelpPeerColorProfileSet)
if !ok || len(firstSet.PaletteColors) == 0 {
t.Fatalf("first profile colors = %T %+v, want profile set with palette", first.Colors[0].Colors, first.Colors[0].Colors)
}
secondSet, ok := second.Colors[0].Colors.(*tg.HelpPeerColorProfileSet)
if !ok || len(secondSet.PaletteColors) == 0 {
t.Fatalf("second profile colors = %T %+v, want profile set with palette", second.Colors[0].Colors, second.Colors[0].Colors)
}
original := secondSet.PaletteColors[0]
firstSet.PaletteColors[0] = original + 1
if secondSet.PaletteColors[0] != original {
t.Fatalf("second profile palette mutated through first response: got %d want %d", secondSet.PaletteColors[0], original)
}
}