From 50803a604c502ad78fbd6a906050d47eadbc584c Mon Sep 17 00:00:00 2001 From: A Date: Mon, 13 Jul 2026 00:58:57 +0800 Subject: [PATCH] feat: sync iOS compatibility support --- README.md | 2 +- README.zh-CN.md | 2 +- .../0084_geolive_media_index.down.sql | 2 + .../0084_geolive_media_index.up.sql | 15 ++ ..._reserved_no_client_type_backfill.down.sql | 2 + ...85_reserved_no_client_type_backfill.up.sql | 3 + internal/app/auth/service.go | 10 +- internal/app/auth/service_test.go | 42 +++++ internal/compat/ios/minimal.go | 14 ++ internal/domain/media_category.go | 5 + internal/domain/media_category_test.go | 1 + internal/mtprotoedge/exchange_compat.go | 114 +++++++++++-- internal/mtprotoedge/exchange_test.go | 95 +++++++++++ internal/rpc/account.go | 7 + internal/rpc/channels_settings_rpc_test.go | 34 +++- internal/rpc/context.go | 74 +++++++-- internal/rpc/errors.go | 1 + internal/rpc/help.go | 7 + internal/rpc/langpack.go | 8 + internal/rpc/messages_forum.go | 39 ++++- internal/rpc/messages_recent_locations.go | 57 +++++++ .../rpc/messages_recent_locations_test.go | 61 +++++++ internal/rpc/messages_register.go | 1 + internal/rpc/router.go | 12 +- internal/rpc/router_dispatch_test.go | 150 +++++++++++++++--- internal/rpc/stickers.go | 55 ++++--- internal/rpc/stickers_test.go | 36 +++++ internal/rpc/updates.go | 8 +- internal/store/authorization.go | 2 + internal/store/memory/auth.go | 28 ++++ internal/store/postgres/authorization.go | 19 +++ .../authorization_revoke_integration_test.go | 43 +++++ 32 files changed, 871 insertions(+), 78 deletions(-) create mode 100644 deploy/migrations/0084_geolive_media_index.down.sql create mode 100644 deploy/migrations/0084_geolive_media_index.up.sql create mode 100644 deploy/migrations/0085_reserved_no_client_type_backfill.down.sql create mode 100644 deploy/migrations/0085_reserved_no_client_type_backfill.up.sql create mode 100644 internal/compat/ios/minimal.go create mode 100644 internal/rpc/messages_recent_locations.go create mode 100644 internal/rpc/messages_recent_locations_test.go diff --git a/README.md b/README.md index 069e8721..f52704fe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 0b62c42b..a2941cdc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 代码,不是隐藏的产品版功能。下一步希望大家一起把这些路径打磨得更稳、更快、更好用。 diff --git a/deploy/migrations/0084_geolive_media_index.down.sql b/deploy/migrations/0084_geolive_media_index.down.sql new file mode 100644 index 00000000..339ce3ff --- /dev/null +++ b/deploy/migrations/0084_geolive_media_index.down.sql @@ -0,0 +1,2 @@ +DELETE FROM message_box_media WHERE category = 10; +DELETE FROM channel_message_media WHERE category = 10; diff --git a/deploy/migrations/0084_geolive_media_index.up.sql b/deploy/migrations/0084_geolive_media_index.up.sql new file mode 100644 index 00000000..677cc264 --- /dev/null +++ b/deploy/migrations/0084_geolive_media_index.up.sql @@ -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; diff --git a/deploy/migrations/0085_reserved_no_client_type_backfill.down.sql b/deploy/migrations/0085_reserved_no_client_type_backfill.down.sql new file mode 100644 index 00000000..56ac3792 --- /dev/null +++ b/deploy/migrations/0085_reserved_no_client_type_backfill.down.sql @@ -0,0 +1,2 @@ +-- No historical client identity data was changed. +SELECT 1; diff --git a/deploy/migrations/0085_reserved_no_client_type_backfill.up.sql b/deploy/migrations/0085_reserved_no_client_type_backfill.up.sql new file mode 100644 index 00000000..6d32cd9c --- /dev/null +++ b/deploy/migrations/0085_reserved_no_client_type_backfill.up.sql @@ -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; diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index d332fb70..5c4fd2ad 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -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) { diff --git a/internal/app/auth/service_test.go b/internal/app/auth/service_test.go index 73a8b562..f8016554 100644 --- a/internal/app/auth/service_test.go +++ b/internal/app/auth/service_test.go @@ -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() diff --git a/internal/compat/ios/minimal.go b/internal/compat/ios/minimal.go new file mode 100644 index 00000000..b0f6410d --- /dev/null +++ b/internal/compat/ios/minimal.go @@ -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 } diff --git a/internal/domain/media_category.go b/internal/domain/media_category.go index 0ad0eefe..e2098816 100644 --- a/internal/domain/media_category.go +++ b/internal/domain/media_category.go @@ -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) } diff --git a/internal/domain/media_category_test.go b/internal/domain/media_category_test.go index e67c3728..d89e5ea6 100644 --- a/internal/domain/media_category_test.go +++ b/internal/domain/media_category_test.go @@ -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}}, diff --git a/internal/mtprotoedge/exchange_compat.go b/internal/mtprotoedge/exchange_compat.go index 24b16730..cb0c26fe 100644 --- a/internal/mtprotoedge/exchange_compat.go +++ b/internal/mtprotoedge/exchange_compat.go @@ -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: diff --git a/internal/mtprotoedge/exchange_test.go b/internal/mtprotoedge/exchange_test.go index 3d9825a8..a6fc7d4d 100644 --- a/internal/mtprotoedge/exchange_test.go +++ b/internal/mtprotoedge/exchange_test.go @@ -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 diff --git a/internal/rpc/account.go b/internal/rpc/account.go index 315ab21d..3dcaef45 100644 --- a/internal/rpc/account.go +++ b/internal/rpc/account.go @@ -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) diff --git a/internal/rpc/channels_settings_rpc_test.go b/internal/rpc/channels_settings_rpc_test.go index 60320e42..6c7289cb 100644 --- a/internal/rpc/channels_settings_rpc_test.go +++ b/internal/rpc/channels_settings_rpc_test.go @@ -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) diff --git a/internal/rpc/context.go b/internal/rpc/context.go index a86de795..0049f1e3 100644 --- a/internal/rpc/context.go +++ b/internal/rpc/context.go @@ -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: diff --git a/internal/rpc/errors.go b/internal/rpc/errors.go index 59cd2f1d..5ca60f2e 100644 --- a/internal/rpc/errors.go +++ b/internal/rpc/errors.go @@ -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") } diff --git a/internal/rpc/help.go b/internal/rpc/help.go index 8f6ebe37..e5b3229d 100644 --- a/internal/rpc/help.go +++ b/internal/rpc/help.go @@ -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 diff --git a/internal/rpc/langpack.go b/internal/rpc/langpack.go index 09dc677a..6ad7de33 100644 --- a/internal/rpc/langpack.go +++ b/internal/rpc/langpack.go @@ -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") { diff --git a/internal/rpc/messages_forum.go b/internal/rpc/messages_forum.go index 12fc2ab6..d61204f6 100644 --- a/internal/rpc/messages_forum.go +++ b/internal/rpc/messages_forum.go @@ -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) { diff --git a/internal/rpc/messages_recent_locations.go b/internal/rpc/messages_recent_locations.go new file mode 100644 index 00000000..48c440be --- /dev/null +++ b/internal/rpc/messages_recent_locations.go @@ -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 +} diff --git a/internal/rpc/messages_recent_locations_test.go b/internal/rpc/messages_recent_locations_test.go new file mode 100644 index 00000000..7569c375 --- /dev/null +++ b/internal/rpc/messages_recent_locations_test.go @@ -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) + } +} diff --git a/internal/rpc/messages_register.go b/internal/rpc/messages_register.go index aa487ff7..b487d74a 100644 --- a/internal/rpc/messages_register.go +++ b/internal/rpc/messages_register.go @@ -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) diff --git a/internal/rpc/router.go b/internal/rpc/router.go index f2dde113..ef3d4dad 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -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 != "" || diff --git a/internal/rpc/router_dispatch_test.go b/internal/rpc/router_dispatch_test.go index 4f4c9970..c07bfb57 100644 --- a/internal/rpc/router_dispatch_test.go +++ b/internal/rpc/router_dispatch_test.go @@ -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}}, diff --git a/internal/rpc/stickers.go b/internal/rpc/stickers.go index 79b03673..80a52519 100644 --- a/internal/rpc/stickers.go +++ b/internal/rpc/stickers.go @@ -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 { diff --git a/internal/rpc/stickers_test.go b/internal/rpc/stickers_test.go index 632694a0..cd8442e2 100644 --- a/internal/rpc/stickers_test.go +++ b/internal/rpc/stickers_test.go @@ -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, diff --git a/internal/rpc/updates.go b/internal/rpc/updates.go index f28e81d0..90e48621 100644 --- a/internal/rpc/updates.go +++ b/internal/rpc/updates.go @@ -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)...) } } diff --git a/internal/store/authorization.go b/internal/store/authorization.go index 08e35552..4bf6a552 100644 --- a/internal/store/authorization.go +++ b/internal/store/authorization.go @@ -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) diff --git a/internal/store/memory/auth.go b/internal/store/memory/auth.go index 63fd7d80..94b5b4e0 100644 --- a/internal/store/memory/auth.go +++ b/internal/store/memory/auth.go @@ -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 { diff --git a/internal/store/postgres/authorization.go b/internal/store/postgres/authorization.go index 43481592..cf4ed2ff 100644 --- a/internal/store/postgres/authorization.go +++ b/internal/store/postgres/authorization.go @@ -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, ` diff --git a/internal/store/postgres/authorization_revoke_integration_test.go b/internal/store/postgres/authorization_revoke_integration_test.go index 93f054f9..d2d2708a 100644 --- a/internal/store/postgres/authorization_revoke_integration_test.go +++ b/internal/store/postgres/authorization_revoke_integration_test.go @@ -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)