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/accounts.py ADDED
@@ -0,0 +1,384 @@
1
+ """The alias registry: which accounts exist, which is active, and how each is.
2
+
3
+ Three things changed from v1 and each of them was a bug:
4
+
5
+ * **an alias is validated before it becomes a path** (SEC-02). v1 accepted
6
+ anything `str.isalnum()` liked after stripping `_` and `-`, which passes for
7
+ the empty string and for a 4 KB name, and rejected nothing that mattered.
8
+ * **a read never creates.** `get_account_dir()` called `mkdir(parents=True)`,
9
+ so `tlgr account list` materialised a directory for an alias that did not
10
+ exist, and a typo left litter behind.
11
+ * **health is persisted here.** The daemon writes each account's state
12
+ (`online`/`degraded`/`needs_login`/`frozen`) into this file, so
13
+ `tlgr account list` tells the truth about an account even when the daemon is
14
+ not running — which is exactly when you ask.
15
+
16
+ Order is preserved everywhere: `list_accounts()` and `connect_order()` return
17
+ lists, never a `set`. v1 connected "whichever alias came first out of a set",
18
+ which is COR-02 — a two-account user could send from the wrong identity.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import shutil
26
+ from dataclasses import asdict, dataclass, field
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from tlgr.core.errors import AccountNotFoundError, TlgrError
32
+ from tlgr.core.paths import TlgrPaths, validate_alias, write_private
33
+
34
+ ACCOUNTS_FILE = "accounts.json"
35
+
36
+ #: The states an `AccountSession` can persist (§6.2). `unknown` is what an
37
+ #: account reads as before a daemon has ever held it.
38
+ ACCOUNT_STATES = (
39
+ "unknown",
40
+ "starting",
41
+ "online",
42
+ "degraded",
43
+ "needs_login",
44
+ "frozen",
45
+ "stopped",
46
+ )
47
+
48
+
49
+ def _now() -> str:
50
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
51
+
52
+
53
+ @dataclass
54
+ class AccountHealth:
55
+ """What the daemon last knew about an account."""
56
+
57
+ state: str = "unknown"
58
+ reason: str = ""
59
+ since: str = ""
60
+ user_id: int | None = None
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ return asdict(self)
64
+
65
+
66
+ @dataclass
67
+ class AccountInfo:
68
+ alias: str
69
+ phone: str | None = None
70
+ username: str | None = None
71
+ first_name: str | None = None
72
+ user_id: int | None = None
73
+ created_at: str | None = None
74
+ health: AccountHealth = field(default_factory=AccountHealth)
75
+
76
+ def to_dict(self) -> dict[str, Any]:
77
+ data = asdict(self)
78
+ return data
79
+
80
+ @classmethod
81
+ def from_dict(cls, data: dict[str, Any]) -> AccountInfo:
82
+ known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
83
+ raw_health = known.pop("health", None)
84
+ health = AccountHealth()
85
+ if isinstance(raw_health, dict):
86
+ health = AccountHealth(
87
+ **{k: v for k, v in raw_health.items() if k in AccountHealth.__dataclass_fields__}
88
+ )
89
+ return cls(**known, health=health)
90
+
91
+ def display_name(self) -> str:
92
+ if self.username:
93
+ return f"@{self.username}"
94
+ if self.first_name:
95
+ return self.first_name
96
+ if self.phone:
97
+ return f"{self.phone[:4]}...{self.phone[-2:]}"
98
+ return self.alias
99
+
100
+
101
+ class AccountManager:
102
+ def __init__(self, base_dir: Path | None = None):
103
+ self.paths = TlgrPaths(base_dir)
104
+ self.base_dir = self.paths.base
105
+ self.accounts_file = self.paths.accounts_file
106
+ self._data: dict[str, Any] | None = None
107
+
108
+ # -- persistence -------------------------------------------------------
109
+
110
+ @property
111
+ def accounts_dir(self) -> Path:
112
+ """The accounts directory. Reading this never creates it."""
113
+ return self.paths.accounts
114
+
115
+ def _load(self) -> dict[str, Any]:
116
+ if self._data is not None:
117
+ return self._data
118
+ if not self.accounts_file.exists():
119
+ self._data = {"active": None, "accounts": {}}
120
+ return self._data
121
+ try:
122
+ with open(self.accounts_file) as handle:
123
+ loaded = json.load(handle)
124
+ if not isinstance(loaded, dict):
125
+ raise ValueError("accounts.json is not an object")
126
+ loaded.setdefault("accounts", {})
127
+ loaded.setdefault("active", None)
128
+ self._data = loaded
129
+ except (OSError, json.JSONDecodeError, ValueError):
130
+ self._data = {"active": None, "accounts": {}}
131
+ return self._data
132
+
133
+ def reload(self) -> None:
134
+ """Drop the cached view so the next read sees another process's write."""
135
+ self._data = None
136
+
137
+ def _save(self) -> None:
138
+ if self._data is None:
139
+ return
140
+ write_private(self.accounts_file, json.dumps(self._data, indent=2, ensure_ascii=False))
141
+
142
+ # -- active alias ------------------------------------------------------
143
+
144
+ def get_active(self) -> str | None:
145
+ data = self._load()
146
+ active = data.get("active")
147
+ if active and active in data.get("accounts", {}):
148
+ return str(active)
149
+ accounts = data.get("accounts", {})
150
+ if accounts:
151
+ first = next(iter(accounts))
152
+ self.set_active(first)
153
+ return str(first)
154
+ return None
155
+
156
+ def set_active(self, alias: str) -> bool:
157
+ data = self._load()
158
+ if alias not in data.get("accounts", {}):
159
+ return False
160
+ data["active"] = alias
161
+ self._save()
162
+ return True
163
+
164
+ # -- queries -----------------------------------------------------------
165
+
166
+ def list_accounts(self) -> list[AccountInfo]:
167
+ data = self._load()
168
+ return [AccountInfo.from_dict(v) for v in data.get("accounts", {}).values()]
169
+
170
+ def aliases(self) -> list[str]:
171
+ """Registered aliases, in insertion order."""
172
+ return list(self._load().get("accounts", {}))
173
+
174
+ def get_account(self, alias: str) -> AccountInfo | None:
175
+ data = self._load()
176
+ info = data.get("accounts", {}).get(alias)
177
+ return AccountInfo.from_dict(info) if info else None
178
+
179
+ def require_account(self, alias: str) -> AccountInfo:
180
+ validate_alias(alias)
181
+ info = self.get_account(alias)
182
+ if info is None:
183
+ raise AccountNotFoundError(f"no account named {alias!r}")
184
+ return info
185
+
186
+ def has_accounts(self) -> bool:
187
+ return bool(self._load().get("accounts"))
188
+
189
+ def connect_order(self, config_default: str = "", extra: list[str] | None = None) -> list[str]:
190
+ """The ordered list of aliases the daemon connects at start (§6.1).
191
+
192
+ A *list*: the union used in v1 was a `set`, so the daemon's connect
193
+ order — and therefore which account answered an under-specified
194
+ request — depended on hash randomisation.
195
+ """
196
+ ordered: list[str] = []
197
+ known = set(self.aliases())
198
+
199
+ def push(alias: str | None) -> None:
200
+ if alias and alias in known and alias not in ordered:
201
+ ordered.append(alias)
202
+
203
+ for alias in extra or []:
204
+ push(alias)
205
+ push(config_default)
206
+ push(self._load().get("active"))
207
+ return ordered
208
+
209
+ # -- mutation ----------------------------------------------------------
210
+
211
+ def add_account(self, alias: str) -> AccountInfo:
212
+ validate_alias(alias)
213
+ data = self._load()
214
+ if alias in data.get("accounts", {}):
215
+ raise TlgrError(f"Account '{alias}' already exists")
216
+ self.paths.ensure_account_dir(alias)
217
+ account = AccountInfo(alias=alias, created_at=datetime.now().isoformat())
218
+ data.setdefault("accounts", {})[alias] = account.to_dict()
219
+ if not data.get("active"):
220
+ data["active"] = alias
221
+ self._save()
222
+ return account
223
+
224
+ def update_account(
225
+ self,
226
+ alias: str,
227
+ *,
228
+ phone: str | None = None,
229
+ username: str | None = None,
230
+ first_name: str | None = None,
231
+ user_id: int | None = None,
232
+ ) -> AccountInfo | None:
233
+ data = self._load()
234
+ if alias not in data.get("accounts", {}):
235
+ return None
236
+ stored = data["accounts"][alias]
237
+ if phone is not None:
238
+ stored["phone"] = phone
239
+ if username is not None:
240
+ stored["username"] = username
241
+ if first_name is not None:
242
+ stored["first_name"] = first_name
243
+ if user_id is not None:
244
+ stored["user_id"] = user_id
245
+ self._save()
246
+ return AccountInfo.from_dict(stored)
247
+
248
+ def set_health(
249
+ self,
250
+ alias: str,
251
+ state: str,
252
+ *,
253
+ reason: str = "",
254
+ user_id: int | None = None,
255
+ ) -> None:
256
+ """Record what the daemon knows about *alias* so the CLI can read it.
257
+
258
+ Written even when the alias is unknown to the registry (an imported
259
+ session that was never `account add`-ed still has a health story), so
260
+ this never raises on the daemon's hot path.
261
+ """
262
+ validate_alias(alias)
263
+ self.reload()
264
+ data = self._load()
265
+ accounts = data.setdefault("accounts", {})
266
+ stored = accounts.setdefault(alias, AccountInfo(alias=alias).to_dict())
267
+ previous = stored.get("health") or {}
268
+ if previous.get("state") == state and previous.get("reason", "") == reason:
269
+ since = previous.get("since") or _now()
270
+ else:
271
+ since = _now()
272
+ stored["health"] = {
273
+ "state": state,
274
+ "reason": reason,
275
+ "since": since,
276
+ "user_id": user_id if user_id is not None else previous.get("user_id"),
277
+ }
278
+ if user_id is not None:
279
+ stored["user_id"] = user_id
280
+ self._save()
281
+
282
+ def get_health(self, alias: str) -> AccountHealth:
283
+ info = self.get_account(alias)
284
+ return info.health if info else AccountHealth()
285
+
286
+ def remove_account(self, alias: str, delete_data: bool = True) -> bool:
287
+ validate_alias(alias)
288
+ data = self._load()
289
+ if alias not in data.get("accounts", {}):
290
+ return False
291
+ del data["accounts"][alias]
292
+ if data.get("active") == alias:
293
+ remaining = list(data["accounts"])
294
+ data["active"] = remaining[0] if remaining else None
295
+ self._save()
296
+ if delete_data:
297
+ account_dir = self.paths.account_dir(alias)
298
+ if account_dir.exists():
299
+ shutil.rmtree(account_dir)
300
+ return True
301
+
302
+ def rename_account(self, old_alias: str, new_alias: str) -> bool:
303
+ validate_alias(old_alias)
304
+ validate_alias(new_alias)
305
+ data = self._load()
306
+ if old_alias not in data.get("accounts", {}):
307
+ return False
308
+ if new_alias in data.get("accounts", {}):
309
+ raise TlgrError(f"Account '{new_alias}' already exists")
310
+ stored = data["accounts"].pop(old_alias)
311
+ stored["alias"] = new_alias
312
+ data["accounts"][new_alias] = stored
313
+ if data.get("active") == old_alias:
314
+ data["active"] = new_alias
315
+ self._save()
316
+ old_dir = self.paths.account_dir(old_alias)
317
+ new_dir = self.paths.account_dir(new_alias)
318
+ if old_dir.exists():
319
+ old_dir.rename(new_dir)
320
+ return True
321
+
322
+ # -- paths -------------------------------------------------------------
323
+
324
+ def _resolve_alias(self, alias: str | None) -> str:
325
+ if alias is None:
326
+ alias = self.get_active()
327
+ if not alias:
328
+ raise TlgrError("No account specified and no active account")
329
+ return validate_alias(alias)
330
+
331
+ def get_account_dir(self, alias: str | None = None, *, create: bool = False) -> Path:
332
+ """The account's directory. Does **not** create it unless asked."""
333
+ resolved = self._resolve_alias(alias)
334
+ if create:
335
+ return self.paths.ensure_account_dir(resolved)
336
+ return self.paths.account_dir(resolved)
337
+
338
+ def ensure_account_dir(self, alias: str | None = None) -> Path:
339
+ return self.get_account_dir(alias, create=True)
340
+
341
+ def get_session_path(self, alias: str | None = None) -> Path:
342
+ return self.paths.session(self._resolve_alias(alias))
343
+
344
+ def get_session_lock_path(self, alias: str | None = None) -> Path:
345
+ return self.paths.session_lock(self._resolve_alias(alias))
346
+
347
+ def get_credentials_path(self, alias: str | None = None) -> Path:
348
+ return self.paths.credentials(self._resolve_alias(alias))
349
+
350
+ # -- credentials -------------------------------------------------------
351
+
352
+ def load_credentials(self, alias: str | None = None) -> tuple[int | None, str | None]:
353
+ cred_path = self.get_credentials_path(alias)
354
+ api_id: int | None = None
355
+ api_hash: str | None = None
356
+
357
+ if cred_path.exists():
358
+ try:
359
+ with open(cred_path) as handle:
360
+ data = json.load(handle)
361
+ api_id = data.get("api_id")
362
+ api_hash = data.get("api_hash")
363
+ except (OSError, json.JSONDecodeError):
364
+ pass
365
+
366
+ env_id = os.environ.get("TELEGRAM_API_ID")
367
+ env_hash = os.environ.get("TELEGRAM_API_HASH")
368
+ if env_id:
369
+ try:
370
+ api_id = int(env_id)
371
+ except ValueError:
372
+ pass
373
+ if env_hash:
374
+ api_hash = env_hash
375
+
376
+ return api_id, api_hash
377
+
378
+ def save_credentials(self, api_id: int, api_hash: str, alias: str | None = None) -> None:
379
+ resolved = self._resolve_alias(alias)
380
+ self.paths.ensure_account_dir(resolved)
381
+ write_private(
382
+ self.paths.credentials(resolved),
383
+ json.dumps({"api_id": api_id, "api_hash": api_hash}, indent=2),
384
+ )