Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

View file

@ -0,0 +1,56 @@
package tdesktop
import (
"time"
"github.com/gotd/td/tg"
)
// BuildConfig 构造 help.getConfig 返回的 tg.Config含自建 DC 的 DCOptions。
//
// 字段值取 Telegram 常见默认TDesktop 联调阶段按客户端实际需要微调
// (记录于 docs/compatibility-matrix.md
func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
return &tg.Config{
Date: int(now.Unix()),
Expires: int(now.Add(time.Hour).Unix()),
TestMode: false,
ThisDC: dc,
DCOptions: []tg.DCOption{
{ID: dc, IPAddress: ip, Port: port, Static: true},
},
ChatSizeMax: 200,
MegagroupSizeMax: 200000,
ForwardedCountMax: 100,
OnlineUpdatePeriodMs: 120000,
OfflineBlurTimeoutMs: 5000,
OfflineIdleTimeoutMs: 30000,
OnlineCloudTimeoutMs: 300000,
NotifyCloudDelayMs: 30000,
NotifyDefaultDelayMs: 1500,
PushChatPeriodMs: 60000,
PushChatLimit: 2,
EditTimeLimit: 172800,
RevokeTimeLimit: 172800,
RevokePmTimeLimit: 172800,
RatingEDecay: 2419200,
StickersRecentLimit: 200,
CallReceiveTimeoutMs: 20000,
CallRingTimeoutMs: 90000,
CallConnectTimeoutMs: 30000,
CallPacketTimeoutMs: 10000,
MeURLPrefix: "https://t.me/",
CaptionLengthMax: 1024,
MessageLengthMax: 4096,
WebfileDCID: dc,
}
}
// NearestDC 构造 help.getNearestDc 返回值。
func NearestDC(dc int) *tg.NearestDC {
return &tg.NearestDC{
Country: "US",
ThisDC: dc,
NearestDC: dc,
}
}

View file

@ -0,0 +1,96 @@
package tdesktop
import (
"crypto/sha256"
"encoding/binary"
"time"
"github.com/gotd/td/tg"
)
const (
appConfigHash = 4
countriesListHash = 1
timezonesListHash = 1
)
// AppConfig returns the fallback TDesktop startup app config used when HelpService is absent.
func AppConfig(hash int) tg.HelpAppConfigClass {
if hash == appConfigHash {
return &tg.HelpAppConfigNotModified{}
}
return &tg.HelpAppConfig{
Hash: appConfigHash,
Config: readMarkAppConfig(),
}
}
func readMarkAppConfig() *tg.JSONObject {
return &tg.JSONObject{Value: []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"}},
}}
}
// TimezonesList returns a small non-empty timezone set for TDesktop business settings preloading.
func TimezonesList(hash int) tg.HelpTimezonesListClass {
if hash == timezonesListHash {
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},
},
}
}
// CountriesList returns the fallback login country list used when HelpService is absent.
func CountriesList(hash int) tg.HelpCountriesListClass {
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{"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[:],
}
}

View file

@ -0,0 +1,8 @@
// Package tdesktop 集中存放 Telegram Desktop 兼容逻辑:目标版本/layer 记录、客户端 patch 说明、
// 启动 RPC 顺序、兼容矩阵辅助、TDesktop 专属 stub 与 feature flags。
//
// 兼容代码只允许出现在本包,禁止散落进业务 handler。后续 Android/iOS 另开 internal/compat/android|ios。
// 当前基线TDesktop dev 9caf32dffcv6.8.4+15Layer 225。
//
// TDesktop 兼容逻辑集中在这里,避免散落到业务服务中。
package tdesktop

View file

