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/ops/_calls.py ADDED
@@ -0,0 +1,535 @@
1
+ """Shared plumbing for the three call groups: `call`, `vc` and `conference`.
2
+
3
+ Three things live here because all three groups need them and none of them
4
+ owns them.
5
+
6
+ * **How a call is addressed.** A 1:1 call is `(id, access_hash)`; a group call
7
+ is that, or a conference `slug`, or the invitation service message, or just
8
+ "the video chat in this chat". One resolver understands all of it, so `vc
9
+ get @team`, `vc get 123:456` and `vc get t.me/call/AbCd` are the same
10
+ command rather than three.
11
+ * **What tlgr remembers.** Telegram hands out a call's `access_hash` exactly
12
+ once, in the update that created the call. A CLI whose process ends between
13
+ "ring" and "hang up" would otherwise be unable to hang up, so the daemon
14
+ keeps the live calls it has seen and `call end 12345` works with the bare
15
+ id. The store is per account and in memory: it describes a connection, not
16
+ a fact worth persisting.
17
+ * **The honest boundary.** tlgr speaks the signalling half of MTProto and has
18
+ no media engine. `MEDIA_NONE` is stamped on every answer that would
19
+ otherwise be mistaken for "you are now in a call".
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import hashlib
26
+ import secrets
27
+ from dataclasses import dataclass, field
28
+ from functools import lru_cache
29
+ from typing import Any
30
+
31
+ from tlgr.core.errors import NotFoundError, UsageError
32
+ from tlgr.models.call import CallRef
33
+ from tlgr.models.peer import parse_peer_ref
34
+ from tlgr.ops._spec import OpContext
35
+
36
+ __all__ = [
37
+ "CallHandle",
38
+ "LiveCall",
39
+ "app_config",
40
+ "call_ref_of",
41
+ "discard_reason",
42
+ "forget_call",
43
+ "input_phone_call",
44
+ "live_calls",
45
+ "protocol",
46
+ "remember_call",
47
+ "resolve_call",
48
+ "state_name",
49
+ ]
50
+
51
+ #: The frozen legacy constant set every official client still sends. These are
52
+ #: not tlgr's capabilities — tlgr has no media engine at all — they are what
53
+ #: the peer's client needs to see to consider the call negotiable.
54
+ PROTOCOL_MIN_LAYER = 65
55
+ PROTOCOL_MAX_LAYER = 92
56
+ LIBRARY_VERSIONS = ("2.4.4", "2.7.7", "5.0.0")
57
+
58
+ #: `phoneCall*` constructor → the word tlgr reports. The constructor name is
59
+ #: the only place the ringing state machine is written down.
60
+ CALL_STATES = {
61
+ "PhoneCallEmpty": "empty",
62
+ "PhoneCallWaiting": "waiting",
63
+ "PhoneCallRequested": "requested",
64
+ "PhoneCallAccepted": "accepted",
65
+ "PhoneCall": "active",
66
+ "PhoneCallDiscarded": "discarded",
67
+ }
68
+
69
+ DISCARD_REASONS = ("missed", "busy", "hangup", "disconnect")
70
+
71
+ _REASON_CLASSES = {
72
+ "missed": "PhoneCallDiscardReasonMissed",
73
+ "busy": "PhoneCallDiscardReasonBusy",
74
+ "hangup": "PhoneCallDiscardReasonHangup",
75
+ "disconnect": "PhoneCallDiscardReasonDisconnect",
76
+ }
77
+
78
+ #: The app-config keys the call surface reads. Kept as one list so `call
79
+ #: config get` and `vc get --limits` cannot disagree about what a limit is
80
+ #: called.
81
+ APP_CONFIG_KEYS = (
82
+ "call_requests_disabled",
83
+ "conference_call_size_limit",
84
+ "group_call_message_length_limit",
85
+ "group_call_message_ttl",
86
+ "groupcall_video_participants_max",
87
+ "stars_groupcall_message_amount_max",
88
+ "stars_groupcall_message_limits",
89
+ )
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # The live-call store
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ @dataclass
98
+ class LiveCall:
99
+ """A 1:1 call this daemon has seen, and the secrets it holds for it.
100
+
101
+ `a`, `g_a` and `p` exist only when *this* session ran the key exchange;
102
+ they never leave the process and are what makes an emoji fingerprint
103
+ derivable at all. Nothing here is written to disk.
104
+ """
105
+
106
+ id: int
107
+ access_hash: int
108
+ state: str = "waiting"
109
+ video: bool = False
110
+ out: bool = False
111
+ peer_id: int | None = None
112
+ admin_id: int | None = None
113
+ participant_id: int | None = None
114
+ conference_supported: bool | None = None
115
+ connections: int = 0
116
+ need_rating: bool | None = None
117
+ need_debug: bool | None = None
118
+ reason: str | None = None
119
+ duration: int | None = None
120
+ a: int | None = None
121
+ p: int | None = None
122
+ g: int | None = None
123
+ g_a: bytes | None = None
124
+ key: bytes | None = None
125
+ discarded: bool = False
126
+
127
+
128
+ @dataclass
129
+ class _Store:
130
+ calls: dict[int, LiveCall] = field(default_factory=dict)
131
+
132
+
133
+ _STORES: dict[str, _Store] = {}
134
+
135
+
136
+ def live_calls(account: str) -> dict[int, LiveCall]:
137
+ """Every call this daemon currently knows about for *account*."""
138
+ return _STORES.setdefault(account, _Store()).calls
139
+
140
+
141
+ def remember_call(account: str, call: LiveCall) -> LiveCall:
142
+ """Record (or merge into) what we know about a call."""
143
+ known = live_calls(account).get(call.id)
144
+ if known is None:
145
+ live_calls(account)[call.id] = call
146
+ return call
147
+ for name, value in vars(call).items():
148
+ if value not in (None, 0, False, "") or getattr(known, name, None) is None:
149
+ setattr(known, name, value)
150
+ return known
151
+
152
+
153
+ def forget_call(account: str, call_id: int) -> None:
154
+ live_calls(account).pop(call_id, None)
155
+
156
+
157
+ def reset_calls() -> None:
158
+ """Drop every remembered call. For tests, and for a daemon restart."""
159
+ _STORES.clear()
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # 1:1 call references
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def parse_call_id(text: str) -> tuple[int, int | None]:
168
+ """`"12345"` or `"12345:678"` → `(id, access_hash | None)`."""
169
+ raw = str(text or "").strip()
170
+ if not raw:
171
+ raise UsageError("a call id is required", field="call")
172
+ head, _, tail = raw.partition(":")
173
+ try:
174
+ call_id = int(head)
175
+ except ValueError as exc:
176
+ raise UsageError(f"{raw!r} is not a call id", field="call") from exc
177
+ if not tail:
178
+ return call_id, None
179
+ try:
180
+ return call_id, int(tail)
181
+ except ValueError as exc:
182
+ raise UsageError(f"{raw!r} is not an id:access_hash pair", field="call") from exc
183
+
184
+
185
+ def input_phone_call(ctx: OpContext, text: str) -> tuple[Any, LiveCall | None]:
186
+ """The `InputPhoneCall` for a call reference, plus what we remember of it.
187
+
188
+ An id on its own is resolved through the store, which is why `call end
189
+ 12345` works: the access hash arrived in an update, not from the user.
190
+ """
191
+ from telethon.tl import types
192
+
193
+ call_id, access_hash = parse_call_id(text)
194
+ known = live_calls(ctx.account).get(call_id)
195
+ if access_hash is None:
196
+ if known is None:
197
+ raise NotFoundError(
198
+ f"call {call_id} is not one this daemon has seen; "
199
+ "give it as id:access_hash, or run `tlgr call watch` to pick it up"
200
+ )
201
+ access_hash = known.access_hash
202
+ elif known is None:
203
+ # A caller who typed the access hash knows something the daemon does
204
+ # not; remembering it is what makes the *next* command work with the
205
+ # bare id, which is the whole point of the store.
206
+ known = remember_call(
207
+ ctx.account, LiveCall(id=call_id, access_hash=access_hash, state="unknown")
208
+ )
209
+ return types.InputPhoneCall(id=call_id, access_hash=access_hash), known
210
+
211
+
212
+ def protocol() -> Any:
213
+ """The `phoneCallProtocol` every client sends, tlgr included."""
214
+ from telethon.tl import types
215
+
216
+ return types.PhoneCallProtocol(
217
+ min_layer=PROTOCOL_MIN_LAYER,
218
+ max_layer=PROTOCOL_MAX_LAYER,
219
+ library_versions=list(LIBRARY_VERSIONS),
220
+ udp_p2p=True,
221
+ udp_reflector=True,
222
+ )
223
+
224
+
225
+ def protocol_dict() -> dict[str, Any]:
226
+ return {
227
+ "min_layer": PROTOCOL_MIN_LAYER,
228
+ "max_layer": PROTOCOL_MAX_LAYER,
229
+ "udp_p2p": True,
230
+ "udp_reflector": True,
231
+ "library_versions": list(LIBRARY_VERSIONS),
232
+ }
233
+
234
+
235
+ def discard_reason(name: str) -> Any:
236
+ """`"hangup"` → `PhoneCallDiscardReasonHangup()`."""
237
+ from telethon.tl import types
238
+
239
+ class_name = _REASON_CLASSES.get(str(name or "").lower())
240
+ if class_name is None:
241
+ raise UsageError(
242
+ f"--reason {name!r} is not a discard reason; expected one of "
243
+ + ", ".join(DISCARD_REASONS),
244
+ field="reason",
245
+ )
246
+ return getattr(types, class_name)()
247
+
248
+
249
+ def reason_name(reason: Any) -> str | None:
250
+ """A `phoneCallDiscardReason*` back to its word."""
251
+ if reason is None:
252
+ return None
253
+ name = type(reason).__name__
254
+ for word, class_name in _REASON_CLASSES.items():
255
+ if class_name == name:
256
+ return word
257
+ if name == "PhoneCallDiscardReasonMigrateConferenceCall":
258
+ return "migrate-conference"
259
+ return name
260
+
261
+
262
+ def state_name(call: Any) -> str:
263
+ return CALL_STATES.get(type(call).__name__, "unknown")
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Group call references
268
+ # ---------------------------------------------------------------------------
269
+
270
+
271
+ @dataclass
272
+ class CallHandle:
273
+ """A resolved group call: what to send, and what to echo back."""
274
+
275
+ input: Any
276
+ ref: CallRef
277
+ chat: Any = None
278
+ call: Any = None
279
+
280
+
281
+ def call_ref_of(call: Any, *, slug: str | None = None, msg_id: int | None = None) -> CallRef:
282
+ """A `groupCall`/`inputGroupCall` as the reference every response carries."""
283
+ return CallRef(
284
+ id=int(getattr(call, "id", 0) or 0),
285
+ access_hash=getattr(call, "access_hash", None),
286
+ slug=slug,
287
+ msg_id=msg_id,
288
+ )
289
+
290
+
291
+ def _slug_from_link(text: str) -> str | None:
292
+ """`t.me/call/AbCd` or `tg://call?slug=AbCd` → `AbCd`."""
293
+ lowered = text.lower()
294
+ if "t.me/call/" in lowered:
295
+ return text.split("/call/", 1)[1].split("?", 1)[0].strip("/")
296
+ if lowered.startswith("tg://call") and "slug=" in lowered:
297
+ return text.split("slug=", 1)[1].split("&", 1)[0]
298
+ if lowered.startswith("slug:"):
299
+ return text.split(":", 1)[1]
300
+ return None
301
+
302
+
303
+ async def _chat_call(ctx: OpContext, ref: str) -> CallHandle:
304
+ """The active (or scheduled) group call of a chat."""
305
+ from telethon.tl.functions import channels, messages
306
+
307
+ from tlgr.ops import _send
308
+
309
+ peer = await _send.resolve(ctx, parse_peer_ref(ref))
310
+ client = getattr(ctx, "client", None)
311
+ if client is None: # pragma: no cover - the daemon always supplies one
312
+ raise UsageError("this operation needs a connected account")
313
+
314
+ name = type(peer).__name__
315
+ if name == "InputPeerChannel":
316
+ from telethon import utils
317
+
318
+ full = await client(channels.GetFullChannelRequest(utils.get_input_channel(peer)))
319
+ elif name == "InputPeerChat":
320
+ full = await client(messages.GetFullChatRequest(chat_id=peer.chat_id))
321
+ else:
322
+ raise UsageError(
323
+ "a 1:1 chat has no video chat; give a group, a channel or a call link",
324
+ field="call",
325
+ )
326
+ call = getattr(getattr(full, "full_chat", None), "call", None)
327
+ if call is None:
328
+ raise NotFoundError("that chat has no video chat running or scheduled")
329
+ return CallHandle(input=call, ref=call_ref_of(call), chat=peer)
330
+
331
+
332
+ async def resolve_call(ctx: OpContext, ref: str) -> CallHandle:
333
+ """Any way a human names a group call → the `InputGroupCall` to send.
334
+
335
+ Accepted: `id:access_hash`, `msg:<id>` for an invitation service message,
336
+ a `t.me/call/<slug>` or `tg://call?slug=` link, `slug:<slug>`, or a chat
337
+ (its video chat). A bare word is tried as a chat first and as a slug
338
+ second, because usernames and conference slugs share a shape and the chat
339
+ is overwhelmingly the common case.
340
+ """
341
+ from telethon.tl import types
342
+
343
+ raw = str(ref or "").strip()
344
+ if not raw:
345
+ raise UsageError("a call reference is required", field="call")
346
+
347
+ if raw.lower().startswith("msg:"):
348
+ try:
349
+ msg_id = int(raw.split(":", 1)[1])
350
+ except ValueError as exc:
351
+ raise UsageError(f"{raw!r} is not msg:<id>", field="call") from exc
352
+ return CallHandle(
353
+ input=types.InputGroupCallInviteMessage(msg_id=msg_id),
354
+ ref=CallRef(msg_id=msg_id),
355
+ )
356
+
357
+ slug = _slug_from_link(raw)
358
+ if slug:
359
+ return CallHandle(input=types.InputGroupCallSlug(slug=slug), ref=CallRef(slug=slug))
360
+
361
+ head, _, tail = raw.partition(":")
362
+ if tail and head.lstrip("-").isdigit() and tail.lstrip("-").isdigit():
363
+ call = types.InputGroupCall(id=int(head), access_hash=int(tail))
364
+ return CallHandle(input=call, ref=call_ref_of(call))
365
+
366
+ try:
367
+ return await _chat_call(ctx, raw)
368
+ except (NotFoundError, UsageError, ValueError):
369
+ if raw.startswith("@") or raw.lstrip("-").isdigit() or "/" in raw:
370
+ raise
371
+ return CallHandle(input=types.InputGroupCallSlug(slug=raw), ref=CallRef(slug=raw))
372
+
373
+
374
+ async def concrete_call(ctx: OpContext, handle: CallHandle) -> CallHandle:
375
+ """Turn a slug/invitation handle into the `(id, access_hash)` pair.
376
+
377
+ Several `phone.*` methods only accept the concrete constructor, and a slug
378
+ is one `getGroupCall` away from it — asking here is cheaper than every
379
+ caller getting the distinction right.
380
+ """
381
+ from telethon.tl import types
382
+ from telethon.tl.functions import phone
383
+
384
+ if isinstance(handle.input, types.InputGroupCall):
385
+ return handle
386
+ client = getattr(ctx, "client", None)
387
+ if client is None: # pragma: no cover
388
+ raise UsageError("this operation needs a connected account")
389
+ result = await client(phone.GetGroupCallRequest(call=handle.input, limit=0))
390
+ call = getattr(result, "call", None)
391
+ if call is None or getattr(call, "access_hash", None) is None:
392
+ raise NotFoundError("that call link does not resolve to a call any more")
393
+ return CallHandle(
394
+ input=types.InputGroupCall(id=call.id, access_hash=call.access_hash),
395
+ ref=call_ref_of(call, slug=handle.ref.slug, msg_id=handle.ref.msg_id),
396
+ chat=handle.chat,
397
+ call=call,
398
+ )
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # App config
403
+ # ---------------------------------------------------------------------------
404
+
405
+
406
+ def _json_value(node: Any) -> Any:
407
+ """A `JSONValue` tree as plain Python."""
408
+ name = type(node).__name__
409
+ if name == "JsonNull":
410
+ return None
411
+ if name in ("JsonBool", "JsonNumber", "JsonString"):
412
+ return node.value
413
+ if name == "JsonArray":
414
+ return [_json_value(item) for item in node.value]
415
+ if name == "JsonObject":
416
+ return {item.key: _json_value(item.value) for item in node.value}
417
+ return node
418
+
419
+
420
+ async def app_config(ctx: OpContext) -> dict[str, Any]:
421
+ """The call-related keys of `help.getAppConfig`, as plain values.
422
+
423
+ Failures are swallowed into an empty dict on purpose: a missing limit must
424
+ degrade the answer, never fail the command that only wanted to mention it.
425
+ """
426
+ from telethon.tl.functions import help as help_fn
427
+
428
+ client = getattr(ctx, "client", None)
429
+ if client is None: # pragma: no cover
430
+ return {}
431
+ try:
432
+ result = await client(help_fn.GetAppConfigRequest(hash=0))
433
+ except Exception: # pragma: no cover - a diagnostic must not fail the op
434
+ return {}
435
+ config = _json_value(getattr(result, "config", None))
436
+ if not isinstance(config, dict):
437
+ return {}
438
+ return {key: config[key] for key in APP_CONFIG_KEYS if key in config}
439
+
440
+
441
+ # ---------------------------------------------------------------------------
442
+ # Diffie-Hellman
443
+ # ---------------------------------------------------------------------------
444
+
445
+
446
+ def _is_probable_prime(n: int, rounds: int = 16) -> bool:
447
+ """Miller-Rabin. Enough rounds that a composite slipping through is not
448
+ the reason a key exchange failed."""
449
+ if n < 2:
450
+ return False
451
+ for small in (2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37):
452
+ if n % small == 0:
453
+ return n == small
454
+ d, r = n - 1, 0
455
+ while d % 2 == 0:
456
+ d //= 2
457
+ r += 1
458
+ for _ in range(rounds):
459
+ a = secrets.randbelow(n - 3) + 2
460
+ x = pow(a, d, n)
461
+ if x in (1, n - 1):
462
+ continue
463
+ for _ in range(r - 1):
464
+ x = x * x % n
465
+ if x == n - 1:
466
+ break
467
+ else:
468
+ return False
469
+ return True
470
+
471
+
472
+ @lru_cache(maxsize=8)
473
+ def validate_dh(p_bytes: bytes, g: int) -> dict[str, Any]:
474
+ """Check the server's DH parameters instead of trusting them.
475
+
476
+ A client that accepts whatever prime the server sends has not performed a
477
+ key exchange; it has accepted a key the server chose. The checks are the
478
+ documented ones: 2048 bits, `p` and `(p-1)/2` prime, and the residue
479
+ condition for the generator.
480
+
481
+ Cached: the parameters change roughly never, and two Miller-Rabin runs
482
+ over a 2048-bit number cost about half a second — worth paying once per
483
+ prime rather than once per call.
484
+ """
485
+ p = int.from_bytes(p_bytes, "big")
486
+ checks: dict[str, Any] = {
487
+ "bits": p.bit_length(),
488
+ "size_ok": p.bit_length() == 2048,
489
+ "generator": g,
490
+ }
491
+ residue = {
492
+ 2: p % 8 == 7,
493
+ 3: p % 3 == 2,
494
+ 4: True,
495
+ 5: p % 5 in (1, 4),
496
+ 6: p % 24 in (19, 23),
497
+ 7: p % 7 in (3, 5, 6),
498
+ }
499
+ checks["generator_ok"] = residue.get(g, False)
500
+ checks["prime"] = _is_probable_prime(p)
501
+ checks["safe_prime"] = checks["prime"] and _is_probable_prime((p - 1) // 2)
502
+ checks["ok"] = bool(checks["size_ok"] and checks["generator_ok"] and checks["safe_prime"])
503
+ return checks
504
+
505
+
506
+ def dh_verdict(p_bytes: bytes, g: int) -> dict[str, Any]:
507
+ """`validate_dh` as a fresh dict, so a caller may annotate it safely."""
508
+ return dict(validate_dh(p_bytes, g))
509
+
510
+
511
+ def key_fingerprint(key: bytes) -> int:
512
+ """The 64-bit fingerprint both sides compare: the tail of SHA1(key)."""
513
+ return int.from_bytes(hashlib.sha1(key).digest()[-8:], "little", signed=True)
514
+
515
+
516
+ def emoji_indices(key: bytes, g_a: bytes) -> list[int]:
517
+ """The four indices into Telegram's 333-emoji verification table.
518
+
519
+ tlgr reports the indices, not the emoji: the table is a fixed list every
520
+ client ships, tlgr does not bundle it, and printing four *guessed* emoji
521
+ for a security check would be worse than printing none.
522
+ """
523
+ digest = hashlib.sha256(key + g_a).digest()
524
+ return [int.from_bytes(digest[8 + i * 8 : 16 + i * 8], "big") % 333 for i in range(4)]
525
+
526
+
527
+ def b64(data: bytes) -> str:
528
+ return base64.b64encode(data).decode("ascii")
529
+
530
+
531
+ def unb64(text: str, *, field: str = "data") -> bytes:
532
+ try:
533
+ return base64.b64decode(text, validate=True)
534
+ except (ValueError, TypeError) as exc:
535
+ raise UsageError(f"--{field} is not valid base64", field=field) from exc
tlgr/ops/_common.py ADDED
@@ -0,0 +1,160 @@
1
+ """The plumbing every operation module needs, in one place.
2
+
3
+ `message` grew these helpers first — the client accessor, the pagination
4
+ window, the affected-history loop, the id-range expander — and PR-9's five
5
+ new modules need all of them. Copying them five times is how "`--limit` means
6
+ at most 1000" becomes true in one module and false in the next, so they live
7
+ here and `ops/message.py` imports them like everybody else.
8
+
9
+ Telethon is imported inside functions, never at module scope: importing the
10
+ registry is what builds `tlgr --help`, and that must not pull in Telethon.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from tlgr.core.errors import UsageError
18
+ from tlgr.core.pagination import PageKind, decode_cursor
19
+ from tlgr.ops._spec import OpContext
20
+
21
+ __all__ = [
22
+ "affected_loop",
23
+ "already",
24
+ "client",
25
+ "ids",
26
+ "input_channel",
27
+ "is_not_modified",
28
+ "only",
29
+ "random_id",
30
+ "window",
31
+ ]
32
+
33
+
34
+ def client(ctx: OpContext) -> Any:
35
+ """The connected Telethon client, or a usage error."""
36
+ handle = getattr(ctx, "client", None)
37
+ if handle is None: # pragma: no cover - the daemon always supplies one
38
+ raise UsageError("this operation needs a connected account")
39
+ return handle
40
+
41
+
42
+ def window(ctx: OpContext, op: str, kind: PageKind, default: int = 20) -> tuple[int, Any]:
43
+ """`(limit, cursor state)` for a paginated op.
44
+
45
+ `--limit`/`--cursor` are transport-level and never request fields
46
+ (registry lint L5), so every paginated implementation reads them the same
47
+ way instead of redeclaring them.
48
+ """
49
+ limit = int(getattr(ctx, "limit", None) or default)
50
+ if limit < 1:
51
+ raise UsageError("--limit must be at least 1", field="limit")
52
+ token = getattr(ctx, "cursor", None)
53
+ state: dict[str, Any] = {}
54
+ if token:
55
+ state = decode_cursor(token, op=op, kind=kind, account=ctx.account)
56
+ return min(limit, 1000), state
57
+
58
+
59
+ def ids(values: tuple[int, ...] | tuple[str, ...] | list[str] | None) -> list[int]:
60
+ """Expand `100-120` ranges alongside plain ids.
61
+
62
+ A range is what a human types when deleting a burst of messages, and
63
+ making them spell out twenty ids is how a wrong one gets in.
64
+ """
65
+ out: list[int] = []
66
+ for value in values or ():
67
+ text = str(value)
68
+ if "-" in text[1:]:
69
+ head, _, tail = text.partition("-")
70
+ try:
71
+ start, end = int(head), int(tail)
72
+ except ValueError as exc:
73
+ raise UsageError(f"{text!r} is not an id or an id range", field="msg_id") from exc
74
+ if end < start or end - start > 10_000:
75
+ raise UsageError(f"{text!r} is not a usable id range", field="msg_id")
76
+ out.extend(range(start, end + 1))
77
+ else:
78
+ try:
79
+ out.append(int(text))
80
+ except ValueError as exc:
81
+ raise UsageError(f"{text!r} is not a message id", field="msg_id") from exc
82
+ return out
83
+
84
+
85
+ def is_not_modified(exc: BaseException) -> bool:
86
+ """MESSAGE_NOT_MODIFIED is success: the world already looks like that.
87
+
88
+ Matched on the class name *and* the message with underscores stripped,
89
+ because Telethon spells it `MessageNotModifiedError` and the server
90
+ spells it `MESSAGE_NOT_MODIFIED`.
91
+ """
92
+ text = f"{type(exc).__name__} {exc}".upper().replace("_", "")
93
+ return "NOTMODIFIED" in text
94
+
95
+
96
+ def input_channel(peer: Any) -> Any:
97
+ """The `InputChannel` a `channels.*` request wants, or a usage error.
98
+
99
+ `utils.get_input_channel` is arithmetic on the peer we already hold; going
100
+ back to `get_input_entity` would be a round trip for something that is
101
+ already known, and would hide the real problem when the peer is a user.
102
+ """
103
+ from telethon import utils
104
+
105
+ try:
106
+ return utils.get_input_channel(peer)
107
+ except (TypeError, ValueError) as exc:
108
+ raise UsageError(
109
+ "this operation only works in a channel or supergroup", field="chat"
110
+ ) from exc
111
+
112
+
113
+ async def affected_loop(ctx: OpContext, make_request: Any) -> int:
114
+ """Drive a `messages.AffectedHistory` call until `offset == 0`.
115
+
116
+ `readMentions`, `readReactions`, `readPollVotes`, `unpinAllMessages` and
117
+ `deleteParticipantHistory` all return a partial result with an offset to
118
+ resume from. v1 called them once and reported success, so "unpin
119
+ everything" unpinned the first hundred.
120
+ """
121
+ handle = client(ctx)
122
+ total = 0
123
+ offset = 0
124
+ for _ in range(100):
125
+ result = await handle(make_request(offset))
126
+ total += int(getattr(result, "pts_count", 0) or 0)
127
+ offset = int(getattr(result, "offset", 0) or 0)
128
+ if offset == 0:
129
+ break
130
+ limiter = getattr(ctx, "limiter", None)
131
+ if limiter is not None:
132
+ await limiter.acquire("bulk")
133
+ return total
134
+
135
+
136
+ def already(ctx: OpContext) -> None:
137
+ """Record that the world already looked the way the caller asked for."""
138
+ mark = getattr(ctx, "mark_already", None)
139
+ if callable(mark):
140
+ mark()
141
+
142
+
143
+ def only(values: dict[str, Any], request: Any) -> dict[str, Any]:
144
+ """Keep the keys *request* actually accepts.
145
+
146
+ The send requests share most of their flags and differ in a few; a
147
+ filtered dict is how one options mapping serves all of them without a
148
+ per-request copy that drifts.
149
+ """
150
+ import inspect
151
+
152
+ allowed = set(inspect.signature(request.__init__).parameters)
153
+ return {k: v for k, v in values.items() if k in allowed and v is not None}
154
+
155
+
156
+ def random_id() -> int:
157
+ """A 64-bit `random_id`, as every send request wants one."""
158
+ import os
159
+
160
+ return int.from_bytes(os.urandom(8), "big", signed=True)