feat: sync iOS compatibility support

This commit is contained in:
A 2026-07-13 00:58:57 +08:00
parent 1f646ef024
commit 50803a604c
32 changed files with 871 additions and 78 deletions

View file

@ -7,6 +7,7 @@ import (
"github.com/gotd/td/tg"
ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop"
"telesrv/internal/domain"
)
@ -19,6 +20,12 @@ func (r *Router) registerAccount(d *tg.ServerDispatcher) {
d.OnAccountUnregisterDevice(func(ctx context.Context, req *tg.AccountUnregisterDeviceRequest) (bool, error) {
return true, nil
})
d.OnAccountUpdateDeviceLocked(func(ctx context.Context, period int) (bool, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return false, internalErr()
}
return ioscompat.DeviceLockedUpdated(), nil
})
d.OnAccountSendChangePhoneCode(r.onAccountSendChangePhoneCode)
d.OnAccountChangePhone(r.onAccountChangePhone)
d.OnAccountCheckUsername(r.onAccountCheckUsername)

View file

@ -916,6 +916,11 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
t.Fatalf("toggle forum chat = %+v, want forum+forum_tabs", forumUpdates.(*tg.Updates).Chats[0])
}
forumPeer := &tg.InputPeerChannel{ChannelID: channel.ID, AccessHash: channel.AccessHash}
if _, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{
Peer: forumPeer,
}); err == nil || !strings.Contains(err.Error(), "TOPICS_EMPTY") {
t.Fatalf("messages.getForumTopicsByID empty topics err = %v, want TOPICS_EMPTY", err)
}
forumTopics, err := r.onMessagesGetForumTopics(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsRequest{
Peer: forumPeer,
Limit: 10,
@ -1001,15 +1006,25 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
if !foundCreated {
t.Fatalf("messages.getForumTopics topics = %+v, want created topic id %d", forumTopicsWithCreated.Topics, topicID)
}
missingTopicID := topicID + 1000
forumTopicsByID, err = r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{
Peer: forumPeer,
Topics: []int{forumGeneralTopicID, topicID},
Topics: []int{topicID, missingTopicID, forumGeneralTopicID, topicID},
})
if err != nil {
t.Fatalf("messages.getForumTopicsByID with created topic: %v", err)
}
if forumTopicsByID.Count != 2 || len(forumTopicsByID.Topics) != 2 || len(forumTopicsByID.Messages) == 0 {
t.Fatalf("messages.getForumTopicsByID with created topic = %+v, want General + created topic", forumTopicsByID)
if forumTopicsByID.Count != 3 || len(forumTopicsByID.Topics) != 3 || len(forumTopicsByID.Messages) == 0 {
t.Fatalf("messages.getForumTopicsByID with created/missing/duplicate topics = %+v, want three unique results", forumTopicsByID)
}
if topic, ok := forumTopicsByID.Topics[0].(*tg.ForumTopic); !ok || topic.ID != topicID {
t.Fatalf("messages.getForumTopicsByID result[0] = %T %+v, want live topic %d", forumTopicsByID.Topics[0], forumTopicsByID.Topics[0], topicID)
}
if topic, ok := forumTopicsByID.Topics[1].(*tg.ForumTopicDeleted); !ok || topic.ID != missingTopicID {
t.Fatalf("messages.getForumTopicsByID result[1] = %T %+v, want deleted placeholder %d", forumTopicsByID.Topics[1], forumTopicsByID.Topics[1], missingTopicID)
}
if topic, ok := forumTopicsByID.Topics[2].(*tg.ForumTopic); !ok || topic.ID != forumGeneralTopicID {
t.Fatalf("messages.getForumTopicsByID result[2] = %T %+v, want General", forumTopicsByID.Topics[2], forumTopicsByID.Topics[2])
}
topicReply := &tg.InputReplyToMessage{ReplyToMsgID: 0}
topicReply.SetTopMsgID(topicID)
@ -1217,6 +1232,19 @@ func TestChannelUsernameAndManagementRPC(t *testing.T) {
if forumTopicsAfterDelete.Count != 1 || len(forumTopicsAfterDelete.Topics) != 1 {
t.Fatalf("messages.getForumTopics after delete topic = %+v, want General only", forumTopicsAfterDelete)
}
deletedByID, err := r.onMessagesGetForumTopicsByID(WithUserID(ctx, owner.ID), &tg.MessagesGetForumTopicsByIDRequest{
Peer: forumPeer,
Topics: []int{topicID},
})
if err != nil {
t.Fatalf("messages.getForumTopicsByID after delete topic: %v", err)
}
if deletedByID.Count != 1 || len(deletedByID.Topics) != 1 {
t.Fatalf("messages.getForumTopicsByID after delete = %+v, want one deleted placeholder", deletedByID)
}
if topic, ok := deletedByID.Topics[0].(*tg.ForumTopicDeleted); !ok || topic.ID != topicID {
t.Fatalf("messages.getForumTopicsByID after delete result = %T %+v, want deleted topic %d", deletedByID.Topics[0], deletedByID.Topics[0], topicID)
}
antiSpamUpdates, err := r.onChannelsToggleAntiSpam(WithUserID(ctx, owner.ID), &tg.ChannelsToggleAntiSpamRequest{Channel: input, Enabled: true})
if err != nil {
t.Fatalf("toggle antispam: %v", err)

View file

@ -38,9 +38,13 @@ var androidSDKVersionRE = regexp.MustCompile(`\bsdk\s+\d+\b`)
type ClientType string
const (
ClientTypeUnknown ClientType = "unknown"
ClientTypeTDesktop ClientType = "tdesktop"
ClientTypeAndroid ClientType = "android"
ClientTypeUnknown ClientType = "unknown"
ClientTypeTDesktop ClientType = "tdesktop"
ClientTypeAndroid ClientType = "android"
ClientTypeIOS ClientType = "ios"
ClientTypeMacOS ClientType = "macos"
ClientTypeTWeb ClientType = "tweb"
ClientTypeTelegramTT ClientType = "telegram-tt"
)
// ClientInfo 是 initConnection 携带的客户端信息。
@ -53,6 +57,10 @@ type ClientInfo struct {
LangPack string
LangCode string
Type ClientType
// typeResolved distinguishes current-connection classification from raw
// metadata restored from storage. A persisted unknown remains unknown until
// a fresh initConnection supplies authoritative wire evidence.
typeResolved bool
}
// WithLayer 在 ctx 注入客户端 layer(来自 invokeWithLayer)。
@ -88,22 +96,32 @@ func ClientTypeFrom(ctx context.Context) ClientType {
}
func normalizeClientInfo(info ClientInfo) ClientInfo {
if !knownClientType(info.Type) {
info.Type = detectClientType(info)
}
info.Type = detectClientType(info)
info.typeResolved = true
return info
}
func (info ClientInfo) ClientType() ClientType {
if knownClientType(info.Type) {
return info.Type
if info.typeResolved {
if knownClientType(info.Type) {
return info.Type
}
return ClientTypeUnknown
}
return detectClientType(info)
}
func restoreClientInfo(info ClientInfo) ClientInfo {
if !knownClientType(info.Type) {
info.Type = ClientTypeUnknown
}
info.typeResolved = true
return info
}
func knownClientType(t ClientType) bool {
switch t {
case ClientTypeTDesktop, ClientTypeAndroid:
case ClientTypeTDesktop, ClientTypeAndroid, ClientTypeIOS, ClientTypeMacOS, ClientTypeTWeb, ClientTypeTelegramTT:
return true
default:
return false
@ -118,25 +136,55 @@ func clientTypeFromAPIID(apiID int) ClientType {
return ClientTypeAndroid
case 2040, 17349, 611335:
return ClientTypeTDesktop
case 8:
return ClientTypeIOS
case 2496, 1025907:
return ClientTypeTWeb
default:
return ClientTypeUnknown
}
}
func detectClientType(info ClientInfo) ClientType {
if t := clientTypeFromAPIID(info.APIID); t != ClientTypeUnknown {
// Wire evidence wins over stored/explicit type and API id. In particular,
// local TWeb builds may reuse api_id=2040 (the official TDesktop id), while
// lang_pack=webk and a browser UA unambiguously identify the web client.
if t := clientTypeFromStrongEvidence(info); t != ClientTypeUnknown {
return t
}
if strings.EqualFold(info.LangPack, string(ClientTypeAndroid)) {
return ClientTypeAndroid
if knownClientType(info.Type) {
return info.Type
}
if strings.EqualFold(info.LangPack, string(ClientTypeTDesktop)) {
return clientTypeFromAPIID(info.APIID)
}
func clientTypeFromStrongEvidence(info ClientInfo) ClientType {
langPack := strings.ToLower(strings.TrimSpace(info.LangPack))
switch langPack {
case "weba":
return ClientTypeTelegramTT
case "web", "webk":
return ClientTypeTWeb
case string(ClientTypeAndroid):
return ClientTypeAndroid
case string(ClientTypeIOS):
return ClientTypeIOS
case string(ClientTypeMacOS):
return ClientTypeMacOS
case string(ClientTypeTDesktop):
return ClientTypeTDesktop
}
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
switch {
case strings.Contains(client, "mozilla/"), strings.Contains(client, "applewebkit/"),
strings.Contains(client, "telegram web"), strings.Contains(client, "webogram"):
return ClientTypeTWeb
case strings.Contains(client, "android"), androidSDKVersionRE.MatchString(client):
return ClientTypeAndroid
case strings.Contains(client, "iphone"), strings.Contains(client, "ipad"),
strings.Contains(client, "ipod"), strings.Contains(client, "ipados"),
strings.Contains(client, "ios "):
return ClientTypeIOS
case strings.Contains(client, "tdesktop"), strings.Contains(client, "desktop"):
return ClientTypeTDesktop
default:

View file

@ -290,6 +290,7 @@ func channelForumMissingErr() error { return tgerr.New(400, "CHANNEL_FORUM_MISSI
func topicTitleEmptyErr() error { return tgerr.New(400, "TOPIC_TITLE_EMPTY") }
func topicIDInvalidErr() error { return tgerr.New(400, "TOPIC_ID_INVALID") }
func topicsEmptyErr() error { return tgerr.New(400, "TOPICS_EMPTY") }
// randomIDEmptyErr 表示发送消息缺少 random_id。
func randomIDEmptyErr() error { return tgerr.New(400, "RANDOM_ID_EMPTY") }

View file

@ -7,6 +7,7 @@ import (
"github.com/gotd/td/tg"
androidcompat "telesrv/internal/compat/android"
ioscompat "telesrv/internal/compat/ios"
"telesrv/internal/compat/tdesktop"
)
@ -21,6 +22,12 @@ func (r *Router) registerHelp(d *tg.ServerDispatcher) {
d.OnHelpGetInviteText(func(ctx context.Context) (*tg.HelpInviteText, error) {
return &tg.HelpInviteText{Message: "Join me on Telegram."}, nil
})
d.OnHelpGetAppUpdate(func(ctx context.Context, source string) (tg.HelpAppUpdateClass, error) {
if _, _, err := r.currentUserID(ctx); err != nil {
return nil, internalErr()
}
return ioscompat.NoAppUpdate(), nil
})
d.OnHelpGetAppConfig(func(ctx context.Context, hash int) (tg.HelpAppConfigClass, error) {
if r.deps.Help == nil {
return tdesktop.AppConfig(hash), nil

View file

@ -143,6 +143,14 @@ func langPackFromClient(ctx context.Context) string {
return string(ClientTypeAndroid)
case ClientTypeTDesktop:
return string(ClientTypeTDesktop)
case ClientTypeIOS:
return string(ClientTypeIOS)
case ClientTypeMacOS:
return string(ClientTypeMacOS)
case ClientTypeTWeb:
return "webk"
case ClientTypeTelegramTT:
return "weba"
}
client := strings.ToLower(info.DeviceModel + " " + info.SystemVersion + " " + info.AppVersion)
if strings.Contains(client, "android") {

View file

@ -321,6 +321,9 @@ func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.Messa
if err != nil {
return nil, internalErr()
}
if len(req.Topics) == 0 {
return nil, topicsEmptyErr()
}
if len(req.Topics) > maxForumTopicIDs {
return nil, limitInvalidErr()
}
@ -332,11 +335,18 @@ func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.Messa
return nil, channelForumMissingErr()
}
includeGeneral := false
requestedIDs := make([]int, 0, len(req.Topics))
ids := make([]int, 0, len(req.Topics))
seen := make(map[int]struct{}, len(req.Topics))
for _, topicID := range req.Topics {
if topicID <= 0 || topicID > domain.MaxMessageBoxID {
return nil, messageIDInvalidErr()
}
if _, ok := seen[topicID]; ok {
continue
}
seen[topicID] = struct{}{}
requestedIDs = append(requestedIDs, topicID)
if topicID == forumGeneralTopicID {
includeGeneral = true
continue
@ -350,7 +360,34 @@ func (r *Router) onMessagesGetForumTopicsByID(ctx context.Context, req *tg.Messa
return nil, forumTopicError(err)
}
}
return r.forumTopicsResponse(ctx, userID, view, list, includeGeneral), nil
return r.forumTopicsByIDResponse(ctx, userID, view, list, includeGeneral, requestedIDs), nil
}
// forumTopicsByIDResponse keeps the request's unique ID order and returns one
// constructor for every requested topic. Telegram clients use
// forumTopicDeleted as a positive deletion/missing confirmation; silently
// omitting an ID leaves their local thread state stale and causes repeat reads.
func (r *Router) forumTopicsByIDResponse(ctx context.Context, userID int64, view domain.ChannelView, list domain.ChannelForumTopicList, includeGeneral bool, requestedIDs []int) *tg.MessagesForumTopics {
response := r.forumTopicsResponse(ctx, userID, view, list, includeGeneral)
live := make(map[int]tg.ForumTopicClass, len(response.Topics))
for _, topic := range response.Topics {
switch topic := topic.(type) {
case *tg.ForumTopic:
live[topic.ID] = topic
case *tg.ForumTopicDeleted:
live[topic.ID] = topic
}
}
response.Topics = make([]tg.ForumTopicClass, 0, len(requestedIDs))
for _, topicID := range requestedIDs {
if topic, ok := live[topicID]; ok {
response.Topics = append(response.Topics, topic)
} else {
response.Topics = append(response.Topics, &tg.ForumTopicDeleted{ID: topicID})
}
}
response.Count = len(response.Topics)
return response
}
func (r *Router) forumTopicPeerView(ctx context.Context, userID int64, peer tg.InputPeerClass) (domain.ChannelView, error) {

View file

@ -0,0 +1,57 @@
package rpc
import (
"context"
"github.com/gotd/td/tg"
"telesrv/internal/domain"
)
// onMessagesGetRecentLocations returns the peer's newest live-location
// messages through the existing media seek indexes. Expired/stopped items are
// deliberately retained: iOS tags every messageMediaGeoLive in its local
// history and applies the active-period check in PeerLiveLocationsContext.
func (r *Router) onMessagesGetRecentLocations(ctx context.Context, req *tg.MessagesGetRecentLocationsRequest) (tg.MessagesMessagesClass, error) {
if req == nil || req.Limit < 0 || req.Limit > maxSearchResultsLimit {
return nil, limitInvalidErr()
}
userID, _, err := r.currentUserID(ctx)
if err != nil {
return nil, internalErr()
}
peer, err := r.checkedDomainPeerFromInputPeer(ctx, userID, req.Peer)
if err != nil {
return nil, err
}
if input, ok := req.Peer.(*tg.InputPeerUser); ok && input != nil {
if err := r.validateInputUser(ctx, &tg.InputUser{UserID: input.UserID, AccessHash: input.AccessHash}); err != nil {
return nil, err
}
}
search := domain.MediaSearchRequest{
Categories: []domain.MediaCategory{domain.MediaCategoryGeoLive},
Limit: req.Limit,
}
if peer.Type == domain.PeerTypeChannel {
if r.deps.Channels == nil {
return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil
}
history, err := r.deps.Channels.SearchChannelMedia(ctx, userID, peer.ID, search)
if err != nil {
return nil, channelInvalidErr(err)
}
history = r.enrichChannelHistory(ctx, userID, history)
r.trackChannelInterest(ctx, userID, peer.ID)
return r.tgChannelHistoryMessages(ctx, userID, history), nil
}
r.clearChannelInterest(ctx, userID)
if r.deps.Messages == nil {
return &tg.MessagesMessages{Messages: []tg.MessageClass{}, Chats: []tg.ChatClass{}, Users: []tg.UserClass{}}, nil
}
list, err := r.deps.Messages.SearchPrivateMedia(ctx, userID, peer.ID, search)
if err != nil {
return nil, internalErr()
}
return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil
}

View file

@ -0,0 +1,61 @@
package rpc
import (
"context"
"testing"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
)
func TestMessagesGetRecentLocationsReturnsOnlyGeoLive(t *testing.T) {
r, owner, friend := newMediaTestRouter(t)
ctx := WithUserID(context.Background(), owner.ID)
peer := &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}
if _, err := r.onMessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
Peer: peer, Message: "not a location", RandomID: 73001,
}); err != nil {
t.Fatalf("send text: %v", err)
}
want := sendTestLiveLocation(t, r, owner.ID, peer, 73002, 900)
result, err := r.onMessagesGetRecentLocations(ctx, &tg.MessagesGetRecentLocationsRequest{Peer: peer, Limit: 20})
if err != nil {
t.Fatalf("getRecentLocations: %v", err)
}
var messages []tg.MessageClass
switch value := result.(type) {
case *tg.MessagesMessages:
messages = value.Messages
case *tg.MessagesMessagesSlice:
messages = value.Messages
default:
t.Fatalf("getRecentLocations = %T", result)
}
if len(messages) != 1 {
t.Fatalf("recent location messages = %d, want 1", len(messages))
}
got, ok := messages[0].(*tg.Message)
if !ok || got.ID != want.ID {
t.Fatalf("recent location = %#v, want id %d", messages[0], want.ID)
}
if _, ok := got.Media.(*tg.MessageMediaGeoLive); !ok {
t.Fatalf("recent location media = %T, want MessageMediaGeoLive", got.Media)
}
}
func TestMessagesGetRecentLocationsValidatesLimitAndAccessHash(t *testing.T) {
r, owner, friend := newMediaTestRouter(t)
ctx := WithUserID(context.Background(), owner.ID)
if _, err := r.onMessagesGetRecentLocations(ctx, &tg.MessagesGetRecentLocationsRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash}, Limit: maxSearchResultsLimit + 1,
}); err == nil || !tgerr.Is(err, "LIMIT_INVALID") {
t.Fatalf("oversized limit err = %v, want LIMIT_INVALID", err)
}
if _, err := r.onMessagesGetRecentLocations(ctx, &tg.MessagesGetRecentLocationsRequest{
Peer: &tg.InputPeerUser{UserID: friend.ID, AccessHash: friend.AccessHash + 1}, Limit: 20,
}); err == nil || !tgerr.Is(err, "USER_ID_INVALID") {
t.Fatalf("bad access hash err = %v, want USER_ID_INVALID", err)
}
}

View file

@ -294,6 +294,7 @@ func (r *Router) registerMessages(d *tg.ServerDispatcher) {
}
return r.tgMessagesMessages(ctx, userID, r.enrichMessageList(ctx, userID, list)), nil
})
d.OnMessagesGetRecentLocations(r.onMessagesGetRecentLocations)
d.OnMessagesReadHistory(func(ctx context.Context, req *tg.MessagesReadHistoryRequest) (*tg.MessagesAffectedMessages, error) {
id, _ := AuthKeyIDFrom(ctx)
userID, _, err := r.currentUserID(ctx)

View file

@ -716,9 +716,15 @@ func (r *Router) rememberClientAPIID(ctx context.Context, apiID int) {
if apiID == 0 {
return
}
info := ClientInfo{APIID: apiID, Type: clientTypeFromAPIID(apiID)}
info := normalizeClientInfo(ClientInfo{APIID: apiID, Type: clientTypeFromAPIID(apiID)})
sessionInfo := clientSessionInfo{clientInfo: info, hasClientInfo: true}
r.rememberClientSessionInfo(ctx, sessionInfo)
// api_id is weak evidence. If initConnection (or durable restoration) has
// already supplied stronger client metadata, persist that effective session
// fact instead of letting auth.sendCode overwrite it on an id collision.
if effective, ok, _ := r.clientSessionInfo(ctx); ok {
sessionInfo = effective
}
r.persistAuthKeyClientInfo(ctx, sessionInfo)
}
@ -1103,7 +1109,7 @@ func clientSessionInfoFromAuthKeyClientInfo(item domain.AuthKeyClientInfo, curre
Type: ClientType(item.Platform),
},
}
info.clientInfo = normalizeClientInfo(info.clientInfo)
info.clientInfo = restoreClientInfo(info.clientInfo)
info.hasClientInfo = info.clientInfo.ClientType() != ClientTypeUnknown ||
info.clientInfo.DeviceModel != "" ||
info.clientInfo.SystemVersion != "" ||
@ -1162,7 +1168,7 @@ func clientSessionInfoFromAuthorizationRecord(item domain.Authorization, current
Type: ClientType(item.Platform),
},
}
info.clientInfo = normalizeClientInfo(info.clientInfo)
info.clientInfo = restoreClientInfo(info.clientInfo)
info.hasClientInfo = info.clientInfo.ClientType() != ClientTypeUnknown ||
info.clientInfo.DeviceModel != "" ||
info.clientInfo.SystemVersion != "" ||

View file

@ -281,6 +281,52 @@ func TestDispatchPersistsPreLoginClientMetadataFromSendCodeAPIID(t *testing.T) {
}
}
func TestSendCodeAPIIDDoesNotOverwriteStrongTWebIdentity(t *testing.T) {
auth := &captureAuthService{}
rawAuthKeyID := [8]byte{0x34, 0xdb, 0xcf, 0xc8, 0x0d, 0x4c, 0x77, 0x97}
const sessionID = int64(8103956954238395545)
r := New(Config{DC: 2, IP: "127.0.0.1", Port: 2398}, Deps{Auth: auth}, zaptest.NewLogger(t), clock.System)
initReq := &tg.InvokeWithLayerRequest{
Layer: currentClientLayer,
Query: &tg.InitConnectionRequest{
APIID: 2040,
DeviceModel: "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36",
SystemVersion: "Win32",
AppVersion: "2.2",
SystemLangCode: "en-US",
LangPack: "webk",
LangCode: "en",
Query: &tg.HelpGetConfigRequest{},
},
}
var initBuf bin.Buffer
if err := initReq.Encode(&initBuf); err != nil {
t.Fatalf("encode init request: %v", err)
}
if _, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &initBuf); err != nil {
t.Fatalf("dispatch init request: %v", err)
}
var sendCode bin.Buffer
if err := (&tg.AuthSendCodeRequest{
PhoneNumber: "+8618800000021",
APIID: 2040,
APIHash: "tweb-local",
Settings: tg.CodeSettings{},
}).Encode(&sendCode); err != nil {
t.Fatalf("encode auth.sendCode: %v", err)
}
if _, err := r.Dispatch(context.Background(), rawAuthKeyID, sessionID, &sendCode); err != nil {
t.Fatalf("dispatch auth.sendCode: %v", err)
}
persisted := auth.authKeyClientInfos[rawAuthKeyID]
if persisted.Platform != string(ClientTypeTWeb) || persisted.DeviceModel != initReq.Query.(*tg.InitConnectionRequest).DeviceModel {
t.Fatalf("API id fallback overwrote strong TWeb identity: %+v", persisted)
}
}
func TestDispatchRestoresPreLoginAndroidMetadataFromAuthKey(t *testing.T) {
core, logs := observer.New(zap.DebugLevel)
authKeyID := [8]byte{0x22, 0xdb, 0xcf, 0xc8, 0x0d, 0x4c, 0x77, 0x97}
@ -460,33 +506,96 @@ func TestInvokeWithLayerPersistsClientLayerUpgrade(t *testing.T) {
}
}
func TestClientTypeDetectsAndroidSDKVersion(t *testing.T) {
info := normalizeClientInfo(ClientInfo{
DeviceModel: "GooglePixel 9a",
SystemVersion: "SDK 36",
AppVersion: "12.7.3 (67509) pbeta",
})
if got := info.ClientType(); got != ClientTypeAndroid {
t.Fatalf("client type = %s, want %s", got, ClientTypeAndroid)
func TestClientTypeDetectionUsesStrongEvidenceBeforeAPIID(t *testing.T) {
tests := []struct {
name string
info ClientInfo
want ClientType
}{
{
name: "iOS 12.8 simulator",
info: ClientInfo{APIID: 1, DeviceModel: "iPhone Simulator", SystemVersion: "26.5", AppVersion: "12.8 (10000)", LangPack: "ios"},
want: ClientTypeIOS,
},
{
name: "restored iOS without lang pack",
info: ClientInfo{DeviceModel: "iPhone 16 Pro", SystemVersion: "18.5", Type: ClientTypeUnknown},
want: ClientTypeIOS,
},
{
name: "TWeb WebK",
info: ClientInfo{APIID: 1025907, DeviceModel: "Mozilla/5.0 Chrome/138.0", SystemVersion: "Win32", LangPack: "webk"},
want: ClientTypeTWeb,
},
{
name: "telegram-tt WebA",
info: ClientInfo{APIID: 2040, DeviceModel: "Mozilla/5.0 Chrome/150.0", SystemVersion: "Windows", AppVersion: "12.0.32 A", LangPack: "weba"},
want: ClientTypeTelegramTT,
},
{
name: "TWeb borrowed TDesktop API id",
info: ClientInfo{APIID: 2040, DeviceModel: "Mozilla/5.0 AppleWebKit/537.36", SystemVersion: "Win32", Type: ClientTypeTDesktop},
want: ClientTypeTWeb,
},
{
name: "mobile TWeb is not native Android",
info: ClientInfo{APIID: 2040, DeviceModel: "Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36", LangPack: "webk"},
want: ClientTypeTWeb,
},
{
name: "Android SDK version",
info: ClientInfo{DeviceModel: "GooglePixel 9a", SystemVersion: "SDK 36", AppVersion: "12.7.3 (67509) pbeta"},
want: ClientTypeAndroid,
},
{name: "DrKLO API fallback", info: ClientInfo{APIID: 4}, want: ClientTypeAndroid},
{name: "TDesktop API fallback", info: ClientInfo{APIID: 2040}, want: ClientTypeTDesktop},
{name: "official iOS API fallback", info: ClientInfo{APIID: 8}, want: ClientTypeIOS},
{name: "official TWeb API fallback", info: ClientInfo{APIID: 2496}, want: ClientTypeTWeb},
{name: "macOS lang pack", info: ClientInfo{LangPack: "macos"}, want: ClientTypeMacOS},
{name: "stored known type", info: ClientInfo{Type: ClientTypeIOS}, want: ClientTypeIOS},
{
name: "gotd remains unknown",
info: ClientInfo{DeviceModel: "go1.26.2", SystemVersion: "windows", AppVersion: "v0.144.0"},
want: ClientTypeUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeClientInfo(tt.info).ClientType(); got != tt.want {
t.Fatalf("client type = %s, want %s", got, tt.want)
}
})
}
}
info = normalizeClientInfo(ClientInfo{
DeviceModel: "go1.26.2",
SystemVersion: "windows",
AppVersion: "v0.144.0",
func TestRestoredUnknownClientInfoIsNotReclassifiedFromHistoricalFields(t *testing.T) {
info := restoreClientInfo(ClientInfo{
APIID: 2040,
DeviceModel: "Mozilla/5.0 AppleWebKit/537.36",
SystemVersion: "Windows",
AppVersion: "12.0.32 A",
Type: ClientTypeUnknown,
})
if got := info.ClientType(); got != ClientTypeUnknown {
t.Fatalf("gotd test client type = %s, want %s", got, ClientTypeUnknown)
t.Fatalf("restored historical client type = %s, want unknown until fresh initConnection", got)
}
info = normalizeClientInfo(ClientInfo{APIID: 4})
if got := info.ClientType(); got != ClientTypeAndroid {
t.Fatalf("DrKLO api_id=4 client type = %s, want %s", got, ClientTypeAndroid)
info = restoreClientInfo(ClientInfo{
DeviceModel: "GooglePixel 9a",
SystemVersion: "SDK 36",
Type: ClientTypeUnknown,
})
if got := info.ClientType(); got != ClientTypeUnknown {
t.Fatalf("restored historical Android client type = %s, want unknown until fresh initConnection", got)
}
info = normalizeClientInfo(ClientInfo{APIID: 2040})
if got := info.ClientType(); got != ClientTypeTDesktop {
t.Fatalf("TDesktop api_id=2040 client type = %s, want %s", got, ClientTypeTDesktop)
info = restoreClientInfo(ClientInfo{
DeviceModel: "Mozilla/5.0 AppleWebKit/537.36",
AppVersion: "12.0.32 A",
Type: ClientTypeTelegramTT,
})
if got := info.ClientType(); got != ClientTypeTelegramTT {
t.Fatalf("restored persisted telegram-tt client type = %s, want %s", got, ClientTypeTelegramTT)
}
}
@ -1092,6 +1201,7 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
{name: "help.getTermsOfServiceUpdate", req: &tg.HelpGetTermsOfServiceUpdateRequest{}},
{name: "help.getPremiumPromo", req: &tg.HelpGetPremiumPromoRequest{}},
{name: "help.getInviteText", req: &tg.HelpGetInviteTextRequest{}},
{name: "help.getAppUpdate", req: &tg.HelpGetAppUpdateRequest{}},
{name: "auth.initPasskeyLogin", req: &tg.AuthInitPasskeyLoginRequest{APIID: 4, APIHash: "test"}},
{name: "account.getPassword", req: &tg.AccountGetPasswordRequest{}},
{name: "account.getNotifySettings", req: &tg.AccountGetNotifySettingsRequest{Peer: &tg.InputNotifyUsers{}}},
@ -1124,6 +1234,7 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
{name: "account.getSavedRingtones", req: &tg.AccountGetSavedRingtonesRequest{}},
{name: "account.resetPassword", req: &tg.AccountResetPasswordRequest{}},
{name: "account.updateStatus", req: &tg.AccountUpdateStatusRequest{Offline: true}},
{name: "account.updateDeviceLocked", req: &tg.AccountUpdateDeviceLockedRequest{Period: 60}},
{name: "payments.getStarsTopupOptions", req: &tg.PaymentsGetStarsTopupOptionsRequest{}},
{name: "payments.getStarsStatus", req: &tg.PaymentsGetStarsStatusRequest{Peer: &tg.InputPeerSelf{}}},
{name: "updates.getDifference", req: &tg.UpdatesGetDifferenceRequest{}},
@ -1155,6 +1266,7 @@ func TestTDesktopStartupRPCsEncode(t *testing.T) {
{name: "messages.getPeerSettings", req: &tg.MessagesGetPeerSettingsRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},
{name: "messages.setChatWallPaper", req: &tg.MessagesSetChatWallPaperRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Wallpaper: &tg.InputWallPaperNoFile{ID: 930000000000000000}}},
{name: "messages.getHistory", req: &tg.MessagesGetHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Limit: 20}},
{name: "messages.getRecentLocations", req: &tg.MessagesGetRecentLocationsRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Limit: 20}},
{name: "messages.readHistory", req: &tg.MessagesReadHistoryRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}}},
{name: "messages.search", req: &tg.MessagesSearchRequest{Peer: &tg.InputPeerUser{UserID: domain.OfficialSystemUserID, AccessHash: domain.OfficialSystemUser().AccessHash}, Filter: &tg.InputMessagesFilterEmpty{}, Limit: 20}},
{name: "messages.searchGlobal", req: &tg.MessagesSearchGlobalRequest{Q: "login", Filter: &tg.InputMessagesFilterEmpty{}, OffsetPeer: &tg.InputPeerEmpty{}, Limit: 20}},

View file

@ -2,6 +2,8 @@ package rpc
import (
"context"
"encoding/json"
"hash/fnv"
"github.com/gotd/td/tg"
"go.uber.org/zap"
@ -24,15 +26,16 @@ func (r *Router) onMessagesGetAvailableReactions(ctx context.Context, hash int)
if len(reactions) == 0 {
return tdesktop.AvailableReactions(hash), nil
}
catalogHash := availableReactionsHash(reactions)
if hash == catalogHash {
return &tg.MessagesAvailableReactionsNotModified{}, nil
}
docs, err := r.deps.Files.GetDocuments(ctx, reactionDocumentIDs(reactions))
if err != nil {
return nil, internalErr()
}
return tgAvailableReactions(reactions, documentsByID(docs), catalogHash), nil
docByID := documentsByID(docs)
catalogHash := availableReactionsHash(reactions, docByID)
if hash == catalogHash {
return &tg.MessagesAvailableReactionsNotModified{}, nil
}
return tgAvailableReactions(reactions, docByID, catalogHash), nil
}
// onMessagesGetAvailableEffects 返回消息发送特效目录(全局静态,seed 进内存)。镜像
@ -457,24 +460,34 @@ func documentsByID(docs []domain.Document) map[int64]domain.Document {
return m
}
// availableReactionsHash 用 reaction 的核心字段算稳定 hash(供 *NotModified 缓存判定)。
func availableReactionsHash(reactions []domain.AvailableReaction) int {
values := make([]int64, 0, len(reactions)*10)
// availableReactionsHash covers every domain field that contributes to the TL
// response, including the embedded documents. A repaired file reference,
// attribute, thumbnail, title, or emoji must invalidate clients which cached an
// older response; hashing only document ids leaves those clients permanently on
// stale resources after a seed repair.
func availableReactionsHash(reactions []domain.AvailableReaction, docByID map[int64]domain.Document) int {
h := fnv.New32a()
for _, r := range reactions {
values = append(values,
int64(len([]rune(r.Reaction))),
boolHashValue(r.Inactive),
boolHashValue(r.Premium),
r.StaticIconID,
r.AppearAnimationID,
r.SelectAnimationID,
r.ActivateAnimationID,
r.EffectAnimationID,
r.AroundAnimationID,
r.CenterIconID,
)
encoded, _ := json.Marshal(r)
_, _ = h.Write(encoded)
_, _ = h.Write([]byte{0xff})
for _, id := range r.DocumentIDs() {
doc, ok := docByID[id]
if !ok {
// Missing mandatory/optional documents are part of the response as
// documentEmpty{id}; keep that state hashable as well.
doc.ID = id
}
encoded, _ = json.Marshal(doc)
_, _ = h.Write(encoded)
_, _ = h.Write([]byte{0xfe})
}
}
return int(tdesktopCountHash(values) & 0x7fffffff)
sum := int(h.Sum32() & 0x7fffffff)
if sum == 0 {
return 1
}
return sum
}
func stickerSetsCatalogHash(sets []domain.StickerSet) int64 {

View file

@ -565,6 +565,42 @@ func TestMessagesGetAvailableReactionsNotModified(t *testing.T) {
}
}
func TestMessagesGetAvailableReactionsDocumentRepairInvalidatesHash(t *testing.T) {
ctx := context.Background()
reactions := []domain.AvailableReaction{{
Reaction: "❤", Title: "Heart", StaticIconID: 101, AppearAnimationID: 102,
SelectAnimationID: 103, ActivateAnimationID: 104, EffectAnimationID: 105,
}}
docs := map[int64]domain.Document{}
for _, id := range reactions[0].DocumentIDs() {
docs[id] = domain.Document{ID: id, AccessHash: id + 1000, DCID: 2, MimeType: "application/x-tgsticker", Size: 10}
}
files := &fakeFiles{reactions: reactions, docs: docs}
r := &Router{deps: Deps{Files: files}}
first, err := r.onMessagesGetAvailableReactions(ctx, 0)
if err != nil {
t.Fatalf("first getAvailableReactions: %v", err)
}
oldHash := first.(*tg.MessagesAvailableReactions).Hash
repaired := docs[103]
repaired.FileReference = []byte("repaired-reference")
repaired.Size = 11
docs[103] = repaired
second, err := r.onMessagesGetAvailableReactions(ctx, oldHash)
if err != nil {
t.Fatalf("getAvailableReactions after repair: %v", err)
}
full, ok := second.(*tg.MessagesAvailableReactions)
if !ok {
t.Fatalf("after document repair = %T, want full response", second)
}
if full.Hash == oldHash {
t.Fatalf("document repair kept hash %d", oldHash)
}
}
func TestTGDocumentCompactsCachedThumbToDownloadableSize(t *testing.T) {
doc := tgDocument(domain.Document{
ID: 100,

View file

@ -16,9 +16,9 @@ func (r *Router) registerUpdates(d *tg.ServerDispatcher) {
// onUpdatesGetState 处理 updates.getState。TDesktop 与 DrKLO 的启动路径把它当作
// 「从当前快照开始同步」的显式 baseline:返回账号当前连续水位并推进该设备 observed。
// 对无法识别的客户端仍返回同一 current state,但不把尚未被客户端带回的服务端快照
// 记成 observed;这保留 durable difference tail,避免把 TDesktop/DrKLO 的兼容例外
// 扩散成所有客户端都能跨过未实际确认事件的 retention 后门。
// 对尚未审计 baseline 语义的客户端仍返回同一 current state,但不把尚未被客户端带回
// 的服务端快照记成 observed;这保留 durable difference tail,避免把 TDesktop/DrKLO
// 的兼容例外扩散成所有客户端都能跨过未实际确认事件的 retention 后门。
func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error) {
id, _ := AuthKeyIDFrom(ctx)
userID, _, err := r.currentUserID(ctx)
@ -35,7 +35,7 @@ func (r *Router) onUpdatesGetState(ctx context.Context) (*tg.UpdatesState, error
} else {
st, err = r.deps.Updates.CurrentState(ctx, userID)
if err == nil {
r.log.Warn("updates.getState returned current snapshot without advancing observed baseline for unknown client",
r.log.Warn("updates.getState returned current snapshot without advancing observed baseline for client without audited baseline policy",
r.contextLogFields(ctx)...)
}
}