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

@ -56,7 +56,7 @@ codebase.
| ✅ | Bots and mini apps | Bot service foundations, callbacks, inline helpers, webview/mini-app paths, a minimal Bot API gateway for libraries such as `python-telegram-bot`, persistent `getUpdates` delivery, and demo tools. |
| ✅ | Calls and live streams | Private call signaling foundations, group call state, RTMP live streaming, scheduled video chats, channel `join_as`, SFU/TURN building blocks, liveness, and expiry workers. |
| ✅ | Admin and operations | Admin API/UI backend, PostgreSQL migrations, Redis volatile state, retention workers, pprof/debug hooks, and load-test helpers. |
| ✅ | Desktop, Android, and Web focus | Telegram Desktop is the primary target, with Android and Web compatibility paths actively covered by the same server. |
| ✅ | Desktop, Android, iOS, and Web focus | Telegram Desktop is the primary target, with Android, iOS, and Web compatibility paths actively covered by the same server. |
Some items are compatibility-first or experimental, but they are real open
server code, not hidden product-only features. The next step is making these

View file

@ -53,7 +53,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
| ✅ | Bots 与 Mini Apps | bot 服务基础、callbacks、inline helpers、webview/mini-app 路径、适配 `python-telegram-bot` 等库的最小 Bot API gateway、持久化 `getUpdates` 投递队列和 demo 工具。 |
| ✅ | 通话与直播 | 私聊通话信令基础、group call 状态、RTMP live stream、定时视频通话、频道 `join_as` 身份、SFU/TURN building blocks、liveness 与 expiry worker。 |
| ✅ | 管理与运维 | Admin API/UI backend、PostgreSQL migrations、Redis 易失态、retention workers、pprof/debug hooks、load-test helpers。 |
| ✅ | Desktop、Android 与 Web 兼容 | Telegram Desktop 是第一目标Android 与 Web 兼容路径也由同一套 server 持续覆盖。 |
| ✅ | Desktop、Android、iOS 与 Web 兼容 | Telegram Desktop 是第一目标Android、iOS 与 Web 兼容路径也由同一套 server 持续覆盖。 |
其中一部分能力仍是兼容优先或实验性质,但它们都是真实开放的 server 代码,不是隐藏的产品版功能。下一步希望大家一起把这些路径打磨得更稳、更快、更好用。

View file

@ -0,0 +1,2 @@
DELETE FROM message_box_media WHERE category = 10;
DELETE FROM channel_message_media WHERE category = 10;

View file

@ -0,0 +1,15 @@
-- messages.getRecentLocations 复用共享媒体 seek index避免在 peer 历史上扫描 JSONB。
-- category=10 是服务端内部 geo_live 类别,不暴露给普通 messages.search filter。
INSERT INTO message_box_media (owner_user_id, box_id, peer_id, category, message_date)
SELECT owner_user_id, box_id, peer_id, 10, message_date
FROM message_boxes
WHERE NOT deleted
AND media ->> 'kind' = 'geo_live'
ON CONFLICT DO NOTHING;
INSERT INTO channel_message_media (channel_id, id, category, message_date)
SELECT channel_id, id, 10, message_date
FROM channel_messages
WHERE NOT deleted
AND media ->> 'kind' = 'geo_live'
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,2 @@
-- No historical client identity data was changed.
SELECT 1;

View file

@ -0,0 +1,3 @@
-- Reserved after local development applied version 85. Client identity must
-- converge only from a fresh initConnection; historical rows are not inferred.
SELECT 1;

View file

