|
| 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