chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
141
internal/compat/layerwire/README.md
Normal file
141
internal/compat/layerwire/README.md
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
# layerwire 操作手册(多 Layer 向后兼容)
|
||||
|
||||
> 本文是 **怎么操作**(runbook)。**为什么这么设计**见 [`docs/layer-compat-220-227-design.md`](../../../docs/layer-compat-220-227-design.md)。
|
||||
> 改这个包前请先读完本文 + 设计文档。所有命令都从 **telesrv 模块根目录**执行。
|
||||
|
||||
## 这个包是干什么的
|
||||
|
||||
让 telesrv 同时正确服务 **Layer 220–227** 的客户端,而业务 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 行 |
|
||||
| `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/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 操作 1:gotd 升级(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——那是旧做法,已全删。统一走数据 + 通用引擎。
|
||||
|
||||
## 操作 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 又不在 allowlist(gotd 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,无需改格式。
|
||||
|
||||
## 稳态心法
|
||||
|
||||
**喂新 schema(gotd 或更老层)→ 跑 `gen` + `go test` → 护栏吐出短清单 → 人只处理新出现的 fallback / 结构性 / converter / rename。** 不再有"运行时撞 NOT_IMPLEMENTED 再手写 handler"。
|
||||
2882
internal/compat/layerwire/_schema/layer-220.tl
Normal file
2882
internal/compat/layerwire/_schema/layer-220.tl
Normal file
File diff suppressed because it is too large
Load diff
2892
internal/compat/layerwire/_schema/layer-221.tl
Normal file
2892
internal/compat/layerwire/_schema/layer-221.tl
Normal file
File diff suppressed because it is too large
Load diff
2906
internal/compat/layerwire/_schema/layer-222.tl
Normal file
2906
internal/compat/layerwire/_schema/layer-222.tl
Normal file
File diff suppressed because it is too large
Load diff
2914
internal/compat/layerwire/_schema/layer-223.tl
Normal file
2914
internal/compat/layerwire/_schema/layer-223.tl
Normal file
File diff suppressed because it is too large
Load diff
2944
internal/compat/layerwire/_schema/layer-224.tl
Normal file
2944
internal/compat/layerwire/_schema/layer-224.tl
Normal file
File diff suppressed because it is too large
Load diff
2977
internal/compat/layerwire/_schema/layer-225.tl
Normal file
2977
internal/compat/layerwire/_schema/layer-225.tl
Normal file
File diff suppressed because it is too large
Load diff
3002
internal/compat/layerwire/_schema/layer-226.tl
Normal file
3002
internal/compat/layerwire/_schema/layer-226.tl
Normal file
File diff suppressed because it is too large
Load diff
3039
internal/compat/layerwire/_schema/layer-227.tl
Normal file
3039
internal/compat/layerwire/_schema/layer-227.tl
Normal file
File diff suppressed because it is too large
Load diff
83
internal/compat/layerwire/bench_test.go
Normal file
83
internal/compat/layerwire/bench_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
36
internal/compat/layerwire/client_aliases.go
Normal file
36
internal/compat/layerwire/client_aliases.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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,
|
||||
}
|
||||
126
internal/compat/layerwire/corpus_test.go
Normal file
126
internal/compat/layerwire/corpus_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
138
internal/compat/layerwire/fallback.go
Normal file
138
internal/compat/layerwire/fallback.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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 int) error {
|
||||
if err := canonical.skipObject(in); 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},
|
||||
}
|
||||
|
||||
// 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 int) error {
|
||||
flags, err := in.Uint32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
option, err := in.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var voters int
|
||||
if flags&(1<<2) != 0 {
|
||||
if voters, err = in.Int(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := canonical.skipValue(in, &peerVectorField); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
out.PutID(target)
|
||||
out.PutUint32(flags & 0b11) // retain chosen/correct, clear the moved bit 2
|
||||
out.PutBytes(option)
|
||||
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 int) 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)
|
||||
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) (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 = m.skipValue(in, f); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
163
internal/compat/layerwire/gate_test.go
Normal file
163
internal/compat/layerwire/gate_test.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
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 "?"
|
||||
}
|
||||
450
internal/compat/layerwire/gen/main.go
Normal file
450
internal/compat/layerwire/gen/main.go
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
// 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
|
||||
}
|
||||
260
internal/compat/layerwire/inbound.go
Normal file
260
internal/compat/layerwire/inbound.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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
|
||||
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",
|
||||
}
|
||||
|
||||
// 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) 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) error {
|
||||
in := &bin.Buffer{Buf: raw}
|
||||
n, err := in.VectorHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.PutVectorHeader(n)
|
||||
for i := 0; i < n; i++ {
|
||||
v, err := in.Int()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.PutID(inputMessageID)
|
||||
out.PutInt(v)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
// bot_id:long -> bot:InputUser{user_id, access_hash=0}.
|
||||
"long->InputUser": func(raw []byte, out *bin.Buffer) error {
|
||||
in := &bin.Buffer{Buf: raw}
|
||||
id, err := in.Long()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.PutID(inputUserID)
|
||||
out.PutLong(id)
|
||||
out.PutLong(0)
|
||||
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 {
|
||||
if len(in.Buf) < 4 {
|
||||
return nil, false, fmt.Errorf("layerwire: short inbound buffer for %#08x", id)
|
||||
}
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("layerwire: upgrade %s (%#08x): %w", old.name, id, err)
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
return nil, false, 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) (*bin.Buffer, error) {
|
||||
target := canonical.byName[old.name]
|
||||
if target == nil {
|
||||
return nil, fmt.Errorf("no canonical method %q", old.name)
|
||||
}
|
||||
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 := canonical.skipValue(in, f); 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); 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)
|
||||
}
|
||||
}
|
||||
115
internal/compat/layerwire/inbound_test.go
Normal file
115
internal/compat/layerwire/inbound_test.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
184
internal/compat/layerwire/inbound_transform_test.go
Normal file
184
internal/compat/layerwire/inbound_transform_test.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
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("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")
|
||||
})
|
||||
}
|
||||
|
||||
// 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")
|
||||
})
|
||||
}
|
||||
204
internal/compat/layerwire/layout.go
Normal file
204
internal/compat/layerwire/layout.go
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// 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
|
||||
}
|
||||
4828
internal/compat/layerwire/schema/canonical-227.tl
Normal file
4828
internal/compat/layerwire/schema/canonical-227.tl
Normal file
File diff suppressed because it is too large
Load diff
28
internal/compat/layerwire/schema/client-drift.tl
Normal file
28
internal/compat/layerwire/schema/client-drift.tl
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// 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;
|
||||
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>;
|
||||
350
internal/compat/layerwire/tables.go
Normal file
350
internal/compat/layerwire/tables.go
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
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 int) 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 int) 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{}
|
||||
if err := lt.transcodeObject(in, out, layer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Len() != 0 {
|
||||
return nil, fmt.Errorf("layerwire: %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 int) error {
|
||||
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)
|
||||
}
|
||||
out.PutID(rule.target)
|
||||
return lt.transcodeBody(in, out, cl, rule.keep, layer)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !lt.dirty[id] {
|
||||
// Unaffected subtree: byte-for-byte copy.
|
||||
pre := in.Buf
|
||||
if err := canonical.skipObject(in); 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)
|
||||
}
|
||||
|
||||
// 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 int) 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, layer); err != nil {
|
||||
return fmt.Errorf("%s.%s: %w", cl.name, f.name, err)
|
||||
}
|
||||
} else if err := canonical.skipValue(in, f); 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, layer int) error {
|
||||
if !lt.fieldDirty(f) {
|
||||
pre := in.Buf
|
||||
if err := canonical.skipValue(in, f); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Put(pre[:len(pre)-len(in.Buf)])
|
||||
return nil
|
||||
}
|
||||
switch f.kind {
|
||||
case kindVector, kindVectorBare:
|
||||
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
|
||||
}
|
||||
out.PutInt(n)
|
||||
for i := 0; i < n; i++ {
|
||||
if err := lt.transcodeValue(in, out, f.elem, layer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case kindObject:
|
||||
return lt.transcodeObject(in, out, layer)
|
||||
case kindBareObject:
|
||||
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)
|
||||
default:
|
||||
// Primitive marked dirty should be impossible.
|
||||
return fmt.Errorf("unexpected dirty primitive kind %d", f.kind)
|
||||
}
|
||||
}
|
||||
388
internal/compat/layerwire/tables_gen.go
Normal file
388
internal/compat/layerwire/tables_gen.go
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
// 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
|
||||
}
|
||||
203
internal/compat/layerwire/transcode_test.go
Normal file
203
internal/compat/layerwire/transcode_test.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
116
internal/compat/layerwire/walk.go
Normal file
116
internal/compat/layerwire/walk.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package layerwire
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gotd/td/bin"
|
||||
)
|
||||
|
||||
// skipObject advances b past one boxed object (CRC + body), resolving the
|
||||
// constructor from the canonical schema.
|
||||
func (m *schemaModel) skipObject(b *bin.Buffer) error {
|
||||
id, err := b.PeekID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cl, ok := m.byCRC[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("layerwire: unknown constructor %#08x", id)
|
||||
}
|
||||
if err := b.ConsumeID(id); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.skipCtorBody(b, cl)
|
||||
}
|
||||
|
||||
// skipCtorBody advances b past a constructor body (no leading CRC), evaluating
|
||||
// flag integers so conditional fields are read iff present.
|
||||
func (m *schemaModel) skipCtorBody(b *bin.Buffer, cl *ctorLayout) error {
|
||||
var flags map[string]uint32
|
||||
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 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
|
||||
}
|
||||
if err := m.skipValue(b, f); 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 (m *schemaModel) skipValue(b *bin.Buffer, f *fieldLayout) error {
|
||||
switch f.kind {
|
||||
case kindInt:
|
||||
_, err := b.Int()
|
||||
return err
|
||||
case kindLong:
|
||||
_, err := b.Long()
|
||||
return err
|
||||
case kindDouble:
|
||||
_, err := b.Double()
|
||||
return err
|
||||
case kindInt128:
|
||||
_, err := b.Int128()
|
||||
return err
|
||||
case kindInt256:
|
||||
_, err := b.Int256()
|
||||
return err
|
||||
case kindBytes:
|
||||
_, err := b.Bytes()
|
||||
return err
|
||||
case kindString:
|
||||
_, err := b.String()
|
||||
return err
|
||||
case kindBool:
|
||||
_, err := b.Bool()
|
||||
return err
|
||||
case kindTrue:
|
||||
return nil
|
||||
case kindVector, kindVectorBare:
|
||||
if f.kind == kindVector {
|
||||
id, err := b.Uint32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id != vectorTypeID {
|
||||
return fmt.Errorf("expected vector id, got %#08x", id)
|
||||
}
|
||||
}
|
||||
n, err := b.Int()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n < 0 {
|
||||
return fmt.Errorf("negative vector length %d", n)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if err := m.skipValue(b, f.elem); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case kindObject:
|
||||
return m.skipObject(b)
|
||||
case kindBareObject:
|
||||
cl, ok := m.bareByT[f.typeName]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown bare type %q", f.typeName)
|
||||
}
|
||||
return m.skipCtorBody(b, cl)
|
||||
default:
|
||||
return fmt.Errorf("bad wire kind %d", f.kind)
|
||||
}
|
||||
}
|
||||
35
internal/compat/layerwire/walk_test.go
Normal file
35
internal/compat/layerwire/walk_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
281
internal/compat/tdesktop/appearance_seed.go
Normal file
281
internal/compat/tdesktop/appearance_seed.go
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
package tdesktop
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/seed/appearance"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
const appearanceSeedDCID = 2
|
||||
const maxPeerColorBoostLevel = 100
|
||||
|
||||
var peerColorOptionsCache = struct {
|
||||
regularOnce sync.Once
|
||||
profileOnce sync.Once
|
||||
regular []tg.HelpPeerColorOption
|
||||
profile []tg.HelpPeerColorOption
|
||||
}{}
|
||||
|
||||
func seedWallPapers() []tg.WallPaperClass {
|
||||
catalog := appearance.Default()
|
||||
out := make([]tg.WallPaperClass, 0, len(catalog.Wallpapers))
|
||||
for _, wallpaper := range catalog.Wallpapers {
|
||||
out = append(out, seedWallPaper(wallpaper))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupWallPaper resolves a cloud wallpaper from the default seed catalog.
|
||||
func LookupWallPaper(input tg.InputWallPaperClass) (tg.WallPaperClass, bool) {
|
||||
if in, ok := input.(*tg.InputWallPaperNoFile); ok {
|
||||
return &tg.WallPaperNoFile{ID: in.ID}, true
|
||||
}
|
||||
catalog := appearance.Default()
|
||||
for _, wallpaper := range catalog.Wallpapers {
|
||||
if inputWallPaperMatches(input, wallpaper) {
|
||||
return seedWallPaper(wallpaper), true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// LookupWallPapers resolves multiple wallpapers from the default seed catalog.
|
||||
func LookupWallPapers(inputs []tg.InputWallPaperClass) ([]tg.WallPaperClass, bool) {
|
||||
out := make([]tg.WallPaperClass, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
wallpaper, ok := LookupWallPaper(input)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, wallpaper)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func inputWallPaperMatches(input tg.InputWallPaperClass, wallpaper appearance.Wallpaper) bool {
|
||||
switch in := input.(type) {
|
||||
case *tg.InputWallPaper:
|
||||
return in.ID == wallpaper.ID && in.AccessHash == wallpaper.AccessHash
|
||||
case *tg.InputWallPaperSlug:
|
||||
return in.Slug != "" && in.Slug == wallpaper.Slug
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func seedWallPaper(in appearance.Wallpaper) tg.WallPaperClass {
|
||||
if in.Type == 1 || in.Document.ID == 0 {
|
||||
out := &tg.WallPaperNoFile{ID: in.ID}
|
||||
out.SetDefault(in.Default)
|
||||
out.SetDark(in.Dark)
|
||||
out.SetSettings(seedWallPaperSettings(in.Settings))
|
||||
return out
|
||||
}
|
||||
out := &tg.WallPaper{
|
||||
ID: in.ID,
|
||||
AccessHash: in.AccessHash,
|
||||
Slug: in.Slug,
|
||||
Document: seedDocument(in.Document),
|
||||
}
|
||||
out.SetDefault(in.Default)
|
||||
out.SetPattern(in.Pattern)
|
||||
out.SetDark(in.Dark)
|
||||
out.SetSettings(seedWallPaperSettings(in.Settings))
|
||||
return out
|
||||
}
|
||||
|
||||
func seedWallPaperSettings(in appearance.WallpaperSettings) tg.WallPaperSettings {
|
||||
var out tg.WallPaperSettings
|
||||
out.SetBlur(in.Blur)
|
||||
out.SetMotion(in.Motion)
|
||||
if in.BackgroundColor != 0 {
|
||||
out.SetBackgroundColor(in.BackgroundColor)
|
||||
}
|
||||
if in.SecondBackgroundColor != 0 {
|
||||
out.SetSecondBackgroundColor(in.SecondBackgroundColor)
|
||||
}
|
||||
if in.ThirdBackgroundColor != 0 {
|
||||
out.SetThirdBackgroundColor(in.ThirdBackgroundColor)
|
||||
}
|
||||
if in.FourthBackgroundColor != 0 {
|
||||
out.SetFourthBackgroundColor(in.FourthBackgroundColor)
|
||||
}
|
||||
if in.Intensity != 0 {
|
||||
out.SetIntensity(in.Intensity)
|
||||
}
|
||||
if in.Rotation != 0 {
|
||||
out.SetRotation(in.Rotation)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedDocument(in appearance.Document) tg.DocumentClass {
|
||||
if in.ID == 0 {
|
||||
return &tg.DocumentEmpty{}
|
||||
}
|
||||
return &tg.Document{
|
||||
ID: in.ID,
|
||||
AccessHash: in.AccessHash,
|
||||
Date: in.Date,
|
||||
MimeType: in.MimeType,
|
||||
Size: in.Size,
|
||||
Thumbs: seedPhotoSizes(in.Thumbs),
|
||||
DCID: appearanceSeedDCID,
|
||||
Attributes: seedDocumentAttributes(in.Attributes),
|
||||
FileReference: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func seedPhotoSizes(in []appearance.PhotoSize) []tg.PhotoSizeClass {
|
||||
out := make([]tg.PhotoSizeClass, 0, len(in))
|
||||
for _, size := range in {
|
||||
switch size.Kind {
|
||||
case "size":
|
||||
if size.Type != "" && size.W > 0 && size.H > 0 && size.Size > 0 {
|
||||
out = append(out, &tg.PhotoSize{Type: size.Type, W: size.W, H: size.H, Size: size.Size})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedDocumentAttributes(in []appearance.DocumentAttribute) []tg.DocumentAttributeClass {
|
||||
out := make([]tg.DocumentAttributeClass, 0, len(in))
|
||||
for _, attr := range in {
|
||||
switch attr.Kind {
|
||||
case "image_size":
|
||||
out = append(out, &tg.DocumentAttributeImageSize{W: attr.W, H: attr.H})
|
||||
case "filename":
|
||||
if attr.FileName != "" {
|
||||
out = append(out, &tg.DocumentAttributeFilename{FileName: attr.FileName})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func seedPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
|
||||
if profile {
|
||||
peerColorOptionsCache.profileOnce.Do(func() {
|
||||
peerColorOptionsCache.profile = buildSeedPeerColorOptions(true)
|
||||
})
|
||||
return clonePeerColorOptions(peerColorOptionsCache.profile)
|
||||
}
|
||||
peerColorOptionsCache.regularOnce.Do(func() {
|
||||
peerColorOptionsCache.regular = buildSeedPeerColorOptions(false)
|
||||
})
|
||||
return clonePeerColorOptions(peerColorOptionsCache.regular)
|
||||
}
|
||||
|
||||
func buildSeedPeerColorOptions(profile bool) []tg.HelpPeerColorOption {
|
||||
catalog := appearance.Default()
|
||||
source := catalog.PeerColors
|
||||
if profile {
|
||||
source = catalog.PeerProfileColors
|
||||
}
|
||||
out := make([]tg.HelpPeerColorOption, 0, len(source))
|
||||
for _, color := range source {
|
||||
option := tg.HelpPeerColorOption{ColorID: color.ID}
|
||||
option.SetHidden(color.Hidden)
|
||||
channelMin := boundedPeerColorMinLevel(color.ChannelMinLevel)
|
||||
if channelMin > 0 {
|
||||
option.SetChannelMinLevel(channelMin)
|
||||
}
|
||||
groupMin := boundedPeerColorMinLevel(color.GroupMinLevel)
|
||||
if groupMin == 0 && profile {
|
||||
groupMin = channelMin
|
||||
}
|
||||
if groupMin > 0 {
|
||||
option.SetGroupMinLevel(groupMin)
|
||||
}
|
||||
if colors := seedPeerColorSet(color.Colors); colors != nil {
|
||||
option.SetColors(colors)
|
||||
}
|
||||
if colors := seedPeerColorSet(color.DarkColors); colors != nil {
|
||||
option.SetDarkColors(colors)
|
||||
}
|
||||
out = append(out, option)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clonePeerColorOptions(in []tg.HelpPeerColorOption) []tg.HelpPeerColorOption {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]tg.HelpPeerColorOption, len(in))
|
||||
for i := range in {
|
||||
out[i] = in[i]
|
||||
if in[i].Colors != nil {
|
||||
out[i].Colors = clonePeerColorSet(in[i].Colors)
|
||||
}
|
||||
if in[i].DarkColors != nil {
|
||||
out[i].DarkColors = clonePeerColorSet(in[i].DarkColors)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func clonePeerColorSet(in tg.HelpPeerColorSetClass) tg.HelpPeerColorSetClass {
|
||||
switch set := in.(type) {
|
||||
case *tg.HelpPeerColorSet:
|
||||
return &tg.HelpPeerColorSet{Colors: append([]int(nil), set.Colors...)}
|
||||
case *tg.HelpPeerColorProfileSet:
|
||||
return &tg.HelpPeerColorProfileSet{
|
||||
PaletteColors: append([]int(nil), set.PaletteColors...),
|
||||
BgColors: append([]int(nil), set.BgColors...),
|
||||
StoryColors: append([]int(nil), set.StoryColors...),
|
||||
}
|
||||
default:
|
||||
return in
|
||||
}
|
||||
}
|
||||
|
||||
func boundedPeerColorMinLevel(level int) int {
|
||||
if level <= 0 {
|
||||
return 0
|
||||
}
|
||||
if level > maxPeerColorBoostLevel {
|
||||
return maxPeerColorBoostLevel
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func seedPeerColorID(id int, profile bool) (bool, bool) {
|
||||
catalog := appearance.Default()
|
||||
source := catalog.PeerColors
|
||||
if profile {
|
||||
source = catalog.PeerProfileColors
|
||||
}
|
||||
if len(source) == 0 {
|
||||
return false, false
|
||||
}
|
||||
for _, color := range source {
|
||||
if color.ID == id {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
func seedPeerColorSet(in *appearance.ColorSet) tg.HelpPeerColorSetClass {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
if len(in.PaletteColors) > 0 || len(in.BgColors) > 0 || len(in.StoryColors) > 0 {
|
||||
return &tg.HelpPeerColorProfileSet{
|
||||
PaletteColors: append([]int(nil), in.PaletteColors...),
|
||||
BgColors: append([]int(nil), in.BgColors...),
|
||||
StoryColors: append([]int(nil), in.StoryColors...),
|
||||
}
|
||||
}
|
||||
if len(in.Colors) > 0 {
|
||||
return &tg.HelpPeerColorSet{Colors: append([]int(nil), in.Colors...)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -16,9 +16,12 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
|
|||
Expires: int(now.Add(time.Hour).Unix()),
|
||||
TestMode: false,
|
||||
ThisDC: dc,
|
||||
DCOptions: []tg.DCOption{
|
||||
{ID: dc, IPAddress: ip, Port: port, Static: true},
|
||||
},
|
||||
// 不下发 DCOptions:客户端(TDesktop patch / drklo fork)已写死 static DC
|
||||
// 地址,空列表会让客户端保留它——drklo ConnectionsManager.cpp 的 processConfig
|
||||
// 在 dc_options 为空时整段跳过 replaceAddresses/saveConfig,既不覆盖也不持久化。
|
||||
// 服务端因此无需配置对外可达 IP,换网络/部署只改客户端写死地址即可。ip/port
|
||||
// 参数暂留,供未来需要显式 advertise 时改回。
|
||||
DCOptions: nil,
|
||||
ChatSizeMax: 200,
|
||||
MegagroupSizeMax: 200000,
|
||||
ForwardedCountMax: 100,
|
||||
|
|
@ -31,15 +34,20 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
|
|||
PushChatPeriodMs: 60000,
|
||||
PushChatLimit: 2,
|
||||
EditTimeLimit: 172800,
|
||||
RevokeTimeLimit: 172800,
|
||||
RevokePmTimeLimit: 172800,
|
||||
// 官方现行 revoke 三元组:无时限 + 允许撤回对方发来的私聊消息。
|
||||
// TDesktop 的私聊 "Also delete for X" 复选框要求
|
||||
// revoke_pm_inbox=true 且 revoke_pm_time_limit=0x7FFFFFFF,
|
||||
// 否则双向删除 UI 永不出现。
|
||||
RevokeTimeLimit: 2147483647,
|
||||
RevokePmTimeLimit: 2147483647,
|
||||
RevokePmInbox: true,
|
||||
RatingEDecay: 2419200,
|
||||
StickersRecentLimit: 200,
|
||||
CallReceiveTimeoutMs: 20000,
|
||||
CallRingTimeoutMs: 90000,
|
||||
CallConnectTimeoutMs: 30000,
|
||||
CallPacketTimeoutMs: 10000,
|
||||
MeURLPrefix: "https://t.me/",
|
||||
MeURLPrefix: "https://telesrv.net/",
|
||||
CaptionLengthMax: 1024,
|
||||
MessageLengthMax: 4096,
|
||||
WebfileDCID: dc,
|
||||
|
|
@ -49,7 +57,8 @@ func BuildConfig(dc int, ip string, port int, now time.Time) *tg.Config {
|
|||
// NearestDC 构造 help.getNearestDc 返回值。
|
||||
func NearestDC(dc int) *tg.NearestDC {
|
||||
return &tg.NearestDC{
|
||||
Country: "US",
|
||||
// 默认国家=中国:DrKLO/TDesktop 登录页据此预选区号(+86)。
|
||||
Country: "CN",
|
||||
ThisDC: dc,
|
||||
NearestDC: dc,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
package tdesktop
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"time"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
|
||||
"telesrv/internal/seed/catalog"
|
||||
)
|
||||
|
||||
const (
|
||||
appConfigHash = 4
|
||||
appConfigHash = 12 // app config 内容变更时必须递增,否则缓存端只会收到 notModified。
|
||||
countriesListHash = 1
|
||||
timezonesListHash = 1
|
||||
)
|
||||
|
|
@ -21,76 +19,149 @@ func AppConfig(hash int) tg.HelpAppConfigClass {
|
|||
}
|
||||
return &tg.HelpAppConfig{
|
||||
Hash: appConfigHash,
|
||||
Config: readMarkAppConfig(),
|
||||
Config: readMarkAppConfig(""),
|
||||
}
|
||||
}
|
||||
|
||||
func readMarkAppConfig() *tg.JSONObject {
|
||||
return &tg.JSONObject{Value: []tg.JSONObjectValue{
|
||||
func readMarkAppConfig(mapboxToken string) *tg.JSONObject {
|
||||
values := []tg.JSONObjectValue{
|
||||
{Key: "chat_read_mark_size_threshold", Value: &tg.JSONNumber{Value: 50}},
|
||||
{Key: "chat_read_mark_expire_period", Value: &tg.JSONNumber{Value: 604800}},
|
||||
{Key: "pm_read_date_expire_period", Value: &tg.JSONNumber{Value: 604800}},
|
||||
{Key: "quote_length_max", Value: &tg.JSONNumber{Value: 1024}},
|
||||
{Key: "telegram_antispam_group_size_min", Value: &tg.JSONNumber{Value: 200}},
|
||||
{Key: "telegram_antispam_user_id", Value: &tg.JSONString{Value: "5434988373"}},
|
||||
}}
|
||||
// premium_purchase_blocked=false:客户端把 star gift「Send a Gift」入口与
|
||||
// premiumCanBuy()=!premium_purchase_blocked 耦合,置 true 会同时隐藏送礼入口
|
||||
// (详见 app/help/service.go 主配置注释)。这里是无 HelpService 时的最小回退。
|
||||
{Key: "premium_purchase_blocked", Value: &tg.JSONBool{Value: false}},
|
||||
// stargifts_blocked=false:DrKLO 缺省 stargiftsBlocked=true 会隐藏 star gift 送礼网格。
|
||||
{Key: "stargifts_blocked", Value: &tg.JSONBool{Value: false}},
|
||||
{Key: "reactions_user_max_premium", Value: &tg.JSONNumber{Value: 3}},
|
||||
// dialog_filters_enabled=true:TDesktop 据此(或已有文件夹)才显示 Settings→Folders 入口。
|
||||
{Key: "dialog_filters_enabled", Value: &tg.JSONBool{Value: true}},
|
||||
{Key: "stories_stealth_future_period", Value: &tg.JSONNumber{Value: 1500}},
|
||||
{Key: "stories_stealth_past_period", Value: &tg.JSONNumber{Value: 300}},
|
||||
{Key: "stories_stealth_cooldown_period", Value: &tg.JSONNumber{Value: 10800}},
|
||||
// upload_markup_video=true 即官方默认(emoji/sticker 头像由客户端本地渲染 mp4 后随
|
||||
// markup 一起上传)。显式下发是为了把曾收到过 false 的客户端持久化配置洗回默认——
|
||||
// 客户端对缺失的 key 会保留本地旧值,仅删除 key 无法恢复。
|
||||
{Key: "upload_markup_video", Value: &tg.JSONBool{Value: true}},
|
||||
// 单 emoji 消息转 InputMediaDice 的白名单;须与 rpc 层 dice 取值表同步。
|
||||
{Key: "emojies_send_dice", Value: &tg.JSONArray{Value: []tg.JSONValueClass{
|
||||
&tg.JSONString{Value: "\U0001F3B2"}, // 🎲
|
||||
&tg.JSONString{Value: "\U0001F3AF"}, // 🎯
|
||||
&tg.JSONString{Value: "\U0001F3C0"}, // 🏀
|
||||
&tg.JSONString{Value: "⚽"},
|
||||
&tg.JSONString{Value: "⚽️"},
|
||||
&tg.JSONString{Value: "\U0001F3B3"}, // 🎳
|
||||
&tg.JSONString{Value: "\U0001F3B0"}, // 🎰
|
||||
}}},
|
||||
}
|
||||
if mapboxToken != "" {
|
||||
// TDesktop 位置选点器(WebView+Mapbox GL)与 business 位置设置的地图 token;
|
||||
// 缺失时选点器地图空白(瓦片直连 api.mapbox.com,不经服务端)。
|
||||
values = append(values, tg.JSONObjectValue{Key: "tdesktop_config_map", Value: &tg.JSONObject{Value: []tg.JSONObjectValue{
|
||||
{Key: "maps", Value: &tg.JSONString{Value: mapboxToken}},
|
||||
{Key: "geo", Value: &tg.JSONString{Value: mapboxToken}},
|
||||
{Key: "bmaps", Value: &tg.JSONString{Value: mapboxToken}},
|
||||
{Key: "bgeo", Value: &tg.JSONString{Value: mapboxToken}},
|
||||
}}})
|
||||
}
|
||||
return &tg.JSONObject{Value: values}
|
||||
}
|
||||
|
||||
// TimezonesList returns a small non-empty timezone set for TDesktop business settings preloading.
|
||||
// fallbackTimezones 是 catalog 未 seed 时的内置最小时区集。
|
||||
var fallbackTimezones = []tg.Timezone{
|
||||
{ID: "Etc/UTC", Name: "UTC", UtcOffset: 0},
|
||||
{ID: "America/New_York", Name: "Eastern Time", UtcOffset: -5 * 60 * 60},
|
||||
{ID: "America/Chicago", Name: "Central Time", UtcOffset: -6 * 60 * 60},
|
||||
{ID: "America/Denver", Name: "Mountain Time", UtcOffset: -7 * 60 * 60},
|
||||
{ID: "America/Los_Angeles", Name: "Pacific Time", UtcOffset: -8 * 60 * 60},
|
||||
{ID: "Asia/Shanghai", Name: "China Standard Time", UtcOffset: 8 * 60 * 60},
|
||||
}
|
||||
|
||||
// TimezonesList 返回时区目录:优先用 catalog 固化的官方全量(~419 个),未 seed 时回退内置最小集。
|
||||
func TimezonesList(hash int) tg.HelpTimezonesListClass {
|
||||
if hash == timezonesListHash {
|
||||
seeded, h := catalog.Timezones()
|
||||
src := seeded
|
||||
if len(src) == 0 {
|
||||
src, h = nil, timezonesListHash
|
||||
}
|
||||
if hash == h {
|
||||
return &tg.HelpTimezonesListNotModified{}
|
||||
}
|
||||
return &tg.HelpTimezonesList{
|
||||
Hash: timezonesListHash,
|
||||
Timezones: []tg.Timezone{
|
||||
{ID: "Etc/UTC", Name: "UTC", UtcOffset: 0},
|
||||
{ID: "America/New_York", Name: "Eastern Time", UtcOffset: -5 * 60 * 60},
|
||||
{ID: "America/Chicago", Name: "Central Time", UtcOffset: -6 * 60 * 60},
|
||||
{ID: "America/Denver", Name: "Mountain Time", UtcOffset: -7 * 60 * 60},
|
||||
{ID: "America/Los_Angeles", Name: "Pacific Time", UtcOffset: -8 * 60 * 60},
|
||||
{ID: "Asia/Shanghai", Name: "China Standard Time", UtcOffset: 8 * 60 * 60},
|
||||
},
|
||||
items := fallbackTimezones
|
||||
if len(src) > 0 {
|
||||
items = make([]tg.Timezone, 0, len(src))
|
||||
for _, t := range src {
|
||||
items = append(items, tg.Timezone{ID: t.ID, Name: t.Name, UtcOffset: t.UTCOffset})
|
||||
}
|
||||
}
|
||||
return &tg.HelpTimezonesList{Hash: h, Timezones: items}
|
||||
}
|
||||
|
||||
// SuggestedDialogFilters 返回新建对话文件夹时的「推荐文件夹」模板(官方固定语义:
|
||||
// Unread/Personal/Unmuted/Groups/Channels/Bots)。客户端据此一键创建对应规则的文件夹;
|
||||
// 模板 id 仅占位,用户采纳时客户端会以新 id 调 updateDialogFilter 真正创建。
|
||||
func SuggestedDialogFilters() []tg.DialogFilterSuggested {
|
||||
mk := func(id int, title, desc string, set func(*tg.DialogFilter)) tg.DialogFilterSuggested {
|
||||
df := &tg.DialogFilter{
|
||||
ID: id,
|
||||
Title: tg.TextWithEntities{Text: title, Entities: []tg.MessageEntityClass{}},
|
||||
PinnedPeers: []tg.InputPeerClass{},
|
||||
IncludePeers: []tg.InputPeerClass{},
|
||||
ExcludePeers: []tg.InputPeerClass{},
|
||||
}
|
||||
set(df)
|
||||
return tg.DialogFilterSuggested{Filter: df, Description: desc}
|
||||
}
|
||||
allTypes := func(d *tg.DialogFilter) {
|
||||
d.Contacts, d.NonContacts, d.Groups, d.Broadcasts, d.Bots = true, true, true, true, true
|
||||
}
|
||||
return []tg.DialogFilterSuggested{
|
||||
mk(2, "Unread", "New messages", func(d *tg.DialogFilter) { allTypes(d); d.ExcludeRead = true }),
|
||||
mk(3, "Personal", "Personal chats", func(d *tg.DialogFilter) { d.Contacts, d.NonContacts = true, true }),
|
||||
mk(4, "Unmuted", "Unmuted chats", func(d *tg.DialogFilter) { allTypes(d); d.ExcludeMuted = true }),
|
||||
mk(5, "Groups", "Group chats", func(d *tg.DialogFilter) { d.Groups = true }),
|
||||
mk(6, "Channels", "Channels only", func(d *tg.DialogFilter) { d.Broadcasts = true }),
|
||||
mk(7, "Bots", "Bot chats", func(d *tg.DialogFilter) { d.Bots = true }),
|
||||
}
|
||||
}
|
||||
|
||||
// CountriesList returns the fallback login country list used when HelpService is absent.
|
||||
// CountriesList 返回登录页国家区号目录:优先用 catalog 固化的官方全量(~235 国),未 seed
|
||||
// 时回退内置最小集(US/CN)。生产路径通常经 HelpService.GetCountries → defaultCountries
|
||||
// 走 catalog;此函数是 HelpService 缺省时的兜底。
|
||||
func CountriesList(hash int) tg.HelpCountriesListClass {
|
||||
if hash == countriesListHash {
|
||||
list := catalog.Countries()
|
||||
if len(list.Countries) == 0 {
|
||||
if hash == countriesListHash {
|
||||
return &tg.HelpCountriesListNotModified{}
|
||||
}
|
||||
return &tg.HelpCountriesList{
|
||||
Hash: countriesListHash,
|
||||
Countries: []tg.HelpCountry{
|
||||
{ISO2: "US", DefaultName: "United States", CountryCodes: []tg.HelpCountryCode{{CountryCode: "1", Prefixes: []string{""}, Patterns: []string{"XXX XXX XXXX"}}}},
|
||||
{ISO2: "CN", DefaultName: "China", CountryCodes: []tg.HelpCountryCode{{CountryCode: "86", Prefixes: []string{""}, Patterns: []string{"XXX XXXX XXXX"}}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
if hash == list.Hash {
|
||||
return &tg.HelpCountriesListNotModified{}
|
||||
}
|
||||
return &tg.HelpCountriesList{
|
||||
Hash: countriesListHash,
|
||||
Countries: []tg.HelpCountry{
|
||||
{
|
||||
ISO2: "US",
|
||||
DefaultName: "United States",
|
||||
CountryCodes: []tg.HelpCountryCode{
|
||||
{CountryCode: "1", Prefixes: []string{"1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
ISO2: "CN",
|
||||
DefaultName: "China",
|
||||
CountryCodes: []tg.HelpCountryCode{
|
||||
{CountryCode: "86", Prefixes: []string{"86"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoginToken returns a short-lived placeholder token for TDesktop's QR-login
|
||||
// screen. First phase only supports phone login, but TDesktop expects this RPC
|
||||
// to produce an auth.loginToken while the QR screen is visible.
|
||||
func LoginToken(now time.Time, authKeyID [8]byte, sessionID int64) *tg.AuthLoginToken {
|
||||
var seed [len(authKeyID) + 8 + 8]byte
|
||||
copy(seed[:len(authKeyID)], authKeyID[:])
|
||||
binary.LittleEndian.PutUint64(seed[len(authKeyID):], uint64(sessionID))
|
||||
binary.LittleEndian.PutUint64(seed[len(authKeyID)+8:], uint64(now.UnixNano()))
|
||||
token := sha256.Sum256(seed[:])
|
||||
return &tg.AuthLoginToken{
|
||||
Expires: int(now.Add(30 * time.Second).Unix()),
|
||||
Token: token[:],
|
||||
out := &tg.HelpCountriesList{Hash: list.Hash, Countries: make([]tg.HelpCountry, 0, len(list.Countries))}
|
||||
for _, c := range list.Countries {
|
||||
item := tg.HelpCountry{
|
||||
Hidden: c.Hidden,
|
||||
ISO2: c.ISO2,
|
||||
DefaultName: c.DefaultName,
|
||||
Name: c.Name,
|
||||
CountryCodes: make([]tg.HelpCountryCode, 0, len(c.CountryCodes)),
|
||||
}
|
||||
for _, cc := range c.CountryCodes {
|
||||
item.CountryCodes = append(item.CountryCodes, tg.HelpCountryCode{CountryCode: cc.CountryCode, Prefixes: cc.Prefixes, Patterns: cc.Patterns})
|
||||
}
|
||||
out.Countries = append(out.Countries, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
196
internal/compat/tdesktop/dialogs.go
Normal file
196
internal/compat/tdesktop/dialogs.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package tdesktop
|
||||
|
||||
import "telesrv/internal/domain"
|
||||
|
||||
// ShouldMergePinnedIntoInitialDialogs reports whether a TDesktop main-list
|
||||
// messages.getDialogs request should carry pinned/archive rows inline.
|
||||
//
|
||||
// TDesktop starts the main list with getDialogs(exclude_pinned=true) and loads
|
||||
// pinned rows through getPinnedDialogs in parallel. If the exclude_pinned result
|
||||
// is rendered first, the first visible list temporarily lacks pinned/archive
|
||||
// rows. Keep the workaround scoped to the first main-folder page with no hash so
|
||||
// pagination and cache probes retain normal Telegram semantics.
|
||||
func ShouldMergePinnedIntoInitialDialogs(filter domain.DialogFilter) bool {
|
||||
if !filter.ExcludePinned || filter.Hash != 0 {
|
||||
return false
|
||||
}
|
||||
if filter.HasFolderID && filter.FolderID != domain.DialogMainFolderID {
|
||||
return false
|
||||
}
|
||||
if filter.OffsetID != 0 || filter.OffsetDate != 0 || filter.HasOffsetPeer {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MergeInitialDialogsWithPinned prepends the pinned getPinnedDialogs view to an
|
||||
// initial getDialogs(exclude_pinned=true) main-list response. The returned list
|
||||
// remains a normal messages.dialogs/messages.dialogsSlice projection; only the
|
||||
// first response carries extra pinned/archive rows for TDesktop startup.
|
||||
func MergeInitialDialogsWithPinned(main, pinned domain.DialogList) domain.DialogList {
|
||||
if pinned.ArchiveSummary == nil && len(pinned.Dialogs) == 0 {
|
||||
return main
|
||||
}
|
||||
out := main
|
||||
out.ArchiveSummary = pinned.ArchiveSummary
|
||||
out.Dialogs = mergeDialogsByPeer(pinned.Dialogs, main.Dialogs)
|
||||
out.Messages = mergePrivateMessages(pinned.Messages, main.Messages)
|
||||
out.ChannelMessages = mergeChannelMessages(pinned.ChannelMessages, main.ChannelMessages)
|
||||
out.Users = mergeUsersByID(pinned.Users, main.Users)
|
||||
out.Channels = mergeChannelsByID(pinned.Channels, main.Channels)
|
||||
pinnedEntries := len(pinned.Dialogs)
|
||||
if pinned.ArchiveSummary != nil {
|
||||
pinnedEntries++
|
||||
}
|
||||
mainCount := main.Count
|
||||
if mainCount == 0 {
|
||||
mainCount = len(main.Dialogs)
|
||||
}
|
||||
out.Count = mainCount + pinnedEntries
|
||||
out.Hash = 0
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeDialogsByPeer(first, second []domain.Dialog) []domain.Dialog {
|
||||
out := make([]domain.Dialog, 0, len(first)+len(second))
|
||||
seen := make(map[domain.Peer]struct{}, len(first)+len(second))
|
||||
for _, d := range first {
|
||||
if d.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[d.Peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[d.Peer] = struct{}{}
|
||||
out = append(out, d)
|
||||
}
|
||||
for _, d := range second {
|
||||
if d.Peer.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[d.Peer]; ok {
|
||||
continue
|
||||
}
|
||||
seen[d.Peer] = struct{}{}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type privateMessageKey struct {
|
||||
peer domain.Peer
|
||||
id int
|
||||
}
|
||||
|
||||
func mergePrivateMessages(first, second []domain.Message) []domain.Message {
|
||||
out := make([]domain.Message, 0, len(first)+len(second))
|
||||
seen := make(map[privateMessageKey]struct{}, len(first)+len(second))
|
||||
for _, msg := range first {
|
||||
key := privateMessageKey{peer: msg.Peer, id: msg.ID}
|
||||
if key.peer.ID == 0 || key.id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, msg)
|
||||
}
|
||||
for _, msg := range second {
|
||||
key := privateMessageKey{peer: msg.Peer, id: msg.ID}
|
||||
if key.peer.ID == 0 || key.id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type channelMessageKey struct {
|
||||
channelID int64
|
||||
id int
|
||||
}
|
||||
|
||||
func mergeChannelMessages(first, second []domain.ChannelMessage) []domain.ChannelMessage {
|
||||
out := make([]domain.ChannelMessage, 0, len(first)+len(second))
|
||||
seen := make(map[channelMessageKey]struct{}, len(first)+len(second))
|
||||
for _, msg := range first {
|
||||
key := channelMessageKey{channelID: msg.ChannelID, id: msg.ID}
|
||||
if key.channelID == 0 || key.id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, msg)
|
||||
}
|
||||
for _, msg := range second {
|
||||
key := channelMessageKey{channelID: msg.ChannelID, id: msg.ID}
|
||||
if key.channelID == 0 || key.id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeUsersByID(first, second []domain.User) []domain.User {
|
||||
out := make([]domain.User, 0, len(first)+len(second))
|
||||
seen := make(map[int64]struct{}, len(first)+len(second))
|
||||
for _, user := range first {
|
||||
if user.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[user.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[user.ID] = struct{}{}
|
||||
out = append(out, user)
|
||||
}
|
||||
for _, user := range second {
|
||||
if user.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[user.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[user.ID] = struct{}{}
|
||||
out = append(out, user)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeChannelsByID(first, second []domain.Channel) []domain.Channel {
|
||||
out := make([]domain.Channel, 0, len(first)+len(second))
|
||||
seen := make(map[int64]struct{}, len(first)+len(second))
|
||||
for _, channel := range first {
|
||||
if channel.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[channel.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[channel.ID] = struct{}{}
|
||||
out = append(out, channel)
|
||||
}
|
||||
for _, channel := range second {
|
||||
if channel.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[channel.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[channel.ID] = struct{}{}
|
||||
out = append(out, channel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
|
||||
"telesrv/internal/seed/appearance"
|
||||
"telesrv/internal/seed/catalog"
|
||||
)
|
||||
|
||||
// NotifySettings returns default per-peer notification settings for empty first-phase accounts.
|
||||
|
|
@ -23,17 +26,6 @@ func NotifySettings() *tg.PeerNotifySettings {
|
|||
return settings
|
||||
}
|
||||
|
||||
// ReactionsNotifySettings returns conservative defaults for reaction notifications.
|
||||
func ReactionsNotifySettings() *tg.ReactionsNotifySettings {
|
||||
return &tg.ReactionsNotifySettings{
|
||||
MessagesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
|
||||
StoriesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
|
||||
PollVotesNotifyFrom: &tg.ReactionNotificationsFromContacts{},
|
||||
Sound: &tg.NotificationSoundDefault{},
|
||||
ShowPreviews: true,
|
||||
}
|
||||
}
|
||||
|
||||
func PrivacyRules(key tg.InputPrivacyKeyClass) *tg.AccountPrivacyRules {
|
||||
var rule tg.PrivacyRuleClass = &tg.PrivacyValueAllowAll{}
|
||||
switch key.(type) {
|
||||
|
|
@ -61,16 +53,176 @@ func Passkeys() *tg.AccountPasskeys {
|
|||
return &tg.AccountPasskeys{Passkeys: []tg.Passkey{}}
|
||||
}
|
||||
|
||||
func ContentSettings() *tg.AccountContentSettings {
|
||||
return &tg.AccountContentSettings{}
|
||||
}
|
||||
|
||||
func GlobalPrivacySettings() *tg.GlobalPrivacySettings {
|
||||
return &tg.GlobalPrivacySettings{}
|
||||
}
|
||||
|
||||
func AccountThemes() tg.AccountThemesClass {
|
||||
return &tg.AccountThemesNotModified{}
|
||||
const chatThemesHash int64 = 2026062501
|
||||
const uniqueGiftChatThemesHash int64 = 2026061201
|
||||
const wallPapersHash int64 = 2026062502
|
||||
const peerColorsHash = 2026061202
|
||||
const peerProfileColorsHash = 2026062801
|
||||
|
||||
// IsChatThemeEmoticon reports whether token is a known official chat theme emoticon.
|
||||
func IsChatThemeEmoticon(token string) bool {
|
||||
for _, ct := range appearance.Default().ChatThemes {
|
||||
if ct.Emoticon == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ChatThemes(hash int64) tg.AccountThemesClass {
|
||||
if hash == chatThemesHash {
|
||||
return &tg.AccountThemesNotModified{}
|
||||
}
|
||||
return &tg.AccountThemes{
|
||||
Hash: chatThemesHash,
|
||||
Themes: chatThemeList(),
|
||||
}
|
||||
}
|
||||
|
||||
// chatThemeList 用官方外观 catalog(appearancefetch 导出)的聊天主题。
|
||||
func chatThemeList() []tg.Theme {
|
||||
return catalogChatThemes(appearance.Default().ChatThemes)
|
||||
}
|
||||
|
||||
// catalogChatThemes 把官方聊天主题转成 tg.Theme。官方每个主题只带 classic+tinted 两组
|
||||
// settings,而 DrKLO 选择器丢弃 base-theme 设置 <4 的主题
|
||||
// (MediaDataController.generateEmojiPreviewThemes),故扩展到 telesrv 宣告的 5 个 base:
|
||||
// 浅色 base(classic/day/arctic)复用官方浅色设置,深色 base(tinted/night)复用官方深色设置。
|
||||
func catalogChatThemes(cts []appearance.ChatTheme) []tg.Theme {
|
||||
out := make([]tg.Theme, 0, len(cts))
|
||||
for i, ct := range cts {
|
||||
var light, dark *appearance.ThemeSettings
|
||||
for j := range ct.Settings {
|
||||
s := &ct.Settings[j]
|
||||
if s.BaseTheme == "tinted" || s.BaseTheme == "night" {
|
||||
if dark == nil {
|
||||
dark = s
|
||||
}
|
||||
} else if light == nil {
|
||||
light = s
|
||||
}
|
||||
}
|
||||
if light == nil {
|
||||
light = dark
|
||||
}
|
||||
if dark == nil {
|
||||
dark = light
|
||||
}
|
||||
if light == nil {
|
||||
continue
|
||||
}
|
||||
theme := tg.Theme{ID: ct.ID, AccessHash: ct.AccessHash, Slug: ct.Slug, Title: ct.Title}
|
||||
theme.SetForChat(true)
|
||||
if ct.Emoticon != "" {
|
||||
theme.SetEmoticon(ct.Emoticon)
|
||||
}
|
||||
theme.SetInstallsCount(1)
|
||||
settings := make([]tg.ThemeSettings, 0, len(chatThemeBaseThemes))
|
||||
for _, b := range chatThemeBaseThemes {
|
||||
src := light
|
||||
if b.dark {
|
||||
src = dark
|
||||
}
|
||||
settings = append(settings, catalogThemeSettings(*src, b.make()))
|
||||
}
|
||||
theme.SetSettings(settings)
|
||||
if i == 0 || ct.Default {
|
||||
theme.SetDefault(true)
|
||||
}
|
||||
out = append(out, theme)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func catalogThemeSettings(s appearance.ThemeSettings, base tg.BaseThemeClass) tg.ThemeSettings {
|
||||
ts := tg.ThemeSettings{BaseTheme: base, AccentColor: s.AccentColor}
|
||||
if s.OutboxAccentColor != 0 {
|
||||
ts.SetOutboxAccentColor(s.OutboxAccentColor)
|
||||
}
|
||||
if len(s.MessageColors) > 0 {
|
||||
ts.SetMessageColors(append([]int(nil), s.MessageColors...))
|
||||
}
|
||||
if s.Wallpaper.ID != 0 || s.Wallpaper.Document.ID != 0 {
|
||||
ts.SetWallpaper(seedWallPaper(s.Wallpaper))
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// DefaultThemes serves the built-in chat theme catalog for account.getThemes.
|
||||
//
|
||||
// DrKLO's "Color theme" preview strip on the Chat Settings page
|
||||
// (DefaultThemesPreviewCell) is populated exclusively from account.getThemes
|
||||
// results flagged is_default=true — it does not consult account.getChatThemes.
|
||||
// A fresh client has no cached emoji themes and therefore sends getThemes with
|
||||
// hash 0; returning themesNotModified to that request leaves defaultEmojiThemes
|
||||
// empty, so the strip stays stuck on its FlickerLoadingView shimmer forever.
|
||||
// We return the same catalog as ChatThemes with every entry marked default so
|
||||
// the strip resolves into real preview cards.
|
||||
func DefaultThemes(hash int64) tg.AccountThemesClass {
|
||||
if hash == chatThemesHash {
|
||||
return &tg.AccountThemesNotModified{}
|
||||
}
|
||||
return &tg.AccountThemes{
|
||||
Hash: chatThemesHash,
|
||||
Themes: DefaultThemeList(),
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultThemeList 返回内置默认主题切片(全标 is_default=true)。account.getThemes
|
||||
// 把它与用户自定义主题合并(rpc 层),故单独暴露列表。
|
||||
func DefaultThemeList() []tg.Theme {
|
||||
themes := chatThemeList()
|
||||
for i := range themes {
|
||||
themes[i].SetDefault(true)
|
||||
}
|
||||
return themes
|
||||
}
|
||||
|
||||
func UniqueGiftChatThemes(hash int64) tg.AccountChatThemesClass {
|
||||
if hash == uniqueGiftChatThemesHash {
|
||||
return &tg.AccountChatThemesNotModified{}
|
||||
}
|
||||
return &tg.AccountChatThemes{
|
||||
Hash: uniqueGiftChatThemesHash,
|
||||
Themes: []tg.ChatThemeClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
// WallPapers returns the read-only default wallpaper catalog. User wallpaper
|
||||
// upload/save/install remains outside the current TDesktop compatibility scope.
|
||||
func WallPapers(hash int64) tg.AccountWallPapersClass {
|
||||
if hash == wallPapersHash {
|
||||
return &tg.AccountWallPapersNotModified{}
|
||||
}
|
||||
wallpapers := seedWallPapers()
|
||||
return &tg.AccountWallPapers{
|
||||
Hash: wallPapersHash,
|
||||
Wallpapers: wallpapers,
|
||||
}
|
||||
}
|
||||
|
||||
// chatThemeBaseThemes enumerates the base themes every emoji chat theme ships
|
||||
// settings for. The client matches a chat theme's settings to whichever base
|
||||
// theme the user currently runs, and DrKLO's theme picker only surfaces a chat
|
||||
// theme whose settings cover at least four base themes
|
||||
// (MediaDataController.generateEmojiPreviewThemes drops anything whose
|
||||
// items.size() < 4, one item per ThemeSettings). dark selects the bubble/accent
|
||||
// palette used for that base theme.
|
||||
var chatThemeBaseThemes = []struct {
|
||||
make func() tg.BaseThemeClass
|
||||
dark bool
|
||||
}{
|
||||
{func() tg.BaseThemeClass { return &tg.BaseThemeClassic{} }, false},
|
||||
{func() tg.BaseThemeClass { return &tg.BaseThemeDay{} }, false},
|
||||
{func() tg.BaseThemeClass { return &tg.BaseThemeArctic{} }, false},
|
||||
{func() tg.BaseThemeClass { return &tg.BaseThemeTinted{} }, true},
|
||||
{func() tg.BaseThemeClass { return &tg.BaseThemeNight{} }, true},
|
||||
}
|
||||
|
||||
func AutoDownloadSettings() *tg.AccountAutoDownloadSettings {
|
||||
|
|
@ -101,10 +253,6 @@ func DefaultGroupPhotoEmojis() tg.EmojiListClass {
|
|||
return &tg.EmojiList{Hash: 0, DocumentID: []int64{}}
|
||||
}
|
||||
|
||||
func ConnectedBots() *tg.AccountConnectedBots {
|
||||
return &tg.AccountConnectedBots{ConnectedBots: []tg.ConnectedBot{}, Users: []tg.UserClass{}}
|
||||
}
|
||||
|
||||
const availableReactionsHash = 20260602
|
||||
const emptyStickerSetHash = 20260602
|
||||
|
||||
|
|
@ -161,10 +309,7 @@ func availableReaction(reaction defaultReaction, index int) tg.AvailableReaction
|
|||
}
|
||||
}
|
||||
|
||||
func Stickers() tg.MessagesStickersClass {
|
||||
return &tg.MessagesStickersNotModified{}
|
||||
}
|
||||
|
||||
// Stickers 返回空的全量 messages.stickers。禁止回 stickersNotModified:DrKLO 对
|
||||
func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass {
|
||||
if req != nil && req.Hash == emptyStickerSetHash {
|
||||
return &tg.MessagesStickerSetNotModified{}
|
||||
|
|
@ -206,8 +351,23 @@ func StickerSet(req *tg.MessagesGetStickerSetRequest) tg.MessagesStickerSetClass
|
|||
}
|
||||
}
|
||||
|
||||
func EmojiGroups() tg.MessagesEmojiGroupsClass {
|
||||
return &tg.MessagesEmojiGroupsNotModified{}
|
||||
// EmojiGroups 返回 emoji 分类目录(客户端 emoji 面板顶部的主题快捷标签)。优先用 catalog
|
||||
// 固化的官方分组,未 seed 时回 NotModified(客户端保留本地/无分组)。icon_emoji_id 指向
|
||||
// custom-emoji 文档,本地缺该文档时分类图标回退占位,不影响分组与按词搜索。
|
||||
func EmojiGroups(hash int) tg.MessagesEmojiGroupsClass {
|
||||
groups, h := catalog.EmojiGroups()
|
||||
if len(groups) == 0 || hash == h {
|
||||
return &tg.MessagesEmojiGroupsNotModified{}
|
||||
}
|
||||
out := make([]tg.EmojiGroupClass, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, &tg.EmojiGroup{Title: g.Title, IconEmojiID: g.IconEmojiID, Emoticons: g.Emoticons})
|
||||
}
|
||||
return &tg.MessagesEmojiGroups{Hash: h, Groups: out}
|
||||
}
|
||||
|
||||
func EmojiStatusGroups() tg.MessagesEmojiGroupsClass {
|
||||
return &tg.MessagesEmojiGroups{Hash: 0, Groups: []tg.EmojiGroupClass{}}
|
||||
}
|
||||
|
||||
func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass {
|
||||
|
|
@ -215,11 +375,20 @@ func EmojiProfilePhotoGroups() tg.MessagesEmojiGroupsClass {
|
|||
}
|
||||
|
||||
func AttachMenuBots() tg.AttachMenuBotsClass {
|
||||
return &tg.AttachMenuBotsNotModified{}
|
||||
return &tg.AttachMenuBots{
|
||||
Hash: 0,
|
||||
Bots: []tg.AttachMenuBot{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func QuickReplies() tg.MessagesQuickRepliesClass {
|
||||
return &tg.MessagesQuickRepliesNotModified{}
|
||||
return &tg.MessagesQuickReplies{
|
||||
QuickReplies: []tg.QuickReply{},
|
||||
Messages: []tg.MessageClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func TopPeers() tg.ContactsTopPeersClass {
|
||||
|
|
@ -234,8 +403,93 @@ func BlockedContacts() tg.ContactsBlockedClass {
|
|||
}
|
||||
}
|
||||
|
||||
func PeerColors() tg.HelpPeerColorsClass {
|
||||
return &tg.HelpPeerColorsNotModified{}
|
||||
type defaultPeerColor struct {
|
||||
id int
|
||||
light []int
|
||||
dark []int
|
||||
bg []int
|
||||
story []int
|
||||
hidden bool
|
||||
}
|
||||
|
||||
var defaultPeerColors = []defaultPeerColor{
|
||||
{id: 0, light: []int{0xcc4d4d}, dark: []int{0xff7a7a}, bg: []int{0xffd6d6, 0xfff0f0}, story: []int{0xff7a7a, 0xf24b4b}},
|
||||
{id: 1, light: []int{0xd97829}, dark: []int{0xffa15c}, bg: []int{0xffdfbd, 0xfff2e2}, story: []int{0xffa15c, 0xf07a2b}},
|
||||
{id: 2, light: []int{0x8756d9}, dark: []int{0xb38cff}, bg: []int{0xe3d8ff, 0xf4eeff}, story: []int{0xb38cff, 0x8756d9}},
|
||||
{id: 3, light: []int{0x3ca66b}, dark: []int{0x74d99b}, bg: []int{0xd5f5df, 0xedfff2}, story: []int{0x74d99b, 0x35a86a}},
|
||||
{id: 4, light: []int{0x2d9fc6}, dark: []int{0x6fd8f5}, bg: []int{0xd4f4ff, 0xf0fbff}, story: []int{0x6fd8f5, 0x2d9fc6}},
|
||||
{id: 5, light: []int{0x4a86d9}, dark: []int{0x78aef5}, bg: []int{0xd8e8ff, 0xf2f7ff}, story: []int{0x78aef5, 0x4a86d9}},
|
||||
{id: 6, light: []int{0xd85b93}, dark: []int{0xff8abb}, bg: []int{0xffd7e8, 0xfff5fb}, story: []int{0xff8abb, 0xd85b93}},
|
||||
{id: 7, light: []int{0x5f6a7a, 0x8c96a6}, dark: []int{0x9aa4b4, 0xc0c8d4}, bg: []int{0xe3e7ee, 0xf7f9fc}, story: []int{0x9aa4b4, 0x6f7b8d}},
|
||||
}
|
||||
|
||||
// IsPeerColorID reports whether id is in the TDesktop-compatible peer color palette.
|
||||
func IsPeerColorID(id int) bool {
|
||||
if found, seeded := seedPeerColorID(id, false); seeded {
|
||||
return found
|
||||
}
|
||||
for _, color := range defaultPeerColors {
|
||||
if color.id == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsPeerProfileColorID reports whether id is in the profile background palette.
|
||||
func IsPeerProfileColorID(id int) bool {
|
||||
if found, seeded := seedPeerColorID(id, true); seeded {
|
||||
return found
|
||||
}
|
||||
return IsPeerColorID(id)
|
||||
}
|
||||
|
||||
func PeerColors(hash int) tg.HelpPeerColorsClass {
|
||||
if hash == peerColorsHash {
|
||||
return &tg.HelpPeerColorsNotModified{}
|
||||
}
|
||||
colors := seedPeerColorOptions(false)
|
||||
if len(colors) == 0 {
|
||||
colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors))
|
||||
for _, color := range defaultPeerColors {
|
||||
option := tg.HelpPeerColorOption{ColorID: color.id}
|
||||
option.SetColors(&tg.HelpPeerColorSet{Colors: append([]int(nil), color.light...)})
|
||||
option.SetDarkColors(&tg.HelpPeerColorSet{Colors: append([]int(nil), color.dark...)})
|
||||
if color.hidden {
|
||||
option.SetHidden(true)
|
||||
}
|
||||
colors = append(colors, option)
|
||||
}
|
||||
}
|
||||
return &tg.HelpPeerColors{Hash: peerColorsHash, Colors: colors}
|
||||
}
|
||||
|
||||
func PeerProfileColors(hash int) tg.HelpPeerColorsClass {
|
||||
if hash == peerProfileColorsHash {
|
||||
return &tg.HelpPeerColorsNotModified{}
|
||||
}
|
||||
colors := seedPeerColorOptions(true)
|
||||
if len(colors) == 0 {
|
||||
colors = make([]tg.HelpPeerColorOption, 0, len(defaultPeerColors))
|
||||
for _, color := range defaultPeerColors {
|
||||
option := tg.HelpPeerColorOption{ColorID: color.id}
|
||||
option.SetColors(&tg.HelpPeerColorProfileSet{
|
||||
PaletteColors: append([]int(nil), color.light...),
|
||||
BgColors: append([]int(nil), color.bg...),
|
||||
StoryColors: append([]int(nil), color.story...),
|
||||
})
|
||||
option.SetDarkColors(&tg.HelpPeerColorProfileSet{
|
||||
PaletteColors: append([]int(nil), color.dark...),
|
||||
BgColors: append([]int(nil), color.bg...),
|
||||
StoryColors: append([]int(nil), color.story...),
|
||||
})
|
||||
if color.hidden {
|
||||
option.SetHidden(true)
|
||||
}
|
||||
colors = append(colors, option)
|
||||
}
|
||||
}
|
||||
return &tg.HelpPeerColors{Hash: peerProfileColorsHash, Colors: colors}
|
||||
}
|
||||
|
||||
func PromoData(now time.Time) tg.HelpPromoDataClass {
|
||||
|
|
@ -246,17 +500,6 @@ func TermsOfServiceUpdate(now time.Time) tg.HelpTermsOfServiceUpdateClass {
|
|||
return &tg.HelpTermsOfServiceUpdateEmpty{Expires: int(now.Add(24 * time.Hour).Unix())}
|
||||
}
|
||||
|
||||
func PremiumPromo() *tg.HelpPremiumPromo {
|
||||
return &tg.HelpPremiumPromo{}
|
||||
}
|
||||
|
||||
func AllStories() tg.StoriesAllStoriesClass {
|
||||
return &tg.StoriesAllStories{
|
||||
State: "",
|
||||
StealthMode: tg.StoriesStealthMode{},
|
||||
}
|
||||
}
|
||||
|
||||
func StoriesArchive() *tg.StoriesStories {
|
||||
return &tg.StoriesStories{}
|
||||
}
|
||||
|
|
@ -273,6 +516,14 @@ func StarGiftActiveAuctions() tg.PaymentsStarGiftActiveAuctionsClass {
|
|||
return &tg.PaymentsStarGiftActiveAuctionsNotModified{}
|
||||
}
|
||||
|
||||
func StarGifts() tg.PaymentsStarGiftsClass {
|
||||
return &tg.PaymentsStarGifts{
|
||||
Gifts: []tg.StarGiftClass{},
|
||||
Chats: []tg.ChatClass{},
|
||||
Users: []tg.UserClass{},
|
||||
}
|
||||
}
|
||||
|
||||
func SavedStarGifts() *tg.PaymentsSavedStarGifts {
|
||||
return &tg.PaymentsSavedStarGifts{
|
||||
Gifts: []tg.SavedStarGift{},
|
||||
|
|
@ -281,6 +532,29 @@ func SavedStarGifts() *tg.PaymentsSavedStarGifts {
|
|||
}
|
||||
}
|
||||
|
||||
func StarGiftCollections() tg.PaymentsStarGiftCollectionsClass {
|
||||
return &tg.PaymentsStarGiftCollections{
|
||||
Collections: []tg.StarGiftCollection{},
|
||||
}
|
||||
}
|
||||
|
||||
func StarsRevenueStats(ton bool) *tg.PaymentsStarsRevenueStats {
|
||||
zeroAmount := tg.StarsAmountClass(&tg.StarsAmount{})
|
||||
if ton {
|
||||
zeroAmount = &tg.StarsTonAmount{}
|
||||
}
|
||||
return &tg.PaymentsStarsRevenueStats{
|
||||
RevenueGraph: &tg.StatsGraphError{Error: "Not enough data to display."},
|
||||
Status: tg.StarsRevenueStatus{
|
||||
CurrentBalance: zeroAmount,
|
||||
AvailableBalance: zeroAmount,
|
||||
OverallRevenue: zeroAmount,
|
||||
WithdrawalEnabled: false,
|
||||
},
|
||||
UsdRate: 0.013,
|
||||
}
|
||||
}
|
||||
|
||||
func AiComposeTones() tg.AicomposeTonesClass {
|
||||
return &tg.AicomposeTonesNotModified{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package tdesktop
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
|
|
@ -32,6 +33,48 @@ func TestTimezonesListIsNonEmptyAndHashable(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAppConfigIncludesStoryStealthPeriods(t *testing.T) {
|
||||
got, ok := AppConfig(0).(*tg.HelpAppConfig)
|
||||
if !ok || got.Hash == 0 {
|
||||
t.Fatalf("AppConfig(0) = %#v, want modified config with hash", got)
|
||||
}
|
||||
values := make(map[string]float64)
|
||||
if object, ok := got.Config.(*tg.JSONObject); ok && object != nil {
|
||||
for _, entry := range object.Value {
|
||||
if number, ok := entry.Value.(*tg.JSONNumber); ok {
|
||||
values[entry.Key] = number.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
want := map[string]float64{
|
||||
"stories_stealth_future_period": 1500,
|
||||
"stories_stealth_past_period": 300,
|
||||
"stories_stealth_cooldown_period": 10800,
|
||||
}
|
||||
for key, expected := range want {
|
||||
if values[key] != expected {
|
||||
t.Fatalf("AppConfig[%q] = %v, want %v", key, values[key], expected)
|
||||
}
|
||||
}
|
||||
if _, ok := AppConfig(got.Hash).(*tg.HelpAppConfigNotModified); !ok {
|
||||
t.Fatalf("AppConfig(hash) = %#v, want notModified", AppConfig(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackAppConfigOmitsMapboxToken(t *testing.T) {
|
||||
got, ok := AppConfig(0).(*tg.HelpAppConfig)
|
||||
if !ok {
|
||||
t.Fatalf("AppConfig(0) = %T, want modified config", got)
|
||||
}
|
||||
if object, ok := got.Config.(*tg.JSONObject); ok && object != nil {
|
||||
for _, entry := range object.Value {
|
||||
if entry.Key == "tdesktop_config_map" {
|
||||
t.Fatal("fallback AppConfig leaked tdesktop_config_map without runtime token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) {
|
||||
got, ok := AvailableReactions(0).(*tg.MessagesAvailableReactions)
|
||||
if !ok {
|
||||
|
|
@ -66,6 +109,195 @@ func TestAvailableReactionsCatalogIsNonEmptyAndHashable(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestChatThemesCatalogIsNonEmptyHashableAndHasQRBackgroundColors(t *testing.T) {
|
||||
got, ok := ChatThemes(0).(*tg.AccountThemes)
|
||||
if !ok {
|
||||
t.Fatalf("ChatThemes(0) = %T, want modified list", got)
|
||||
}
|
||||
if got.Hash == 0 {
|
||||
t.Fatal("ChatThemes(0).Hash = 0, want stable cache hash")
|
||||
}
|
||||
if len(got.Themes) == 0 {
|
||||
t.Fatal("ChatThemes(0).Themes is empty")
|
||||
}
|
||||
for i, theme := range got.Themes {
|
||||
if !theme.GetForChat() {
|
||||
t.Fatalf("theme[%d].for_chat = false, want true", i)
|
||||
}
|
||||
if emoticon, ok := theme.GetEmoticon(); !ok || emoticon == "" || !IsChatThemeEmoticon(emoticon) {
|
||||
t.Fatalf("theme[%d].emoticon = %q ok=%v, want supported token", i, emoticon, ok)
|
||||
}
|
||||
settings, ok := theme.GetSettings()
|
||||
// DrKLO only surfaces an emoji chat theme in the picker when it ships
|
||||
// settings for at least four base themes
|
||||
// (MediaDataController.generateEmojiPreviewThemes drops items.size() < 4,
|
||||
// one item per ThemeSettings); fewer leaves the strip stuck on its
|
||||
// loading shimmer.
|
||||
if !ok || len(settings) < 4 {
|
||||
t.Fatalf("theme[%d].settings = %#v ok=%v, want >=4 base-theme settings", i, settings, ok)
|
||||
}
|
||||
var hasDay, hasNight bool
|
||||
for j, setting := range settings {
|
||||
if setting.BaseTheme == nil {
|
||||
t.Fatalf("theme[%d].settings[%d].base_theme missing", i, j)
|
||||
}
|
||||
switch setting.BaseTheme.(type) {
|
||||
case *tg.BaseThemeDay:
|
||||
hasDay = true
|
||||
case *tg.BaseThemeNight:
|
||||
hasNight = true
|
||||
}
|
||||
// 官方聊天主题用文档型主题背景墙纸(非旧的纯渐变 WallPaperNoFile),
|
||||
// 这里只要求每组设置带一个墙纸。
|
||||
if wallpaper, ok := setting.GetWallpaper(); !ok || wallpaper == nil {
|
||||
t.Fatalf("theme[%d].settings[%d].wallpaper = %#v ok=%v, want wallpaper", i, j, wallpaper, ok)
|
||||
}
|
||||
}
|
||||
if !hasDay || !hasNight {
|
||||
t.Fatalf("theme[%d] settings missing day/night coverage (day=%v night=%v)", i, hasDay, hasNight)
|
||||
}
|
||||
}
|
||||
if _, ok := ChatThemes(got.Hash).(*tg.AccountThemesNotModified); !ok {
|
||||
t.Fatalf("ChatThemes(hash) = %#v, want notModified", ChatThemes(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultThemesAreDefaultFlaggedForPicker(t *testing.T) {
|
||||
// account.getThemes feeds DrKLO's "Color theme" preview strip
|
||||
// (DefaultThemesPreviewCell), which only consumes themes flagged
|
||||
// is_default=true. Returning themesNotModified to a fresh client (hash 0)
|
||||
// leaves the strip stuck on a loading shimmer, so a fresh request must
|
||||
// return a populated, default-flagged catalog.
|
||||
got, ok := DefaultThemes(0).(*tg.AccountThemes)
|
||||
if !ok {
|
||||
t.Fatalf("DefaultThemes(0) = %T, want modified list", got)
|
||||
}
|
||||
if got.Hash == 0 {
|
||||
t.Fatal("DefaultThemes(0).Hash = 0, want stable cache hash")
|
||||
}
|
||||
if len(got.Themes) == 0 {
|
||||
t.Fatal("DefaultThemes(0).Themes is empty")
|
||||
}
|
||||
for i, theme := range got.Themes {
|
||||
if !theme.GetDefault() {
|
||||
t.Fatalf("theme[%d].is_default = false, want true so the picker shows it", i)
|
||||
}
|
||||
if !theme.GetForChat() {
|
||||
t.Fatalf("theme[%d].for_chat = false, want true", i)
|
||||
}
|
||||
if settings, ok := theme.GetSettings(); !ok || len(settings) < 4 {
|
||||
t.Fatalf("theme[%d].settings len=%d ok=%v, want >=4 for the picker", i, len(settings), ok)
|
||||
}
|
||||
}
|
||||
if _, ok := DefaultThemes(got.Hash).(*tg.AccountThemesNotModified); !ok {
|
||||
t.Fatalf("DefaultThemes(hash) = %#v, want notModified", DefaultThemes(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueGiftChatThemesIsEmptyHashableStub(t *testing.T) {
|
||||
got, ok := UniqueGiftChatThemes(0).(*tg.AccountChatThemes)
|
||||
if !ok {
|
||||
t.Fatalf("UniqueGiftChatThemes(0) = %T, want modified list", got)
|
||||
}
|
||||
if got.Hash == 0 {
|
||||
t.Fatal("UniqueGiftChatThemes(0).Hash = 0, want stable cache hash")
|
||||
}
|
||||
if len(got.Themes) != 0 || len(got.Chats) != 0 || len(got.Users) != 0 {
|
||||
t.Fatalf("UniqueGiftChatThemes(0) = themes %d chats %d users %d, want empty vectors",
|
||||
len(got.Themes), len(got.Chats), len(got.Users))
|
||||
}
|
||||
if _, ok := UniqueGiftChatThemes(got.Hash).(*tg.AccountChatThemesNotModified); !ok {
|
||||
t.Fatalf("UniqueGiftChatThemes(hash) = %#v, want notModified", UniqueGiftChatThemes(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWallPapersUsesOrangeFileCatalog(t *testing.T) {
|
||||
got, ok := WallPapers(0).(*tg.AccountWallPapers)
|
||||
if !ok {
|
||||
t.Fatalf("WallPapers(0) = %T, want modified list", got)
|
||||
}
|
||||
if got.Hash == 0 {
|
||||
t.Fatal("WallPapers(0).Hash = 0, want stable cache hash")
|
||||
}
|
||||
if len(got.Wallpapers) == 0 {
|
||||
t.Fatal("WallPapers(0).Wallpapers is empty")
|
||||
}
|
||||
wallpaper, ok := got.Wallpapers[0].(*tg.WallPaper)
|
||||
if !ok {
|
||||
t.Fatalf("WallPapers(0).Wallpapers[0] = %T, want *tg.WallPaper", got.Wallpapers[0])
|
||||
}
|
||||
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)
|
||||
}
|
||||
doc, ok := wallpaper.Document.(*tg.Document)
|
||||
if !ok {
|
||||
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 {
|
||||
t.Fatalf("wallpaper document = id %d hash %d size %d mime %q dc %d, want downloadable seed document",
|
||||
doc.ID, doc.AccessHash, doc.Size, doc.MimeType, doc.DCID)
|
||||
}
|
||||
if len(doc.Thumbs) == 0 {
|
||||
t.Fatal("wallpaper document has no thumbnails")
|
||||
}
|
||||
if _, ok := doc.Thumbs[0].(*tg.PhotoSize); !ok {
|
||||
t.Fatalf("wallpaper thumb = %T, want *tg.PhotoSize", doc.Thumbs[0])
|
||||
}
|
||||
if _, ok := WallPapers(got.Hash).(*tg.AccountWallPapersNotModified); !ok {
|
||||
t.Fatalf("WallPapers(hash) = %#v, want notModified", WallPapers(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupWallPaperByIDAndSlug(t *testing.T) {
|
||||
catalog := WallPapers(0).(*tg.AccountWallPapers)
|
||||
first := catalog.Wallpapers[0].(*tg.WallPaper)
|
||||
byID, ok := LookupWallPaper(&tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash})
|
||||
if !ok {
|
||||
t.Fatal("LookupWallPaper(inputWallPaper) = false, want true")
|
||||
}
|
||||
if got := byID.(*tg.WallPaper).Slug; got != first.Slug {
|
||||
t.Fatalf("LookupWallPaper(inputWallPaper).Slug = %q, want %q", got, first.Slug)
|
||||
}
|
||||
bySlug, ok := LookupWallPaper(&tg.InputWallPaperSlug{Slug: first.Slug})
|
||||
if !ok {
|
||||
t.Fatal("LookupWallPaper(inputWallPaperSlug) = false, want true")
|
||||
}
|
||||
if got := bySlug.(*tg.WallPaper).ID; got != first.ID {
|
||||
t.Fatalf("LookupWallPaper(inputWallPaperSlug).ID = %d, want %d", got, first.ID)
|
||||
}
|
||||
if _, ok := LookupWallPaper(&tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash + 1}); ok {
|
||||
t.Fatal("LookupWallPaper(wrong access hash) = true, want false")
|
||||
}
|
||||
multi, ok := LookupWallPapers([]tg.InputWallPaperClass{
|
||||
&tg.InputWallPaper{ID: first.ID, AccessHash: first.AccessHash},
|
||||
&tg.InputWallPaperSlug{Slug: catalog.Wallpapers[1].(*tg.WallPaper).Slug},
|
||||
})
|
||||
if !ok || len(multi) != 2 {
|
||||
t.Fatalf("LookupWallPapers = len %d ok %v, want 2 true", len(multi), ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStarsRevenueStatsIsZeroBalanceCompatStub(t *testing.T) {
|
||||
got := StarsRevenueStats(false)
|
||||
if got == nil {
|
||||
t.Fatal("StarsRevenueStats(false) = nil")
|
||||
}
|
||||
if _, ok := got.RevenueGraph.(*tg.StatsGraphError); !ok {
|
||||
t.Fatalf("RevenueGraph = %T, want *tg.StatsGraphError", got.RevenueGraph)
|
||||
}
|
||||
if got.Status.WithdrawalEnabled {
|
||||
t.Fatal("WithdrawalEnabled = true, want false")
|
||||
}
|
||||
if _, ok := got.Status.CurrentBalance.(*tg.StarsAmount); !ok {
|
||||
t.Fatalf("CurrentBalance = %T, want *tg.StarsAmount", got.Status.CurrentBalance)
|
||||
}
|
||||
|
||||
ton := StarsRevenueStats(true)
|
||||
if _, ok := ton.Status.CurrentBalance.(*tg.StarsTonAmount); !ok {
|
||||
t.Fatalf("TON CurrentBalance = %T, want *tg.StarsTonAmount", ton.Status.CurrentBalance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleEmojiStatusesIsEmptyModifiedList(t *testing.T) {
|
||||
got, ok := CollectibleEmojiStatuses().(*tg.AccountEmojiStatuses)
|
||||
if !ok {
|
||||
|
|
@ -105,13 +337,169 @@ func TestEmojiProfilePhotoGroupsIsEmptyModifiedList(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestConnectedBotsIsEmptyList(t *testing.T) {
|
||||
got := ConnectedBots()
|
||||
if got == nil {
|
||||
t.Fatal("ConnectedBots() = nil")
|
||||
func TestEmojiStatusGroupsIsEmptyModifiedList(t *testing.T) {
|
||||
got, ok := EmojiStatusGroups().(*tg.MessagesEmojiGroups)
|
||||
if !ok {
|
||||
t.Fatalf("EmojiStatusGroups() = %T, want empty modified list", got)
|
||||
}
|
||||
if len(got.ConnectedBots) != 0 || len(got.Users) != 0 {
|
||||
t.Fatalf("ConnectedBots() = bots %d users %d, want empty vectors", len(got.ConnectedBots), len(got.Users))
|
||||
if got.Hash != 0 {
|
||||
t.Fatalf("EmojiStatusGroups().Hash = %d, want 0", got.Hash)
|
||||
}
|
||||
if len(got.Groups) != 0 {
|
||||
t.Fatalf("EmojiStatusGroups().Groups length = %d, want 0", len(got.Groups))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickRepliesIsEmptyModifiedList(t *testing.T) {
|
||||
got, ok := QuickReplies().(*tg.MessagesQuickReplies)
|
||||
if !ok {
|
||||
t.Fatalf("QuickReplies() = %T, want empty modified list", got)
|
||||
}
|
||||
if len(got.QuickReplies) != 0 || len(got.Messages) != 0 || len(got.Chats) != 0 || len(got.Users) != 0 {
|
||||
t.Fatalf("QuickReplies() = shortcuts %d messages %d chats %d users %d, want empty vectors",
|
||||
len(got.QuickReplies), len(got.Messages), len(got.Chats), len(got.Users))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerColorsAreNonEmptyHashableAccentSets(t *testing.T) {
|
||||
got, ok := PeerColors(0).(*tg.HelpPeerColors)
|
||||
if !ok {
|
||||
t.Fatalf("PeerColors(0) = %T, want modified colors", got)
|
||||
}
|
||||
if got.Hash == 0 || len(got.Colors) == 0 {
|
||||
t.Fatalf("PeerColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors))
|
||||
}
|
||||
if len(got.Colors) != 21 {
|
||||
t.Fatalf("PeerColors(0).Colors length = %d, want seed palette count 21", len(got.Colors))
|
||||
}
|
||||
withExplicitColors := 0
|
||||
for i, option := range got.Colors {
|
||||
if !IsPeerColorID(option.ColorID) {
|
||||
t.Fatalf("PeerColors()[%d].ColorID = %d, want supported id", i, option.ColorID)
|
||||
}
|
||||
colors, ok := option.GetColors()
|
||||
if !ok {
|
||||
if option.ColorID > 6 {
|
||||
t.Fatalf("PeerColors()[%d].Colors missing for non-default id %d", i, option.ColorID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := colors.(*tg.HelpPeerColorSet); !ok {
|
||||
t.Fatalf("PeerColors()[%d].Colors = %T, want *tg.HelpPeerColorSet", i, colors)
|
||||
}
|
||||
withExplicitColors++
|
||||
}
|
||||
if withExplicitColors == 0 {
|
||||
t.Fatal("PeerColors() has no explicit seed color sets")
|
||||
}
|
||||
if _, ok := PeerColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok {
|
||||
t.Fatalf("PeerColors(hash) = %#v, want notModified", PeerColors(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerProfileColorsAreNonEmptyHashableProfileSets(t *testing.T) {
|
||||
got, ok := PeerProfileColors(0).(*tg.HelpPeerColors)
|
||||
if !ok {
|
||||
t.Fatalf("PeerProfileColors(0) = %T, want modified colors", got)
|
||||
}
|
||||
if got.Hash == 0 || len(got.Colors) == 0 {
|
||||
t.Fatalf("PeerProfileColors(0) = hash %d colors %d, want non-empty stable list", got.Hash, len(got.Colors))
|
||||
}
|
||||
if len(got.Colors) != 16 {
|
||||
t.Fatalf("PeerProfileColors(0).Colors length = %d, want seed profile palette count 16", len(got.Colors))
|
||||
}
|
||||
for i, option := range got.Colors {
|
||||
if !IsPeerProfileColorID(option.ColorID) {
|
||||
t.Fatalf("PeerProfileColors()[%d].ColorID = %d, want supported id", i, option.ColorID)
|
||||
}
|
||||
groupMin, ok := option.GetGroupMinLevel()
|
||||
if !ok || groupMin <= 0 || groupMin > maxPeerColorBoostLevel {
|
||||
t.Fatalf("PeerProfileColors()[%d].group_min_level = %d ok %v, want bounded positive level", i, groupMin, ok)
|
||||
}
|
||||
if channelMin, ok := option.GetChannelMinLevel(); ok && (channelMin <= 0 || channelMin > maxPeerColorBoostLevel) {
|
||||
t.Fatalf("PeerProfileColors()[%d].channel_min_level = %d, want bounded positive level", i, channelMin)
|
||||
}
|
||||
colors, ok := option.GetColors()
|
||||
if !ok {
|
||||
t.Fatalf("PeerProfileColors()[%d].Colors missing", i)
|
||||
}
|
||||
profile, ok := colors.(*tg.HelpPeerColorProfileSet)
|
||||
if !ok {
|
||||
t.Fatalf("PeerProfileColors()[%d].Colors = %T, want *tg.HelpPeerColorProfileSet", i, colors)
|
||||
}
|
||||
if len(profile.PaletteColors) == 0 || len(profile.BgColors) == 0 || len(profile.StoryColors) != 2 {
|
||||
t.Fatalf("PeerProfileColors()[%d] = palette %d bg %d story %d, want usable profile palette",
|
||||
i, len(profile.PaletteColors), len(profile.BgColors), len(profile.StoryColors))
|
||||
}
|
||||
}
|
||||
if _, ok := PeerProfileColors(got.Hash).(*tg.HelpPeerColorsNotModified); !ok {
|
||||
t.Fatalf("PeerProfileColors(hash) = %#v, want notModified", PeerProfileColors(got.Hash))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerProfileColorsKeepTDesktopBoostFeatureLevelsBounded(t *testing.T) {
|
||||
got, ok := PeerProfileColors(0).(*tg.HelpPeerColors)
|
||||
if !ok {
|
||||
t.Fatalf("PeerProfileColors(0) = %T, want modified colors", got)
|
||||
}
|
||||
levels := make([]int, 0, len(got.Colors))
|
||||
lowestNonZeroLevel := math.MaxInt
|
||||
for _, option := range got.Colors {
|
||||
level, ok := option.GetGroupMinLevel()
|
||||
if !ok {
|
||||
level = 0
|
||||
}
|
||||
levels = append(levels, level)
|
||||
if level != 0 && level < lowestNonZeroLevel {
|
||||
lowestNonZeroLevel = level
|
||||
}
|
||||
}
|
||||
if lowestNonZeroLevel == math.MaxInt {
|
||||
t.Fatal("profile color group levels are all zero; TDesktop would aggregate them at MaxInt in BoostBox")
|
||||
}
|
||||
maxFeatureLevel := 0
|
||||
for _, level := range levels {
|
||||
if level < lowestNonZeroLevel {
|
||||
level = lowestNonZeroLevel
|
||||
}
|
||||
if level > maxFeatureLevel {
|
||||
maxFeatureLevel = level
|
||||
}
|
||||
}
|
||||
if maxFeatureLevel <= 0 || maxFeatureLevel > maxPeerColorBoostLevel {
|
||||
t.Fatalf("TDesktop profile feature max level = %d, want 1..%d", maxFeatureLevel, maxPeerColorBoostLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeerProfileColorsReturnsIndependentOptions(t *testing.T) {
|
||||
first, ok := PeerProfileColors(0).(*tg.HelpPeerColors)
|
||||
if !ok {
|
||||
t.Fatalf("PeerProfileColors(0) first = %T, want modified colors", first)
|
||||
}
|
||||
second, ok := PeerProfileColors(0).(*tg.HelpPeerColors)
|
||||
if !ok {
|
||||
t.Fatalf("PeerProfileColors(0) second = %T, want modified colors", second)
|
||||
}
|
||||
if len(first.Colors) == 0 || len(second.Colors) == 0 {
|
||||
t.Fatal("PeerProfileColors returned empty colors")
|
||||
}
|
||||
first.Colors[0].SetGroupMinLevel(maxPeerColorBoostLevel + 1)
|
||||
if groupMin, ok := second.Colors[0].GetGroupMinLevel(); !ok || groupMin > maxPeerColorBoostLevel {
|
||||
t.Fatalf("second PeerProfileColors group_min_level = %d ok %v, want independent bounded copy", groupMin, ok)
|
||||
}
|
||||
|
||||
firstSet, ok := first.Colors[0].Colors.(*tg.HelpPeerColorProfileSet)
|
||||
if !ok || len(firstSet.PaletteColors) == 0 {
|
||||
t.Fatalf("first profile colors = %T %+v, want profile set with palette", first.Colors[0].Colors, first.Colors[0].Colors)
|
||||
}
|
||||
secondSet, ok := second.Colors[0].Colors.(*tg.HelpPeerColorProfileSet)
|
||||
if !ok || len(secondSet.PaletteColors) == 0 {
|
||||
t.Fatalf("second profile colors = %T %+v, want profile set with palette", second.Colors[0].Colors, second.Colors[0].Colors)
|
||||
}
|
||||
original := secondSet.PaletteColors[0]
|
||||
firstSet.PaletteColors[0] = original + 1
|
||||
if secondSet.PaletteColors[0] != original {
|
||||
t.Fatalf("second profile palette mutated through first response: got %d want %d", secondSet.PaletteColors[0], original)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue