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/stars.py ADDED
@@ -0,0 +1,594 @@
1
+ """The `stars` group: the balance, the ledger, subscriptions and revenue.
2
+
3
+ Everything here reads. Acquiring Stars, moving them and withdrawing them are
4
+ financial transfers, and tlgr performs none of them — `stars url get` prints
5
+ the Fragment URL and leaves the transfer to a human in a browser, which is
6
+ what "control-only" means in the catalog and what the whole group is shaped
7
+ around.
8
+
9
+ Two details are load-bearing.
10
+
11
+ * **A Stars amount is `(amount, nanos)`.** TON arrives in the same shape with
12
+ nine decimals, and both halves are reported. A ledger that rounds is a
13
+ ledger that cannot be reconciled.
14
+ * **The transactions cursor is an opaque string**, not an integer offset.
15
+ `next_offset` comes back from the server and goes back to it unchanged;
16
+ tlgr signs it into a normal `--cursor` token so it cannot be spliced onto
17
+ another account or another op.
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 Annotated, Any
25
+
26
+ from tlgr.core.errors import UsageError
27
+ from tlgr.core.pagination import PageKind, build_page
28
+ from tlgr.core.timefmt import fmt_dt, to_unix
29
+ from tlgr.models.base import Request
30
+ from tlgr.models.page import Page
31
+ from tlgr.models.payment import StarSubscription
32
+ from tlgr.models.peer import PeerRef
33
+ from tlgr.models.stars import (
34
+ StarsBalance,
35
+ StarsRating,
36
+ StarsRefulfill,
37
+ StarsRevenue,
38
+ StarsTransaction,
39
+ StarsUrl,
40
+ )
41
+ from tlgr.ops import _settings
42
+ from tlgr.ops._common import client, window
43
+ from tlgr.ops._params import arg, opt
44
+ from tlgr.ops._spec import OpContext, OperationSpec
45
+
46
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
47
+
48
+ _PASSWORD = opt(
49
+ secret=True, envvar="TLGR_2FA_PASSWORD", help="The 2FA cloud password (never in argv)."
50
+ )
51
+
52
+ #: `starsTransactionPeer*` → the one word that says who the other side was.
53
+ PEER_KINDS = {
54
+ "StarsTransactionPeer": "peer",
55
+ "StarsTransactionPeerAppStore": "app-store",
56
+ "StarsTransactionPeerPlayMarket": "play-market",
57
+ "StarsTransactionPeerPremiumBot": "premium-bot",
58
+ "StarsTransactionPeerFragment": "fragment",
59
+ "StarsTransactionPeerAds": "ads",
60
+ "StarsTransactionPeerAPI": "api",
61
+ "StarsTransactionPeerUnsupported": "unsupported",
62
+ }
63
+
64
+ #: The boolean flags a transaction carries, in the order a reader wants them.
65
+ KINDS = (
66
+ "gift",
67
+ "reaction",
68
+ "stargift_upgrade",
69
+ "stargift_resale",
70
+ "stargift_auction_bid",
71
+ "business_transfer",
72
+ "posts_search",
73
+ "offer",
74
+ )
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # stars balance get
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ class BalanceGetReq(Request):
83
+ ton: Annotated[bool, opt("--ton", help="The TON balance instead (amounts are nanotons).")] = (
84
+ False
85
+ )
86
+
87
+
88
+ async def balance_get(ctx: OpContext, req: BalanceGetReq) -> StarsBalance:
89
+ """My Telegram Stars balance, or my TON balance.
90
+
91
+ Read-only on purpose: topping up and withdrawing happen on Fragment or in
92
+ an official app, and a CLI that could do either would be a CLI that could
93
+ lose money by accident.
94
+ """
95
+ from telethon.tl import types
96
+ from telethon.tl.functions import payments as fn
97
+
98
+ result = await client(ctx)(
99
+ fn.GetStarsStatusRequest(peer=types.InputPeerSelf(), ton=req.ton or None)
100
+ )
101
+ amount, nanos = _settings.stars_of(getattr(result, "balance", None))
102
+ missing = [
103
+ row
104
+ for row in getattr(result, "subscriptions", None) or []
105
+ if getattr(row, "missing_balance", False)
106
+ ]
107
+ return StarsBalance(
108
+ stars=0 if req.ton else amount,
109
+ nanos=nanos,
110
+ ton=amount if req.ton else None,
111
+ currency="TON" if req.ton else "XTR",
112
+ subscriptions_missing_balance=len(missing) or None,
113
+ )
114
+
115
+
116
+ SPEC_BALANCE_GET = OperationSpec(
117
+ id="stars.balance.get",
118
+ request=BalanceGetReq,
119
+ response=StarsBalance,
120
+ impl=balance_get,
121
+ summary="My Telegram Stars balance (and the TON balance with --ton)",
122
+ description=(
123
+ "`nanos` is the fractional part the wire carries; TON amounts are "
124
+ "nanotons. Neither is rounded, because a rounded ledger cannot be "
125
+ "reconciled."
126
+ ),
127
+ idempotent=True,
128
+ columns=("stars", "nanos", "ton", "subscriptions_missing_balance"),
129
+ headers=("Stars", "Nanos", "TON", "Lapsing subs"),
130
+ example={"stars": 250, "nanos": 0, "currency": "XTR"},
131
+ example_args="stars balance get",
132
+ covers=(
133
+ "bots.bot-stars-balance",
134
+ "bots.stars-topup-deeplink",
135
+ "bots.stars-topup-options",
136
+ "stars.balance",
137
+ "stars.topup-options",
138
+ ),
139
+ covers_partial=("stars.ton-balance",),
140
+ coverage_note="The TON ledger itself is `stars transaction list --ton`.",
141
+ tags=frozenset({"agent-safe"}),
142
+ )
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # stars transaction list
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ class TransactionListReq(Request):
151
+ inbound: Annotated[bool, opt("--in", help="Incoming only.")] = False
152
+ outbound: Annotated[bool, opt("--out", help="Outgoing only.")] = False
153
+ ton: Annotated[bool, opt("--ton", help="The TON ledger instead of the Stars one.")] = False
154
+ peer: Annotated[
155
+ PeerRef | None,
156
+ opt("--peer", metavar="CHAT", kind="peer", help="Transactions with one bot or channel."),
157
+ ] = None
158
+ subscription: Annotated[
159
+ str | None, opt("--subscription", metavar="ID", help="Only one subscription's charges.")
160
+ ] = None
161
+ ascending: Annotated[bool, opt("--ascending", help="Oldest first.")] = False
162
+ id: Annotated[
163
+ str | None, opt("--id", metavar="LIST", help="Fetch specific transactions by id.")
164
+ ] = None
165
+
166
+
167
+ async def transaction_list(ctx: OpContext, req: TransactionListReq) -> Page[StarsTransaction]:
168
+ """Star (or TON) transaction history.
169
+
170
+ The cursor here is the server's opaque `next_offset` string rather than a
171
+ number: passing an integer where the API wants a token silently restarts
172
+ the walk from the beginning, which is how a ledger export ends up with
173
+ the first page repeated.
174
+ """
175
+ from telethon.tl import types
176
+ from telethon.tl.functions import payments as fn
177
+
178
+ handle = client(ctx)
179
+ limit, state = window(ctx, "stars.transaction.list", PageKind.RATE, default=50)
180
+ peer = await _settings.resolve(ctx, req.peer) if req.peer is not None else types.InputPeerSelf()
181
+
182
+ if req.id:
183
+ wanted = [
184
+ types.InputStarsTransaction(id=part.strip())
185
+ for part in req.id.split(",")
186
+ if part.strip()
187
+ ]
188
+ result = await handle(
189
+ fn.GetStarsTransactionsByIDRequest(peer=peer, id=wanted, ton=req.ton or None)
190
+ )
191
+ rows = [_transaction(row, result) for row in getattr(result, "history", None) or []]
192
+ return Page(items=rows, has_more=False, total=len(rows))
193
+
194
+ result = await handle(
195
+ fn.GetStarsTransactionsRequest(
196
+ peer=peer,
197
+ offset=str(state.get("offset", "") or ""),
198
+ limit=limit,
199
+ inbound=req.inbound or None,
200
+ outbound=req.outbound or None,
201
+ ascending=req.ascending or None,
202
+ ton=req.ton or None,
203
+ subscription_id=req.subscription,
204
+ )
205
+ )
206
+ rows = [_transaction(row, result) for row in getattr(result, "history", None) or []]
207
+ next_offset = str(getattr(result, "next_offset", "") or "")
208
+ return build_page(
209
+ rows,
210
+ op="stars.transaction.list",
211
+ kind=PageKind.RATE,
212
+ state={"offset": next_offset},
213
+ account=ctx.account,
214
+ has_more=bool(next_offset),
215
+ )
216
+
217
+
218
+ def _transaction(raw: Any, envelope: Any) -> StarsTransaction:
219
+ from tlgr.ops._serialize import peer_id_of
220
+
221
+ amount, nanos = _settings.stars_of(getattr(raw, "amount", None))
222
+ holder = getattr(raw, "peer", None)
223
+ inner = getattr(holder, "peer", None)
224
+ known = _settings.entity_map(envelope)
225
+ peer_id = peer_id_of(inner) if inner is not None else None
226
+ date = getattr(raw, "date", None)
227
+ return StarsTransaction(
228
+ id=str(getattr(raw, "id", "") or ""),
229
+ date=fmt_dt(date),
230
+ date_unix=to_unix(date),
231
+ stars=amount,
232
+ nanos=nanos,
233
+ refund=bool(getattr(raw, "refund", False)),
234
+ pending=bool(getattr(raw, "pending", False)),
235
+ failed=bool(getattr(raw, "failed", False)),
236
+ peer=peer_id,
237
+ peer_kind=PEER_KINDS.get(type(holder).__name__, ""),
238
+ peer_ref=_settings.peer_model(known.get(abs(peer_id)) if peer_id else None),
239
+ title=getattr(raw, "title", None),
240
+ description=getattr(raw, "description", None),
241
+ msg_id=getattr(raw, "msg_id", None),
242
+ subscription_period=getattr(raw, "subscription_period", None),
243
+ transaction_url=getattr(raw, "transaction_url", None),
244
+ kind=next((name for name in KINDS if getattr(raw, name, False)), ""),
245
+ )
246
+
247
+
248
+ SPEC_TRANSACTION_LIST = OperationSpec(
249
+ id="stars.transaction.list",
250
+ request=TransactionListReq,
251
+ response=Page[StarsTransaction],
252
+ impl=transaction_list,
253
+ summary="Star (or TON) transaction history",
254
+ aliases=("stars.transactions",),
255
+ paginated=PageKind.RATE,
256
+ idempotent=True,
257
+ columns=("id", "date", "stars", "peer", "title", "kind", "refund"),
258
+ headers=("Id", "Date", "Stars", "Peer", "Title", "Kind", "Refund"),
259
+ example={
260
+ "items": [
261
+ {"id": "tx1", "stars": -25, "peer": 5000001, "title": "Sticker pack", "kind": "gift"}
262
+ ],
263
+ "has_more": False,
264
+ },
265
+ example_args="stars transaction list --out",
266
+ covers=("stars.ton-balance", "stars.transactions"),
267
+ tags=frozenset({"agent-safe"}),
268
+ )
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # stars subscription list / refulfill
273
+ # ---------------------------------------------------------------------------
274
+
275
+
276
+ class SubscriptionListReq(Request):
277
+ missing_balance: Annotated[
278
+ bool, opt("--missing-balance", help="Only the ones about to lapse for want of Stars.")
279
+ ] = False
280
+
281
+
282
+ async def subscription_list(ctx: OpContext, req: SubscriptionListReq) -> Page[StarSubscription]:
283
+ """My Star subscriptions.
284
+
285
+ `can_refulfill` means the *server* would allow a re-join; tlgr still
286
+ refuses to make the charge, which is what `stars subscription refulfill`
287
+ reports.
288
+ """
289
+ from telethon.tl import types
290
+ from telethon.tl.functions import payments as fn
291
+
292
+ from tlgr.ops._serialize import peer_id_of
293
+
294
+ _, state = window(ctx, "stars.subscription.list", PageKind.RATE, default=50)
295
+ result = await client(ctx)(
296
+ fn.GetStarsSubscriptionsRequest(
297
+ peer=types.InputPeerSelf(),
298
+ offset=str(state.get("offset", "") or ""),
299
+ missing_balance=req.missing_balance or None,
300
+ )
301
+ )
302
+ rows = []
303
+ for raw in getattr(result, "subscriptions", None) or []:
304
+ pricing = getattr(raw, "pricing", None)
305
+ until = getattr(raw, "until_date", None)
306
+ rows.append(
307
+ StarSubscription(
308
+ id=str(getattr(raw, "id", "") or ""),
309
+ peer=peer_id_of(getattr(raw, "peer", None)),
310
+ until_date=fmt_dt(until),
311
+ until_date_unix=to_unix(until),
312
+ pricing=(
313
+ {
314
+ "period": int(getattr(pricing, "period", 0) or 0),
315
+ "amount": int(getattr(pricing, "amount", 0) or 0),
316
+ }
317
+ if pricing is not None
318
+ else None
319
+ ),
320
+ cancelled=getattr(raw, "canceled", None),
321
+ can_refulfill=getattr(raw, "can_refulfill", None),
322
+ missing_balance=getattr(raw, "missing_balance", None),
323
+ invoice_slug=getattr(raw, "invoice_slug", None),
324
+ chat_invite_hash=getattr(raw, "chat_invite_hash", None),
325
+ title=getattr(raw, "title", None),
326
+ )
327
+ )
328
+ next_offset = str(getattr(result, "subscriptions_next_offset", "") or "")
329
+ return build_page(
330
+ rows,
331
+ op="stars.subscription.list",
332
+ kind=PageKind.RATE,
333
+ state={"offset": next_offset},
334
+ account=ctx.account,
335
+ has_more=bool(next_offset),
336
+ )
337
+
338
+
339
+ SPEC_SUBSCRIPTION_LIST = OperationSpec(
340
+ id="stars.subscription.list",
341
+ request=SubscriptionListReq,
342
+ response=Page[StarSubscription],
343
+ impl=subscription_list,
344
+ summary="My Star subscriptions",
345
+ paginated=PageKind.RATE,
346
+ idempotent=True,
347
+ columns=("id", "peer", "until_date", "cancelled", "missing_balance"),
348
+ headers=("Id", "Peer", "Until", "Cancelled", "Lapsing"),
349
+ example={
350
+ "items": [{"id": "sub1", "peer": -1001600, "until_date": "2026-10-01T00:00:00Z"}],
351
+ "has_more": False,
352
+ },
353
+ example_args="stars subscription list",
354
+ covers=(
355
+ "groups-channels-admin.channel-subscription-manage",
356
+ "stars.subscriptions-list",
357
+ ),
358
+ tags=frozenset({"agent-safe"}),
359
+ )
360
+
361
+
362
+ class SubscriptionRefulfillReq(Request):
363
+ id: Annotated[str, arg(0, metavar="ID", help="The subscription id.")]
364
+
365
+
366
+ async def subscription_refulfill(ctx: OpContext, req: SubscriptionRefulfillReq) -> StarsRefulfill:
367
+ """Report whether a lapsed Star subscription could be re-joined — and refuse to.
368
+
369
+ Re-joining debits Stars. `payments.fulfillStarsSubscription` is one of the
370
+ four methods `ops/payment.py` names as deliberately absent from tlgr's
371
+ surface, and this command exists to say so with the subscription's own
372
+ state attached rather than to be a second way in.
373
+ """
374
+ page = await subscription_list(ctx, SubscriptionListReq())
375
+ for row in page.items:
376
+ if row.id == req.id:
377
+ return StarsRefulfill(
378
+ id=req.id,
379
+ ok=False,
380
+ can_refulfill=row.can_refulfill,
381
+ stars=(row.pricing or {}).get("amount"),
382
+ reason=_settings.NO_SPEND,
383
+ )
384
+ raise UsageError(
385
+ f"no Star subscription with the id {req.id!r}; `stars subscription list` shows them",
386
+ field="id",
387
+ )
388
+
389
+
390
+ SPEC_SUBSCRIPTION_REFULFILL = OperationSpec(
391
+ id="stars.subscription.refulfill",
392
+ request=SubscriptionRefulfillReq,
393
+ response=StarsRefulfill,
394
+ impl=subscription_refulfill,
395
+ summary="Report whether a lapsed Star subscription can be re-joined (tlgr does not charge)",
396
+ idempotent=True,
397
+ columns=("id", "ok", "can_refulfill", "stars", "reason"),
398
+ headers=("Id", "Done", "Allowed", "Stars", "Why not"),
399
+ example={"id": "sub1", "ok": False, "can_refulfill": True, "stars": 100},
400
+ example_args="stars subscription refulfill sub1",
401
+ covers_partial=("stars.subscription-refulfill",),
402
+ coverage_note=(
403
+ "Whether the server would allow it, and what it would cost, are "
404
+ "reported; the charge itself is absent from tlgr's surface by policy."
405
+ ),
406
+ tags=frozenset({"agent-safe"}),
407
+ )
408
+
409
+
410
+ # ---------------------------------------------------------------------------
411
+ # stars rating get / revenue get / url get
412
+ # ---------------------------------------------------------------------------
413
+
414
+
415
+ class RatingGetReq(Request):
416
+ user: Annotated[
417
+ PeerRef | None,
418
+ opt("--user", metavar="USER", kind="user", help="Whose rating (default: me)."),
419
+ ] = None
420
+
421
+
422
+ async def rating_get(ctx: OpContext, req: RatingGetReq) -> StarsRating:
423
+ """The Star rating badge: level, progress and what is still pending."""
424
+ from telethon.tl import types
425
+ from telethon.tl.functions import users as fn
426
+
427
+ target = (
428
+ await _settings.input_user(ctx, req.user) if req.user is not None else types.InputUserSelf()
429
+ )
430
+ answer = await client(ctx)(fn.GetFullUserRequest(id=target))
431
+ full = getattr(answer, "full_user", None)
432
+ rating = getattr(full, "stars_rating", None)
433
+ pending = getattr(full, "stars_my_pending_rating", None)
434
+ config = await _settings.app_config(ctx)
435
+ return StarsRating(
436
+ level=int(getattr(rating, "level", 0) or 0),
437
+ stars=int(getattr(rating, "stars", 0) or 0),
438
+ current_level_stars=int(getattr(rating, "current_level_stars", 0) or 0),
439
+ next_level_stars=getattr(rating, "next_level_stars", None),
440
+ pending_stars=getattr(pending, "stars", None),
441
+ pending_date=fmt_dt(getattr(full, "stars_my_pending_rating_date", None)),
442
+ learnmore_url=str(config.get("stars_rating_learnmore_url") or "") or None,
443
+ )
444
+
445
+
446
+ SPEC_RATING_GET = OperationSpec(
447
+ id="stars.rating.get",
448
+ request=RatingGetReq,
449
+ response=StarsRating,
450
+ impl=rating_get,
451
+ summary="Star rating badge (level and progress)",
452
+ idempotent=True,
453
+ columns=("level", "stars", "current_level_stars", "next_level_stars", "pending_stars"),
454
+ headers=("Level", "Stars", "This level", "Next level", "Pending"),
455
+ example={"level": 3, "stars": 1200, "current_level_stars": 1000, "next_level_stars": 2000},
456
+ example_args="stars rating get",
457
+ covers=("stars.rating",),
458
+ tags=frozenset({"agent-safe"}),
459
+ )
460
+
461
+
462
+ class RevenueGetReq(Request):
463
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="A channel or bot I own.")]
464
+ ton: Annotated[bool, opt("--ton", help="TON revenue instead of Stars.")] = False
465
+ dark: Annotated[bool, opt("--dark", help="Dark-theme graph tokens.")] = False
466
+
467
+
468
+ async def revenue_get(ctx: OpContext, req: RevenueGetReq) -> StarsRevenue:
469
+ """Star (or ad) revenue statistics for a channel or bot I own.
470
+
471
+ The graphs come back as `statsGraphAsync` tokens that need a second load;
472
+ the token is reported rather than resolved, because loading it is the
473
+ stats domain's job and doing it here would double every call.
474
+ """
475
+ from telethon.tl.functions import payments as fn
476
+
477
+ peer = await _settings.resolve(ctx, req.chat)
478
+ result = await client(ctx)(
479
+ fn.GetStarsRevenueStatsRequest(peer=peer, dark=req.dark or None, ton=req.ton or None)
480
+ )
481
+ status = getattr(result, "status", None)
482
+ current, _ = _settings.stars_of(getattr(status, "current_balance", None))
483
+ available, _ = _settings.stars_of(getattr(status, "available_balance", None))
484
+ overall, _ = _settings.stars_of(getattr(status, "overall_revenue", None))
485
+ return StarsRevenue(
486
+ chat_id=_settings.peer_of(peer),
487
+ current_balance=current,
488
+ available_balance=available,
489
+ overall_revenue=overall,
490
+ withdrawal_enabled=bool(getattr(status, "withdrawal_enabled", False)),
491
+ next_withdrawal_at=fmt_dt(getattr(status, "next_withdrawal_at", None)),
492
+ usd_rate=getattr(result, "usd_rate", None),
493
+ revenue_graph=_graph(getattr(result, "revenue_graph", None)),
494
+ top_hours_graph=_graph(getattr(result, "top_hours_graph", None)),
495
+ )
496
+
497
+
498
+ def _graph(raw: Any) -> dict[str, Any] | None:
499
+ if raw is None:
500
+ return None
501
+ return {
502
+ "kind": type(raw).__name__.removeprefix("StatsGraph").lower() or "graph",
503
+ "token": getattr(raw, "token", None),
504
+ "json": getattr(getattr(raw, "json", None), "data", None),
505
+ "error": getattr(raw, "error", None),
506
+ }
507
+
508
+
509
+ SPEC_REVENUE_GET = OperationSpec(
510
+ id="stars.revenue.get",
511
+ request=RevenueGetReq,
512
+ response=StarsRevenue,
513
+ impl=revenue_get,
514
+ summary="Star / ad revenue statistics for a channel or bot I own",
515
+ description="Needs `channelFull.can_view_stars_revenue`; the graphs are async tokens.",
516
+ idempotent=True,
517
+ columns=("chat_id", "current_balance", "available_balance", "withdrawal_enabled"),
518
+ headers=("Chat", "Balance", "Available", "Withdrawable"),
519
+ example={"chat_id": -1001600, "current_balance": 4200, "withdrawal_enabled": True},
520
+ example_args="stars revenue get @mychannel",
521
+ covers=("bots.bot-revenue-stats", "stars.revenue-stats"),
522
+ tags=frozenset({"agent-safe"}),
523
+ )
524
+
525
+
526
+ class UrlGetReq(Request):
527
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel or bot.")]
528
+ password: Annotated[str | None, _PASSWORD] = None
529
+ ads: Annotated[bool, opt("--ads", help="The ads-account URL instead of a withdrawal URL.")] = (
530
+ False
531
+ )
532
+ amount: Annotated[int | None, opt("--amount", metavar="STARS", help="Amount to withdraw.")] = (
533
+ None
534
+ )
535
+ ton: Annotated[bool, opt("--ton", help="Withdraw TON instead of Stars.")] = False
536
+
537
+
538
+ async def url_get(ctx: OpContext, req: UrlGetReq) -> StarsUrl:
539
+ """Print the Fragment URL for withdrawing revenue, or for the ads account.
540
+
541
+ Control-only by design. The withdrawal needs the cloud password as an SRP
542
+ proof, and what comes back is a URL a human opens in a browser — tlgr
543
+ prints it and stops, and does not drive the ad-purchase flow either.
544
+ """
545
+ from telethon.tl.functions import payments as fn
546
+
547
+ from tlgr.ops import _auth
548
+
549
+ handle = client(ctx)
550
+ peer = await _settings.resolve(ctx, req.chat)
551
+ if req.ads:
552
+ result = await handle(fn.GetStarsRevenueAdsAccountUrlRequest(peer=peer))
553
+ return StarsUrl(
554
+ url=str(getattr(result, "url", "") or ""),
555
+ kind="ads",
556
+ chat_id=_settings.peer_of(peer),
557
+ )
558
+
559
+ result = await _auth.with_password(
560
+ handle,
561
+ lambda srp: fn.GetStarsRevenueWithdrawalUrlRequest(
562
+ peer=peer, password=srp, ton=req.ton or None, amount=req.amount
563
+ ),
564
+ req.password,
565
+ )
566
+ return StarsUrl(
567
+ url=str(getattr(result, "url", "") or ""),
568
+ kind="withdrawal",
569
+ chat_id=_settings.peer_of(peer),
570
+ amount=req.amount,
571
+ ton=req.ton,
572
+ )
573
+
574
+
575
+ SPEC_URL_GET = OperationSpec(
576
+ id="stars.url.get",
577
+ request=UrlGetReq,
578
+ response=StarsUrl,
579
+ impl=url_get,
580
+ summary="Get the Fragment URL for withdrawing revenue, or for buying ads with Stars",
581
+ description=(
582
+ "Control-only: the URL is printed and the human completes the "
583
+ "transfer in a browser. tlgr moves no money."
584
+ ),
585
+ idempotent=True,
586
+ rate_class="send",
587
+ columns=("kind", "url", "amount", "ton"),
588
+ headers=("Kind", "URL", "Amount", "TON"),
589
+ example={"kind": "withdrawal", "url": "https://fragment.com/stars/withdraw?…"},
590
+ example_args="stars url get @mychannel --amount 1000",
591
+ covers=("gifts.withdraw-ton", "stars.ads-account", "stars.withdraw"),
592
+ )
593
+
594
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]