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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|