Skip to content

Commit dcbdff4

Browse files
artgas1claude
andcommitted
feat: script to rebuild Telethon's TL classes against the current layer
Telethon releases lag behind production Telegram. As of 2026-08-11 the newest release (1.44.0) is built for LAYER 227 while the server answers with LAYER 228 objects, where the `user` constructor changed (0x31774388 -> 0xb1b8cc83). Upgrading is not an option — 1.44.0 is already the latest published release. An unknown constructor desynchronises the whole read buffer, so the failure is partial and misleading: list_chats, get_common_chats, resolve_username and get_full_user break while get_chats, search_contacts, list_messages and send_message keep working. Verified on a live account: get_entity() and get_dialogs() failed before the patch and succeed after it. scripts/patch_telethon_layer.py regenerates telethon/tl/{types,functions,alltlobjects.py} from the vendored schema using the vendored Telethon generator, leaving the rest of the library untouched. It has --check and --restore, is idempotent, and writes a backup of the original next to the package on first run. vendor/api-layer228.tl comes from the Telegram Desktop dev branch; vendor/telethon_generator/ from Telethon's v1 branch, which is frozen on GitHub at LAYER 222 ("Migrate off GitHub"), so there is nowhere to fetch it from at runtime. vendor/ is excluded from black and flake8 — third-party code stays byte-identical to upstream so it remains diffable on refresh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a7bdd4d commit dcbdff4

25 files changed

Lines changed: 10032 additions & 0 deletions

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,39 @@ Telegram messages, display names, chat titles, and button labels are untrusted c
597597
- **Auth errors after password changes:** regenerate your session string.
598598
- **Bot-only tool rejected:** regular user accounts cannot manage bot command settings.
599599
- **Need details:** check your MCP client logs, terminal output, and `mcp_errors.log`.
600+
- **Some tools fail while neighbours work** (`list_chats`, `get_common_chats`, `resolve_username`,
601+
`get_full_user` error out, but `get_chats` / `search_contacts` are fine) — this is MTProto
602+
**schema drift**, not a missing user or chat. See below.
603+
604+
### MTProto schema drift (`TypeNotFoundError`)
605+
606+
Telethon releases lag behind production Telegram. As of 11.08.2026 the newest release
607+
(1.44.0, built for **LAYER 227**) does not know objects the server now sends on **LAYER 228**,
608+
where the `user` constructor changed (`0x31774388``0xb1b8cc83`).
609+
610+
An unknown constructor does not just drop a field — it **desynchronises the whole read buffer**.
611+
So the reported constructor id is usually garbage that exists in no schema at all, only *some*
612+
calls break, and the failure reads like "no such user". Upgrading Telethon does not help: 1.44.0
613+
is already the latest published release.
614+
615+
Fix — rebuild the TL classes against the current layer:
616+
617+
```bash
618+
uv run python scripts/patch_telethon_layer.py # apply
619+
uv run python scripts/patch_telethon_layer.py --check # compare installed vs vendored layer
620+
uv run python scripts/patch_telethon_layer.py --restore # roll back from the automatic backup
621+
```
622+
623+
The script regenerates `telethon/tl/{types,functions,alltlobjects.py}` from
624+
`vendor/api-layer228.tl` (taken from the Telegram Desktop dev branch) using the vendored
625+
Telethon generator, and leaves the rest of the library untouched. A backup of the original
626+
is written next to the package on first run, so rollback needs no reinstall.
627+
628+
⚠️ **Already running processes keep the old code in memory** — only newly started ones pick the
629+
patch up. Restart your MCP clients (or the sessions that spawned them) after applying.
630+
631+
When Telethon ships a release for the current layer, drop the vendored schema and this script.
632+
600633

601634
## Contributing
602635

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ telegram-mcp-generate-session = "session_string_generator:main"
5353
[tool.black]
5454
line-length = 99
5555
target-version = ['py311']
56+
# vendor/ is third-party code (the Telethon generator) — keep it byte-identical to
57+
# upstream so it stays diffable when the vendored copy is refreshed.
58+
extend-exclude = '^/vendor/'
5659

5760
[tool.flake8]
5861
ignore = ["E203", "E501", "W503"]
@@ -65,6 +68,7 @@ exclude = [
6568
"build",
6669
"dist",
6770
"docs/source/conf.py",
71+
"vendor",
6872
]
6973

7074
[dependency-groups]

