tlgr-cli 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. tlgr_cli-2.0.1.dist-info/top_level.txt +1 -0
tlgr/core/config.py ADDED
@@ -0,0 +1,358 @@
1
+ """`config.toml` → typed structs, and the path helpers everything else uses.
2
+
3
+ v1 parsed the file by hand into dataclasses with `raw.get("x", default)` at
4
+ every key, which meant a typo in `config.toml` was silently the default and a
5
+ new section had to be threaded through three functions. Here the schema of
6
+ §10.2 *is* the type: msgspec decodes the TOML into it, an unknown key inside a
7
+ known section is reported with its path, and a wrong type is a `CONFIG_ERROR`
8
+ naming the key instead of a `TypeError` three modules later.
9
+
10
+ The v1 `jobs.toml` engine that used to live in this module is gone: jobs are
11
+ `jobs.yaml`, parsed by `gateway/config.py`, and the TOML loader had no callers
12
+ left (MNT-04).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from pathlib import Path
20
+ from typing import Any, TypeVar
21
+
22
+ import msgspec
23
+
24
+ from tlgr.core.errors import ConfigurationError
25
+ from tlgr.core.paths import TlgrPaths, default_base, write_private
26
+
27
+ if sys.version_info >= (3, 11):
28
+ import tomllib
29
+ else: # pragma: no cover - 3.10 only
30
+ try:
31
+ import tomllib
32
+ except ModuleNotFoundError:
33
+ import tomli as tomllib
34
+
35
+ try:
36
+ import tomli_w
37
+ except ImportError: # pragma: no cover - optional at runtime, required to write
38
+ tomli_w = None # type: ignore[assignment]
39
+
40
+ #: Kept as a module constant because v1 modules import it directly. New code
41
+ #: should call `default_base()` so that `TLGR_HOME` is honoured per call.
42
+ CONFIG_DIR = default_base()
43
+
44
+
45
+ def _ensure_dir(path: Path) -> Path:
46
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
47
+ return path
48
+
49
+
50
+ class _Section(msgspec.Struct, forbid_unknown_fields=False):
51
+ """A config section.
52
+
53
+ Unknown keys are tolerated rather than fatal: a config written by a newer
54
+ tlgr must not stop an older one from starting, and `tlgr config validate`
55
+ is where unknown keys are reported.
56
+ """
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Sections (§10.2)
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ class Defaults(_Section):
65
+ drop_author: bool = False
66
+ delete_after: bool = False
67
+ output: str = "human"
68
+ require_account: bool = False
69
+ #: v2 emits RFC-3339; `legacy_dates` restores v1's `str(datetime)` spelling
70
+ #: for the one minor release the migration note in §12.4 covers.
71
+ legacy_dates: bool = False
72
+ #: v1 defaulted to markdown, which silently ate `_`, `*` and backticks in
73
+ #: ordinary text (COR-21). `none` is the safe default.
74
+ parse_mode: str = "none"
75
+ timezone: str = ""
76
+ confirm_destructive: bool = True
77
+
78
+
79
+ class DaemonConfig(_Section):
80
+ auto_start: bool = True
81
+ log_level: str = "info"
82
+ idle_timeout: int = 1800
83
+ #: Seconds tlgr will sleep off inside a request before giving up. Kept at
84
+ #: the daemon level as well as under `[flood]` because v1 spelled it here.
85
+ flood_wait_max: int = 120
86
+ start_timeout: int = 30
87
+ drain_seconds: int = 30
88
+ preconnect: list[str] = []
89
+ event_buffer: int = 4096
90
+ event_workers: int = 8
91
+ resync_depth: int = 50
92
+ state_save_interval: int = 60
93
+
94
+
95
+ class IdentityConfig(_Section):
96
+ device_model: str = ""
97
+ system_version: str = ""
98
+ lang_code: str = ""
99
+ system_lang_code: str = ""
100
+ tz_offset: bool = True
101
+
102
+
103
+ class PresenceConfig(_Section):
104
+ #: off | online | mirror. Off by default because appearing online is a
105
+ #: visible, account-affecting behaviour the operator must opt into.
106
+ mode: str = "off"
107
+
108
+
109
+ class NetworkConfig(_Section):
110
+ proxy: str = ""
111
+ ipv6: bool = False
112
+ connect_timeout: int = 10
113
+ connection: str = "tcp_full"
114
+
115
+
116
+ class FloodConfig(_Section):
117
+ sleep_threshold: int = 120
118
+ max_wait: int = 600
119
+ persist: bool = True
120
+
121
+
122
+ class RateClassConfig(_Section):
123
+ rate: float = 10.0
124
+ burst: int = 20
125
+ new_peers_per_day: int = 0
126
+
127
+
128
+ class LimitsConfig(_Section):
129
+ entity_cache: int = 20000
130
+ request_retries: int = 5
131
+ dialog_scan_max: int = 5000
132
+ download_concurrency_small: int = 5
133
+ download_concurrency_large: int = 2
134
+ upload_parts_in_flight: int = 4
135
+ max_album: int = 10
136
+
137
+
138
+ class SecurityConfig(_Section):
139
+ require_token: bool = False
140
+ peer_uid_check: bool = True
141
+ warn_insecure_webhook: bool = True
142
+
143
+
144
+ class PolicyConfig(_Section):
145
+ allow: list[str] = msgspec.field(default_factory=lambda: ["*"])
146
+ deny: list[str] = []
147
+
148
+
149
+ class LoggingConfig(_Section):
150
+ redact: bool = True
151
+ max_bytes: int = 8388608
152
+ backups: int = 5
153
+
154
+
155
+ class MediaConfig(_Section):
156
+ download_dir: str = ""
157
+ ffprobe: str = "auto"
158
+
159
+
160
+ class AccountsSection(_Section):
161
+ default: str = ""
162
+
163
+
164
+ #: The shipped defaults for an unwarmed account (§6.4). `resolve` is slow
165
+ #: because `contacts.resolveUsername` floods at roughly 50 calls in a short
166
+ #: period; `send` is slow because a young account that sends fast gets frozen.
167
+ _DEFAULT_RATES: dict[str, RateClassConfig] = {
168
+ "read": RateClassConfig(rate=10.0, burst=20),
169
+ "resolve": RateClassConfig(rate=0.5, burst=5),
170
+ "send": RateClassConfig(rate=1.0, burst=3, new_peers_per_day=30),
171
+ "bulk": RateClassConfig(rate=2.0, burst=4),
172
+ "file": RateClassConfig(rate=5.0, burst=10),
173
+ "local": RateClassConfig(rate=1000.0, burst=1000),
174
+ }
175
+
176
+
177
+ class AppConfig(msgspec.Struct, forbid_unknown_fields=False):
178
+ """The whole of `config.toml`, decoded."""
179
+
180
+ accounts: AccountsSection = msgspec.field(default_factory=AccountsSection)
181
+ defaults: Defaults = msgspec.field(default_factory=Defaults)
182
+ daemon: DaemonConfig = msgspec.field(default_factory=DaemonConfig)
183
+ identity: IdentityConfig = msgspec.field(default_factory=IdentityConfig)
184
+ presence: PresenceConfig = msgspec.field(default_factory=PresenceConfig)
185
+ network: NetworkConfig = msgspec.field(default_factory=NetworkConfig)
186
+ flood: FloodConfig = msgspec.field(default_factory=FloodConfig)
187
+ rate: dict[str, RateClassConfig] = msgspec.field(default_factory=dict)
188
+ limits: LimitsConfig = msgspec.field(default_factory=LimitsConfig)
189
+ security: SecurityConfig = msgspec.field(default_factory=SecurityConfig)
190
+ policy: PolicyConfig = msgspec.field(default_factory=PolicyConfig)
191
+ logging: LoggingConfig = msgspec.field(default_factory=LoggingConfig)
192
+ media: MediaConfig = msgspec.field(default_factory=MediaConfig)
193
+
194
+ @property
195
+ def default_account(self) -> str:
196
+ """v1 spelled `[accounts] default` as a flat attribute; keep it."""
197
+ return self.accounts.default
198
+
199
+ def rate_for(self, rate_class: str) -> RateClassConfig:
200
+ """The bucket for a `rate_class`, falling back to the shipped default."""
201
+ configured = self.rate.get(rate_class)
202
+ if configured is not None:
203
+ return configured
204
+ return _DEFAULT_RATES.get(rate_class, _DEFAULT_RATES["read"])
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Webhook
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ class WebhookRetryConfig(_Section):
213
+ enabled: bool = True
214
+ max_attempts: int = 3
215
+ backoff_base: int = 2
216
+
217
+
218
+ class WebhookFilterConfig(_Section):
219
+ chats: list[str] = []
220
+ raw: dict[str, Any] = {}
221
+
222
+
223
+ class WebhookConfig(_Section):
224
+ enabled: bool = False
225
+ url: str = ""
226
+ token: str = ""
227
+ #: HMAC-SHA256 key for `X-Tlgr-Signature`. Falls back to `token` so an
228
+ #: existing config keeps signing without being edited.
229
+ secret: str = ""
230
+ events: list[str] = msgspec.field(default_factory=lambda: ["message_new"])
231
+ queue_size: int = 2048
232
+ workers: int = 4
233
+ timeout: int = 30
234
+ retry: WebhookRetryConfig = msgspec.field(default_factory=WebhookRetryConfig)
235
+ filters: WebhookFilterConfig = msgspec.field(default_factory=WebhookFilterConfig)
236
+
237
+ @property
238
+ def signing_key(self) -> str:
239
+ return self.secret or self.token
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # Loading
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ def _load_toml(path: Path) -> dict[str, Any]:
248
+ if not path.exists():
249
+ return {}
250
+ try:
251
+ with open(path, "rb") as handle:
252
+ loaded: dict[str, Any] = tomllib.load(handle)
253
+ return loaded
254
+ except tomllib.TOMLDecodeError as exc:
255
+ raise ConfigurationError(f"{path} is not valid TOML: {exc}") from exc
256
+
257
+
258
+ def _save_toml(path: Path, data: dict[str, Any]) -> None:
259
+ if tomli_w is None:
260
+ raise ConfigurationError("tomli_w is required to write TOML files")
261
+ write_private(path, tomli_w.dumps(data).encode("utf-8"))
262
+
263
+
264
+ _T = TypeVar("_T")
265
+
266
+
267
+ def _decode(raw: dict[str, Any], type_: type[_T], what: str) -> _T:
268
+ try:
269
+ decoded: _T = msgspec.convert(raw, type=type_, strict=False)
270
+ return decoded
271
+ except msgspec.ValidationError as exc:
272
+ raise ConfigurationError(f"{what}: {exc}") from exc
273
+
274
+
275
+ def load_app_config(base: Path | None = None) -> AppConfig:
276
+ """Decode `config.toml` under *base*.
277
+
278
+ A malformed value is a `CONFIG_ERROR` naming the key, not a silent
279
+ fallback to the default: "the daemon ignored your setting" is the failure
280
+ mode this whole module exists to remove.
281
+ """
282
+ paths = TlgrPaths(base)
283
+ raw = _load_toml(paths.config)
284
+ config = _decode(raw, AppConfig, f"{paths.config}")
285
+ if not config.rate:
286
+ config.rate = dict(_DEFAULT_RATES)
287
+ else:
288
+ merged = dict(_DEFAULT_RATES)
289
+ merged.update(config.rate)
290
+ config.rate = merged
291
+ _apply_env(config)
292
+ return config
293
+
294
+
295
+ _ENV_KEYS: tuple[tuple[str, str, str], ...] = (
296
+ ("TLGR_ACCOUNT", "accounts", "default"),
297
+ ("TLGR_LOG_LEVEL", "daemon", "log_level"),
298
+ )
299
+
300
+
301
+ def _apply_env(config: AppConfig) -> None:
302
+ """CLI flag → environment → `config.toml` → default (§10.2)."""
303
+ for env_key, section, key in _ENV_KEYS:
304
+ value = os.environ.get(env_key, "").strip()
305
+ if value:
306
+ setattr(getattr(config, section), key, value)
307
+
308
+
309
+ def load_webhook_config(base: Path | None = None) -> WebhookConfig:
310
+ paths = TlgrPaths(base)
311
+ raw = _load_toml(paths.webhook).get("webhook", {})
312
+ if not raw:
313
+ return WebhookConfig()
314
+ filters_raw = dict(raw.get("filters", {}) or {})
315
+ chats = filters_raw.pop("chats", [])
316
+ extra = {k: v for k, v in filters_raw.items() if k != "raw"}
317
+ raw = dict(raw)
318
+ raw["filters"] = {"chats": chats, "raw": extra}
319
+ return _decode(raw, WebhookConfig, f"{paths.webhook}")
320
+
321
+
322
+ def save_webhook_config(config: WebhookConfig, base: Path | None = None) -> None:
323
+ paths = TlgrPaths(base)
324
+ body: dict[str, Any] = msgspec.to_builtins(config)
325
+ filters: dict[str, Any] = body.pop("filters", {}) or {}
326
+ merged: dict[str, Any] = {k: v for k, v in filters.items() if k != "raw"}
327
+ merged.update(filters.get("raw", {}) or {})
328
+ body["filters"] = merged
329
+ _save_toml(paths.webhook, {"webhook": body})
330
+
331
+
332
+ # ---------------------------------------------------------------------------
333
+ # Path helpers (kept at their v1 names; every caller in the tree uses them)
334
+ # ---------------------------------------------------------------------------
335
+
336
+
337
+ def get_config_dir() -> Path:
338
+ return _ensure_dir(default_base())
339
+
340
+
341
+ def get_accounts_dir(base: Path | None = None) -> Path:
342
+ return _ensure_dir(TlgrPaths(base).accounts)
343
+
344
+
345
+ def get_logs_dir(base: Path | None = None) -> Path:
346
+ return TlgrPaths(base).ensure_logs()
347
+
348
+
349
+ def get_downloads_dir(base: Path | None = None) -> Path:
350
+ return TlgrPaths(base).ensure_downloads()
351
+
352
+
353
+ def get_socket_path(base: Path | None = None) -> Path:
354
+ return TlgrPaths(base).socket
355
+
356
+
357
+ def get_pid_path(base: Path | None = None) -> Path:
358
+ return TlgrPaths(base).pid
tlgr/core/custom_tl.py ADDED
@@ -0,0 +1,170 @@
1
+ """Calling a method Telethon's layer does not know about (§6.14).
2
+
3
+ Telethon 1.44 speaks layer 227. When Telegram ships a method at layer 229 that
4
+ tlgr needs before Telethon catches up, the choice is: wait for a release, fork
5
+ Telethon, or serialise the request by hand. This module is the third option,
6
+ written down once so that nobody has to rediscover the constructor-id rules
7
+ under deadline.
8
+
9
+ **Nothing here is used today.** Communities, ephemeral messages and the new
10
+ keyboard model are the only layer-229 features, none is on the P0/P1 path, and
11
+ Firebase login is Android-only (§1.2). It ships as the base classes and one
12
+ tested example, because a recipe you have never run is not a recipe.
13
+
14
+ The four things that are easy to get wrong:
15
+
16
+ * `CONSTRUCTOR_ID` is the little-endian CRC32 of the *TL definition line* with
17
+ its parameter names and types, not of the method name. It has to be copied
18
+ from the schema; it cannot be derived from anything you have.
19
+ * `SUBCLASS_OF_ID` is `zlib.crc32(b'<ResultType>')` — the **result** type, and
20
+ it is what lets Telethon's reader find the right parser.
21
+ * the result type must be registered in `tlobject.alltlobjects` before the
22
+ reply arrives, or the reader raises `TypeNotFoundError` with the id in hex
23
+ and no other clue.
24
+ * a request above the negotiated layer must be wrapped in
25
+ `InvokeWithLayerRequest`, because the connection announced layer 227 at
26
+ `initConnection` time and the server enforces it.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import struct
32
+ import zlib
33
+ from typing import Any
34
+
35
+ from telethon.extensions import BinaryReader
36
+ from telethon.tl.tlobject import TLObject, TLRequest
37
+
38
+ __all__ = [
39
+ "CustomRequest",
40
+ "CustomType",
41
+ "register",
42
+ "subclass_of",
43
+ ]
44
+
45
+
46
+ def subclass_of(result_type: str) -> int:
47
+ """`zlib.crc32(b'<ResultType>')` — the id Telethon matches results against."""
48
+ return zlib.crc32(result_type.encode("ascii"))
49
+
50
+
51
+ def register(*types: type[TLObject]) -> None:
52
+ """Teach Telethon's reader about a constructor it does not know.
53
+
54
+ Must happen before the reply is read, which in practice means at import
55
+ time of the module that defines the request.
56
+ """
57
+ from telethon.tl import alltlobjects
58
+
59
+ for klass in types:
60
+ alltlobjects.tlobjects[klass.CONSTRUCTOR_ID] = klass
61
+
62
+
63
+ class CustomType(TLObject):
64
+ """Base for a result type Telethon cannot parse yet.
65
+
66
+ Subclasses set `CONSTRUCTOR_ID` and implement `from_reader`.
67
+ """
68
+
69
+ CONSTRUCTOR_ID = 0
70
+ SUBCLASS_OF_ID = 0
71
+
72
+ @classmethod
73
+ def from_reader(cls, reader: BinaryReader) -> CustomType: # pragma: no cover - abstract
74
+ raise NotImplementedError
75
+
76
+
77
+ class CustomRequest(TLRequest):
78
+ """Base for a request above Telethon's layer.
79
+
80
+ Subclasses set `CONSTRUCTOR_ID`, `SUBCLASS_OF_ID` and implement `_bytes()`.
81
+ `read_result` is inherited and works as soon as the result type is
82
+ registered.
83
+ """
84
+
85
+ CONSTRUCTOR_ID = 0
86
+ SUBCLASS_OF_ID = 0
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ return {"_": type(self).__name__}
90
+
91
+ def _bytes(self) -> bytes: # pragma: no cover - abstract
92
+ raise NotImplementedError
93
+
94
+ def __bytes__(self) -> bytes:
95
+ return self._bytes()
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # The worked example: help.getNearestDc, which Telethon *does* have.
100
+ # ---------------------------------------------------------------------------
101
+
102
+
103
+ class NearestDc(CustomType):
104
+ """`nearestDc#8e1a1775 country:string this_dc:int nearest_dc:int = NearestDc`.
105
+
106
+ Deliberately a method Telethon already supports, so the example can be
107
+ tested against a real reply shape without needing a layer bump. Copy this
108
+ class, change the four constants, and you have the new method.
109
+
110
+ >>> NearestDc.CONSTRUCTOR_ID == 0x8E1A1775
111
+ True
112
+ >>> NearestDc.SUBCLASS_OF_ID == subclass_of("NearestDc")
113
+ True
114
+ """
115
+
116
+ CONSTRUCTOR_ID = 0x8E1A1775
117
+ SUBCLASS_OF_ID = subclass_of("NearestDc")
118
+
119
+ def __init__(self, country: str, this_dc: int, nearest_dc: int) -> None:
120
+ self.country = country
121
+ self.this_dc = this_dc
122
+ self.nearest_dc = nearest_dc
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ return {
126
+ "_": "NearestDc",
127
+ "country": self.country,
128
+ "this_dc": self.this_dc,
129
+ "nearest_dc": self.nearest_dc,
130
+ }
131
+
132
+ @classmethod
133
+ def from_reader(cls, reader: BinaryReader) -> NearestDc:
134
+ return cls(
135
+ country=reader.tgread_string(),
136
+ this_dc=reader.read_int(),
137
+ nearest_dc=reader.read_int(),
138
+ )
139
+
140
+
141
+ class GetNearestDcRequest(CustomRequest):
142
+ """`help.getNearestDc#1fb33026 = NearestDc`.
143
+
144
+ >>> bytes(GetNearestDcRequest()).hex()
145
+ '2630b31f'
146
+ """
147
+
148
+ CONSTRUCTOR_ID = 0x1FB33026
149
+ SUBCLASS_OF_ID = subclass_of("NearestDc")
150
+
151
+ def _bytes(self) -> bytes:
152
+ return struct.pack("<I", self.CONSTRUCTOR_ID)
153
+
154
+
155
+ # Deliberately *not* registered at import: `NearestDc` is a type Telethon
156
+ # already parses, and replacing its entry in `alltlobjects` would make every
157
+ # ordinary `help.getNearestDc` return this class instead. A real layer-229
158
+ # type has no incumbent, so its own module calls `register()` at import.
159
+
160
+
161
+ async def invoke_with_layer(client: Any, request: TLRequest, layer: int) -> Any:
162
+ """Send *request* announcing a newer layer for this call only.
163
+
164
+ The connection negotiated its layer at `initConnection`; a method above it
165
+ is rejected until the wrapper says otherwise, and the wrapper applies to
166
+ exactly one request.
167
+ """
168
+ from telethon.tl.functions import InvokeWithLayerRequest
169
+
170
+ return await client(InvokeWithLayerRequest(layer, request))