@ -1126,14 +1126,20 @@ func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return nil
}
return s.authKeys.UpdateClientInfo(ctx, authKeyID, store.AuthKeyClientInfo{
if err := s.authKeys.UpdateClientInfo(ctx, authKeyID, store.AuthKeyClientInfo{
Layer: info.Layer,
DeviceModel: info.DeviceModel,
Platform: info.Platform,
SystemVersion: info.SystemVersion,
APIID: info.APIID,
AppVersion: info.AppVersion,
})
}); err != nil {
return err
}
if s.auths != nil {
return s.auths.UpdateClientInfo(ctx, authKeyID, info)
}
return nil
}
func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) {

View file

@ -71,6 +71,48 @@ func TestBindTempAuthKeyValidatesEncryptedMessage(t *testing.T) {
}
}
func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
ctx := context.Background()
keys := memory.NewAuthKeyStore()
authz := memory.NewAuthorizationStore()
key := testAuthKey(0x23)
saveAuthKey(t, keys, key)
if err := authz.Bind(ctx, domain.Authorization{
AuthKeyID: key.ID,
UserID: 1780243200,
Platform: "unknown",
}); err != nil {
t.Fatalf("bind authorization: %v", err)
}
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), keys, nil, "12345")
info := domain.AuthKeyClientInfo{
Layer: 227,
DeviceModel: "iPhone Simulator",
Platform: "ios",
SystemVersion: "26.5",
APIID: 1,
AppVersion: "12.8 (10000)",
}
if err := svc.UpdateAuthKeyClientInfo(ctx, key.ID, info); err != nil {
t.Fatalf("update auth key client info: %v", err)
}
storedKey, found, err := keys.Get(ctx, key.ID)
if err != nil || !found {
t.Fatalf("get auth key: found=%v err=%v", found, err)
}
storedAuth, found, err := authz.ByAuthKey(ctx, key.ID)
if err != nil || !found {
t.Fatalf("get authorization: found=%v err=%v", found, err)
}
if storedKey.Platform != "ios" || storedAuth.Platform != "ios" ||
storedKey.DeviceModel != info.DeviceModel || storedAuth.DeviceModel != info.DeviceModel ||
storedKey.AppVersion != info.AppVersion || storedAuth.AppVersion != info.AppVersion {
t.Fatalf("client metadata did not converge: key=%+v authorization=%+v", storedKey, storedAuth)
}
}
func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
ctx := context.Background()
tempBindings := memory.NewTempAuthKeyBindingStore()

View file

@ -0,0 +1,14 @@
package ios
import "github.com/gotd/td/tg"
// NoAppUpdate is the bounded answer used until telesrv has an application
// release catalog. It makes iOS keep its installed build and retry on its
// normal schedule instead of retrying a failed RPC.
func NoAppUpdate() tg.HelpAppUpdateClass {
return &tg.HelpNoAppUpdate{}
}
// DeviceLockedUpdated acknowledges the client-side autolock report. telesrv
// currently has no push-notification privacy state to persist from this hint.
func DeviceLockedUpdated() bool { return true }

View file

@ -18,6 +18,9 @@ const (
MediaCategoryRoundVideo MediaCategory = 7 // document 含 video 属性且 round_message=true视频消息
MediaCategoryURL MediaCategory = 8 // 文本含 url/text_url/email 实体或 messageMediaWebPage
MediaCategoryPoll MediaCategory = 9 // media.kind=poll
// MediaCategoryGeoLive 是 messages.getRecentLocations 的内部索引类别。
// 它不映射到普通共享媒体筛选器,只用于按 peer 有界读取 live-location 消息。
MediaCategoryGeoLive MediaCategory = 10 // media.kind=geo_live
)
// MediaCategoryCounts 是共享媒体索引按基础类别聚合出的精确计数。
@ -84,6 +87,8 @@ func ClassifyMediaCategories(media *MessageMedia, entities []MessageEntity) []Me
}
case MessageMediaKindPoll:
add(MediaCategoryPoll)
case MessageMediaKindGeoLive:
add(MediaCategoryGeoLive)
case MessageMediaKindWebPage:
add(MediaCategoryURL)
}

View file

