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/chat_stats.py ADDED
@@ -0,0 +1,902 @@
1
+ """`chat stats *`, `chat revenue *` and `boost *`: the numbers, never redrawn.
2
+
3
+ Three rules, and the first one is the reason this module exists at all.
4
+
5
+ * **Every `stats.*` call goes to `channelFull.stats_dc`, not the home DC.**
6
+ The server answers a stats request on the wrong data centre with
7
+ `STATS_MIGRATE_X`, and a client that does not follow it reports "no
8
+ statistics" for a channel that has plenty. `_stats()` follows the
9
+ migration through Telethon's exported sender, once, for every stats call
10
+ in the module.
11
+ * **A graph is emitted verbatim.** Telegram's chart specification
12
+ (columns/types/colors/names/subchart/y_scaled/percentage/stacked) is
13
+ reported as the API's own JSON. tlgr never tries to draw it: a redrawn
14
+ chart is a chart that can be subtly wrong, and the caller is better placed
15
+ to render than we are. Async graphs appear as `{token, zoom_token}` until
16
+ `--load-graphs` or `--graph` resolves them.
17
+ * **Revenue is read-only on purpose.** When `withdrawal_enabled` is true the
18
+ command says so and points at an official client: tlgr does not implement
19
+ `payments.getStarsRevenueWithdrawalUrl`, because it moves money and wants
20
+ the 2FA password.
21
+
22
+ Telethon is imported inside functions, never at module scope (§2.2).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from pathlib import Path
29
+ from typing import Annotated, Any
30
+
31
+ from tlgr.core.errors import IndeterminateError, NotFoundError, UsageError
32
+ from tlgr.core.pagination import PageKind, build_page
33
+ from tlgr.core.timefmt import fmt_dt, to_unix
34
+ from tlgr.models.admin import (
35
+ Boost,
36
+ BoostApplied,
37
+ BoostStatus,
38
+ ChatStats,
39
+ Graph,
40
+ PublicForward,
41
+ RevenueSummary,
42
+ RevenueTransaction,
43
+ StatValue,
44
+ )
45
+ from tlgr.models.base import Request
46
+ from tlgr.models.page import Page
47
+ from tlgr.models.peer import PeerRef
48
+ from tlgr.ops import _admin, _send
49
+ from tlgr.ops._params import arg, opt
50
+ from tlgr.ops._serialize import peer_id_of
51
+ from tlgr.ops._spec import OpContext, OperationSpec
52
+
53
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
54
+
55
+ _EXAMPLE_STATS: dict[str, Any] = {
56
+ "chat_id": -1001600,
57
+ "type": "broadcast",
58
+ "followers": {"current": 1200.0, "previous": 1150.0, "growth": 4.3},
59
+ "graphs": [{"name": "growth_graph", "token": "abc123"}],
60
+ }
61
+
62
+ #: appConfig keys that gate a tlgr flag, and the flag they gate. `boost get
63
+ #: --features` maps them so "why is --autotranslate refused" has an answer
64
+ #: that does not require reading the API docs.
65
+ _BOOST_FEATURES = {
66
+ "channel_autotranslation_level_min": "chat setting set --autotranslate",
67
+ "channel_wallpaper_level_min": "chat wallpaper set",
68
+ "group_wallpaper_level_min": "chat wallpaper set",
69
+ "channel_emoji_status_level_min": "chat edit --emoji-status",
70
+ "group_emoji_status_level_min": "chat edit --emoji-status",
71
+ "group_emoji_stickers_level_min": "chat setting set --emoji-set",
72
+ "channel_restrict_sponsored_level_min": "chat setting set --ads off",
73
+ "channel_custom_wallpaper_level_min": "chat wallpaper set --file",
74
+ "channel_profile_bg_icon_level_min": "chat edit --profile-color-emoji",
75
+ "channel_bg_icon_level_min": "chat edit --color-emoji",
76
+ "boosts_channel_level_max": "(the level ceiling)",
77
+ }
78
+
79
+
80
+ async def _stats(ctx: OpContext, request: Any) -> Any:
81
+ """Send a `stats.*` request, following STATS_MIGRATE to the stats DC.
82
+
83
+ Telethon's own `get_stats` does this with a borrowed exported sender;
84
+ doing it here means every stats call in the module — not just the two
85
+ Telethon wraps — reaches the right data centre.
86
+ """
87
+ client = _admin.client(ctx)
88
+ try:
89
+ return await client(request)
90
+ except Exception as exc:
91
+ if type(exc).__name__ != "StatsMigrateError":
92
+ raise
93
+ dc = int(getattr(exc, "dc", 0) or 0)
94
+ borrow = getattr(client, "_borrow_exported_sender", None)
95
+ if borrow is None or not dc: # pragma: no cover - Telethon always has it
96
+ raise IndeterminateError(
97
+ "these statistics live on another data centre and this Telethon "
98
+ "build has no exported sender to reach it"
99
+ ) from exc
100
+ sender = await borrow(dc)
101
+ try:
102
+ return await sender.send(request)
103
+ finally:
104
+ release = getattr(client, "_return_exported_sender", None)
105
+ if release is not None:
106
+ await release(sender)
107
+
108
+
109
+ def _moment(value: Any) -> str | None:
110
+ """`next_withdrawal_at` arrives as a unix int, not a datetime."""
111
+ if isinstance(value, int) and value:
112
+ from datetime import datetime, timezone
113
+
114
+ return fmt_dt(datetime.fromtimestamp(value, tz=timezone.utc))
115
+ return fmt_dt(value)
116
+
117
+
118
+ def _stat_value(raw: Any) -> StatValue | None:
119
+ """`statsAbsValueAndPrev` / `statsPercentValue` → one shape with growth."""
120
+ if raw is None:
121
+ return None
122
+ if hasattr(raw, "part"):
123
+ part = float(getattr(raw, "part", 0.0) or 0.0)
124
+ total = float(getattr(raw, "total", 0.0) or 0.0)
125
+ return StatValue(
126
+ current=part, previous=total, growth=round(100.0 * part / total, 2) if total else 0.0
127
+ )
128
+ current = float(getattr(raw, "current", 0.0) or 0.0)
129
+ previous = float(getattr(raw, "previous", 0.0) or 0.0)
130
+ growth = round(100.0 * (current - previous) / previous, 2) if previous else 0.0
131
+ return StatValue(current=current, previous=previous, growth=growth)
132
+
133
+
134
+ def _graph(name: str, raw: Any) -> Graph:
135
+ """One `statsGraph*`, whichever of the three shapes it is."""
136
+ kind = type(raw).__name__
137
+ if kind == "StatsGraphAsync":
138
+ return Graph(name=name, token=str(getattr(raw, "token", "") or ""))
139
+ if kind == "StatsGraphError":
140
+ return Graph(name=name, error=str(getattr(raw, "error", "") or ""))
141
+ payload = getattr(getattr(raw, "json", None), "data", None)
142
+ parsed: Any = None
143
+ if payload:
144
+ try:
145
+ parsed = json.loads(payload)
146
+ except (TypeError, ValueError): # pragma: no cover - the server sends JSON
147
+ parsed = payload
148
+ return Graph(
149
+ name=name, json=parsed, zoom_token=str(getattr(raw, "zoom_token", "") or "") or None
150
+ )
151
+
152
+
153
+ def _collect_graphs(result: Any) -> list[Graph]:
154
+ """Every `*_graph` field on a stats reply, in declaration order.
155
+
156
+ Reflected rather than listed: broadcast, megagroup, message and story
157
+ statistics carry different graph sets, and a hand-written list would go
158
+ stale the next time the server grows one.
159
+ """
160
+ out: list[Graph] = []
161
+ for name in getattr(result, "__slots__", ()) or dir(result):
162
+ if not name.endswith("_graph"):
163
+ continue
164
+ value = getattr(result, name, None)
165
+ if value is not None:
166
+ out.append(_graph(name, value))
167
+ return out
168
+
169
+
170
+ async def _resolve_graphs(ctx: OpContext, graphs: list[Graph], *, zoom: int | None) -> None:
171
+ from telethon.tl.functions import stats as fn
172
+
173
+ for graph in graphs:
174
+ if not graph.token:
175
+ continue
176
+ try:
177
+ resolved = await _stats(ctx, fn.LoadAsyncGraphRequest(token=graph.token, x=zoom))
178
+ except Exception as exc: # a graph that will not load must not kill the report
179
+ graph.error = f"{type(exc).__name__}: {exc}"
180
+ continue
181
+ loaded = _graph(graph.name, resolved)
182
+ graph.json = loaded.json
183
+ graph.zoom_token = loaded.zoom_token or graph.zoom_token
184
+ graph.error = loaded.error
185
+
186
+
187
+ def _write_graphs(graphs: list[Graph], directory: str) -> None:
188
+ root = Path(directory).expanduser()
189
+ root.mkdir(parents=True, exist_ok=True)
190
+ for graph in graphs:
191
+ if graph.json is None:
192
+ continue
193
+ path = root / f"{graph.name}.json"
194
+ path.write_text(json.dumps(graph.json, ensure_ascii=False, indent=2), encoding="utf-8")
195
+ graph.path = str(path)
196
+ graph.json = None
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # chat stats get
201
+ # ---------------------------------------------------------------------------
202
+
203
+
204
+ class StatsGetReq(Request):
205
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel or supergroup.")]
206
+ message: Annotated[
207
+ int | None, opt("--message", metavar="ID", kind="msg_id", help="Per-post statistics.")
208
+ ] = None
209
+ story: Annotated[int | None, opt("--story", metavar="ID", help="Story statistics.")] = None
210
+ poll: Annotated[
211
+ int | None, opt("--poll", metavar="ID", kind="msg_id", help="Poll vote statistics.")
212
+ ] = None
213
+ dark: Annotated[bool, opt("--dark", help="Ask for the dark colour set in the specs.")] = False
214
+ graph: Annotated[
215
+ str | None, opt("--graph", metavar="TOKEN", help="Resolve one async graph token.")
216
+ ] = None
217
+ zoom: Annotated[int | None, opt("--zoom", metavar="X", help="With --graph: zoom into x.")] = (
218
+ None
219
+ )
220
+ load_graphs: Annotated[
221
+ bool, opt("--load-graphs", help="Resolve every async graph before printing.")
222
+ ] = False
223
+ out: Annotated[
224
+ str | None, opt("--out", metavar="DIR", kind="path", help="Write graph specs to files.")
225
+ ] = None
226
+
227
+
228
+ async def get_stats(ctx: OpContext, req: StatsGetReq) -> ChatStats:
229
+ """Channel, supergroup, post, story or poll statistics."""
230
+ from telethon.tl.functions import stats as fn
231
+
232
+ peer = await _send.resolve(ctx, req.chat)
233
+ chat_id = peer_id_of(peer) or 0
234
+
235
+ if req.graph:
236
+ resolved = await _stats(ctx, fn.LoadAsyncGraphRequest(token=req.graph, x=req.zoom))
237
+ graph = _graph("graph", resolved)
238
+ stats = ChatStats(chat_id=chat_id, type="graph", graphs=[graph])
239
+ if req.out:
240
+ _write_graphs(stats.graphs, req.out)
241
+ return stats
242
+
243
+ channel = _admin.input_channel(peer) if _admin.is_channel(peer) else None
244
+ if req.message is not None:
245
+ if channel is None:
246
+ raise UsageError("post statistics need a channel", field="message")
247
+ result = await _stats(
248
+ ctx, fn.GetMessageStatsRequest(channel=channel, msg_id=req.message, dark=req.dark)
249
+ )
250
+ kind = "message"
251
+ elif req.story is not None:
252
+ result = await _stats(ctx, fn.GetStoryStatsRequest(peer=peer, id=req.story, dark=req.dark))
253
+ kind = "story"
254
+ elif req.poll is not None:
255
+ result = await _stats(
256
+ ctx, fn.GetPollStatsRequest(peer=peer, msg_id=req.poll, dark=req.dark)
257
+ )
258
+ kind = "poll"
259
+ else:
260
+ if channel is None:
261
+ raise UsageError(
262
+ "statistics need a channel or supergroup; a basic group has none", field="chat"
263
+ )
264
+ _full, entity, _entities = await _admin.full_chat(ctx, peer)
265
+ megagroup = bool(getattr(entity, "megagroup", False))
266
+ request = (
267
+ fn.GetMegagroupStatsRequest(channel=channel, dark=req.dark)
268
+ if megagroup
269
+ else fn.GetBroadcastStatsRequest(channel=channel, dark=req.dark)
270
+ )
271
+ result = await _stats(ctx, request)
272
+ kind = "megagroup" if megagroup else "broadcast"
273
+
274
+ period_raw = getattr(result, "period", None)
275
+ stats = ChatStats(
276
+ chat_id=chat_id,
277
+ type=kind,
278
+ period=(
279
+ {
280
+ "min_date": fmt_dt(getattr(period_raw, "min_date", None)) or "",
281
+ "max_date": fmt_dt(getattr(period_raw, "max_date", None)) or "",
282
+ }
283
+ if period_raw is not None
284
+ else {}
285
+ ),
286
+ followers=_stat_value(getattr(result, "followers", None)),
287
+ views_per_post=_stat_value(getattr(result, "views_per_post", None)),
288
+ shares_per_post=_stat_value(getattr(result, "shares_per_post", None)),
289
+ reactions_per_post=_stat_value(getattr(result, "reactions_per_post", None)),
290
+ enabled_notifications=_stat_value(getattr(result, "enabled_notifications", None)),
291
+ members=_stat_value(getattr(result, "members", None)),
292
+ messages=_stat_value(getattr(result, "messages", None)),
293
+ viewers=_stat_value(getattr(result, "viewers", None)),
294
+ posters=_stat_value(getattr(result, "posters", None)),
295
+ views=getattr(result, "views", None),
296
+ forwards=getattr(result, "forwards", None),
297
+ reactions=getattr(result, "reactions", None),
298
+ graphs=_collect_graphs(result),
299
+ )
300
+ for post in getattr(result, "recent_posts_interactions", None) or []:
301
+ stats.recent_posts.append(
302
+ {
303
+ "msg_id": int(getattr(post, "msg_id", 0) or 0),
304
+ "story_id": int(getattr(post, "story_id", 0) or 0),
305
+ "views": int(getattr(post, "views", 0) or 0),
306
+ "forwards": int(getattr(post, "forwards", 0) or 0),
307
+ "reactions": int(getattr(post, "reactions", 0) or 0),
308
+ }
309
+ )
310
+ if req.load_graphs:
311
+ await _resolve_graphs(ctx, stats.graphs, zoom=req.zoom)
312
+ if req.out:
313
+ _write_graphs(stats.graphs, req.out)
314
+ return stats
315
+
316
+
317
+ SPEC_STATS_GET = OperationSpec(
318
+ id="chat.stats.get",
319
+ request=StatsGetReq,
320
+ response=ChatStats,
321
+ impl=get_stats,
322
+ summary="Channel, supergroup, post, story or poll statistics",
323
+ description=(
324
+ "Needs `channelFull.can_view_stats` (channels need about 500 "
325
+ "members). Every call is routed to `channelFull.stats_dc`. Graph "
326
+ "payloads are the API's own chart specification, emitted verbatim; "
327
+ "async graphs stay as `{token, zoom_token}` until `--load-graphs` or "
328
+ "`--graph` resolves them, and `--out DIR` writes each one to a file "
329
+ "instead of inlining it."
330
+ ),
331
+ aliases=("stats.get",),
332
+ timeout_s=300,
333
+ columns=("chat_id", "type"),
334
+ example=_EXAMPLE_STATS,
335
+ example_args="chat stats get @mychannel --load-graphs",
336
+ covers=(
337
+ "groups-channels-admin.channel-stats",
338
+ "groups-channels-admin.poll-stats",
339
+ "groups-channels-admin.stats-async-graph",
340
+ "groups-channels-admin.supergroup-stats",
341
+ "messages-core.message-statistics",
342
+ ),
343
+ covers_partial=(
344
+ "groups-channels-admin.message-stats",
345
+ "groups-channels-admin.story-stats",
346
+ ),
347
+ coverage_note="Per-post numbers are here; the repost list is `chat stats list`.",
348
+ )
349
+
350
+
351
+ class StatsListReq(Request):
352
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
353
+ message: Annotated[
354
+ int | None, opt("--message", metavar="ID", kind="msg_id", help="Public forwards of a post.")
355
+ ] = None
356
+ story: Annotated[
357
+ int | None, opt("--story", metavar="ID", help="Public forwards of a story.")
358
+ ] = None
359
+
360
+
361
+ async def list_public_forwards(ctx: OpContext, req: StatsListReq) -> Page[PublicForward]:
362
+ """Who reposted a post or a story, publicly."""
363
+ from telethon.tl.functions import stats as fn
364
+
365
+ limit, state = _admin.window(ctx, "chat.stats.list", PageKind.PARTICIPANTS)
366
+ peer = await _send.resolve(ctx, req.chat)
367
+ offset = str(state.get("offset", "") or "")
368
+ if req.message is not None:
369
+ reply = await _stats(
370
+ ctx,
371
+ fn.GetMessagePublicForwardsRequest(
372
+ channel=_admin.input_channel(peer), msg_id=req.message, offset=offset, limit=limit
373
+ ),
374
+ )
375
+ elif req.story is not None:
376
+ reply = await _stats(
377
+ ctx,
378
+ fn.GetStoryPublicForwardsRequest(peer=peer, id=req.story, offset=offset, limit=limit),
379
+ )
380
+ else:
381
+ raise UsageError("name --message or --story", field="message")
382
+
383
+ entities = _admin.entity_map(reply)
384
+ rows: list[PublicForward] = []
385
+ for item in getattr(reply, "forwards", None) or []:
386
+ message = getattr(item, "message", None)
387
+ story = getattr(item, "story", None)
388
+ source = message if message is not None else story
389
+ chat_id = peer_id_of(getattr(source, "peer_id", None) or getattr(source, "peer", None)) or 0
390
+ entity = entities.get(chat_id)
391
+ date = getattr(source, "date", None)
392
+ rows.append(
393
+ PublicForward(
394
+ chat_id=chat_id,
395
+ chat_title=_admin.display_name(entity),
396
+ msg_id=int(getattr(message, "id", 0) or 0) or None,
397
+ story_id=int(getattr(story, "id", 0) or 0) or None,
398
+ views=getattr(source, "views", None),
399
+ date=fmt_dt(date),
400
+ date_unix=to_unix(date),
401
+ )
402
+ )
403
+ return build_page(
404
+ rows,
405
+ op="chat.stats.list",
406
+ kind=PageKind.PARTICIPANTS,
407
+ state={"offset": str(getattr(reply, "next_offset", "") or "")},
408
+ account=ctx.account,
409
+ limit=limit,
410
+ total=int(getattr(reply, "count", len(rows)) or len(rows)),
411
+ )
412
+
413
+
414
+ SPEC_STATS_LIST = OperationSpec(
415
+ id="chat.stats.list",
416
+ request=StatsListReq,
417
+ response=Page[PublicForward],
418
+ impl=list_public_forwards,
419
+ summary="Public forwards (reposts) of a post or a story",
420
+ description="Also routed to the stats DC. The cursor is the opaque `next_offset` string.",
421
+ aliases=("stats.list",),
422
+ paginated=PageKind.PARTICIPANTS,
423
+ columns=("chat_id", "chat_title", "msg_id", "views"),
424
+ example={"items": [{"chat_id": -1001700, "chat_title": "Repost", "msg_id": 12, "views": 90}]},
425
+ example_args="chat stats list @mychannel --message 918",
426
+ covers=("groups-channels-admin.message-stats", "groups-channels-admin.story-stats"),
427
+ )
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # chat revenue
432
+ # ---------------------------------------------------------------------------
433
+
434
+
435
+ def _stars(raw: Any) -> int:
436
+ """A `starsAmount` as whole Stars.
437
+
438
+ The nanos field is a fractional Star and is dropped here deliberately:
439
+ every balance tlgr prints is the whole-Star figure the GUI shows, and
440
+ silently rounding it up or down per call would make two reports of the
441
+ same balance disagree.
442
+ """
443
+ return int(getattr(raw, "amount", 0) or 0)
444
+
445
+
446
+ class RevenueGetReq(Request):
447
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
448
+ ton: Annotated[bool, opt("--ton", help="TON (ad) revenue instead of Stars.")] = False
449
+ since: Annotated[
450
+ PeerRef | None,
451
+ opt("--since", metavar="USER", kind="user", help="Stars earned from this user's messages."),
452
+ ] = None
453
+ dark: Annotated[bool, opt("--dark", help="Dark colour set in the graph specs.")] = False
454
+
455
+
456
+ async def get_revenue(ctx: OpContext, req: RevenueGetReq) -> RevenueSummary:
457
+ """Stars or TON revenue, read-only by design."""
458
+ from telethon.tl.functions import account as acct_fn
459
+ from telethon.tl.functions import payments as fn
460
+
461
+ peer = await _send.resolve(ctx, req.chat)
462
+ handle = _admin.client(ctx)
463
+ reply = await handle(
464
+ fn.GetStarsRevenueStatsRequest(peer=peer, dark=req.dark or None, ton=req.ton or None)
465
+ )
466
+ status = getattr(reply, "status", None)
467
+ next_at = getattr(status, "next_withdrawal_at", None)
468
+ summary = RevenueSummary(
469
+ chat_id=peer_id_of(peer) or 0,
470
+ currency="ton" if req.ton else "stars",
471
+ current_balance=_stars(getattr(status, "current_balance", None)),
472
+ available_balance=_stars(getattr(status, "available_balance", None)),
473
+ overall_revenue=_stars(getattr(status, "overall_revenue", None)),
474
+ withdrawal_enabled=bool(getattr(status, "withdrawal_enabled", False)),
475
+ next_withdrawal_at=_moment(next_at),
476
+ usd_rate=float(getattr(reply, "usd_rate", 0.0) or 0.0),
477
+ graphs=_collect_graphs(reply),
478
+ )
479
+ if summary.withdrawal_enabled:
480
+ ctx.warn(
481
+ "a withdrawal is available. tlgr does not implement "
482
+ "payments.getStarsRevenueWithdrawalUrl: it moves money and needs your "
483
+ "2FA password, so use an official client for that step"
484
+ )
485
+ if req.since is not None:
486
+ user = await _send.resolve(ctx, req.since)
487
+ paid = await handle(
488
+ acct_fn.GetPaidMessagesRevenueRequest(user_id=_admin.input_user(user), parent_peer=peer)
489
+ )
490
+ summary.from_user_revenue = _stars(getattr(paid, "stars_amount", None))
491
+ return summary
492
+
493
+
494
+ SPEC_REVENUE_GET = OperationSpec(
495
+ id="chat.revenue.get",
496
+ request=RevenueGetReq,
497
+ response=RevenueSummary,
498
+ impl=get_revenue,
499
+ summary="Stars / TON revenue of a channel (and per-user paid-message revenue)",
500
+ description=(
501
+ "Read-only by design: when `withdrawal_enabled` is true the command "
502
+ "says so and points at an official client, because "
503
+ "`payments.getStarsRevenueWithdrawalUrl` moves money and wants the "
504
+ "2FA password. Needs `can_view_revenue` / `can_view_stars_revenue`."
505
+ ),
506
+ columns=("chat_id", "currency", "available_balance", "overall_revenue"),
507
+ example={
508
+ "chat_id": -1001600,
509
+ "currency": "stars",
510
+ "current_balance": 120,
511
+ "overall_revenue": 900,
512
+ },
513
+ example_args="chat revenue get @mychannel",
514
+ covers=(
515
+ "groups-channels-admin.paid-message-revenue",
516
+ "groups-channels-admin.stars-revenue-stats",
517
+ ),
518
+ )
519
+
520
+
521
+ class RevenueListReq(Request):
522
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
523
+ ton: Annotated[bool, opt("--ton", help="TON transactions instead of Stars.")] = False
524
+ inbound: Annotated[bool, opt("--in", help="Incoming only.")] = False
525
+ outbound: Annotated[bool, opt("--out", help="Outgoing only.")] = False
526
+ ascending: Annotated[bool, opt("--ascending", help="Oldest first.")] = False
527
+ subscription: Annotated[
528
+ str | None, opt("--subscription", metavar="ID", help="Only this subscription's rows.")
529
+ ] = None
530
+
531
+
532
+ async def list_revenue(ctx: OpContext, req: RevenueListReq) -> Page[RevenueTransaction]:
533
+ """The Stars or TON transaction history of a channel."""
534
+ from telethon.tl.functions import payments as fn
535
+
536
+ limit, state = _admin.window(ctx, "chat.revenue.list", PageKind.PARTICIPANTS)
537
+ peer = await _send.resolve(ctx, req.chat)
538
+ reply = await _admin.client(ctx)(
539
+ fn.GetStarsTransactionsRequest(
540
+ peer=peer,
541
+ offset=str(state.get("offset", "") or ""),
542
+ limit=limit,
543
+ inbound=req.inbound or None,
544
+ outbound=req.outbound or None,
545
+ ascending=req.ascending or None,
546
+ ton=req.ton or None,
547
+ subscription_id=req.subscription,
548
+ )
549
+ )
550
+ rows: list[RevenueTransaction] = []
551
+ for row in getattr(reply, "history", None) or []:
552
+ date = getattr(row, "date", None)
553
+ rows.append(
554
+ RevenueTransaction(
555
+ id=str(getattr(row, "id", "") or ""),
556
+ date=fmt_dt(date),
557
+ date_unix=to_unix(date),
558
+ amount=_stars(getattr(row, "amount", None)),
559
+ currency="ton" if req.ton else "stars",
560
+ peer=type(getattr(row, "peer", None)).__name__,
561
+ title=str(getattr(row, "title", "") or ""),
562
+ refund=bool(getattr(row, "refund", False)),
563
+ pending=bool(getattr(row, "pending", False)),
564
+ failed=bool(getattr(row, "failed", False)),
565
+ subscription_period=getattr(row, "subscription_period", None),
566
+ )
567
+ )
568
+ return build_page(
569
+ rows,
570
+ op="chat.revenue.list",
571
+ kind=PageKind.PARTICIPANTS,
572
+ state={"offset": str(getattr(reply, "next_offset", "") or "")},
573
+ account=ctx.account,
574
+ limit=limit,
575
+ )
576
+
577
+
578
+ SPEC_REVENUE_LIST = OperationSpec(
579
+ id="chat.revenue.list",
580
+ request=RevenueListReq,
581
+ response=Page[RevenueTransaction],
582
+ impl=list_revenue,
583
+ summary="Stars / TON transaction history of a channel",
584
+ description="The cursor is the opaque `next_offset` string the server hands back.",
585
+ paginated=PageKind.PARTICIPANTS,
586
+ columns=("id", "date", "amount", "title"),
587
+ example={"items": [{"id": "tx1", "amount": 50, "title": "Subscription"}], "has_more": False},
588
+ example_args="chat revenue list @mychannel --in",
589
+ covers=("groups-channels-admin.stars-transactions",),
590
+ )
591
+
592
+
593
+ # ---------------------------------------------------------------------------
594
+ # boost
595
+ # ---------------------------------------------------------------------------
596
+
597
+
598
+ class BoostGetReq(Request):
599
+ chat: Annotated[
600
+ PeerRef | None,
601
+ arg(0, metavar="CHAT", kind="peer", required=False, help="Chat to inspect."),
602
+ ] = None
603
+ features: Annotated[bool, opt("--features", help="Print what each boost level unlocks.")] = (
604
+ False
605
+ )
606
+ level: Annotated[
607
+ int | None, opt("--level", metavar="N", help="With --features: one level.")
608
+ ] = None
609
+ kind: Annotated[
610
+ str | None, opt("--kind", metavar="CHANNEL|GROUP", help="With --features and no chat.")
611
+ ] = None
612
+
613
+
614
+ async def get_boosts(ctx: OpContext, req: BoostGetReq) -> BoostStatus:
615
+ """A chat's boost level and progress, and what the next level unlocks."""
616
+ from telethon.tl.functions import help as help_fn
617
+ from telethon.tl.functions import premium as fn
618
+
619
+ handle = _admin.client(ctx)
620
+ status = BoostStatus()
621
+ if req.chat is not None:
622
+ peer = await _send.resolve(ctx, req.chat)
623
+ reply = await handle(fn.GetBoostsStatusRequest(peer=peer))
624
+ audience = getattr(reply, "premium_audience", None)
625
+ status = BoostStatus(
626
+ chat_id=peer_id_of(peer) or 0,
627
+ level=int(getattr(reply, "level", 0) or 0),
628
+ boosts=int(getattr(reply, "boosts", 0) or 0),
629
+ current_level_boosts=int(getattr(reply, "current_level_boosts", 0) or 0),
630
+ next_level_boosts=getattr(reply, "next_level_boosts", None),
631
+ premium_audience=(
632
+ {
633
+ "part": float(getattr(audience, "part", 0.0) or 0.0),
634
+ "total": float(getattr(audience, "total", 0.0) or 0.0),
635
+ }
636
+ if audience is not None
637
+ else None
638
+ ),
639
+ boost_url=str(getattr(reply, "boost_url", "") or ""),
640
+ my_boost=bool(getattr(reply, "my_boost", False)),
641
+ boosts_applied=len(getattr(reply, "my_boost_slots", None) or []) or None,
642
+ prepaid_giveaways=[
643
+ {
644
+ "id": int(getattr(item, "id", 0) or 0),
645
+ "quantity": int(getattr(item, "quantity", 0) or 0),
646
+ "months": int(getattr(item, "months", 0) or 0),
647
+ }
648
+ for item in (getattr(reply, "prepaid_giveaways", None) or [])
649
+ ],
650
+ )
651
+ elif not req.features:
652
+ raise UsageError("name a chat, or pass --features for the level table", field="chat")
653
+
654
+ if req.features:
655
+ config = await handle(help_fn.GetAppConfigRequest(hash=0))
656
+ for item in getattr(getattr(config, "config", None), "value", None) or []:
657
+ key = str(getattr(item, "key", "") or "")
658
+ if key not in _BOOST_FEATURES:
659
+ continue
660
+ raw = getattr(getattr(item, "value", None), "value", None)
661
+ try:
662
+ needed = int(float(raw or 0))
663
+ except (TypeError, ValueError): # pragma: no cover - appConfig is numeric here
664
+ continue
665
+ if req.level is not None and needed != req.level:
666
+ continue
667
+ status.features.append({"key": key, "level": needed, "unlocks": _BOOST_FEATURES[key]})
668
+ status.features.sort(key=lambda row: (int(row["level"]), str(row["key"])))
669
+ return status
670
+
671
+
672
+ SPEC_BOOST_GET = OperationSpec(
673
+ id="boost.get",
674
+ request=BoostGetReq,
675
+ response=BoostStatus,
676
+ impl=get_boosts,
677
+ summary="Boost status of a chat, or the boost-level feature table",
678
+ description=(
679
+ "`--features` maps the appConfig level keys to the tlgr flags they "
680
+ "gate, so “why was `--autotranslate` refused” has an answer without "
681
+ "reading the API docs. `boost_url` is the shareable boost link."
682
+ ),
683
+ aliases=("chat.boost.get",),
684
+ columns=("chat_id", "level", "boosts", "next_level_boosts"),
685
+ example={"chat_id": -1001600, "level": 3, "boosts": 12, "next_level_boosts": 15},
686
+ example_args="boost get @mychannel",
687
+ covers=(
688
+ "giveaway.boost-status",
689
+ "groups-channels-admin.boost-level-features",
690
+ "groups-channels-admin.boost-link",
691
+ "groups-channels-admin.boost-status",
692
+ ),
693
+ )
694
+
695
+
696
+ class BoostListReq(Request):
697
+ chat: Annotated[
698
+ PeerRef | None,
699
+ arg(0, metavar="CHAT", kind="peer", required=False, help="Chat whose boosters to list."),
700
+ ] = None
701
+ user: Annotated[
702
+ PeerRef | None,
703
+ opt("--user", metavar="USER", kind="user", help="Only the boosts this user applied."),
704
+ ] = None
705
+ gifts: Annotated[bool, opt("--gifts", help="Only gift and giveaway boosts.")] = False
706
+ mine: Annotated[bool, opt("--mine", help="My own boost slots across every chat.")] = False
707
+
708
+
709
+ async def list_boosts(ctx: OpContext, req: BoostListReq) -> Page[Boost]:
710
+ """Boosters of a chat, one user's boosts, or my own slots."""
711
+ from telethon.tl.functions import premium as fn
712
+
713
+ limit, state = _admin.window(ctx, "boost.list", PageKind.PARTICIPANTS)
714
+ handle = _admin.client(ctx)
715
+
716
+ if req.mine:
717
+ reply = await handle(fn.GetMyBoostsRequest())
718
+ rows = [
719
+ Boost(
720
+ slot=int(getattr(row, "slot", 0) or 0),
721
+ chat_id=peer_id_of(getattr(row, "peer", None)) or None,
722
+ date=fmt_dt(getattr(row, "date", None)),
723
+ date_unix=to_unix(getattr(row, "date", None)),
724
+ expires=fmt_dt(getattr(row, "expires", None)),
725
+ cooldown_until_date=fmt_dt(getattr(row, "cooldown_until_date", None)),
726
+ )
727
+ for row in (getattr(reply, "my_boosts", None) or [])
728
+ ]
729
+ return build_page(
730
+ rows,
731
+ op="boost.list",
732
+ kind=PageKind.PARTICIPANTS,
733
+ account=ctx.account,
734
+ has_more=False,
735
+ total=len(rows),
736
+ )
737
+
738
+ if req.chat is None:
739
+ raise UsageError("name a chat, or pass --mine for your own slots", field="chat")
740
+ peer = await _send.resolve(ctx, req.chat)
741
+ if req.user is not None:
742
+ user = await _send.resolve(ctx, req.user)
743
+ reply = await handle(fn.GetUserBoostsRequest(peer=peer, user_id=_admin.input_user(user)))
744
+ raw_rows = getattr(reply, "boosts", None) or []
745
+ next_state: dict[str, Any] = {}
746
+ more = False
747
+ else:
748
+ reply = await handle(
749
+ fn.GetBoostsListRequest(
750
+ peer=peer,
751
+ offset=str(state.get("offset", "") or ""),
752
+ limit=limit,
753
+ gifts=req.gifts or None,
754
+ )
755
+ )
756
+ raw_rows = getattr(reply, "boosts", None) or []
757
+ next_state = {"offset": str(getattr(reply, "next_offset", "") or "")}
758
+ more = bool(next_state["offset"])
759
+
760
+ rows = [
761
+ Boost(
762
+ id=str(getattr(row, "id", "") or ""),
763
+ user_id=getattr(row, "user_id", None),
764
+ chat_id=peer_id_of(peer) or None,
765
+ gift=bool(getattr(row, "gift", False)),
766
+ giveaway=bool(getattr(row, "giveaway", False)),
767
+ unclaimed=bool(getattr(row, "unclaimed", False)),
768
+ multiplier=getattr(row, "multiplier", None),
769
+ stars=getattr(row, "stars", None),
770
+ date=fmt_dt(getattr(row, "date", None)),
771
+ date_unix=to_unix(getattr(row, "date", None)),
772
+ expires=fmt_dt(getattr(row, "expires", None)),
773
+ )
774
+ for row in raw_rows
775
+ ]
776
+ return build_page(
777
+ rows,
778
+ op="boost.list",
779
+ kind=PageKind.PARTICIPANTS,
780
+ state=next_state,
781
+ account=ctx.account,
782
+ has_more=more,
783
+ total=int(getattr(reply, "count", len(rows)) or len(rows)),
784
+ )
785
+
786
+
787
+ SPEC_BOOST_LIST = OperationSpec(
788
+ id="boost.list",
789
+ request=BoostListReq,
790
+ response=Page[Boost],
791
+ impl=list_boosts,
792
+ summary="List boosters of a chat, one user's boosts, or my own boost slots",
793
+ description=(
794
+ "The cursor is the opaque `next_offset` string. `--mine` reports "
795
+ "each slot's `cooldown_until_date`, which is what `boost add` needs "
796
+ "before moving a slot to another chat."
797
+ ),
798
+ aliases=("chat.boost.list",),
799
+ paginated=PageKind.PARTICIPANTS,
800
+ columns=("id", "user_id", "gift", "expires"),
801
+ example={"items": [{"id": "b1", "user_id": 4242, "expires": "2026-06-01T00:00:00Z"}]},
802
+ example_args="boost list @mychannel",
803
+ covers=(
804
+ "giveaway.boosts-list",
805
+ "giveaway.boosts-unrestrict",
806
+ "giveaway.user-boosts",
807
+ "groups-channels-admin.boost-list",
808
+ "groups-channels-admin.boost-user",
809
+ ),
810
+ coverage_note=(
811
+ "The boost surface a booster and an admin both read. Letting boosters "
812
+ "bypass restrictions is `chat setting set` territory and shares this "
813
+ "surface."
814
+ ),
815
+ )
816
+
817
+
818
+ class BoostAddReq(Request):
819
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to boost.")]
820
+ slots: Annotated[
821
+ list[int], opt("--slots", metavar="ID", help="Slot ids to spend; default: the free ones.")
822
+ ] = []
823
+
824
+
825
+ async def add_boost(ctx: OpContext, req: BoostAddReq) -> BoostApplied:
826
+ """Spend Premium boost slots on a chat.
827
+
828
+ With no `--slots` the free slots reported by `premium.getMyBoosts` are
829
+ spent, which is what the GUI does. Moving a slot that is already on
830
+ another chat is rate-limited by its `cooldown_until_date`, so the
831
+ cooldown is reported rather than discovered as a flood wait.
832
+ """
833
+ from telethon.tl.functions import premium as fn
834
+
835
+ handle = _admin.client(ctx)
836
+ peer = await _send.resolve(ctx, req.chat)
837
+ chat_id = peer_id_of(peer) or 0
838
+
839
+ slots = [int(s) for s in req.slots]
840
+ cooldown: str | None = None
841
+ if not slots:
842
+ mine = await handle(fn.GetMyBoostsRequest())
843
+ for row in getattr(mine, "my_boosts", None) or []:
844
+ if getattr(row, "peer", None) is None:
845
+ slots.append(int(getattr(row, "slot", 0) or 0))
846
+ elif peer_id_of(row.peer) == chat_id:
847
+ _admin.already(ctx)
848
+ if not slots:
849
+ free_cooldowns = [
850
+ fmt_dt(getattr(row, "cooldown_until_date", None))
851
+ for row in (getattr(mine, "my_boosts", None) or [])
852
+ if getattr(row, "cooldown_until_date", None) is not None
853
+ ]
854
+ cooldown = next((value for value in free_cooldowns if value), None)
855
+ raise NotFoundError(
856
+ "no free boost slot; `boost list --mine` shows each slot and when "
857
+ "it comes off cooldown"
858
+ )
859
+
860
+ try:
861
+ reply = await handle(fn.ApplyBoostRequest(peer=peer, slots=slots or None))
862
+ except Exception as exc:
863
+ if "BOOST_NOT_MODIFIED" not in str(exc):
864
+ raise
865
+ _admin.already(ctx)
866
+ return BoostApplied(chat_id=chat_id, already=True, slots=slots)
867
+ ctx.emit("chat_boosted", {"chat_id": chat_id, "slots": slots})
868
+ return BoostApplied(
869
+ chat_id=chat_id,
870
+ level=int(getattr(reply, "level", 0) or 0),
871
+ boosts=int(getattr(reply, "boosts", 0) or 0),
872
+ my_boost=bool(getattr(reply, "my_boost", True)),
873
+ peer=str(req.chat.raw),
874
+ slots=slots,
875
+ cooldown_until_date=cooldown,
876
+ )
877
+
878
+
879
+ SPEC_BOOST_ADD = OperationSpec(
880
+ id="boost.add",
881
+ request=BoostAddReq,
882
+ response=BoostApplied,
883
+ impl=add_boost,
884
+ summary="Boost a channel or group with my Premium slots",
885
+ description=(
886
+ "Needs Telegram Premium (PREMIUM_ACCOUNT_REQUIRED exits 6). "
887
+ "BOOST_NOT_MODIFIED reports `already: true` and exits 0; moving a "
888
+ "slot during its cooldown raises a flood wait and exits 7 with the "
889
+ "wait."
890
+ ),
891
+ aliases=("boost.apply", "chat.boost.apply", "premium.boost.apply"),
892
+ mutating=True,
893
+ columns=("chat_id", "level", "boosts"),
894
+ example={"chat_id": -1001600, "level": 4, "boosts": 15, "my_boost": True},
895
+ example_args="boost add @mychannel",
896
+ covers=(
897
+ "giveaway.boost-status",
898
+ "groups-channels-admin.boost-apply",
899
+ "premium.apply-boost",
900
+ ),
901
+ tags=frozenset({"visible-to-others"}),
902
+ )