scripts/patch_telethon_layer.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env python3
2+
"""Rebuild Telethon's TL classes against the current MTProto layer.
3+
4+
Why
5+
---
6+
Telethon releases lag behind production Telegram. As of 2026-08-11 the newest
7+
release (1.44.0, published 2026-06-15) is built for LAYER 227, while the server
8+
already answers with LAYER 228 objects, where the `user` constructor changed
9+
(0x31774388 -> 0xb1b8cc83).
10+
11+
A client that does not know a constructor does not merely lose one field — the
12+
whole read buffer desynchronises. Outwards that shows up as `TypeNotFoundError`
13+
carrying an id that exists in no schema at all (garbage read past the
14+
desync), and only *some* calls break while their neighbours keep working:
15+
`list_chats`, `get_common_chats`, `resolve_username` and `get_full_user` fail
16+
while `get_chats`, `search_contacts`, `list_messages` and `send_message` are
17+
fine. Upgrading does not help — 1.44.0 is already the latest published release.
18+
19+
What this does
20+
--------------
21+
Takes the vendored schema (`vendor/api-layer228.tl`, lifted from the Telegram
22+
Desktop dev branch) and the vendored Telethon generator, rebuilds
23+
`telethon/tl/{types,functions,alltlobjects.py}` from them and drops the result
24+
over the installed package. The rest of the library — client, networking,
25+
custom classes — stays exactly as shipped; only generated TL classes change.
26+
27+
Usage
28+
-----
29+
uv run python scripts/patch_telethon_layer.py # apply
30+
uv run python scripts/patch_telethon_layer.py --check # compare layers
31+
uv run python scripts/patch_telethon_layer.py --restore # roll back
32+
33+
A backup of the original is written next to the package on first run, so
34+
rolling back needs no reinstall.
35+
36+
NOTE: already running processes keep the old code in memory — only newly
37+
started ones pick the patch up. Restart your MCP clients after applying.
38+
39+
When Telethon ships a release for the current layer, drop the vendored schema
40+
and this script.
41+
"""
42+
43+
from __future__ import annotations
44+
45+
import argparse
46+
import shutil
47+
import subprocess
48+
import sys
49+
import tarfile
50+
import tempfile
51+
from pathlib import Path
52+
53+
REPO = Path(__file__).resolve().parent.parent
54+
VENDOR_SCHEMA = REPO / "vendor" / "api-layer228.tl"
55+
VENDOR_GENERATOR = REPO / "vendor" / "telethon_generator"
56+
GENERATED = ("types", "functions", "alltlobjects.py")
57+
58+
59+
def telethon_tl_dir() -> Path:
60+
"""The tl/ directory of the installed telethon — the one the server actually runs."""
61+
import telethon
62+
63+
return Path(telethon.__file__).parent / "tl"
64+
65+
66+
def current_layer() -> tuple[int, str]:
67+
"""Layer of the installed telethon — MUST be read in a separate process.
68+
69+
In our own process `telethon.tl` is already imported, so after swapping the
70+
files Python keeps serving the cached module: the check would report the old
71+
layer and claim the patch had failed on a patch that in fact succeeded.
72+
"""
73+
code = (
74+
"from telethon.tl.alltlobjects import LAYER;"
75+
"from telethon.tl.types import User;"
76+
"print(LAYER, hex(User.CONSTRUCTOR_ID))"
77+
)
78+
res = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
79+
if res.returncode != 0:
80+
raise SystemExit(f"could not read the telethon layer:\n{res.stderr}")
81+
layer, ctor = res.stdout.split()
82+
return int(layer), ctor
83+
84+
85+
def schema_layer() -> int:
86+
for line in VENDOR_SCHEMA.read_text(encoding="utf-8").splitlines():
87+
if line.strip().startswith("// LAYER "):
88+
return int(line.strip().rsplit(" ", 1)[1])
89+
raise SystemExit(f"no '// LAYER N' line in {VENDOR_SCHEMA}")
90+
91+
92+
def backup_path(tl_dir: Path, layer: int) -> Path:
93+
return tl_dir.parent / f"tl.orig-layer{layer}.tar.gz"
94+
95+
96+
def do_check() -> int:
97+
layer, user_ctor = current_layer()
98+
want = schema_layer()
99+
print(f"installed: LAYER {layer}, user# = {user_ctor}")
100+
print(f"vendored schema: LAYER {want}")
101+
if layer == want:
102+
print("match — patch is applied")
103+
return 0
104+
print("mismatch — patch needed (see module docstring)")
105+
return 1
106+
107+
108+
def do_restore() -> int:
109+
tl_dir = telethon_tl_dir()
110+
backups = sorted(tl_dir.parent.glob("tl.orig-layer*.tar.gz"))
111+
if not backups:
112+
raise SystemExit(
113+
"no backup — roll back by reinstalling: uv sync --reinstall-package telethon"
114+
)
115+
archive = backups[-1]
116+
for name in GENERATED:
117+
target = tl_dir / name
118+
shutil.rmtree(target) if target.is_dir() else target.unlink(missing_ok=True)
119+
with tarfile.open(archive) as tar:
120+
# filter="data" is the Python 3.14 default; set explicitly to avoid the warning
121+
tar.extractall(tl_dir.parent, filter="data")
122+
_drop_pycache(tl_dir.parent)
123+
print(f"restored from {archive.name}")
124+
return do_check()
125+
126+
127+
def _drop_pycache(root: Path) -> None:
128+
for cache in root.rglob("__pycache__"):
129+
shutil.rmtree(cache, ignore_errors=True)
130+
131+
132+
def do_patch() -> int:
133+
if not VENDOR_SCHEMA.exists() or not VENDOR_GENERATOR.exists():
134+
raise SystemExit("missing vendor/api-layer228.tl or vendor/telethon_generator")
135+
136+
tl_dir = telethon_tl_dir()
137+
layer_before, user_before = current_layer()
138+
want = schema_layer()
139+
if layer_before == want:
140+
print(f"already LAYER {want} — nothing to do")
141+
return 0
142+
143+
backup = backup_path(tl_dir, layer_before)
144+
if not backup.exists():
145+
with tarfile.open(backup, "w:gz") as tar:
146+
for name in GENERATED:
147+
tar.add(tl_dir / name, arcname=f"tl/{name}")
148+
print(f"backup of the original: {backup}")
149+
150+
# The Telethon generator writes relative to itself, so build in a temp tree.
151+
with tempfile.TemporaryDirectory() as tmp:
152+
work = Path(tmp)
153+
shutil.copytree(VENDOR_GENERATOR, work / "telethon_generator")
154+
shutil.copy(VENDOR_SCHEMA, work / "telethon_generator" / "data" / "api.tl")
155+
(work / "telethon" / "tl").mkdir(parents=True, exist_ok=True)
156+
157+
gen = work / "gen.py"
158+
gen.write_text(
159+
"import sys, itertools; sys.path.insert(0, '.')\n"
160+
"from pathlib import Path\n"
161+
"from telethon_generator.parsers import parse_tl, find_layer, parse_errors, parse_methods\n"
162+
"from telethon_generator.generators import generate_tlobjects\n"
163+
"gen = Path('telethon_generator')\n"
164+
"tls = sorted(gen.glob('data/*.tl'))\n"
165+
"layer = next(filter(None, map(find_layer, tls)))\n"
166+
"errors = list(parse_errors(gen / 'data/errors.csv'))\n"
167+
"methods = list(parse_methods(gen / 'data/methods.csv', gen / 'data/friendly.csv',\n"
168+
" {e.str_code: e for e in errors}))\n"
169+
"objs = list(itertools.chain(*(parse_tl(f, layer, methods) for f in tls)))\n"
170+
"generate_tlobjects(objs, layer, 2, Path('telethon/tl'))\n"
171+
"print('LAYER', layer)\n",
172+
encoding="utf-8",
173+
)
174+
res = subprocess.run([sys.executable, "gen.py"], cwd=work, capture_output=True, text=True)
175+
if res.returncode != 0:
176+
raise SystemExit(f"generation failed:\n{res.stdout}\n{res.stderr}")
177+
178+
built = work / "telethon" / "tl"
179+
missing = [n for n in GENERATED if not (built / n).exists()]
180+
if missing:
181+
raise SystemExit(f"generator did not produce: {missing}")
182+
183+
for name in GENERATED:
184+
target = tl_dir / name
185+
shutil.rmtree(target) if target.is_dir() else target.unlink(missing_ok=True)
186+
src = built / name
187+
shutil.copytree(src, target) if src.is_dir() else shutil.copy(src, target)
188+
189+
_drop_pycache(tl_dir.parent)
190+
print(f"was: LAYER {layer_before}, user# = {user_before}")
191+
return do_check()
192+
193+
194+
def main() -> int:
195+
ap = argparse.ArgumentParser(
196+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
197+
)
198+
g = ap.add_mutually_exclusive_group()
199+
g.add_argument("--check", action="store_true", help="show installed vs vendored layer")
200+
g.add_argument("--restore", action="store_true", help="roll back from the backup")
201+
args = ap.parse_args()
202+
if args.check:
203+
return do_check()
204+
if args.restore:
205+
return do_restore()
206+
return do_patch()
207+
208+
209+
if __name__ == "__main__":
210+
raise SystemExit(main())

0 commit comments

Comments
 (0)