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/paths.py ADDED
@@ -0,0 +1,360 @@
1
+ """The `~/.tlgr` layout, in one module.
2
+
3
+ Every path tlgr writes is built here, and every one of them is built from a
4
+ *validated* alias. SEC-02 was the absence of that: an alias went straight into
5
+ `accounts_dir / alias`, so `-a ../../etc` addressed a directory outside the
6
+ tree, and a read path called `mkdir(parents=True)` on the way, so merely
7
+ asking about an account created one.
8
+
9
+ Two rules follow from that and are enforced here rather than remembered at
10
+ each call site:
11
+
12
+ * `validate_alias()` is called before an alias becomes part of a path;
13
+ * a *read* path never creates anything. Only the handful of functions whose
14
+ name says so (`ensure_*`) make a directory.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import os
21
+ import re
22
+ import stat
23
+ import tempfile
24
+ from pathlib import Path
25
+
26
+ from tlgr.core.errors import ConfigurationError, UsageError
27
+
28
+ __all__ = [
29
+ "ALIAS_RE",
30
+ "PRODUCTION_MARKER",
31
+ "TlgrPaths",
32
+ "audit_permissions",
33
+ "default_base",
34
+ "is_production_home",
35
+ "refuse_production_home",
36
+ "secure_session_files",
37
+ "validate_alias",
38
+ "write_private",
39
+ ]
40
+
41
+ #: The one alias grammar. Anything outside it never reaches a path join.
42
+ ALIAS_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
43
+
44
+ _HOME_ENV = "TLGR_HOME"
45
+
46
+
47
+ def default_base() -> Path:
48
+ """`$TLGR_HOME` when set, else `~/.tlgr`.
49
+
50
+ Read at call time rather than captured at import so a test (and a user
51
+ with two profiles) can point the whole tree somewhere else.
52
+ """
53
+ override = os.environ.get(_HOME_ENV, "").strip()
54
+ if override:
55
+ return Path(override).expanduser()
56
+ return Path.home() / ".tlgr"
57
+
58
+
59
+ #: A home directory carrying this marker belongs to a live deployment.
60
+ PRODUCTION_MARKER = ".production"
61
+ _ALLOW_PRODUCTION_ENV = "TLGR_ALLOW_PRODUCTION_HOME"
62
+
63
+
64
+ def is_production_home(base: Path) -> bool:
65
+ """Is *base* marked as a live deployment?"""
66
+ try:
67
+ return (base / PRODUCTION_MARKER).exists()
68
+ except OSError: # pragma: no cover - an unreadable home is not ours to judge
69
+ return False
70
+
71
+
72
+ def refuse_production_home(base: Path) -> None:
73
+ """Refuse to operate on a home marked as production unless told to.
74
+
75
+ A development checkout that resolves to the same `~/.tlgr` as the
76
+ installed, running tlgr is a live deploy by accident: on 2026-09-03 a dev
77
+ daemon started from a worktree bound the production socket, held the
78
+ production session files (``database is locked``) and served a registry
79
+ without the message routes the running agent needed. `TLGR_HOME` already
80
+ lets a dev point elsewhere; this makes forgetting to set it fail fast
81
+ instead of silently taking over. Touch ``<home>/.production`` on the
82
+ deployment; set ``TLGR_ALLOW_PRODUCTION_HOME=1`` only from the installed
83
+ tlgr that is meant to run there.
84
+ """
85
+ if os.environ.get(_ALLOW_PRODUCTION_ENV, "").strip() == "1":
86
+ return
87
+ if (base / PRODUCTION_MARKER).exists():
88
+ raise ConfigurationError(
89
+ f"{base} is marked as a production home ({PRODUCTION_MARKER} present). "
90
+ "Set TLGR_HOME to a separate directory for development, or "
91
+ f"{_ALLOW_PRODUCTION_ENV}=1 when this really is the deployed tlgr."
92
+ )
93
+
94
+
95
+ def validate_alias(alias: str) -> str:
96
+ """Return *alias* if it can safely become a path segment, else raise.
97
+
98
+ Rejects the empty string, anything with a separator, `.`/`..`, and
99
+ anything over 64 characters — before the value is joined onto a path,
100
+ which is the only point at which the check is worth anything.
101
+ """
102
+ if not isinstance(alias, str) or not ALIAS_RE.match(alias):
103
+ raise UsageError(
104
+ f"invalid account alias {alias!r}: use 1-64 characters from A-Z, a-z, 0-9, '_' and '-'",
105
+ field="account",
106
+ )
107
+ return alias
108
+
109
+
110
+ def write_private(path: Path, data: bytes | str, *, mode: int = 0o600) -> None:
111
+ """Write *data* to *path* so that it is never briefly world-readable.
112
+
113
+ The write goes to a temporary file in the same directory, is chmod-ed
114
+ before it is named, and is then atomically renamed into place. Writing
115
+ the file first and fixing the mode afterwards (SEC-07) leaves a window in
116
+ which a session, a token or a config with an `api_hash` in it is readable
117
+ by every process on the machine.
118
+ """
119
+ payload = data.encode("utf-8") if isinstance(data, str) else data
120
+ parent = path.parent
121
+ parent.mkdir(parents=True, exist_ok=True, mode=0o700)
122
+ fd, tmp_name = tempfile.mkstemp(dir=str(parent), prefix=f".{path.name}.", suffix=".tmp")
123
+ tmp = Path(tmp_name)
124
+ try:
125
+ with os.fdopen(fd, "wb") as handle:
126
+ handle.write(payload)
127
+ handle.flush()
128
+ os.fsync(handle.fileno())
129
+ os.chmod(tmp, mode)
130
+ os.replace(tmp, path)
131
+ except BaseException:
132
+ tmp.unlink(missing_ok=True)
133
+ raise
134
+
135
+
136
+ def secure_session_files(session_path: Path) -> None:
137
+ """chmod 600 the session db and every sqlite sibling Telethon creates.
138
+
139
+ A session file is a complete account credential. v1 chmod-ed the one
140
+ imported by `account import` and left the one written by `account add`
141
+ at whatever the umask allowed — 0644 on a default Ubuntu box, which is
142
+ every other user on the machine holding the account.
143
+
144
+ Telethon appends `.session` and creates `-journal`/`-wal`/`-shm`
145
+ siblings at runtime, so the glob is the point: securing only the file
146
+ whose name we know leaves the write-ahead log readable.
147
+ """
148
+ parent = session_path.parent
149
+ if not parent.exists():
150
+ return
151
+ for path in parent.glob(session_path.name + "*"):
152
+ with contextlib.suppress(OSError):
153
+ path.chmod(0o600)
154
+
155
+
156
+ class TlgrPaths:
157
+ """Every path under one base directory (§10.1)."""
158
+
159
+ __slots__ = ("base",)
160
+
161
+ def __init__(self, base: Path | str | None = None) -> None:
162
+ self.base = Path(base) if base is not None else default_base()
163
+ refuse_production_home(self.base)
164
+
165
+ # -- top level ---------------------------------------------------------
166
+
167
+ @property
168
+ def config(self) -> Path:
169
+ return self.base / "config.toml"
170
+
171
+ @property
172
+ def accounts_file(self) -> Path:
173
+ return self.base / "accounts.json"
174
+
175
+ @property
176
+ def jobs(self) -> Path:
177
+ return self.base / "jobs.yaml"
178
+
179
+ @property
180
+ def webhook(self) -> Path:
181
+ return self.base / "webhook.toml"
182
+
183
+ @property
184
+ def socket(self) -> Path:
185
+ return self.base / "daemon.sock"
186
+
187
+ @property
188
+ def pid(self) -> Path:
189
+ return self.base / "daemon.pid"
190
+
191
+ @property
192
+ def lock(self) -> Path:
193
+ return self.base / "daemon.lock"
194
+
195
+ @property
196
+ def state(self) -> Path:
197
+ return self.base / "daemon.state"
198
+
199
+ @property
200
+ def token(self) -> Path:
201
+ return self.base / "ipc.token"
202
+
203
+ @property
204
+ def cursor_key(self) -> Path:
205
+ return self.base / "cursor.key"
206
+
207
+ @property
208
+ def identity(self) -> Path:
209
+ return self.base / "identity.json"
210
+
211
+ @property
212
+ def proxies(self) -> Path:
213
+ """Saved proxies, including their passwords and MTProxy secrets (0600)."""
214
+ return self.base / "proxies.json"
215
+
216
+ @property
217
+ def dead_letter(self) -> Path:
218
+ return self.base / "dead_letter.jsonl"
219
+
220
+ @property
221
+ def logs(self) -> Path:
222
+ return self.base / "logs"
223
+
224
+ @property
225
+ def log_file(self) -> Path:
226
+ return self.logs / "daemon.log"
227
+
228
+ @property
229
+ def downloads(self) -> Path:
230
+ return self.base / "downloads"
231
+
232
+ @property
233
+ def cache(self) -> Path:
234
+ return self.base / "cache"
235
+
236
+ @property
237
+ def accounts(self) -> Path:
238
+ return self.base / "accounts"
239
+
240
+ # -- per account -------------------------------------------------------
241
+
242
+ def account_dir(self, alias: str) -> Path:
243
+ return self.accounts / validate_alias(alias)
244
+
245
+ def session(self, alias: str) -> Path:
246
+ return self.account_dir(alias) / "session"
247
+
248
+ def session_file(self, alias: str) -> Path:
249
+ return self.account_dir(alias) / "session.session"
250
+
251
+ def session_lock(self, alias: str) -> Path:
252
+ return self.account_dir(alias) / "session.lock"
253
+
254
+ def credentials(self, alias: str) -> Path:
255
+ return self.account_dir(alias) / "config.json"
256
+
257
+ def peers_db(self, alias: str) -> Path:
258
+ return self.account_dir(alias) / "peers.json"
259
+
260
+ def flood(self, alias: str) -> Path:
261
+ return self.account_dir(alias) / "flood.json"
262
+
263
+ def events_state(self, alias: str) -> Path:
264
+ return self.account_dir(alias) / "events.state"
265
+
266
+ def outbox(self, alias: str) -> Path:
267
+ return self.account_dir(alias) / "outbox.jsonl"
268
+
269
+ # -- creation ----------------------------------------------------------
270
+
271
+ def ensure_base(self) -> Path:
272
+ self.base.mkdir(parents=True, exist_ok=True, mode=0o700)
273
+ return self.base
274
+
275
+ def ensure_logs(self) -> Path:
276
+ self.logs.mkdir(parents=True, exist_ok=True, mode=0o700)
277
+ return self.logs
278
+
279
+ def ensure_downloads(self) -> Path:
280
+ self.downloads.mkdir(parents=True, exist_ok=True, mode=0o700)
281
+ return self.downloads
282
+
283
+ def ensure_cache(self) -> Path:
284
+ self.cache.mkdir(parents=True, exist_ok=True, mode=0o700)
285
+ return self.cache
286
+
287
+ def ensure_account_dir(self, alias: str) -> Path:
288
+ directory = self.account_dir(alias)
289
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
290
+ return directory
291
+
292
+
293
+ #: Files whose leak is equivalent to handing over the account.
294
+ _SECRET_GLOBS = (
295
+ "accounts/*/session*",
296
+ "accounts/*/config.json",
297
+ "ipc.token",
298
+ "cursor.key",
299
+ "webhook.toml",
300
+ "proxies.json",
301
+ )
302
+
303
+ _SECRET_MODE = 0o600
304
+ _DIR_MODE = 0o700
305
+
306
+
307
+ def audit_permissions(base: Path, *, fix: bool = True) -> list[str]:
308
+ """Check (and optionally fix) the modes of everything secret under *base*.
309
+
310
+ Returns the list of problems that could **not** be fixed; the daemon
311
+ refuses to start when it is non-empty rather than opening a session file
312
+ that the rest of the machine can read. Ownership is checked too: a file
313
+ owned by someone else cannot be chmod-ed by us and is always a refusal.
314
+ """
315
+ problems: list[str] = []
316
+ uid = os.getuid()
317
+
318
+ def check(path: Path, want: int) -> None:
319
+ try:
320
+ info = path.lstat()
321
+ except OSError:
322
+ return
323
+ if info.st_uid != uid:
324
+ problems.append(f"{path} is owned by uid {info.st_uid}, not {uid}")
325
+ return
326
+ actual = stat.S_IMODE(info.st_mode)
327
+ if actual & ~want:
328
+ if not fix:
329
+ problems.append(f"{path} is mode {actual:04o}, want {want:04o}")
330
+ return
331
+ try:
332
+ os.chmod(path, want)
333
+ except OSError as exc:
334
+ problems.append(f"{path} is mode {actual:04o}; chmod {want:04o} failed: {exc}")
335
+
336
+ if base.exists():
337
+ check(base, _DIR_MODE)
338
+ accounts = base / "accounts"
339
+ if accounts.exists():
340
+ check(accounts, _DIR_MODE)
341
+ for child in sorted(accounts.iterdir()):
342
+ if child.is_dir():
343
+ check(child, _DIR_MODE)
344
+ for pattern in _SECRET_GLOBS:
345
+ for path in sorted(base.glob(pattern)):
346
+ if path.is_file():
347
+ check(path, _SECRET_MODE)
348
+ return problems
349
+
350
+
351
+ def require_safe_permissions(base: Path) -> None:
352
+ """Raise `ConfigurationError` naming the exact `chmod` when the tree is unsafe."""
353
+ problems = audit_permissions(base, fix=True)
354
+ if problems:
355
+ listing = "\n ".join(problems)
356
+ raise ConfigurationError(
357
+ "refusing to start: files under "
358
+ f"{base} are not private:\n {listing}\n"
359
+ f"Fix with: chmod -R go-rwx {base}"
360
+ )