feat: sync iOS compatibility support
This commit is contained in:
parent
1f646ef024
commit
50803a604c
32 changed files with 871 additions and 78 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 代码,不是隐藏的产品版功能。下一步希望大家一起把这些路径打磨得更稳、更快、更好用。
|
||||
|
||||
|
|
|
|||
2
deploy/migrations/0084_geolive_media_index.down.sql
Normal file
2
deploy/migrations/0084_geolive_media_index.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DELETE FROM message_box_media WHERE category = 10;
|
||||
DELETE FROM channel_message_media WHERE category = 10;
|
||||
15
deploy/migrations/0084_geolive_media_index.up.sql
Normal file
15
deploy/migrations/0084_geolive_media_index.up.sql
Normal 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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- No historical client identity data was changed.
|
||||
SELECT 1;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
14
internal/compat/ios/minimal.go
Normal file
14
internal/compat/ios/minimal.go
Normal 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 }
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
57
internal/rpc/messages_recent_locations.go
Normal file
57
internal/rpc/messages_recent_locations.go
Normal 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
|
||||
}
|
||||
61
internal/rpc/messages_recent_locations_test.go
Normal file
61
internal/rpc/messages_recent_locations_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 != "" ||
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)...)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, `
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue