feat: sync multilayer td integration

This commit is contained in:
A 2026-07-15 13:32:06 +08:00
parent 20a310f6ca
commit 766c5db992
491 changed files with 26235 additions and 35340 deletions

View file

@ -27,6 +27,14 @@ TELESRV_MTPROTO_RPC_TIMEOUT=30s
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256 TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192 TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912 TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
# In-process 331s rpc_result ownership budgets: global >= auth >= session.
TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES=262144
TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES=67108864
TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES=32768
TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES=33554432
TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES=16384
TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES=16777216
TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH=2048
# Process-wide in-flight transport wire + decrypted plaintext reservation. # Process-wide in-flight transport wire + decrypted plaintext reservation.
TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES=536870912 TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES=536870912
# Per-connection outbound mailboxes (normal/control) and process-wide resend pending bodies. # Per-connection outbound mailboxes (normal/control) and process-wide resend pending bodies.

View file

@ -5,6 +5,11 @@ written in Go. It is a Telegram-like backend for real client compatibility,
self-hosted chat experiments, protocol research, and long-running work toward a self-hosted chat experiments, protocol research, and long-running work toward a
practical community server. practical community server.
The protocol stack is built on the published
[`github.com/iamxvbaba/td`](https://github.com/iamxvbaba/td) module
(`v1.0.0`), using a canonical Layer 228 schema with exact Layer 225-228
compatibility profiles.
If you are looking for a **Telegram server**, **MTProto server**, If you are looking for a **Telegram server**, **MTProto server**,
**Telegram backend**, **Telegram clone server**, or **self-hosted **Telegram backend**, **Telegram clone server**, or **self-hosted
Telegram-like chat server**, this repository is the server-side implementation Telegram-like chat server**, this repository is the server-side implementation
@ -40,7 +45,7 @@ codebase.
| Status | Feature | What works today | | Status | Feature | What works today |
|---|---|---| |---|---|---|
| ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, and layer compatibility helpers. | | ✅ | MTProto server edge | TCP transport, RSA key exchange, auth keys, encrypted sessions, salts, ack/resend, bad messages, RPC dispatch, canonical Layer 228, and exact Layer 225-228 compatibility profiles. |
| ✅ | Login and accounts | Development login code, sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, email/passkey-oriented paths. | | ✅ | Login and accounts | Development login code, sign-in, sign-up, log-out, authorizations, account settings, SRP/password state, email/passkey-oriented paths. |
| ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. | | ✅ | Users and contacts | User profiles, usernames, profile photos, contact import/search, blocked/privacy state, presence, and last-seen style status. |
| ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. | | ✅ | Dialogs and sync | Dialog list, pinned dialogs, manual unread, folders/filters, drafts, read boundaries, durable updates, online fan-out, and offline difference recovery. |
@ -214,7 +219,8 @@ minimal protocol patch.
Current Telegram Desktop baseline: Current Telegram Desktop baseline:
- Telegram Desktop commit: `9caf32dffc90ddd9bb08ad5777b865f729fa167b` - Telegram Desktop commit: `9caf32dffc90ddd9bb08ad5777b865f729fa167b`
- TL layer: 227 - Canonical TL layer: 228
- Exact compatibility profiles: Layer 225-228
- Local DC: `127.0.0.1:2398`, DC id `2` - Local DC: `127.0.0.1:2398`, DC id `2`
After `gramsrv` generates `data/server_rsa.pem`, export the matching public key: After `gramsrv` generates `data/server_rsa.pem`, export the matching public key:

View file

@ -4,6 +4,11 @@
它是一个 Telegram-like backend面向真实客户端兼容、自建聊天实验、协议研究 它是一个 Telegram-like backend面向真实客户端兼容、自建聊天实验、协议研究
以及一条长期可演进的社区 server 路线。 以及一条长期可演进的社区 server 路线。
协议栈基于已发布的
[`github.com/iamxvbaba/td`](https://github.com/iamxvbaba/td) module
`v1.0.0`),使用 canonical Layer 228 schema并提供 exact Layer 225-228
compatibility profiles。
如果你正在搜索 **Telegram server 实现**、**MTProto server 实现**、 如果你正在搜索 **Telegram server 实现**、**MTProto server 实现**、
**Telegram 后端**、**Telegram clone server**、**自建 Telegram-like 聊天服务器** **Telegram 后端**、**Telegram clone server**、**自建 Telegram-like 聊天服务器**
这个仓库就是可以运行、研究和共同优化的 server 侧实现。 这个仓库就是可以运行、研究和共同优化的 server 侧实现。
@ -37,7 +42,7 @@ https://github.com/user-attachments/assets/25e651dc-a022-4d60-8b9b-ca3e8bfe216c
| 状态 | 功能 | 当前已实现 | | 状态 | 功能 | 当前已实现 |
|---|---|---| |---|---|---|
| ✅ | MTProto server 接入层 | TCP transport、RSA key exchange、auth key、加密 session、salt、ack/resend、bad message、RPC dispatch、layer 兼容辅助。 | | ✅ | MTProto server 接入层 | TCP transport、RSA key exchange、auth key、加密 session、salt、ack/resend、bad message、RPC dispatch、canonical Layer 228以及 exact Layer 225-228 compatibility profiles。 |
| ✅ | 登录与账号 | 开发验证码登录、sign-in、sign-up、log-out、授权设备、账号设置、SRP/password 状态、email/passkey 相关路径。 | | ✅ | 登录与账号 | 开发验证码登录、sign-in、sign-up、log-out、授权设备、账号设置、SRP/password 状态、email/passkey 相关路径。 |
| ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 | | ✅ | 用户与联系人 | 用户资料、username、头像、联系人导入/搜索、block/privacy 状态、presence、last seen。 |
| ✅ | 会话与同步 | dialog list、置顶、手动未读、folders/filters、草稿、read boundary、durable updates、在线 fan-out、离线 difference 恢复。 | | ✅ | 会话与同步 | dialog list、置顶、手动未读、folders/filters、草稿、read boundary、durable updates、在线 fan-out、离线 difference 恢复。 |
@ -199,7 +204,8 @@ TELESRV_PUBLIC_APP_NAME=YourApp
当前 Telegram Desktop 基线: 当前 Telegram Desktop 基线:
- Telegram Desktop commit`9caf32dffc90ddd9bb08ad5777b865f729fa167b` - Telegram Desktop commit`9caf32dffc90ddd9bb08ad5777b865f729fa167b`
- TL layer227 - Canonical TL layer228
- Exact compatibility profilesLayer 225-228
- 本地 DC`127.0.0.1:2398`DC id `2` - 本地 DC`127.0.0.1:2398`DC id `2`
`gramsrv` 生成 `data/server_rsa.pem` 后,导出匹配的公钥: `gramsrv` 生成 `data/server_rsa.pem` 后,导出匹配的公钥:

View file

@ -1,5 +1,5 @@
// Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图, // Command appearancefetch 从官方 Telegram 拉取墙纸 + 聊天主题,下载文档/缩略图,
// 生成 telesrv 外观 seed(default_appearance_seed.json + default_wallpapers/{documents,thumbs/m}/*.dat)。 // 生成 telesrv 外观 seed(Default_appearance_seed.json + Default_wallpapers/{documents,thumbs/m}/*.dat)。
// 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。 // 复用 internal/seed/appearance 的结构体保证 schema 完全一致。peer_colors 从现有 JSON 沿用。
// //
// 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。 // 需登录(墙纸/主题接口非免登)。api 凭据用 TDesktop 开源公开的 id/hash。
@ -21,10 +21,10 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/auth" "github.com/iamxvbaba/td/telegram/auth"
"github.com/gotd/td/telegram/downloader" "github.com/iamxvbaba/td/telegram/downloader"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/seed/appearance" "telesrv/internal/seed/appearance"
) )
@ -114,8 +114,8 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
} }
api := client.API() api := client.API()
docsDir := filepath.Join(outDir, "default_wallpapers", "documents") docsDir := filepath.Join(outDir, "Default_wallpapers", "documents")
thumbsDir := filepath.Join(outDir, "default_wallpapers", "thumbs", "m") thumbsDir := filepath.Join(outDir, "Default_wallpapers", "thumbs", "m")
if err := os.MkdirAll(docsDir, 0o755); err != nil { if err := os.MkdirAll(docsDir, 0o755); err != nil {
return err return err
} }
@ -169,7 +169,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
return appearance.Document{}, err return appearance.Document{}, err
} }
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
out.Path = "default_wallpapers/documents/" + name out.Path = "Default_wallpapers/documents/" + name
out.SHA256 = hex.EncodeToString(sum[:]) out.SHA256 = hex.EncodeToString(sum[:])
// "m" 缩略图 // "m" 缩略图
for _, t := range doc.Thumbs { for _, t := range doc.Thumbs {
@ -187,7 +187,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
tsum := sha256.Sum256(tdata) tsum := sha256.Sum256(tdata)
out.Thumbs = append(out.Thumbs, appearance.PhotoSize{ out.Thumbs = append(out.Thumbs, appearance.PhotoSize{
Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size, Kind: "size", Type: "m", W: ps.W, H: ps.H, Size: ps.Size,
Path: "default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]), Path: "Default_wallpapers/thumbs/m/" + name, SHA256: hex.EncodeToString(tsum[:]),
}) })
break break
} }
@ -390,7 +390,7 @@ func doFetch(ctx context.Context, client *telegram.Client, outDir string) error
if err != nil { if err != nil {
return err return err
} }
jsonPath := filepath.Join(outDir, "default_appearance_seed.json") jsonPath := filepath.Join(outDir, "Default_appearance_seed.json")
if err := os.WriteFile(jsonPath, out, 0o644); err != nil { if err := os.WriteFile(jsonPath, out, 0o644); err != nil {
return err return err
} }

View file

@ -29,14 +29,14 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -50,15 +50,15 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/telegram/uploader" "github.com/iamxvbaba/td/telegram/uploader"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -19,8 +19,8 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
const ( const (

View file

@ -24,14 +24,14 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -3,6 +3,7 @@
// 开源公开的 id/hash。 // 开源公开的 id/hash。
// //
// 用法: // 用法:
//
// langpackfetch languages [pack] 列出某 pack(默认 android)的可用语言 // langpackfetch languages [pack] 列出某 pack(默认 android)的可用语言
// langpackfetch <out_dir> <langCode> [pack...] 拉取语言包(默认 packs = android ios macos) // langpackfetch <out_dir> <langCode> [pack...] 拉取语言包(默认 packs = android ios macos)
package main package main
@ -16,8 +17,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
const ( const (

View file

@ -28,14 +28,14 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -27,9 +27,9 @@ import (
"strings" "strings"
"time" "time"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/downloader" "github.com/iamxvbaba/td/telegram/downloader"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
const ( const (

View file

@ -6,7 +6,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
func TestSpecToInputSystemSets(t *testing.T) { func TestSpecToInputSystemSets(t *testing.T) {

View file

@ -14,16 +14,16 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
tdcrypto "github.com/gotd/td/crypto" tdcrypto "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mtproxy" "github.com/iamxvbaba/td/mtproxy"
"github.com/gotd/td/mtproxy/obfuscator" "github.com/iamxvbaba/td/mtproxy/obfuscator"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/session" "github.com/iamxvbaba/td/session"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
) )

View file

@ -1,4 +1,4 @@
// Command telesrv 是基于 gotd/td 的 Telegram-like server第一兼容目标Telegram Desktop // Command telesrv 是基于 iamxvbaba/td 的 Telegram-like server第一兼容目标Telegram Desktop
package main package main
import ( import (
@ -18,9 +18,9 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
adminapp "telesrv/internal/admin" adminapp "telesrv/internal/admin"
"telesrv/internal/adminapi" "telesrv/internal/adminapi"
@ -297,7 +297,8 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("parse listen port %q: %w", portStr, err) return fmt.Errorf("parse listen port %q: %w", portStr, err)
} }
// tg.Layer 来自 gotd/td v0.158.0Layer 227与目标 TDesktop 基线对齐。 // tg.Layer 由当前导入的 canonical schema 生成;纳入未来 Layer 后无需
// 在 telesrv 另维护一份常量。
logger.Info("telesrv 启动", logger.Info("telesrv 启动",
zap.String("listen", cfg.ListenAddr), zap.String("listen", cfg.ListenAddr),
zap.Int("dc", cfg.DC), zap.Int("dc", cfg.DC),
@ -473,6 +474,7 @@ func run(logger *zap.Logger) error {
cfg.RetentionBatch, cfg.RetentionBatch,
).WithDispatchOutboxPoisonPolicy(cfg.OutboxPoisonRetention, cfg.OutboxPoisonCleanupInterval). ).WithDispatchOutboxPoisonPolicy(cfg.OutboxPoisonRetention, cfg.OutboxPoisonCleanupInterval).
WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention). WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).
WithAuthKeySessionLayerRetention(authKeyStore).
WithLoginCodeDeliveryRetention(messageStore). WithLoginCodeDeliveryRetention(messageStore).
WithUserUpdateRetention(updateEventStore). WithUserUpdateRetention(updateEventStore).
WithChannelUpdateRetention(channelStore). WithChannelUpdateRetention(channelStore).
@ -721,6 +723,7 @@ func run(logger *zap.Logger) error {
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries, TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
}, rpc.Deps{ }, rpc.Deps{
Auth: authService, Auth: authService,
AuthKeySessionLayers: authKeyStore,
Account: accountService, Account: accountService,
Privacy: privacyService, Privacy: privacyService,
Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken)), Help: help.NewService(helpStore, helpStore, help.WithMapboxToken(cfg.MapboxToken)),
@ -833,7 +836,7 @@ func run(logger *zap.Logger) error {
Logger: logger.Named("mtprotoedge"), Logger: logger.Named("mtprotoedge"),
DC: cfg.DC, DC: cfg.DC,
RSAKey: rsaKey, RSAKey: rsaKey,
RPC: router, LayerRPC: router,
AuthKeys: authKeyStore, AuthKeys: authKeyStore,
ActiveSessions: activeSessions, ActiveSessions: activeSessions,
ObfuscatedTCP: true, ObfuscatedTCP: true,
@ -848,6 +851,13 @@ func run(logger *zap.Logger) error {
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers, RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks, RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes, RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
RPCResultCacheMaxEntries: cfg.MTProtoRPCResultCacheMaxEntries,
RPCResultCacheMaxBytes: cfg.MTProtoRPCResultCacheMaxBytes,
RPCResultCacheAuthMaxEntries: cfg.MTProtoRPCResultCacheAuthMaxEntries,
RPCResultCacheAuthMaxBytes: cfg.MTProtoRPCResultCacheAuthMaxBytes,
RPCResultCacheSessionMaxEntries: cfg.MTProtoRPCResultCacheSessionMaxEntries,
RPCResultCacheSessionMaxBytes: cfg.MTProtoRPCResultCacheSessionMaxBytes,
RPCResultPendingPerAuth: cfg.MTProtoRPCResultPendingPerAuth,
InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes, InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes,
OutboundQueueSize: cfg.MTProtoOutboundQueueSize, OutboundQueueSize: cfg.MTProtoOutboundQueueSize,
OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize, OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize,

View file

@ -0,0 +1,7 @@
DROP TABLE IF EXISTS public.auth_key_session_layers;
ALTER TABLE public.auth_keys
DROP CONSTRAINT IF EXISTS auth_keys_layer_observation_id_valid,
DROP COLUMN IF EXISTS layer_observation_id;
DROP SEQUENCE IF EXISTS public.auth_key_layer_observation_seq;

View file

@ -0,0 +1,38 @@
-- Short-lived, durable per-session invokeWithLayer watermark. The in-process
-- exact-profile registry prevents ordinary replay rollback, while this table
-- closes the durable commit/restart and cross-instance replacement-connection
-- window for client msg_ids that are still fresh enough to be accepted by
-- MTProto. It does not broadcast profile changes into an already-live remote
-- physical connection; that connection remains frozen until its own selector.
CREATE SEQUENCE public.auth_key_layer_observation_seq AS bigint;
ALTER TABLE public.auth_keys
ADD COLUMN layer_observation_id bigint NOT NULL DEFAULT 0,
ADD CONSTRAINT auth_keys_layer_observation_id_valid
CHECK (layer_observation_id >= 0);
CREATE TABLE public.auth_key_session_layers (
raw_auth_key_id bigint NOT NULL,
session_id bigint NOT NULL,
layer integer NOT NULL,
msg_id bigint NOT NULL,
observation_id bigint NOT NULL,
expires_at timestamptz NOT NULL,
PRIMARY KEY (raw_auth_key_id, session_id),
CONSTRAINT auth_key_session_layers_auth_key_fkey
FOREIGN KEY (raw_auth_key_id)
REFERENCES public.auth_keys(auth_key_id)
ON DELETE CASCADE,
CONSTRAINT auth_key_session_layers_layer_valid CHECK (layer > 0),
CONSTRAINT auth_key_session_layers_msg_id_valid
CHECK (
msg_id > 0
AND msg_id % 4 = 0
AND (msg_id & 4294967295) <> 0
),
CONSTRAINT auth_key_session_layers_observation_id_valid
CHECK (observation_id > 0)
);
CREATE INDEX auth_key_session_layers_expiry_idx
ON public.auth_key_session_layers (expires_at, raw_auth_key_id, session_id);

View file

@ -33,6 +33,13 @@ This document describes every setting loaded by `internal/config`. Defaults and
| `TELESRV_MTPROTO_RPC_GLOBAL_WORKERS` | int / `256` | Shared fair-scheduler worker count. | | `TELESRV_MTPROTO_RPC_GLOBAL_WORKERS` | int / `256` | Shared fair-scheduler worker count. |
| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS` | int / `8192` | Process-wide scheduled/in-flight RPC task cap. | | `TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS` | int / `8192` | Process-wide scheduled/in-flight RPC task cap. |
| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Process-wide queued/in-flight RPC request-body budget. | | `TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Process-wide queued/in-flight RPC request-body budget. |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES` | int / `262144` | Global ownership entries for pending owners, completed results, and tombstones during the in-process 331-second replay window. |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES` | int64 bytes / `67108864` | Global retained-byte budget. Owner admission reserves one byte; Put transfers it to a body or tombstone. Must be at least `16775168`. |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES` | int / `32768` | Per raw-auth-key ownership entries; charged together with global and session scopes. |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES` | int64 bytes / `33554432` | Per raw-auth-key retained bytes. Limits must satisfy `global >= auth >= session`. |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES` | int / `16384` | Per `raw auth key + session_id` ownership entries. |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES` | int64 bytes / `16777216` | Per `raw auth key + session_id` retained bytes; large enough for one legal outbound body. |
| `TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH` | int / `2048` | Additional active-owner cap per raw auth key; no greater than global pending tasks or auth entries. |
| `TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Process-wide reservation for transport wire bytes plus maximum decrypted plaintext, acquired before payload allocation. | | `TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | Process-wide reservation for transport wire bytes plus maximum decrypted plaintext, acquired before payload allocation. |
| `TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE` | int / `128` | Per-connection normal outbound mailbox capacity. | | `TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE` | int / `128` | Per-connection normal outbound mailbox capacity. |
| `TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE` | int / `32` | Per-connection control-message mailbox capacity. | | `TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE` | int / `32` | Per-connection control-message mailbox capacity. |

View file

@ -32,7 +32,14 @@
| `TELESRV_MTPROTO_RPC_TIMEOUT` | duration / `30s` | 调度后 RPC handler 的端到端超时。 | | `TELESRV_MTPROTO_RPC_TIMEOUT` | duration / `30s` | 调度后 RPC handler 的端到端超时。 |
| `TELESRV_MTPROTO_RPC_GLOBAL_WORKERS` | int / `256` | 共享公平调度器 worker 数。 | | `TELESRV_MTPROTO_RPC_GLOBAL_WORKERS` | int / `256` | 共享公平调度器 worker 数。 |
| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS` | int / `8192` | 进程级排队与执行中的 RPC task 上限。 | | `TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS` | int / `8192` | 进程级排队与执行中的 RPC task 上限。 |
| `TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | 进程级排队/执行中 RPC request body 字节预算。 | | `TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES` | int64 charge bytes / `536870912` | 进程级已预留/排队/执行中 RPC 内存 charge 预算legacy 等于 copied bodyexact 是 typed decode 前按 wire 与生成对象放大计算的保守 materialization charge不代表可并发接收同等大小的 wire body。 |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES` | int / `262144` | 331 秒进程内重放窗口中pending owner、completed `rpc_result` 与容量 tombstone 的全局 ownership 条目上限。owner 执行前先占 1 条,转 completed 时不重复计数。 |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES` | int64 bytes / `67108864` | 上述 ownership 的全局 retained-byte 上限owner 先占 1 bytePut 转移为真实 body 或 1-byte identity tombstone。不得低于 `16775168`(单条合法 outbound body 上限)。 |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES` | int / `32768` | 单 raw auth key 的 ownership 条目上限与全局、session 层同时计费,防一个 auth key 吃满进程缓存。必须 `global >= auth >= session`。 |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES` | int64 bytes / `33554432` | 单 raw auth key retained-byte 上限;必须不低于单条合法 outbound body且满足 byte 层级关系。 |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES` | int / `16384` | 单 `raw auth key + session_id` ownership 条目上限;不同 session 不共享该局部额度。 |
| `TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES` | int64 bytes / `16777216` | 单 `raw auth key + session_id` retained-byte 上限;默认略高于单条合法 outbound body确保空预算时任一合法结果可完整进入。 |
| `TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH` | int / `2048` | 单 raw auth key 的 active pending owner 附加上限;必须不大于 `RPC_GLOBAL_MAX_TASKS` 和 auth entry 上限。Put/Abort 都立即归还此 active 额度。 |
| `TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | transport wire 与最大解密明文的进程级在途预算,在分配 payload 前预留。 | | `TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES` | int64 bytes / `536870912` | transport wire 与最大解密明文的进程级在途预算,在分配 payload 前预留。 |
| `TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE` | int / `128` | 单连接普通 outbound mailbox 容量。 | | `TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE` | int / `128` | 单连接普通 outbound mailbox 容量。 |
| `TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE` | int / `32` | 单连接控制消息 mailbox 容量。 | | `TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE` | int / `32` | 单连接控制消息 mailbox 容量。 |

3
go.mod
View file

@ -8,8 +8,7 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang-migrate/migrate/v4 v4.19.1
github.com/gotd/ige v0.2.2 github.com/gotd/ige v0.2.2
github.com/gotd/log/logzap v0.1.1 github.com/gotd/log/logzap v0.1.1
github.com/gotd/td v0.159.0 github.com/iamxvbaba/td v1.0.0
github.com/gotd/tl v0.4.0
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa
github.com/jackc/pgx/v5 v5.9.2 github.com/jackc/pgx/v5 v5.9.2
github.com/pion/datachannel v1.6.2 github.com/pion/datachannel v1.6.2

12
go.sum
View file

@ -73,15 +73,13 @@ github.com/gotd/log/logzap v0.1.1 h1:O6l7d8HUbODe+UMcrM47eXYDwdJ6RNmpQejLjrlcEIQ
github.com/gotd/log/logzap v0.1.1/go.mod h1:5ObZkITbfhbsBOLzBkzmMk9QxXc0eNQpimau7zRL+Y8= github.com/gotd/log/logzap v0.1.1/go.mod h1:5ObZkITbfhbsBOLzBkzmMk9QxXc0eNQpimau7zRL+Y8=
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ= github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ= github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/td v0.159.0 h1:kKXt2NLmfIOgebbFS34FSlZbdydaf5fsta+nP69nP+w=
github.com/gotd/td v0.159.0/go.mod h1:rdZ2NfOMUViApJa3EvYJ94GAxENjCB0b98tJbfS9NCc=
github.com/gotd/tl v0.4.0 h1:8k2z0drujiPyhpLDa9PRm/yU1Gwlfn3iUzeInPiXwMA=
github.com/gotd/tl v0.4.0/go.mod h1:CMIcjPWFS4qxxJ+1Ce7U/ilbtPrkoVo/t8uhN5Y/D7c=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/iamxvbaba/td v1.0.0 h1:dgwQPxZCRWEZIDY9qNeZgIe6jvF7KAdI9NM1mxhnl6w=
github.com/iamxvbaba/td v1.0.0/go.mod h1:oG/fu7sqGC7NznoBD8f3fmTy9NFR42+DMNtdCPStX04=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@ -148,7 +146,6 @@ github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ= github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ=
github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -160,13 +157,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=

View file

@ -1,5 +1,5 @@
// Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。 // Package auth 是认证应用服务:验证码、登录、注册、注销,以及 auth key 与 user 的绑定。
// 第一阶段用开发固定验证码2FA 配置由 account 服务持久化查询。 // 第一阶段用开发固定验证码2FA 配置由 account 服务持久化查询。
// //
// 输入输出在 RPC 边界使用 gotd/td/tg 类型,本包内部只用 internal/domain 模型。 // 输入输出在 RPC 边界使用 iamxvbaba/td/tg 类型,本包内部只用 internal/domain 模型。
package auth package auth

View file

@ -14,8 +14,8 @@ import (
"unicode/utf8" "unicode/utf8"
"github.com/gotd/ige" "github.com/gotd/ige"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
mtcrypto "github.com/gotd/td/crypto" mtcrypto "github.com/iamxvbaba/td/crypto"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/mail" "telesrv/internal/mail"
@ -188,6 +188,9 @@ func WithLoginEmail(opts LoginEmailOptions) Option {
// NewService 创建登录服务。fixedCode 为开发固定验证码。 // NewService 创建登录服务。fixedCode 为开发固定验证码。
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service { func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6} s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute, codeMaxAttempts: 5, loginEmailCodeLength: 6}
if linker, ok := auths.(store.AuthKeyAuthorityLinker); ok && authKeys != nil {
linker.LinkAuthKeyAuthority(authKeys)
}
for _, opt := range opts { for _, opt := range opts {
opt(s) opt(s)
} }
@ -1102,13 +1105,6 @@ func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.
return s.auths.ByAuthKey(ctx, authKeyID) return s.auths.ByAuthKey(ctx, authKeyID)
} }
func (s *Service) UpdateAuthorizationLayer(ctx context.Context, authKeyID [8]byte, layer int) error {
if s == nil || s.auths == nil || authKeyID == ([8]byte{}) || layer <= 0 {
return nil
}
return s.auths.UpdateLayer(ctx, authKeyID, layer)
}
func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error) { func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (domain.AuthKeyClientInfo, bool, error) {
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) { if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
return domain.AuthKeyClientInfo{}, false, nil return domain.AuthKeyClientInfo{}, false, nil
@ -1119,6 +1115,7 @@ func (s *Service) AuthKeyClientInfo(ctx context.Context, authKeyID [8]byte) (dom
} }
info := domain.AuthKeyClientInfo{ info := domain.AuthKeyClientInfo{
Layer: key.Layer, Layer: key.Layer,
LayerObservationID: key.LayerObservationID,
DeviceModel: key.DeviceModel, DeviceModel: key.DeviceModel,
Platform: key.Platform, Platform: key.Platform,
SystemVersion: key.SystemVersion, SystemVersion: key.SystemVersion,
@ -1147,7 +1144,15 @@ func (s *Service) UpdateAuthKeyClientInfo(ctx context.Context, authKeyID [8]byte
return err return err
} }
if s.auths != nil { if s.auths != nil {
return s.auths.UpdateClientInfo(ctx, authKeyID, info) // Layer is an ordered protocol fact. Its authorization-table mirror is
// advanced atomically by the durable Layer evidence/bind transactions.
// A generic metadata update is deliberately two-store and can race such
// a transaction, so it must never write an older Layer after the primary
// auth_keys row has already advanced.
authorizationInfo := info
authorizationInfo.Layer = 0
authorizationInfo.LayerObservationID = 0
return s.auths.UpdateClientInfo(ctx, authKeyID, authorizationInfo)
} }
return nil return nil
} }

View file

@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
mtcrypto "github.com/gotd/td/crypto" mtcrypto "github.com/iamxvbaba/td/crypto"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/store" "telesrv/internal/store"
@ -135,7 +135,7 @@ func TestBindTempAuthKeyClassifiesExpiryWithoutDestroyingPermanentKey(t *testing
} }
} }
func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) { func TestUpdateAuthKeyClientInfoConvergesMemoryAuthorizationToAuthKeyLayerAuthority(t *testing.T) {
ctx := context.Background() ctx := context.Background()
keys := memory.NewAuthKeyStore() keys := memory.NewAuthKeyStore()
authz := memory.NewAuthorizationStore() authz := memory.NewAuthorizationStore()
@ -144,6 +144,7 @@ func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
if err := authz.Bind(ctx, domain.Authorization{ if err := authz.Bind(ctx, domain.Authorization{
AuthKeyID: key.ID, AuthKeyID: key.ID,
UserID: 1780243200, UserID: 1780243200,
Layer: 220,
Platform: "unknown", Platform: "unknown",
}); err != nil { }); err != nil {
t.Fatalf("bind authorization: %v", err) t.Fatalf("bind authorization: %v", err)
@ -171,6 +172,7 @@ func TestUpdateAuthKeyClientInfoConvergesAuthorizationMetadata(t *testing.T) {
t.Fatalf("get authorization: found=%v err=%v", found, err) t.Fatalf("get authorization: found=%v err=%v", found, err)
} }
if storedKey.Platform != "ios" || storedAuth.Platform != "ios" || if storedKey.Platform != "ios" || storedAuth.Platform != "ios" ||
storedKey.Layer != info.Layer || storedAuth.Layer != info.Layer ||
storedKey.DeviceModel != info.DeviceModel || storedAuth.DeviceModel != info.DeviceModel || storedKey.DeviceModel != info.DeviceModel || storedAuth.DeviceModel != info.DeviceModel ||
storedKey.AppVersion != info.AppVersion || storedAuth.AppVersion != info.AppVersion { storedKey.AppVersion != info.AppVersion || storedAuth.AppVersion != info.AppVersion {
t.Fatalf("client metadata did not converge: key=%+v authorization=%+v", storedKey, storedAuth) t.Fatalf("client metadata did not converge: key=%+v authorization=%+v", storedKey, storedAuth)

View file

@ -10,7 +10,7 @@ import (
"image" "image"
"image/color" "image/color"
stddraw "image/draw" stddraw "image/draw"
_ "image/jpeg" // 注册 jpeg DecodeConfig用于读取上传头像/图片尺寸 "image/jpeg"
"image/png" "image/png"
"io" "io"
"math" "math"
@ -24,8 +24,8 @@ import (
_ "golang.org/x/image/webp" // 注册 webp Decode用于 custom emoji / sticker 静态缩略图合成 _ "golang.org/x/image/webp" // 注册 webp Decode用于 custom emoji / sticker 静态缩略图合成
) )
// 头像与图片消息共用的尺寸 type'a' 小图≤160'c' 大图,'x' 通用下载尺寸。 // 头像使用真实的 's'(≤150)/'a'(≤160)/'c'(原图) rendition图片消息使用
// 同一份上传字节在多个 location_key 下建 blob不做实际缩放dev 主路径足够) // 'm' 缩略与 'x' 大图。每个头像 location_key 的元数据尺寸必须与实际 blob 一致
// UploadProfilePhoto 把已上传文件组装成头像 Photo落 blob/photos/profile_photos并设为当前头像。 // UploadProfilePhoto 把已上传文件组装成头像 Photo落 blob/photos/profile_photos并设为当前头像。
func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) { func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) {
@ -103,8 +103,8 @@ func (s *Service) GetDocument(ctx context.Context, id int64) (domain.Document, b
return s.media.GetDocument(ctx, id) return s.media.GetDocument(ctx, id)
} }
// CreateAvatarFromUpload 把已上传文件组装成头像 Photo'a'/'c' 尺寸,匹配 InputPeerPhotoFileLocation // CreateAvatarFromUpload 把已上传文件组装成头像 Photo's'/'a'/'c' 尺寸,'a'/'c' 匹配
// big/small 与 channelFull 合成尺寸的下载路径),不绑定 profile_photos。用于频道 editPhoto。 // InputPeerPhotoFileLocation big/small 与 channelFull 下载路径),不绑定 profile_photos。用于频道 editPhoto。
func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) { func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.UploadedFileRef) (domain.Photo, error) {
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts) data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
if err != nil { if err != nil {
@ -113,7 +113,7 @@ func (s *Service) CreateAvatarFromUpload(ctx context.Context, file domain.Upload
if len(data) == 0 { if len(data) == 0 {
return domain.Photo{}, domain.ErrPhotoInvalid return domain.Photo{}, domain.ErrPhotoInvalid
} }
return s.createPhoto(ctx, data, photoSizeSpecsForAvatar(data)) return s.createAvatarPhoto(ctx, data)
} }
// CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes. // CreateAvatarVideoFromUpload stores an animated profile video as photo.video_sizes.
@ -151,7 +151,7 @@ func (s *Service) createAvatarVideoFromUpload(ctx context.Context, file domain.U
} }
s.blobCache.put(blob.LocationKey, blob) s.blobCache.put(blob.LocationKey, blob)
stillBytes := s.avatarVideoStill(ctx, body, extraSizes) stillBytes := s.avatarVideoStill(ctx, body, extraSizes)
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes)) sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil { if err != nil {
return domain.Photo{}, err return domain.Photo{}, err
} }
@ -192,7 +192,7 @@ func (s *Service) CreateAvatarMarkup(ctx context.Context, size domain.PhotoSize)
} }
photoID := randomID() photoID := randomID()
stillBytes := s.generatedAvatarStill(ctx, size) stillBytes := s.generatedAvatarStill(ctx, size)
sizes, err := s.putPhotoStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes)) sizes, err := s.putAvatarStaticSizes(ctx, photoID, stillBytes, photoSizeSpecsForAvatar(stillBytes))
if err != nil { if err != nil {
return domain.Photo{}, err return domain.Photo{}, err
} }
@ -605,6 +605,26 @@ func (s *Service) createPhoto(ctx context.Context, data []byte, specs []photoSiz
return photo, nil return photo, nil
} }
func (s *Service) createAvatarPhoto(ctx context.Context, data []byte) (domain.Photo, error) {
photoID := randomID()
sizes, err := s.putAvatarStaticSizes(ctx, photoID, data, photoSizeSpecsForAvatar(data))
if err != nil {
return domain.Photo{}, err
}
photo := domain.Photo{
ID: photoID,
AccessHash: randomID(),
FileReference: randomFileReference(),
Date: int(time.Now().Unix()),
DCID: s.dc,
Sizes: sizes,
}
if err := s.media.PutPhoto(ctx, photo); err != nil {
return domain.Photo{}, err
}
return photo, nil
}
func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) { func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
objectKey, err := s.blobs.Put(ctx, data) objectKey, err := s.blobs.Put(ctx, data)
if err != nil { if err != nil {
@ -630,6 +650,87 @@ func (s *Service) putPhotoStaticSizes(ctx context.Context, photoID int64, data [
return sizes, nil return sizes, nil
} }
// putAvatarStaticSizes stores independently rendered avatar sizes. DrKLO uses the
// photos.uploadProfilePhoto response's closest 150px size as an immediate local
// location, while UserProfilePhoto updates synthesize the canonical 'a' location.
// Keeping a real 's' rendition therefore gives those two states distinct keys and,
// more importantly, keeps every advertised size backed by matching image bytes.
func (s *Service) putAvatarStaticSizes(ctx context.Context, photoID int64, data []byte, specs []photoSizeSpec) ([]domain.PhotoSize, error) {
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil || src.Bounds().Dx() <= 0 || src.Bounds().Dy() <= 0 {
return nil, domain.ErrPhotoInvalid
}
sizes := make([]domain.PhotoSize, 0, len(specs))
for _, spec := range specs {
rendition, err := avatarRendition(data, src, format, spec)
if err != nil {
return nil, err
}
objectKey, err := s.blobs.Put(ctx, rendition)
if err != nil {
return nil, err
}
blob := domain.FileBlob{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, spec.Type),
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(rendition)),
MimeType: imageMimeType(rendition),
}
if err := s.media.PutFileBlob(ctx, blob); err != nil {
return nil, err
}
s.blobCache.put(blob.LocationKey, blob)
s.prewarmSmallBlob(objectKey, rendition)
sizes = append(sizes, domain.PhotoSize{
Kind: domain.PhotoSizeKindDefault,
Type: spec.Type,
W: spec.W,
H: spec.H,
Size: len(rendition),
})
}
return sizes, nil
}
func avatarRendition(original []byte, src image.Image, format string, spec photoSizeSpec) ([]byte, error) {
bounds := src.Bounds()
if bounds.Dx() == spec.W && bounds.Dy() == spec.H {
return append([]byte(nil), original...), nil
}
if spec.W <= 0 || spec.H <= 0 {
return nil, domain.ErrPhotoInvalid
}
dst := image.NewRGBA(image.Rect(0, 0, spec.W, spec.H))
srcRect := centerCropRect(bounds, spec.W, spec.H)
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, srcRect, xdraw.Src, nil)
var buf bytes.Buffer
if format == "jpeg" {
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 90}); err != nil {
return nil, err
}
} else if err := png.Encode(&buf, dst); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func centerCropRect(bounds image.Rectangle, targetW, targetH int) image.Rectangle {
sourceW, sourceH := bounds.Dx(), bounds.Dy()
if int64(sourceW)*int64(targetH) > int64(sourceH)*int64(targetW) {
cropW := maxInt(1, sourceH*targetW/targetH)
x := bounds.Min.X + (sourceW-cropW)/2
return image.Rect(x, bounds.Min.Y, x+cropW, bounds.Max.Y)
}
if int64(sourceW)*int64(targetH) < int64(sourceH)*int64(targetW) {
cropH := maxInt(1, sourceW*targetH/targetW)
y := bounds.Min.Y + (sourceH-cropH)/2
return image.Rect(bounds.Min.X, y, bounds.Max.X, y+cropH)
}
return bounds
}
func (s *Service) putDocumentThumb(ctx context.Context, docID int64, thumbData []byte) (domain.PhotoSize, error) { func (s *Service) putDocumentThumb(ctx context.Context, docID int64, thumbData []byte) (domain.PhotoSize, error) {
if len(thumbData) == 0 { if len(thumbData) == 0 {
return domain.PhotoSize{}, fmt.Errorf("empty document thumbnail") return domain.PhotoSize{}, fmt.Errorf("empty document thumbnail")
@ -723,12 +824,15 @@ type photoSizeSpec struct {
func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec { func photoSizeSpecsForAvatar(data []byte) []photoSizeSpec {
w, h := imageDimensions(data, 640, 640) w, h := imageDimensions(data, 640, 640)
small := 160 shortSide := w
if w < small { if h < shortSide {
small = w shortSide = h
} }
sSize := minInt(shortSide, 150)
aSize := minInt(shortSide, 160)
return []photoSizeSpec{ return []photoSizeSpec{
{Type: "a", W: small, H: small}, {Type: "s", W: sSize, H: sSize},
{Type: "a", W: aSize, H: aSize},
{Type: "c", W: w, H: h}, {Type: "c", W: w, H: h},
} }
} }
@ -767,10 +871,14 @@ const (
avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。 avatarMarkupMaxSourceBytes = 2 << 20 // emoji/sticker thumb 小对象保护线。
) )
// avatarVideoStill 生成动画头像的静态尺寸字节:优先抽取上传视频首帧——动画头像 // avatarVideoStill 生成动画头像的静态尺寸字节。emoji/sticker markup 能解析到
// emoji/sticker 构造器或自选视频)的首帧就是用户在客户端看到的真实画面(彩色 // 服务端缩略图时优先合成DrKLO 生成的 MP4 第一帧可能只有背景渐变,直接抽第一帧
// emoji、圆角、布局都一致抽帧不可用时回退到按 markup 服务端合成 // 会让静态头像永久缺少 emoji。普通视频或 markup 资源不可用时才回退 ffmpeg 首帧
func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte { func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob, extraSizes []domain.PhotoSize) []byte {
markup := avatarStillMarkup(extraSizes)
if still, ok := s.generatedAvatarMarkupStill(ctx, markup); ok {
return still
}
if s.thumbs != nil && body.Size > 0 && body.Size <= videoThumbnailMaxInputBytes { if s.thumbs != nil && body.Size > 0 && body.Size <= videoThumbnailMaxInputBytes {
data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size) data, total, err := s.blobs.GetRange(ctx, body.ObjectKey, 0, body.Size)
if err == nil && int64(len(data)) == total && total == body.Size { if err == nil && int64(len(data)) == total && total == body.Size {
@ -789,17 +897,30 @@ func (s *Service) avatarVideoStill(ctx context.Context, body assembledUploadBlob
zap.Error(err)) zap.Error(err))
} }
} }
return s.generatedAvatarStill(ctx, avatarStillMarkup(extraSizes)) return s.generatedAvatarStill(ctx, markup)
} }
func (s *Service) generatedAvatarStill(ctx context.Context, markup domain.PhotoSize) []byte { func (s *Service) generatedAvatarStill(ctx context.Context, markup domain.PhotoSize) []byte {
img := generatedAvatarBackground(markup.BackgroundColors) img := generatedAvatarBackground(markup.BackgroundColors)
if overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup); ok { if still, ok := s.generatedAvatarMarkupStillOnBackground(ctx, markup, img); ok {
drawAvatarMarkup(img, overlay, tintWhite) return still
} }
return encodeAvatarPNG(img) return encodeAvatarPNG(img)
} }
func (s *Service) generatedAvatarMarkupStill(ctx context.Context, markup domain.PhotoSize) ([]byte, bool) {
return s.generatedAvatarMarkupStillOnBackground(ctx, markup, generatedAvatarBackground(markup.BackgroundColors))
}
func (s *Service) generatedAvatarMarkupStillOnBackground(ctx context.Context, markup domain.PhotoSize, img *image.RGBA) ([]byte, bool) {
overlay, tintWhite, ok := s.avatarMarkupOverlay(ctx, markup)
if !ok {
return nil, false
}
drawAvatarMarkup(img, overlay, tintWhite)
return encodeAvatarPNG(img), true
}
func generatedAvatarBackground(colors []int) *image.RGBA { func generatedAvatarBackground(colors []int) *image.RGBA {
if len(colors) == 0 { if len(colors) == 0 {
colors = []int{0x5b8def, 0x53c6a4} colors = []int{0x5b8def, 0x53c6a4}
@ -865,6 +986,11 @@ func (s *Service) avatarMarkupOverlay(ctx context.Context, markup domain.PhotoSi
zap.Error(err)) zap.Error(err))
return nil, false, false return nil, false, false
} }
// Seed 的 1x1 透明图只是“没有可用静态资源”的显式占位,不是可合成
// 内容。把它视为 unavailable交给调用方回退到 ffmpeg 视频首帧。
if img.Bounds().Dx() <= 1 || img.Bounds().Dy() <= 1 {
return nil, false, false
}
return img, documentIsTextColorEmoji(doc), true return img, documentIsTextColorEmoji(doc), true
} }

View file

@ -223,6 +223,57 @@ func TestCreatePhotoFromBytesStoresDownloadableMessageSizes(t *testing.T) {
} }
} }
func TestCreateAvatarFromUploadStoresRealSizedRenditions(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
svc := NewService(media, blobs, 2)
data := testJPEG(t, 640, 480)
if _, err := svc.SaveFilePart(ctx, 10, 301, 0, data); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
photo, err := svc.CreateAvatarFromUpload(ctx, domain.UploadedFileRef{
OwnerUserID: 10,
FileID: 301,
Parts: 1,
Name: "avatar.jpg",
})
if err != nil {
t.Fatalf("CreateAvatarFromUpload: %v", err)
}
wants := map[string]image.Point{
"s": {X: 150, Y: 150},
"a": {X: 160, Y: 160},
"c": {X: 640, Y: 480},
}
if len(photo.Sizes) != len(wants) {
t.Fatalf("avatar sizes = %+v, want s/a/c", photo.Sizes)
}
objectKeys := map[string]struct{}{}
for _, size := range photo.Sizes {
want, ok := wants[size.Type]
if !ok || size.W != want.X || size.H != want.Y {
t.Fatalf("avatar size = %+v, want one of %v", size, wants)
}
assertAvatarImageSize(t, svc, photo.ID, size.Type, want.X, want.Y, "image/jpeg")
blob, found, err := media.GetFileBlob(ctx, fmt.Sprintf("photo:%d:%s", photo.ID, size.Type))
if err != nil || !found {
t.Fatalf("avatar %s blob found=%v err=%v", size.Type, found, err)
}
if blob.Size != int64(size.Size) {
t.Fatalf("avatar %s blob size=%d metadata size=%d", size.Type, blob.Size, size.Size)
}
objectKeys[blob.ObjectKey] = struct{}{}
}
if len(objectKeys) != 3 {
t.Fatalf("avatar object keys = %v, want distinct s/a/c renditions", objectKeys)
}
}
func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) { func TestCreateDocumentFromBytesStoresBodyAndAttributes(t *testing.T) {
ctx := context.Background() ctx := context.Background()
media := newFakeMediaStore() media := newFakeMediaStore()
@ -299,8 +350,9 @@ func TestCreateAvatarMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
if !domain.PhotoHasVideo(photo.Sizes) { if !domain.PhotoHasVideo(photo.Sizes) {
t.Fatalf("avatar markup photo sizes = %+v, want video markup", photo.Sizes) t.Fatalf("avatar markup photo sizes = %+v, want video markup", photo.Sizes)
} }
assertDownloadableAvatarSize(t, svc, photo.ID, "a") assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
assertDownloadableAvatarSize(t, svc, photo.ID, "c") assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
} }
// TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes 守护两个行为: // TestCreateAvatarMarkupComposesEmojiThumbIntoStaticSizes 守护两个行为:
@ -433,8 +485,9 @@ func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err) t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
} }
assertDownloadableAvatarSize(t, svc, photo.ID, "a") assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
assertDownloadableAvatarSize(t, svc, photo.ID, "c") assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{ chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:u", photo.ID), LocationKey: fmt.Sprintf("photo:%d:u", photo.ID),
Offset: 0, Offset: 0,
@ -448,9 +501,9 @@ func TestCreateAvatarVideoMarkupGeneratesDownloadableStaticSizes(t *testing.T) {
} }
} }
// TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame 守护动画头像静态尺寸优先取 // TestCreateAvatarVideoMarkupFallsBackToVideoFirstFrame 守护 markup document/thumb
// 上传视频首帧(客户端真实渲染画面),而不是服务端合成的近似 still // 不可用时仍可从上传视频抽帧,不能让普通动画头像失去静态尺寸
func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) { func TestCreateAvatarVideoMarkupFallsBackToVideoFirstFrame(t *testing.T) {
ctx := context.Background() ctx := context.Background()
media := newFakeMediaStore() media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir()) blobs, err := NewLocalFS(t.TempDir())
@ -479,7 +532,7 @@ func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls) t.Fatalf("thumbnailer calls = %d, want 1", thumbnailer.calls)
} }
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{ chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:a", photo.ID), LocationKey: fmt.Sprintf("photo:%d:c", photo.ID),
Offset: 0, Offset: 0,
Limit: 1 << 20, Limit: 1 << 20,
}) })
@ -492,9 +545,112 @@ func TestCreateAvatarVideoMarkupStillUsesVideoFirstFrame(t *testing.T) {
if chunk.MimeType != "image/jpeg" { if chunk.MimeType != "image/jpeg" {
t.Fatalf("avatar still mime = %q, want image/jpeg from extracted frame", chunk.MimeType) t.Fatalf("avatar still mime = %q, want image/jpeg from extracted frame", chunk.MimeType)
} }
assertAvatarImageSize(t, svc, photo.ID, "s", 150, 150, "image/jpeg")
assertAvatarImageSize(t, svc, photo.ID, "a", 160, 160, "image/jpeg")
} }
func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string) { func TestCreateAvatarVideoMarkupRejectsSyntheticPreviewAndFallsBackToVideo(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
const emojiID = int64(78)
if err := media.PutDocument(ctx, domain.Document{
ID: emojiID,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached, Type: "m", W: 1, H: 1,
Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
}},
}); err != nil {
t.Fatalf("PutDocument: %v", err)
}
frame := testJPEG(t, 640, 640)
thumbnailer := &fakeVideoThumbnailer{thumb: frame}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
if _, err := svc.SaveFilePart(ctx, 10, 503, 0, []byte("profile-video-without-server-preview")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 503, Parts: 1, Name: "avatar.mp4"},
0,
domain.PhotoSize{Kind: domain.PhotoSizeKindVideoEmojiMarkup, EmojiID: emojiID, BackgroundColors: []int{0x112233}})
if err != nil {
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
}
if thumbnailer.calls != 1 {
t.Fatalf("thumbnailer calls = %d, want synthetic preview rejected and video fallback used", thumbnailer.calls)
}
chunk, found, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("photo:%d:c", photo.ID), Limit: 1 << 20})
if err != nil || !found {
t.Fatalf("avatar c blob found=%v err=%v", found, err)
}
if !bytes.Equal(chunk.Bytes, frame) {
t.Fatal("avatar still did not use extracted video frame after rejecting synthetic preview")
}
}
// TestCreateAvatarVideoMarkupPrefersComposedStill 守护 DrKLO emoji 构造器边界:
// 客户端生成 MP4 的第一帧可能只有渐变背景;只要 markup thumb 可解析,静态头像
// 必须用服务端合成结果,确保 emoji 在当前 session 回显和冷启动中都可见。
func TestCreateAvatarVideoMarkupPrefersComposedStill(t *testing.T) {
ctx := context.Background()
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("NewLocalFS: %v", err)
}
const emojiID = int64(501)
if err := media.PutDocument(ctx, domain.Document{
ID: emojiID,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached,
Type: "m",
W: 64,
H: 64,
Bytes: testTransparentThumbPNG(t),
}},
}); err != nil {
t.Fatalf("PutDocument: %v", err)
}
thumbnailer := &fakeVideoThumbnailer{thumb: testJPEG(t, 320, 320)}
svc := NewService(media, blobs, 2, WithVideoThumbnailer(thumbnailer))
if _, err := svc.SaveFilePart(ctx, 10, 502, 0, []byte("background-only-profile-video")); err != nil {
t.Fatalf("SaveFilePart: %v", err)
}
photo, err := svc.CreateAvatarVideoMarkupFromUpload(ctx,
domain.UploadedFileRef{OwnerUserID: 10, FileID: 502, Parts: 1, Name: "avatar.mp4"},
0,
domain.PhotoSize{
Kind: domain.PhotoSizeKindVideoEmojiMarkup,
EmojiID: emojiID,
BackgroundColors: []int{0x112233, 0x445566},
})
if err != nil {
t.Fatalf("CreateAvatarVideoMarkupFromUpload: %v", err)
}
if thumbnailer.calls != 0 {
t.Fatalf("thumbnailer calls = %d, want 0 when markup still is available", thumbnailer.calls)
}
r, g, b, _ := avatarStillCenterPixel(t, svc, photo.ID)
if r < 200 || g > 90 || b > 90 {
t.Fatalf("composed center pixel rgb=(%d,%d,%d), want visible red emoji overlay", r, g, b)
}
assertDownloadableAvatarSize(t, svc, photo.ID, "s", 150)
assertDownloadableAvatarSize(t, svc, photo.ID, "a", 160)
assertDownloadableAvatarSize(t, svc, photo.ID, "c", 640)
}
func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, sizeType string, side int) {
t.Helper()
assertAvatarImageSize(t, svc, photoID, sizeType, side, side, "image/png")
}
func assertAvatarImageSize(t *testing.T, svc *Service, photoID int64, sizeType string, wantW, wantH int, wantMime string) {
t.Helper() t.Helper()
chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{ chunk, found, err := svc.GetFile(context.Background(), domain.FileDownloadRequest{
LocationKey: fmt.Sprintf("photo:%d:%s", photoID, sizeType), LocationKey: fmt.Sprintf("photo:%d:%s", photoID, sizeType),
@ -504,8 +660,15 @@ func assertDownloadableAvatarSize(t *testing.T, svc *Service, photoID int64, siz
if err != nil || !found { if err != nil || !found {
t.Fatalf("avatar %s blob found=%v err=%v", sizeType, found, err) t.Fatalf("avatar %s blob found=%v err=%v", sizeType, found, err)
} }
if len(chunk.Bytes) == 0 || chunk.MimeType != "image/png" { if len(chunk.Bytes) == 0 || chunk.MimeType != wantMime {
t.Fatalf("avatar %s chunk mime=%q bytes=%d, want image/png bytes", sizeType, chunk.MimeType, len(chunk.Bytes)) t.Fatalf("avatar %s chunk mime=%q bytes=%d, want %s bytes", sizeType, chunk.MimeType, len(chunk.Bytes), wantMime)
}
img, _, err := image.Decode(bytes.NewReader(chunk.Bytes))
if err != nil {
t.Fatalf("decode avatar %s: %v", sizeType, err)
}
if gotW, gotH := img.Bounds().Dx(), img.Bounds().Dy(); gotW != wantW || gotH != wantH {
t.Fatalf("avatar %s pixels=%dx%d, want %dx%d", sizeType, gotW, gotH, wantW, wantH)
} }
} }

View file

@ -81,6 +81,7 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int
// 这样向已部署(非空 store)的 data/sticker-seed 丢新集后重启即可生效,无需清库重 seed。 // 这样向已部署(非空 store)的 data/sticker-seed 丢新集后重启即可生效,无需清库重 seed。
// 仅当检测到旧版缩略图/可渲染预览元数据缺失时 force=true 全量重导修复。 // 仅当检测到旧版缩略图/可渲染预览元数据缺失时 force=true 全量重导修复。
forceSticker := false forceSticker := false
previewState := fmt.Sprintf("%s:dc=%d", seedStickerPreviewStateVersion, s.dc)
if n, err := s.media.CountStickerSets(ctx); err != nil { if n, err := s.media.CountStickerSets(ctx); err != nil {
return stats, err return stats, err
} else if n > 0 { } else if n > 0 {
@ -88,11 +89,18 @@ func (s *Service) SeedMedia(ctx context.Context, root string, maxRegularSets int
if err != nil { if err != nil {
return stats, err return stats, err
} }
forceSticker = stale migrated, err := s.seedStateMatches(ctx, seedStickerPreviewStateKey, previewState)
if err != nil {
return stats, err
}
forceSticker = stale || !migrated
} }
if err := s.seedStickerSets(ctx, root, maxRegularSets, forceSticker, &stats); err != nil { if err := s.seedStickerSets(ctx, root, maxRegularSets, forceSticker, &stats); err != nil {
return stats, fmt.Errorf("seed sticker sets: %w", err) return stats, fmt.Errorf("seed sticker sets: %w", err)
} }
if err := s.putSeedState(ctx, seedStickerPreviewStateKey, previewState); err != nil {
return stats, fmt.Errorf("record sticker preview seed state: %w", err)
}
s.logSeedPhase("sticker_sets", phaseStarted, phaseBefore, stats) s.logSeedPhase("sticker_sets", phaseStarted, phaseBefore, stats)
phaseStarted = time.Now() phaseStarted = time.Now()
@ -379,6 +387,10 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
if dj.ID == 0 { if dj.ID == 0 {
return domain.Document{}, nil return domain.Document{}, nil
} }
existing, existingFound, err := s.media.GetDocument(ctx, dj.ID)
if err != nil {
return domain.Document{}, err
}
ref, _ := hex.DecodeString(dj.FileReference) ref, _ := hex.DecodeString(dj.FileReference)
doc := domain.Document{ doc := domain.Document{
ID: dj.ID, ID: dj.ID,
@ -436,11 +448,18 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
if err != nil { if err != nil {
return domain.Document{}, err return domain.Document{}, err
} }
ps.Size = len(data)
ps = seedInlineCachedDocumentThumb(ps, data)
// A duplicate document can be present in several catalogs. Do not replace a
// better already-persisted preview (and its shared location key) with a lower
// quality rendition from the catalog imported later.
if prior, ok := seedDocumentThumbByType(existing.Thumbs, ps.Type); existingFound && ok && seedPhotoSizeBetter(prior, ps) {
ps = prior
} else {
objectKey, err := s.blobs.Put(ctx, data) objectKey, err := s.blobs.Put(ctx, data)
if err != nil { if err != nil {
return domain.Document{}, err return domain.Document{}, err
} }
ps.Size = len(data)
if err := s.media.PutFileBlob(ctx, domain.FileBlob{ if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, ps.Type), LocationKey: fmt.Sprintf("doc:%d:%s", doc.ID, ps.Type),
Backend: domain.MediaBackend(s.blobs.Name()), Backend: domain.MediaBackend(s.blobs.Name()),
@ -450,13 +469,19 @@ func (s *Service) importDocument(ctx context.Context, dj seedDocumentJSON, binDi
}); err != nil { }); err != nil {
return domain.Document{}, err return domain.Document{}, err
} }
ps = seedInlineCachedDocumentThumb(ps, data)
s.prewarmSmallBlob(objectKey, data) s.prewarmSmallBlob(objectKey, data)
stats.Blobs++ stats.Blobs++
} }
}
thumbs = append(thumbs, ps) thumbs = append(thumbs, ps)
} }
doc.Thumbs = thumbs doc.Thumbs = thumbs
if existingFound {
doc.Thumbs = mergeSeedDocumentThumbs(existing.Thumbs, doc.Thumbs)
}
if err := s.ensureSeedCachedThumbBlobs(ctx, doc, stats); err != nil {
return domain.Document{}, err
}
if err := s.ensureTGStickerPreviewThumb(ctx, &doc, stats); err != nil { if err := s.ensureTGStickerPreviewThumb(ctx, &doc, stats); err != nil {
return domain.Document{}, err return domain.Document{}, err
@ -700,6 +725,138 @@ func seedInlineCachedDocumentThumb(ps domain.PhotoSize, data []byte) domain.Phot
return ps return ps
} }
// mergeSeedDocumentThumbs makes duplicate seed imports monotonic for preview quality.
// PhotoSize.Type is also the blob location suffix, so only one winner per type may be
// advertised. Incoming metadata wins ties; a richer existing preview wins downgrades.
func mergeSeedDocumentThumbs(existing, incoming []domain.PhotoSize) []domain.PhotoSize {
out := append([]domain.PhotoSize(nil), incoming...)
byType := make(map[string]int, len(out))
for i, thumb := range out {
if thumb.Type != "" {
byType[thumb.Type] = i
}
}
for _, thumb := range existing {
if thumb.Type != "" {
if i, ok := byType[thumb.Type]; ok {
if seedPhotoSizeBetter(thumb, out[i]) {
out[i] = thumb
}
continue
}
byType[thumb.Type] = len(out)
}
out = append(out, thumb)
}
hasRealPreview := false
for _, thumb := range out {
if !seedSyntheticTGStickerPreviewThumb(thumb) && seedPhotoSizePreviewTier(thumb) > 1 {
hasRealPreview = true
break
}
}
if !hasRealPreview {
return out
}
filtered := out[:0]
for _, thumb := range out {
if !seedSyntheticTGStickerPreviewThumb(thumb) {
filtered = append(filtered, thumb)
}
}
return filtered
}
func seedDocumentThumbByType(thumbs []domain.PhotoSize, typ string) (domain.PhotoSize, bool) {
for _, thumb := range thumbs {
if thumb.Type == typ {
return thumb, true
}
}
return domain.PhotoSize{}, false
}
func seedPhotoSizeBetter(a, b domain.PhotoSize) bool {
aTier, bTier := seedPhotoSizePreviewTier(a), seedPhotoSizePreviewTier(b)
if aTier != bTier {
return aTier > bTier
}
aArea, bArea := int64(a.W)*int64(a.H), int64(b.W)*int64(b.H)
if aArea != bArea {
return aArea > bArea
}
aPayload, bPayload := len(a.Bytes)+a.Size, len(b.Bytes)+b.Size
return aPayload > bPayload
}
func seedPhotoSizePreviewTier(thumb domain.PhotoSize) int {
if seedSyntheticTGStickerPreviewThumb(thumb) {
return 0
}
switch thumb.Kind {
case domain.PhotoSizeKindCached:
if len(thumb.Bytes) > 0 && thumb.W > 0 && thumb.H > 0 {
return 4
}
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive:
if thumb.Size > 0 && thumb.W > 0 && thumb.H > 0 {
return 4
}
case domain.PhotoSizeKindPath, domain.PhotoSizeKindStripped:
if len(thumb.Bytes) > 0 {
return 3
}
}
return 1
}
func seedSyntheticTGStickerPreviewThumb(thumb domain.PhotoSize) bool {
return thumb.Kind == domain.PhotoSizeKindCached &&
thumb.Type == seedSyntheticDocumentThumbType &&
thumb.W == 1 && thumb.H == 1 &&
bytes.Equal(thumb.Bytes, seedSyntheticTGStickerPreviewThumbPNG)
}
// ensureSeedCachedThumbBlobs keeps the RPC conversion invariant: document cached
// previews are exposed as downloadable PhotoSize entries, so every advertised type
// must have a matching blob even when the source JSON carried the bytes inline.
func (s *Service) ensureSeedCachedThumbBlobs(ctx context.Context, doc domain.Document, stats *SeedStats) error {
for _, thumb := range doc.Thumbs {
if thumb.Kind != domain.PhotoSizeKindCached || thumb.Type == "" || len(thumb.Bytes) == 0 {
continue
}
locationKey := fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)
mimeType := seedThumbMimeType(thumb.Bytes)
stored, found, err := s.media.GetFileBlob(ctx, locationKey)
if err != nil {
return err
}
if found && stored.Size == int64(len(thumb.Bytes)) && stored.MimeType == mimeType {
continue
}
if s.blobs == nil {
return fmt.Errorf("blob backend not configured for cached document thumb %s", locationKey)
}
objectKey, err := s.blobs.Put(ctx, thumb.Bytes)
if err != nil {
return err
}
if err := s.media.PutFileBlob(ctx, domain.FileBlob{
LocationKey: locationKey,
Backend: domain.MediaBackend(s.blobs.Name()),
ObjectKey: objectKey,
Size: int64(len(thumb.Bytes)),
MimeType: mimeType,
}); err != nil {
return err
}
s.prewarmSmallBlob(objectKey, thumb.Bytes)
stats.Blobs++
}
return nil
}
func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.Document, stats *SeedStats) error { func (s *Service) ensureTGStickerPreviewThumb(ctx context.Context, doc *domain.Document, stats *SeedStats) error {
if !seedDocumentNeedsSyntheticTGStickerPreviewThumb(*doc) { if !seedDocumentNeedsSyntheticTGStickerPreviewThumb(*doc) {
return nil return nil
@ -826,11 +983,12 @@ func (s *Service) documentsNeedSeedRepair(ctx context.Context, ids []int64) (boo
if err != nil { if err != nil {
return false, err return false, err
} }
if ok { if !ok {
want := seedThumbMimeType(thumb.Bytes)
if want != "application/octet-stream" && blob.MimeType != want {
return true, nil return true, nil
} }
want := seedThumbMimeType(thumb.Bytes)
if blob.Size != int64(len(thumb.Bytes)) || (want != "application/octet-stream" && blob.MimeType != want) {
return true, nil
} }
} }
} }

View file

@ -10,11 +10,18 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"telesrv/internal/domain"
) )
const ( const (
seedEffectsStateKey = "files.effects" seedEffectsStateKey = "files.effects"
seedEffectsStateVersion = "effects-v2" seedEffectsStateVersion = "effects-v2"
seedStickerPreviewStateKey = "files.sticker_previews"
// v2 explicitly rebuilds sticker documents written before duplicate seed imports
// became monotonic. Those databases may contain an effects-generated transparent
// 1x1 preview where the sticker catalog has a real static thumbnail.
seedStickerPreviewStateVersion = "sticker-previews-v2-monotonic"
seedAppearanceStateKey = "files.appearance" seedAppearanceStateKey = "files.appearance"
seedAppearanceStateVersion = "appearance-v1" seedAppearanceStateVersion = "appearance-v1"
) )
@ -88,15 +95,16 @@ func seedDocumentJSONLocationKeys(dj seedDocumentJSON, index seedDirIndex) []str
} }
for _, tj := range dj.Thumbs { for _, tj := range dj.Thumbs {
ps, downloadable := seedPhotoSize(tj) ps, downloadable := seedPhotoSize(tj)
if !downloadable || ps.Type == "" { if ps.Type == "" {
continue continue
} }
if downloadable {
if _, ok := index.thumb[dj.ID][ps.Type]; ok { if _, ok := index.thumb[dj.ID][ps.Type]; ok {
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type)) keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type))
} }
} else if ps.Kind == domain.PhotoSizeKindCached && len(ps.Bytes) > 0 && ps.W > 0 && ps.H > 0 {
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, ps.Type))
} }
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
keys = append(keys, fmt.Sprintf("doc:%d:%s", dj.ID, seedSyntheticDocumentThumbType))
} }
return keys return keys
} }
@ -144,6 +152,28 @@ func (s *Service) seedDocumentJSONsReady(ctx context.Context, docs []seedDocumen
if doc.DCID != s.dc || doc.MimeType != dj.MimeType || doc.Size != dj.Size { if doc.DCID != s.dc || doc.MimeType != dj.MimeType || doc.Size != dj.Size {
return false, nil return false, nil
} }
// A catalog without its own thumbnail may share this document with a richer
// catalog. Readiness follows the preview that is actually stored instead of
// demanding the synthetic "m" key and repeatedly downgrading that richer
// document on every import.
if seedDocumentJSONNeedsSyntheticTGStickerPreviewThumb(dj) {
if len(doc.Thumbs) == 0 {
return false, nil
}
for _, thumb := range doc.Thumbs {
switch thumb.Kind {
case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive, domain.PhotoSizeKindCached:
if thumb.Type == "" {
return false, nil
}
key := fmt.Sprintf("doc:%d:%s", doc.ID, thumb.Type)
if _, seen := seenLocationKeys[key]; !seen {
seenLocationKeys[key] = struct{}{}
locationKeys = append(locationKeys, key)
}
}
}
}
delete(expected, doc.ID) delete(expected, doc.ID)
} }
if len(expected) > 0 { if len(expected) > 0 {

View file

@ -1,6 +1,7 @@
package files package files
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"os" "os"
@ -463,8 +464,8 @@ func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("repair seed: %v", err) t.Fatalf("repair seed: %v", err)
} }
if stats.Reactions != 1 || stats.Blobs != 3 || stats.Skipped { if stats.Reactions != 1 || stats.Blobs != 2 || stats.Skipped {
t.Fatalf("repair stats = %+v, want repair import", stats) t.Fatalf("repair stats = %+v, want two missing/revalidated main blobs without rewriting intact preview", stats)
} }
if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok { if _, ok, _ := media.GetFileBlob(context.Background(), "doc:2222222"); !ok {
t.Fatal("missing reaction blob was not repaired") t.Fatal("missing reaction blob was not repaired")
@ -609,8 +610,113 @@ func TestSeedMediaSkipsUnchangedEffectsDocuments(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("repair seed: %v", err) t.Fatalf("repair seed: %v", err)
} }
if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 2 { if repaired.Effects != 1 || repaired.Documents != 1 || repaired.Blobs != 1 {
t.Fatalf("repair stats = %+v, want missing blob to force reimport", repaired) t.Fatalf("repair stats = %+v, want missing main blob repaired without rewriting intact preview", repaired)
}
}
func TestSeedEffectsDoesNotDowngradeSharedStickerPreview(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 7777777
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 29)
writeEffectsSeed(t, seedDir, sourceID)
media := newFakeMediaStore()
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
svc := NewService(media, blobs, 2)
first, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("first seed: %v", err)
}
if first.StickerSets != 1 || first.Effects != 1 {
t.Fatalf("first stats = %+v, want shared sticker and effect catalogs", first)
}
doc, ok, err := media.GetDocument(ctx, sourceID)
if err != nil || !ok {
t.Fatalf("shared document ok=%v err=%v", ok, err)
}
thumb, ok := findCachedThumb(doc.Thumbs)
if !ok {
t.Fatalf("shared document thumbs = %+v, want real cached preview", doc.Thumbs)
}
if thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) || seedSyntheticTGStickerPreviewThumb(thumb) {
t.Fatalf("shared preview = %+v, want original 128x128 catalog thumbnail", thumb)
}
blob, ok, err := media.GetFileBlob(ctx, fmt.Sprintf("doc:%d:m", sourceID))
if err != nil || !ok {
t.Fatalf("shared preview blob ok=%v err=%v", ok, err)
}
if blob.MimeType != "image/jpeg" || blob.Size != int64(len(realThumb)) {
t.Fatalf("shared preview blob = %+v, want real JPEG metadata", blob)
}
chunk, ok, err := svc.GetFile(ctx, domain.FileDownloadRequest{LocationKey: fmt.Sprintf("doc:%d:m", sourceID), Limit: 1024})
if err != nil || !ok {
t.Fatalf("get shared preview ok=%v err=%v", ok, err)
}
if !bytes.Equal(chunk.Bytes, realThumb) {
t.Fatalf("downloaded shared preview = %x, want %x", chunk.Bytes, realThumb)
}
second, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("second seed: %v", err)
}
if second.Documents != 0 || second.Blobs != 0 {
t.Fatalf("second stats = %+v, want shared rich preview to satisfy effects readiness", second)
}
}
func TestSeedMediaMigratesSyntheticStickerPreviewToExportedThumbnail(t *testing.T) {
ctx := context.Background()
seedDir := t.TempDir()
const sourceID int64 = 8888888
realThumb := writeStatusPackWithThumbSeed(t, seedDir, sourceID, 31)
media := newFakeMediaStore()
if err := media.PutDocument(ctx, domain.Document{
ID: sourceID,
MimeType: "application/x-tgsticker",
Thumbs: []domain.PhotoSize{{
Kind: domain.PhotoSizeKindCached, Type: seedSyntheticDocumentThumbType,
W: 1, H: 1, Bytes: append([]byte(nil), seedSyntheticTGStickerPreviewThumbPNG...),
}},
}); err != nil {
t.Fatalf("put stale document: %v", err)
}
if err := media.PutStickerSet(ctx, domain.StickerSet{
ID: 773947703670341676, AccessHash: 1, ShortName: "StatusPack", Title: "Status Pack",
Hash: 31, Kind: domain.StickerSetKindEmoji, Emojis: true, DocumentIDs: []int64{sourceID},
}); err != nil {
t.Fatalf("put stale sticker set: %v", err)
}
blobs, err := NewLocalFS(t.TempDir())
if err != nil {
t.Fatalf("local fs: %v", err)
}
svc := NewService(media, blobs, 2)
stats, err := svc.SeedMedia(ctx, seedDir, 0)
if err != nil {
t.Fatalf("migration seed: %v", err)
}
if stats.StickerSets != 1 || stats.Documents != 1 {
t.Fatalf("migration stats = %+v, want forced sticker document rebuild", stats)
}
doc, ok, err := media.GetDocument(ctx, sourceID)
if err != nil || !ok {
t.Fatalf("migrated document ok=%v err=%v", ok, err)
}
thumb, ok := findCachedThumb(doc.Thumbs)
if !ok || thumb.W != 128 || thumb.H != 128 || !bytes.Equal(thumb.Bytes, realThumb) {
t.Fatalf("migrated thumbs = %+v, want exported 128x128 preview", doc.Thumbs)
}
if state, ok, err := media.GetSeedState(ctx, seedStickerPreviewStateKey); err != nil || !ok || state == "" {
t.Fatalf("preview migration state = %q ok=%v err=%v", state, ok, err)
} }
} }
@ -735,6 +841,28 @@ func writeStatusPackWithoutThumbSeed(t *testing.T, seedDir string, sourceID int6
} }
} }
func writeStatusPackWithThumbSeed(t *testing.T, seedDir string, sourceID int64, setHash int) []byte {
t.Helper()
setDir := filepath.Join(seedDir, "telegram_emoji_export", "StatusPack_773947703670341676")
stickersDir := filepath.Join(setDir, "stickers")
if err := os.MkdirAll(stickersDir, 0o755); err != nil {
t.Fatal(err)
}
realThumb := []byte{0xff, 0xd8, 0xff, 0xdb, 0, 4, 0xff, 0xd9}
raw := fmt.Sprintf(`{"result":{"set":{"id":773947703670341676,"access_hash":1,"title":"Status Pack","short_name":"StatusPack","count":1,"hash":%d,"emojis":true,"packs":[{"emoticon":"👋","documents":[%d]}]},"packs":[{"emoticon":"👋","documents":[%d]}],"documents":[{"id":%d,"access_hash":2,"file_reference":"","date":"2026-06-29T00:00:00Z","mime_type":"application/x-tgsticker","size":4,"dc_id":4,"attributes":[{"_":"DocumentAttributeImageSize","w":512,"h":512},{"_":"DocumentAttributeCustomEmoji","alt":"👋","text_color":true,"stickerset":{"id":773947703670341676,"access_hash":1}},{"_":"DocumentAttributeFilename","file_name":"AnimatedSticker.tgs"}],"thumbs":[{"_":"PhotoPathSize","type":"j","bytes":"01"},{"_":"PhotoSize","type":"m","w":128,"h":128,"size":%d}]}]}}`, setHash, sourceID, sourceID, sourceID, len(realThumb))
if err := os.WriteFile(filepath.Join(setDir, "set_info.json"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stickersDir, fmt.Sprintf("status_%d.tgs", sourceID)), []byte("tgs!"), 0o644); err != nil {
t.Fatal(err)
}
thumbName := fmt.Sprintf("status_%d_thumb1_PhotoSize_typem_128x128.jpg", sourceID)
if err := os.WriteFile(filepath.Join(stickersDir, thumbName), realThumb, 0o644); err != nil {
t.Fatal(err)
}
return realThumb
}
func writeEffectsSeed(t *testing.T, seedDir string, sourceID int64) { func writeEffectsSeed(t *testing.T, seedDir string, sourceID int64) {
t.Helper() t.Helper()
docsDir := filepath.Join(seedDir, "telegram_effects_export", "documents") docsDir := filepath.Join(seedDir, "telegram_effects_export", "documents")
@ -992,7 +1120,7 @@ func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
if err := media.PutDocument(ctx, doc); err != nil { if err := media.PutDocument(ctx, doc); err != nil {
t.Fatalf("put doc: %v", err) t.Fatalf("put doc: %v", err)
} }
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/jpeg"}); err != nil { if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Size: int64(len(webp)), MimeType: "image/jpeg"}); err != nil {
t.Fatalf("put blob: %v", err) t.Fatalf("put blob: %v", err)
} }
svc := NewService(media, nil, 2) svc := NewService(media, nil, 2)
@ -1004,7 +1132,7 @@ func TestDocumentsNeedInlineCachedThumbsDetectsStaleMime(t *testing.T) {
t.Fatal("expected stale mime to require repair") t.Fatal("expected stale mime to require repair")
} }
if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", MimeType: "image/webp"}); err != nil { if err := media.PutFileBlob(ctx, domain.FileBlob{LocationKey: "doc:100:m", Size: int64(len(webp)), MimeType: "image/webp"}); err != nil {
t.Fatalf("put repaired blob: %v", err) t.Fatalf("put repaired blob: %v", err)
} }
stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID}) stale, err = svc.documentsNeedInlineCachedThumbs(ctx, []int64{doc.ID})

View file

@ -17,6 +17,13 @@ type TempAuthKeyRetentionStore interface {
DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error) DeleteExpired(ctx context.Context, expiredBefore int64, limit int) (int, error)
} }
// AuthKeySessionLayerRetentionStore reclaims expired short-lived Layer
// watermarks. Selector freshness, not retention timing, is the correctness
// gate; this worker only bounds durable storage.
type AuthKeySessionLayerRetentionStore interface {
DeleteExpiredSessionLayers(ctx context.Context, limit int) (int, error)
}
// OrphanAuthKeyRetentionStore 回收从未形成授权/temp binding 的旧握手 key。 // OrphanAuthKeyRetentionStore 回收从未形成授权/temp binding 的旧握手 key。
// protected 是当前连接注册表实际使用的 raw auth_key_id 快照。 // protected 是当前连接注册表实际使用的 raw auth_key_id 快照。
type OrphanAuthKeyRetentionStore interface { type OrphanAuthKeyRetentionStore interface {
@ -87,6 +94,7 @@ const (
type RetentionWorker struct { type RetentionWorker struct {
outbox DispatchOutboxRetentionStore outbox DispatchOutboxRetentionStore
tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定) tempKeys TempAuthKeyRetentionStore // 可为 nil不回收 temp key 绑定)
authKeySessionLayers AuthKeySessionLayerRetentionStore
botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列) botAPIUpdates BotAPIUpdateRetentionStore // 可为 nil不回收 Bot API 队列)
userUpdates UserUpdateEventRetentionStore userUpdates UserUpdateEventRetentionStore
channelUpdates ChannelUpdateEventRetentionStore channelUpdates ChannelUpdateEventRetentionStore
@ -176,6 +184,13 @@ func (w *RetentionWorker) WithLoginCodeDeliveryRetention(store LoginCodeDelivery
return w return w
} }
// WithAuthKeySessionLayerRetention enables bounded seek cleanup for expired
// per-session Layer evidence.
func (w *RetentionWorker) WithAuthKeySessionLayerRetention(store AuthKeySessionLayerRetentionStore) *RetentionWorker {
w.authKeySessionLayers = store
return w
}
// WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key // WithOrphanAuthKeyRetention 启用未授权握手 key 的有界回收。active 必须提供 raw key
// 不能提供 temp→perm business key否则未登录或 PFS 连接会被误判为 orphan。 // 不能提供 temp→perm business key否则未登录或 PFS 连接会被误判为 orphan。
func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker { func (w *RetentionWorker) WithOrphanAuthKeyRetention(store OrphanAuthKeyRetentionStore, active ActiveRawAuthKeyProvider, retention time.Duration) *RetentionWorker {
@ -243,6 +258,14 @@ func (w *RetentionWorker) runOutboxPoisonOnce(ctx context.Context) {
} }
func (w *RetentionWorker) runRetentionOnce(ctx context.Context) { func (w *RetentionWorker) runRetentionOnce(ctx context.Context) {
if w.authKeySessionLayers != nil {
deleted, err := w.authKeySessionLayers.DeleteExpiredSessionLayers(ctx, w.batch)
if err != nil {
w.logger.Warn("回收过期 auth-key session Layer 证据失败", zap.Error(err))
} else if deleted > 0 {
w.logger.Info("回收过期 auth-key session Layer 证据完成", zap.Int("deleted", deleted))
}
}
if w.loginCodeDeliveries != nil { if w.loginCodeDeliveries != nil {
deleted, err := w.loginCodeDeliveries.DeleteExpiredLoginCodeDeliveries(ctx, time.Now(), w.batch) deleted, err := w.loginCodeDeliveries.DeleteExpiredLoginCodeDeliveries(ctx, time.Now(), w.batch)
if err != nil { if err != nil {

View file

@ -9,7 +9,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"telesrv/internal/domain" "telesrv/internal/domain"
) )

View file

@ -8,7 +8,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"telesrv/internal/domain" "telesrv/internal/domain"
) )

View file

@ -148,7 +148,14 @@ func TestGetStateDoesNotConfirmUnfetchedEvents(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetState after difference: %v", err) t.Fatalf("GetState after difference: %v", err)
} }
if st.Pts != 2 { if st.Pts != 1 {
t.Fatalf("GetState after difference pts=%d, want confirmed pts=2", st.Pts) t.Fatalf("GetState before delivery commit pts=%d, want confirmed pts=1", st.Pts)
}
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, diff.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
t.Fatalf("CommitDeliveredState: %v", err)
}
st, err = svc.GetState(ctx, authKeyID, userID)
if err != nil || st.Pts != 2 {
t.Fatalf("GetState after delivery commit = %+v err=%v, want pts=2", st, err)
} }
} }

View file

@ -135,27 +135,34 @@ func (s *Service) ConfirmEvent(ctx context.Context, authKeyID [8]byte, userID in
return s.saveConfirmedState(ctx, authKeyID, userID, domain.UpdateState{Pts: event.Pts, Date: date, Seq: 0}) return s.saveConfirmedState(ctx, authKeyID, userID, domain.UpdateState{Pts: event.Pts, Date: date, Seq: 0})
} }
// AcknowledgeCurrentState 返回账号当前最大连续状态,并把该设备的确认水位推进到此。 // ObserveDifferenceRequest records only the cursor a client carried into this
// // request. It is deliberately independent from response delivery: even when
// 供 updates.getState 使用:协议语义是客户端宣告「从现在开始同步」,启动期的 // encoding or the socket write later fails, the request still proves the client
// 离线数据由 getDialogs 快照承载TDesktop 不持久化 pts每次启动都走此路径 // already owned this (clamped) cursor before contacting us.
// 若改为返回设备旧确认水位,客户端会在 getDialogs 最新快照之上再重放历史差分, func (s *Service) ObserveDifferenceRequest(ctx context.Context, authKeyID [8]byte, userID int64, from domain.UpdateState) (domain.UpdateState, error) {
// 造成未读重复累计、dialog 预览被旧消息抢占。持久化 pts 的客户端Android current, err := s.currentState(ctx, userID)
// 启动时直接带本地 pts 调 getDifference不经过 getState不受影响。
func (s *Service) AcknowledgeCurrentState(ctx context.Context, authKeyID [8]byte, userID int64) (domain.UpdateState, error) {
st, err := s.currentState(ctx, userID)
if err != nil { if err != nil {
return domain.UpdateState{}, err return domain.UpdateState{}, err
} }
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil { from = clampDifferenceState(from, current)
if err := s.observeClientState(ctx, authKeyID, userID, from); err != nil {
return domain.UpdateState{}, err return domain.UpdateState{}, err
} }
// getState 明确建立“从当前快照开始同步”的 baseline即使响应丢失客户端也会 return from, nil
// 重试 getState/重新拉 snapshot而不会依赖 baseline 之前的 durable event。 }
if err := s.observeClientState(ctx, authKeyID, userID, st); err != nil {
return domain.UpdateState{}, err // CommitDeliveredState persists the exact cursor justified by a physically
// delivered RPC result. The store owns the atomic/monotonic invariant because
// delivery callbacks from different responses may complete out of order.
func (s *Service) CommitDeliveredState(ctx context.Context, authKeyID [8]byte, userID int64, st domain.UpdateState, mode domain.UpdateStateCommitMode) error {
if s.states == nil {
return nil
} }
return st, nil if mode != domain.UpdateStateCommitDeliveredOnly && mode != domain.UpdateStateCommitDeliveredAndObservedBaseline {
return fmt.Errorf("invalid delivered update state commit mode %d", mode)
}
st.Seq = 0
return s.states.CommitDeliveredState(ctx, authKeyID, userID, st, mode)
} }
// getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。 // getDifferenceLimit 是单次 getDifference 返回的最大连续事件数;超出置 Partial 让客户端翻页。
@ -171,19 +178,9 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
if err != nil { if err != nil {
return domain.UpdateDifference{}, err return domain.UpdateDifference{}, err
} }
// 只把客户端在本次请求中实际带回的 cursor 记为 observed。绝不能把本次将要 // Computation is pure with respect to device confirmed/observed state. The
// 返回的 State 当确认:响应可能在 socket/进程故障中丢失。恶意/损坏客户端带来的 // request observer and physical-delivery commit are explicit caller phases.
// 超前 pts 钳到账号当前连续水位,避免把 retention 安全边界推过 durable truth。 from = clampDifferenceState(from, st)
observed := from
if observed.Pts < 0 {
observed.Pts = 0
}
if observed.Pts > st.Pts {
observed.Pts = st.Pts
}
if err := s.observeClientState(ctx, authKeyID, userID, observed); err != nil {
return domain.UpdateDifference{}, err
}
// TDesktop 不支持账号级 updates.differenceTooLong。retention 只能删除所有授权 // TDesktop 不支持账号级 updates.differenceTooLong。retention 只能删除所有授权
// 设备都已确认的共同前缀;当前设备若仍带更旧 pts用一个空的普通 // 设备都已确认的共同前缀;当前设备若仍带更旧 pts用一个空的普通
// differenceSlice 把 IntermediateState 推进到已确认 checkpoint再从 live tail 续拉。 // differenceSlice 把 IntermediateState 推进到已确认 checkpoint再从 live tail 续拉。
@ -196,9 +193,6 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
if from.Date != 0 { if from.Date != 0 {
st.Date = from.Date st.Date = from.Date
} }
if err := s.saveConfirmedState(ctx, authKeyID, userID, st); err != nil {
return domain.UpdateDifference{}, err
}
return domain.UpdateDifference{State: st}, nil return domain.UpdateDifference{State: st}, nil
} }
events, err := s.events.ListAfter(ctx, userID, from.Pts, getDifferenceLimit) events, err := s.events.ListAfter(ctx, userID, from.Pts, getDifferenceLimit)
@ -246,9 +240,6 @@ func (s *Service) GetDifference(ctx context.Context, authKeyID [8]byte, userID i
if len(contiguous) > 0 { if len(contiguous) > 0 {
out.Date = contiguous[len(contiguous)-1].Date out.Date = contiguous[len(contiguous)-1].Date
} }
if err := s.saveConfirmedState(ctx, authKeyID, userID, out); err != nil {
return domain.UpdateDifference{}, err
}
return domain.UpdateDifference{ return domain.UpdateDifference{
State: out, State: out,
Events: contiguous, Events: contiguous,
@ -276,12 +267,20 @@ func (s *Service) retainedPrefixCheckpoint(ctx context.Context, authKeyID [8]byt
} else if checkpoint.Date == 0 { } else if checkpoint.Date == 0 {
checkpoint.Date = current.Date checkpoint.Date = current.Date
} }
if err := s.saveConfirmedState(ctx, authKeyID, userID, checkpoint); err != nil {
return domain.UpdateDifference{}, false, err
}
return domain.UpdateDifference{State: checkpoint, Partial: true}, true, nil return domain.UpdateDifference{State: checkpoint, Partial: true}, true, nil
} }
func clampDifferenceState(from, current domain.UpdateState) domain.UpdateState {
if from.Pts < 0 {
from.Pts = 0
}
if from.Pts > current.Pts {
from.Pts = current.Pts
}
from.Seq = 0
return from
}
func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) { func (s *Service) currentState(ctx context.Context, userID int64) (domain.UpdateState, error) {
current, err := s.currentPts(ctx, userID) current, err := s.currentPts(ctx, userID)
if err != nil { if err != nil {

View file

@ -490,11 +490,10 @@ func TestDeleteMessagesPtsRangeFeedsGetDifference(t *testing.T) {
} }
} }
// TestAcknowledgeCurrentStateAdvancesConfirmedWatermark 验证 updates.getState // TestCurrentStateCommitsAuditedBaselineOnlyAfterDelivery verifies that
// 的语义:返回账号当前最新连续 pts而非设备旧确认水位并把确认水位推进 // computing a getState result is side-effect free and that its physically
// 到此——TDesktop 不持久化 pts启动靠 getState+getDialogs 快照对齐,返回旧 // delivered baseline advances confirmed+observed atomically.
// 水位会诱导其重放快照前差分未读重复累计、dialog 预览被旧消息抢占)。 func TestCurrentStateCommitsAuditedBaselineOnlyAfterDelivery(t *testing.T) {
func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
ctx := context.Background() ctx := context.Background()
var authKeyID [8]byte var authKeyID [8]byte
authKeyID[0] = 11 authKeyID[0] = 11
@ -509,8 +508,8 @@ func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
t.Fatalf("append: %v", err) t.Fatalf("append: %v", err)
} }
// 设备确认水位停在 pts=1 后账号又推进两格。 // 设备确认水位停在 pts=1 后账号又推进两格。
if _, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 1}); err != nil { if err := states.Save(ctx, authKeyID, userID, domain.UpdateState{Pts: 1, Date: 1700000001}); err != nil {
t.Fatalf("GetDifference: %v", err) t.Fatalf("seed confirmed state: %v", err)
} }
for pts := 2; pts <= 3; pts++ { for pts := 2; pts <= 3; pts++ {
if err := events.Append(ctx, userID, domain.UpdateEvent{ if err := events.Append(ctx, userID, domain.UpdateEvent{
@ -521,23 +520,33 @@ func TestAcknowledgeCurrentStateAdvancesConfirmedWatermark(t *testing.T) {
} }
} }
st, err := svc.AcknowledgeCurrentState(ctx, authKeyID, userID) st, err := svc.CurrentState(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("AcknowledgeCurrentState: %v", err) t.Fatalf("CurrentState: %v", err)
} }
if st.Pts != 3 { if st.Pts != 3 {
t.Fatalf("acknowledged state pts = %d, want account current 3", st.Pts) t.Fatalf("current state pts = %d, want account current 3", st.Pts)
} }
confirmed, err := svc.GetState(ctx, authKeyID, userID) confirmed, _, err := svc.ConfirmedState(ctx, authKeyID, userID)
if err != nil { if err != nil {
t.Fatalf("GetState after acknowledge: %v", err) t.Fatalf("ConfirmedState before delivery: %v", err)
} }
if confirmed.Pts != 3 { if confirmed.Pts != 1 {
t.Fatalf("confirmed watermark = %d, want advanced to 3", confirmed.Pts) t.Fatalf("confirmed before delivery = %d, want 1", confirmed.Pts)
}
if _, ok := states.ObservedClientState(authKeyID, userID); ok {
t.Fatal("computed getState unexpectedly advanced observed")
}
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, st, domain.UpdateStateCommitDeliveredAndObservedBaseline); err != nil {
t.Fatalf("CommitDeliveredState: %v", err)
}
confirmed, _, err = svc.ConfirmedState(ctx, authKeyID, userID)
if err != nil || confirmed.Pts != 3 {
t.Fatalf("confirmed after delivery = %+v err=%v, want pts=3", confirmed, err)
} }
observed, ok := states.ObservedClientState(authKeyID, userID) observed, ok := states.ObservedClientState(authKeyID, userID)
if !ok || observed.Pts != 3 { if !ok || observed.Pts != 3 {
t.Fatalf("getState observed watermark = %+v/%v, want pts=3", observed, ok) t.Fatalf("observed after delivered baseline = %+v/%v, want pts=3", observed, ok)
} }
} }
@ -559,7 +568,11 @@ func TestGetDifferenceRetainsOnlyClientObservedInputCursor(t *testing.T) {
// 服务端把 pts=1..2 放进 response并不证明客户端收到了 responseobserved 只能 // 服务端把 pts=1..2 放进 response并不证明客户端收到了 responseobserved 只能
// 保持在本次 request 实际携带的 pts=0。 // 保持在本次 request 实际携带的 pts=0。
diff, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 0, Date: 1700000100}) from, err := svc.ObserveDifferenceRequest(ctx, authKeyID, userID, domain.UpdateState{Pts: 0, Date: 1700000100})
if err != nil {
t.Fatalf("observe first request: %v", err)
}
diff, err := svc.GetDifference(ctx, authKeyID, userID, from)
if err != nil { if err != nil {
t.Fatalf("first difference: %v", err) t.Fatalf("first difference: %v", err)
} }
@ -570,10 +583,23 @@ func TestGetDifferenceRetainsOnlyClientObservedInputCursor(t *testing.T) {
if !ok || observed.Pts != 0 { if !ok || observed.Pts != 0 {
t.Fatalf("observed after merely sending response = %+v/%v, want pts=0", observed, ok) t.Fatalf("observed after merely sending response = %+v/%v, want pts=0", observed, ok)
} }
if _, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || found {
t.Fatalf("computed response advanced confirmed: found=%v err=%v", found, err)
}
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, diff.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
t.Fatalf("commit delivered difference: %v", err)
}
if confirmed, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || !found || confirmed.Pts != 2 {
t.Fatalf("confirmed after delivery = %+v/%v err=%v, want pts=2", confirmed, found, err)
}
observed, _ = states.ObservedClientState(authKeyID, userID)
if observed.Pts != 0 {
t.Fatalf("delivered difference advanced observed to %d, want 0", observed.Pts)
}
// 客户端下一次明确带回 pts=2 后,才允许 retention 把共同安全水位推进到 2。 // 客户端下一次明确带回 pts=2 后,才允许 retention 把共同安全水位推进到 2。
if _, err := svc.GetDifference(ctx, authKeyID, userID, domain.UpdateState{Pts: 2, Date: 1700000102}); err != nil { if _, err := svc.ObserveDifferenceRequest(ctx, authKeyID, userID, domain.UpdateState{Pts: 2, Date: 1700000102}); err != nil {
t.Fatalf("confirming difference: %v", err) t.Fatalf("observing next request: %v", err)
} }
observed, ok = states.ObservedClientState(authKeyID, userID) observed, ok = states.ObservedClientState(authKeyID, userID)
if !ok || observed.Pts != 2 { if !ok || observed.Pts != 2 {
@ -624,6 +650,15 @@ func TestGetDifferenceBelowRetainedFloorUsesEmptySliceCheckpoint(t *testing.T) {
if !checkpoint.Partial || len(checkpoint.Events) != 0 || checkpoint.State.Pts != 2 || checkpoint.State.Date != 1700000202 { if !checkpoint.Partial || len(checkpoint.Events) != 0 || checkpoint.State.Pts != 2 || checkpoint.State.Date != 1700000202 {
t.Fatalf("checkpoint difference = %+v, want empty differenceSlice at pts/date 2/1700000202", checkpoint) t.Fatalf("checkpoint difference = %+v, want empty differenceSlice at pts/date 2/1700000202", checkpoint)
} }
if _, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || found {
t.Fatalf("computed checkpoint advanced confirmed: found=%v err=%v", found, err)
}
if err := svc.CommitDeliveredState(ctx, authKeyID, userID, checkpoint.State, domain.UpdateStateCommitDeliveredOnly); err != nil {
t.Fatalf("commit delivered checkpoint: %v", err)
}
if confirmed, found, err := svc.ConfirmedState(ctx, authKeyID, userID); err != nil || !found || confirmed.Pts != 2 {
t.Fatalf("confirmed checkpoint = %+v/%v err=%v, want pts=2", confirmed, found, err)
}
tail, err := svc.GetDifference(ctx, authKeyID, userID, checkpoint.State) tail, err := svc.GetDifference(ctx, authKeyID, userID, checkpoint.State)
if err != nil { if err != nil {
@ -722,6 +757,10 @@ func (s *captureStateStore) Save(_ context.Context, authKeyID [8]byte, userID in
return nil return nil
} }
func (s *captureStateStore) CommitDeliveredState(ctx context.Context, authKeyID [8]byte, userID int64, state domain.UpdateState, _ domain.UpdateStateCommitMode) error {
return s.Save(ctx, authKeyID, userID, state)
}
func (s *captureStateStore) ObserveClientState(_ context.Context, _ [8]byte, _ int64, _ domain.UpdateState) error { func (s *captureStateStore) ObserveClientState(_ context.Context, _ [8]byte, _ int64, _ domain.UpdateState) error {
return nil return nil
} }

View file

@ -0,0 +1,38 @@
package android
import (
"errors"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
)
var ErrPrivateLayerRPCInvalid = errors.New("android private layer RPC is invalid")
// AdaptPrivateLayerRPC invokes the provenance-locked static gotdgen overlay
// from the generated unknown-method view. Nested values decode with the exact
// connection profile and the canonical request is re-profiled by gotd core.
func AdaptPrivateLayerRPC(view tg.LayerRPCUnknownMethodView) (tg.LayerOutboundCall, bool, error) {
outbound, handled, err := view.AdaptClientRPCOverlay(tg.LayerClientRPCOverlayDrkloAndroid)
if err == nil && !handled {
outbound, handled, err = view.AdaptClientRPCOverlay(tg.LayerClientRPCOverlayDrkloAndroidTheme)
}
if err != nil {
return tg.LayerOutboundCall{}, handled, errors.Join(ErrPrivateLayerRPCInvalid, err)
}
return outbound, handled, nil
}
// UpgradePrivateLayerRPC is retained only for Router.Dispatch's legacy test
// seam. Production admission uses AdaptPrivateLayerRPC above so its decode
// shares the outer generated request budget.
func UpgradePrivateLayerRPC(profile tg.LayerProfile, in *bin.Buffer, limits tg.LayerDecodeLimits) (*bin.Buffer, bool, error) {
upgraded, handled, err := tg.AdaptClientRPCOverlayWithLimits(profile, tg.LayerClientRPCOverlayDrkloAndroid, in, limits)
if err == nil && !handled {
upgraded, handled, err = tg.AdaptClientRPCOverlayWithLimits(profile, tg.LayerClientRPCOverlayDrkloAndroidTheme, in, limits)
}
if err != nil {
return nil, handled, errors.Join(ErrPrivateLayerRPCInvalid, err)
}
return upgraded, handled, nil
}

View file

@ -0,0 +1,54 @@
package android
import (
"errors"
"testing"
"github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/tg"
)
func TestUpgradePrivateLayerRPCOnlyAcceptsAuditedAndroidConstructors(t *testing.T) {
// DrKLO messages.forwardMessages private CRC has a body identical to the
// canonical request for the flags=0 empty-vector case.
private := bin.Buffer{}
private.PutID(0x41d41ade)
private.PutInt(0)
private.PutID(0x7f3b18ea) // inputPeerEmpty
private.PutVectorHeader(0)
private.PutVectorHeader(0)
private.PutID(0x7f3b18ea) // inputPeerEmpty
in := &bin.Buffer{Buf: private.Copy()}
upgraded, ok, err := UpgradePrivateLayerRPC(tg.LayerProfileCanonical, in, tg.LayerDecodeLimits{})
if err != nil || !ok {
t.Fatalf("upgrade private method = ok:%v err:%v", ok, err)
}
if in.Len() != 0 {
t.Fatalf("successful private method left %d bytes", in.Len())
}
if id, peekErr := upgraded.PeekID(); peekErr != nil || id != 0x13704a7c {
t.Fatalf("canonical id = %#x err=%v", id, peekErr)
}
official := bin.Buffer{}
official.PutID(0xb921bd04) // arbitrary non-private/official constructor
if value, handled, err := UpgradePrivateLayerRPC(tg.LayerProfileCanonical, &official, tg.LayerDecodeLimits{}); value != nil || handled || err != nil {
t.Fatalf("non-private method = value:%v handled:%v err:%v", value, handled, err)
}
}
func TestGeneratedPrivateLayerRPCOverlayHasAllAuditedMethods(t *testing.T) {
if got, want := tg.LayerClientRPCOverlayMethodCount(tg.LayerClientRPCOverlayDrkloAndroid), 15; got != want {
t.Fatalf("generated DrKLO method count = %d, want %d", got, want)
}
}
func TestUpgradePrivateLayerRPCRejectsMalformedBody(t *testing.T) {
malformed := bin.Buffer{}
malformed.PutID(0x41d41ade)
_, ok, err := UpgradePrivateLayerRPC(tg.LayerProfileCanonical, &malformed, tg.LayerDecodeLimits{})
if !ok || !errors.Is(err, ErrPrivateLayerRPCInvalid) {
t.Fatalf("malformed private method = ok:%v err:%v", ok, err)
}
}

View file

@ -1,6 +1,6 @@
package ios package ios
import "github.com/gotd/td/tg" import "github.com/iamxvbaba/td/tg"
// NoAppUpdate is the bounded answer used until telesrv has an application // NoAppUpdate is the bounded answer used until telesrv has an application
// release catalog. It makes iOS keep its installed build and retry on its // release catalog. It makes iOS keep its installed build and retry on its

View file

@ -1,142 +0,0 @@
# layerwire 操作手册(多 Layer 向后兼容)
> 本文是 **怎么操作**runbook。**为什么这么设计**见 [`docs/layer-compat-220-227-design.md`](../../../docs/layer-compat-220-227-design.md)。
> 改这个包前请先读完本文 + 设计文档。所有命令都从 **telesrv 模块根目录**执行。
## 这个包是干什么的
让 telesrv 同时正确服务 **Layer 220227** 的客户端,而业务 handler / gotd 永远只跑 canonical(227)、一行不改。
- **出站**:把 227 对象降级成老客户端能解的 wire 形态(`Transcode`)。
- **入站**:把老客户端发来的旧构造器升级成 227 请求,再交正常 gotd dispatcher`UpgradeInbound`)。
两个正交维度(**务必分清**
- **官方层漂移**:构造器在 layer N 的字段/CRC 与 227 不同。真值=官方 TDesktop `api.tl` 各层。**自动从 schema 生成**。
- **客户端构造器漂移**:某客户端(如 DrKLO Android手维护的 TL 实际发了个旧 layer 的官方构造器,但它声明的整体 layer 却是新的。真值=该客户端源码TLRPC.java。**声明在 `client-drift.tl` / `client_aliases.go`**。
## 文件地图
| 文件 | 角色 | 谁改 |
|---|---|---|
| `schema/canonical-227.tl` | **embed**,运行期 walker 的 227 字段布局(= gotd `td/_schema/tdesktop.tl` 的副本) | gotd 升级时 re-sync |
| `_schema/layer-2NN.tl` | 历史层官方 schema从 TDesktop git 抽,**仅生成期用**,下划线=不编译/不 embed | 升级/下探 floor 时抽取 |
| `schema/client-drift.tl` | **声明式**客户端发的旧构造器老布局body 与 227 不同的) | 发现客户端漂移时 +1 行 |
| `schema/routable-compat.tl` | **仅结构预检**:已有 RPC fallback adapter 的非 canonical wire 布局(当前只含 4 个 DrKLO theme 构造器);与 canonical 图合并后完整 walk但不自动升级 | 收敛既有手写 adapter 时维护,禁止借此新增业务 fallback |
| `client_aliases.go` | 客户端漂移里 **body 与 227 字节一致**的,纯 `老CRC→227CRC` | 发现纯换 CRC 漂移时 +1 条 |
| `tables_gen.go` | **生成产物**(勿手改):官方层降级表 + 入站升级表 + 新类型集 | 跑 `gen` 重生成 |
| `gen/main.go` | 生成器:对拍 schema、证明机械性、产 `tables_gen.go` | 升级逻辑变更时 |
| `layout.go` `walk.go` `tables.go` | 通用解释器(读/丈量/递归转码)| 核心,少动 |
| `fallback.go` | 出站手写兜底(结构性 / 227-only 类型)| CoverageGate 报缺时 |
| `inbound.go` | 入站通用升级引擎 + `fieldConverters` + `driftFieldRenames` | DriftCoverage 报缺时 |
## 核心命令
```bash
# 复核 schema 差异数字(不改文件)
go run ./internal/compat/layerwire/gen -report
# 重新生成 tables_gen.go官方层漂移表
go run ./internal/compat/layerwire/gen -emit internal/compat/layerwire/tables_gen.go
# 全部护栏(漂移门禁 + 对各历史层真实 schema 对拍 + 性能基准)
go test ./internal/compat/layerwire/
go test ./internal/compat/layerwire/ -run '^$' -bench . -benchmem # 性能
# 改完务必:
gofmt -w internal/compat/layerwire/ && go build ./... && go vet ./internal/...
```
---
## 操作 1gotd 升级canonical layer 上移,例 227 → 230
> gotd bump 是显式任务(见 AGENTS.md 铁律 #6。canonical schema 随之变化,按下列步骤同步。
1. **同步 canonical schema**gotd 的就是实际编出的字节):
```bash
cp ../td/_schema/tdesktop.tl internal/compat/layerwire/schema/canonical-230.tl
rm internal/compat/layerwire/schema/canonical-227.tl
```
`layout.go``//go:embed schema/canonical-230.tl``const CanonicalLayer = 230`
2. **把原 canonical 层并入历史 TO 层**:现在 227/228/229 成了"老层",从 TDesktop git 抽进 `_schema/`(见文末「抽取 api.tl@N」)。
3. **改生成期常量**`gen/main.go``canonicalLayer = 230`。(`supportedFloor` 不变。)
4. **重生成 + 复核**
```bash
go run ./internal/compat/layerwire/gen -report # 看 changed/new 数字是否合理
go run ./internal/compat/layerwire/gen -emit internal/compat/layerwire/tables_gen.go
```
5. **跑护栏、按报告 triage**
```bash
go test ./internal/compat/layerwire/
```
- `TestCoverageGate` 失败 = 出现了 telesrv 可达但没处理的 227(新 canonical)-only / 结构性类型 → 去 `fallback.go` 加 by-type 兜底或结构性转换,或确认 telesrv 不发就加进 `unemittedAllowlist``gate_test.go`,附理由)。
- 生成器 `-report` 里 "structural" 列出的需手写转换(参照 `fallback.go transcodePollAnswerVoters`)。
6. `gofmt`/`build`/`vet`/全量 `go test`。真机 220/老层/新层各一台回归。
## 操作 2下探 floor支持更老客户端例 220 → 215
1. 从 TDesktop git 抽 `layer-215.tl … layer-219.tl``_schema/`(见文末)。
2. 改 `supportedFloor``layout.go``SupportedFloor = 215` **和** `gen/main.go``supportedFloor = 215`(两处都要)。
3. `go run ... -emit ...` 重生成 → `go test`
4. 越老的层结构性差异越多,按 `TestCoverageGate` / 生成器 report triage同操作 1 第 5 步)。
## 操作 3新增「客户端构造器漂移」最常见
触发:某客户端发的旧构造器导致 `NOT_IMPLEMENTED`(入站)或对端渲染异常;或主动审计客户端源码发现它发旧 CRC。
1. **拿到老构造器的精确 TL 定义**
- 优先看该客户端源码的序列化DrKLO Android`TMessagesProj/.../TLRPC.java``serializeToStream`,按 `writeInt32/writeString/...` 顺序还原字段)。
- 或它是某旧 layer 官方构造器:`git -C ../tdesktop/tdesktop log -S"#<crc>" -- <api.tl>` 找到所在层,再取该层定义。
2. **判断 body 是否与 227 字节一致**
- **一致**(只是 CRC 不同;典型=227 只追加了 flag-gated 可选字段而客户端不设)→ 往 `client_aliases.go clientMethodAliases``0x<老CRC>: 0x<227CRC>`
- **不一致**(缺 flags 整数 / 字段类型变了 / 缺必填字段)→ 往 `schema/client-drift.tl` 加**一行老布局 TL**(用 method 的限定名,结果类型随便填合法值,引擎只按名字匹配 227
3. **跑测试**
```bash
go test ./internal/compat/layerwire/ -run TestInbound
```
- 绿 = 通用引擎已能自动升级(复制共享字段 + 插 flags=0 + 按 kind 补默认)。**完事**。
- `TestInboundDriftCoverage``needs converter A->B` = 有字段类型变更 → 往 `inbound.go fieldConverters` 加一条 `"A->B"`(可复用,参照 `Vector<int>->Vector<InputMessage>`)。
- 报 `field X not defaultable` 或字段**改名** → 往 `inbound.go driftFieldRenames``"<method>\x00<227字段>": "<老字段>"`(参照 `bots.exportBotToken\x00bot`)。
4. **绝不**为此写一个新的 `handleLegacyXxx` 解码 handler——统一走数据 + 通用引擎。`routable-compat.tl` 只给既存 DrKLO theme fallback 补 dispatcher 前结构门禁,不是新增 adapter 的入口。
## 操作 4出站 `TestCoverageGate` 失败
说明 telesrv 现在会发某个"经保留字段可达"的 227-only / 结构性类型,但没处理。
- 该类**有同抽象类的老成员**可降级 → `fallback.go``newTypeFallbacksByType["<抽象类>"]`(如 `PageBlock→pageBlockUnsupported`)。
- 是**结构性变更类型**且 telesrv 真发 → `fallback.go structuralTransforms` 加手写转换。
- **确认 telesrv 不发** → 加进 `gate_test.go unemittedAllowlist`**必须附理由**,引用出站构造器审计)。
---
## 护栏:每个测试拦什么
| 测试 | 拦截 |
|---|---|
| `TestWalkConsumesCanonicalObjects` | 解释器读不全某个 227 类型(字段布局漏) |
| `TestTranscodeDowngradeValid` | 降级输出对 220..226 **真实 schema** 解析失败/有残留字节 |
| `TestTranscodeChangedTypeNestedInUnchangedContainer` | 「外层 CRC 不变但内含变更类型」被误整段拷贝 |
| `TestCoverageGate` | 出站 227-only/结构性类型无 handler 又不在 allowlistgotd bump/客户端升级引入新形态时报) |
| `TestInboundDriftCoverage` | `client-drift.tl` 某条目无法自动升级(缺 converter/rename |
| `TestInboundBodyTransforms` / `TestInboundCRCSwaps` | 入站升级产出不是合法 227 请求 |
| `TestNegotiatedLayerStickyContract` | layer 协商的 `(layer, ok)` 契约(避免缓存驱逐把老客户端误降回 227 |
**运行期 fail-safe**:出站遇未处理类型 → `Transcode` 返错 → 边界记日志并发 canonical 字节(连接存活,单对象可能渲染异常)。入站遇未覆盖旧 CRC → 落 gotd dispatcher → `NOT_IMPLEMENTED`(须按 AGENTS.md #5 进 compatibility trace + 矩阵)。**护栏的意义就是把这些从"线上撞见"提前到"提交期/测试期发现"。**
## 抽取 api.tl@N(操作 1/2 用)
```bash
TD=../tdesktop/tdesktop
APITL=Telegram/SourceFiles/mtproto/scheme/api.tl
# 找 layer N 的提交(取最后一个写入 "// LAYER N" 的;可能有初版+修订,选最全的)
git -C "$TD" log --oneline -S"// LAYER N" -- "$APITL"
# 抽取(务必校验文件末尾确是 "// LAYER N"
git -C "$TD" show <commit>:"$APITL" > internal/compat/layerwire/_schema/layer-N.tl
tail -1 internal/compat/layerwire/_schema/layer-N.tl # 应为: // LAYER N
```
各层→commit 对照见设计文档 §3 表220..227 的 canonical 抽取点)。`gotd/tl` 解析器能直接吃 TDesktop api.tl无需改格式。
## 稳态心法
**喂新 schemagotd 或更老层)→ 跑 `gen` + `go test` → 护栏吐出短清单 → 人只处理新出现的 fallback / 结构性 / converter / rename。** 不再有"运行时撞 NOT_IMPLEMENTED 再手写 handler"。

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,83 +0,0 @@
package layerwire
import (
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
func benchEncode(o bin.Encoder) []byte {
var b bin.Buffer
if err := o.Encode(&b); err != nil {
panic(err)
}
return b.Copy()
}
// BenchmarkTranscodeOutbound measures the outbound seam: the 227 passthrough
// (the overwhelmingly common case) vs a real message downgrade to 220.
func BenchmarkTranscodeOutbound(b *testing.B) {
richMessage := canonicalCorpus()[1]
raw := benchEncode(richMessage)
b.Run("identity_227", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := Transcode(raw, 227); err != nil {
b.Fatal(err)
}
}
})
b.Run("downgrade_220_message", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := Transcode(raw, 220); err != nil {
b.Fatal(err)
}
}
})
dialogs := benchEncode(canonicalCorpus()[11]) // messages.dialogs
b.Run("downgrade_220_dialogs", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := Transcode(dialogs, 220); err != nil {
b.Fatal(err)
}
}
})
}
// BenchmarkUpgradeInbound measures the inbound seam: a 227 client (miss, the
// common case), a CRC-swap drift, and a body-transform drift.
func BenchmarkUpgradeInbound(b *testing.B) {
b.Run("miss_227", func(b *testing.B) {
// A canonical method id that needs no upgrade.
body := benchEncode(&tg.HelpGetConfigRequest{})
b.ReportAllocs()
for i := 0; i < b.N; i++ {
in := &bin.Buffer{Buf: body}
id, _ := in.PeekID()
if _, ok, _ := UpgradeInbound(id, in); ok {
b.Fatal("unexpected upgrade")
}
}
})
// uploadMedia body transform (peer+media -> flags+peer+media).
var um bin.Buffer
um.PutID(0x519bc2b1)
_ = (&tg.InputPeerSelf{}).Encode(&um)
_ = (&tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 10, Parts: 1, Name: "a.jpg"}}).Encode(&um)
umRaw := um.Copy()
b.Run("drift_uploadMedia", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
in := &bin.Buffer{Buf: append([]byte(nil), umRaw...)}
if _, ok, err := UpgradeInbound(0x519bc2b1, in); !ok || err != nil {
b.Fatal(ok, err)
}
}
})
}

View file

@ -1,36 +0,0 @@
package layerwire
// clientMethodAliases maps method constructor ids emitted by a specific client's
// hand-maintained TL (constructor *drift*, NOT official api.tl layer drift) to
// the canonical (227) id — for the subset whose request body is byte-identical
// to canonical so a 4-byte id swap suffices.
//
// This is the second, hand-maintained half of the inbound compat table; the
// generated inboundMethodUpgrades (tables_gen.go) covers official layer drift
// derived from TDesktop api.tl, which by construction never contains these
// client-private ids. Entries here are sourced from client source (e.g. DrKLO
// TLRPC.java), each verified body-compatible against the canonical layout.
//
// Client-drift constructors whose body differs structurally (a missing flags
// integer, a different field type, or that need business logic such as
// access_hash resolution or a legacy-shaped response) are NOT here — they remain
// dedicated decode handlers in internal/rpc (dispatchCompat), because the body
// cannot be reused as-is and the transform needs more than an id swap.
var clientMethodAliases = map[uint32]uint32{
// DrKLO Android (post-Layer225) messages.forwardMessages. Wire layout is
// identical to canonical #13704a7c for every flag bit the client can set
// (the only schema delta is flags it never sets), so the body decodes as-is.
0x41d41ade: 0x13704a7c,
// DrKLO Android channels.inviteToChannel. Body is still
// channel:InputChannel users:Vector<InputUser> = canonical #c9e33d54.
0x199f3a6c: 0xc9e33d54,
// DrKLO Android updates.getDifference. Old layout only uses flags.0
// (pts_total_limit); canonical #19c2f763 adds pts_limit(flags.1)/
// qts_limit(flags.2) which the client leaves clear ⇒ zero wire bytes, so the
// old body decodes byte-for-byte as canonical.
0x25939651: 0x19c2f763,
// DrKLO Android messages.createChat. Body is byte-identical to canonical
// #92ceddd4; the legacy-shaped response is produced by ClientType==Android
// (createChatNeedsLegacyChat), so no dedicated handler is needed.
0x0034a818: 0x92ceddd4,
}

View file

@ -1,126 +0,0 @@
package layerwire
import (
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
// canonicalCorpus is a diverse set of canonical (gotd, Layer 227) objects shared
// by the walker and transcoder tests. It deliberately exercises changed types
// (message, messageMediaPhoto, keyboardButton*, dialog, channelFull, userFull,
// pollResults/pollAnswerVoters), nested containers, vectors, and multi-flags.
func canonicalCorpus() []bin.Encoder {
photo := &tg.Photo{
ID: 10,
AccessHash: 11,
FileReference: []byte{1, 2, 3},
Date: 100,
Sizes: []tg.PhotoSizeClass{
&tg.PhotoSize{Type: "x", W: 100, H: 100, Size: 2048},
&tg.PhotoStrippedSize{Type: "i", Bytes: []byte{9, 8, 7}},
},
DCID: 2,
}
return []bin.Encoder{
&tg.Message{ID: 1, PeerID: &tg.PeerUser{UserID: 2}, Date: 100, Message: "hi"},
&tg.Message{
Out: true,
ID: 2,
FromID: &tg.PeerUser{UserID: 3},
PeerID: &tg.PeerChannel{ChannelID: 4},
Date: 101,
Message: "rich",
Media: &tg.MessageMediaPhoto{Photo: photo, TTLSeconds: 5},
Entities: []tg.MessageEntityClass{
&tg.MessageEntityBold{Offset: 0, Length: 2},
&tg.MessageEntityTextURL{Offset: 0, Length: 2, URL: "https://x"},
},
ReplyMarkup: &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
{Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{Text: "ok", Data: []byte("d")},
&tg.KeyboardButtonURL{Text: "go", URL: "https://y"},
}},
}},
ReplyTo: &tg.MessageReplyHeader{ReplyToMsgID: 1},
FwdFrom: tg.MessageFwdHeader{FromName: "n", Date: 99},
Views: 7,
Forwards: 2,
Reactions: tg.MessageReactions{Results: []tg.ReactionCount{{Reaction: &tg.ReactionEmoji{Emoticon: "👍"}, Count: 3}}},
GroupedID: 555,
},
&tg.MessageService{
ID: 3,
PeerID: &tg.PeerUser{UserID: 2},
Date: 102,
Action: &tg.MessageActionChatEditTitle{Title: "t"},
},
&tg.Updates{
Updates: []tg.UpdateClass{
&tg.UpdateNewMessage{Message: &tg.Message{ID: 9, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "u"}, Pts: 1, PtsCount: 1},
&tg.UpdateMessageID{ID: 9, RandomID: 123},
},
Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}},
Chats: []tg.ChatClass{&tg.Channel{ID: 4, AccessHash: 6, Title: "C", Photo: &tg.ChatPhotoEmpty{}}},
Date: 100,
Seq: 1,
},
&tg.User{
ID: 2,
AccessHash: 5,
FirstName: "A",
Username: "a",
Photo: &tg.UserProfilePhoto{PhotoID: 7, DCID: 2},
Status: &tg.UserStatusOnline{Expires: 999},
},
&tg.UserFull{
ID: 2,
About: "hi",
Settings: tg.PeerSettings{},
NotifySettings: tg.PeerNotifySettings{},
CommonChatsCount: 0,
},
&tg.Channel{ID: 4, AccessHash: 6, Title: "C", Megagroup: true, Photo: &tg.ChatPhotoEmpty{}},
&tg.ChannelFull{
ID: 4,
About: "about",
ReadInboxMaxID: 1,
ReadOutboxMaxID: 1,
UnreadCount: 0,
ChatPhoto: &tg.PhotoEmpty{ID: 0},
NotifySettings: tg.PeerNotifySettings{},
Pts: 1,
},
&tg.Dialog{
Peer: &tg.PeerUser{UserID: 2},
TopMessage: 2,
ReadInboxMaxID: 1,
NotifySettings: tg.PeerNotifySettings{},
},
&tg.Poll{
ID: 1,
Question: tg.TextWithEntities{Text: "q?"},
Answers: []tg.PollAnswerClass{
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "a"}, Option: []byte{0}},
&tg.PollAnswer{Text: tg.TextWithEntities{Text: "b"}, Option: []byte{1}},
},
},
&tg.PollResults{
Results: []tg.PollAnswerVoters{
{Option: []byte{0}, Voters: 3, Chosen: true},
{Option: []byte{1}, Voters: 1},
},
TotalVoters: 4,
},
&tg.MessagesDialogs{
Dialogs: []tg.DialogClass{&tg.Dialog{Peer: &tg.PeerUser{UserID: 2}, TopMessage: 2, NotifySettings: tg.PeerNotifySettings{}}},
Messages: []tg.MessageClass{&tg.Message{ID: 2, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "x"}},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}},
},
&tg.MessagesMessages{
Messages: []tg.MessageClass{&tg.Message{ID: 2, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "x"}},
Chats: []tg.ChatClass{},
Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}},
},
}
}

View file

@ -1,144 +0,0 @@
package layerwire
import "github.com/gotd/td/bin"
// Hand-written transforms for the message subtree: structural changes that are
// not pure field drops, and 227-only constructors that older clients cannot
// decode. By-abstract-type fallbacks auto-cover future variants of the same
// class (e.g. a new MessageAction added in a later layer still degrades to
// messageActionEmpty). See docs/layer-compat-220-227-design.md §5.2.
const (
messageActionEmptyID = 0xb6aef7b0 // messageActionEmpty = MessageAction
messageEntityUnknownID = 0xbb92ba95 // messageEntityUnknown offset:int length:int = MessageEntity
pageBlockUnsupportedID = 0x13567e8a // pageBlockUnsupported = PageBlock
textEmptyID = 0xdc3d824f // textEmpty = RichText
)
func init() {
structuralTransforms["pollAnswerVoters"] = transcodePollAnswerVoters
// 227-only constructors degrade to a class member every supported layer has.
// Each target carries no body, so the replacement is a bare id (a new
// variant inside a Vector keeps its slot — no element drop needed).
newTypeFallbacksByType["MessageAction"] = replaceWithBare(messageActionEmptyID)
newTypeFallbacksByType["PageBlock"] = replaceWithBare(pageBlockUnsupportedID)
newTypeFallbacksByType["RichText"] = replaceWithBare(textEmptyID)
newTypeFallbacksByType["MessageEntity"] = fallbackMessageEntity
}
// replaceWithBare consumes the canonical (227-only) object and emits a
// bodyless constructor id the target layer understands.
func replaceWithBare(id uint32) fallbackFunc {
return func(cl *ctorLayout, in, out *bin.Buffer, layer, depth int, walk *walkState) error {
if err := in.ConsumeID(cl.crc); err != nil {
return err
}
if err := walk.skipCtorBody(canonical, in, cl, depth); err != nil {
return err
}
out.PutID(id)
return nil
}
}
// peerVectorField is a synthetic Vector<Peer> layout used to consume the
// canonical recent_voters field.
var peerVectorField = fieldLayout{
kind: kindVector,
flagBit: -1,
elem: &fieldLayout{kind: kindObject, typeName: "Peer", flagBit: -1},
}
var pollOptionBytesField = fieldLayout{kind: kindBytes, flagBit: -1}
// transcodePollAnswerVoters downgrades pollAnswerVoters: canonical (227) made
// voters conditional (flags.2?int) and added recent_voters (flags.2?Vector<Peer>);
// older layers carry voters as a plain int. The leading CRC is already consumed.
//
// 227: flags:# chosen:flags.0?true correct:flags.1?true option:bytes
// voters:flags.2?int recent_voters:flags.2?Vector<Peer>
// <=226: flags:# chosen:flags.0?true correct:flags.1?true option:bytes voters:int
func transcodePollAnswerVoters(cl *ctorLayout, target uint32, in, out *bin.Buffer, layer, depth int, walk *walkState) error {
flags, err := in.Uint32()
if err != nil {
return err
}
optionStart := in.Buf
if err := walk.skipValue(canonical, in, &pollOptionBytesField, cl, depth); err != nil {
return err
}
optionRaw := optionStart[:len(optionStart)-len(in.Buf)]
var voters int
if flags&(1<<2) != 0 {
if voters, err = in.Int(); err != nil {
return err
}
if err := walk.skipValue(canonical, in, &peerVectorField, cl, depth); err != nil {
return err
}
}
out.PutID(target)
out.PutUint32(flags & 0b11) // retain chosen/correct, clear the moved bit 2
out.Put(optionRaw)
out.PutInt(voters)
return nil
}
// fallbackMessageEntity replaces any 227-only MessageEntity with
// messageEntityUnknown, preserving offset/length so text positions stay valid.
func fallbackMessageEntity(cl *ctorLayout, in, out *bin.Buffer, layer, depth int, walk *walkState) error {
id, err := in.PeekID()
if err != nil {
return err
}
if err := in.ConsumeID(id); err != nil {
return err
}
offset, length, err := canonical.decodeOffsetLength(in, cl, depth, walk)
if err != nil {
return err
}
out.PutID(messageEntityUnknownID)
out.PutInt(offset)
out.PutInt(length)
return nil
}
// decodeOffsetLength walks a constructor body (no leading CRC) per the canonical
// layout, returning its offset/length int fields and discarding the rest.
func (m *schemaModel) decodeOffsetLength(in *bin.Buffer, cl *ctorLayout, depth int, walk *walkState) (offset, length int, err error) {
var flags map[string]uint32
for i := range cl.fields {
f := &cl.fields[i]
if f.isFlags {
v, e := in.Uint32()
if e != nil {
return 0, 0, e
}
if flags == nil {
flags = make(map[string]uint32, 2)
}
flags[f.name] = v
continue
}
if f.conditional() && flags[f.flagName]&(1<<uint(f.flagBit)) == 0 {
continue
}
switch {
case f.kind == kindInt && f.name == "offset":
if offset, err = in.Int(); err != nil {
return
}
case f.kind == kindInt && f.name == "length":
if length, err = in.Int(); err != nil {
return
}
default:
if err = walk.skipValue(m, in, f, cl, depth); err != nil {
return
}
}
}
return
}

View file

@ -1,163 +0,0 @@
package layerwire
import (
"sort"
"strings"
"testing"
)
// isInbound reports whether a constructor is a client->server (Input*) type the
// server never emits, so it cannot appear in downgraded output.
func isInbound(cl *ctorLayout) bool {
return strings.HasPrefix(cl.result, "Input") || strings.HasPrefix(cl.name, "input")
}
// collectReachableTypes returns abstract/bare type names that can appear in
// downgraded output at a layer: those referenced by a *retained* field of a
// constructor that is itself emittable (not a function, not a 227-only type that
// is replaced wholesale, not an inbound Input* type).
func collectReachableTypes(lt *layerTables) map[string]bool {
refs := map[string]bool{}
var addField func(f *fieldLayout)
addField = func(f *fieldLayout) {
switch f.kind {
case kindObject, kindBareObject:
refs[f.typeName] = true
case kindVector, kindVectorBare:
addField(f.elem)
}
}
for crc, cl := range canonical.byCRC {
if cl.isFunc || isInbound(cl) || lt.newTypes[crc] {
continue
}
var keep map[string]bool
if r := lt.rules[crc]; r != nil && r.structural == "" {
keep = r.keep
}
for i := range cl.fields {
f := &cl.fields[i]
if f.isFlags {
continue
}
if keep != nil && !keep[f.name] {
continue // dropped by a mechanical rule
}
addField(f)
}
}
return refs
}
// unemittedAllowlist is the curated set of reachable-but-unhandled 227-only
// constructors that telesrv does not actually emit (confirmed against the
// outbound-constructor scoping audit, 2026-06-25). They live behind features the
// server lacks (instant-view rich pages, AI compose, managed bots, web-browser
// settings, guest chat, star-gift rarity/craft, join-chat bot results). The gate
// fails if a NEW reachable type appears that is neither handled nor listed here,
// forcing a human to triage on every gotd bump / client upgrade.
var unemittedAllowlist = map[string]bool{
"aiComposeTone": true,
"aiComposeToneDefault": true,
"aiComposeToneExample": true,
"botInlineMessageRichMessage": true,
"channelAdminLogEventActionParticipantEditRank": true,
"joinChatBotResultApproved": true,
"joinChatBotResultDeclined": true,
"joinChatBotResultQueued": true,
"joinChatBotResultWebView": true,
"messages.chatInviteJoinResultWebView": true,
"messages.emojiGameDiceInfo": true,
"messages.emojiGameUnavailable": true,
"requestPeerTypeCreateBot": true,
"richMessage": true,
"sendMessageRichMessageDraftAction": true,
"starGiftAttributeRarity": true,
"starGiftAttributeRarityEpic": true,
"starGiftAttributeRarityLegendary": true,
"starGiftAttributeRarityRare": true,
"starGiftAttributeRarityUncommon": true,
"topPeerCategoryBotsGuestChat": true,
"updateAiComposeTones": true,
"updateBotGuestChatQuery": true,
"updateChatParticipantRank": true,
"updateEmojiGameInfo": true,
"updateJoinChatWebViewDecision": true,
"updateManagedBot": true,
"updateNewBotConnection": true,
"updateStarGiftCraftFail": true,
"updateWebBrowserException": true,
"updateWebBrowserSettings": true,
"webDomainException": true,
"webPageAttributeAiComposeTone": true,
// Structural changed-types telesrv does not emit (see design Appendix C and
// the scoping audit); their hand transforms are deferred to CI-todo.
"pageListOrderedItemText": true,
"pageListOrderedItemBlocks": true,
"starGiftAttributeModel": true,
"starGiftAttributeBackdrop": true,
"starGiftAttributePattern": true,
"urlAuthResultAccepted": true,
"inputMediaPoll": true, // inbound only
}
func newTypeHandled(crc uint32, result string) bool {
if newTypeFallbacks[crc] != nil {
return true
}
return newTypeFallbacksByType[result] != nil
}
// TestCoverageGate is the drift gate. For every supported layer, each 227-only
// or structural constructor that can appear in downgraded output must be either
// handled (fallback / structural transform) or explicitly allowlisted as not
// emitted. A bare failure means new wire shape slipped in unhandled.
func TestCoverageGate(t *testing.T) {
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
lt := tables[layer]
if lt == nil {
t.Fatalf("no tables for layer %d", layer)
}
reach := collectReachableTypes(lt)
// Structural rules that are reachable need a registered transform.
for crc, r := range lt.rules {
if r.structural == "" {
continue
}
cl := canonical.byCRC[crc]
reachable := cl != nil && reach[cl.result] && !isInbound(cl)
handled := structuralTransforms[r.structural] != nil
if reachable && !handled && !unemittedAllowlist[nameOf(crc)] {
t.Errorf("layer %d: reachable structural %s (%#08x) has no transform", layer, nameOf(crc), crc)
}
}
// New constructors reachable through a retained field need a fallback.
var gaps []string
for crc := range lt.newTypes {
cl := canonical.byCRC[crc]
if cl == nil || cl.isFunc || isInbound(cl) {
continue
}
if !reach[cl.result] {
continue
}
if newTypeHandled(crc, cl.result) || unemittedAllowlist[cl.name] {
continue
}
gaps = append(gaps, cl.name)
}
if len(gaps) > 0 {
sort.Strings(gaps)
t.Errorf("layer %d: %d reachable 227-only types lack a fallback or allowlist entry:\n %v", layer, len(gaps), gaps)
}
}
}
func nameOf(crc uint32) string {
if cl := canonical.byCRC[crc]; cl != nil {
return cl.name
}
return "?"
}

View file

@ -1,450 +0,0 @@
// Command layerwire-gen diffs the canonical gotd schema (Layer 227, the bytes
// telesrv actually emits) against historical TDesktop api.tl layers (220..226)
// and classifies every per-constructor change as either MECHANICAL (a pure
// append-only delta that can be downgraded by dropping trailing/optional fields
// and masking flag bits) or STRUCTURAL (field reorder / reinterpretation that
// needs a hand-written transform).
//
// It is the generate-time half of the layer-compat design
// (docs/layer-compat-220-227-design.md). Run from the telesrv module root:
//
// go run ./internal/compat/layerwire/gen -report
//
// This first iteration only prints a report so the numbers can be validated
// against the design doc before any table is emitted.
package main
import (
"flag"
"fmt"
"go/format"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/gotd/tl"
)
// canonicalLayer is the layer telesrv's gotd is pinned to.
const canonicalLayer = 227
// supportedFloor is the oldest client layer telesrv aims to serve.
const supportedFloor = 220
// spec is a single TL constructor or method with field-level metadata.
type spec struct {
qname string // qualified name, e.g. "messages.dialogs" or "message"
crc uint32
params []tl.Parameter
isFunc bool
}
// schema indexes one parsed .tl file by qualified name and by CRC.
type schema struct {
layer int
byName map[string]*spec
byCRC map[uint32]*spec
ordered []*spec
}
func qualify(d tl.Definition) string {
if len(d.Namespace) == 0 {
return d.Name
}
return strings.Join(d.Namespace, ".") + "." + d.Name
}
func load(path string) (*schema, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
parsed, err := tl.Parse(f)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
s := &schema{
layer: parsed.Layer,
byName: make(map[string]*spec),
byCRC: make(map[uint32]*spec),
}
for i := range parsed.Definitions {
sd := parsed.Definitions[i]
d := sd.Definition
sp := &spec{
qname: qualify(d),
crc: d.ID,
params: d.Params,
isFunc: sd.Category == tl.CategoryFunction,
}
// Skip the implicit vector pseudo-definition if present.
if sp.qname == "vector" {
continue
}
s.byName[sp.qname] = sp
s.byCRC[sp.crc] = sp
s.ordered = append(s.ordered, sp)
}
return s, nil
}
// classifyResult describes how a changed constructor downgrades from canonical
// (227) to a target layer.
type classifyResult struct {
mechanical bool
drops []string // canonical fields absent at the target layer
reason string // populated when !mechanical
}
// classifyDowngrade aligns the target params as a subsequence (by name) of the
// canonical params. Success ⇒ mechanical drop of the unmatched canonical fields.
// Any name mismatch, type change, or flag-condition change ⇒ structural.
func classifyDowngrade(from, to *spec) classifyResult {
var drops []string
i, j := 0, 0
fp, tp := from.params, to.params
for j < len(tp) {
// Advance over canonical fields until we reach the target field name.
for i < len(fp) && fp[i].Name != tp[j].Name {
drops = append(drops, fp[i].Name)
i++
}
if i == len(fp) {
return classifyResult{reason: fmt.Sprintf("target field %q not found in canonical (reorder/insert)", tp[j].Name)}
}
if reason := compatible(fp[i], tp[j]); reason != "" {
return classifyResult{reason: fmt.Sprintf("field %q: %s", tp[j].Name, reason)}
}
i++
j++
}
for ; i < len(fp); i++ {
drops = append(drops, fp[i].Name)
}
return classifyResult{mechanical: true, drops: drops}
}
// compatible reports "" if a kept field is wire-compatible between canonical and
// target, or a reason string otherwise.
func compatible(f, t tl.Parameter) string {
if f.Flags != t.Flags {
return "flags-int vs field mismatch"
}
if f.Flags {
// Both are `#` flag integers; the name must match because conditional
// fields reference it by name.
if f.Name != t.Name {
return fmt.Sprintf("flags int renamed %q->%q", t.Name, f.Name)
}
return ""
}
// Conditional-ness must match exactly (no flag-bit remap supported yet).
fc, tc := f.Flag != nil, t.Flag != nil
if fc != tc {
return "conditional-ness changed"
}
if fc {
if f.Flag.Name != t.Flag.Name || f.Flag.Index != t.Flag.Index {
return fmt.Sprintf("flag moved %s.%d->%s.%d", t.Flag.Name, t.Flag.Index, f.Flag.Name, f.Flag.Index)
}
}
if f.Type.String() != t.Type.String() {
return fmt.Sprintf("type changed %s->%s", t.Type.String(), f.Type.String())
}
return ""
}
type changed struct {
qname string
fromCRC, toCRC uint32
res classifyResult
}
// diff compares canonical (from) against a single target layer (to).
type diffResult struct {
layer int
changedTypes []changed
changedMethods []changed
newTypes []string // exist in canonical, absent at target
newMethods []string
removedTypes []string // exist at target, absent in canonical
}
func diff(from, to *schema) diffResult {
r := diffResult{layer: to.layer}
for _, sp := range from.ordered {
other, ok := to.byName[sp.qname]
if !ok {
if sp.isFunc {
r.newMethods = append(r.newMethods, sp.qname)
} else {
r.newTypes = append(r.newTypes, sp.qname)
}
continue
}
if other.crc == sp.crc {
continue
}
c := changed{qname: sp.qname, fromCRC: sp.crc, toCRC: other.crc, res: classifyDowngrade(sp, other)}
if sp.isFunc {
r.changedMethods = append(r.changedMethods, c)
} else {
r.changedTypes = append(r.changedTypes, c)
}
}
for _, sp := range to.ordered {
if _, ok := from.byName[sp.qname]; !ok {
r.removedTypes = append(r.removedTypes, sp.qname)
}
}
return r
}
func main() {
var (
schemaDir = flag.String("schema", "internal/compat/layerwire/_schema", "dir with layer-NNN.tl")
canonical = flag.String("canonical", "internal/compat/layerwire/schema/canonical-227.tl", "gotd canonical 227 schema")
emit = flag.String("emit", "", "write generated tables_gen.go to this path")
_ = flag.Bool("report", true, "print report")
)
flag.Parse()
canon, err := load(*canonical)
if err != nil {
fmt.Fprintln(os.Stderr, "load canonical:", err)
os.Exit(1)
}
if *emit != "" {
if err := emitTables(canon, *schemaDir, *emit); err != nil {
fmt.Fprintln(os.Stderr, "emit:", err)
os.Exit(1)
}
fmt.Printf("wrote %s\n", *emit)
return
}
fmt.Printf("canonical (gotd) layer=%d defs=%d\n", canon.layer, len(canon.ordered))
// Per-layer diff + union across the supported range.
unionChangedTypes := map[string]bool{}
unionChangedMethods := map[string]bool{}
unionNewTypes := map[string]bool{}
unionNewMethods := map[string]bool{}
structuralTypes := map[string]string{} // qname -> reason (worst case seen)
for L := supportedFloor; L < canonicalLayer; L++ {
path := filepath.Join(*schemaDir, fmt.Sprintf("layer-%d.tl", L))
tgt, err := load(path)
if err != nil {
fmt.Fprintln(os.Stderr, "load", path, ":", err)
os.Exit(1)
}
r := diff(canon, tgt)
mech, struc := 0, 0
for _, c := range r.changedTypes {
unionChangedTypes[c.qname] = true
if c.res.mechanical {
mech++
} else {
struc++
structuralTypes[c.qname] = c.res.reason
}
}
for _, c := range r.changedMethods {
unionChangedMethods[c.qname] = true
}
for _, n := range r.newTypes {
unionNewTypes[n] = true
}
for _, n := range r.newMethods {
unionNewMethods[n] = true
}
fmt.Printf("layer %d: defs=%d changedTypes=%d (mech=%d struc=%d) changedMethods=%d newTypes=%d newMethods=%d removed=%d\n",
L, len(tgt.ordered), len(r.changedTypes), mech, struc, len(r.changedMethods), len(r.newTypes), len(r.newMethods), len(r.removedTypes))
}
fmt.Printf("\n=== UNION %d..%d vs %d ===\n", supportedFloor, canonicalLayer-1, canonicalLayer)
fmt.Printf("changed types: %d\n", len(unionChangedTypes))
fmt.Printf("changed methods: %d\n", len(unionChangedMethods))
fmt.Printf("new types: %d\n", len(unionNewTypes))
fmt.Printf("new methods: %d\n", len(unionNewMethods))
fmt.Printf("structural types (need hand transform): %d\n", len(structuralTypes))
for _, q := range sortedKeys(structuralTypes) {
fmt.Printf(" - %s : %s\n", q, structuralTypes[q])
}
// Detailed 220-vs-227 drop table (matches design doc Appendix A).
fmt.Printf("\n=== 220 vs 227 changed-type drop table ===\n")
tgt220, _ := load(filepath.Join(*schemaDir, "layer-220.tl"))
r := diff(canon, tgt220)
sort.Slice(r.changedTypes, func(a, b int) bool { return r.changedTypes[a].qname < r.changedTypes[b].qname })
for _, c := range r.changedTypes {
tag := "MECH"
detail := "drop: " + strings.Join(c.res.drops, ", ")
if !c.res.mechanical {
tag = "STRUCT"
detail = c.res.reason
}
fmt.Printf(" [%-6s] %-34s %#08x->%#08x %s\n", tag, c.qname, c.toCRC, c.fromCRC, detail)
}
}
// emitTables writes the runtime downgrade tables (tables_gen.go) for every
// supported layer: per changed constructor a mechanical keep-list or a
// structural marker, plus the set of canonical CRCs absent at that layer.
func emitTables(canon *schema, schemaDir, outPath string) error {
var b strings.Builder
b.WriteString("// Code generated by ./internal/compat/layerwire/gen; DO NOT EDIT.\n")
b.WriteString("// Source: gotd canonical schema (Layer 227) diffed against TDesktop api.tl@N.\n\n")
b.WriteString("package layerwire\n\n")
b.WriteString("// generatedTables maps a supported client layer to its canonical(227)->layer\n")
b.WriteString("// downgrade table. See docs/layer-compat-220-227-design.md.\n")
b.WriteString("var generatedTables = map[int]layerRaw{\n")
// inbound 方法升级(扁平:老方法 CRC -> 227 CRC。老 CRC 本身编码了格式,故无需 layer 维度。
// 仅收"升级安全"的方法227 新增字段全为 flag-gated 条件字段(老客户端清零位=零字节,
// 其 body 本就是合法 227 body换 4 字节 CRC 即可交给 227 handler
inboundUpgrades := map[uint32]uint32{} // oldCRC -> 227CRC
inboundUnsafe := map[string]string{} // qname -> reason
for L := supportedFloor; L < canonicalLayer; L++ {
tgt, err := load(filepath.Join(schemaDir, fmt.Sprintf("layer-%d.tl", L)))
if err != nil {
return err
}
r := diff(canon, tgt)
for _, c := range r.changedMethods {
canonSpec := canon.byName[c.qname]
if reason := methodUpgradeSafe(canonSpec, c.res); reason == "" {
inboundUpgrades[c.toCRC] = c.fromCRC // client(old) -> canonical(227)
} else if _, done := inboundUpgrades[c.toCRC]; !done {
inboundUnsafe[c.qname] = reason
}
}
fmt.Fprintf(&b, "\t%d: {\n", L)
sort.Slice(r.changedTypes, func(i, j int) bool { return r.changedTypes[i].fromCRC < r.changedTypes[j].fromCRC })
b.WriteString("\t\trules: map[uint32]ruleRaw{\n")
for _, c := range r.changedTypes {
canonSpec := canon.byName[c.qname]
if c.res.mechanical {
dropSet := map[string]bool{}
for _, d := range c.res.drops {
dropSet[d] = true
}
var keep []string
for _, p := range canonSpec.params {
if !dropSet[p.Name] {
keep = append(keep, p.Name)
}
}
fmt.Fprintf(&b, "\t\t\t0x%08x: {target: 0x%08x, keep: %s}, // %s\n", c.fromCRC, c.toCRC, goStrSlice(keep), c.qname)
} else {
fmt.Fprintf(&b, "\t\t\t0x%08x: {target: 0x%08x, structural: %q}, // %s\n", c.fromCRC, c.toCRC, c.qname, c.res.reason)
}
}
b.WriteString("\t\t},\n")
var newCRC []uint32
for _, q := range r.newTypes {
if sp := canon.byName[q]; sp != nil {
newCRC = append(newCRC, sp.crc)
}
}
sort.Slice(newCRC, func(i, j int) bool { return newCRC[i] < newCRC[j] })
b.WriteString("\t\tnewTypes: []uint32{")
for i, c := range newCRC {
if i%6 == 0 {
b.WriteString("\n\t\t\t")
}
fmt.Fprintf(&b, "0x%08x, ", c)
}
if len(newCRC) > 0 {
b.WriteString("\n\t\t")
}
b.WriteString("},\n")
b.WriteString("\t},\n")
}
b.WriteString("}\n\n")
// Flat inbound method CRC upgrade table.
b.WriteString("// inboundMethodUpgrades maps an old client method constructor id to the\n")
b.WriteString("// canonical (227) id. Only upgrade-safe changes (all 227 additions flag-gated)\n")
b.WriteString("// are listed: rewriting the 4-byte id yields a valid 227 request body.\n")
if len(inboundUnsafe) > 0 {
b.WriteString("// NOT upgrade-safe as a pure id swap (declare a body transform in client-drift.tl when needed):\n")
for _, q := range sortedKeys(inboundUnsafe) {
fmt.Fprintf(&b, "// %s: %s\n", q, inboundUnsafe[q])
}
}
b.WriteString("var inboundMethodUpgrades = map[uint32]uint32{\n")
oldCRCs := make([]uint32, 0, len(inboundUpgrades))
for old := range inboundUpgrades {
oldCRCs = append(oldCRCs, old)
}
sort.Slice(oldCRCs, func(i, j int) bool { return oldCRCs[i] < oldCRCs[j] })
for _, old := range oldCRCs {
fmt.Fprintf(&b, "\t0x%08x: 0x%08x, // %s\n", old, inboundUpgrades[old], canon.byCRC[inboundUpgrades[old]].qname)
}
b.WriteString("}\n")
formatted, err := format.Source([]byte(b.String()))
if err != nil {
_ = os.WriteFile(outPath, []byte(b.String()), 0o644)
return fmt.Errorf("gofmt: %w", err)
}
return os.WriteFile(outPath, formatted, 0o644)
}
// methodUpgradeSafe reports "" if a layer-N request body for a changed method
// is also a valid 227 body after only swapping the constructor id — i.e. the
// downgrade is mechanical and every 227-only field is flag-gated (a conditional
// field the old client leaves clear ⇒ zero wire bytes). A 227-only non-conditional
// field or an inserted flags integer breaks the byte alignment ⇒ unsafe.
func methodUpgradeSafe(canonSpec *spec, res classifyResult) string {
if !res.mechanical {
return res.reason
}
byName := map[string]tl.Parameter{}
for _, p := range canonSpec.params {
byName[p.Name] = p
}
for _, d := range res.drops {
p, ok := byName[d]
if !ok {
return fmt.Sprintf("dropped field %q not in canonical", d)
}
if p.Flags {
return fmt.Sprintf("227 inserts flags integer %q", d)
}
if p.Flag == nil {
return fmt.Sprintf("227-only field %q is non-conditional", d)
}
}
return ""
}
func goStrSlice(ss []string) string {
var b strings.Builder
b.WriteString("[]string{")
for i, s := range ss {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(strconv.Quote(s))
}
b.WriteString("}")
return b.String()
}
func sortedKeys[V any](m map[string]V) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}

View file

@ -1,326 +0,0 @@
package layerwire
import (
_ "embed"
"encoding/binary"
"fmt"
"github.com/gotd/td/bin"
)
// Canonical ids used to synthesize converted/defaulted values.
const (
inputUserID = 0xf21158c6 // inputUser user_id:long access_hash:long
inputMessageID = 0xa676a322 // inputMessageID id:int
inputChannelEmptyID = 0xee8c1e86 // inputChannelEmpty
inputChannelID = 0xf35aec28 // inputChannel channel_id:long access_hash:long
inputChannelFromMessageID = 0x5b934f9d // inputChannelFromMessage peer:InputPeer msg_id:int channel_id:long
inputPeerEmptyID = 0x7f3b18ea // inputPeerEmpty
inputPeerChannelID = 0x27bcbbfc // inputPeerChannel channel_id:long access_hash:long
inputPeerChannelFromMessageID = 0xbd2a0840 // inputPeerChannelFromMessage peer:InputPeer msg_id:int channel_id:long
boolFalseID = 0xbc799737 // boolFalse
)
//go:embed schema/client-drift.tl
var clientDriftSchema string
// driftModel holds the declared old-layout of each client-drift constructor.
var driftModel = mustLoadDrift()
func mustLoadDrift() *schemaModel {
m, err := parseSchemaModel(clientDriftSchema)
if err != nil {
panic("layerwire: parse client-drift schema: " + err.Error())
}
return m
}
// driftFieldRenames maps a canonical field that was renamed from the client's
// old constructor: key "<qualified method>\x00<canonical field>" -> old field.
// Pure schema diff cannot recover a rename, so it is declared here (data, not a
// transform). It is the only thing a structural rename needs.
var driftFieldRenames = map[string]string{
"bots.exportBotToken\x00bot": "bot_id",
"messages.editChatCreator\x00peer": "channel",
}
// fieldConverter rewrites one field whose wire type changed between the old and
// canonical layout. Keyed by "<oldTypeSig>-><newTypeSig>"; raw is the old field's
// encoded bytes. Reusable across any method with the same type change.
type fieldConverter func(raw []byte, out *bin.Buffer, walk *walkState, owner *ctorLayout, field *fieldLayout) error
var fieldConverters = map[string]fieldConverter{
// id:Vector<int> -> id:Vector<InputMessage> (wrap each int in inputMessageID).
"Vector<int>->Vector<InputMessage>": func(raw []byte, out *bin.Buffer, walk *walkState, owner *ctorLayout, field *fieldLayout) error {
in := &bin.Buffer{Buf: raw}
n, err := in.VectorHeader()
if err != nil {
return err
}
if max := walk.vectorLimit(owner, field); n > max {
return limitf("vector %s.%s length %d exceeds limit %d", ownerName(owner), fieldName(field), n, max)
}
out.PutVectorHeader(n)
for i := 0; i < n; i++ {
v, err := in.Int()
if err != nil {
return err
}
out.PutID(inputMessageID)
out.PutInt(v)
}
if in.Len() != 0 {
return malformedf("%d trailing bytes in Vector<int> converter", in.Len())
}
return nil
},
// bot_id:long -> bot:InputUser{user_id, access_hash=0}.
"long->InputUser": func(raw []byte, out *bin.Buffer, walk *walkState, owner *ctorLayout, field *fieldLayout) error {
in := &bin.Buffer{Buf: raw}
id, err := in.Long()
if err != nil {
return err
}
out.PutID(inputUserID)
out.PutLong(id)
out.PutLong(0)
if in.Len() != 0 {
return malformedf("%d trailing bytes in long converter", in.Len())
}
return nil
},
// channel:InputChannel -> peer:InputPeer for the old channels.editCreator
// Android constructor. Concrete layouts are otherwise byte-compatible.
"InputChannel->InputPeer": func(raw []byte, out *bin.Buffer, walk *walkState, owner *ctorLayout, field *fieldLayout) error {
in := &bin.Buffer{Buf: raw}
id, err := in.ID()
if err != nil {
return err
}
switch id {
case inputChannelEmptyID:
out.PutID(inputPeerEmptyID)
case inputChannelID:
out.PutID(inputPeerChannelID)
out.Put(in.Buf)
case inputChannelFromMessageID:
out.PutID(inputPeerChannelFromMessageID)
out.Put(in.Buf)
default:
return bin.NewUnexpectedID(id)
}
return nil
},
}
// UpgradeInbound converts an old client's inbound request to canonical (227)
// form so the normal gotd dispatcher can handle it. It unifies three data-driven
// sources, all of which require no per-method handler code:
// - inboundMethodUpgrades (generated from api.tl diff): official layer drift.
// - clientMethodAliases (client_aliases.go): body-identical client drift.
// - driftModel (client-drift.tl): body-different client drift, upgraded by the
// generic engine below.
//
// ok=false means no upgrade applies. On ok=true the returned buffer (canonical
// id + body) is what to dispatch.
func UpgradeInbound(id uint32, in *bin.Buffer) (*bin.Buffer, bool, error) {
if newID, ok := UpgradeMethodCRC(id); ok {
target := canonical.byCRC[newID]
if target == nil || !target.isFunc {
return nil, true, malformedf("alias %#08x targets unknown canonical method %#08x", id, newID)
}
if err := validateAliasedMethod(id, target, in.Buf); err != nil {
return nil, true, err
}
// Copy rather than rewrite in place: never mutate the caller's buffer
// (matches the body-transform path, which also returns a fresh buffer).
out := &bin.Buffer{Buf: append([]byte(nil), in.Buf...)}
binary.LittleEndian.PutUint32(out.Buf[:4], newID)
return out, true, nil
}
if old := driftModel.byCRC[id]; old != nil {
out, err := upgradeFromDrift(old, in, newWalkState())
if err != nil {
return nil, true, classifyWalkError(fmt.Errorf("layerwire: upgrade %s (%#08x): %w", old.name, id, err))
}
return out, true, nil
}
return nil, false, nil
}
// validateAliasedMethod validates the old-id/canonical-body shape before
// allocating the replacement buffer. The body is walked against the canonical
// target layout while the original constructor id remains untouched.
func validateAliasedMethod(oldID uint32, target *ctorLayout, raw []byte) error {
walk := newWalkState()
if err := walk.enter(1, "constructor"); err != nil {
return err
}
b := &bin.Buffer{Buf: raw}
if err := b.ConsumeID(oldID); err != nil {
return classifyWalkError(err)
}
if err := walk.skipCtorBody(canonical, b, target, 1); err != nil {
return classifyWalkError(err)
}
if b.Len() != 0 {
return malformedf("%d trailing bytes after aliased method %s", b.Len(), target.name)
}
return nil
}
// IsClientDrift reports whether id is a client-private constructor (DrKLO
// constructor drift), as opposed to official layer drift from api.tl.
func IsClientDrift(id uint32) bool {
if _, ok := clientMethodAliases[id]; ok {
return true
}
return driftModel.byCRC[id] != nil
}
// upgradeFromDrift rebuilds a canonical (227) request from an old client-drift
// body, comparing the declared old layout to the canonical layout field by field.
func upgradeFromDrift(old *ctorLayout, in *bin.Buffer, walk *walkState) (*bin.Buffer, error) {
target := canonical.byName[old.name]
if target == nil {
return nil, fmt.Errorf("no canonical method %q", old.name)
}
if err := walk.enter(1, "constructor"); err != nil {
return nil, err
}
if err := in.ConsumeID(old.crc); err != nil {
return nil, err
}
// Decode the old body: capture each present field's raw bytes + flag ints.
vals := make(map[string][]byte, len(old.fields))
present := make(map[string]bool, len(old.fields))
oldFlags := make(map[string]uint32, 2)
oldByName := make(map[string]*fieldLayout, len(old.fields))
for i := range old.fields {
f := &old.fields[i]
oldByName[f.name] = f
if f.isFlags {
v, err := in.Uint32()
if err != nil {
return nil, err
}
oldFlags[f.name] = v
continue
}
if f.conditional() && oldFlags[f.flagName]&(1<<uint(f.flagBit)) == 0 {
continue
}
present[f.name] = true
if f.kind == kindTrue {
continue
}
pre := in.Buf
if err := walk.skipValue(canonical, in, f, old, 1); err != nil {
return nil, fmt.Errorf("decode old field %q: %w", f.name, err)
}
vals[f.name] = pre[:len(pre)-len(in.Buf)]
}
if in.Len() != 0 {
return nil, fmt.Errorf("%d trailing bytes after old body", in.Len())
}
// Emit the canonical body.
out := &bin.Buffer{}
out.PutID(target.crc)
for i := range target.fields {
nf := &target.fields[i]
if nf.isFlags {
out.PutUint32(oldFlags[nf.name]) // 0 when absent in old (new flags int)
continue
}
oldName := nf.name
if mapped, ok := driftFieldRenames[old.name+"\x00"+nf.name]; ok {
oldName = mapped
}
if present[oldName] {
of := oldByName[oldName]
if of != nil && typeSig(of) != typeSig(nf) {
conv := fieldConverters[typeSig(of)+"->"+typeSig(nf)]
if conv == nil {
return nil, fmt.Errorf("field %q: no converter %s->%s", nf.name, typeSig(of), typeSig(nf))
}
if err := conv(vals[oldName], out, walk, old, of); err != nil {
return nil, fmt.Errorf("field %q convert: %w", nf.name, err)
}
} else {
out.Put(vals[oldName]) // shared field, identical wire (kindTrue => no bytes)
}
continue
}
// Canonical-only field absent in old.
if nf.conditional() || nf.kind == kindTrue {
continue // optional: leave absent (its flag bit is clear)
}
if err := writeDefault(nf, out); err != nil {
return nil, fmt.Errorf("field %q default: %w", nf.name, err)
}
}
return out, nil
}
// writeDefault writes the zero value of a required canonical-only field.
func writeDefault(f *fieldLayout, out *bin.Buffer) error {
switch f.kind {
case kindInt:
out.PutInt(0)
case kindLong:
out.PutLong(0)
case kindDouble:
out.PutDouble(0)
case kindInt128:
out.PutInt128(bin.Int128{})
case kindInt256:
out.PutInt256(bin.Int256{})
case kindBytes:
out.PutBytes(nil)
case kindString:
out.PutString("")
case kindBool:
out.PutID(boolFalseID)
case kindVector:
out.PutVectorHeader(0)
case kindVectorBare:
out.PutInt(0)
default:
return fmt.Errorf("cannot default kind %d (boxed object needs a transform)", f.kind)
}
return nil
}
// typeSig is a stable wire-type signature for matching/converter lookup.
func typeSig(f *fieldLayout) string {
switch f.kind {
case kindInt:
return "int"
case kindLong:
return "long"
case kindDouble:
return "double"
case kindInt128:
return "int128"
case kindInt256:
return "int256"
case kindBytes:
return "bytes"
case kindString:
return "string"
case kindBool:
return "Bool"
case kindTrue:
return "true"
case kindVector:
return "Vector<" + typeSig(f.elem) + ">"
case kindVectorBare:
return "vector<" + typeSig(f.elem) + ">"
case kindObject, kindBareObject:
return f.typeName
default:
return fmt.Sprintf("kind%d", f.kind)
}
}

View file

@ -1,115 +0,0 @@
package layerwire
import "testing"
// TestInboundUpgradeTableWellFormed checks every inbound upgrade maps an old id
// to a real canonical method id, and that the old id is genuinely historical
// (not already a canonical constructor).
func TestInboundUpgradeTableWellFormed(t *testing.T) {
if len(inboundMethodUpgrades) == 0 {
t.Fatal("inboundMethodUpgrades is empty")
}
for oldID, newID := range inboundMethodUpgrades {
cl := canonical.byCRC[newID]
if cl == nil {
t.Errorf("upgrade target %#08x is not a canonical constructor", newID)
continue
}
if !cl.isFunc {
t.Errorf("upgrade target %s (%#08x) is not a method", cl.name, newID)
}
if oldID == newID {
t.Errorf("%s: old id equals canonical id %#08x", cl.name, oldID)
}
if prev := canonical.byCRC[oldID]; prev != nil {
t.Errorf("old id %#08x collides with canonical %s", oldID, prev.name)
}
}
}
// TestClientMethodAliasesWellFormed checks every hand-maintained client-drift
// alias maps to a real canonical method, and is reachable via UpgradeMethodCRC.
func TestClientMethodAliasesWellFormed(t *testing.T) {
for oldID, newID := range clientMethodAliases {
cl := canonical.byCRC[newID]
if cl == nil || !cl.isFunc {
t.Errorf("alias target %#08x is not a canonical method", newID)
}
if _, ok := inboundMethodUpgrades[oldID]; ok {
t.Errorf("alias %#08x duplicates a generated upgrade entry", oldID)
}
if got, ok := UpgradeMethodCRC(oldID); !ok || got != newID {
t.Errorf("UpgradeMethodCRC(%#08x) = (%#08x,%v), want (%#08x,true)", oldID, got, ok, newID)
}
}
}
// TestInboundDriftCoverage is the inbound drift gate: it statically proves every
// client-drift constructor in client-drift.tl can be upgraded to its canonical
// method — shared fields match (or have a converter), canonical-only required
// fields are defaultable, and renamed fields are mapped. Adding a TL line that
// isn't auto-upgradable fails here, telling the author exactly what converter or
// rename to declare (instead of discovering it at runtime).
func TestInboundDriftCoverage(t *testing.T) {
defaultable := map[wireKind]bool{
kindInt: true, kindLong: true, kindDouble: true, kindInt128: true, kindInt256: true,
kindBytes: true, kindString: true, kindBool: true, kindVector: true, kindVectorBare: true,
}
for crc, old := range driftModel.byCRC {
target := canonical.byName[old.name]
if target == nil {
t.Errorf("drift %s (%#08x): no canonical method of that name", old.name, crc)
continue
}
oldHas := map[string]*fieldLayout{}
for i := range old.fields {
oldHas[old.fields[i].name] = &old.fields[i]
}
for i := range target.fields {
nf := &target.fields[i]
if nf.isFlags {
continue
}
oldName := nf.name
if m, ok := driftFieldRenames[old.name+"\x00"+nf.name]; ok {
oldName = m
}
if of, ok := oldHas[oldName]; ok {
if typeSig(of) != typeSig(nf) && fieldConverters[typeSig(of)+"->"+typeSig(nf)] == nil {
t.Errorf("drift %s: field %q needs converter %s->%s", old.name, nf.name, typeSig(of), typeSig(nf))
}
continue
}
if nf.conditional() || nf.kind == kindTrue {
continue // optional canonical-only field — left absent
}
if !defaultable[nf.kind] {
t.Errorf("drift %s: canonical-only required field %q (kind %d) is not defaultable; declare a transform", old.name, nf.name, nf.kind)
}
}
}
}
// TestInboundUpgradeSendMessage validates the full chain for the highest-value
// method: a layer-220 client's messages.sendMessage id upgrades to the 227 id.
func TestInboundUpgradeSendMessage(t *testing.T) {
m220 := loadLayerModel(t, 220)
old, ok := m220.byName["messages.sendMessage"]
if !ok {
t.Fatal("messages.sendMessage missing from layer-220 schema")
}
canon, ok := canonical.byName["messages.sendMessage"]
if !ok {
t.Fatal("messages.sendMessage missing from canonical schema")
}
if old.crc == canon.crc {
t.Skip("sendMessage unchanged 220->227; nothing to upgrade")
}
newID, ok := UpgradeMethodCRC(old.crc)
if !ok {
t.Fatalf("sendMessage@220 (%#08x) not in upgrade table", old.crc)
}
if newID != canon.crc {
t.Fatalf("sendMessage upgrade = %#08x, want canonical %#08x", newID, canon.crc)
}
}

View file

@ -1,236 +0,0 @@
package layerwire
import (
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
// validateMethodRequest asserts that buf holds a single canonical (227) method
// request: its leading id equals wantCRC and the canonical walker consumes every
// byte (proving the rebuilt body matches the 227 layout).
func validateMethodRequest(t *testing.T, buf *bin.Buffer, wantCRC uint32, label string) {
t.Helper()
id, err := (&bin.Buffer{Buf: buf.Buf}).PeekID()
if err != nil {
t.Fatalf("%s: peek id: %v", label, err)
}
if id != wantCRC {
t.Fatalf("%s: id = %#08x, want %#08x", label, id, wantCRC)
}
probe := &bin.Buffer{Buf: append([]byte(nil), buf.Buf...)}
if err := canonical.skipObject(probe); err != nil {
t.Fatalf("%s: result not a valid 227 request: %v", label, err)
}
if probe.Len() != 0 {
t.Fatalf("%s: %d trailing bytes in rebuilt request", label, probe.Len())
}
}
func TestInboundBodyTransforms(t *testing.T) {
// uploadMedia: peer + media -> flags + peer + media.
t.Run("uploadMedia", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x519bc2b1)
_ = (&tg.InputPeerSelf{}).Encode(&in)
_ = (&tg.InputMediaUploadedPhoto{File: &tg.InputFile{ID: 10, Parts: 1, Name: "a.jpg"}}).Encode(&in)
out, ok, err := UpgradeInbound(0x519bc2b1, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0x14967978, "uploadMedia")
})
t.Run("authSignUp", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x80eee427)
in.PutString("+15550000000")
in.PutString("hash")
in.PutString("First")
in.PutString("Last")
out, ok, err := UpgradeInbound(0x80eee427, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xaac7b717, "authSignUp")
})
t.Run("channelsGetMessages", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x93d7b347)
_ = (&tg.InputChannel{ChannelID: 4, AccessHash: 5}).Encode(&in)
in.PutVectorHeader(2)
in.PutInt(11)
in.PutInt(12)
out, ok, err := UpgradeInbound(0x93d7b347, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xad8c9a23, "channelsGetMessages")
})
t.Run("messagesGetMessages", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x4222fa74)
in.PutVectorHeader(2)
in.PutInt(21)
in.PutInt(22)
out, ok, err := UpgradeInbound(0x4222fa74, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, tg.MessagesGetMessagesRequestTypeID, "messagesGetMessages")
var req tg.MessagesGetMessagesRequest
if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil {
t.Fatalf("decode upgraded messages.getMessages: %v", err)
}
if len(req.ID) != 2 {
t.Fatalf("upgraded ids = %d, want 2", len(req.ID))
}
first, ok := req.ID[0].(*tg.InputMessageID)
if !ok || first.ID != 21 {
t.Fatalf("upgraded id[0] = %T %+v, want inputMessageID(21)", req.ID[0], req.ID[0])
}
})
t.Run("botsExportBotToken", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x0063b089)
in.PutLong(777)
in.PutID(0x997275b5) // boolTrue (revoke)
out, ok, err := UpgradeInbound(0x0063b089, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xbd0d99eb, "botsExportBotToken")
})
t.Run("accountRegisterDevice", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x637ea878)
in.PutInt(2)
in.PutString("token-blob")
out, ok, err := UpgradeInbound(0x637ea878, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xec86017a, "accountRegisterDevice")
})
t.Run("contactsSearch", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x11f812d8)
in.PutString("ngame")
in.PutInt(20)
out, ok, err := UpgradeInbound(0x11f812d8, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, tg.ContactsSearchRequestTypeID, "contactsSearch")
var req tg.ContactsSearchRequest
if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil {
t.Fatalf("decode upgraded contacts.search: %v", err)
}
if req.Flags != 0 || req.Q != "ngame" || req.Limit != 20 {
t.Fatalf("upgraded contacts.search = flags:%#x q:%q limit:%d", req.Flags, req.Q, req.Limit)
}
})
t.Run("langpackGetLangPack", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x9ab5c58e)
in.PutString("en")
out, ok, err := UpgradeInbound(0x9ab5c58e, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xf2f2330a, "langpackGetLangPack")
})
t.Run("langpackGetStrings", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x2e1ee318)
in.PutString("en")
in.PutVectorHeader(2)
in.PutString("key1")
in.PutString("key2")
out, ok, err := UpgradeInbound(0x2e1ee318, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xefea3803, "langpackGetStrings")
})
t.Run("langpackGetLanguages", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x800fd57d)
out, ok, err := UpgradeInbound(0x800fd57d, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0x42c6978f, "langpackGetLanguages")
})
t.Run("channelsEditCreatorToMessagesEditChatCreator", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x8f38cd1f)
_ = (&tg.InputChannel{ChannelID: 132, AccessHash: 8956724956393200600}).Encode(&in)
_ = (&tg.InputUser{UserID: 1780243211, AccessHash: 42}).Encode(&in)
_ = (&tg.InputCheckPasswordEmpty{}).Encode(&in)
out, ok, err := UpgradeInbound(0x8f38cd1f, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0xf743b857, "messagesEditChatCreator")
var req tg.MessagesEditChatCreatorRequest
if err := req.Decode(&bin.Buffer{Buf: append([]byte(nil), out.Buf...)}); err != nil {
t.Fatalf("decode upgraded editChatCreator: %v", err)
}
peer, ok := req.Peer.(*tg.InputPeerChannel)
if !ok || peer.ChannelID != 132 || peer.AccessHash != 8956724956393200600 {
t.Fatalf("upgraded peer = %T %+v, want inputPeerChannel", req.Peer, req.Peer)
}
user, ok := req.UserID.(*tg.InputUser)
if !ok || user.UserID != 1780243211 || user.AccessHash != 42 {
t.Fatalf("upgraded user = %T %+v, want inputUser", req.UserID, req.UserID)
}
if _, ok := req.Password.(*tg.InputCheckPasswordEmpty); !ok {
t.Fatalf("upgraded password = %T, want inputCheckPasswordEmpty", req.Password)
}
})
}
// TestInboundCRCSwaps covers the body-compatible client-drift methods that only
// need a 4-byte id swap.
func TestInboundCRCSwaps(t *testing.T) {
t.Run("updatesGetDifference", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x25939651)
in.PutUint32(0) // flags (no pts_total_limit)
in.PutInt(100) // pts
in.PutInt(200) // date
in.PutInt(0) // qts
out, ok, err := UpgradeInbound(0x25939651, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0x19c2f763, "updatesGetDifference")
})
t.Run("createChat", func(t *testing.T) {
var in bin.Buffer
in.PutID(0x0034a818)
if err := (&tg.MessagesCreateChatRequest{
Users: []tg.InputUserClass{&tg.InputUser{UserID: 2, AccessHash: 3}},
Title: "Group",
}).EncodeBare(&in); err != nil {
t.Fatalf("encode createChat body: %v", err)
}
out, ok, err := UpgradeInbound(0x0034a818, &in)
if !ok || err != nil {
t.Fatalf("upgrade: ok=%v err=%v", ok, err)
}
validateMethodRequest(t, out, 0x92ceddd4, "createChat")
})
}

View file

@ -1,204 +0,0 @@
// Package layerwire downgrades canonical (Layer 227, the bytes gotd actually
// emits) TL objects to the wire shape expected by older clients (down to
// Layer 220), and is the runtime half of docs/layer-compat-220-227-design.md.
//
// The package is schema-driven: at init it parses the embedded canonical-227
// schema into a per-constructor field layout used by a generic walker; the
// generate-time tables (tables_gen.go, produced by ./gen) describe the
// per-layer downgrade rules. Business handlers and gotd are never touched —
// they always produce Layer 227, and transcoding happens only at the edge and
// only when the negotiated client layer is below 227.
package layerwire
import (
_ "embed"
"fmt"
"strings"
"github.com/gotd/tl"
)
// CanonicalLayer is the layer telesrv's pinned gotd emits.
const CanonicalLayer = 227
// SupportedFloor is the oldest client layer the transcoder targets.
const SupportedFloor = 220
// vectorTypeID is the boxed Vector constructor id.
const vectorTypeID = 0x1cb5c415
//go:embed schema/canonical-227.tl
var canonicalSchema string
// wireKind is the on-wire representation of a single TL value.
type wireKind uint8
const (
kindInt wireKind = iota // 4 bytes
kindLong // 8 bytes
kindDouble // 8 bytes
kindInt128 // 16 bytes
kindInt256 // 32 bytes
kindBytes // TL bytes (length-prefixed, padded)
kindString // TL string (same wire as bytes)
kindBool // boxed Bool (4-byte CRC)
kindTrue // flag-only pseudo value, 0 bytes
kindVector // boxed Vector<elem> (0x1cb5c415 + n + elems)
kindVectorBare // bare vector<elem> (n + elems, no id)
kindObject // boxed object (4-byte CRC + body)
kindBareObject // bare object (body only, resolved by typeName)
)
// fieldLayout is one parameter of a constructor with its decoded wire shape.
type fieldLayout struct {
name string
kind wireKind
isFlags bool // this is a `#` flags integer
flagName string // when conditional: which flags integer gates it
flagBit int // when conditional: bit index; -1 otherwise
elem *fieldLayout // vector element layout
typeName string // object/bareObject: (qualified) referenced type name
}
func (f fieldLayout) conditional() bool { return f.flagBit >= 0 }
// ctorLayout is the decoded field layout of a single constructor.
type ctorLayout struct {
crc uint32
name string // qualified TL name, e.g. "messages.dialogs" / "message"
result string // qualified result (abstract) type name
fields []fieldLayout
isFunc bool
}
// schemaModel is the parsed canonical schema indexed for the walker.
type schemaModel struct {
byCRC map[uint32]*ctorLayout
byName map[string]*ctorLayout // qualified ctor name -> layout
bareByT map[string]*ctorLayout // bare type name -> its single constructor
ctorsOfT map[string][]*ctorLayout // abstract result type -> constructors
}
// canonical is the parsed Layer 227 model, built once at init.
var canonical = mustLoadCanonical()
func mustLoadCanonical() *schemaModel {
m, err := parseSchemaModel(canonicalSchema)
if err != nil {
panic("layerwire: parse canonical schema: " + err.Error())
}
return m
}
func qualifyName(ns []string, name string) string {
if len(ns) == 0 {
return name
}
return strings.Join(ns, ".") + "." + name
}
func qualifyType(t tl.Type) string {
return qualifyName(t.Namespace, t.Name)
}
func parseSchemaModel(src string) (*schemaModel, error) {
parsed, err := tl.Parse(strings.NewReader(src))
if err != nil {
return nil, err
}
m := &schemaModel{
byCRC: make(map[uint32]*ctorLayout),
byName: make(map[string]*ctorLayout),
bareByT: make(map[string]*ctorLayout),
ctorsOfT: make(map[string][]*ctorLayout),
}
for i := range parsed.Definitions {
sd := parsed.Definitions[i]
d := sd.Definition
name := qualifyName(d.Namespace, d.Name)
if name == "vector" {
continue // implicit Vector pseudo-definition
}
cl := &ctorLayout{
crc: d.ID,
name: name,
result: qualifyType(d.Type),
isFunc: sd.Category == tl.CategoryFunction,
}
for _, p := range d.Params {
fl, err := toFieldLayout(p)
if err != nil {
return nil, fmt.Errorf("%s field %q: %w", name, p.Name, err)
}
cl.fields = append(cl.fields, fl)
}
if prev, ok := m.byCRC[cl.crc]; ok && prev.name != cl.name {
return nil, fmt.Errorf("crc collision %#08x: %s vs %s", cl.crc, prev.name, cl.name)
}
m.byCRC[cl.crc] = cl
m.byName[cl.name] = cl
if !cl.isFunc {
m.ctorsOfT[cl.result] = append(m.ctorsOfT[cl.result], cl)
// A bare type name is the lowercase constructor name itself.
m.bareByT[cl.name] = cl
}
}
return m, nil
}
func toFieldLayout(p tl.Parameter) (fieldLayout, error) {
if p.Flags {
return fieldLayout{name: p.Name, kind: kindInt, isFlags: true, flagBit: -1}, nil
}
fl := fieldLayout{name: p.Name, flagBit: -1}
if p.Flag != nil {
fl.flagName = p.Flag.Name
fl.flagBit = p.Flag.Index
}
kind, typeName, elem, err := resolveType(p.Type)
if err != nil {
return fieldLayout{}, err
}
fl.kind = kind
fl.typeName = typeName
fl.elem = elem
return fl, nil
}
func resolveType(t tl.Type) (kind wireKind, typeName string, elem *fieldLayout, err error) {
if t.GenericArg != nil {
ek, etn, eel, eerr := resolveType(*t.GenericArg)
if eerr != nil {
return 0, "", nil, eerr
}
el := &fieldLayout{kind: ek, typeName: etn, elem: eel, flagBit: -1}
if t.Name == "vector" { // bare vector
return kindVectorBare, "", el, nil
}
return kindVector, "", el, nil
}
switch t.Name {
case "int":
return kindInt, "", nil, nil
case "long":
return kindLong, "", nil, nil
case "double":
return kindDouble, "", nil, nil
case "int128":
return kindInt128, "", nil, nil
case "int256":
return kindInt256, "", nil, nil
case "bytes":
return kindBytes, "", nil, nil
case "string":
return kindString, "", nil, nil
case "Bool":
return kindBool, "", nil, nil
case "true":
return kindTrue, "", nil, nil
}
if t.Bare {
return kindBareObject, qualifyType(t), nil, nil
}
return kindObject, qualifyType(t), nil, nil
}

View file

@ -1,80 +0,0 @@
package layerwire
import (
_ "embed"
"fmt"
"github.com/gotd/td/bin"
)
const maxOpaqueRequestBytes = 16 << 20
//go:embed schema/routable-compat.tl
var routableCompatSchema string
// routable combines the canonical Layer 227 model with the small set of
// explicitly declared compatibility-only methods. Nested objects in those
// methods are canonical Input* constructors, so one combined graph is needed
// for the same depth/vector/bytes walker to validate the complete request.
var routable = mustLoadRoutable()
func mustLoadRoutable() *schemaModel {
compat, err := parseSchemaModel(routableCompatSchema)
if err != nil {
panic("layerwire: parse routable compat schema: " + err.Error())
}
m := &schemaModel{
byCRC: make(map[uint32]*ctorLayout, len(canonical.byCRC)+len(compat.byCRC)),
byName: make(map[string]*ctorLayout, len(canonical.byName)+len(compat.byName)),
bareByT: make(map[string]*ctorLayout, len(canonical.bareByT)),
ctorsOfT: make(map[string][]*ctorLayout, len(canonical.ctorsOfT)),
}
for id, cl := range canonical.byCRC {
m.byCRC[id] = cl
}
for name, cl := range canonical.byName {
m.byName[name] = cl
}
for name, cl := range canonical.bareByT {
m.bareByT[name] = cl
}
for name, ctors := range canonical.ctorsOfT {
m.ctorsOfT[name] = ctors
}
for id, cl := range compat.byCRC {
if existing := m.byCRC[id]; existing != nil {
panic(fmt.Sprintf("layerwire: routable compat crc %#08x collides with %s", id, existing.name))
}
m.byCRC[id] = cl
m.byName[cl.name] = cl
}
return m
}
// ValidateRoutableRequest validates every request shape the router knows how to
// decode, including compatibility-only fallback methods. known=false denotes
// a genuinely unknown top-level constructor. Such a request is never decoded:
// it is treated as opaque, word-aligned TL data, bounded by both this total-size
// cap and mtprotoedge's transport/RPC budgets, and must continue to the router's
// compatibility trace rather than being mislabeled as malformed input.
func ValidateRoutableRequest(body []byte) (known bool, err error) {
b := &bin.Buffer{Buf: body}
id, err := b.PeekID()
if err != nil {
return false, classifyWalkError(err)
}
cl := routable.byCRC[id]
if cl == nil {
if len(body) > maxOpaqueRequestBytes {
return false, limitf("opaque request length %d exceeds limit %d", len(body), maxOpaqueRequestBytes)
}
if len(body)%bin.Word != 0 {
return false, malformedf("opaque request length %d is not word aligned", len(body))
}
return false, nil
}
if !cl.isFunc {
return true, malformedf("constructor %s (%#08x) is not a method", cl.name, id)
}
return true, validateRequestLayout(routable, cl, body)
}

View file

@ -1,43 +0,0 @@
package layerwire
import (
"errors"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
func TestValidateRoutableRequestCompatibilityAndUnknown(t *testing.T) {
t.Run("legacy theme is fully walked", func(t *testing.T) {
var b bin.Buffer
b.PutID(0x8d9d742b)
b.PutString("android")
(&tg.InputThemeSlug{Slug: "night"}).Encode(&b)
b.PutLong(42)
known, err := ValidateRoutableRequest(b.Buf)
if err != nil || !known {
t.Fatalf("legacy theme known=%v err=%v, want true/nil", known, err)
}
b.Buf = b.Buf[:len(b.Buf)-4]
known, err = ValidateRoutableRequest(b.Buf)
if !known || !errors.Is(err, ErrMalformed) {
t.Fatalf("truncated legacy theme known=%v err=%v, want true/malformed", known, err)
}
})
t.Run("unknown stays opaque and bounded", func(t *testing.T) {
var b bin.Buffer
b.PutID(0x12345678)
b.PutUint32(0xffffffff)
known, err := ValidateRoutableRequest(b.Buf)
if err != nil || known {
t.Fatalf("opaque unknown known=%v err=%v, want false/nil", known, err)
}
known, err = ValidateRoutableRequest(append(b.Buf, 1))
if known || !errors.Is(err, ErrMalformed) {
t.Fatalf("unaligned unknown known=%v err=%v, want false/malformed", known, err)
}
})
}

File diff suppressed because it is too large Load diff

View file

@ -1,33 +0,0 @@
// Client constructor drift — method constructors emitted by a specific client's
// hand-maintained TL (DrKLO Android, TLRPC.java) that are absent from the
// canonical (227) schema. They are old-layer API versions the client never
// updated; from the server's view they request the SAME api method, only with an
// older wire shape.
//
// The generic inbound upgrader (inbound.go) matches each by qualified name to
// the canonical method and rebuilds a canonical request: copy shared fields,
// write 0 for inserted flags integers, synthesize defaults for new required
// fields, and convert changed field types via the converter registry. So adding
// support for a newly-observed drifted constructor is one line here + a `gen`
// run — never a runtime-discovered hand patch.
//
// Only bodies that differ structurally belong here. Body-identical drift (only
// the constructor id differs) is a plain id swap in clientMethodAliases
// (client_aliases.go).
---functions---
messages.uploadMedia#519bc2b1 peer:InputPeer media:InputMedia = MessageMedia;
auth.signUp#80eee427 phone_number:string phone_code_hash:string first_name:string last_name:string = auth.Authorization;
messages.getMessages#4222fa74 id:Vector<int> = messages.Messages;
channels.getMessages#93d7b347 channel:InputChannel id:Vector<int> = messages.Messages;
bots.exportBotToken#0063b089 bot_id:long revoke:Bool = bots.ExportedBotToken;
account.registerDevice#637ea878 token_type:int token:string = Bool;
contacts.search#11f812d8 q:string limit:int = contacts.Found;
langpack.getLangPack#9ab5c58e lang_code:string = LangPackDifference;
langpack.getStrings#2e1ee318 lang_code:string keys:Vector<string> = Vector<LangPackString>;
langpack.getLanguages#800fd57d = Vector<LangPackLanguage>;
// DrKLO still emits old channels.editCreator#8f38cd1f; canonical 227 replaced
// that flow with messages.editChatCreator(peer:InputPeer,...). Keep the old
// constructor id here but target the canonical method name for generic upgrade.
messages.editChatCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates;

View file

@ -1,11 +0,0 @@
// Hand-maintained request layouts that are intentionally handled by the RPC
// fallback instead of gotd's canonical ServerDispatcher. They still belong in
// the structural preflight model: fallback handlers must never become a way to
// bypass the canonical vector/depth/bytes budgets.
---functions---
compat.legacyCreateTheme#8432c21f flags:# slug:string title:string document:flags.2?InputDocument settings:flags.3?InputThemeSettings = Object;
compat.legacyUpdateTheme#5cb367d5 flags:# format:string theme:InputTheme slug:flags.0?string title:flags.1?string document:flags.2?InputDocument settings:flags.3?InputThemeSettings = Object;
compat.legacyInstallTheme#7ae43737 flags:# dark:flags.0?true format:flags.1?string theme:flags.1?InputTheme = Object;
compat.legacyGetTheme#8d9d742b format:string theme:InputTheme document_id:long = Object;

View file

@ -1,374 +0,0 @@
package layerwire
import (
"fmt"
"github.com/gotd/td/bin"
)
// ruleRaw is the generated, compact form of a single changed-constructor
// downgrade (see tables_gen.go). Mechanical rules carry the target CRC plus the
// canonical field names retained at the target layer; structural rules name a
// hand-written transform registered in fallback.go.
type ruleRaw struct {
target uint32
keep []string
structural string
}
// layerRaw is the generated downgrade table for one target layer.
type layerRaw struct {
rules map[uint32]ruleRaw
newTypes []uint32 // canonical CRCs that do not exist at this layer
}
// downgradeRule is the runtime form of ruleRaw with keep as a set.
type downgradeRule struct {
target uint32
keep map[string]bool
structural string
}
// layerTables is the runtime downgrade model for one target layer.
type layerTables struct {
rules map[uint32]*downgradeRule
newTypes map[uint32]bool
dirty map[uint32]bool // ctor CRC needs a deep walk
dirtyT map[string]bool // abstract/bare type name reaches a dirty ctor
}
// tables holds the runtime model per supported layer, built lazily.
var tables = func() map[int]*layerTables {
out := make(map[int]*layerTables, len(generatedTables))
for layer, raw := range generatedTables {
out[layer] = buildLayerTables(raw)
}
return out
}()
func buildLayerTables(raw layerRaw) *layerTables {
lt := &layerTables{
rules: make(map[uint32]*downgradeRule, len(raw.rules)),
newTypes: make(map[uint32]bool, len(raw.newTypes)),
}
for crc, r := range raw.rules {
dr := &downgradeRule{target: r.target, structural: r.structural}
if r.structural == "" {
dr.keep = make(map[string]bool, len(r.keep))
for _, n := range r.keep {
dr.keep[n] = true
}
}
lt.rules[crc] = dr
}
for _, crc := range raw.newTypes {
lt.newTypes[crc] = true
}
lt.computeDirty()
return lt
}
// computeDirty marks every constructor (and abstract/bare type) that can
// transitively contain a changed, structural, or layer-absent constructor, so
// the transcoder can byte-copy the ~86% of the type graph that is unaffected.
func (lt *layerTables) computeDirty() {
lt.dirty = make(map[uint32]bool)
lt.dirtyT = make(map[string]bool)
// Seed: rules + new types are themselves dirty.
for crc := range lt.rules {
lt.dirty[crc] = true
}
for crc := range lt.newTypes {
lt.dirty[crc] = true
}
markType := func(name string) {
if name != "" && !lt.dirtyT[name] {
lt.dirtyT[name] = true
}
}
// Seed dirty types from seeded dirty ctors.
for crc := range lt.dirty {
if cl := canonical.byCRC[crc]; cl != nil {
markType(cl.result)
markType(cl.name) // bare reference
}
}
// Fixpoint: a ctor is dirty if any field's type is dirty; a type is dirty
// if any of its constructors is dirty.
for changed := true; changed; {
changed = false
for crc, cl := range canonical.byCRC {
if lt.dirty[crc] {
continue
}
if lt.ctorHasDirtyField(cl) {
lt.dirty[crc] = true
if !lt.dirtyT[cl.result] {
lt.dirtyT[cl.result] = true
}
if !lt.dirtyT[cl.name] {
lt.dirtyT[cl.name] = true
}
changed = true
}
}
}
}
func (lt *layerTables) ctorHasDirtyField(cl *ctorLayout) bool {
for i := range cl.fields {
if lt.fieldDirty(&cl.fields[i]) {
return true
}
}
return false
}
func (lt *layerTables) fieldDirty(f *fieldLayout) bool {
switch f.kind {
case kindObject, kindBareObject:
return lt.dirtyT[f.typeName]
case kindVector, kindVectorBare:
return lt.fieldDirty(f.elem)
default:
return false
}
}
// structuralFunc transforms a changed constructor whose downgrade is not a pure
// field drop. The leading CRC has already been consumed from in; the transform
// reads the canonical body from in and writes the target-layer object (whose
// constructor id is target) to out.
type structuralFunc func(cl *ctorLayout, target uint32, in, out *bin.Buffer, layer, depth int, walk *walkState) error
// fallbackFunc replaces a layer-absent (227-only) constructor with an
// equivalent the target layer understands. The leading CRC is NOT yet consumed.
type fallbackFunc func(cl *ctorLayout, in, out *bin.Buffer, layer, depth int, walk *walkState) error
// structuralTransforms and the newType fallback registries are populated in
// fallback.go. newTypeFallbacks is keyed by canonical CRC (specific override);
// newTypeFallbacksByType is keyed by the canonical abstract result type and
// covers every 227-only constructor of that class (e.g. any new MessageAction).
var (
structuralTransforms = map[string]structuralFunc{}
newTypeFallbacks = map[uint32]fallbackFunc{}
newTypeFallbacksByType = map[string]fallbackFunc{}
)
// Transcode downgrades a single canonical (Layer 227) boxed object to the wire
// shape of layer. layer >= CanonicalLayer (or unsupported) returns in verbatim.
// On any transform gap it returns an error so the edge can fall back to sending
// the canonical bytes rather than corrupting the stream.
func Transcode(canonicalBytes []byte, layer int) ([]byte, error) {
if layer >= CanonicalLayer {
return canonicalBytes, nil
}
lt := tables[layer]
if lt == nil {
return canonicalBytes, nil // unsupported floor: best-effort passthrough
}
// Top-level constructors that are not in the canonical tg schema are MTProto
// control/error objects (mt.*, e.g. rpc_error) — layer-invariant, so pass
// them through. A nested unknown id is still a hard error (real gap).
if id, err := (&bin.Buffer{Buf: canonicalBytes}).PeekID(); err != nil || canonical.byCRC[id] == nil {
return canonicalBytes, nil
}
in := &bin.Buffer{Buf: canonicalBytes}
out := &bin.Buffer{}
walk := newWalkState()
if err := lt.transcodeObject(in, out, layer, 1, walk); err != nil {
return nil, classifyWalkError(err)
}
if in.Len() != 0 {
return nil, malformedf("%d trailing bytes after transcode to layer %d", in.Len(), layer)
}
return out.Buf, nil
}
// UpgradeMethodCRC maps an old client's method constructor id to the canonical
// (227) id when the request body is byte-compatible — i.e. swapping the leading
// 4-byte id yields a valid 227 request. It unifies two sources: generated
// official layer drift (inboundMethodUpgrades) and hand-maintained client
// constructor drift (clientMethodAliases). Returns ok=false for unchanged
// methods and for changes that need a real decode (those stay as rpc handlers).
func UpgradeMethodCRC(oldID uint32) (uint32, bool) {
if newID, ok := inboundMethodUpgrades[oldID]; ok {
return newID, true
}
newID, ok := clientMethodAliases[oldID]
return newID, ok
}
func (lt *layerTables) transcodeObject(in, out *bin.Buffer, layer, depth int, walk *walkState) error {
if err := walk.enter(depth, "constructor"); err != nil {
return err
}
id, err := in.PeekID()
if err != nil {
return err
}
cl, ok := canonical.byCRC[id]
if !ok {
return fmt.Errorf("layerwire: unknown constructor %#08x", id)
}
if rule := lt.rules[id]; rule != nil {
if err := in.ConsumeID(id); err != nil {
return err
}
if rule.structural != "" {
fn := structuralTransforms[rule.structural]
if fn == nil {
return fmt.Errorf("layerwire: no structural transform %q for %s@%d", rule.structural, cl.name, layer)
}
return fn(cl, rule.target, in, out, layer, depth, walk)
}
out.PutID(rule.target)
return lt.transcodeBody(in, out, cl, rule.keep, layer, depth, walk)
}
if lt.newTypes[id] {
fn := newTypeFallbacks[id]
if fn == nil {
fn = newTypeFallbacksByType[cl.result]
}
if fn == nil {
return fmt.Errorf("layerwire: %s (%#08x) absent at layer %d and no fallback", cl.name, id, layer)
}
return fn(cl, in, out, layer, depth, walk)
}
if !lt.dirty[id] {
// Unaffected subtree: byte-for-byte copy.
pre := in.Buf
if err := in.ConsumeID(id); err != nil {
return err
}
if err := walk.skipCtorBody(canonical, in, cl, depth); err != nil {
return err
}
out.Put(pre[:len(pre)-len(in.Buf)])
return nil
}
// Unchanged at this level but a descendant is dirty: keep CRC, recurse.
if err := in.ConsumeID(id); err != nil {
return err
}
out.PutID(id)
return lt.transcodeBody(in, out, cl, nil, layer, depth, walk)
}
// transcodeBody re-encodes a constructor body. keep==nil means retain every
// field (recursing into dirty descendants); otherwise only the named canonical
// fields are written, flag integers are remasked to the retained bits, and
// dropped fields are read-and-discarded.
func (lt *layerTables) transcodeBody(in, out *bin.Buffer, cl *ctorLayout, keep map[string]bool, layer, depth int, walk *walkState) error {
kept := func(name string) bool { return keep == nil || keep[name] }
var flags map[string]uint32
for i := range cl.fields {
f := &cl.fields[i]
if f.isFlags {
v, err := in.Uint32()
if err != nil {
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
}
if flags == nil {
flags = make(map[string]uint32, 2)
}
flags[f.name] = v
if kept(f.name) {
out.PutUint32(v & lt.keptMask(cl, f.name, kept))
}
continue
}
present := !f.conditional() || flags[f.flagName]&(1<<uint(f.flagBit)) != 0
if !present {
continue
}
if kept(f.name) {
if err := lt.transcodeValue(in, out, f, cl, layer, depth, walk); err != nil {
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
}
} else if err := walk.skipValue(canonical, in, f, cl, depth); err != nil {
return fmt.Errorf("%s.%s (drop): %w", cl.name, f.name, err)
}
}
return nil
}
// keptMask is the OR of bits for retained conditional fields gated by flagName,
// clearing bits whose fields are dropped at the target layer.
func (lt *layerTables) keptMask(cl *ctorLayout, flagName string, kept func(string) bool) uint32 {
var mask uint32
for i := range cl.fields {
g := &cl.fields[i]
if g.conditional() && g.flagName == flagName && kept(g.name) {
mask |= 1 << uint(g.flagBit)
}
}
return mask
}
// transcodeValue writes one present field value, recursing only into dirty
// subtrees and byte-copying everything else.
func (lt *layerTables) transcodeValue(in, out *bin.Buffer, f *fieldLayout, owner *ctorLayout, layer, depth int, walk *walkState) error {
if !lt.fieldDirty(f) {
pre := in.Buf
if err := walk.skipValue(canonical, in, f, owner, depth); err != nil {
return err
}
out.Put(pre[:len(pre)-len(in.Buf)])
return nil
}
switch f.kind {
case kindVector, kindVectorBare:
vectorDepth := depth + 1
if vectorDepth <= 0 || vectorDepth > walk.limits.maxDepth {
return limitf("vector nesting depth %d exceeds limit %d", vectorDepth, walk.limits.maxDepth)
}
if f.kind == kindVector {
id, err := in.Uint32()
if err != nil {
return err
}
if id != vectorTypeID {
return fmt.Errorf("expected vector id, got %#08x", id)
}
out.PutUint32(vectorTypeID)
}
n, err := in.Int()
if err != nil {
return err
}
if n < 0 {
return malformedf("negative vector length %d", n)
}
if max := walk.vectorLimit(owner, f); n > max {
return limitf("vector %s.%s length %d exceeds limit %d", ownerName(owner), fieldName(f), n, max)
}
if err := walk.addUnits(n, "vector "+ownerName(owner)+"."+fieldName(f)); err != nil {
return err
}
out.PutInt(n)
for i := 0; i < n; i++ {
if err := lt.transcodeValue(in, out, f.elem, nil, layer, vectorDepth, walk); err != nil {
return err
}
}
return nil
case kindObject:
return lt.transcodeObject(in, out, layer, depth+1, walk)
case kindBareObject:
bareDepth := depth + 1
if err := walk.enter(bareDepth, "bare constructor"); err != nil {
return err
}
cl, ok := canonical.bareByT[f.typeName]
if !ok {
return fmt.Errorf("unknown bare type %q", f.typeName)
}
// Bare objects have no CRC and (within 220..227) no changed bare ctor;
// recurse all-kept to reach any dirty descendants.
return lt.transcodeBody(in, out, cl, nil, layer, bareDepth, walk)
default:
// Primitive marked dirty should be impossible.
return fmt.Errorf("unexpected dirty primitive kind %d", f.kind)
}
}

View file

@ -1,388 +0,0 @@
// Code generated by ./internal/compat/layerwire/gen; DO NOT EDIT.
// Source: gotd canonical schema (Layer 227) diffed against TDesktop api.tl@N.
package layerwire
// generatedTables maps a supported client layer to its canonical(227)->layer
// downgrade table. See docs/layer-compat-220-227-design.md.
var generatedTables = map[int]layerRaw{
220: {
rules: map[uint32]ruleRaw{
0x02b78156: {target: 0xc9662d05, keep: []string{"flags", "name_requested", "username_requested", "photo_requested", "text", "button_id", "peer_type", "max_quantity"}}, // inputKeyboardButtonRequestPeer
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin
0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull
0x08cbec07: {target: 0x3f7ee58b, keep: []string{"value", "emoticon"}}, // messageMediaDice
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem
0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader
0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed
0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant
0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage
0x3cd623ec: {target: 0x92d33a0e, keep: []string{"flags", "request_write_access", "bot", "domain"}}, // urlAuthResultRequest
0x3fa53905: {target: 0xafd93fbb, keep: []string{"text"}}, // keyboardButtonBuy
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x417efd8f: {target: 0xb16a6c29, keep: []string{"text"}}, // keyboardButtonRequestPhone
0x41df43fc: {target: 0xead6805e, keep: []string{"flags", "name_hidden", "unsaved", "refunded", "can_upgrade", "pinned_to_top", "upgrade_separate", "from_id", "date", "gift", "message", "msg_id", "saved_id", "convert_stars", "upgrade_stars", "can_export_at", "transfer_stars", "can_transfer_at", "can_resell_at", "collection_id", "prepaid_upgrade_hash", "drop_original_details_stars", "gift_num"}}, // savedStarGift
0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer
0x4e7085ea: {target: 0x13acff19, structural: "starGiftAttributePattern"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0x565251e2: {target: 0x39d99013, structural: "starGiftAttributeModel"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0x5b0f15f5: {target: 0x53d7bfd8, keep: []string{"text", "button_id", "peer_type", "max_quantity"}}, // keyboardButtonRequestPeer
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x623a8fa0: {target: 0x8f8c0e4e, structural: "urlAuthResultAccepted"}, // field "url": conditional-ness changed
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x68013e72: {target: 0xd02e7fd4, keep: []string{"flags", "request_write_access", "text", "fwd_text", "url", "bot"}}, // inputKeyboardButtonUrlAuth
0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings
0x7600b9d3: {target: 0xb92f76cf, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period"}}, // message
0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote
0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll
0x7a11d782: {target: 0xbbc7515d, keep: []string{"flags", "quiz", "text"}}, // keyboardButtonRequestPoll
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x7d170cff: {target: 0xa2fa4880, keep: []string{"text"}}, // keyboardButton
0x7d5e07c7: {target: 0xe988037b, keep: []string{"text", "user_id"}}, // inputKeyboardButtonUserProfile
0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto
0x85f0a9cd: {target: 0x569d64c9, keep: []string{"flags", "require_premium", "resale_ton_only", "theme_available", "id", "gift_id", "title", "slug", "num", "owner_id", "owner_name", "owner_address", "attributes", "availability_issued", "availability_total", "gift_address", "resell_amount", "released_by", "value_amount", "value_currency", "value_usd_amount", "theme_peer", "peer_color", "host_id", "offer_min_stars"}}, // starGiftUnique
0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector<bytes>->Vector<int>
0x89c590f9: {target: 0x50f41ccf, keep: []string{"text"}}, // keyboardButtonGame
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll
0x991399fc: {target: 0x93b9fbb5, keep: []string{"flags", "same_peer", "text", "query", "peer_types"}}, // keyboardButtonSwitchInline
0x9f2504e4: {target: 0xd93d859c, structural: "starGiftAttributeBackdrop"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf
0xaa40f94d: {target: 0xfc796b3f, keep: []string{"text"}}, // keyboardButtonRequestGeoLocation
0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults
0xbcc4af10: {target: 0x75d2698e, keep: []string{"text", "copy_text"}}, // keyboardButtonCopy
0xc0fd5d09: {target: 0x308660c1, keep: []string{"text", "user_id"}}, // keyboardButtonUserProfile
0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned
0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll
0xd80c25ec: {target: 0x258aff05, keep: []string{"text", "url"}}, // keyboardButtonUrl
0xe15c4370: {target: 0xa0c0505c, keep: []string{"text", "url"}}, // keyboardButtonSimpleWebView
0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator
0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto
0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto
0xe62bc960: {target: 0x35bbdb6b, keep: []string{"flags", "requires_password", "text", "data"}}, // keyboardButtonCallback
0xe6c31522: {target: 0x95728543, keep: []string{"flags", "upgrade", "transferred", "saved", "refunded", "prepaid_upgrade", "assigned", "from_offer", "gift", "can_export_at", "transfer_stars", "from_id", "peer", "saved_id", "resale_amount", "can_transfer_at", "can_resell_at", "drop_original_details_stars"}}, // messageActionStarGiftUnique
0xe846b1a0: {target: 0x13767230, keep: []string{"text", "url"}}, // keyboardButtonWebView
0xf51006f9: {target: 0x10b78d29, keep: []string{"flags", "text", "fwd_text", "url", "button_id"}}, // keyboardButtonUrlAuth
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog
0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec,
0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96,
0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x36437737,
0x399674dc, 0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495,
0x445663a7, 0x44e56023, 0x4880ed9a, 0x4c2a5d62, 0x4fdd3430, 0x519524ea,
0x574b617f, 0x5806b4ec, 0x59080c20, 0x59e65335, 0x5b1ccb28, 0x67e731ad,
0x682a41a9, 0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18, 0x78fbf3a8,
0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x904ac7c7, 0x90d7adfa,
0x933ca597, 0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0x9da1cd6c,
0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac072444, 0xac6a83aa, 0xae152a69,
0xb07ed085, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b,
0xbaff072f, 0xbd8367b9, 0xbdac7e70, 0xbf7d6572, 0xc1f46103, 0xc31c8f4e,
0xc39a2ade, 0xc556a45d, 0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcef7e7a8,
0xcff63ea9, 0xd6e3b813, 0xda2ad647, 0xdacb836a, 0xdbbe6c6a, 0xdbce6389,
0xdd1fbf93, 0xe188503b, 0xe2b23b51, 0xe4c449fc, 0xf08d516b, 0xf13bbcd7,
0xf1d628ec, 0xf3a9244a, 0xfa2bc90a, 0xfb9c547a,
},
},
221: {
rules: map[uint32]ruleRaw{
0x02b78156: {target: 0xc9662d05, keep: []string{"flags", "name_requested", "username_requested", "photo_requested", "text", "button_id", "peer_type", "max_quantity"}}, // inputKeyboardButtonRequestPeer
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin
0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem
0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader
0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed
0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant
0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage
0x3cd623ec: {target: 0x92d33a0e, keep: []string{"flags", "request_write_access", "bot", "domain"}}, // urlAuthResultRequest
0x3fa53905: {target: 0xafd93fbb, keep: []string{"text"}}, // keyboardButtonBuy
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x417efd8f: {target: 0xb16a6c29, keep: []string{"text"}}, // keyboardButtonRequestPhone
0x41df43fc: {target: 0xead6805e, keep: []string{"flags", "name_hidden", "unsaved", "refunded", "can_upgrade", "pinned_to_top", "upgrade_separate", "from_id", "date", "gift", "message", "msg_id", "saved_id", "convert_stars", "upgrade_stars", "can_export_at", "transfer_stars", "can_transfer_at", "can_resell_at", "collection_id", "prepaid_upgrade_hash", "drop_original_details_stars", "gift_num"}}, // savedStarGift
0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer
0x4e7085ea: {target: 0x13acff19, structural: "starGiftAttributePattern"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0x565251e2: {target: 0x39d99013, structural: "starGiftAttributeModel"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0x5b0f15f5: {target: 0x53d7bfd8, keep: []string{"text", "button_id", "peer_type", "max_quantity"}}, // keyboardButtonRequestPeer
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x623a8fa0: {target: 0x8f8c0e4e, structural: "urlAuthResultAccepted"}, // field "url": conditional-ness changed
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x68013e72: {target: 0xd02e7fd4, keep: []string{"flags", "request_write_access", "text", "fwd_text", "url", "bot"}}, // inputKeyboardButtonUrlAuth
0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings
0x7600b9d3: {target: 0x9cb490e9, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote
0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll
0x7a11d782: {target: 0xbbc7515d, keep: []string{"flags", "quiz", "text"}}, // keyboardButtonRequestPoll
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x7d170cff: {target: 0xa2fa4880, keep: []string{"text"}}, // keyboardButton
0x7d5e07c7: {target: 0xe988037b, keep: []string{"text", "user_id"}}, // inputKeyboardButtonUserProfile
0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto
0x85f0a9cd: {target: 0x569d64c9, keep: []string{"flags", "require_premium", "resale_ton_only", "theme_available", "id", "gift_id", "title", "slug", "num", "owner_id", "owner_name", "owner_address", "attributes", "availability_issued", "availability_total", "gift_address", "resell_amount", "released_by", "value_amount", "value_currency", "value_usd_amount", "theme_peer", "peer_color", "host_id", "offer_min_stars"}}, // starGiftUnique
0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector<bytes>->Vector<int>
0x89c590f9: {target: 0x50f41ccf, keep: []string{"text"}}, // keyboardButtonGame
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll
0x991399fc: {target: 0x93b9fbb5, keep: []string{"flags", "same_peer", "text", "query", "peer_types"}}, // keyboardButtonSwitchInline
0x9f2504e4: {target: 0xd93d859c, structural: "starGiftAttributeBackdrop"}, // target field "rarity_permille" not found in canonical (reorder/insert)
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf
0xaa40f94d: {target: 0xfc796b3f, keep: []string{"text"}}, // keyboardButtonRequestGeoLocation
0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults
0xbcc4af10: {target: 0x75d2698e, keep: []string{"text", "copy_text"}}, // keyboardButtonCopy
0xc0fd5d09: {target: 0x308660c1, keep: []string{"text", "user_id"}}, // keyboardButtonUserProfile
0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned
0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll
0xd80c25ec: {target: 0x258aff05, keep: []string{"text", "url"}}, // keyboardButtonUrl
0xe15c4370: {target: 0xa0c0505c, keep: []string{"text", "url"}}, // keyboardButtonSimpleWebView
0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator
0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto
0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto
0xe62bc960: {target: 0x35bbdb6b, keep: []string{"flags", "requires_password", "text", "data"}}, // keyboardButtonCallback
0xe6c31522: {target: 0x95728543, keep: []string{"flags", "upgrade", "transferred", "saved", "refunded", "prepaid_upgrade", "assigned", "from_offer", "gift", "can_export_at", "transfer_stars", "from_id", "peer", "saved_id", "resale_amount", "can_transfer_at", "can_resell_at", "drop_original_details_stars"}}, // messageActionStarGiftUnique
0xe846b1a0: {target: 0x13767230, keep: []string{"text", "url"}}, // keyboardButtonWebView
0xf51006f9: {target: 0x10b78d29, keep: []string{"flags", "text", "fwd_text", "url", "button_id"}}, // keyboardButtonUrlAuth
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog
0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec,
0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96,
0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x36437737,
0x399674dc, 0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495,
0x445663a7, 0x4880ed9a, 0x4c2a5d62, 0x4fdd3430, 0x519524ea, 0x574b617f,
0x5806b4ec, 0x59080c20, 0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe,
0x71777116, 0x7781fe18, 0x78fbf3a8, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd,
0x8c0f91fb, 0x904ac7c7, 0x90d7adfa, 0x933ca597, 0x98a3a840, 0x9b00622b,
0x9bad6414, 0x9d2eac97, 0x9da1cd6c, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b,
0xac072444, 0xac6a83aa, 0xae152a69, 0xb07ed085, 0xb22083a6, 0xb43df56c,
0xb532772b, 0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbd8367b9, 0xbdac7e70,
0xbf7d6572, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d, 0xc6c1e5a7,
0xcd24cf44, 0xcdd4093d, 0xcef7e7a8, 0xcff63ea9, 0xd6e3b813, 0xdacb836a,
0xdbbe6c6a, 0xdbce6389, 0xdd1fbf93, 0xe188503b, 0xe2b23b51, 0xe4c449fc,
0xf08d516b, 0xf13bbcd7, 0xf1d628ec, 0xfa2bc90a,
},
},
222: {
rules: map[uint32]ruleRaw{
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x0360d5d2: {target: 0xa0933f5b, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipantAdmin
0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem
0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader
0x1bd54456: {target: 0xcb397619, keep: []string{"flags", "user_id", "date", "subscription_until_date"}}, // channelParticipant
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed
0x38e79fde: {target: 0xc02d4007, keep: []string{"user_id", "inviter_id", "date"}}, // chatParticipant
0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage
0x3cd623ec: {target: 0x32fabf1a, keep: []string{"flags", "request_write_access", "request_phone_number", "bot", "domain", "browser", "platform", "ip", "region"}}, // urlAuthResultRequest
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings
0x7600b9d3: {target: 0x9cb490e9, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote
0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto
0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector<bytes>->Vector<int>
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xa9478a1a: {target: 0x4f607bef, keep: []string{"flags", "via_request", "user_id", "inviter_id", "date", "subscription_until_date"}}, // channelParticipantSelf
0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults
0xd5f0ad91: {target: 0x6df8014e, keep: []string{"flags", "left", "peer", "kicked_by", "date", "banned_rights"}}, // channelParticipantBanned
0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll
0xe1f867b8: {target: 0xe46bcee4, keep: []string{"user_id"}}, // chatParticipantCreator
0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto
0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog
0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec,
0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96,
0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x399674dc,
0x3c29a3e2, 0x3c60b621, 0x3e2793ba, 0x3e81e078, 0x402b4495, 0x445663a7,
0x4880ed9a, 0x4c2a5d62, 0x519524ea, 0x574b617f, 0x5806b4ec, 0x59080c20,
0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18,
0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x904ac7c7, 0x90d7adfa,
0x933ca597, 0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0x9da1cd6c,
0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa, 0xae152a69, 0xb22083a6,
0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbd8367b9,
0xbdac7e70, 0xbf7d6572, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d,
0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813, 0xdacb836a,
0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf13bbcd7, 0xf1d628ec,
0xfa2bc90a,
},
},
223: {
rules: map[uint32]ruleRaw{
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x06cbe645: {target: 0xa02bc13e, keep: []string{"flags", "blocked", "phone_calls_available", "phone_calls_private", "can_pin_message", "has_scheduled", "video_calls_available", "voice_messages_forbidden", "translations_disabled", "stories_pinned_available", "blocked_my_stories_from", "wallpaper_overridden", "contact_require_premium", "read_dates_private", "flags2", "sponsored_enabled", "can_view_revenue", "bot_can_manage_emoji_status", "display_gifts_button", "noforwards_my_enabled", "noforwards_peer_enabled", "id", "about", "settings", "personal_photo", "profile_photo", "fallback_photo", "notify_settings", "bot_info", "pinned_msg_id", "common_chats_count", "folder_id", "ttl_period", "theme", "private_forward_name", "bot_group_admin_rights", "bot_broadcast_admin_rights", "wallpaper", "stories", "business_work_hours", "business_location", "business_greeting_message", "business_away_message", "business_intro", "birthday", "personal_channel_id", "personal_channel_message", "stargifts_count", "starref_program", "bot_verification", "send_paid_messages_stars", "disallowed_gifts", "stars_rating", "stars_my_pending_rating", "stars_my_pending_rating_date", "main_tab", "saved_music", "note"}}, // userFull
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x16a4b93c: {target: 0xedf164f1, keep: []string{"flags", "pinned", "public", "close_friends", "min", "noforwards", "edited", "contacts", "selected_contacts", "out", "id", "date", "from_id", "fwd_from", "expire_date", "caption", "entities", "media", "media_areas", "privacy", "views", "sent_reaction", "albums"}}, // storyItem
0x1b97dd66: {target: 0x6917560b, keep: []string{"flags", "reply_to_scheduled", "forum_topic", "quote", "reply_to_msg_id", "reply_to_peer_id", "reply_from", "reply_media", "reply_to_top_id", "quote_text", "quote_entities", "quote_offset", "todo_item_id"}}, // messageReplyHeader
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3645230a: {target: 0x3b6ddad2, structural: "pollAnswerVoters"}, // field "voters": conditional-ness changed
0x3bd4b7c2: {target: 0x869fbe10, keep: []string{"flags", "reply_to_msg_id", "top_msg_id", "reply_to_peer_id", "quote_text", "quote_entities", "quote_offset", "monoforum_peer_id", "todo_item_id"}}, // inputReplyToMessage
0x3cd623ec: {target: 0xf8f8eb1e, keep: []string{"flags", "request_write_access", "request_phone_number", "match_codes_first", "bot", "domain", "browser", "platform", "ip", "region", "match_codes", "user_id_hint"}}, // urlAuthResultRequest
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x4b7d786a: {target: 0xff16e2ca, keep: []string{"text", "option"}}, // pollAnswer
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x71e4ea58: {target: 0x56e34970, keep: []string{"flags", "messages_notify_from", "stories_notify_from", "sound", "show_previews"}}, // reactionsNotifySettings
0x7600b9d3: {target: 0x3ae56482, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7699f014: {target: 0x24f40e77, keep: []string{"poll_id", "peer", "options", "qts"}}, // updateMessagePollVote
0x773f4e66: {target: 0x4bd6e798, keep: []string{"poll", "results"}}, // messageMediaPoll
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x7d8375da: {target: 0x1e287d04, keep: []string{"flags", "spoiler", "file", "stickers", "ttl_seconds"}}, // inputMediaUploadedPhoto
0x883a4108: {target: 0x0f94e5f1, structural: "inputMediaPoll"}, // field "correct_answers": type changed Vector<bytes>->Vector<int>
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0x58747131, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "question", "answers", "close_period", "close_date"}}, // poll
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xba7bb15e: {target: 0x7adf2420, keep: []string{"flags", "min", "results", "total_voters", "recent_voters", "solution", "solution_entities"}}, // pollResults
0xd64c522b: {target: 0xaca1657b, keep: []string{"flags", "poll_id", "poll", "results"}}, // updateMessagePoll
0xe216eb63: {target: 0x695150d7, keep: []string{"flags", "spoiler", "photo", "ttl_seconds"}}, // messageMediaPhoto
0xe3af4434: {target: 0xb3ba0635, keep: []string{"flags", "spoiler", "id", "ttl_seconds"}}, // inputMediaPhoto
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
0xfc89f7f3: {target: 0xd58a08c6, keep: []string{"flags", "pinned", "unread_mark", "view_forum_as_messages", "peer", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "notify_settings", "pts", "draft", "folder_id", "ttl_period"}}, // dialog
0xfcdad815: {target: 0xcdff0eca, keep: []string{"flags", "my", "closed", "pinned", "short", "hidden", "title_missing", "id", "date", "peer", "title", "icon_color", "icon_emoji_id", "top_message", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "unread_mentions_count", "unread_reactions_count", "from_id", "notify_settings", "draft"}}, // forumTopic
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0652c1c5, 0x0773c080, 0x096b2aec,
0x0a617e7b, 0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x16605e3e, 0x199fed96,
0x1fa01357, 0x1fe9a9bf, 0x24c26789, 0x2999beed, 0x2f51c337, 0x399674dc,
0x3c29a3e2, 0x3c60b621, 0x3e81e078, 0x402b4495, 0x445663a7, 0x4880ed9a,
0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9,
0x6c24f3dd, 0x6c9d0efe, 0x71777116, 0x7781fe18, 0x79eb8cb3, 0x7b9e1801,
0x83281dbd, 0x8c0f91fb, 0x90d7adfa, 0x933ca597, 0x98a3a840, 0x9b00622b,
0x9bad6414, 0x9d2eac97, 0x9da1cd6c, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b,
0xac6a83aa, 0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d,
0xbaf39d8b, 0xbaff072f, 0xbdac7e70, 0xc1f46103, 0xc31c8f4e, 0xc39a2ade,
0xc556a45d, 0xc6c1e5a7, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813,
0xdacb836a, 0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf13bbcd7,
0xf1d628ec, 0xfa2bc90a,
},
},
224: {
rules: map[uint32]ruleRaw{
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x7600b9d3: {target: 0x3ae56482, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0x966e2dbf: {target: 0xb8425be9, keep: []string{"id", "flags", "closed", "public_voters", "multiple_choice", "quiz", "open_answers", "revoting_disabled", "shuffle_answers", "hide_results_until_close", "creator", "question", "answers", "close_period", "close_date", "hash"}}, // poll
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x0773c080, 0x096b2aec, 0x0a617e7b,
0x0e6e47c4, 0x0efa0194, 0x140502d1, 0x1fa01357, 0x1fe9a9bf, 0x24c26789,
0x2999beed, 0x2f51c337, 0x3c29a3e2, 0x445663a7, 0x4c2a5d62, 0x519524ea,
0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9, 0x6c24f3dd, 0x6c9d0efe,
0x7781fe18, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x8c0f91fb, 0x933ca597,
0x98a3a840, 0x9b00622b, 0x9bad6414, 0x9d2eac97, 0xa26156c0, 0xa2cb24f9,
0xa5b45e2b, 0xac6a83aa, 0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b,
0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xbdac7e70, 0xc1f46103, 0xc31c8f4e,
0xc39a2ade, 0xc556a45d, 0xcd24cf44, 0xcdd4093d, 0xcff63ea9, 0xd6e3b813,
0xdacb836a, 0xdbbe6c6a, 0xdd1fbf93, 0xe2b23b51, 0xe4c449fc, 0xf1d628ec,
},
},
225: {
rules: map[uint32]ruleRaw{
0x033ed001: {target: 0xcd64636c, keep: []string{"flags", "bot_id", "recipients", "rights"}}, // connectedBot
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x3fc18057: {target: 0x9bb2636d, keep: []string{"flags", "restore", "phone_number", "phone_code_hash", "currency", "amount"}}, // inputStorePaymentAuthCode
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x7600b9d3: {target: 0x95ef6f2b, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "guestchat_via_from", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x7cb34d79: {target: 0x11dfa986, keep: []string{"peer", "date", "user_id", "about", "invite", "qts"}}, // updateBotChatInviteRequester
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
0xa04e8d3a: {target: 0xe4e0b29d, keep: []string{"flags", "can_view_participants", "can_set_username", "can_set_stickers", "hidden_prehistory", "can_set_location", "has_scheduled", "can_view_stats", "blocked", "flags2", "can_delete_channel", "antispam", "participants_hidden", "translations_disabled", "stories_pinned_available", "view_forum_as_messages", "restricted_sponsored", "can_view_revenue", "paid_media_allowed", "can_view_stars_revenue", "paid_reactions_available", "stargifts_available", "paid_messages_available", "id", "about", "participants_count", "admins_count", "kicked_count", "banned_count", "online_count", "read_inbox_max_id", "read_outbox_max_id", "unread_count", "chat_photo", "notify_settings", "exported_invite", "bot_info", "migrated_from_chat_id", "migrated_from_max_id", "pinned_msg_id", "stickerset", "available_min_id", "folder_id", "linked_chat_id", "location", "slowmode_seconds", "slowmode_next_send_date", "stats_dc", "pts", "call", "ttl_period", "pending_suggestions", "groupcall_default_join_as", "theme_emoticon", "requests_pending", "recent_requesters", "default_send_as", "available_reactions", "reactions_limit", "stories", "wallpaper", "boosts_applied", "boosts_unrestrict", "emojiset", "bot_verification", "stargifts_count", "send_paid_messages_stars", "main_tab"}}, // channelFull
0xf8827ebf: {target: 0xe0955a3c, keep: []string{"store_product", "phone_code_hash", "support_email_address", "support_email_subject", "currency", "amount"}}, // auth.sentCodePaymentRequired
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x096b2aec, 0x0a617e7b, 0x0e6e47c4,
0x0efa0194, 0x140502d1, 0x24c26789, 0x2f51c337, 0x3c29a3e2, 0x445663a7,
0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20, 0x67e731ad, 0x682a41a9,
0x6c24f3dd, 0x79eb8cb3, 0x7b9e1801, 0x83281dbd, 0x933ca597, 0x98a3a840,
0x9b00622b, 0x9d2eac97, 0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa,
0xae152a69, 0xb22083a6, 0xb43df56c, 0xb532772b, 0xb956812d, 0xbaf39d8b,
0xbaff072f, 0xbdac7e70, 0xc31c8f4e, 0xc39a2ade, 0xc556a45d, 0xcd24cf44,
0xd6e3b813, 0xdacb836a, 0xdbbe6c6a, 0xe2b23b51, 0xe4c449fc,
},
},
226: {
rules: map[uint32]ruleRaw{
0x15031189: {target: 0x5e068047, structural: "pageListOrderedItemText"}, // field "num": conditional-ness changed
0x1fd6f6c1: {target: 0x9a8ae1e1, keep: []string{"items"}}, // pageBlockOrderedList
0x2f51c337: {target: 0x774bbdf4, structural: "messages.chatInviteJoinResultWebView"}, // target field "url" not found in canonical (reorder/insert)
0x2f58683c: {target: 0xb92fb6cd, keep: []string{"text"}}, // pageListItemText
0x60fe3294: {target: 0x96eaa5eb, keep: []string{"flags", "no_webpage", "invert_media", "reply_to", "message", "entities", "media", "date", "effect", "suggested_post"}}, // draftMessage
0x63ca67aa: {target: 0x25e073fc, keep: []string{"blocks"}}, // pageListItemBlocks
0x7600b9d3: {target: 0x95ef6f2b, keep: []string{"flags", "out", "mentioned", "media_unread", "silent", "post", "from_scheduled", "legacy", "edit_hide", "pinned", "noforwards", "invert_media", "flags2", "offline", "video_processing_pending", "paid_suggested_post_stars", "paid_suggested_post_ton", "id", "from_id", "from_boosts_applied", "from_rank", "peer_id", "saved_peer_id", "fwd_from", "via_bot_id", "via_business_bot_id", "guestchat_via_from", "reply_to", "date", "message", "media", "reply_markup", "entities", "views", "forwards", "replies", "edit_date", "post_author", "grouped_id", "reactions", "restriction_reason", "ttl_period", "quick_reply_shortcut_id", "effect", "factcheck", "report_delivery_until_date", "paid_message_stars", "suggested_post", "schedule_repeat_period", "summary_from_language"}}, // message
0x8ff2d5f0: {target: 0x98dd8936, structural: "pageListOrderedItemBlocks"}, // field "num": conditional-ness changed
},
newTypes: []uint32{
0x004b572c, 0x01a9fbfc, 0x02ff29d3, 0x096b2aec, 0x0a617e7b, 0x0e6e47c4,
0x24c26789, 0x3c29a3e2, 0x4c2a5d62, 0x519524ea, 0x574b617f, 0x59080c20,
0x67e731ad, 0x682a41a9, 0x7b9e1801, 0x83281dbd, 0x9b00622b, 0x9d2eac97,
0xa26156c0, 0xa2cb24f9, 0xa5b45e2b, 0xac6a83aa, 0xb43df56c, 0xb532772b,
0xb956812d, 0xbaf39d8b, 0xbaff072f, 0xc556a45d, 0xcd24cf44, 0xdacb836a,
0xdbbe6c6a, 0xe2b23b51, 0xe4c449fc,
},
},
}
// inboundMethodUpgrades maps an old client method constructor id to the
// canonical (227) id. Only upgrade-safe changes (all 227 additions flag-gated)
// are listed: rewriting the 4-byte id yields a valid 227 request body.
// NOT upgrade-safe as a pure id swap (declare a body transform in client-drift.tl when needed):
//
// channels.editAdmin: field "rank": conditional-ness changed
// channels.toggleJoinRequest: 227 inserts flags integer "flags"
// contacts.search: 227 inserts flags integer "flags"
// messages.composeMessageWithAI: target field "change_tone" not found in canonical (reorder/insert)
// messages.getPollResults: 227-only field "poll_hash" is non-conditional
// messages.sendBotRequestedPeer: field "msg_id": conditional-ness changed
// messages.toggleNoForwards: 227 inserts flags integer "flags"
var inboundMethodUpgrades = map[uint32]uint32{
0x052b08db: 0xb8f106e3, // messages.setBotGuestChatResult
0x198fb446: 0x894cc99c, // messages.requestUrlAuth
0x24b524c5: 0x7f6a1e22, // channels.joinChannel
0x2d0a0571: 0x60ed4229, // account.toggleWebBrowserSettingsException
0x51e842e1: 0xb106e66c, // messages.editMessage
0x545cd15a: 0xfef48f62, // messages.sendMessage
0x54ae308e: 0xad0fa15c, // messages.saveDraft
0x63183030: 0xa5eec345, // messages.translateText
0x6c50051c: 0xde91436e, // messages.importChatInvite
0x737fc2ec: 0x8f9e6898, // stories.sendStory
0x83557dba: 0xa423bb51, // messages.editInlineBotMessage
0x9d4104e2: 0xabbbd346, // messages.summarizeText
0xb12c7125: 0x67a3f0de, // messages.acceptUrlAuth
0xb583ba46: 0x2c63a72b, // stories.editStory
}

View file

@ -1,249 +0,0 @@
package layerwire
import (
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
// loadLayerModel parses a vendored historical schema (_schema/layer-N.tl) into a
// schemaModel used as an independent oracle: downgraded bytes must parse cleanly
// against the actual target-layer schema.
func loadLayerModel(t *testing.T, layer int) *schemaModel {
t.Helper()
src, err := os.ReadFile(filepath.Join("_schema", fmt.Sprintf("layer-%d.tl", layer)))
if err != nil {
t.Fatalf("read layer %d schema: %v", layer, err)
}
m, err := parseSchemaModel(string(src))
if err != nil {
t.Fatalf("parse layer %d schema: %v", layer, err)
}
return m
}
// TestTranscodeIdentity verifies that targeting the canonical layer (or above)
// is a pure passthrough — the transcoder must never mutate 227 bytes.
func TestTranscodeIdentity(t *testing.T) {
for _, o := range canonicalCorpus() {
raw := mustEncode(t, o)
out, err := Transcode(raw, CanonicalLayer)
if err != nil {
t.Fatalf("%T: identity transcode: %v", o, err)
}
if !bytes.Equal(out, raw) {
t.Errorf("%T: identity transcode changed bytes", o)
}
}
}
// TestTranscodeDowngradeValid downgrades the corpus to every supported layer and
// asserts the result parses cleanly (full byte consumption) against that layer's
// own schema. This is the core correctness oracle for the transcoder.
func TestTranscodeDowngradeValid(t *testing.T) {
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
model := loadLayerModel(t, layer)
for _, o := range canonicalCorpus() {
raw := mustEncode(t, o)
out, err := Transcode(raw, layer)
if err != nil {
t.Errorf("layer %d %T: transcode: %v", layer, o, err)
continue
}
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
if err := model.skipObject(b); err != nil {
t.Errorf("layer %d %T: result invalid at target: %v", layer, o, err)
continue
}
if b.Len() != 0 {
t.Errorf("layer %d %T: %d trailing bytes in downgraded output", layer, o, b.Len())
}
}
}
}
// TestTranscodeMessageGolden checks that a message downgraded to 220 carries the
// 220 constructor id and is strictly shorter (dropped trailing fields).
func TestTranscodeMessageGolden(t *testing.T) {
const message220CRC = 0xb92f76cf
raw := mustEncode(t, canonicalCorpus()[1]) // the rich message
out, err := Transcode(raw, 220)
if err != nil {
t.Fatalf("transcode message->220: %v", err)
}
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
id, err := b.PeekID()
if err != nil {
t.Fatalf("peek id: %v", err)
}
if id != message220CRC {
t.Fatalf("message@220 id = %#08x, want %#08x", id, message220CRC)
}
if len(out) >= len(raw) {
t.Errorf("downgraded message not shorter: %d >= %d", len(out), len(raw))
}
}
func TestTranscodeFormattedDateEntityLayerBoundary(t *testing.T) {
const formattedDateEntityCRC = 0x904ac7c7
entityCRC := func(crc uint32) []byte {
return []byte{byte(crc), byte(crc >> 8), byte(crc >> 16), byte(crc >> 24)}
}
msg := &tg.Message{
ID: 7,
PeerID: &tg.PeerUser{UserID: 2},
Date: 100,
Message: "Meet soon",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityFormattedDate{Offset: 5, Length: 4, Date: 1773436800, ShortDate: true, ShortTime: true},
},
}
raw := mustEncode(t, msg)
out222, err := Transcode(raw, 222)
if err != nil {
t.Fatalf("transcode message->222: %v", err)
}
if bytes.Contains(out222, entityCRC(formattedDateEntityCRC)) {
t.Fatalf("layer 222 output leaked formatted-date entity")
}
if !bytes.Contains(out222, entityCRC(messageEntityUnknownID)) {
t.Fatalf("layer 222 output missing messageEntityUnknown fallback")
}
m222 := loadLayerModel(t, 222)
b222 := &bin.Buffer{Buf: append([]byte(nil), out222...)}
if err := m222.skipObject(b222); err != nil || b222.Len() != 0 {
t.Fatalf("layer 222 formatted-date fallback does not parse cleanly (err=%v left=%d)", err, b222.Len())
}
out223, err := Transcode(raw, 223)
if err != nil {
t.Fatalf("transcode message->223: %v", err)
}
if !bytes.Contains(out223, entityCRC(formattedDateEntityCRC)) {
t.Fatalf("layer 223 output did not preserve formatted-date entity")
}
m223 := loadLayerModel(t, 223)
b223 := &bin.Buffer{Buf: append([]byte(nil), out223...)}
if err := m223.skipObject(b223); err != nil || b223.Len() != 0 {
t.Fatalf("layer 223 formatted-date output does not parse cleanly (err=%v left=%d)", err, b223.Len())
}
}
// TestTranscodePassthroughNonAPI verifies that a top-level constructor absent
// from the tg schema (an MTProto control object such as rpc_error) passes
// through untouched at any layer.
func TestTranscodePassthroughNonAPI(t *testing.T) {
var b bin.Buffer
b.PutID(0xc4b9f9bb) // rpc_error#c4b9f9bb (mt.*), not a tg API constructor
b.PutInt(420)
b.PutString("FLOOD_WAIT")
raw := b.Copy()
out, err := Transcode(raw, 220)
if err != nil {
t.Fatalf("passthrough transcode: %v", err)
}
if !bytes.Equal(out, raw) {
t.Errorf("non-API object was modified by transcode")
}
}
// TestTranscodeChangedTypeNestedInUnchangedContainer is the case raised in
// review: an outer constructor whose CRC is IDENTICAL across 227 and the target
// layer (so a naive "same CRC ⇒ copy verbatim" would be wrong) but which nests a
// CHANGED type (message). The dirty closure must mark the outer container dirty
// purely because it can transitively reach a changed type, so the transcoder
// keeps the outer CRC yet recurses and rewrites the inner message to the target.
func TestTranscodeChangedTypeNestedInUnchangedContainer(t *testing.T) {
const (
message227CRC = 0x7600b9d3
message220CRC = 0xb92f76cf
)
// updates#... nests Vector<Update> → updateNewMessage → message:Message.
updates := &tg.Updates{
Updates: []tg.UpdateClass{
&tg.UpdateNewMessage{
Message: &tg.Message{ID: 7, PeerID: &tg.PeerUser{UserID: 2}, Date: 1, Message: "nested"},
Pts: 1, PtsCount: 1,
},
},
Users: []tg.UserClass{&tg.User{ID: 2, AccessHash: 5, FirstName: "A"}},
Chats: []tg.ChatClass{},
Date: 100, Seq: 1,
}
// Premise of the question: the OUTER container's CRC is unchanged at 220.
m220 := loadLayerModel(t, 220)
if canonical.byName["updates"].crc != m220.byName["updates"].crc {
t.Skip("updates CRC differs 220<->227; premise no longer holds")
}
raw := mustEncode(t, updates)
out, err := Transcode(raw, 220)
if err != nil {
t.Fatalf("transcode updates->220: %v", err)
}
// Outer CRC preserved (it really is unchanged).
if id, _ := (&bin.Buffer{Buf: out}).PeekID(); id != canonical.byName["updates"].crc {
t.Fatalf("outer updates id changed to %#08x", id)
}
// Inner message rewritten to the 220 constructor; the 227 one must be gone.
le := func(crc uint32) []byte { return []byte{byte(crc), byte(crc >> 8), byte(crc >> 16), byte(crc >> 24)} }
if bytes.Contains(out, le(message227CRC)) {
t.Errorf("downgraded output still contains the 227 message constructor")
}
if !bytes.Contains(out, le(message220CRC)) {
t.Errorf("downgraded output missing the 220 message constructor")
}
// Rigorous: the whole thing must parse cleanly against the real 220 schema —
// impossible if a 227-only nested constructor leaked through.
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
if err := m220.skipObject(b); err != nil || b.Len() != 0 {
t.Fatalf("downgraded updates invalid at 220 (err=%v left=%d)", err, b.Len())
}
}
// TestTranscodePollResults exercises the pollAnswerVoters structural transform.
func TestTranscodePollResults(t *testing.T) {
raw := mustEncode(t, canonicalCorpus()[10]) // PollResults
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
out, err := Transcode(raw, layer)
if err != nil {
t.Fatalf("layer %d: pollResults transcode: %v", layer, err)
}
model := loadLayerModel(t, layer)
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
if err := model.skipObject(b); err != nil || b.Len() != 0 {
t.Errorf("layer %d: pollResults invalid (err=%v left=%d)", layer, err, b.Len())
}
}
}
// TestTranscodePollAnswerVotersAbsentFlag exercises the structural transform's
// flag-bit-2-unset path: a pollAnswerVoters whose voters field is absent in 227
// (flags.2 clear) must still emit voters:0 (unconditional int) at older layers.
func TestTranscodePollAnswerVotersAbsentFlag(t *testing.T) {
// voters absent (flag bit 2 unset): Voters=0 ⇒ gotd SetFlags leaves flags.2 clear.
pr := &tg.PollResults{
Results: []tg.PollAnswerVoters{{Option: []byte{0}, Chosen: true}},
TotalVoters: 0,
}
raw := mustEncode(t, pr)
for layer := SupportedFloor; layer < CanonicalLayer; layer++ {
out, err := Transcode(raw, layer)
if err != nil {
t.Fatalf("layer %d: transcode: %v", layer, err)
}
model := loadLayerModel(t, layer)
b := &bin.Buffer{Buf: append([]byte(nil), out...)}
if err := model.skipObject(b); err != nil || b.Len() != 0 {
t.Errorf("layer %d: voters-absent pollResults invalid (err=%v left=%d)", layer, err, b.Len())
}
}
}

View file

@ -1,433 +0,0 @@
package layerwire
import (
"errors"
"fmt"
"io"
"math"
"github.com/gotd/td/bin"
)
// ErrMalformed identifies invalid or truncated TL wire data. Callers may use
// errors.Is to distinguish it from an otherwise well-formed request which was
// rejected by a walker resource limit.
var ErrMalformed = errors.New("layerwire: malformed TL")
// ErrResourceLimit identifies structurally valid-looking TL input which would
// exceed a walker resource budget.
var ErrResourceLimit = errors.New("layerwire: resource limit")
const (
defaultMaxVectorElements = 4096
defaultMaxWalkDepth = 32
defaultMaxWalkUnits = 131072 // constructors + declared vector elements
defaultMaxFieldBytes = 16 << 20
defaultMaxTotalBytes = 32 << 20
)
// A very small number of API methods have a documented limit above the
// package-wide default. Keeping overrides keyed by constructor and field makes
// every exception explicit and prevents a large vector in an unrelated method
// from inheriting the larger allowance.
type vectorLimitKey struct {
owner string
field string
}
var vectorElementLimitOverrides = map[vectorLimitKey]int{
{owner: "contacts.editCloseFriends", field: "id"}: 5000,
{owner: "contacts.setBlocked", field: "id"}: 5000,
}
type walkLimits struct {
maxVectorElements int
maxDepth int
maxUnits uint64
maxFieldBytes uint64
maxTotalBytes uint64
}
var defaultWalkLimits = walkLimits{
maxVectorElements: defaultMaxVectorElements,
maxDepth: defaultMaxWalkDepth,
maxUnits: defaultMaxWalkUnits,
maxFieldBytes: defaultMaxFieldBytes,
maxTotalBytes: defaultMaxTotalBytes,
}
// walkState is deliberately request-scoped. Every branch of one transform
// shares it, so splitting a large value across nested constructors or vectors
// cannot reset the aggregate budgets.
type walkState struct {
limits walkLimits
units uint64
bytes uint64
}
func newWalkState() *walkState {
return &walkState{limits: defaultWalkLimits}
}
func malformedf(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrMalformed, fmt.Sprintf(format, args...))
}
func limitf(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrResourceLimit, fmt.Sprintf(format, args...))
}
// classifyWalkError makes all public walker/transform failures classifiable,
// including errors returned by the low-level gotd bin decoder.
func classifyWalkError(err error) error {
if err == nil || errors.Is(err, ErrMalformed) || errors.Is(err, ErrResourceLimit) {
return err
}
return fmt.Errorf("%w: %v", ErrMalformed, err)
}
func (s *walkState) enter(depth int, what string) error {
if depth <= 0 || depth > s.limits.maxDepth {
return limitf("%s nesting depth %d exceeds limit %d", what, depth, s.limits.maxDepth)
}
return s.addUnits(1, what)
}
func (s *walkState) addUnits(n int, what string) error {
if n < 0 {
return malformedf("negative %s count %d", what, n)
}
u := uint64(n)
// Subtraction form avoids overflow even if limits are changed later.
if s.units > s.limits.maxUnits || u > s.limits.maxUnits-s.units {
return limitf("constructor/vector element budget exceeds %d at %s", s.limits.maxUnits, what)
}
s.units += u
return nil
}
func (s *walkState) addBytes(n uint64, what string) error {
if n > s.limits.maxFieldBytes {
return limitf("%s payload length %d exceeds per-field limit %d", what, n, s.limits.maxFieldBytes)
}
if s.bytes > s.limits.maxTotalBytes || n > s.limits.maxTotalBytes-s.bytes {
return limitf("string/bytes payload budget exceeds %d at %s", s.limits.maxTotalBytes, what)
}
s.bytes += n
return nil
}
func (s *walkState) vectorLimit(owner *ctorLayout, f *fieldLayout) int {
if owner != nil && f != nil {
if n := vectorElementLimitOverrides[vectorLimitKey{owner: owner.name, field: f.name}]; n > 0 {
return n
}
}
return s.limits.maxVectorElements
}
const maxConstructorFlagWords = 8
type constructorFlagWord struct {
name string
value uint32
}
// ValidateCanonicalRequest performs a complete, allocation-free structural
// preflight of one canonical Layer 227 method request. It is intended for the
// router seam immediately before typed dispatch. A successful result means the
// walker consumed exactly one known function constructor and all of its body.
func ValidateCanonicalRequest(body []byte) error {
b := &bin.Buffer{Buf: body}
id, err := b.PeekID()
if err != nil {
return classifyWalkError(err)
}
cl := canonical.byCRC[id]
if cl == nil {
return malformedf("unknown canonical request constructor %#08x", id)
}
if !cl.isFunc {
return malformedf("constructor %s (%#08x) is not a method", cl.name, id)
}
return validateRequestLayout(canonical, cl, body)
}
func validateRequestLayout(m *schemaModel, cl *ctorLayout, body []byte) error {
b := &bin.Buffer{Buf: body}
s := newWalkState()
if err := s.skipObject(m, b, 1); err != nil {
return classifyWalkError(err)
}
if b.Len() != 0 {
return malformedf("%d trailing bytes after canonical request %s", b.Len(), cl.name)
}
return nil
}
// skipObject advances b past one boxed object (CRC + body), resolving the
// constructor from m. This compatibility wrapper creates a fresh budget; all
// production transforms call the stateful variant directly.
func (m *schemaModel) skipObject(b *bin.Buffer) error {
return classifyWalkError(newWalkState().skipObject(m, b, 1))
}
func (s *walkState) skipObject(m *schemaModel, b *bin.Buffer, depth int) error {
if err := s.enter(depth, "constructor"); err != nil {
return err
}
id, err := b.PeekID()
if err != nil {
return err
}
cl, ok := m.byCRC[id]
if !ok {
return malformedf("unknown constructor %#08x", id)
}
if err := b.ConsumeID(id); err != nil {
return err
}
return s.skipCtorBody(m, b, cl, depth)
}
// skipCtorBody advances b past a constructor body (no leading CRC), evaluating
// flag integers so conditional fields are read iff present. The constructor's
// unit and depth have already been charged by the caller.
func (s *walkState) skipCtorBody(m *schemaModel, b *bin.Buffer, cl *ctorLayout, depth int) error {
// Layer 227 constructors currently use at most flags + flags2. Keep generous fixed stack
// storage so the allocation-free preflight remains allocation-free on the hottest flagged
// methods; the explicit bound also prevents a future malformed/generated layout from turning
// every request into an attacker-amplified map allocation.
var flags [maxConstructorFlagWords]constructorFlagWord
flagCount := 0
for i := range cl.fields {
f := &cl.fields[i]
if f.isFlags {
v, err := b.Uint32()
if err != nil {
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
}
if flagCount >= len(flags) {
return limitf("constructor %s has more than %d flags words", cl.name, len(flags))
}
flags[flagCount] = constructorFlagWord{name: f.name, value: v}
flagCount++
continue
}
if f.conditional() {
var (
flagValue uint32
found bool
)
for j := 0; j < flagCount; j++ {
if flags[j].name == f.flagName {
flagValue = flags[j].value
found = true
break
}
}
if !found {
return malformedf("constructor %s conditional field %s references missing flags word %s", cl.name, f.name, f.flagName)
}
if flagValue&(1<<uint(f.flagBit)) == 0 {
continue
}
}
if err := s.skipValue(m, b, f, cl, depth); err != nil {
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
}
}
return nil
}
// skipValue advances b past one already-known-present field value.
func (s *walkState) skipValue(m *schemaModel, b *bin.Buffer, f *fieldLayout, owner *ctorLayout, depth int) error {
switch f.kind {
case kindInt:
return skipFixed(b, 4)
case kindLong, kindDouble:
return skipFixed(b, 8)
case kindInt128:
return skipFixed(b, 16)
case kindInt256:
return skipFixed(b, 32)
case kindBytes:
return s.skipTLBytes(b, "bytes")
case kindString:
return s.skipTLBytes(b, "string")
case kindBool:
if err := s.addUnits(1, "Bool constructor"); err != nil {
return err
}
id, err := b.Uint32()
if err != nil {
return err
}
if id != bin.TypeTrue && id != bin.TypeFalse {
return malformedf("invalid Bool constructor %#08x", id)
}
return nil
case kindTrue:
return nil
case kindVector, kindVectorBare:
vectorDepth := depth + 1
if vectorDepth <= 0 || vectorDepth > s.limits.maxDepth {
return limitf("vector nesting depth %d exceeds limit %d", vectorDepth, s.limits.maxDepth)
}
if f.kind == kindVector {
id, err := b.Uint32()
if err != nil {
return err
}
if id != vectorTypeID {
return malformedf("expected vector id, got %#08x", id)
}
}
n, err := b.Int()
if err != nil {
return err
}
if n < 0 {
return malformedf("negative vector length %d", n)
}
if max := s.vectorLimit(owner, f); n > max {
return limitf("vector %s.%s length %d exceeds limit %d", ownerName(owner), fieldName(f), n, max)
}
if err := s.addUnits(n, "vector "+ownerName(owner)+"."+fieldName(f)); err != nil {
return err
}
if width, ok := fixedWireWidth(f.elem); ok {
total, ok := checkedMulInt(n, width)
if !ok {
return malformedf("vector byte length overflow: %d * %d", n, width)
}
return skipFixed(b, total)
}
for i := 0; i < n; i++ {
if err := s.skipValue(m, b, f.elem, nil, vectorDepth); err != nil {
return fmt.Errorf("vector element %d: %w", i, err)
}
}
return nil
case kindObject:
return s.skipObject(m, b, depth+1)
case kindBareObject:
bareDepth := depth + 1
if err := s.enter(bareDepth, "bare constructor"); err != nil {
return err
}
cl, ok := m.bareByT[f.typeName]
if !ok {
return malformedf("unknown bare type %q", f.typeName)
}
return s.skipCtorBody(m, b, cl, bareDepth)
default:
return malformedf("bad wire kind %d", f.kind)
}
}
// skipTLBytes parses TL's 1/4-byte length prefix directly and advances the
// input slice. Unlike bin.Buffer.Bytes it never copies payload data.
func (s *walkState) skipTLBytes(b *bin.Buffer, what string) error {
if len(b.Buf) == 0 {
return io.ErrUnexpectedEOF
}
var header, payload uint64
switch b.Buf[0] {
case 254:
if len(b.Buf) < 4 {
return io.ErrUnexpectedEOF
}
header = 4
payload = uint64(b.Buf[1]) | uint64(b.Buf[2])<<8 | uint64(b.Buf[3])<<16
case 255:
return malformedf("invalid %s length prefix 255", what)
default:
header = 1
payload = uint64(b.Buf[0])
}
if err := s.addBytes(payload, what); err != nil {
return err
}
encoded, ok := checkedAddUint64(header, payload)
if !ok {
return malformedf("%s encoded length overflow", what)
}
withPadding, ok := checkedAddUint64(encoded, 3)
if !ok {
return malformedf("%s padded length overflow", what)
}
padded := withPadding &^ uint64(3)
if padded > uint64(math.MaxInt) {
return malformedf("%s padded length %d overflows int", what, padded)
}
if uint64(len(b.Buf)) < padded {
return io.ErrUnexpectedEOF
}
b.Buf = b.Buf[int(padded):]
return nil
}
func skipFixed(b *bin.Buffer, n int) error {
if n < 0 {
return malformedf("negative fixed-width skip %d", n)
}
if len(b.Buf) < n {
return io.ErrUnexpectedEOF
}
b.Buf = b.Buf[n:]
return nil
}
func fixedWireWidth(f *fieldLayout) (int, bool) {
if f == nil {
return 0, false
}
switch f.kind {
case kindInt:
return 4, true
case kindLong, kindDouble:
return 8, true
case kindInt128:
return 16, true
case kindInt256:
return 32, true
case kindTrue:
return 0, true
default:
// Bool deliberately stays on the element loop so constructor ids are
// validated and charged to the aggregate constructor budget.
return 0, false
}
}
func checkedMulInt(a, b int) (int, bool) {
if a < 0 || b < 0 {
return 0, false
}
if a != 0 && b > math.MaxInt/a {
return 0, false
}
return a * b, true
}
func checkedAddUint64(a, b uint64) (uint64, bool) {
if b > math.MaxUint64-a {
return 0, false
}
return a + b, true
}
func ownerName(cl *ctorLayout) string {
if cl == nil || cl.name == "" {
return "<nested>"
}
return cl.name
}
func fieldName(f *fieldLayout) string {
if f == nil || f.name == "" {
return "<element>"
}
return f.name
}

View file

@ -1,263 +0,0 @@
package layerwire
import (
"errors"
"math"
"testing"
"github.com/gotd/td/bin"
"github.com/gotd/td/tg"
)
func TestValidateCanonicalRequestFlaggedHotPathAllocatesNothing(t *testing.T) {
var body bin.Buffer
req := &tg.MessagesSendMessageRequest{
Peer: &tg.InputPeerSelf{},
Message: "hello",
RandomID: 7,
}
if err := req.Encode(&body); err != nil {
t.Fatalf("encode request: %v", err)
}
if err := ValidateCanonicalRequest(body.Buf); err != nil {
t.Fatalf("validate request: %v", err)
}
if allocs := testing.AllocsPerRun(1000, func() {
if err := ValidateCanonicalRequest(body.Buf); err != nil {
panic(err)
}
}); allocs != 0 {
t.Fatalf("canonical request preflight allocations = %.2f, want 0", allocs)
}
}
func TestValidateCanonicalRequestVectorLimits(t *testing.T) {
editCloseFriends := canonical.byName["contacts.editCloseFriends"]
if editCloseFriends == nil {
t.Fatal("contacts.editCloseFriends missing from canonical schema")
}
t.Run("explicit_5000_override", func(t *testing.T) {
var body bin.Buffer
body.PutID(editCloseFriends.crc)
body.PutVectorHeader(5000)
for i := 0; i < 5000; i++ {
body.PutLong(int64(i))
}
if err := ValidateCanonicalRequest(body.Buf); err != nil {
t.Fatalf("validate legal 5000-element close-friends request: %v", err)
}
})
t.Run("override_stops_at_5000", func(t *testing.T) {
var body bin.Buffer
body.PutID(editCloseFriends.crc)
body.PutVectorHeader(5001)
err := ValidateCanonicalRequest(body.Buf)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
t.Run("default_4096", func(t *testing.T) {
getMessages := canonical.byName["messages.getMessages"]
var body bin.Buffer
body.PutID(getMessages.crc)
body.PutVectorHeader(defaultMaxVectorElements + 1)
err := ValidateCanonicalRequest(body.Buf)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
t.Run("max_int32_count_rejected_before_iteration", func(t *testing.T) {
var body bin.Buffer
body.PutID(editCloseFriends.crc)
body.PutID(vectorTypeID)
body.PutInt32(math.MaxInt32)
err := ValidateCanonicalRequest(body.Buf)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
}
func TestValidateCanonicalRequestDepthLimit(t *testing.T) {
invoke := canonical.byName["invokeWithoutUpdates"]
leaf := canonical.byName["help.getConfig"]
if invoke == nil || leaf == nil {
t.Fatal("generic wrapper methods missing from canonical schema")
}
request := func(wrappers int) []byte {
var body bin.Buffer
for i := 0; i < wrappers; i++ {
body.PutID(invoke.crc)
}
body.PutID(leaf.crc)
return body.Buf
}
if err := ValidateCanonicalRequest(request(defaultMaxWalkDepth - 1)); err != nil {
t.Fatalf("depth exactly %d rejected: %v", defaultMaxWalkDepth, err)
}
err := ValidateCanonicalRequest(request(defaultMaxWalkDepth))
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("depth %d error = %v, want ErrResourceLimit", defaultMaxWalkDepth+1, err)
}
}
func TestTLBytesSkipIsZeroCopyAndBounded(t *testing.T) {
var encoded bin.Buffer
encoded.PutBytes([]byte("payload"))
fieldLen := len(encoded.Buf)
raw := append(encoded.Copy(), 0xaa, 0xbb, 0xcc, 0xdd)
b := &bin.Buffer{Buf: raw}
walk := newWalkState()
if err := walk.skipTLBytes(b, "bytes"); err != nil {
t.Fatalf("skip bytes: %v", err)
}
if len(b.Buf) != 4 || &b.Buf[0] != &raw[fieldLen] {
t.Fatalf("walker did not retain the original backing buffer")
}
t.Run("per_field_budget", func(t *testing.T) {
limited := newWalkState()
limited.limits.maxFieldBytes = 3
probe := &bin.Buffer{Buf: encoded.Copy()}
err := limited.skipTLBytes(probe, "bytes")
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
t.Run("aggregate_budget", func(t *testing.T) {
limited := newWalkState()
limited.limits.maxTotalBytes = 10
first := &bin.Buffer{Buf: encoded.Copy()}
if err := limited.skipTLBytes(first, "bytes"); err != nil {
t.Fatalf("first field: %v", err)
}
second := &bin.Buffer{Buf: encoded.Copy()}
err := limited.skipTLBytes(second, "bytes")
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("second error = %v, want ErrResourceLimit", err)
}
})
t.Run("truncated_payload_is_malformed", func(t *testing.T) {
importAuth := canonical.byName["auth.importAuthorization"]
var body bin.Buffer
body.PutID(importAuth.crc)
body.PutLong(1)
body.Put([]byte{5, 'a', 'b'}) // declares five bytes, lacks payload/padding
err := ValidateCanonicalRequest(body.Buf)
if !errors.Is(err, ErrMalformed) || errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want only ErrMalformed", err)
}
})
}
func TestInboundTransformsShareWalkerBudgets(t *testing.T) {
t.Run("canonical_alias", func(t *testing.T) {
var body bin.Buffer
body.PutID(0x41d41ade) // DrKLO messages.forwardMessages alias
body.PutUint32(0)
body.PutID(canonical.byName["inputPeerEmpty"].crc)
body.PutID(vectorTypeID)
body.PutInt32(math.MaxInt32)
_, ok, err := UpgradeInbound(0x41d41ade, &body)
if !ok || !errors.Is(err, ErrResourceLimit) {
t.Fatalf("ok=%v error=%v, want matched ErrResourceLimit", ok, err)
}
})
t.Run("drift_body_transform", func(t *testing.T) {
var body bin.Buffer
body.PutID(0x2e1ee318) // DrKLO langpack.getStrings body transform
body.PutString("en")
body.PutID(vectorTypeID)
body.PutInt32(math.MaxInt32)
_, ok, err := UpgradeInbound(0x2e1ee318, &body)
if !ok || !errors.Is(err, ErrResourceLimit) {
t.Fatalf("ok=%v error=%v, want matched ErrResourceLimit", ok, err)
}
})
t.Run("outbound_structural_transform", func(t *testing.T) {
poll := canonical.byName["pollAnswerVoters"]
var body bin.Buffer
body.PutID(poll.crc)
body.PutUint32(1 << 2)
body.PutBytes(nil)
body.PutInt(1)
body.PutID(vectorTypeID)
body.PutInt32(math.MaxInt32)
_, err := Transcode(body.Buf, CanonicalLayer-1)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
}
func TestWalkerArithmeticAndMalformedClassification(t *testing.T) {
if defaultMaxWalkUnits != 131072 {
t.Fatalf("default constructor/vector budget = %d, want 131072", defaultMaxWalkUnits)
}
if _, ok := checkedMulInt(math.MaxInt, 2); ok {
t.Fatal("checkedMulInt accepted overflow")
}
if _, ok := checkedAddUint64(math.MaxUint64, 1); ok {
t.Fatal("checkedAddUint64 accepted overflow")
}
t.Run("aggregate_constructor_and_vector_units", func(t *testing.T) {
editCloseFriends := canonical.byName["contacts.editCloseFriends"]
var body bin.Buffer
body.PutID(editCloseFriends.crc)
body.PutVectorHeader(4)
for i := 0; i < 4; i++ {
body.PutLong(int64(i))
}
walk := newWalkState()
walk.limits.maxUnits = 4 // top constructor + four elements needs five
probe := &bin.Buffer{Buf: body.Buf}
err := walk.skipObject(canonical, probe, 1)
if !errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want ErrResourceLimit", err)
}
})
tests := []struct {
name string
body []byte
}{
{name: "empty"},
{name: "unknown_constructor", body: []byte{1, 2, 3, 4}},
{name: "trailing_bytes", body: append(methodIDBytes(canonical.byName["help.getConfig"].crc), 0, 0, 0, 0)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCanonicalRequest(tt.body)
if !errors.Is(err, ErrMalformed) || errors.Is(err, ErrResourceLimit) {
t.Fatalf("error = %v, want only ErrMalformed", err)
}
})
}
}
func methodIDBytes(id uint32) []byte {
var b bin.Buffer
b.PutID(id)
return b.Buf
}
func FuzzValidateCanonicalRequest(f *testing.F) {
f.Add(methodIDBytes(canonical.byName["help.getConfig"].crc))
f.Add([]byte{})
f.Add([]byte{1, 2, 3, 4})
f.Fuzz(func(t *testing.T, body []byte) {
err := ValidateCanonicalRequest(body)
if err != nil && !errors.Is(err, ErrMalformed) && !errors.Is(err, ErrResourceLimit) {
t.Fatalf("unclassified walker error: %v", err)
}
})
}

View file

@ -1,35 +0,0 @@
package layerwire
import (
"testing"
"github.com/gotd/td/bin"
)
func mustEncode(t *testing.T, o bin.Encoder) []byte {
t.Helper()
var b bin.Buffer
if err := o.Encode(&b); err != nil {
t.Fatalf("encode %T: %v", o, err)
}
return b.Copy()
}
// TestWalkConsumesCanonicalObjects encodes a diverse corpus of canonical (gotd,
// Layer 227) objects and asserts the generic walker consumes every byte. Full
// consumption proves the layout handles each field's wire kind (flags,
// multi-flags, conditionals, vectors, nested boxed/bare objects) exactly as gotd
// encoded them.
func TestWalkConsumesCanonicalObjects(t *testing.T) {
for _, o := range canonicalCorpus() {
raw := mustEncode(t, o)
b := &bin.Buffer{Buf: append([]byte(nil), raw...)}
if err := canonical.skipObject(b); err != nil {
t.Errorf("%T: walk error: %v", o, err)
continue
}
if b.Len() != 0 {
t.Errorf("%T: %d/%d bytes left after walk", o, b.Len(), len(raw))
}
}
}

View file

@ -5,7 +5,7 @@ import (
"telesrv/internal/seed/appearance" "telesrv/internal/seed/appearance"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
const appearanceSeedDCID = 2 const appearanceSeedDCID = 2
@ -18,16 +18,16 @@ var peerColorOptionsCache = struct {
profile []tg.HelpPeerColorOption profile []tg.HelpPeerColorOption
}{} }{}
func seedWallPapers() []tg.WallPaperClass { func DefaultWallPapers() []tg.WallPaperClass {
catalog := appearance.Default() catalog := appearance.Default()
out := make([]tg.WallPaperClass, 0, len(catalog.Wallpapers)) out := make([]tg.WallPaperClass, 0, len(catalog.Wallpapers))
for _, wallpaper := range catalog.Wallpapers { for _, wallpaper := range catalog.Wallpapers {
out = append(out, seedWallPaper(wallpaper)) out = append(out, DefaultWallPaper(wallpaper))
} }
return out return out
} }
// LookupWallPaper resolves a cloud wallpaper from the default seed catalog. // LookupWallPaper resolves a cloud wallpaper from the Default seed catalog.
func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) { func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) {
if in, ok := input.(*tg.InputWallPaperNoFile); ok { if in, ok := input.(*tg.InputWallPaperNoFile); ok {
return &tg.WallPaperNoFile{ID: in.ID}, true return &tg.WallPaperNoFile{ID: in.ID}, true
@ -35,13 +35,13 @@ func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) {
catalog := appearance.Default() catalog := appearance.Default()
for _, wallpaper := range catalog.Wallpapers { for _, wallpaper := range catalog.Wallpapers {
if inputWallPaperMatches(input, wallpaper) { if inputWallPaperMatches(input, wallpaper) {
return seedWallPaper(wallpaper), true return DefaultWallPaper(wallpaper), true
} }
} }
return nil, false return nil, false
} }
// LookupWallPapers resolves multiple wallpapers from the default seed catalog. // LookupWallPapers resolves multiple wallpapers from the Default seed catalog.
func LookupWallPapers(inputs []tg.InputWallPaperClass) ([]tg.WallPaperClass, bool) { func LookupWallPapers(inputs []tg.InputWallPaperClass) ([]tg.WallPaperClass, bool) {
out := make([]tg.WallPaperClass, 0, len(inputs)) out := make([]tg.WallPaperClass, 0, len(inputs))
for _, input := range inputs { for _, input := range inputs {
@ -65,28 +65,28 @@ func inputWallPaperMatches(input tg.InputWallPaperClass, wallpaper appearance.Wa
} }
} }
func seedWallPaper(in appearance.Wallpaper) tg.WallPaperClass { func DefaultWallPaper(in appearance.Wallpaper) tg.WallPaperClass {
if in.Type == 1 || in.Document.ID == 0 { if in.Type == 1 || in.Document.ID == 0 {
out := &tg.WallPaperNoFile{ID: in.ID} out := &tg.WallPaperNoFile{ID: in.ID}
out.SetDefault(in.Default) out.SetDefault(in.Default)
out.SetDark(in.Dark) out.SetDark(in.Dark)
out.SetSettings(seedWallPaperSettings(in.Settings)) out.SetSettings(DefaultWallPaperSettings(in.Settings))
return out return out
} }
out := &tg.WallPaper{ out := &tg.WallPaper{
ID: in.ID, ID: in.ID,
AccessHash: in.AccessHash, AccessHash: in.AccessHash,
Slug: in.Slug, Slug: in.Slug,
Document: seedDocument(in.Document), Document: DefaultDocument(in.Document),
} }
out.SetDefault(in.Default) out.SetDefault(in.Default)
out.SetPattern(in.Pattern) out.SetPattern(in.Pattern)
out.SetDark(in.Dark) out.SetDark(in.Dark)
out.SetSettings(seedWallPaperSettings(in.Settings)) out.SetSettings(DefaultWallPaperSettings(in.Settings))
return out return out
} }
func seedWallPaperSettings(in appearance.WallpaperSettings) tg.WallPaperSettings { func DefaultWallPaperSettings(in appearance.WallpaperSettings) tg.WallPaperSettings {
var out tg.WallPaperSettings var out tg.WallPaperSettings
out.SetBlur(in.Blur) out.SetBlur(in.Blur)
out.SetMotion(in.Motion) out.SetMotion(in.Motion)
@ -111,7 +111,7 @@ func seedWallPaperSettings(in appearance.WallpaperSettings) tg.WallPaperSettings
return out return out
} }
func seedDocument(in appearance.Document) tg.DocumentClass { func DefaultDocument(in appearance.Document) tg.DocumentClass {
if in.ID == 0 { if in.ID == 0 {
return &tg.DocumentEmpty{} return &tg.DocumentEmpty{}
} }
@ -121,14 +121,14 @@ func seedDocument(in appearance.Document) tg.DocumentClass {
Date: in.Date, Date: in.Date,
MimeType: in.MimeType, MimeType: in.MimeType,
Size: in.Size, Size: in.Size,
Thumbs: seedPhotoSizes(in.Thumbs), Thumbs: DefaultPhotoSizes(in.Thumbs),
DCID: appearanceSeedDCID, DCID: appearanceSeedDCID,
Attributes: seedDocumentAttributes(in.Attributes), Attributes: DefaultDocumentAttributes(in.Attributes),
FileReference: nil, FileReference: nil,
} }
} }
func seedPhotoSizes(in []appearance.PhotoSize) []tg.PhotoSizeClass { func DefaultPhotoSizes(in []appearance.PhotoSize) []tg.PhotoSizeClass {
out := make([]tg.PhotoSizeClass, 0, len(in)) out := make([]tg.PhotoSizeClass, 0, len(in))
for _, size := range in { for _, size := range in {
switch size.Kind { switch size.Kind {
@ -144,7 +144,7 @@ func seedPhotoSizes(in []appearance.PhotoSize) []tg.PhotoSizeClass {
return out return out
} }
func seedDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttributeClass { func DefaultDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttributeClass {
out := make([]tg.DocumentAttributeClass, 0, len(in)) out := make([]tg.DocumentAttributeClass, 0, len(in))
for _, attr := range in { for _, attr := range in {
switch attr.Kind { switch attr.Kind {
@ -159,20 +159,20 @@ func seedDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttr
return out return out
} }
func seedPeerColorOptions(profile bool) []tg.HelpPeerColorOption { func DefaultPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
if profile { if profile {
peerColorOptionsCache.profileOnce.Do(func() { peerColorOptionsCache.profileOnce.Do(func() {
peerColorOptionsCache.profile = buildSeedPeerColorOptions(true) peerColorOptionsCache.profile = buildDefaultPeerColorOptions(true)
}) })
return clonePeerColorOptions(peerColorOptionsCache.profile) return clonePeerColorOptions(peerColorOptionsCache.profile)
} }
peerColorOptionsCache.regularOnce.Do(func() { peerColorOptionsCache.regularOnce.Do(func() {
peerColorOptionsCache.regular = buildSeedPeerColorOptions(false) peerColorOptionsCache.regular = buildDefaultPeerColorOptions(false)
}) })
return clonePeerColorOptions(peerColorOptionsCache.regular) return clonePeerColorOptions(peerColorOptionsCache.regular)
} }
func buildSeedPeerColorOptions(profile bool) []tg.HelpPeerColorOption { func buildDefaultPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
catalog := appearance.Default() catalog := appearance.Default()
source := catalog.PeerColors source := catalog.PeerColors
if profile { if profile {
@ -193,10 +193,10 @@ func buildSeedPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
if groupMin > 0 { if groupMin > 0 {
option.SetGroupMinLevel(groupMin) option.SetGroupMinLevel(groupMin)
} }
if colors := seedPeerColorSet(color.Colors); colors != nil { if colors := DefaultPeerColorSet(color.Colors); colors != nil {
option.SetColors(colors) option.SetColors(colors)
} }
if colors := seedPeerColorSet(color.DarkColors); colors != nil { if colors := DefaultPeerColorSet(color.DarkColors); colors != nil {
option.SetDarkColors(colors) option.SetDarkColors(colors)
} }
out = append(out, option) out = append(out, option)
@ -246,7 +246,7 @@ func boundedPeerColorMinLevel(level int) int {
return level return level
} }
func seedPeerColorID(id int, profile bool) (bool, bool) { func DefaultPeerColorID(id int, profile bool) (bool, bool) {
catalog := appearance.Default() catalog := appearance.Default()
source := catalog.PeerColors source := catalog.PeerColors
if profile { if profile {
@ -263,7 +263,7 @@ func seedPeerColorID(id int, profile bool) (bool, bool) {
return false, true return false, true
} }
func seedPeerColorSet(in *appearance.ColorSet) tg.HelpPeerColorSetClass { func DefaultPeerColorSet(in *appearance.ColorSet) tg.HelpPeerColorSetClass {
if in == nil { if in == nil {
return nil return nil
} }

View file

@ -3,7 +3,7 @@ package tdesktop
import ( import (
"time" "time"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/links" "telesrv/internal/links"
) )

View file

@ -1,7 +1,7 @@
package tdesktop package tdesktop
import ( import (
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/seed/catalog" "telesrv/internal/seed/catalog"
) )

View file

@ -3,7 +3,7 @@ package tdesktop
import ( import (
"time" "time"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"telesrv/internal/seed/appearance" "telesrv/internal/seed/appearance"
"telesrv/internal/seed/catalog" "telesrv/internal/seed/catalog"
@ -147,7 +147,7 @@ func catalogThemeSettings(s appearance.ThemeSettings, base tg.BaseThemeClass) tg
ts.SetMessageColors(append([]int(nil), s.MessageColors...)) ts.SetMessageColors(append([]int(nil), s.MessageColors...))
} }
if s.Wallpaper.ID != 0 || s.Wallpaper.Document.ID != 0 { if s.Wallpaper.ID != 0 || s.Wallpaper.Document.ID != 0 {
ts.SetWallpaper(seedWallPaper(s.Wallpaper)) ts.SetWallpaper(DefaultWallPaper(s.Wallpaper))
} }
return ts return ts
} }
@ -194,13 +194,13 @@ func UniqueGiftChatThemes(hash int64) tg.AccountChatThemesClass {
} }
} }
// WallPapers returns the read-only default wallpaper catalog. User wallpaper // WallPapers returns the read-only Default wallpaper catalog. User wallpaper
// upload/save/install remains outside the current TDesktop compatibility scope. // upload/save/install remains outside the current TDesktop compatibility scope.
func WallPapers(hash int64) tg.AccountWallPapersClass { func WallPapers(hash int64) tg.AccountWallPapersClass {
if hash == wallPapersHash { if hash == wallPapersHash {
return &tg.AccountWallPapersNotModified{} return &tg.AccountWallPapersNotModified{}
} }
wallpapers := seedWallPapers() wallpapers := DefaultWallPapers()
return &tg.AccountWallPapers{ return &tg.AccountWallPapers{
Hash: wallPapersHash, Hash: wallPapersHash,
Wallpapers: wallpapers, Wallpapers: wallpapers,
@ -425,7 +425,7 @@ var defaultPeerColors = []defaultPeerColor{
// IsPeerColorID reports whether id is in the TDesktop-compatible peer color palette. // IsPeerColorID reports whether id is in the TDesktop-compatible peer color palette.
func IsPeerColorID(id int) bool { func IsPeerColorID(id int) bool {
if found, seeded := seedPeerColorID(id, false); seeded { if found, seeded := DefaultPeerColorID(id, false); seeded {
return found return found
} }
for _, color := range defaultPeerColors { for _, color := range defaultPeerColors {
@ -438,7 +438,7 @@ func IsPeerColorID(id int) bool {
// IsPeerProfileColorID reports whether id is in the profile background palette. // IsPeerProfileColorID reports whether id is in the profile background palette.
func IsPeerProfileColorID(id int) bool { func IsPeerProfileColorID(id int) bool {
if found, seeded := seedPeerColorID(id, true); seeded { if found, seeded := DefaultPeerColorID(id, true); seeded {
return found return found
} }
return IsPeerColorID(id) return IsPeerColorID(id)
@ -448,7 +448,7 @@ func PeerColors(hash int) tg.HelpPeerColorsClass {
if hash == peerColorsHash { if hash == peerColorsHash {
return &tg.HelpPeerColorsNotModified{} return &tg.HelpPeerColorsNotModified{}
} }
colors := seedPeerColorOptions(false) colors := DefaultPeerColorOptions(false)
if len(colors) == 0 { if len(colors) == 0 {
colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors)) colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors))
for _, color := range defaultPeerColors { for _, color := range defaultPeerColors {
@ -468,7 +468,7 @@ func PeerProfileColors(hash int) tg.HelpPeerColorsClass {
if hash == peerProfileColorsHash { if hash == peerProfileColorsHash {
return &tg.HelpPeerColorsNotModified{} return &tg.HelpPeerColorsNotModified{}
} }
colors := seedPeerColorOptions(true) colors := DefaultPeerColorOptions(true)
if len(colors) == 0 { if len(colors) == 0 {
colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors)) colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors))
for _, color := range defaultPeerColors { for _, color := range defaultPeerColors {

View file

@ -4,7 +4,7 @@ import (
"math" "math"
"testing" "testing"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
func TestNotifySettingsDefaultIsAudible(t *testing.T) { func TestNotifySettingsDefaultIsAudible(t *testing.T) {
@ -233,7 +233,7 @@ func TestUniqueGiftChatThemesIsEmptyHashableStub(t *testing.T) {
} }
} }
func TestWallPapersUsesOrangeFileCatalog(t *testing.T) { func TestWallPapersUsesDefaultFileCatalog(t *testing.T) {
got, ok := WallPapers(0).(*tg.AccountWallPapers) got, ok := WallPapers(0).(*tg.AccountWallPapers)
if !ok { if !ok {
t.Fatalf("WallPapers(0) = %T, want modified list", got) t.Fatalf("WallPapers(0) = %T, want modified list", got)
@ -249,14 +249,14 @@ func TestWallPapersUsesOrangeFileCatalog(t *testing.T) {
t.Fatalf("WallPapers(0).Wallpapers[0] = %T, want *tg.WallPaper", got.Wallpapers[0]) t.Fatalf("WallPapers(0).Wallpapers[0] = %T, want *tg.WallPaper", got.Wallpapers[0])
} }
if wallpaper.ID == 0 || wallpaper.AccessHash == 0 || wallpaper.Slug == "" { if wallpaper.ID == 0 || wallpaper.AccessHash == 0 || wallpaper.Slug == "" {
t.Fatalf("wallpaper identity = id %d hash %d slug %q, want seed ids", wallpaper.ID, wallpaper.AccessHash, wallpaper.Slug) t.Fatalf("wallpaper identity = id %d hash %d slug %q, want Default ids", wallpaper.ID, wallpaper.AccessHash, wallpaper.Slug)
} }
doc, ok := wallpaper.Document.(*tg.Document) doc, ok := wallpaper.Document.(*tg.Document)
if !ok { if !ok {
t.Fatalf("wallpaper document = %T, want *tg.Document", wallpaper.Document) t.Fatalf("wallpaper document = %T, want *tg.Document", wallpaper.Document)
} }
if doc.ID == 0 || doc.AccessHash == 0 || doc.Size == 0 || doc.MimeType == "" || doc.DCID != appearanceSeedDCID { if doc.ID == 0 || doc.AccessHash == 0 || doc.Size == 0 || doc.MimeType == "" || doc.DCID != appearanceSeedDCID {
t.Fatalf("wallpaper document = id %d hash %d size %d mime %q dc %d, want downloadable seed document", t.Fatalf("wallpaper document = id %d hash %d size %d mime %q dc %d, want downloadable Default document",
doc.ID, doc.AccessHash, doc.Size, doc.MimeType, doc.DCID) doc.ID, doc.AccessHash, doc.Size, doc.MimeType, doc.DCID)
} }
if len(doc.Thumbs) == 0 { if len(doc.Thumbs) == 0 {
@ -392,7 +392,7 @@ func TestPeerColorsAreNonEmptyHashableAccentSets(t *testing.T) {
t.Fatalf("PeerColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors)) t.Fatalf("PeerColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors))
} }
if len(got.Colors) != 21 { if len(got.Colors) != 21 {
t.Fatalf("PeerColors(0).Colors length = %d, want seed palette count 21", len(got.Colors)) t.Fatalf("PeerColors(0).Colors length = %d, want Default palette count 21", len(got.Colors))
} }
withExplicitColors := 0 withExplicitColors := 0
for i, option := range got.Colors { for i, option := range got.Colors {
@ -412,7 +412,7 @@ func TestPeerColorsAreNonEmptyHashableAccentSets(t *testing.T) {
withExplicitColors++ withExplicitColors++
} }
if withExplicitColors == 0 { if withExplicitColors == 0 {
t.Fatal("PeerColors() has no explicit seed color sets") t.Fatal("PeerColors() has no explicit Default color sets")
} }
if _, ok := PeerColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok { if _, ok := PeerColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok {
t.Fatalf("PeerColors(hash) = %#v, want notModified", PeerColors(got.Hash)) t.Fatalf("PeerColors(hash) = %#v, want notModified", PeerColors(got.Hash))
@ -428,7 +428,7 @@ func TestPeerProfileColorsAreNonEmptyHashableProfileSets(t *testing.T) {
t.Fatalf("PeerProfileColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors)) t.Fatalf("PeerProfileColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors))
} }
if len(got.Colors) != 16 { if len(got.Colors) != 16 {
t.Fatalf("PeerProfileColors(0).Colors length = %d, want seed profile palette count 16", len(got.Colors)) t.Fatalf("PeerProfileColors(0).Colors length = %d, want Default profile palette count 16", len(got.Colors))
} }
for i, option := range got.Colors { for i, option := range got.Colors {
if !IsPeerProfileColorID(option.ColorID) { if !IsPeerProfileColorID(option.ColorID) {

View file

@ -36,13 +36,23 @@ type Config struct {
// MTProtoMaxConcurrentHandshakes 限制昂贵 RSA/DH exchange 并发;负数关闭。 // MTProtoMaxConcurrentHandshakes 限制昂贵 RSA/DH exchange 并发;负数关闭。
MTProtoMaxConcurrentHandshakes int MTProtoMaxConcurrentHandshakes int
// MTProto RPC 使用 Server 共享公平调度器per-connection 与 global 预算共同限制 // MTProto RPC 使用 Server 共享公平调度器per-connection 与 global 预算共同限制
// goroutine、排队任务和 request body 内存。 // goroutine、排队任务和 request memory charge。legacy charge 等于 copied body
// exact charge 是 typed decode 前的保守 materialization 上界,不等同 wire bytes。
MTProtoRPCMaxInflight int MTProtoRPCMaxInflight int
MTProtoRPCQueueSize int MTProtoRPCQueueSize int
MTProtoRPCTimeout time.Duration MTProtoRPCTimeout time.Duration
MTProtoRPCGlobalWorkers int MTProtoRPCGlobalWorkers int
MTProtoRPCGlobalMaxTasks int MTProtoRPCGlobalMaxTasks int
MTProtoRPCGlobalMaxBytes int64 MTProtoRPCGlobalMaxBytes int64
// Pending ownership and completed rpc_result replay state share a three-level
// global/raw-auth/session budget over the full MTProto duplicate horizon.
MTProtoRPCResultCacheMaxEntries int
MTProtoRPCResultCacheMaxBytes int64
MTProtoRPCResultCacheAuthMaxEntries int
MTProtoRPCResultCacheAuthMaxBytes int64
MTProtoRPCResultCacheSessionMaxEntries int
MTProtoRPCResultCacheSessionMaxBytes int64
MTProtoRPCResultPendingPerAuth int
// MTProtoInboundFrameGlobalMaxBytes 是 transport wire + 最大解密 plaintext 的 // MTProtoInboundFrameGlobalMaxBytes 是 transport wire + 最大解密 plaintext 的
// 进程级在途预算frame 长度读出后、payload 分配前预留。 // 进程级在途预算frame 长度读出后、payload 分配前预留。
MTProtoInboundFrameGlobalMaxBytes int64 MTProtoInboundFrameGlobalMaxBytes int64
@ -403,6 +413,17 @@ func Load() (Config, error) {
MTProtoRPCGlobalWorkers: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", 256), MTProtoRPCGlobalWorkers: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", 256),
MTProtoRPCGlobalMaxTasks: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", 8192), MTProtoRPCGlobalMaxTasks: envIntOr("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", 8192),
MTProtoRPCGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", 512<<20), MTProtoRPCGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", 512<<20),
MTProtoRPCResultCacheMaxEntries: envIntOr("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES", 1<<18),
MTProtoRPCResultCacheMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES", 64<<20),
MTProtoRPCResultCacheAuthMaxEntries: envIntOr("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES", 1<<15),
MTProtoRPCResultCacheAuthMaxBytes: envInt64Or("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES", 32<<20),
MTProtoRPCResultCacheSessionMaxEntries: envIntOr(
"TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES", 1<<14,
),
MTProtoRPCResultCacheSessionMaxBytes: envInt64Or(
"TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES", 16<<20,
),
MTProtoRPCResultPendingPerAuth: envIntOr("TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH", 1<<11),
MTProtoInboundFrameGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES", 512<<20), MTProtoInboundFrameGlobalMaxBytes: envInt64Or("TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES", 512<<20),
MTProtoOutboundQueueSize: envIntOr("TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE", 128), MTProtoOutboundQueueSize: envIntOr("TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE", 128),
MTProtoOutboundControlQueueSize: envIntOr("TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE", 32), MTProtoOutboundControlQueueSize: envIntOr("TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE", 32),
@ -549,9 +570,45 @@ func Load() (Config, error) {
if err := validateLoginEmailConfig(cfg); err != nil { if err := validateLoginEmailConfig(cfg); err != nil {
return Config{}, err return Config{}, err
} }
if err := validateRPCResultCacheConfig(cfg); err != nil {
return Config{}, err
}
return cfg, nil return cfg, nil
} }
const mtProtoRPCResultMinBytes = int64((1 << 24) - (2 << 10))
func validateRPCResultCacheConfig(cfg Config) error {
if cfg.MTProtoRPCResultCacheMaxEntries <= 0 || cfg.MTProtoRPCResultCacheAuthMaxEntries <= 0 ||
cfg.MTProtoRPCResultCacheSessionMaxEntries <= 0 {
return fmt.Errorf("MTProto rpc_result entry limits must be positive")
}
if cfg.MTProtoRPCResultCacheMaxEntries < cfg.MTProtoRPCResultCacheAuthMaxEntries ||
cfg.MTProtoRPCResultCacheAuthMaxEntries < cfg.MTProtoRPCResultCacheSessionMaxEntries {
return fmt.Errorf("MTProto rpc_result entry hierarchy must satisfy global >= auth >= session: %d/%d/%d",
cfg.MTProtoRPCResultCacheMaxEntries, cfg.MTProtoRPCResultCacheAuthMaxEntries, cfg.MTProtoRPCResultCacheSessionMaxEntries)
}
if cfg.MTProtoRPCResultCacheMaxBytes < mtProtoRPCResultMinBytes ||
cfg.MTProtoRPCResultCacheAuthMaxBytes < mtProtoRPCResultMinBytes ||
cfg.MTProtoRPCResultCacheSessionMaxBytes < mtProtoRPCResultMinBytes {
return fmt.Errorf("MTProto rpc_result byte limits must each be at least %d: %d/%d/%d",
mtProtoRPCResultMinBytes, cfg.MTProtoRPCResultCacheMaxBytes,
cfg.MTProtoRPCResultCacheAuthMaxBytes, cfg.MTProtoRPCResultCacheSessionMaxBytes)
}
if cfg.MTProtoRPCResultCacheMaxBytes < cfg.MTProtoRPCResultCacheAuthMaxBytes ||
cfg.MTProtoRPCResultCacheAuthMaxBytes < cfg.MTProtoRPCResultCacheSessionMaxBytes {
return fmt.Errorf("MTProto rpc_result byte hierarchy must satisfy global >= auth >= session: %d/%d/%d",
cfg.MTProtoRPCResultCacheMaxBytes, cfg.MTProtoRPCResultCacheAuthMaxBytes, cfg.MTProtoRPCResultCacheSessionMaxBytes)
}
if cfg.MTProtoRPCGlobalMaxTasks <= 0 || cfg.MTProtoRPCResultPendingPerAuth <= 0 ||
cfg.MTProtoRPCResultPendingPerAuth > cfg.MTProtoRPCGlobalMaxTasks ||
cfg.MTProtoRPCResultPendingPerAuth > cfg.MTProtoRPCResultCacheAuthMaxEntries {
return fmt.Errorf("MTProto rpc_result pending-per-auth %d must be positive and <= global pending %d and auth entries %d",
cfg.MTProtoRPCResultPendingPerAuth, cfg.MTProtoRPCGlobalMaxTasks, cfg.MTProtoRPCResultCacheAuthMaxEntries)
}
return nil
}
func validateLoginEmailConfig(cfg Config) error { func validateLoginEmailConfig(cfg Config) error {
if cfg.LoginEmailRequireSetup && !cfg.LoginEmailEnable { if cfg.LoginEmailRequireSetup && !cfg.LoginEmailEnable {
return fmt.Errorf("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP requires TELESRV_LOGIN_EMAIL_ENABLE=true") return fmt.Errorf("TELESRV_LOGIN_EMAIL_REQUIRE_SETUP requires TELESRV_LOGIN_EMAIL_ENABLE=true")

View file

@ -57,6 +57,13 @@ func TestLoadMTProtoAdmissionAndRPCBudgets(t *testing.T) {
t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", "33") t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_WORKERS", "33")
t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", "444") t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS", "444")
t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", "555555") t.Setenv("TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES", "555555")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES", "555")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES", "70000000")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES", "444")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES", "40000000")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES", "333")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES", "20000000")
t.Setenv("TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH", "222")
t.Setenv("TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES", "777777") t.Setenv("TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES", "777777")
t.Setenv("TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE", "88") t.Setenv("TELESRV_MTPROTO_OUTBOUND_QUEUE_SIZE", "88")
t.Setenv("TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE", "22") t.Setenv("TELESRV_MTPROTO_OUTBOUND_CONTROL_QUEUE_SIZE", "22")
@ -77,6 +84,16 @@ func TestLoadMTProtoAdmissionAndRPCBudgets(t *testing.T) {
cfg.MTProtoRPCGlobalWorkers != 33 || cfg.MTProtoRPCGlobalMaxTasks != 444 || cfg.MTProtoRPCGlobalMaxBytes != 555555 { cfg.MTProtoRPCGlobalWorkers != 33 || cfg.MTProtoRPCGlobalMaxTasks != 444 || cfg.MTProtoRPCGlobalMaxBytes != 555555 {
t.Fatalf("rpc budget config = %d/%d/%v/%d/%d/%d", cfg.MTProtoRPCMaxInflight, cfg.MTProtoRPCQueueSize, cfg.MTProtoRPCTimeout, cfg.MTProtoRPCGlobalWorkers, cfg.MTProtoRPCGlobalMaxTasks, cfg.MTProtoRPCGlobalMaxBytes) t.Fatalf("rpc budget config = %d/%d/%v/%d/%d/%d", cfg.MTProtoRPCMaxInflight, cfg.MTProtoRPCQueueSize, cfg.MTProtoRPCTimeout, cfg.MTProtoRPCGlobalWorkers, cfg.MTProtoRPCGlobalMaxTasks, cfg.MTProtoRPCGlobalMaxBytes)
} }
if cfg.MTProtoRPCResultCacheMaxEntries != 555 || cfg.MTProtoRPCResultCacheMaxBytes != 70000000 ||
cfg.MTProtoRPCResultCacheAuthMaxEntries != 444 || cfg.MTProtoRPCResultCacheAuthMaxBytes != 40000000 ||
cfg.MTProtoRPCResultCacheSessionMaxEntries != 333 || cfg.MTProtoRPCResultCacheSessionMaxBytes != 20000000 ||
cfg.MTProtoRPCResultPendingPerAuth != 222 {
t.Fatalf("rpc result cache config = global:%d/%d auth:%d/%d session:%d/%d pending/auth:%d",
cfg.MTProtoRPCResultCacheMaxEntries, cfg.MTProtoRPCResultCacheMaxBytes,
cfg.MTProtoRPCResultCacheAuthMaxEntries, cfg.MTProtoRPCResultCacheAuthMaxBytes,
cfg.MTProtoRPCResultCacheSessionMaxEntries, cfg.MTProtoRPCResultCacheSessionMaxBytes,
cfg.MTProtoRPCResultPendingPerAuth)
}
if cfg.MTProtoInboundFrameGlobalMaxBytes != 777777 { if cfg.MTProtoInboundFrameGlobalMaxBytes != 777777 {
t.Fatalf("inbound frame budget config = %d", cfg.MTProtoInboundFrameGlobalMaxBytes) t.Fatalf("inbound frame budget config = %d", cfg.MTProtoInboundFrameGlobalMaxBytes)
} }
@ -88,6 +105,46 @@ func TestLoadMTProtoAdmissionAndRPCBudgets(t *testing.T) {
} }
} }
func TestLoadRPCResultFairBudgetDefaults(t *testing.T) {
disableDefaultConfigFile(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.MTProtoRPCResultCacheMaxEntries != 1<<18 || cfg.MTProtoRPCResultCacheMaxBytes != 64<<20 ||
cfg.MTProtoRPCResultCacheAuthMaxEntries != 1<<15 || cfg.MTProtoRPCResultCacheAuthMaxBytes != 32<<20 ||
cfg.MTProtoRPCResultCacheSessionMaxEntries != 1<<14 || cfg.MTProtoRPCResultCacheSessionMaxBytes != 16<<20 ||
cfg.MTProtoRPCResultPendingPerAuth != 1<<11 {
t.Fatalf("rpc_result fair defaults = global:%d/%d auth:%d/%d session:%d/%d pending/auth:%d",
cfg.MTProtoRPCResultCacheMaxEntries, cfg.MTProtoRPCResultCacheMaxBytes,
cfg.MTProtoRPCResultCacheAuthMaxEntries, cfg.MTProtoRPCResultCacheAuthMaxBytes,
cfg.MTProtoRPCResultCacheSessionMaxEntries, cfg.MTProtoRPCResultCacheSessionMaxBytes,
cfg.MTProtoRPCResultPendingPerAuth)
}
}
func TestLoadRejectsInvalidRPCResultFairBudgets(t *testing.T) {
tests := []struct {
name string
key string
value string
}{
{name: "entry hierarchy", key: "TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES", value: "1024"},
{name: "byte below outbound body", key: "TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES", value: "16700000"},
{name: "byte hierarchy", key: "TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES", value: "70000000"},
{name: "pending hierarchy", key: "TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH", value: "9000"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
disableDefaultConfigFile(t)
t.Setenv(test.key, test.value)
if _, err := Load(); err == nil {
t.Fatalf("Load accepted invalid %s=%s", test.key, test.value)
}
})
}
}
func TestLoadOutboxPoisonPolicy(t *testing.T) { func TestLoadOutboxPoisonPolicy(t *testing.T) {
disableDefaultConfigFile(t) disableDefaultConfigFile(t)
t.Setenv("TELESRV_OUTBOX_POISON_RETENTION", "2m") t.Setenv("TELESRV_OUTBOX_POISON_RETENTION", "2m")

View file

@ -8,6 +8,9 @@ type Authorization struct {
AuthKeyID [8]byte // 协议原生 auth_key_idstore 边界按小端转 int64 AuthKeyID [8]byte // 协议原生 auth_key_idstore 边界按小端转 int64
UserID int64 UserID int64
Hash int64 Hash int64
// Layer is the last supported protocol profile explicitly observed for this
// auth key. It is a durable default for a new session, never an instruction
// to rewrite an already-active session's own profile.
Layer int Layer int
DeviceModel string DeviceModel string
Platform string Platform string
@ -23,10 +26,15 @@ type Authorization struct {
} }
// AuthKeyClientInfo 是未登录 auth_key 也需要保留的客户端协商元数据。 // AuthKeyClientInfo 是未登录 auth_key 也需要保留的客户端协商元数据。
// 登录后的设备授权仍由 Authorization 表达;这里仅用于服务端重启后恢复 // 登录后的设备授权仍由 Authorization 表达。Layer 保存最后一次受支持的显式
// pre-auth / setup 流程的 client type 与 layer。 // wire profile供服务端重启后为同一 auth key 的新 session 初始化默认值;活跃
// session 仍以自己的显式 invokeWithLayer 纠正值为准。
type AuthKeyClientInfo struct { type AuthKeyClientInfo struct {
Layer int Layer int
// LayerObservationID is a read-only ordering token owned by the protocol
// store. Generic client metadata updates must never manufacture or advance
// it; only ordered invokeWithLayer evidence may do so.
LayerObservationID int64
DeviceModel string DeviceModel string
Platform string Platform string
SystemVersion string SystemVersion string

View file

@ -1,4 +1,4 @@
// Package domain 存放业务实体与值对象User、Peer、Dialog、Message、MessageID 等)。 // Package domain 存放业务实体与值对象User、Peer、Dialog、Message、MessageID 等)。
// //
// 铁律:本包禁止依赖 gotd/td/tg 等协议层类型TL 类型只允许出现在 RPC/MTProto 边界。 // 铁律:本包禁止依赖 iamxvbaba/td/tg 等协议层类型TL 类型只允许出现在 RPC/MTProto 边界。
package domain package domain

View file

@ -8,3 +8,14 @@ type UpdateState struct {
Date int Date int
Seq int Seq int
} }
// UpdateStateCommitMode describes what a physically delivered update-state
// response proves. Every delivered baseline advances the device-local
// confirmed cursor; only an explicitly audited getState baseline also proves
// that retention may advance the client-observed cursor to the same point.
type UpdateStateCommitMode uint8
const (
UpdateStateCommitDeliveredOnly UpdateStateCommitMode = iota + 1
UpdateStateCommitDeliveredAndObservedBaseline
)

View file

@ -9,8 +9,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"telesrv/internal/store" "telesrv/internal/store"
"telesrv/internal/store/memory" "telesrv/internal/store/memory"

View file

@ -12,15 +12,15 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mt" "github.com/iamxvbaba/td/mt"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
) )
func TestAuthKeyProtocolUnavailable(t *testing.T) { func TestAuthKeyProtocolUnavailable(t *testing.T) {
@ -120,7 +120,7 @@ func TestActiveTemporaryAuthKeyExpiresBeforeNextRPCDispatch(t *testing.T) {
addr, pub, srv := startTestServer(t, Options{ addr, pub, srv := startTestServer(t, Options{
DC: dc, DC: dc,
Clock: testClock, Clock: testClock,
RPC: handler, legacyRPC: handler,
}) })
conn, auth, cipher := dialTemporaryHandshakeForExpiryTest(t, addr, dc, expiresIn, pub) conn, auth, cipher := dialTemporaryHandshakeForExpiryTest(t, addr, dc, expiresIn, pub)
@ -179,7 +179,7 @@ func TestExpiredTemporaryAuthKeyRejectsServerPushWithoutWireWrite(t *testing.T)
c.authKeyExpiresAt = int(now.Unix()) c.authKeyExpiresAt = int(now.Unix())
err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer,
&encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}}, 0) exactTestUpdatesTooLong(t, c), 0)
if !errors.Is(err, ErrConnClosed) { if !errors.Is(err, ErrConnClosed) {
t.Fatalf("push on expired temp key = %v, want ErrConnClosed", err) t.Fatalf("push on expired temp key = %v, want ErrConnClosed", err)
} }
@ -198,7 +198,7 @@ func TestQueuedPushCannotCrossTemporaryAuthKeyExpiry(t *testing.T) {
c := newOutboundTestConn(t, tr, nil) c := newOutboundTestConn(t, tr, nil)
c.now = clock.Now c.now = clock.Now
c.authKeyExpiresAt = int(now.Add(time.Minute).Unix()) c.authKeyExpiresAt = int(now.Add(time.Minute).Unix())
encoded := &encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}} encoded := exactTestUpdatesTooLong(t, c)
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil { if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil {
t.Fatalf("enqueue first push: %v", err) t.Fatalf("enqueue first push: %v", err)
@ -247,7 +247,7 @@ func TestTemporaryAuthKeyExpiryWhileWaitingForPhysicalWriterSkipsRawSend(t *test
t.Fatal("direct protocol write did not acquire physical writer") t.Fatal("direct protocol write did not acquire physical writer")
} }
encoded := &encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}} encoded := exactTestUpdatesTooLong(t, c)
actorDone := make(chan error, 1) actorDone := make(chan error, 1)
go func() { go func() {
actorDone <- c.SendEncoded(context.Background(), proto.MessageFromServer, encoded) actorDone <- c.SendEncoded(context.Background(), proto.MessageFromServer, encoded)
@ -295,9 +295,9 @@ func TestRetiredActorWaitingForPhysicalWriterDoesNotDefeatLeaseTransfer(t *testi
} }
actorDone := make(chan error, 1) actorDone := make(chan error, 1)
encoded := exactTestUpdatesTooLong(t, c)
go func() { go func() {
actorDone <- c.SendEncoded(context.Background(), proto.MessageFromServer, actorDone <- c.SendEncoded(context.Background(), proto.MessageFromServer, encoded)
&encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}})
}() }()
select { select {
case <-signaling.entered: case <-signaling.entered:
@ -350,9 +350,9 @@ func TestTerminalAuthKeyNotFoundSurvivesActorWaitingForPhysicalWriter(t *testing
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("direct protocol write did not acquire physical writer") t.Fatal("direct protocol write did not acquire physical writer")
} }
encoded := exactTestUpdatesTooLong(t, c)
go func() { go func() {
_ = c.SendEncoded(context.Background(), proto.MessageFromServer, _ = c.SendEncoded(context.Background(), proto.MessageFromServer, encoded)
&encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}})
}() }()
select { select {
case <-signaling.entered: case <-signaling.entered:
@ -402,7 +402,7 @@ func TestTerminalAuthKeyNotFoundWaitsForOutboundAndIsLastFrame(t *testing.T) {
c.transportLease = lease c.transportLease = lease
c.now = clock.Now c.now = clock.Now
c.authKeyExpiresAt = int(now.Add(time.Minute).Unix()) c.authKeyExpiresAt = int(now.Add(time.Minute).Unix())
encoded := &encodedOutboundMessage{typeID: tg.UpdatesTooLongTypeID, body: []byte{0x0b, 0xa1, 0x01, 0xe3}} encoded := exactTestUpdatesTooLong(t, c)
if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil { if err := c.SendBestEffortEncoded(context.Background(), proto.MessageFromServer, encoded, 0); err != nil {
t.Fatalf("enqueue blocked push: %v", err) t.Fatalf("enqueue blocked push: %v", err)
} }

View file

@ -4,8 +4,8 @@ import (
"testing" "testing"
"time" "time"
"github.com/gotd/td/mt" "github.com/iamxvbaba/td/mt"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
) )
func TestEncryptedConnectionSwitchesAuthKeyEvenWhenSessionIDIsReused(t *testing.T) { func TestEncryptedConnectionSwitchesAuthKeyEvenWhenSessionIDIsReused(t *testing.T) {

View file

@ -12,13 +12,13 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/session" "github.com/iamxvbaba/td/session"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/app/account" "telesrv/internal/app/account"
"telesrv/internal/app/auth" "telesrv/internal/app/auth"
@ -84,8 +84,19 @@ func newBotCallbackEnv(t *testing.T, ctx context.Context) *botCallbackEnv {
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
botsService.SetRouterHooks(router) botsService.SetRouterHooks(router)
botsService.SetTextDraftPusher(router) botsService.SetTextDraftPusher(router)
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions}) srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router, ActiveSessions: activeSessions})
go func() { _ = srv.Serve(ctx, ln) }() serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve(ctx, ln) }()
t.Cleanup(func() {
select {
case err := <-serveErr:
if err != nil {
t.Errorf("serve: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("server did not stop after callback test context cancellation")
}
})
newCli := func(storage *session.StorageMemory, handler telegram.UpdateHandler) *telegram.Client { newCli := func(storage *session.StorageMemory, handler telegram.UpdateHandler) *telegram.Client {
if handler == nil { if handler == nil {

View file

@ -13,14 +13,14 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/session" "github.com/iamxvbaba/td/session"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/tgerr" "github.com/iamxvbaba/td/tgerr"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/app/account" "telesrv/internal/app/account"
"telesrv/internal/app/auth" "telesrv/internal/app/auth"
@ -87,7 +87,7 @@ func TestBotManagementRPCFlow(t *testing.T) {
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
botsService.SetRouterHooks(router) botsService.SetRouterHooks(router)
botsService.SetTextDraftPusher(router) botsService.SetTextDraftPusher(router)
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions}) srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router, ActiveSessions: activeSessions})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
@ -323,7 +323,7 @@ func TestBotFatherCreateAndBotLoginFlow(t *testing.T) {
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
botsService.SetRouterHooks(router) botsService.SetRouterHooks(router)
botsService.SetTextDraftPusher(router) botsService.SetTextDraftPusher(router)
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router, ActiveSessions: activeSessions}) srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router, ActiveSessions: activeSessions})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()

View file

@ -4,16 +4,16 @@ import (
"bufio" "bufio"
"context" "context"
"encoding/hex" "encoding/hex"
"errors"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/transport"
"telesrv/internal/compat/layerwire"
) )
// Conn 是一个已识别 session 的客户端连接,持有向其加密发送消息所需的全部上下文。 // Conn 是一个已识别 session 的客户端连接,持有向其加密发送消息所需的全部上下文。
@ -39,6 +39,21 @@ const (
connLifecycleRetired connLifecycleRetired
) )
var (
// ErrLayerProfileUnsupported means the requested profile has no generated
// exact codec. Profiles are never clamped to the nearest supported layer.
ErrLayerProfileUnsupported = errors.New("unsupported exact layer profile")
// ErrLayerProfileConflict means one admitted request carried contradictory
// profile evidence. A later, well-formed invokeWithLayer is allowed to correct
// the connection profile and therefore does not use this error.
ErrLayerProfileConflict = errors.New("connection layer profile conflict")
// ErrLayerProfileEpochExhausted is a defensive terminal guard. Reaching it
// would require more than four billion effective layer corrections on one
// physical connection, so wrapping the epoch and making stale pushes current
// again is never safe.
ErrLayerProfileEpochExhausted = errors.New("connection layer profile epoch exhausted")
)
type Conn struct { type Conn struct {
transport transport.Conn transport transport.Conn
// transportLease owns exactly one generation of the physical transport. // transportLease owns exactly one generation of the physical transport.
@ -114,14 +129,22 @@ type Conn struct {
rpcRunning int rpcRunning int
rpcReady bool rpcReady bool
rpcClosed bool rpcClosed bool
// rpcReplayRestores is a per-physical-connection ordering barrier. An exact
// cached/rewrapped init request has already executed its business handler,
// but its wrapper/client/readiness state becomes authoritative only after the
// replacement rpc_result is physically written. Queued naked RPCs remain
// admitted and budgeted, but are not scheduler-runnable until every such
// restore finishes or the connection is fenced.
rpcReplayRestores int
// Rewrap aliasing never delays execution. initialized stops collecting // Rewrap aliasing never delays execution. initialized stops collecting
// candidates after the first valid init wrapper on this physical generation. // candidates after the first valid init wrapper on this physical generation.
rpcRewrapInitialized atomic.Bool rpcRewrapInitialized atomic.Bool
// rpcResultAcked is invoked by the sole outbound actor after it resolves an // rpcResultAcked is invoked by the sole outbound actor after it resolves an
// acknowledged server frame back to the rpc_result request msg_id. // acknowledged server frame back to the rpc_result request msg_id.
rpcResultAcked func(*Conn, int64) rpcResultAcked func(*Conn, int64)
// inflightRPCBytes 跟踪已入队未完成的 inbound RPC body 总字节,配合 maxInflightRPCBytes // inflightRPCBytes 跟踪已预留/入队/执行中 inbound RPC 的 memory chargelegacy
// 给 RPC 队列设字节预算(不止限条数),防对抗客户端发大请求撑内存。 // 等于 copied bodyexact 是 typed materialization 的保守放大值。它配合
// maxInflightRPCBytes 给 RPC 队列设内存预算(不止限条数)。
inflightRPCBytes atomic.Int64 inflightRPCBytes atomic.Int64
// 单连接只保留并发配额;实际 worker 来自 Server 共享池,避免每连接预留 goroutine。 // 单连接只保留并发配额;实际 worker 来自 Server 共享池,避免每连接预留 goroutine。
rpcRootCtx context.Context rpcRootCtx context.Context
@ -152,9 +175,26 @@ type Conn struct {
membershipGen atomic.Int64 membershipGen atomic.Int64
// createdAt 是连接建立时刻,供同 auth_key session 数触顶时驱逐真正最旧的连接。 // createdAt 是连接建立时刻,供同 auth_key session 数触顶时驱逐真正最旧的连接。
createdAt time.Time createdAt time.Time
// clientLayer 是本连接协商的 TL layerinvokeWithLayer/initConnection由 handleRPC // layerProfileState atomically packs profile, provenance and epoch. A profile
// 在每次 Dispatch 后从 RPC 注册表刷新。出站(rpc_result/push)按此把 227 对象降级给老客户端; // inherited from auth-key metadata is only a default; a later well-formed
// 0 表示尚未协商,按 canonical(227) 处理=不降级。 // invokeWithLayer may correct it. The epoch fences proactive updates prepared
// before that correction without invalidating request-bound RPC results.
layerProfileMu sync.RWMutex
// layerProfileEvidenceMsgID is protected by layerProfileMu. Zero means the
// selected profile came from inherited/legacy recovery and therefore has no
// ordered client-message cursor yet. Positive values are the newest accepted
// invokeWithLayer message for this exact MTProto session.
layerProfileEvidenceMsgID int64
// layerProfileEvidenceLayer retains the raw negotiated Layer even when this
// binary has no generated codec for it. In that case the packed profile stays
// Unknown, but msg_id ordering can still admit a newer supported correction
// and reject an older/same-id rollback.
layerProfileEvidenceLayer int
layerProfileState atomic.Uint64
// clientLayer is the package-internal mirror used only by legacy state-machine
// regression tests. Production application RPC/result/update encoding uses
// the structured profile state. Zero is unknown and must fail closed if a legacy
// application value reaches an outbound boundary.
clientLayer atomic.Int32 clientLayer atomic.Int32
} }
@ -236,16 +276,55 @@ func (c *Conn) isPhysicalTransportCurrentOpen() bool {
return c != nil && (c.transportLease == nil || c.transportLease.IsCurrentOpen()) return c != nil && (c.transportLease == nil || c.transportLease.IsCurrentOpen())
} }
// ClientLayer 返回连接协商的 TL layer未协商时返回 canonical layer227不降级 // LayerProfile returns the exact TL profile currently selected for this
func (c *Conn) ClientLayer() int { // connection. ok is false until admission or an inherited auth-key default
if l := c.clientLayer.Load(); l != 0 { // supplies a supported generated profile.
return int(l) func (c *Conn) LayerProfile() (profile tg.LayerProfile, ok bool) {
} state := c.LayerProfileState()
return layerwire.CanonicalLayer return state.Profile, state.Origin != LayerProfileUnknown
} }
// SetClientLayer 记录连接协商的 TL layer。 // FreezeLayerProfile records explicit protocol evidence observed during ordered
func (c *Conn) SetClientLayer(layer int) { c.clientLayer.Store(int32(layer)) } // admission. Repeating the same value is idempotent. A later well-formed
// invokeWithLayer may replace either an inherited default or older explicit
// evidence; already-admitted requests retain their own immutable profile.
func (c *Conn) FreezeLayerProfile(profile tg.LayerProfile) error {
_, err := c.setLayerProfile(profile, LayerProfileExplicit, true)
return err
}
// FreezeLayerProfileAt applies explicit protocol evidence in client msg_id
// order. A duplicate older than the last accepted evidence is inert; the same
// msg_id carrying another Layer is a protocol conflict. Advancing the evidence
// cursor at an unchanged Layer does not rotate the outbound epoch because the
// wire profile itself did not change.
func (c *Conn) FreezeLayerProfileAt(profile tg.LayerProfile, msgID int64) (bool, error) {
return c.freezeLayerProfileAt(profile, msgID)
}
// SeedLayerProfile restores explicit evidence previously proven for this exact
// logical session. It is kept as the compatible same-session restore API;
// auth-key-wide metadata must use SeedInheritedLayerProfile instead.
func (c *Conn) SeedLayerProfile(profile tg.LayerProfile) error {
_, err := c.setLayerProfile(profile, LayerProfileExplicit, true)
return err
}
// SeedInheritedLayerProfile installs an auth-key-wide default only while the
// connection is still unknown. It never overwrites explicit evidence or an
// already selected inherited default; client protocol evidence owns correction.
func (c *Conn) SeedInheritedLayerProfile(profile tg.LayerProfile) error {
_, err := c.setLayerProfile(profile, LayerProfileInherited, false)
return err
}
// legacyClientLayer returns the test-only canonical-transcoder profile. Zero
// deliberately remains unknown; it must never be converted into an implicit
// canonical application profile.
func (c *Conn) legacyClientLayer() int { return int(c.clientLayer.Load()) }
// setLegacyClientLayer records the package-internal legacy mirror.
func (c *Conn) setLegacyClientLayer(layer int) { c.clientLayer.Store(int32(layer)) }
// AuthKeyID 返回连接的 auth_key_id。 // AuthKeyID 返回连接的 auth_key_id。
func (c *Conn) AuthKeyID() [8]byte { return c.authKeyID } func (c *Conn) AuthKeyID() [8]byte { return c.authKeyID }

View file

@ -0,0 +1,506 @@
package mtprotoedge
import (
"context"
"fmt"
"math"
"github.com/iamxvbaba/td/tg"
"go.uber.org/zap"
)
// LayerProfileOrigin records why a Conn currently uses a wire profile. The
// distinction is protocol-significant: inherited auth-key metadata is a useful
// availability default, while explicit invokeWithLayer evidence may correct it.
type LayerProfileOrigin uint8
const (
LayerProfileUnknown LayerProfileOrigin = iota
LayerProfileInherited
LayerProfileExplicit
)
// LayerProfileSnapshot is one atomic observation of a connection's current
// profile. Epoch advances on every effective correction, including promotion
// from inherited to explicit evidence at the same numeric layer.
type LayerProfileSnapshot struct {
Profile tg.LayerProfile
Origin LayerProfileOrigin
Epoch uint32
}
const (
layerProfileValueBits = 16
layerProfileOriginBits = 8
layerProfileOriginShift = layerProfileValueBits
layerProfileEpochShift = 32
layerProfileValueMask = uint64(1<<layerProfileValueBits - 1)
layerProfileOriginMask = uint64(1<<layerProfileOriginBits - 1)
)
func packLayerProfileState(state LayerProfileSnapshot) uint64 {
return uint64(state.Epoch)<<layerProfileEpochShift |
(uint64(state.Origin)&layerProfileOriginMask)<<layerProfileOriginShift |
(uint64(state.Profile) & layerProfileValueMask)
}
func unpackLayerProfileState(raw uint64) LayerProfileSnapshot {
if raw == 0 {
return LayerProfileSnapshot{}
}
return LayerProfileSnapshot{
Profile: tg.LayerProfile(raw & layerProfileValueMask),
Origin: LayerProfileOrigin((raw >> layerProfileOriginShift) & layerProfileOriginMask),
Epoch: uint32(raw >> layerProfileEpochShift),
}
}
// LayerProfileState returns profile, provenance and epoch from one atomic load.
func (c *Conn) LayerProfileState() LayerProfileSnapshot {
if c == nil {
return LayerProfileSnapshot{}
}
return unpackLayerProfileState(c.layerProfileState.Load())
}
func (c *Conn) setLayerProfile(profile tg.LayerProfile, origin LayerProfileOrigin, replace bool) (bool, error) {
if err := validateLayerProfile(profile); err != nil {
return false, err
}
if origin != LayerProfileInherited && origin != LayerProfileExplicit {
return false, fmt.Errorf("invalid layer profile origin %d", origin)
}
c.layerProfileMu.Lock()
defer c.layerProfileMu.Unlock()
for {
raw := c.layerProfileState.Load()
current := unpackLayerProfileState(raw)
if origin == LayerProfileInherited && c.layerProfileEvidenceMsgID > 0 {
return false, nil
}
if current.Origin != LayerProfileUnknown {
if !replace {
return false, nil
}
if current.Profile == profile && current.Origin == origin {
// The force-style compatibility APIs carry no ordered msg_id.
// Clearing the cursor keeps a subsequent production observation
// eligible instead of comparing it with unrelated test/recovery state.
c.layerProfileEvidenceMsgID = 0
c.layerProfileEvidenceLayer = 0
return false, nil
}
}
if current.Epoch == math.MaxUint32 {
return false, ErrLayerProfileEpochExhausted
}
next := LayerProfileSnapshot{Profile: profile, Origin: origin, Epoch: current.Epoch + 1}
if c.layerProfileState.CompareAndSwap(raw, packLayerProfileState(next)) {
c.layerProfileEvidenceMsgID = 0
c.layerProfileEvidenceLayer = 0
c.setLegacyClientLayer(int(profile))
return true, nil
}
}
}
func validateLayerProfile(profile tg.LayerProfile) error {
resolved, ok := tg.ResolveLayerProfile(int(profile))
if !ok || resolved != profile || uint64(profile) > layerProfileValueMask {
return fmt.Errorf("%w: %d", ErrLayerProfileUnsupported, profile)
}
return nil
}
// layerProfileEvidenceState observes the packed wire state and its ordering
// cursor under one read lock. LayerProfileState remains the allocation-free
// atomic hot-path accessor used by outbound encoding.
func (c *Conn) layerProfileEvidenceState() (LayerProfileSnapshot, int64) {
state, _, msgID := c.layerProfileRawEvidenceState()
return state, msgID
}
func (c *Conn) layerProfileRawEvidenceState() (LayerProfileSnapshot, int, int64) {
if c == nil {
return LayerProfileSnapshot{}, 0, 0
}
c.layerProfileMu.RLock()
state := unpackLayerProfileState(c.layerProfileState.Load())
layer := c.layerProfileEvidenceLayer
msgID := c.layerProfileEvidenceMsgID
c.layerProfileMu.RUnlock()
if layer == 0 && state.Origin == LayerProfileExplicit {
layer = int(state.Profile)
}
return state, layer, msgID
}
// freezeLayerProfileAt is the production explicit-evidence transition. The
// positive client msg_id is the protocol ordering authority across TCP
// reconnects and cached request replays.
func (c *Conn) freezeLayerProfileAt(profile tg.LayerProfile, msgID int64) (bool, error) {
if c == nil {
return false, fmt.Errorf("nil connection layer profile")
}
if msgID <= 0 {
return false, fmt.Errorf("invalid layer evidence msg_id %d", msgID)
}
if err := validateLayerProfile(profile); err != nil {
return false, err
}
return c.freezeRawLayerProfileAt(int(profile), msgID)
}
func (c *Conn) freezeRawLayerProfileAt(layer int, msgID int64) (bool, error) {
if c == nil {
return false, fmt.Errorf("nil connection layer profile")
}
if layer <= 0 || msgID <= 0 {
return false, fmt.Errorf("invalid raw layer evidence layer=%d msg_id=%d", layer, msgID)
}
profile, supported := tg.ResolveLayerProfile(layer)
c.layerProfileMu.Lock()
defer c.layerProfileMu.Unlock()
current := unpackLayerProfileState(c.layerProfileState.Load())
if c.layerProfileEvidenceMsgID > 0 {
switch {
case msgID < c.layerProfileEvidenceMsgID:
return false, nil
case msgID == c.layerProfileEvidenceMsgID:
if c.layerProfileEvidenceLayer != layer {
return false, fmt.Errorf("%w: msg_id %d selected both layer %d and %d", ErrLayerProfileConflict, msgID, c.layerProfileEvidenceLayer, layer)
}
return false, nil
}
}
desired := LayerProfileSnapshot{Epoch: current.Epoch}
if supported {
desired.Profile = profile
desired.Origin = LayerProfileExplicit
}
// A newer proof at the same Layer advances only the ordering cursor. No wire
// bytes prepared under this profile become stale, so rotating epoch would be
// unnecessary push churn.
if current.Profile == desired.Profile && current.Origin == desired.Origin {
c.layerProfileEvidenceLayer = layer
c.layerProfileEvidenceMsgID = msgID
return true, nil
}
if current.Epoch == math.MaxUint32 {
return false, ErrLayerProfileEpochExhausted
}
next := desired
next.Epoch = current.Epoch + 1
c.layerProfileState.Store(packLayerProfileState(next))
c.layerProfileEvidenceLayer = layer
c.layerProfileEvidenceMsgID = msgID
if supported {
c.setLegacyClientLayer(layer)
} else {
c.setLegacyClientLayer(0)
}
return true, nil
}
// seedOrderedLayerProfile restores exact-session evidence atomically before
// any request on a replacement physical connection is admitted.
func (c *Conn) seedOrderedLayerProfile(profile tg.LayerProfile, msgID int64) error {
if c == nil {
return nil
}
if msgID < 0 {
return fmt.Errorf("invalid restored layer evidence msg_id %d", msgID)
}
if err := validateLayerProfile(profile); err != nil {
return err
}
if msgID > 0 {
_, err := c.freezeRawLayerProfileAt(int(profile), msgID)
return err
}
c.layerProfileMu.Lock()
defer c.layerProfileMu.Unlock()
current := unpackLayerProfileState(c.layerProfileState.Load())
if current.Profile != profile || current.Origin != LayerProfileExplicit {
if current.Epoch == math.MaxUint32 {
return ErrLayerProfileEpochExhausted
}
current = LayerProfileSnapshot{Profile: profile, Origin: LayerProfileExplicit, Epoch: current.Epoch + 1}
c.layerProfileState.Store(packLayerProfileState(current))
c.setLegacyClientLayer(int(profile))
}
c.layerProfileEvidenceMsgID = msgID
c.layerProfileEvidenceLayer = 0
return nil
}
func (c *Conn) seedRawLayerEvidence(layer int, msgID int64) error {
if layer <= 0 || msgID <= 0 {
return fmt.Errorf("invalid restored raw layer evidence layer=%d msg_id=%d", layer, msgID)
}
_, err := c.freezeRawLayerProfileAt(layer, msgID)
return err
}
// refreshInheritedLayerProfile is reserved for identity normalization at
// auth.bindTempAuthKey: once a raw temporary key is resolved to its permanent
// key, the permanent key's default supersedes an older raw-key shadow. Explicit
// evidence on the concrete session is never overwritten.
func (c *Conn) refreshInheritedLayerProfile(profile tg.LayerProfile) (bool, error) {
if c == nil {
return false, nil
}
if err := validateLayerProfile(profile); err != nil {
return false, err
}
c.layerProfileMu.Lock()
defer c.layerProfileMu.Unlock()
current := unpackLayerProfileState(c.layerProfileState.Load())
if current.Origin == LayerProfileExplicit || c.layerProfileEvidenceMsgID > 0 {
return false, nil
}
if current.Origin == LayerProfileInherited && current.Profile == profile {
return false, nil
}
if current.Epoch == math.MaxUint32 {
return false, ErrLayerProfileEpochExhausted
}
next := LayerProfileSnapshot{Profile: profile, Origin: LayerProfileInherited, Epoch: current.Epoch + 1}
c.layerProfileState.Store(packLayerProfileState(next))
c.layerProfileEvidenceMsgID = 0
c.layerProfileEvidenceLayer = 0
c.setLegacyClientLayer(int(profile))
return true, nil
}
func (c *Conn) clearInheritedLayerProfileState() (bool, error) {
if c == nil {
return false, nil
}
c.layerProfileMu.Lock()
defer c.layerProfileMu.Unlock()
current := unpackLayerProfileState(c.layerProfileState.Load())
if current.Origin != LayerProfileInherited {
return false, nil
}
if current.Epoch == math.MaxUint32 {
return false, ErrLayerProfileEpochExhausted
}
c.layerProfileState.Store(packLayerProfileState(LayerProfileSnapshot{Epoch: current.Epoch + 1}))
c.layerProfileEvidenceMsgID = 0
c.layerProfileEvidenceLayer = 0
c.setLegacyClientLayer(0)
return true, nil
}
func (c *Conn) clearInheritedLayerProfile() error {
_, err := c.clearInheritedLayerProfileState()
return err
}
// seedInitialLayerProfile applies recovery sources in descending authority.
// Unsupported metadata never clamps to a nearby generated layer: the Conn stays
// unknown so the client can explicitly renegotiate.
func (s *Server) seedInitialLayerProfile(
ctx context.Context,
c *Conn,
fetchedLayer int,
previous LayerProfileSnapshot,
) error {
if s == nil || c == nil {
return nil
}
durableResolver, hasDurableResolver := s.layerRPC.(LayerRPCDurableSessionProfileResolver)
if hasDurableResolver {
layer, msgID, found, err := durableResolver.ResolveNegotiatedSessionLayerEvidence(ctx, c.authKeyID, c.sessionID)
if err != nil {
if isLayerEvidenceDurabilityUnavailable(err) {
s.log.Warn("Resolve durable exact session Layer during connection seed unavailable; continuing with auth-key default",
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID), zap.Error(err))
// Exact-session proof is the strongest recovery source, but its
// availability failure must not discard a permanent auth_keys.layer
// already loaded with the key on this same first frame. Continue to the
// auth-key default below; never reuse previous connection-local evidence
// in durable mode.
found = false
} else {
return fmt.Errorf("resolve durable exact session Layer during connection seed: %w", err)
}
}
if found {
if layer <= 0 || msgID < 0 {
return fmt.Errorf("invalid durable exact session Layer seed layer=%d msg_id=%d", layer, msgID)
}
if msgID > 0 {
return c.seedRawLayerEvidence(layer, msgID)
}
// Older in-process exact-session registries did not retain a message
// watermark. Keep that compatibility-only seed usable without treating
// it as durable ordered evidence; real durable stores never persist zero.
profile, supported := tg.ResolveLayerProfile(layer)
if !supported {
return nil
}
return c.seedOrderedLayerProfile(profile, 0)
}
} else if resolver, ok := s.layerRPC.(LayerRPCOrderedSessionProfileResolver); ok {
if layer, msgID, found := resolver.NegotiatedSessionLayerEvidence(c.authKeyID, c.sessionID); found {
if layer <= 0 || msgID < 0 {
return nil
}
if msgID > 0 {
return c.seedRawLayerEvidence(layer, msgID)
}
profile, supported := tg.ResolveLayerProfile(layer)
if !supported {
return nil
}
return c.seedOrderedLayerProfile(profile, 0)
}
} else if resolver, ok := s.layerRPC.(LayerRPCSessionProfileResolver); ok {
if layer, found := resolver.NegotiatedSessionLayer(c.authKeyID, c.sessionID); found {
profile, supported := tg.ResolveLayerProfile(layer)
if !supported {
return nil
}
return c.SeedLayerProfile(profile)
}
}
// A freshly fetched permanent-key row is already the canonical auth-key
// record. Prefer its non-zero Layer without making Router query the same PG
// row again. Unsupported metadata remains unknown and must not fall through
// to a weaker mirror.
if c.authKeyExpiresAt == 0 && fetchedLayer != 0 {
profile, supported := tg.ResolveLayerProfile(fetchedLayer)
if !supported {
return nil
}
return c.SeedInheritedLayerProfile(profile)
}
// Temporary keys resolve through their bound permanent key before consulting
// the raw temp-key shadow, which may predate a client upgrade.
if resolver, ok := s.layerRPC.(LayerRPCInheritedAuthKeyProfileResolver); ok {
layer, found, err := resolver.ResolveInheritedAuthKeyLayer(ctx, c.authKeyID)
if err != nil {
if !isLayerEvidenceDurabilityUnavailable(err) {
// A binding conflict, missing/destroyed key, or malformed durable
// value is not an availability hint. Stay unknown and never let a
// stale raw temp-key shadow outrank that structural failure.
if s.log != nil {
s.log.Warn("Resolve inherited auth-key Layer failed; awaiting explicit evidence",
zap.String("auth_key_id", c.authKeyHex), zap.Error(err))
}
return nil
}
// The same first frame already authenticated and loaded fetchedLayer
// from this raw temp key. During a transient permanent-identity lookup
// outage it is safe only as this physical Conn's inherited shadow; it
// is never published back to the shared permanent default.
if s.log != nil {
s.log.Warn("Resolve inherited auth-key Layer unavailable; continuing with raw auth-key shadow",
zap.String("auth_key_id", c.authKeyHex), zap.Error(err))
}
} else if !found {
// Fall through to a raw auth-key shadow when the resolver has no
// canonical permanent-key default (for example an unbound temp key).
} else {
profile, supported := tg.ResolveLayerProfile(layer)
if !supported {
return nil
}
return c.SeedInheritedLayerProfile(profile)
}
}
if fetchedLayer != 0 {
profile, supported := tg.ResolveLayerProfile(fetchedLayer)
if !supported {
return nil
}
return c.SeedInheritedLayerProfile(profile)
}
if hasDurableResolver {
// previous belongs to the old logical Conn which occupied this physical
// transport. In durable mode only an exact session row or auth-key default
// may cross that boundary. In particular, a connection-local selector used
// during a store outage must not leak into a newly selected session.
return nil
}
if previous.Origin != LayerProfileUnknown {
return c.SeedInheritedLayerProfile(previous.Profile)
}
return nil
}
// refreshActivatedInheritedLayerProfile closes the auth.bindTempAuthKey race
// after BeginActivation has made this Conn visible in claimsByAuth. If bind won
// first, the second resolver read sees the permanent-key default; if the claim
// won first, bind's SessionManager refresh sees and updates this Conn. Explicit
// session evidence always wins either ordering.
func (s *Server) refreshActivatedInheritedLayerProfile(ctx context.Context, c *Conn, fetchedLayer int) error {
if s == nil || c == nil || c.LayerProfileState().Origin == LayerProfileExplicit {
return nil
}
if c.authKeyExpiresAt == 0 {
if fetchedLayer == 0 {
return nil
}
profile, ok := tg.ResolveLayerProfile(fetchedLayer)
if !ok {
return c.clearInheritedLayerProfile()
}
_, err := c.refreshInheritedLayerProfile(profile)
return err
}
if resolver, ok := s.layerRPC.(LayerRPCInheritedAuthKeyProfileResolver); ok {
layer, found, err := resolver.ResolveInheritedAuthKeyLayer(ctx, c.authKeyID)
if err != nil {
if !isLayerEvidenceDurabilityUnavailable(err) {
if s.log != nil {
s.log.Warn("Re-resolve inherited auth-key Layer after activation claim failed",
zap.String("auth_key_id", c.authKeyHex), zap.Error(err))
}
// A pre-claim raw temp shadow may have been installed before a
// concurrent bind became visible. Structural identity/key failures
// must revoke that weaker inherited evidence; only an explicit
// selector (guarded above) is allowed to survive this branch.
return c.clearInheritedLayerProfile()
}
if s.log != nil {
s.log.Warn("Re-resolve inherited auth-key Layer unavailable; keeping raw auth-key shadow",
zap.String("auth_key_id", c.authKeyHex), zap.Error(err))
}
} else if found {
profile, supported := tg.ResolveLayerProfile(layer)
if !supported {
return c.clearInheritedLayerProfile()
}
_, err = c.refreshInheritedLayerProfile(profile)
return err
}
}
if fetchedLayer == 0 {
return nil
}
profile, ok := tg.ResolveLayerProfile(fetchedLayer)
if !ok {
return c.clearInheritedLayerProfile()
}
_, err := c.refreshInheritedLayerProfile(profile)
return err
}
// lockSessionLayerBinding linearizes a proactive update's final validation and
// physical write with profile correction. If correction wins first, validation
// observes the new epoch and drops the update. If the writer wins first, the
// correction becomes visible only after those bytes have landed. Returning a
// bool avoids allocating a release closure on the outbound hot path.
func (c *Conn) lockSessionLayerBinding(binding *outboundLayerBinding) bool {
if c == nil || binding == nil || binding.wireInvariant ||
binding.kind == outboundLayerBindingRequest || binding.epoch == 0 {
return false
}
c.layerProfileMu.RLock()
return true
}

View file

@ -7,8 +7,8 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
// TestPushSkipsConnReboundToOtherUser 锁定跨账号投递窗口的修复pushToUserWithSender 在锁外 // TestPushSkipsConnReboundToOtherUser 锁定跨账号投递窗口的修复pushToUserWithSender 在锁外
@ -28,6 +28,9 @@ func TestPushSkipsConnReboundToOtherUser(t *testing.T) {
c.userID.Store(userA) c.userID.Store(userA)
c.userIDResolved.Store(true) c.userIDResolved.Store(true)
c.receivesUpdates.Store(true) c.receivesUpdates.Store(true)
if err := c.FreezeLayerProfile(tg.LayerProfileCanonical); err != nil {
t.Fatal(err)
}
sm.Register(c) sm.Register(c)
return c return c
} }

View file

@ -8,8 +8,8 @@ import (
"github.com/gotd/ige" "github.com/gotd/ige"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
) )
// clientFrame 是解密后的单帧客户端消息视图。data/plaintext 引用调用方持有的复用明文 // clientFrame 是解密后的单帧客户端消息视图。data/plaintext 引用调用方持有的复用明文

View file

@ -9,8 +9,8 @@ import (
"github.com/gotd/ige" "github.com/gotd/ige"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
) )
func newTestAuthKey(t *testing.T) crypto.AuthKey { func newTestAuthKey(t *testing.T) crypto.AuthKey {

View file

@ -1,9 +1,12 @@
package mtprotoedge package mtprotoedge
import ( import (
"errors"
"fmt" "fmt"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/iamxvbaba/td/proto"
"github.com/iamxvbaba/td/tg"
) )
const ( const (
@ -12,6 +15,39 @@ const (
destroyAuthKeyFailTypeID = 0xea109b13 destroyAuthKeyFailTypeID = 0xea109b13
) )
var errDestroyAuthKeyMustBeExclusive = errors.New("wrapped destroy_auth_key must be the only logical message")
// wrappedDestroyAuthKeyTerminal accepts only evidence emitted by the generated
// exact wrapper parser after it has legally reached the innermost non-API
// terminal. It never re-parses wrapper bytes at runtime.
func wrappedDestroyAuthKeyTerminal(err error) (*tg.LayerRPCUnknownTerminalError, bool) {
var terminal *tg.LayerRPCUnknownTerminalError
if !errors.As(err, &terminal) || terminal == nil || terminal.WireID != destroyAuthKeyRequestTypeID {
return nil, false
}
return terminal, true
}
// validWrappedDestroyAuthKeyChain is deliberately narrower than "some generated
// wrapper decoded". invokeAfter*, takeout and update-suppression wrappers carry
// execution semantics which the service-message fast path must not silently
// discard. The official first-connection path is exactly
// invokeWithLayer(initConnection(destroy_auth_key)); an already initialized
// connection sends the bare service message and is classified before Layer RPC
// admission.
func validWrappedDestroyAuthKeyChain(terminal *tg.LayerRPCUnknownTerminalError) bool {
if terminal == nil || terminal.WrapperCount() != 2 {
return false
}
outer, outerOK := terminal.Wrapper(0)
inner, innerOK := terminal.Wrapper(1)
return outerOK && innerOK &&
outer.Profile() == terminal.Profile &&
inner.Profile() == terminal.Profile &&
outer.Semantic() == tg.LayerSemanticMethodInvokeWithLayer &&
inner.Semantic() == tg.LayerSemanticMethodInitConnection
}
type destroyAuthKeyRequest struct{} type destroyAuthKeyRequest struct{}
func (*destroyAuthKeyRequest) Encode(b *bin.Buffer) error { func (*destroyAuthKeyRequest) Encode(b *bin.Buffer) error {
@ -26,16 +62,26 @@ func (*destroyAuthKeyRequest) Decode(b *bin.Buffer) error {
return nil return nil
} }
type destroyAuthKeyOk struct{} // destroyAuthKeyRPCResult is the only rpc_result envelope admitted as a
// layer-invariant control value. Its closed result set is part of the type, so
func (*destroyAuthKeyOk) Encode(b *bin.Buffer) error { // it can never smuggle a profile-dependent API payload past the exact Layer
b.PutID(destroyAuthKeyOkTypeID) // binding boundary.
return nil type destroyAuthKeyRPCResult struct {
RequestMessageID int64
ResultTypeID uint32
} }
type destroyAuthKeyFail struct{} func (r *destroyAuthKeyRPCResult) Encode(b *bin.Buffer) error {
if r == nil {
func (*destroyAuthKeyFail) Encode(b *bin.Buffer) error { return fmt.Errorf("encode destroy_auth_key rpc_result: nil result")
b.PutID(destroyAuthKeyFailTypeID) }
switch r.ResultTypeID {
case destroyAuthKeyOkTypeID, destroyAuthKeyFailTypeID:
default:
return fmt.Errorf("encode destroy_auth_key rpc_result: invalid inner constructor %#x", r.ResultTypeID)
}
b.PutID(proto.ResultTypeID)
b.PutLong(r.RequestMessageID)
b.PutID(r.ResultTypeID)
return nil return nil
} }

View file

@ -7,9 +7,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
type blockingDuplicateRPC struct { type blockingDuplicateRPC struct {
@ -53,7 +53,7 @@ func TestPendingSameConnectionDuplicateDoesNotBlockFreshRequest(t *testing.T) {
defer handler.unblock() defer handler.unblock()
addr, pub, server := startTestServer(t, Options{ addr, pub, server := startTestServer(t, Options{
DC: dc, DC: dc,
RPC: handler, legacyRPC: handler,
RPCMaxInflight: 2, RPCMaxInflight: 2,
RPCGlobalWorkers: 2, RPCGlobalWorkers: 2,
RPCQueueSize: 8, RPCQueueSize: 8,

View file

@ -11,13 +11,13 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/session" "github.com/iamxvbaba/td/session"
"github.com/gotd/td/telegram" "github.com/iamxvbaba/td/telegram"
"github.com/gotd/td/telegram/dcs" "github.com/iamxvbaba/td/telegram/dcs"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/app/auth" "telesrv/internal/app/auth"
"telesrv/internal/app/updates" "telesrv/internal/app/updates"
@ -26,7 +26,7 @@ import (
"telesrv/internal/store/memory" "telesrv/internal/store/memory"
) )
// TestTelegramClientEndToEnd 是连接层的最强端到端验证:用 gotd/td 的完整 // TestTelegramClientEndToEnd 是连接层的最强端到端验证:用 iamxvbaba/td 的完整
// telegram.Client而非底层 cipher连本地 mtprotoedgeclient 自动经 // telegram.Client而非底层 cipher连本地 mtprotoedgeclient 自动经
// invokeWithLayer(initConnection(help.getConfig)) 完成初始化,并取得含本地 DC 的 Config。 // invokeWithLayer(initConnection(help.getConfig)) 完成初始化,并取得含本地 DC 的 Config。
func TestTelegramClientEndToEnd(t *testing.T) { func TestTelegramClientEndToEnd(t *testing.T) {
@ -51,7 +51,7 @@ func TestTelegramClientEndToEnd(t *testing.T) {
Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()), Updates: updates.NewService(memory.NewUpdateStateStore(), memory.NewUpdateEventStore()),
} }
router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System) router := rpc.New(rpc.Config{DC: dc, IP: tcpAddr.IP.String(), Port: tcpAddr.Port}, deps, zaptest.NewLogger(t), clock.System)
srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, RPC: router}) srv := New(Options{Logger: zaptest.NewLogger(t), DC: dc, RSAKey: rsaKey, AuthKeys: authKeyStore, LayerRPC: router})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()

View file

@ -10,19 +10,20 @@ import (
"fmt" "fmt"
"io" "io"
"math" "math"
"runtime/debug"
"time" "time"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/mt" "github.com/iamxvbaba/td/mt"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/tgerr" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/tgerr"
"github.com/iamxvbaba/td/transport"
"telesrv/internal/compat/layerwire"
"telesrv/internal/observability/dbtrace" "telesrv/internal/observability/dbtrace"
"telesrv/internal/postresponse" "telesrv/internal/postresponse"
"telesrv/internal/store" "telesrv/internal/store"
@ -140,7 +141,11 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
// 临时创建 Conn 会在同一 socket 上启动多个 outbound actorAndroid 的启动重试 // 临时创建 Conn 会在同一 socket 上启动多个 outbound actorAndroid 的启动重试
// 风暴随即变成并发写和重复结果放大。 // 风暴随即变成并发写和重复结果放大。
if current == nil || current.sessionID != frame.sessionID || current.authKeyID != key.ID { if current == nil || current.sessionID != frame.sessionID || current.authKeyID != key.ID {
var previousLayer LayerProfileSnapshot
if current != nil { if current != nil {
if current.authKeyID == key.ID {
previousLayer = current.LayerProfileState()
}
cs.reset() cs.reset()
current.beginTerminalShutdown() current.beginTerminalShutdown()
s.conns.Unregister(current) s.conns.Unregister(current)
@ -156,15 +161,20 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
current = s.newConn(tc, key, frame.sessionID, serverSalt) current = s.newConn(tc, key, frame.sessionID, serverSalt)
} }
current.authKeyExpiresAt = authKeyExpiresAt current.authKeyExpiresAt = authKeyExpiresAt
// 注册即播种协商 layer新 Conn 的 clientLayer 为 0=canonical 227若等到 // Same-session evidence is restored as explicit; auth-key metadata is only
// 首条 RPC 的 Dispatch 返回后才刷新,重连老客户端在首条 RPC handler 执行期间 // an inherited default and can be corrected by the next invokeWithLayer.
// 收到的 pending flush / 并发 push 会漏降级。进程内重连时 rpc 层留有
// (auth_key, session) / auth_key 两级协商记录,这里一次查询即可闭合该空窗。
if s.rpc != nil { if s.rpc != nil {
if layer, ok := s.rpc.NegotiatedLayer(current.authKeyID, current.sessionID); ok { if layer, ok := s.rpc.NegotiatedLayer(current.authKeyID, current.sessionID); ok {
current.SetClientLayer(layer) current.setLegacyClientLayer(layer)
} }
} }
fetchedLayer := 0
if fetchedKey != nil {
fetchedLayer = fetchedKey.Layer
}
if err := s.seedInitialLayerProfile(ctx, current, fetchedLayer, previousLayer); err != nil {
return current, fmt.Errorf("seed connection layer profile: %w", err)
}
} }
if frame.salt != serverSalt { if frame.salt != serverSalt {
@ -194,6 +204,13 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
} }
defer plan.close() defer plan.close()
if err := s.prepareInboundRPCBatch(ctx, current, plan); err != nil { if err := s.prepareInboundRPCBatch(ctx, current, plan); err != nil {
if errors.Is(err, errDestroyAuthKeyMustBeExclusive) {
s.log.Debug("Rejecting mixed destroy_auth_key container",
zap.Int64("msg_id", frame.messageID),
zap.Int32("seq_no", frame.seqNo),
)
return current, s.sendBadMsg(ctx, current, frame.messageID, frame.seqNo, badMsgContainer)
}
return current, err return current, err
} }
if err := sendQuickAckIfRequested(ctx, current.transport, key, frame.plaintext, s.writeTimeout); err != nil { if err := sendQuickAckIfRequested(ctx, current.transport, key, frame.plaintext, s.writeTimeout); err != nil {
@ -233,6 +250,12 @@ func (s *Server) handleEncrypted(ctx context.Context, tc transport.Conn, cs *con
} }
return current, errActivationAuthKeyRejected return current, errActivationAuthKeyRejected
} }
// Re-resolve inherited Layer only after the activation claim is visible.
// This closes the bind-vs-connect window for temporary keys without ever
// replacing explicit invokeWithLayer evidence admitted above.
if err := s.refreshActivatedInheritedLayerProfile(ctx, current, fresh.Layer); err != nil {
return current, fmt.Errorf("refresh claimed connection layer profile: %w", err)
}
if current.isRetired() || !current.isPhysicalTransportCurrentOpen() { if current.isRetired() || !current.isPhysicalTransportCurrentOpen() {
return current, ErrConnClosed return current, ErrConnClosed
} }
@ -674,29 +697,44 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method str
ctx = postresponse.WithCallbacks(ctx) ctx = postresponse.WithCallbacks(ctx)
ctx, dbStats := dbtrace.WithStats(ctx) ctx, dbStats := dbtrace.WithStats(ctx)
// legacyRPC is an unexported package-test hook, but its result still has to
// obey the production exact-codec invariant. Admit a defensive copy using
// the generated current profile before the legacy router consumes b.
admissionBody := &bin.Buffer{Buf: append([]byte(nil), b.Buf...)}
admitted, err := tg.NewServerDispatcher(nil).AdmitDefaultLayerWithLimits(
tg.LayerProfileCanonical,
admissionBody,
inboundLayerDecodeLimits,
)
if err != nil {
return fmt.Errorf("admit legacy test RPC through generated codec: %w", err)
}
start := s.clock.Now() start := s.clock.Now()
effectiveMethod := method effectiveMethod := method
var ( var (
result bin.Encoder result bin.Encoder
err error dispatchErr error
) )
if detailed, ok := s.rpc.(RPCHandlerWithMethod); ok { if detailed, ok := s.rpc.(legacyRPCHandlerWithMethod); ok {
var innerMethod string var innerMethod string
result, innerMethod, err = detailed.DispatchWithMethod(ctx, c.authKeyID, c.sessionID, b) result, innerMethod, dispatchErr = detailed.DispatchWithMethod(ctx, c.authKeyID, c.sessionID, b)
if innerMethod != "" { if innerMethod != "" {
effectiveMethod = innerMethod effectiveMethod = innerMethod
} }
} else { } else {
result, err = s.rpc.Dispatch(ctx, c.authKeyID, c.sessionID, b) result, dispatchErr = s.rpc.Dispatch(ctx, c.authKeyID, c.sessionID, b)
}
if dispatchErr == nil && result != nil && !isLayerInvariantRPCResultEncoder(result) {
if _, exact := result.(exactLayerRPCResultEncoder); !exact {
result = &legacyTestRPCResultEncoder{call: admitted.Call(), result: result}
}
} }
dur := s.clock.Now().Sub(start) dur := s.clock.Now().Sub(start)
s.metrics.RPCHandled(effectiveMethod, dur, err) s.metrics.RPCHandled(effectiveMethod, dur, dispatchErr)
// 刷新本连接协商 layerinvokeWithLayer/initConnection 已被 Dispatch 处理并登记), // 刷新本连接由 invokeWithLayer 证明并冻结的 exact-session layer。ok=false
// 供 rpc_result 与后续 push 出站降级使用。仅在确实观测到 layer 时更新——缓存被驱逐 // 表示仍无协议证据;设备/授权元数据和其它 session 都不具备回填资格。
// 时 NegotiatedLayer 返回 ok=false此时必须保留连接已记住的 layer绝不覆盖成默认值
// 否则长连接老客户端的条目被驱逐后会被误降回 227。
if layer, ok := s.rpc.NegotiatedLayer(c.authKeyID, c.sessionID); ok { if layer, ok := s.rpc.NegotiatedLayer(c.authKeyID, c.sessionID); ok {
c.SetClientLayer(layer) c.setLegacyClientLayer(layer)
} }
fields := make([]zap.Field, 0, 12) fields := make([]zap.Field, 0, 12)
@ -725,7 +763,7 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method str
// Plain connection cancellation remains retryable on the replacement. // Plain connection cancellation remains retryable on the replacement.
var terminal bin.Encoder var terminal bin.Encoder
runPostResponse := false runPostResponse := false
if err == nil && result != nil { if dispatchErr == nil && result != nil {
terminal = result terminal = result
runPostResponse = true runPostResponse = true
} else if errors.Is(ctxErr, context.DeadlineExceeded) { } else if errors.Is(ctxErr, context.DeadlineExceeded) {
@ -741,23 +779,23 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method str
} }
} }
cancelFields := append(fields, zap.NamedError("context_error", ctxErr)) cancelFields := append(fields, zap.NamedError("context_error", ctxErr))
if err != nil { if dispatchErr != nil {
cancelFields = append(cancelFields, zap.NamedError("dispatch_error", err)) cancelFields = append(cancelFields, zap.NamedError("dispatch_error", dispatchErr))
} }
s.log.Info("RPC canceled", cancelFields...) s.log.Info("RPC canceled", cancelFields...)
return ctxErr return ctxErr
} }
if err != nil { if dispatchErr != nil {
var rpcErr *tgerr.Error var rpcErr *tgerr.Error
if errors.As(err, &rpcErr) { if errors.As(dispatchErr, &rpcErr) {
s.log.Info("RPC error", append(fields, zap.Int("code", rpcErr.Code), zap.String("error", rpcErr.Message))...) s.log.Info("RPC error", append(fields, zap.Int("code", rpcErr.Code), zap.String("error", rpcErr.Message))...)
return s.publishRPCResult(c, msgID, effectiveMethod, owner, &mt.RPCError{ return s.publishRPCResult(c, msgID, effectiveMethod, owner, &mt.RPCError{
ErrorCode: rpcErr.Code, ErrorCode: rpcErr.Code,
ErrorMessage: rpcErr.Message, ErrorMessage: rpcErr.Message,
}, nil) }, nil)
} }
s.log.Info("RPC internal error", append(fields, zap.Error(err))...) s.log.Info("RPC internal error", append(fields, zap.Error(dispatchErr))...)
return s.publishRPCResult(c, msgID, effectiveMethod, owner, &mt.RPCError{ return s.publishRPCResult(c, msgID, effectiveMethod, owner, &mt.RPCError{
ErrorCode: 500, ErrorCode: 500,
ErrorMessage: "INTERNAL", ErrorMessage: "INTERNAL",
@ -768,9 +806,14 @@ func (s *Server) handleRPC(ctx context.Context, c *Conn, msgID int64, method str
return s.publishRPCResult(c, msgID, effectiveMethod, owner, result, postresponse.Take(ctx)) return s.publishRPCResult(c, msgID, effectiveMethod, owner, result, postresponse.Take(ctx))
} }
var errRPCResultRetentionHandoff = errors.New("mtproto rpc result retention handoff failed")
type rpcResultRetentionHandoff func(*encodedOutboundMessage, error) error
// publishRPCResult ends the inbound worker's ownership at bounded egress // publishRPCResult ends the inbound worker's ownership at bounded egress
// admission. Physical delivery, fencing, completed-cache publication and the // admission. Physical delivery is thereafter owned either by the single
// post-response hook are thereafter owned by the single outbound actor. // outbound actor or, under retained-byte saturation, by a fenced completed-cache
// entry that the replacement connection can replay without rerunning business.
func (s *Server) publishRPCResult( func (s *Server) publishRPCResult(
c *Conn, c *Conn,
reqMsgID int64, reqMsgID int64,
@ -788,19 +831,7 @@ func (s *Server) publishRPCResult(
} }
prepareCtx, cancel := context.WithTimeout(context.Background(), prepareTimeout) prepareCtx, cancel := context.WithTimeout(context.Background(), prepareTimeout)
defer cancel() defer cancel()
encoded, err := s.encodeRPCResultContext(prepareCtx, c, reqMsgID, result) prepareEncoded := func(encoded *encodedOutboundMessage) (outboundPriority, bool) {
if err != nil {
s.log.Warn("Encode RPC result failed; publishing INTERNAL",
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.Error(err))
afterDelivered = nil
encoded, err = s.encodeRPCResultContext(prepareCtx, c, reqMsgID, &mt.RPCError{
ErrorCode: 500, ErrorMessage: "INTERNAL",
})
if err != nil {
c.fenceUndeliveredRPCResult()
return err
}
}
if owner != nil && owner.Delivery() != nil { if owner != nil && owner.Delivery() != nil {
// The owner-level delivery coordinator exists before the handler starts, so // The owner-level delivery coordinator exists before the handler starts, so
// an initConnection rewrap can retarget even while result encoding is still // an initConnection rewrap can retarget even while result encoding is still
@ -809,24 +840,108 @@ func (s *Server) publishRPCResult(
encoded.delivery = owner.Delivery() encoded.delivery = owner.Delivery()
} }
if afterDelivered != nil { if afterDelivered != nil {
encoded.delivery.fn = afterDelivered encoded.setDeliveryHook(afterDelivered)
} }
priority := rpcResultPriority(method, encoded)
encoded.priority = priority
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
metrics.RPCResultPrepared(method, priority.String(), encoded.uncompressedBytes, len(encoded.body), encoded.compressed)
}
visible := encoded.compressed || priority == outboundPriorityCritical || priority == outboundPriorityBulk
return priority, visible
}
// A successful business result may never leave the encode slot as an
// unaccounted []byte. If the primary 512MiB retained-body budget is full, make
// overload terminal for this physical generation and publish the exact result
// into the independently bounded completed cache before releasing the slot.
retainForReplay := func(encoded *encodedOutboundMessage, admissionErr error) error {
if s == nil || s.rpcResults == nil || c == nil || encoded == nil || reqMsgID == 0 {
return errors.New("rpc result completed cache is unavailable")
}
if int64(len(encoded.body)) > s.rpcResults.completedBytes.max {
// Every transport-legal result fits the production completed cache by the
// compile-time invariant in rpc_result_cache.go. A test/custom cache that
// violates it cannot safely complete this flight, so fail fast while the
// body is still confined to the encode slot.
panic(fmt.Sprintf(
"mtprotoedge: encoded rpc result exceeds completed-cache budget: body=%d max=%d",
len(encoded.body), s.rpcResults.completedBytes.max,
))
}
priority, visible := prepareEncoded(encoded)
if owner != nil && !owner.HandOff() {
return ErrRPCResultFlightInvalid
}
started := time.Now()
encoded.markReplayable()
// Put may expose a completed result only after the old logical connection
// is irreversibly unable to accept another same-generation request.
c.fenceUndeliveredRPCResult()
s.storeRPCResult(c, reqMsgID, encoded)
latency := time.Since(started)
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
metrics.RPCResultDelivered(method, latency, len(encoded.body), admissionErr)
}
resultLogLevel := zap.DebugLevel
if visible {
resultLogLevel = zap.InfoLevel
}
if checked := s.log.Check(resultLogLevel, "RPC result retained for replay after egress saturation"); checked != nil {
checked.Write(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
zap.Int64("delivered_req_msg_id", encoded.writtenRequestID()),
zap.String("auth_key_id", c.authKeyHex), zap.Int64("session_id", c.sessionID),
zap.Int("wire_bytes", len(encoded.body)), zap.Bool("gzip", encoded.compressed),
zap.String("priority", priority.String()), zap.Error(admissionErr))
}
return nil
}
encoded, reserved, retained, err := s.encodeRPCResultReservedWithHandoffContext(
prepareCtx, c, reqMsgID, result, retainForReplay,
)
if retained {
return err
}
if errors.Is(err, errRPCResultRetentionHandoff) {
c.fenceUndeliveredRPCResult()
return err
}
if err != nil {
s.log.Warn("Encode RPC result failed; publishing INTERNAL",
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.Error(err))
afterDelivered = nil
encoded, reserved, retained, err = s.encodeRPCResultReservedWithHandoffContext(
prepareCtx, c, reqMsgID, &mt.RPCError{ErrorCode: 500, ErrorMessage: "INTERNAL"}, retainForReplay,
)
if retained {
return err
}
if err != nil {
c.fenceUndeliveredRPCResult()
return err
}
}
if encoded == nil || reserved == nil {
c.fenceUndeliveredRPCResult()
return errors.New("rpc result encode completed without tracked retention")
}
// Until enqueue transfers ownership, every exit must return the retained-byte
// charge. A successful transfer clears the reservation and makes this a no-op.
defer reserved.release()
priority, visible := prepareEncoded(encoded)
if owner != nil && !owner.HandOff() { if owner != nil && !owner.HandOff() {
return ErrRPCResultFlightInvalid return ErrRPCResultFlightInvalid
} }
priority := rpcResultPriority(method, encoded)
encoded.priority = priority
resultLogLevel := zap.DebugLevel resultLogLevel := zap.DebugLevel
if encoded.compressed || priority == outboundPriorityCritical || priority == outboundPriorityBulk { if visible {
// Keep ordinary small RPCs at debug, but make convergence and bulk/gzip // Keep ordinary small RPCs at debug, but make convergence and bulk/gzip
// delivery visible in default service logs. These are the // delivery visible in the default service logs. These are the
// responses whose queueing and write latency diagnose startup Updating. // responses whose queueing and write latency diagnose startup Updating.
resultLogLevel = zap.InfoLevel resultLogLevel = zap.InfoLevel
} }
if metrics, ok := s.metrics.(RPCResultMetrics); ok {
metrics.RPCResultPrepared(method, priority.String(), encoded.uncompressedBytes, len(encoded.body), encoded.compressed)
}
egressStarted := time.Now() egressStarted := time.Now()
terminal := func(deliveryErr error) { terminal := func(deliveryErr error) {
latency := time.Since(egressStarted) latency := time.Since(egressStarted)
@ -848,8 +963,8 @@ func (s *Server) publishRPCResult(
} }
return return
} }
s.storeRPCResult(c, reqMsgID, encoded)
encoded.markDelivered() encoded.markDelivered()
s.storeRPCResult(c, reqMsgID, encoded)
if checked := s.log.Check(resultLogLevel, "RPC result delivered"); checked != nil { if checked := s.log.Check(resultLogLevel, "RPC result delivered"); checked != nil {
checked.Write( checked.Write(
zap.String("method", method), zap.Int64("req_msg_id", reqMsgID), zap.String("method", method), zap.Int64("req_msg_id", reqMsgID),
@ -860,7 +975,7 @@ func (s *Server) publishRPCResult(
} }
} }
encoded.markQueued() encoded.markQueued()
if err := c.enqueueEncodedDelivery(prepareCtx, proto.MessageServerResponse, encoded, priority, terminal); err != nil { if err := c.enqueueEncodedDeliveryReserved(prepareCtx, proto.MessageServerResponse, encoded, priority, terminal, reserved); err != nil {
// HandOff already made the egress path the terminal owner. No bytes were // HandOff already made the egress path the terminal owner. No bytes were
// admitted, so fence this generation before publishing a replayable result. // admitted, so fence this generation before publishing a replayable result.
terminal(err) terminal(err)
@ -905,10 +1020,10 @@ func (s *Server) sendResult(ctx context.Context, c *Conn, reqMsgID int64, result
s.storeRPCResult(c, reqMsgID, encoded) s.storeRPCResult(c, reqMsgID, encoded)
return err return err
} }
encoded.markDelivered()
// On a live Conn, completed means the rpc_result has reached the reliable byte // On a live Conn, completed means the rpc_result has reached the reliable byte
// stream. Same-physical duplicates can therefore be ACK-only without data loss. // stream. Same-physical duplicates can therefore be ACK-only without data loss.
s.storeRPCResult(c, reqMsgID, encoded) s.storeRPCResult(c, reqMsgID, encoded)
encoded.markDelivered()
return nil return nil
} }
@ -916,24 +1031,114 @@ func (s *Server) sendResult(ctx context.Context, c *Conn, reqMsgID int64, result
// for completed-flight replays: either the cached result reaches this physical // for completed-flight replays: either the cached result reaches this physical
// byte stream, or this logical Conn is fenced so a replacement may retry it. // byte stream, or this logical Conn is fenced so a replacement may retry it.
func (s *Server) sendCachedRPCResult(ctx context.Context, c *Conn, encoded *encodedOutboundMessage) error { func (s *Server) sendCachedRPCResult(ctx context.Context, c *Conn, encoded *encodedOutboundMessage) error {
return s.sendCachedRPCResultWithHook(ctx, c, encoded, nil)
}
func (s *Server) sendCachedRPCResultWithHook(
ctx context.Context,
c *Conn,
encoded *encodedOutboundMessage,
afterSuccessfulDelivery func() error,
) error {
if encoded == nil { if encoded == nil {
c.fenceUndeliveredRPCResult() c.fenceUndeliveredRPCResult()
return errors.New("nil cached rpc_result") return errors.New("nil cached rpc_result")
} }
if err := c.SendEncoded(ctx, proto.MessageServerResponse, encoded); err != nil { attempt, reserved, err := c.cloneRPCResultForRequestReserved(encoded, encoded.reqMsgID, false)
if err != nil {
c.failOutboundBudget(err)
c.fenceUndeliveredRPCResult() c.fenceUndeliveredRPCResult()
encoded.markReplayable()
return err return err
} }
encoded.markDelivered() // take clears the producer reservation after actor admission. Every earlier
// return, including a closed connection, must drop the replay pin here.
defer reserved.release()
pendingLogicalRestore := attempt.pendingLogicalDeliveryHook()
var finishRestore func()
if afterSuccessfulDelivery != nil || pendingLogicalRestore {
finishRestore = c.beginRPCReplayRestore()
defer finishRestore()
}
// Cached replay owns its delivery-gated state synchronously. Calling the
// lower send primitive avoids reserving the process-wide asynchronous hook
// executor; the logical hook is claimed only after this physical write wins.
if err := c.sendOutboundWithTerminalReserved(
ctx, proto.MessageServerResponse, nil, attempt, false, nil, reserved,
); err != nil {
c.fenceUndeliveredRPCResult()
attempt.markReplayable()
return err
}
// Physical success is irrevocable even if the caller's send context expires
// at the same instant. Give the ordered restore its own bounded lifetime.
restoreCtx, cancelRestore := boundedRPCReplayRestoreContext(context.Background())
defer cancelRestore()
logicalRestore, claimErr := attempt.claimLogicalDeliveryHook(restoreCtx, false)
attempt.markDelivered()
if claimErr != nil {
// Another replay owns Claimed/InProgress state (or a retarget still owns
// the sticky deferral). Fence before the deferred barrier is released; a
// later physical generation may wait for Done and replay the same bytes.
c.fenceUndeliveredRPCResult()
return fmt.Errorf("wait for cached rpc_result logical restore: %w", claimErr)
}
return s.runBoundedRPCReplayRestore(
restoreCtx, c, "cached rpc_result", logicalRestore, afterSuccessfulDelivery,
)
}
// composeRPCReplayRestore keeps replacement-connection metadata first while
// still guaranteeing that the original handler's delivery-gated cursor/outbox
// work runs after a physical replay even when metadata restoration reports an
// error. runRPCReplayRestore provides panic isolation and terminal fencing for
// the combined ordered transaction.
func composeRPCReplayRestore(logical func(), replacement func() error) func() error {
if logical == nil && replacement == nil {
return nil return nil
}
return func() (err error) {
if logical != nil {
defer logical()
}
if replacement != nil {
return replacement()
}
return nil
}
}
// runRPCReplayRestore is the panic/error boundary executed only by the fixed-
// capacity runner in rpc_replay_restore.go. Replay restore may touch auth/session
// stores and membership state; its caller holds the per-Conn scheduler barrier.
// Any error or panic fences the partially restored physical generation so a
// replacement can retry from the immutable completed result.
func (s *Server) runRPCReplayRestore(c *Conn, source string, restore func() error) (err error) {
if restore == nil {
return nil
}
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("restore replay state after %s: panic: %v", source, recovered)
if s != nil && s.log != nil {
s.log.Error("Exact RPC replay state restore panicked",
zap.String("source", source), zap.ByteString("stack", debug.Stack()), zap.Any("panic", recovered))
}
}
if err != nil {
if s != nil && s.log != nil {
s.log.Warn("Exact RPC replay state restore failed", zap.String("source", source), zap.Error(err))
}
c.fenceUndeliveredRPCResult()
}
}()
return restore()
} }
// encodeRPCResult 编码 rpc_result。内层对象与 rpc_result 头type_id + req_msg_id // encodeRPCResult 编码 rpc_result。内层对象与 rpc_result 头type_id + req_msg_id
// 一次性编码进同一 buffer——旧实现先编码内层、再经 proto.Result.Encode 整体拷贝一遍, // 一次性编码进同一 buffer——旧实现先编码内层、再经 proto.Result.Encode 整体拷贝一遍,
// 每条响应多一份全量 body 拷贝。内层按连接协商 layer 降级layer==227 直通,零开销), // 每条响应多一份全量 body 拷贝。生成式结果携带完整 Layer profile + result TypeRef
// 降级改写字节时才重建整条消息。降级失败 fail-safe记日志并发送 canonical 字节—— // 绑定,后续发送、缓存和重放均只能用于同一精确 profilepackage 测试保留的 legacy
// 宁可老客户端对个别长尾对象渲染异常,也不让连接/流崩。 // handler 也必须先绑定 generated admitted call生产路径不存在旧转码桥
func (s *Server) encodeRPCResult(c *Conn, reqMsgID int64, result bin.Encoder) (*encodedOutboundMessage, error) { func (s *Server) encodeRPCResult(c *Conn, reqMsgID int64, result bin.Encoder) (*encodedOutboundMessage, error) {
return s.encodeRPCResultContext(context.Background(), c, reqMsgID, result) return s.encodeRPCResultContext(context.Background(), c, reqMsgID, result)
} }
@ -942,36 +1147,138 @@ func (s *Server) encodeRPCResultContext(ctx context.Context, c *Conn, reqMsgID i
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
} }
var inner bin.Buffer var encoded *encodedOutboundMessage
// Terminal result preparation must survive physical-generation retirement: err := withOutboundEncodeSlot(ctx, nil, func() error {
// an overlapping replacement may already be waiting to replay this owner's var err error
// result. Only the bounded preparation context, not the old socket stop, owns it. encoded, err = s.encodeRPCResultWithoutSlot(ctx, c, reqMsgID, result)
if err := withOutboundEncodeSlot(ctx, nil, func() error { return err
return result.Encode(&inner) })
}); err != nil { return encoded, err
}
// encodeRPCResultReservedContext keeps the process-wide encode slot until the
// completed immutable body is charged to the shared retained-byte budget. This
// closes the otherwise unbounded interval in which every RPC worker could own
// a large encoded result that neither the inbound nor outbound budget tracked.
func (s *Server) encodeRPCResultReservedContext(
ctx context.Context,
c *Conn,
reqMsgID int64,
result bin.Encoder,
) (*encodedOutboundMessage, *outboundBodyReservation, error) {
encoded, reserved, _, err := s.encodeRPCResultReservedWithHandoffContext(ctx, c, reqMsgID, result, nil)
return encoded, reserved, err
}
// encodeRPCResultReservedWithHandoffContext has only two successful ownership
// outcomes for a completed body: a primary outbound reservation, or a caller
// handoff that synchronously installs another bounded owner while the encode slot
// is still held. A failed/no handoff clears encoded before the slot is released.
func (s *Server) encodeRPCResultReservedWithHandoffContext(
ctx context.Context,
c *Conn,
reqMsgID int64,
result bin.Encoder,
handoff rpcResultRetentionHandoff,
) (*encodedOutboundMessage, *outboundBodyReservation, bool, error) {
if ctx == nil {
ctx = context.Background()
}
var (
encoded *encodedOutboundMessage
reserved *outboundBodyReservation
retained bool
)
err := withOutboundEncodeSlot(ctx, nil, func() error {
var err error
encoded, err = s.encodeRPCResultWithoutSlot(ctx, c, reqMsgID, result)
if err != nil {
return err
}
budget := c.outboundMessageBudget(encoded.typeID, false)
bytes := len(encoded.body)
if budget.reserve(bytes) {
reserved = &outboundBodyReservation{budget: budget, bytes: bytes}
return nil
}
if handoff != nil {
admissionErr := fmt.Errorf("reserve encoded rpc result: %w", ErrOutboundTrackedBudget)
if err := handoff(encoded, admissionErr); err != nil {
encoded = nil
return fmt.Errorf("%w: %w", errRPCResultRetentionHandoff, errors.Join(admissionErr, err))
}
retained = true
// The handoff owns the only surviving pointer. Do not return a second
// producer reference after the encode slot releases; the completed cache
// may independently evict the entry under its bounded policy.
encoded = nil
return admissionErr
}
// Non-publish callers have no alternate bounded owner. They may wait for
// the caller's deadline, but on failure the body is discarded in-slot.
if err := budget.waitReserve(ctx, nil, bytes); err != nil {
encoded = nil
return fmt.Errorf("reserve encoded rpc result: %w", err)
}
reserved = &outboundBodyReservation{budget: budget, bytes: bytes}
return nil
})
if err != nil && !retained && reserved == nil {
encoded = nil
}
return encoded, reserved, retained, err
}
func (s *Server) encodeRPCResultWithoutSlot(ctx context.Context, c *Conn, reqMsgID int64, result bin.Encoder) (*encodedOutboundMessage, error) {
var layerBinding *outboundLayerBinding
exactResult, exactLayerResult := result.(exactLayerRPCResultEncoder)
layerInvariantResult := isLayerInvariantRPCResultEncoder(result)
if !exactLayerResult && !layerInvariantResult {
return nil, ErrOutboundLayerBindingRequired
}
if exactLayerResult {
binding := exactResult.exactLayerRPCResultBinding()
layerBinding = &binding
if err := validateOutboundLayerBinding(c, &encodedOutboundMessage{layer: layerBinding}); err != nil {
return nil, fmt.Errorf("bind exact layer rpc result: %w", err)
}
}
// Encode the ordinary exact/no-gzip path directly behind the rpc_result
// prefix. This avoids both the old generated Prepare snapshot and another
// full-body copy merely to prepend the 12-byte envelope.
var envelope bin.Buffer
envelope.PutID(proto.ResultTypeID)
envelope.PutLong(reqMsgID)
if err := result.Encode(&envelope); err != nil {
return nil, fmt.Errorf("encode rpc result: %w", err) return nil, fmt.Errorf("encode rpc result: %w", err)
} }
innerBody := inner.Raw() envelopeInner := envelope.Raw()[12:]
if layer := c.ClientLayer(); layer < layerwire.CanonicalLayer { innerBody := envelopeInner
if down, err := layerwire.Transcode(innerBody, layer); err != nil { // Inputs above gotd's decompression ceiling can never be gzip_packed. Reject
s.log.Warn("layerwire downgrade failed; sending canonical rpc_result", // them before allocating a second transport-envelope-sized buffer.
zap.Int("layer", layer), zap.Int64("req_msg_id", reqMsgID), zap.Error(err)) if len(innerBody) > rpcResultGZIPMaxInputBytes && len(innerBody) > maxOutboundBodyBytes-12 {
} else { return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, len(innerBody)+12, maxOutboundBodyBytes)
innerBody = down
}
} }
wireInner, compressed, err := encodeAdaptiveRPCResultInner(ctx, nil, innerBody) wireInner, compressed, err := encodeAdaptiveRPCResultInner(ctx, nil, innerBody)
if err != nil { if err != nil {
return nil, fmt.Errorf("compress rpc result: %w", err) return nil, fmt.Errorf("compress rpc result: %w", err)
} }
if len(wireInner) > maxOutboundBodyBytes-12 {
return nil, fmt.Errorf("%w: body=%d limit=%d", ErrOutboundMessageTooLarge, len(wireInner)+12, maxOutboundBodyBytes)
}
body := envelope.Raw()
if compressed || !sameBacking(wireInner, envelopeInner) {
var out bin.Buffer var out bin.Buffer
out.PutID(proto.ResultTypeID) out.PutID(proto.ResultTypeID)
out.PutLong(reqMsgID) out.PutLong(reqMsgID)
out.Put(wireInner) out.Put(wireInner)
body = out.Raw()
}
return &encodedOutboundMessage{ return &encodedOutboundMessage{
typeID: proto.ResultTypeID, body: out.Raw(), reqMsgID: reqMsgID, typeID: proto.ResultTypeID, body: body, reqMsgID: reqMsgID,
compressed: compressed, uncompressedBytes: len(innerBody), delivery: newRPCResultDelivery(), compressed: compressed, uncompressedBytes: len(innerBody), delivery: newRPCResultDelivery(0),
layer: layerBinding, layerInvariant: layerInvariantResult,
}, nil }, nil
} }
@ -1075,11 +1382,20 @@ func (s *Server) sendMsgsStateInfo(ctx context.Context, c *Conn, reqMsgID int64,
} }
func (s *Server) sendDestroySession(ctx context.Context, c *Conn, sessionID int64) error { func (s *Server) sendDestroySession(ctx context.Context, c *Conn, sessionID int64) error {
removed := false removed, removedDurable := false, false
if sessionID != c.sessionID { if sessionID != c.sessionID {
if deleter, ok := s.layerRPC.(LayerRPCDurableSessionProfileDeleter); ok {
var err error
removedDurable, err = deleter.DeleteNegotiatedSessionLayerEvidence(ctx, c.authKeyID, sessionID)
if err != nil {
return fmt.Errorf("delete durable exact session Layer evidence: %w", err)
}
}
if s.conns != nil {
removed = s.conns.DestroySessionForAuthKey(c.authKeyID, sessionID) removed = s.conns.DestroySessionForAuthKey(c.authKeyID, sessionID)
} }
if removed { }
if removed || removedDurable {
return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionOk{SessionID: sessionID}) return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionOk{SessionID: sessionID})
} }
return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionNone{SessionID: sessionID}) return c.Send(ctx, proto.MessageServerResponse, &mt.DestroySessionNone{SessionID: sessionID})
@ -1113,7 +1429,7 @@ func (s *Server) typeName(id uint32) string {
} }
func validateClientEnvelope(now time.Time, msgID int64, seqNo int32, typeID uint32) int { func validateClientEnvelope(now time.Time, msgID int64, seqNo int32, typeID uint32) int {
if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient { if !validClientMessageIDBits(msgID) {
return badMsgIDInvalidBits return badMsgIDInvalidBits
} }
msgTime := proto.MessageID(msgID).Time() msgTime := proto.MessageID(msgID).Time()
@ -1137,7 +1453,7 @@ func validateClientEnvelope(now time.Time, msgID int64, seqNo int32, typeID uint
} }
func validateClientContainerEnvelope(msgID int64, seqNo int32, typeID uint32) int { func validateClientContainerEnvelope(msgID int64, seqNo int32, typeID uint32) int {
if msgID == 0 || proto.MessageID(msgID).Type() != proto.MessageFromClient { if !validClientMessageIDBits(msgID) {
return badMsgIDInvalidBits return badMsgIDInvalidBits
} }
if clientMessageAllowsEitherSeqParity(typeID) { if clientMessageAllowsEitherSeqParity(typeID) {

View file

@ -2,17 +2,73 @@ package mtprotoedge
import ( import (
"context" "context"
"errors"
"sync"
"testing" "testing"
"time" "time"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/mt" "github.com/iamxvbaba/td/mt"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/store/memory"
) )
type durableDestroyLayerRPC struct {
*admissionOnlyLayerRPC
mu sync.Mutex
deleted bool
err error
authKeyID [8]byte
sessionID int64
}
type deleteFailAuthKeyStore struct {
*memory.AuthKeyStore
err error
}
type trailingDestroyAuthKeyRequest struct{}
func (*trailingDestroyAuthKeyRequest) Encode(b *bin.Buffer) error {
b.PutID(destroyAuthKeyRequestTypeID)
b.PutID(0xdeadbeef)
return nil
}
func (*trailingDestroyAuthKeyRequest) Decode(b *bin.Buffer) error {
if err := b.ConsumeID(destroyAuthKeyRequestTypeID); err != nil {
return err
}
_, err := b.ID()
return err
}
func (s *deleteFailAuthKeyStore) Delete(context.Context, [8]byte) error {
return s.err
}
func (h *durableDestroyLayerRPC) DeleteNegotiatedSessionLayerEvidence(
_ context.Context,
authKeyID [8]byte,
sessionID int64,
) (bool, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.authKeyID = authKeyID
h.sessionID = sessionID
return h.deleted, h.err
}
func (h *durableDestroyLayerRPC) deletion() ([8]byte, int64) {
h.mu.Lock()
defer h.mu.Unlock()
return h.authKeyID, h.sessionID
}
// TestEncryptedPingPong 验证 M2/M4握手后 client 加密 ping // TestEncryptedPingPong 验证 M2/M4握手后 client 加密 ping
// server 回 new_session_created + pong + msgs_ack。 // server 回 new_session_created + pong + msgs_ack。
func TestEncryptedPingPong(t *testing.T) { func TestEncryptedPingPong(t *testing.T) {
@ -47,7 +103,7 @@ func TestEncryptedPingPong(t *testing.T) {
func TestDuplicateMsgIDIdempotent(t *testing.T) { func TestDuplicateMsgIDIdempotent(t *testing.T) {
const dc = 2 const dc = 2
handler := &admissionCountingRPC{} handler := &admissionCountingRPC{}
addr, pub, _ := startTestServer(t, Options{DC: dc, RPC: handler}) addr, pub, _ := startTestServer(t, Options{DC: dc, legacyRPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub) conn, auth, cipher := dialHandshake(t, addr, dc, pub)
clientMsgID := proto.NewMessageIDGen(time.Now) clientMsgID := proto.NewMessageIDGen(time.Now)
@ -231,8 +287,65 @@ func TestDestroySession(t *testing.T) {
} }
} }
func TestDestroySessionAcknowledgesOfflineDurableEvidenceDeletion(t *testing.T) {
const dc = 2
handler := &durableDestroyLayerRPC{
admissionOnlyLayerRPC: newAdmissionOnlyLayerRPC(),
deleted: true,
}
addr, pub, _ := startTestServer(t, Options{DC: dc, LayerRPC: handler})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
clientMsgID := proto.NewMessageIDGen(time.Now)
reqMsgID := clientMsgID.New(proto.MessageFromClient)
targetSessionID := auth.SessionID + 4
sendEncrypted(t, conn, cipher, auth, reqMsgID, &mt.DestroySessionRequest{SessionID: targetSessionID})
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.DestroySessionOkTypeID)
buf := mustHave(t, replies, mt.DestroySessionOkTypeID, "destroy_session_ok")
var res mt.DestroySessionOk
if err := res.Decode(buf); err != nil {
t.Fatal(err)
}
if res.SessionID != targetSessionID {
t.Fatalf("destroy_session_ok.session_id = %d, want %d", res.SessionID, targetSessionID)
}
authKeyID, deletedSessionID := handler.deletion()
if authKeyID == ([8]byte{}) || deletedSessionID != targetSessionID {
t.Fatalf("durable deletion = auth:%x session:%d", authKeyID, deletedSessionID)
}
}
func TestDestroySessionDurabilityFailureDoesNotAcknowledgeOrRetireLiveSession(t *testing.T) {
boom := errors.New("database unavailable")
handler := &durableDestroyLayerRPC{
admissionOnlyLayerRPC: newAdmissionOnlyLayerRPC(),
err: boom,
}
manager := NewSessionManager(nil)
authKeyID := [8]byte{0xd3, 0x57}
target := &Conn{authKeyID: authKeyID, sessionID: 2, metrics: NopMetrics{}}
if err := manager.Register(target); err != nil {
t.Fatal(err)
}
defer manager.Unregister(target)
s := New(Options{DC: 2, LayerRPC: handler, ActiveSessions: manager})
current := &Conn{authKeyID: authKeyID, sessionID: 1, metrics: NopMetrics{}}
err := s.sendDestroySession(context.Background(), current, target.sessionID)
if !errors.Is(err, boom) {
t.Fatalf("destroy durability error = %v, want %v", err, boom)
}
manager.mu.RLock()
stillCurrent := manager.bySession[connSessionKey(target)] == target
manager.mu.RUnlock()
if !stillCurrent {
t.Fatal("durability failure retired the live target session")
}
}
// TestRPCDropAnswer 验证 rpc_drop_answer 以 rpc_result 包装 RpcDropAnswer 返回, // TestRPCDropAnswer 验证 rpc_drop_answer 以 rpc_result 包装 RpcDropAnswer 返回,
// 与 gotd/td 和 TDesktop 的请求/响应模型对齐。 // 与 iamxvbaba/td 和 TDesktop 的请求/响应模型对齐。
func TestRPCDropAnswer(t *testing.T) { func TestRPCDropAnswer(t *testing.T) {
const dc = 2 const dc = 2
addr, pub, _ := startTestServer(t, Options{DC: dc}) addr, pub, _ := startTestServer(t, Options{DC: dc})
@ -411,19 +524,49 @@ func TestPingDelayDisconnectOddSeqAccepted(t *testing.T) {
} }
} }
// TestDestroyAuthKey 验证 MTProto service message destroy_auth_key 由连接层直接响应, // TestDestroyAuthKey 验证 MTProto service message destroy_auth_key 无论裸发,
// 避免 TDesktop 清理旧 key 时落到业务 RPC fallback。 // 还是沿官方客户端的 invokeWithLayer/initConnection 路径发送,都由连接层
// 直接处理,并以绑定原请求的 rpc_result 回复。
func TestDestroyAuthKey(t *testing.T) { func TestDestroyAuthKey(t *testing.T) {
tests := []struct {
name string
layer int
wrapped bool
}{
{name: "bare"},
{name: "layer225_wrapped", layer: 225, wrapped: true},
{name: "layer226_wrapped", layer: 226, wrapped: true},
{name: "layer227_wrapped", layer: 227, wrapped: true},
{name: "layer228_wrapped", layer: 228, wrapped: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
const dc = 2 const dc = 2
addr, pub, srv := startTestServer(t, Options{DC: dc}) addr, pub, srv := startTestServer(t, Options{DC: dc, LayerRPC: newAdmissionOnlyLayerRPC()})
conn, auth, cipher := dialHandshake(t, addr, dc, pub) conn, auth, cipher := dialHandshake(t, addr, dc, pub)
var request bin.Encoder = &destroyAuthKeyRequest{}
if test.wrapped {
request = &tg.InvokeWithLayerRequest{
Layer: test.layer,
Query: &tg.InitConnectionRequest{
APIID: 1,
DeviceModel: "destroy-key-test",
SystemVersion: "test",
AppVersion: "test",
SystemLangCode: "en",
LangCode: "en",
Query: &destroyAuthKeyRequest{},
},
}
}
clientMsgID := proto.NewMessageIDGen(time.Now) clientMsgID := proto.NewMessageIDGen(time.Now)
reqMsgID := clientMsgID.New(proto.MessageFromClient) reqMsgID := clientMsgID.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, reqMsgID, &destroyAuthKeyRequest{}) sendEncrypted(t, conn, cipher, auth, reqMsgID, request)
replies := collectReplies(t, conn, cipher, auth.AuthKey, destroyAuthKeyOkTypeID) replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID)
mustHave(t, replies, destroyAuthKeyOkTypeID, "destroy_auth_key_ok") assertDestroyAuthKeyRPCResult(t, mustHave(t, replies, proto.ResultTypeID, "destroy_auth_key rpc_result"), reqMsgID, destroyAuthKeyOkTypeID)
if _, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID); err != nil || found { if _, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID); err != nil || found {
t.Fatalf("auth key after destroy: found=%v err=%v", found, err) t.Fatalf("auth key after destroy: found=%v err=%v", found, err)
} }
@ -431,7 +574,168 @@ func TestDestroyAuthKey(t *testing.T) {
defer cancel() defer cancel()
var frame bin.Buffer var frame bin.Buffer
if err := conn.Recv(ctx, &frame); err == nil { if err := conn.Recv(ctx, &frame); err == nil {
t.Fatal("destroy_auth_key requester remained readable after required ok") t.Fatal("destroy_auth_key requester remained readable after required rpc_result(ok)")
}
})
}
}
func assertDestroyAuthKeyRPCResult(t *testing.T, b *bin.Buffer, reqMsgID int64, wantInner uint32) {
t.Helper()
var result proto.Result
if err := result.Decode(b); err != nil {
t.Fatalf("decode destroy_auth_key rpc_result: %v", err)
}
if result.RequestMessageID != reqMsgID {
t.Fatalf("destroy_auth_key rpc_result.req_msg_id = %d, want %d", result.RequestMessageID, reqMsgID)
}
inner := &bin.Buffer{Buf: result.Result}
innerID, err := inner.PeekID()
if err != nil {
t.Fatalf("peek destroy_auth_key rpc_result inner: %v", err)
}
if innerID != wantInner || inner.Len() != bin.Word {
t.Fatalf("destroy_auth_key rpc_result inner = %#x/%d bytes, want %#x/%d", innerID, inner.Len(), wantInner, bin.Word)
}
}
func TestDestroyAuthKeyDeleteFailureReturnsCorrelatedFailAndKeepsConnection(t *testing.T) {
const dc = 2
deleteErr := errors.New("delete auth key failed")
keys := &deleteFailAuthKeyStore{AuthKeyStore: memory.NewAuthKeyStore(), err: deleteErr}
addr, pub, srv := startTestServer(t, Options{DC: dc, AuthKeys: keys, LayerRPC: newAdmissionOnlyLayerRPC()})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
destroyReqMsgID := ids.New(proto.MessageFromClient)
sendEncrypted(t, conn, cipher, auth, destroyReqMsgID, &destroyAuthKeyRequest{})
replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID)
assertDestroyAuthKeyRPCResult(t, mustHave(t, replies, proto.ResultTypeID, "destroy_auth_key fail rpc_result"), destroyReqMsgID, destroyAuthKeyFailTypeID)
if _, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID); err != nil || !found {
t.Fatalf("auth key after failed delete: found=%v err=%v", found, err)
}
pingReqMsgID := ids.New(proto.MessageFromClient)
sendEncryptedWithSeq(t, conn, cipher, auth, pingReqMsgID, 3, &mt.PingRequest{PingID: 99})
pongReplies := collectReplies(t, conn, cipher, auth.AuthKey, mt.PongTypeID)
var pong mt.Pong
if err := pong.Decode(mustHave(t, pongReplies, mt.PongTypeID, "pong after failed destroy_auth_key")); err != nil {
t.Fatalf("decode pong after failed destroy_auth_key: %v", err)
}
if pong.MsgID != pingReqMsgID || pong.PingID != 99 {
t.Fatalf("pong after failed destroy_auth_key = %+v", pong)
}
}
func TestWrappedDestroyAuthKeyTrailingBytesDoNotDelete(t *testing.T) {
const dc = 2
addr, pub, srv := startTestServer(t, Options{DC: dc, LayerRPC: newAdmissionOnlyLayerRPC()})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
reqMsgID := ids.New(proto.MessageFromClient)
request := &tg.InvokeWithLayerRequest{
Layer: 228,
Query: &tg.InitConnectionRequest{
APIID: 1, DeviceModel: "malformed-destroy-key-test", SystemVersion: "test",
AppVersion: "test", SystemLangCode: "en", LangCode: "en",
Query: &trailingDestroyAuthKeyRequest{},
},
}
sendEncrypted(t, conn, cipher, auth, reqMsgID, request)
replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID)
var result proto.Result
if err := result.Decode(mustHave(t, replies, proto.ResultTypeID, "malformed destroy_auth_key rpc_result")); err != nil {
t.Fatalf("decode malformed destroy_auth_key rpc_result: %v", err)
}
if result.RequestMessageID != reqMsgID {
t.Fatalf("malformed destroy_auth_key req_msg_id = %d, want %d", result.RequestMessageID, reqMsgID)
}
var rpcErr mt.RPCError
if err := rpcErr.Decode(&bin.Buffer{Buf: result.Result}); err != nil {
t.Fatalf("decode malformed destroy_auth_key RPC error: %v", err)
}
if rpcErr.ErrorCode != 400 || rpcErr.ErrorMessage != "INPUT_REQUEST_INVALID" {
t.Fatalf("malformed destroy_auth_key RPC error = %+v", rpcErr)
}
if _, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID); err != nil || !found {
t.Fatalf("auth key after malformed wrapped destroy: found=%v err=%v", found, err)
}
}
func TestWrappedDestroyAuthKeySemanticWrapperDoesNotDelete(t *testing.T) {
const dc = 2
addr, pub, srv := startTestServer(t, Options{DC: dc, LayerRPC: newAdmissionOnlyLayerRPC()})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
reqMsgID := ids.New(proto.MessageFromClient)
request := &tg.InvokeWithLayerRequest{
Layer: 228,
Query: &tg.InitConnectionRequest{
APIID: 1, DeviceModel: "semantic-wrapper-destroy-key-test", SystemVersion: "test",
AppVersion: "test", SystemLangCode: "en", LangCode: "en",
Query: &tg.InvokeAfterMsgRequest{
MsgID: 1,
Query: &destroyAuthKeyRequest{},
},
},
}
sendEncrypted(t, conn, cipher, auth, reqMsgID, request)
replies := collectReplies(t, conn, cipher, auth.AuthKey, proto.ResultTypeID)
var result proto.Result
if err := result.Decode(mustHave(t, replies, proto.ResultTypeID, "semantic-wrapper destroy_auth_key rpc_result")); err != nil {
t.Fatalf("decode semantic-wrapper destroy_auth_key rpc_result: %v", err)
}
if result.RequestMessageID != reqMsgID {
t.Fatalf("semantic-wrapper destroy_auth_key req_msg_id = %d, want %d", result.RequestMessageID, reqMsgID)
}
var rpcErr mt.RPCError
if err := rpcErr.Decode(&bin.Buffer{Buf: result.Result}); err != nil {
t.Fatalf("decode semantic-wrapper destroy_auth_key RPC error: %v", err)
}
if rpcErr.ErrorCode != 400 || rpcErr.ErrorMessage != "INPUT_REQUEST_INVALID" {
t.Fatalf("semantic-wrapper destroy_auth_key RPC error = %+v", rpcErr)
}
if _, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID); err != nil || !found {
t.Fatalf("auth key after semantic-wrapper destroy_auth_key: found=%v err=%v", found, err)
}
}
func TestWrappedDestroyAuthKeyMixedContainerIsRejectedAtomically(t *testing.T) {
const dc = 2
addr, pub, srv := startTestServer(t, Options{DC: dc, LayerRPC: newAdmissionOnlyLayerRPC()})
conn, auth, cipher := dialHandshake(t, addr, dc, pub)
ids := proto.NewMessageIDGen(time.Now)
destroyBody := encodeClientMessageBodyForTest(t, &tg.InvokeWithLayerRequest{
Layer: 228,
Query: &tg.InitConnectionRequest{
APIID: 1, DeviceModel: "mixed-destroy-key-test", SystemVersion: "test",
AppVersion: "test", SystemLangCode: "en", LangCode: "en",
Query: &destroyAuthKeyRequest{},
},
})
pingBody := encodeClientMessageBodyForTest(t, &mt.PingRequest{PingID: 7})
destroyMsgID := ids.New(proto.MessageFromClient)
pingMsgID := ids.New(proto.MessageFromClient)
outerMsgID := ids.New(proto.MessageFromClient)
container := &proto.MessageContainer{Messages: []proto.Message{
{ID: destroyMsgID, SeqNo: 1, Bytes: len(destroyBody), Body: destroyBody},
{ID: pingMsgID, SeqNo: 3, Bytes: len(pingBody), Body: pingBody},
}}
sendEncrypted(t, conn, cipher, auth, outerMsgID, container)
replies := collectReplies(t, conn, cipher, auth.AuthKey, mt.BadMsgNotificationTypeID)
var bad mt.BadMsgNotification
if err := bad.Decode(mustHave(t, replies, mt.BadMsgNotificationTypeID, "bad_msg for mixed destroy_auth_key container")); err != nil {
t.Fatalf("decode mixed destroy_auth_key bad_msg: %v", err)
}
if bad.BadMsgID != outerMsgID || bad.ErrorCode != badMsgContainer {
t.Fatalf("mixed destroy_auth_key bad_msg = %+v, want msg_id=%d code=%d", bad, outerMsgID, badMsgContainer)
}
if _, found, err := srv.authKeys.Get(context.Background(), auth.AuthKey.ID); err != nil || !found {
t.Fatalf("auth key after mixed destroy_auth_key container: found=%v err=%v", found, err)
} }
} }

View file

@ -8,12 +8,12 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mt" "github.com/iamxvbaba/td/mt"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/store" "telesrv/internal/store"
) )

View file

@ -13,14 +13,14 @@ import (
gofaster "github.com/go-faster/errors" gofaster "github.com/go-faster/errors"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/clock" "github.com/iamxvbaba/td/clock"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mt" "github.com/iamxvbaba/td/mt"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
) )
// runServerExchange is a gotd server exchange compatibility shim. // runServerExchange is a gotd server exchange compatibility shim.
@ -425,7 +425,7 @@ func (s serverExchangeCompat) readUnencrypted(ctx context.Context, b *bin.Buffer
if err := msg.Decode(b); err != nil { if err := msg.Decode(b); err != nil {
return err return err
} }
if proto.MessageID(msg.MessageID).Type() != proto.MessageFromClient { if !validClientMessageIDBits(msg.MessageID) {
return gofaster.New("bad msg type") return gofaster.New("bad msg type")
} }
b.ResetTo(msg.MessageData) b.ResetTo(msg.MessageData)

View file

@ -15,12 +15,12 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/mt" "github.com/iamxvbaba/td/mt"
tgproto "github.com/gotd/td/proto" tgproto "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
"telesrv/internal/store" "telesrv/internal/store"
"telesrv/internal/store/memory" "telesrv/internal/store/memory"

View file

@ -4,8 +4,8 @@ import (
"context" "context"
"testing" "testing"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/tg" "github.com/iamxvbaba/td/tg"
) )
// TestRunFlushDiscardsBatchOnIdentitySwitch 验证排空进行中连接易主(登出/换号致 // TestRunFlushDiscardsBatchOnIdentitySwitch 验证排空进行中连接易主(登出/换号致

View file

@ -7,9 +7,9 @@ import (
"io" "io"
"sync/atomic" "sync/atomic"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
) )
const defaultInboundFrameGlobalMaxBytes int64 = 512 << 20 const defaultInboundFrameGlobalMaxBytes int64 = 512 << 20

View file

@ -10,9 +10,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/proto/codec" "github.com/iamxvbaba/td/proto/codec"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
) )
type frameBudgetTestConn struct { type frameBudgetTestConn struct {

View file

@ -5,10 +5,10 @@ import (
"errors" "errors"
"testing" "testing"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/mt" "github.com/iamxvbaba/td/mt"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/transport"
) )
// reqPQConn 是只会不断返回同一个 req_pq_multi 帧的假 transport.Conn用于驱动 bufferedConn // reqPQConn 是只会不断返回同一个 req_pq_multi 帧的假 transport.Conn用于驱动 bufferedConn

View file

@ -11,13 +11,97 @@ import (
"go.uber.org/zap/zaptest" "go.uber.org/zap/zaptest"
"github.com/gotd/log/logzap" "github.com/gotd/log/logzap"
"github.com/gotd/td/bin" "github.com/iamxvbaba/td/bin"
"github.com/gotd/td/crypto" "github.com/iamxvbaba/td/crypto"
"github.com/gotd/td/exchange" "github.com/iamxvbaba/td/exchange"
"github.com/gotd/td/proto" "github.com/iamxvbaba/td/proto"
"github.com/gotd/td/transport" "github.com/iamxvbaba/td/tg"
"github.com/iamxvbaba/td/transport"
) )
// legacyCanonicalTestConn explicitly declares the canonical-only profile used
// by old connection-state tests. Production exact-path tests must instead call
// FreezeLayerProfile/SeedLayerProfile with protocol evidence.
func legacyCanonicalTestConn(t testing.TB, c *Conn) *Conn {
return legacyLayerWireTestConn(t, c, int(tg.LayerProfileCanonical))
}
// legacyLayerWireTestConn preserves only the old tests' profile setup. It does
// not enable any wire conversion; application values still need an exact
// generated binding at the outbound boundary.
func legacyLayerWireTestConn(t testing.TB, c *Conn, layer int) *Conn {
t.Helper()
if c == nil {
t.Fatal("nil legacy exact-layer test Conn")
}
profile, ok := tg.ResolveLayerProfile(layer)
if !ok {
t.Fatalf("unsupported generated test Layer %d", layer)
}
if err := c.FreezeLayerProfile(profile); err != nil {
t.Fatalf("freeze generated test Layer %d: %v", layer, err)
}
c.setLegacyClientLayer(layer)
return c
}
// exactTestUpdatesEncoded gives transport/state-machine tests an explicit
// generated session binding without invoking the production fan-out cache.
// Tests which assert wire conversion use layerUpdatesFanout directly instead.
func exactTestUpdatesEncoded(t testing.TB, c *Conn, body []byte) *encodedOutboundMessage {
t.Helper()
if c == nil {
t.Fatal("nil exact test Conn")
}
state := c.LayerProfileState()
if state.Origin == LayerProfileUnknown {
t.Fatal("exact test Conn has no generated Layer profile")
}
return &encodedOutboundMessage{
body: append([]byte(nil), body...),
typeID: tg.UpdatesTooLongTypeID,
layer: &outboundLayerBinding{
profile: state.Profile,
typ: tg.LayerClassUpdatesType().Ref(),
epoch: state.Epoch,
},
}
}
func exactTestUpdatesTooLong(t testing.TB, c *Conn) *encodedOutboundMessage {
t.Helper()
var body bin.Buffer
if err := (&tg.UpdatesTooLong{}).Encode(&body); err != nil {
t.Fatalf("encode exact test updatesTooLong: %v", err)
}
return exactTestUpdatesEncoded(t, c, body.Raw())
}
// opaqueExactTestRPCResult is an explicit request-bound capability for tests
// of compression, retention and delivery mechanics. Semantic result conversion
// is covered by generated dispatcher tests; no production path constructs it.
type opaqueExactTestRPCResult struct{ result bin.Encoder }
func (r *opaqueExactTestRPCResult) Encode(b *bin.Buffer) error { return r.result.Encode(b) }
func (r *opaqueExactTestRPCResult) exactLayerRPCResultBinding() outboundLayerBinding {
return outboundLayerBinding{
profile: tg.LayerProfileCanonical,
typ: tg.LayerClassUpdatesType().Ref(),
kind: outboundLayerBindingRequest,
}
}
func exactTestRPCResult(result bin.Encoder) bin.Encoder {
if result == nil || isLayerInvariantRPCResultEncoder(result) {
return result
}
if _, ok := result.(exactLayerRPCResultEncoder); ok {
return result
}
return &opaqueExactTestRPCResult{result: result}
}
// startTestServer 生成 RSA key、监听随机端口并启动 Server返回监听地址与公钥。 // startTestServer 生成 RSA key、监听随机端口并启动 Server返回监听地址与公钥。
// 通过 t.Cleanup 自动取消并校验优雅退出。opts 的 RSAKey/Logger/DC 会被补默认。 // 通过 t.Cleanup 自动取消并校验优雅退出。opts 的 RSAKey/Logger/DC 会被补默认。
func startTestServer(t *testing.T, opts Options) (addr string, pub exchange.PublicKey, srv *Server) { func startTestServer(t *testing.T, opts Options) (addr string, pub exchange.PublicKey, srv *Server) {
@ -92,6 +176,27 @@ func dialTransportOnly(t *testing.T, addr string) transport.Conn {
return conn return conn
} }
// freezeActiveTestSessionProfile gives low-level transport fixtures the exact
// profile that a production invokeWithLayer admission would have proven. It is
// intentionally explicit: handshake/new_session_created alone never implies a
// TL Layer, and production push code must keep failing closed in that state.
func freezeActiveTestSessionProfile(t *testing.T, sessions *SessionManager, authKeyID [8]byte, sessionID int64, profile tg.LayerProfile) {
t.Helper()
if sessions == nil {
t.Fatal("freeze test session profile on nil SessionManager")
}
key := sessionKey{authKeyID: authKeyID, sessionID: sessionID}
sessions.mu.RLock()
c := sessions.bySession[key]
sessions.mu.RUnlock()
if c == nil {
t.Fatalf("active test session %x/%d is missing", authKeyID, sessionID)
}
if err := c.FreezeLayerProfile(profile); err != nil {
t.Fatalf("freeze active test session profile %d: %v", profile, err)
}
}
// sendEncrypted 用 client cipher 加密并发送一条带 msgID 的消息。 // sendEncrypted 用 client cipher 加密并发送一条带 msgID 的消息。
func sendEncrypted(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, msgID int64, msg bin.Encoder) { func sendEncrypted(t *testing.T, conn transport.Conn, cipher crypto.Cipher, auth exchange.ClientExchangeResult, msgID int64, msg bin.Encoder) {
t.Helper() t.Helper()

Some files were not shown because too many files have changed in this diff Show more