@ -22,6 +22,7 @@ func TestClassifyMediaCategories(t *testing.T) {
{"nil media no entities", nil, nil, []MediaCategory{}},
{"photo", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, nil, []MediaCategory{MediaCategoryPhoto}},
{"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}},
{"live location", &MessageMedia{Kind: MessageMediaKindGeoLive, GeoLive: &MessageGeoLive{}}, nil, []MediaCategory{MediaCategoryGeoLive}},
{"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}},
{"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}},
{"gif animation", &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{MimeType: "video/mp4", Attributes: []DocumentAttribute{{Kind: DocAttrAnimated}, {Kind: DocAttrVideo, W: 320, H: 240, Duration: 1}}}}, nil, []MediaCategory{MediaCategoryGif}},

View file

@ -1,6 +1,7 @@
package mtprotoedge
import (
"bytes"
"context"
crand "crypto/rand"
"encoding/hex"
@ -69,6 +70,17 @@ type serverExchangeCompat struct {
commitKey func(context.Context, exchange.ServerExchangeResult) error
}
const pqInnerDataTempTypeID uint32 = 0x3c6a84d4
// compatPQInnerData is the normalized handshake input accepted at the MTProto
// edge. gotd v0.158.0 does not generate p_q_inner_data_temp#3c6a84d4, which is
// still emitted by Telegram-iOS for PFS temporary auth keys.
type compatPQInnerData struct {
Data mt.PQInnerData
Temp bool
ExpiresIn int
}
func (s serverExchangeCompat) run(ctx context.Context) (exchange.ServerExchangeResult, error) {
wrapKeyNotFound := func(err error) error {
return exchangeError(codec.CodeAuthKeyNotFound, err)
@ -119,28 +131,36 @@ SendResPQ:
var innerData mt.PQInnerData
{
if dhParams.DH.Nonce != req.Nonce {
return exchange.ServerExchangeResult{}, gofaster.New("req_DH_params nonce does not match req_pq")
}
if dhParams.DH.ServerNonce != serverNonce {
return exchange.ServerExchangeResult{}, gofaster.New("req_DH_params server_nonce does not match resPQ")
}
if dhParams.DH.PublicKeyFingerprint != s.key.Fingerprint() {
return exchange.ServerExchangeResult{}, gofaster.New("req_DH_params public key fingerprint does not match server key")
}
r, err := crypto.DecodeRSAPad(dhParams.DH.EncryptedData, s.key.RSA)
if err != nil {
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
}
b.ResetTo(r)
d, err := mt.DecodePQInnerData(b)
d, generated, err := decodeCompatPQInnerData(b)
if err != nil {
return exchange.ServerExchangeResult{}, err
}
if err := s.validatePQInnerDataDC(d); err != nil {
if generated != nil {
if err := s.validatePQInnerDataDC(generated); err != nil {
return exchange.ServerExchangeResult{}, err
}
}
if err := validatePQInnerData(d, req, dhParams.DH, serverNonce, pq); err != nil {
return exchange.ServerExchangeResult{}, err
}
innerData = mt.PQInnerData{
Pq: d.GetPq(),
P: d.GetP(),
Q: d.GetQ(),
Nonce: d.GetNonce(),
ServerNonce: d.GetServerNonce(),
NewNonce: d.GetNewNonce(),
}
innerData = d.Data
}
dhPrime, err := s.rng.DhPrime()
@ -188,6 +208,12 @@ SendResPQ:
return exchange.ServerExchangeResult{}, err
}
s.log.Debug("Received client SetClientDHParamsRequest")
if clientDhParams.Nonce != req.Nonce {
return exchange.ServerExchangeResult{}, gofaster.New("set_client_DH_params nonce does not match req_pq")
}
if clientDhParams.ServerNonce != serverNonce {
return exchange.ServerExchangeResult{}, gofaster.New("set_client_DH_params server_nonce does not match resPQ")
}
decrypted, err := crypto.DecryptExchangeAnswer(clientDhParams.EncryptedData, key, iv)
if err != nil {
@ -200,6 +226,12 @@ SendResPQ:
if err := clientInnerData.Decode(b); err != nil {
return exchange.ServerExchangeResult{}, wrapKeyNotFound(err)
}
if clientInnerData.Nonce != req.Nonce {
return exchange.ServerExchangeResult{}, gofaster.New("client_DH_inner_data nonce does not match req_pq")
}
if clientInnerData.ServerNonce != serverNonce {
return exchange.ServerExchangeResult{}, gofaster.New("client_DH_inner_data server_nonce does not match resPQ")
}
gB := big.NewInt(0).SetBytes(clientInnerData.GB)
var authKey crypto.Key
@ -235,6 +267,68 @@ SendResPQ:
return serverResult, nil
}
func decodeCompatPQInnerData(b *bin.Buffer) (compatPQInnerData, mt.PQInnerDataClass, error) {
id, err := b.PeekID()
if err != nil {
return compatPQInnerData{}, nil, err
}
if id == pqInnerDataTempTypeID {
if err := b.ConsumeID(pqInnerDataTempTypeID); err != nil {
return compatPQInnerData{}, nil, err
}
var data mt.PQInnerData
if err := data.DecodeBare(b); err != nil {
return compatPQInnerData{}, nil, fmt.Errorf("decode p_q_inner_data_temp: %w", err)
}
expiresIn, err := b.Int()
if err != nil {
return compatPQInnerData{}, nil, fmt.Errorf("decode p_q_inner_data_temp expires_in: %w", err)
}
return compatPQInnerData{Data: data, Temp: true, ExpiresIn: expiresIn}, nil, nil
}
generated, err := mt.DecodePQInnerData(b)
if err != nil {
return compatPQInnerData{}, nil, err
}
result := compatPQInnerData{Data: mt.PQInnerData{
Pq: generated.GetPq(),
P: generated.GetP(),
Q: generated.GetQ(),
Nonce: generated.GetNonce(),
ServerNonce: generated.GetServerNonce(),
NewNonce: generated.GetNewNonce(),
}}
if temp, ok := generated.(*mt.PQInnerDataTempDC); ok {
result.Temp = true
result.ExpiresIn = temp.ExpiresIn
}
return result, generated, nil
}
func validatePQInnerData(d compatPQInnerData, req compatReqPQ, dh mt.ReqDHParamsRequest, serverNonce bin.Int128, pq *big.Int) error {
if d.Data.Nonce != req.Nonce {
return gofaster.New("p_q_inner_data nonce does not match req_pq")
}
if d.Data.ServerNonce != serverNonce {
return gofaster.New("p_q_inner_data server_nonce does not match resPQ")
}
if !bytes.Equal(d.Data.Pq, pq.Bytes()) {
return gofaster.New("p_q_inner_data pq does not match resPQ")
}
if !bytes.Equal(d.Data.P, dh.P) || !bytes.Equal(d.Data.Q, dh.Q) {
return gofaster.New("p_q_inner_data factors do not match req_DH_params")
}
product := new(big.Int).Mul(new(big.Int).SetBytes(d.Data.P), new(big.Int).SetBytes(d.Data.Q))
if product.Cmp(pq) != 0 {
return gofaster.New("p_q_inner_data factors do not multiply to pq")
}
if d.Temp && d.ExpiresIn <= 0 {
return gofaster.New("p_q_inner_data temporary key expires_in must be positive")
}
return nil
}
func (s serverExchangeCompat) validatePQInnerDataDC(d mt.PQInnerDataClass) error {
switch innerDataDC := d.(type) {
case *mt.PQInnerDataDC:

View file

@ -1,11 +1,13 @@
package mtprotoedge
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"encoding/binary"
"errors"
"math/big"
"net"
"testing"
"time"
@ -366,6 +368,99 @@ func TestKeyExchangeRejectsWrongNegativeTempDC(t *testing.T) {
}
}
func TestDecodeCompatPQInnerDataTemp(t *testing.T) {
want := mt.PQInnerData{
Pq: []byte{0x0f},
P: []byte{0x03},
Q: []byte{0x05},
Nonce: bin.Int128{1, 2, 3},
ServerNonce: bin.Int128{4, 5, 6},
NewNonce: bin.Int256{7, 8, 9},
}
b := new(bin.Buffer)
b.PutID(pqInnerDataTempTypeID)
if err := want.EncodeBare(b); err != nil {
t.Fatalf("encode bare: %v", err)
}
b.PutInt(86400)
got, generated, err := decodeCompatPQInnerData(b)
if err != nil {
t.Fatalf("decode: %v", err)
}
if generated != nil {
t.Fatalf("generated class = %T, want nil for iOS temp compatibility type", generated)
}
if !got.Temp || got.ExpiresIn != 86400 {
t.Fatalf("temp metadata = (%v, %d), want (true, 86400)", got.Temp, got.ExpiresIn)
}
if !bytes.Equal(got.Data.Pq, want.Pq) || !bytes.Equal(got.Data.P, want.P) || !bytes.Equal(got.Data.Q, want.Q) ||
got.Data.Nonce != want.Nonce || got.Data.ServerNonce != want.ServerNonce || got.Data.NewNonce != want.NewNonce {
t.Fatalf("decoded data = %+v, want %+v", got.Data, want)
}
}
func TestDecodeCompatPQInnerDataTempRejectsTruncatedData(t *testing.T) {
b := new(bin.Buffer)
b.PutID(pqInnerDataTempTypeID)
b.PutBytes([]byte{0x0f})
if _, _, err := decodeCompatPQInnerData(b); err == nil {
t.Fatal("truncated p_q_inner_data_temp decoded successfully")
}
}
func TestValidatePQInnerDataInvariants(t *testing.T) {
nonce := bin.Int128{1}
serverNonce := bin.Int128{2}
pq := big.NewInt(15)
valid := compatPQInnerData{
Data: mt.PQInnerData{
Pq: pq.Bytes(),
P: []byte{3},
Q: []byte{5},
Nonce: nonce,
ServerNonce: serverNonce,
},
Temp: true,
ExpiresIn: 86400,
}
req := compatReqPQ{Nonce: nonce}
dh := mt.ReqDHParamsRequest{P: []byte{3}, Q: []byte{5}}
if err := validatePQInnerData(valid, req, dh, serverNonce, pq); err != nil {
t.Fatalf("valid inner data: %v", err)
}
tests := []struct {
name string
mutate func(*compatPQInnerData)
}{
{name: "nonce", mutate: func(d *compatPQInnerData) { d.Data.Nonce = bin.Int128{9} }},
{name: "server nonce", mutate: func(d *compatPQInnerData) { d.Data.ServerNonce = bin.Int128{9} }},
{name: "pq", mutate: func(d *compatPQInnerData) { d.Data.Pq = []byte{21} }},
{name: "outer factors", mutate: func(d *compatPQInnerData) { d.Data.P = []byte{5} }},
{name: "factor product", mutate: func(d *compatPQInnerData) { d.Data.P = []byte{2}; dh.P = []byte{2} }},
{name: "expiry", mutate: func(d *compatPQInnerData) { d.ExpiresIn = 0 }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
candidate := valid
candidate.Data.Pq = bytes.Clone(valid.Data.Pq)
candidate.Data.P = bytes.Clone(valid.Data.P)
candidate.Data.Q = bytes.Clone(valid.Data.Q)
localDH := dh
if tt.name == "factor product" {
candidate.Data.P = []byte{2}
localDH.P = []byte{2}
} else {
tt.mutate(&candidate)
}
if err := validatePQInnerData(candidate, req, localDH, serverNonce, pq); err == nil {
t.Fatal("invalid inner data validated successfully")
}
})
}
}
func TestKeyExchangeIgnoresUnencryptedMsgsAck(t *testing.T) {
const dc = 2

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

@ -41,6 +41,10 @@ const (
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.typeResolved = true
return info
}
func (info ClientInfo) ClientType() ClientType {
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
}
return int(tdesktopCountHash(values) & 0x7fffffff)
encoded, _ = json.Marshal(doc)
_, _ = h.Write(encoded)
_, _ = h.Write([]byte{0xfe})
}
}
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)...)
}
}

View file

@ -11,6 +11,8 @@ type AuthorizationStore interface {
Bind(ctx context.Context, a domain.Authorization) error
ByAuthKey(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error)
UpdateLayer(ctx context.Context, authKeyID [8]byte, layer int) error
// UpdateClientInfo 合并更新已绑定授权的客户端元数据,使设备列表与 auth key 协商事实一致。
UpdateClientInfo(ctx context.Context, authKeyID [8]byte, info domain.AuthKeyClientInfo) error
ListByUser(ctx context.Context, userID int64) ([]domain.Authorization, error)
Delete(ctx context.Context, authKeyID [8]byte) error
DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)

View file

@ -172,6 +172,34 @@ func (s *AuthorizationStore) UpdateLayer(_ context.Context, id [8]byte, layer in
return nil
}
func (s *AuthorizationStore) UpdateClientInfo(_ context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
s.mu.Lock()
if a, ok := s.m[id]; ok {
if info.Layer > 0 {
a.Layer = info.Layer
}
if info.DeviceModel != "" {
a.DeviceModel = info.DeviceModel
}
if info.Platform != "" {
a.Platform = info.Platform
}
if info.SystemVersion != "" {
a.SystemVersion = info.SystemVersion
}
if info.APIID != 0 {
a.APIID = info.APIID
}
if info.AppVersion != "" {
a.AppVersion = info.AppVersion
}
a.ActiveAt = time.Now()
s.m[id] = a
}
s.mu.Unlock()
return nil
}
func (s *AuthorizationStore) MarkPasswordPassed(_ context.Context, id [8]byte) error {
s.mu.Lock()
if a, ok := s.m[id]; ok {

View file

@ -183,6 +183,25 @@ UPDATE authorizations SET layer = $2, active_at = now() WHERE auth_key_id = $1`,
return nil
}
func (s *AuthorizationStore) UpdateClientInfo(ctx context.Context, id [8]byte, info domain.AuthKeyClientInfo) error {
if _, err := s.db.Exec(ctx, `
UPDATE authorizations SET
layer = CASE WHEN $2 > 0 THEN $2 ELSE layer END,
device_model = CASE WHEN $3 <> '' THEN $3 ELSE device_model END,
platform = CASE WHEN $4 <> '' THEN $4 ELSE platform END,
system_version = CASE WHEN $5 <> '' THEN $5 ELSE system_version END,
api_id = CASE WHEN $6 <> 0 THEN $6 ELSE api_id END,
app_version = CASE WHEN $7 <> '' THEN $7 ELSE app_version END,
active_at = now()
WHERE auth_key_id = $1`,
authKeyIDToInt64(id), int32(info.Layer), info.DeviceModel, info.Platform,
info.SystemVersion, int32(info.APIID), info.AppVersion,
); err != nil {
return fmt.Errorf("update authorization client info: %w", err)
}
return nil
}
// MarkPasswordPassed 在两步验证通过后清除 password_pending使 auth_key 转为完全授权。
func (s *AuthorizationStore) MarkPasswordPassed(ctx context.Context, id [8]byte) error {
if _, err := s.db.Exec(ctx, `

View file

@ -110,6 +110,49 @@ func TestAuthorizationStoreRevokeByUserExceptDeletesOnlyRevokedKeysPostgres(t *t
assertRevokeTestMissingAuthKey(t, ctx, keys, tempForTwo)
}
func TestAuthorizationStoreUpdateClientInfoMergesPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
userID := createRevokeTestUser(t, ctx, pool, "client-info")
id := revokeTestAuthKeyID(0xb1)
keys := NewAuthKeyStore(pool)
auths := NewAuthorizationStore(pool)
saveRevokeTestAuthKey(t, ctx, keys, id)
if err := auths.Bind(ctx, domain.Authorization{
AuthKeyID: id,
UserID: userID,
Hash: 9201,
Platform: "unknown",
DeviceModel: "legacy",
IP: "127.0.0.1",
}); err != nil {
t.Fatalf("bind authorization: %v", err)
}
if err := auths.UpdateClientInfo(ctx, id, domain.AuthKeyClientInfo{
Layer: 227,
DeviceModel: "iPhone Simulator",
Platform: "ios",
SystemVersion: "26.5",
APIID: 1,
AppVersion: "12.8 (10000)",
}); err != nil {
t.Fatalf("update client info: %v", err)
}
// Empty/zero values are a partial update and must not erase strong metadata.
if err := auths.UpdateClientInfo(ctx, id, domain.AuthKeyClientInfo{AppVersion: "12.8.1"}); err != nil {
t.Fatalf("partial update client info: %v", err)
}
got, found, err := auths.ByAuthKey(ctx, id)
if err != nil || !found {
t.Fatalf("get authorization: found=%v err=%v", found, err)
}
if got.Layer != 227 || got.DeviceModel != "iPhone Simulator" || got.Platform != "ios" ||
got.SystemVersion != "26.5" || got.APIID != 1 || got.AppVersion != "12.8.1" {
t.Fatalf("merged client info = %+v", got)
}
}
func createRevokeTestUser(t *testing.T, ctx context.Context, db *pgxpool.Pool, suffix string) int64 {
t.Helper()
phone := fmt.Sprintf("+1555%09d", time.Now().UnixNano()%1_000_000_000)