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/_settings.py ADDED
@@ -0,0 +1,306 @@
1
+ """The plumbing the nine settings modules share.
2
+
3
+ `profile`, `privacy`, `notify`, `settings`, `business`, `premium`, `stars`,
4
+ `gift` and `giveaway` are one GUI screen each, but they keep meeting the same
5
+ four problems, and a second copy of any of them is how two commands start
6
+ disagreeing about the same server field:
7
+
8
+ * **a Stars amount is `(amount, nanos)`**, and TON arrives in the same shape
9
+ with nine decimals — collapsing either to a float loses the digit a ledger
10
+ reconciliation needs;
11
+ * **half of this surface replaces a whole constructor**, so the read half of
12
+ a read-modify-write is shared rather than re-derived per flag;
13
+ * **a gift is addressed by a reference string**, and the three spellings
14
+ (`msg:<id>`, `<peer>:<saved_id>`, a bare slug) must parse and *print* the
15
+ same way in every module;
16
+ * **five payments methods are absent from Telethon 1.44**, and the refusal
17
+ has to say which one and why, in one sentence, everywhere.
18
+
19
+ Telethon is imported inside functions, never at module scope (§2.2).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ from tlgr.core.errors import NotSupportedError, PermissionError_, UsageError
27
+ from tlgr.core.timefmt import fmt_dt, fmt_unix
28
+ from tlgr.models.peer import Peer, PeerRef
29
+ from tlgr.ops._common import client
30
+ from tlgr.ops._spec import OpContext
31
+
32
+ __all__ = [
33
+ "ABSENT_METHODS",
34
+ "NO_SPEND",
35
+ "app_config",
36
+ "client",
37
+ "color_int",
38
+ "color_text",
39
+ "entity_map",
40
+ "gift_ref_text",
41
+ "input_gift",
42
+ "input_user",
43
+ "iso",
44
+ "method_gap",
45
+ "on_off",
46
+ "peer_model",
47
+ "peer_of",
48
+ "refuse_spend",
49
+ "resolve",
50
+ "slug_of",
51
+ "sound_text",
52
+ "sound_value",
53
+ "stars_of",
54
+ "unix",
55
+ ]
56
+
57
+ #: The one sentence every refusal to move value ends with. PR-10 settled the
58
+ #: policy for the `payment` group (`ops/payment.py`); PR-12 inherits it rather
59
+ #: than opening a second door onto the same money.
60
+ NO_SPEND = (
61
+ "tlgr never spends money: it reads the price and refuses to sign the form. "
62
+ "Complete the purchase in an official client if you want it"
63
+ )
64
+
65
+ #: `payments.*` methods this build has no request class for, and what each
66
+ #: one would have answered. Named here so the refusal, the docs and
67
+ #: `agent capabilities` cannot describe the gap three different ways.
68
+ ABSENT_METHODS: dict[str, str] = {
69
+ "payments.canSendStarGift": "whether a specific recipient accepts a specific gift",
70
+ "payments.getStarGiftCraftCandidates": "which of my gifts can be melted into a given one",
71
+ "payments.getStarGiftAttributes": "the full attribute table of a gift type",
72
+ "payments.getStarGiftValueInfo": "the floor price and last sale of a collectible",
73
+ "payments.getPrepaidGiveaways": "a channel's prepaid giveaways as a list of their own",
74
+ }
75
+
76
+
77
+ def method_gap(feature: str, method: str) -> Any:
78
+ """Refuse a feature whose only MTProto method this Telethon lacks (exit 13).
79
+
80
+ Distinct from `_layer.py`: those are layer-229 *features* with no
81
+ constructor at all, these are individual methods missing from a Telethon
82
+ that otherwise speaks the surface. The command still exists and the rest
83
+ of it still works — only the flag that needs the method refuses.
84
+ """
85
+ answers = ABSENT_METHODS.get(method, "")
86
+ raise NotSupportedError(
87
+ f"{feature} needs {method}, which Telethon 1.44 has no request class for"
88
+ + (f" (it is what answers {answers})" if answers else "")
89
+ + "; the rest of this command works, and the flag starts working with "
90
+ "the Telethon uplift without its spelling changing"
91
+ )
92
+
93
+
94
+ def refuse_spend(what: str) -> Any:
95
+ """Refuse an operation that would move a financial asset (exit 6)."""
96
+ raise PermissionError_(f"{what}: {NO_SPEND}")
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Peers
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ async def resolve(ctx: OpContext, ref: PeerRef | str | None) -> Any:
105
+ """The `InputPeer` for *ref*, through the account's own resolver."""
106
+ from tlgr.ops import _send
107
+
108
+ return await _send.resolve(ctx, ref)
109
+
110
+
111
+ async def input_user(ctx: OpContext, ref: PeerRef | str | None, *, field: str = "user") -> Any:
112
+ """The `InputUser` a `users.*`/`account.*` request wants."""
113
+ from tlgr.ops import _bots
114
+
115
+ return await _bots.input_user(ctx, ref, field=field)
116
+
117
+
118
+ def peer_of(peer: Any) -> int:
119
+ """The marked id of a resolved `InputPeer`."""
120
+ from tlgr.ops import _send
121
+
122
+ return _send.peer_id_of(peer)
123
+
124
+
125
+ def peer_model(entity: Any) -> Peer | None:
126
+ """A `User`/`Chat`/`Channel` as the shared `Peer` shape."""
127
+ if entity is None:
128
+ return None
129
+ from tlgr.ops._serialize import entity_to_peer
130
+
131
+ return entity_to_peer(entity)
132
+
133
+
134
+ def entity_map(result: Any) -> dict[int, Any]:
135
+ """`{raw id: entity}` for the users *and* chats an answer carried.
136
+
137
+ Every `payments.*` and `account.*` answer in this group ships its peers in
138
+ two parallel vectors; a single map is what lets one lookup fill in a name
139
+ without caring which vector it came from.
140
+ """
141
+ found: dict[int, Any] = {}
142
+ for name in ("users", "chats"):
143
+ for entity in getattr(result, name, None) or []:
144
+ with_id = getattr(entity, "id", None)
145
+ if with_id is not None:
146
+ found[int(with_id)] = entity
147
+ return found
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Scalars
152
+ # ---------------------------------------------------------------------------
153
+
154
+
155
+ def iso(value: Any) -> str | None:
156
+ """An RFC-3339 string for a datetime or a unix int, or None."""
157
+ if value is None:
158
+ return None
159
+ if isinstance(value, (int, float)):
160
+ return fmt_unix(int(value)) if value else None
161
+ return fmt_dt(value)
162
+
163
+
164
+ def unix(value: Any) -> int | None:
165
+ if value is None:
166
+ return None
167
+ if isinstance(value, (int, float)):
168
+ return int(value) or None
169
+ from tlgr.core.timefmt import to_unix
170
+
171
+ return to_unix(value)
172
+
173
+
174
+ def stars_of(amount: Any) -> tuple[int, int]:
175
+ """`starsAmount` as `(amount, nanos)`.
176
+
177
+ An int is accepted because half the payments surface still reports a bare
178
+ Star count; the nanos are then genuinely zero rather than unknown.
179
+ """
180
+ if amount is None:
181
+ return 0, 0
182
+ if isinstance(amount, (int, float)):
183
+ return int(amount), 0
184
+ return int(getattr(amount, "amount", 0) or 0), int(getattr(amount, "nanos", 0) or 0)
185
+
186
+
187
+ def on_off(value: str | None, *, field: str) -> bool | None:
188
+ """`on`/`off` as a bool, `None` for "the caller did not say"."""
189
+ if value is None:
190
+ return None
191
+ text = value.strip().lower()
192
+ if text in ("on", "true", "yes", "1"):
193
+ return True
194
+ if text in ("off", "false", "no", "0"):
195
+ return False
196
+ raise UsageError(f"--{field.replace('_', '-')} takes on or off", field=field)
197
+
198
+
199
+ def color_int(text: str | None, *, field: str = "color") -> int | None:
200
+ """`#RRGGBB`, `0xRRGGBB` or a decimal, as the int the API wants."""
201
+ if text is None:
202
+ return None
203
+ raw = str(text).strip().lstrip("#")
204
+ if raw.lower().startswith("0x"):
205
+ raw = raw[2:]
206
+ try:
207
+ return int(raw, 16) if not raw.isdigit() or len(raw) == 6 else int(raw)
208
+ except ValueError as exc:
209
+ raise UsageError(f"{text!r} is not a colour (use #RRGGBB)", field=field) from exc
210
+
211
+
212
+ def color_text(value: Any) -> str:
213
+ """An int colour as `#RRGGBB`, which is how a human reads one back."""
214
+ return f"#{int(value or 0) & 0xFFFFFF:06X}"
215
+
216
+
217
+ def sound_value(text: str | None) -> Any:
218
+ """`default | none | local:<title> | ringtone:<id> | <id>` as a constructor."""
219
+ from telethon.tl import types
220
+
221
+ if text is None:
222
+ return None
223
+ value = text.strip()
224
+ if value in ("none", "off", "silent"):
225
+ return types.NotificationSoundNone()
226
+ if value in ("default", ""):
227
+ return types.NotificationSoundDefault()
228
+ if value.startswith("local:"):
229
+ title = value.split(":", 1)[1]
230
+ return types.NotificationSoundLocal(title=title, data=title)
231
+ if value.startswith("ringtone:"):
232
+ value = value.split(":", 1)[1]
233
+ try:
234
+ return types.NotificationSoundRingtone(id=int(value))
235
+ except ValueError as exc:
236
+ raise UsageError(
237
+ "--sound takes default, none, local:<title> or ringtone:<id>", field="sound"
238
+ ) from exc
239
+
240
+
241
+ def sound_text(value: Any) -> str | None:
242
+ """The inverse of `sound_value`, so a read can be piped into a write."""
243
+ from tlgr.ops._serialize import _sound
244
+
245
+ return _sound(value)
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Gift references
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ async def input_gift(ctx: OpContext, ref: str, *, field: str = "ref") -> Any:
254
+ """One `inputSavedStarGift*` from tlgr's single reference spelling.
255
+
256
+ `msg:<id>` is a gift received in a private chat, `<peer>:<saved_id>` one
257
+ held by a channel, and anything else is a collectible slug (a `t.me/nft/`
258
+ link is accepted and reduced to its slug). Three server constructors, one
259
+ string a caller can copy out of a listing.
260
+ """
261
+ from telethon.tl import types
262
+
263
+ text = str(ref).strip()
264
+ if not text:
265
+ raise UsageError("give a gift reference", field=field)
266
+ head, sep, tail = text.partition(":")
267
+ if sep and head.lower() in ("msg", "message"):
268
+ if not tail.lstrip("-").isdigit():
269
+ raise UsageError(f"{text!r}: msg:<id> wants a message id", field=field)
270
+ return types.InputSavedStarGiftUser(msg_id=int(tail))
271
+ if sep and tail.lstrip("-").isdigit() and not text.startswith("http"):
272
+ return types.InputSavedStarGiftChat(peer=await resolve(ctx, head), saved_id=int(tail))
273
+ return types.InputSavedStarGiftSlug(slug=slug_of(text))
274
+
275
+
276
+ def slug_of(text: str) -> str:
277
+ """`t.me/nft/PlushPepe-42` → `PlushPepe-42`; a bare slug passes through."""
278
+ value = str(text).strip()
279
+ for marker in ("/nft/", "t.me/", "tg://nft?slug="):
280
+ if marker in value:
281
+ value = value.split(marker, 1)[1]
282
+ return value.split("?", 1)[0].split("#", 1)[0].strip("/")
283
+
284
+
285
+ def gift_ref_text(raw: Any) -> str:
286
+ """The reference string for a `savedStarGift` the server just handed us."""
287
+ msg_id = getattr(raw, "msg_id", None)
288
+ if msg_id:
289
+ return f"msg:{msg_id}"
290
+ saved_id = getattr(raw, "saved_id", None)
291
+ if saved_id:
292
+ return f"saved:{saved_id}"
293
+ gift = getattr(raw, "gift", None)
294
+ return str(getattr(gift, "slug", "") or "")
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # App config
299
+ # ---------------------------------------------------------------------------
300
+
301
+
302
+ async def app_config(ctx: OpContext) -> dict[str, Any]:
303
+ """`help.getAppConfig` as plain Python. Never hardcode a server limit."""
304
+ from tlgr.ops import _media
305
+
306
+ return await _media.app_config(ctx)
tlgr/ops/_spec.py ADDED
@@ -0,0 +1,167 @@
1
+ """`OperationSpec` — the one artefact per operation.
2
+
3
+ Adding an operation means adding one of these. The Click command, the daemon
4
+ dispatch entry, the JSON Schema, the reference docs and the contract tests are
5
+ all derived from it, so there is no second place for the description of an
6
+ operation to drift away from the first.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, field
13
+ from enum import Enum
14
+ from typing import Any, Protocol, TypeAlias, runtime_checkable
15
+
16
+ import msgspec
17
+
18
+ from tlgr.core.errors import EXIT_SUCCESS
19
+ from tlgr.core.pagination import PageKind
20
+
21
+ __all__ = [
22
+ "Impl",
23
+ "OpContext",
24
+ "OperationSpec",
25
+ "PageKind",
26
+ "Surface",
27
+ ]
28
+
29
+ RATE_CLASSES = frozenset({"read", "send", "resolve", "bulk", "file", "local"})
30
+
31
+
32
+ class Surface(str, Enum):
33
+ """Where an operation runs."""
34
+
35
+ DAEMON = "daemon"
36
+ LOCAL = "local"
37
+ EITHER = "either"
38
+
39
+
40
+ @runtime_checkable
41
+ class OpContext(Protocol):
42
+ """What an implementation is handed besides its request.
43
+
44
+ Deliberately a Protocol: `ops/` must not import `daemon/`, and a test must
45
+ be able to build one without a socket or a Telethon client. The services
46
+ an implementation legitimately needs — the client, the per-account peer
47
+ resolver, the rate limiter, the file pipeline, the event bus — are
48
+ *injected* here rather than imported, which is what keeps the layering
49
+ rule from being a fiction.
50
+ """
51
+
52
+ account: str
53
+ dry_run: bool
54
+ request_id: str
55
+
56
+ def warn(self, message: str) -> None:
57
+ """Add a non-fatal advisory to `meta.warnings`."""
58
+ ...
59
+
60
+ def emit(self, event_type: str, payload: dict[str, Any], **kwargs: Any) -> None:
61
+ """Echo an action tlgr itself performed onto the event bus (§6.5).
62
+
63
+ Telethon dispatches no `NewMessage` for our own sends, so without this
64
+ a `tlgr watch` never shows what tlgr just did. A context with no bus
65
+ drops it, which is why the signature returns nothing.
66
+ """
67
+ ...
68
+
69
+ def mark_already(self) -> None:
70
+ """Record that the world already looked the way the caller asked for.
71
+
72
+ Part of the contract, not a convenience: COMMANDS.md promises that an
73
+ idempotent no-op reports `already: true` rather than pretending to
74
+ have done something, and an implementation cannot honour that without
75
+ a way to say so.
76
+ """
77
+ ...
78
+
79
+
80
+ #: `Awaitable[Any] | AsyncIterator[Any]`: a streaming operation is an async
81
+ #: *generator*, which is not awaitable. Registry lint L6 is what keeps the two
82
+ #: kinds honest — `stream=True` must be an async generator and nothing else.
83
+ Impl: TypeAlias = Callable[[Any, Any], Any]
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class OperationSpec:
88
+ # ---- identity ----
89
+ id: str
90
+ request: type[msgspec.Struct]
91
+ response: Any
92
+ impl: Impl
93
+ summary: str
94
+ description: str = ""
95
+ #: Canonicalised to `id` before any policy check — in the CLI *and* the
96
+ #: daemon. SEC-04 was exactly this gap: `--enable-commands message`
97
+ #: allowed `message send` but not the `send` alias for the same op.
98
+ aliases: tuple[str, ...] = ()
99
+ # ---- behaviour flags ----
100
+ mutating: bool = False
101
+ destructive: bool = False
102
+ paginated: PageKind | None = None
103
+ stream: bool = False
104
+ needs_account: bool = True
105
+ #: False for a daemon operation that needs no *Telegram* client: reading
106
+ #: the event bus, the flood store, the dead-letter file, the job table.
107
+ #: Without it every one of those would connect an account to answer a
108
+ #: question about the daemon, and `--account all` could not be expressed
109
+ #: at all (there is no single session to acquire).
110
+ needs_client: bool = True
111
+ needs_auth: bool = True
112
+ surface: Surface = Surface.DAEMON
113
+ idempotent: bool = False
114
+ # ---- policy / limits ----
115
+ timeout_s: int = 120
116
+ rate_class: str = "read"
117
+ min_interval_s: float = 0.0
118
+ # ---- presentation ----
119
+ columns: tuple[str, ...] = ()
120
+ headers: tuple[str, ...] = ()
121
+ #: 0 or EXIT_EMPTY. Settles COR-36 centrally: lists exit 0 with an empty
122
+ #: result; only point lookups and harvests that found nothing opt in to 3.
123
+ empty_exit: int = EXIT_SUCCESS
124
+ example: Any = None
125
+ example_args: str = ""
126
+ # ---- parity ----
127
+ covers: tuple[str, ...] = ()
128
+ covers_partial: tuple[str, ...] = ()
129
+ coverage_note: str = ""
130
+ # ---- migration ----
131
+ #: v1 CLI paths this op replaces. They stay invocable, so no documented
132
+ #: command path ever disappears (§12.4).
133
+ legacy_paths: tuple[str, ...] = ()
134
+ since: str = "2.0"
135
+ deprecated: str = ""
136
+ tags: frozenset[str] = field(default_factory=frozenset)
137
+
138
+ @property
139
+ def group(self) -> str:
140
+ """The first path segment: `chat` for `chat.member.ban`."""
141
+ return self.id.split(".")[0]
142
+
143
+ @property
144
+ def path(self) -> tuple[str, ...]:
145
+ """The command path: `("chat", "member", "ban")`."""
146
+ return tuple(self.id.split("."))
147
+
148
+ @property
149
+ def verb(self) -> str:
150
+ return self.id.split(".")[-1]
151
+
152
+ @property
153
+ def cli_path(self) -> str:
154
+ """How a human types it: `chat member ban`."""
155
+ return " ".join(self.path)
156
+
157
+ @property
158
+ def names(self) -> tuple[str, ...]:
159
+ """Every name this op answers to, canonical id first, deduplicated.
160
+
161
+ A legacy path usually *is* the canonical path (`agent exit-codes`),
162
+ and an alias may repeat one; both are normal, not a conflict.
163
+ """
164
+ seen: dict[str, None] = {}
165
+ for name in (self.id, *self.aliases, *(p.replace(" ", ".") for p in self.legacy_paths)):
166
+ seen.setdefault(name, None)
167
+ return tuple(seen)