@ -0,0 +1,278 @@
package tdesktop
import (
"time"
"github.com/gotd/td/tg"
)
// NotifySettings returns default per-peer notification settings for empty first-phase accounts.
func NotifySettings() *tg.PeerNotifySettings {
settings := &tg.PeerNotifySettings{}
settings.SetShowPreviews(true)
settings.SetSilent(false)
settings.SetMuteUntil(0)
settings.SetIosSound(&tg.NotificationSoundDefault{})
settings.SetAndroidSound(&tg.NotificationSoundDefault{})
settings.SetOtherSound(&tg.NotificationSoundDefault{})
settings.SetStoriesMuted(false)
settings.SetStoriesHideSender(false)
settings.SetStoriesIosSound(&tg.NotificationSoundDefault{})
settings.SetStoriesAndroidSound(&tg.NotificationSoundDefault{})
settings.SetStoriesOtherSound(&tg.NotificationSoundDefault{})
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) {
case *tg.InputPrivacyKeyPhoneNumber:
rule = &tg.PrivacyValueDisallowAll{}
case *tg.InputPrivacyKeyBirthday:
rule = &tg.PrivacyValueAllowContacts{}
}
return &tg.AccountPrivacyRules{
Rules: []tg.PrivacyRuleClass{rule},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}
func Authorizations() *tg.AccountAuthorizations {
return &tg.AccountAuthorizations{Authorizations: []tg.Authorization{}}
}
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{}
}
func DefaultEmojiStatuses() tg.AccountEmojiStatusesClass {
return &tg.AccountEmojiStatusesNotModified{}
}
func CollectibleEmojiStatuses() tg.AccountEmojiStatusesClass {
return &tg.AccountEmojiStatuses{Hash: 0, Statuses: []tg.EmojiStatusClass{}}
}
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
type defaultReaction struct {
emoticon string
title string
}
var defaultAvailableReactions = []defaultReaction{
{emoticon: "\U0001f44d", title: "Thumbs Up"},
{emoticon: "\u2764\ufe0f", title: "Red Heart"},
{emoticon: "\U0001f602", title: "Face With Tears of Joy"},
{emoticon: "\U0001f62e", title: "Face With Open Mouth"},
{emoticon: "\U0001f622", title: "Crying Face"},
{emoticon: "\U0001f64f", title: "Folded Hands"},
}
// DefaultReactionEmoticons returns the TDesktop-compatible emoji reaction catalog order.
func DefaultReactionEmoticons() []string {
out := make([]string, 0, len(defaultAvailableReactions))
for _, reaction := range defaultAvailableReactions {
out = append(out, reaction.emoticon)
}
return out
}
func AvailableReactions(hash int) tg.MessagesAvailableReactionsClass {
if hash == availableReactionsHash {
return &tg.MessagesAvailableReactionsNotModified{}
}
reactions := make([]tg.AvailableReaction, 0, len(defaultAvailableReactions))
for i, reaction := range defaultAvailableReactions {
reactions = append(reactions, availableReaction(reaction, i))
}
return &tg.MessagesAvailableReactions{
Hash: availableReactionsHash,
Reactions: reactions,
}
}
func availableReaction(reaction defaultReaction, index int) tg.AvailableReaction {
const documentBaseID int64 = 900000000000000000
doc := func(slot int64) tg.DocumentClass {
return &tg.DocumentEmpty{ID: documentBaseID + int64(index)*10 + slot}
}
return tg.AvailableReaction{
Reaction: reaction.emoticon,
Title: reaction.title,
StaticIcon: doc(1),
AppearAnimation: doc(2),
SelectAnimation: doc(3),
ActivateAnimation: doc(4),
EffectAnimation: doc(5),
}
}
func Stickers() tg.MessagesStickersClass {
return &tg.MessagesStickersNotModified{}
}
func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass {
if req != nil && req.Hash == emptyStickerSetHash {
return &tg.MessagesStickerSetNotModified{}
}
title, shortName := "Telesrv Empty Sticker Set", "telesrv_empty"
if req != nil {
switch set := req.Stickerset.(type) {
case *tg.InputStickerSetAnimatedEmoji:
title, shortName = "Animated Emoji", "AnimatedEmojies"
case *tg.InputStickerSetAnimatedEmojiAnimations:
title, shortName = "Emoji Animations", "EmojiAnimations"
case *tg.InputStickerSetEmojiGenericAnimations:
title, shortName = "Emoji Generic Animations", "EmojiGenericAnimations"
case *tg.InputStickerSetDice:
title, shortName = "Dice Animations", "AnimatedDices"
if set.Emoticon != "" {
shortName = "AnimatedDice"
}
case *tg.InputStickerSetPremiumGifts:
title, shortName = "Premium Gifts", "GiftsPremium"
case *tg.InputStickerSetShortName:
if set.ShortName != "" {
title, shortName = set.ShortName, set.ShortName
}
}
}
return &tg.MessagesStickerSet{
Set: tg.StickerSet{
ID: 910000000000000000,
AccessHash: 910000000000000001,
Title: title,
ShortName: shortName,
Count: 0,
Hash: emptyStickerSetHash,
},
Packs: []tg.StickerPack{},
Keywords: []tg.StickerKeyword{},
Documents: []tg.DocumentClass{},
}
}
func EmojiGroups() tg.MessagesEmojiGroupsClass {
return &tg.MessagesEmojiGroupsNotModified{}
}
func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass {
return &tg.MessagesEmojiGroups{Hash: 0, Groups: []tg.EmojiGroupClass{}}
}
func AttachMenuBots() tg.AttachMenuBotsClass {
return &tg.AttachMenuBotsNotModified{}
}
func QuickReplies() tg.MessagesQuickRepliesClass {
return &tg.MessagesQuickRepliesNotModified{}
}
func TopPeers() tg.ContactsTopPeersClass {
return &tg.ContactsTopPeersDisabled{}
}
func BlockedContacts() tg.ContactsBlockedClass {
return &tg.ContactsBlocked{
Blocked: []tg.PeerBlocked{},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}
func PeerColors() tg.HelpPeerColorsClass {
return &tg.HelpPeerColorsNotModified{}
}
func PromoData(now time.Time) tg.HelpPromoDataClass {
return &tg.HelpPromoDataEmpty{Expires: int(now.Add(time.Hour).Unix())}
}
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{}
}
func PinnedStories() *tg.StoriesStories {
return &tg.StoriesStories{}
}
func StoryAlbums() tg.StoriesAlbumsClass {
return &tg.StoriesAlbums{Hash: 0, Albums: []tg.StoryAlbum{}}
}
func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass {
return &tg.PaymentsStarGiftActiveAuctionsNotModified{}
}
func SavedStarGifts() *tg.PaymentsSavedStarGifts {
return &tg.PaymentsSavedStarGifts{
Gifts: []tg.SavedStarGift{},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}
func AiComposeTones() tg.AicomposeTonesClass {
return &tg.AicomposeTonesNotModified{}
}
func WebPage(url string) *tg.MessagesWebPage {
page := &tg.WebPageEmpty{ID: 0}
if url != "" {
page.SetURL(url)
}
return &tg.MessagesWebPage{
Webpage: page,
Chats: []tg.ChatClass{},
Users: []tg.UserClass{},
}
}

View file

@ -0,0 +1,157 @@
package tdesktop
import (
"testing"
"github.com/gotd/td/tg"
)
func TestNotifySettingsDefaultIsAudible(t *testing.T) {
settings := NotifySettings()
if value, ok := settings.GetShowPreviews(); !ok || !value {
t.Fatalf("show_previews = %v ok=%v, want true", value, ok)
}
if value, ok := settings.GetSilent(); !ok || value {
t.Fatalf("silent = %v ok=%v, want explicit false", value, ok)
}
if value, ok := settings.GetMuteUntil(); !ok || value != 0 {
t.Fatalf("mute_until = %d ok=%v, want explicit 0", value, ok)
}
if value, ok := settings.GetOtherSound(); !ok || value == nil {
t.Fatalf("other_sound = %#v ok=%v, want default sound", value, ok)
}
}
func TestTimezonesListIsNonEmptyAndHashable(t *testing.T) {
got, ok := TimezonesList(0).(*tg.HelpTimezonesList)
if !ok || got.Hash == 0 || len(got.Timezones) == 0 {
t.Fatalf("TimezonesList(0) = %#v, want non-empty modified list", got)
}
if _, ok := TimezonesList(got.Hash).(*tg.HelpTimezonesListNotModified); !ok {
t.Fatalf("TimezonesList(hash) = %#v, want notModified", TimezonesList(got.Hash))
}
}
func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) {
got, ok := AvailableReactions(0).(*tg.MessagesAvailableReactions)
if !ok {
t.Fatalf("AvailableReactions(0) = %T, want modified list", got)
}
if got.Hash == 0 {
t.Fatal("AvailableReactions(0).Hash = 0, want stable cache hash")
}
if len(got.Reactions) == 0 {
t.Fatal("AvailableReactions(0).Reactions is empty")
}
for i, reaction := range got.Reactions {
if reaction.Reaction == "" {
t.Fatalf("reaction[%d].Reaction is empty", i)
}
if reaction.Title == "" {
t.Fatalf("reaction[%d].Title is empty", i)
}
if reaction.StaticIcon == nil ||
reaction.AppearAnimation == nil ||
reaction.SelectAnimation == nil ||
reaction.ActivateAnimation == nil ||
reaction.EffectAnimation == nil {
t.Fatalf("reaction[%d] has nil required document: %#v", i, reaction)
}
if reaction.Inactive || reaction.Premium {
t.Fatalf("reaction[%d] flags = inactive %v premium %v, want active non-premium", i, reaction.Inactive, reaction.Premium)
}
}
if _, ok := AvailableReactions(got.Hash).(*tg.MessagesAvailableReactionsNotModified); !ok {
t.Fatalf("AvailableReactions(hash) = %#v, want notModified", AvailableReactions(got.Hash))
}
}
func TestCollectibleEmojiStatusesIsEmptyModifiedList(t *testing.T) {
got, ok := CollectibleEmojiStatuses().(*tg.AccountEmojiStatuses)
if !ok {
t.Fatalf("CollectibleEmojiStatuses() = %T, want empty modified list", got)
}
if got.Hash != 0 {
t.Fatalf("CollectibleEmojiStatuses().Hash = %d, want 0", got.Hash)
}
if len(got.Statuses) != 0 {
t.Fatalf("CollectibleEmojiStatuses().Statuses length = %d, want 0", len(got.Statuses))
}
}
func TestDefaultGroupPhotoEmojisIsEmptyModifiedList(t *testing.T) {
got, ok := DefaultGroupPhotoEmojis().(*tg.EmojiList)
if !ok {
t.Fatalf("DefaultGroupPhotoEmojis() = %T, want empty modified list", got)
}
if got.Hash != 0 {
t.Fatalf("DefaultGroupPhotoEmojis().Hash = %d, want 0", got.Hash)
}
if len(got.DocumentID) != 0 {
t.Fatalf("DefaultGroupPhotoEmojis().DocumentID length = %d, want 0", len(got.DocumentID))
}
}
func TestEmojiProfilePhotoGroupsIsEmptyModifiedList(t *testing.T) {
got, ok := EmojiProfilePhotoGroups().(*tg.MessagesEmojiGroups)
if !ok {
t.Fatalf("EmojiProfilePhotoGroups() = %T, want empty modified list", got)
}
if got.Hash != 0 {
t.Fatalf("EmojiProfilePhotoGroups().Hash = %d, want 0", got.Hash)
}
if len(got.Groups) != 0 {
t.Fatalf("EmojiProfilePhotoGroups().Groups length = %d, want 0", len(got.Groups))
}
}
func TestConnectedBotsIsEmptyList(t *testing.T) {
got := ConnectedBots()
if got == nil {
t.Fatal("ConnectedBots() = nil")
}
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))
}
}
func TestStoryAlbumsIsEmptyModifiedList(t *testing.T) {
got, ok := StoryAlbums().(*tg.StoriesAlbums)
if !ok {
t.Fatalf("StoryAlbums() = %T, want empty modified list", got)
}
if got.Hash != 0 {
t.Fatalf("StoryAlbums().Hash = %d, want 0", got.Hash)
}
if len(got.Albums) != 0 {
t.Fatalf("StoryAlbums().Albums length = %d, want 0", len(got.Albums))
}
}
func TestStickerSetReturnsEmptyModifiedSetForColdRequest(t *testing.T) {
got, ok := StickerSet(&tg.MessagesGetStickerSetRequest{
Stickerset: &tg.InputStickerSetEmojiGenericAnimations{},
Hash: 0,
}).(*tg.MessagesStickerSet)
if !ok {
t.Fatalf("StickerSet(hash=0) = %T, want modified empty set", got)
}
if got.Set.Hash == 0 {
t.Fatal("StickerSet(hash=0).Set.Hash = 0, want stable cache hash")
}
if got.Set.ShortName != "EmojiGenericAnimations" {
t.Fatalf("StickerSet(hash=0).Set.ShortName = %q, want EmojiGenericAnimations", got.Set.ShortName)
}
if len(got.Packs) != 0 || len(got.Keywords) != 0 || len(got.Documents) != 0 {
t.Fatalf("StickerSet(hash=0) = packs %d keywords %d documents %d, want empty vectors", len(got.Packs), len(got.Keywords), len(got.Documents))
}
if _, ok := StickerSet(&tg.MessagesGetStickerSetRequest{
Stickerset: &tg.InputStickerSetEmojiGenericAnimations{},
Hash: got.Set.Hash,
}).(*tg.MessagesStickerSetNotModified); !ok {
t.Fatalf("StickerSet(hash) = %#v, want notModified", StickerSet(&tg.MessagesGetStickerSetRequest{
Stickerset: &tg.InputStickerSetEmojiGenericAnimations{},
Hash: got.Set.Hash,
}))
}
}