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/premium.py ADDED
@@ -0,0 +1,488 @@
1
+ """The `premium` group: subscription status, the limit table, boosts, gifts.
2
+
3
+ The genuinely useful part for a CLI is `premium feature list --limits`. The
4
+ caption length, the upload size, the folder count, the pinned-chat count and
5
+ the public-username count all change with Premium, and a script that guesses
6
+ them writes a message the server then refuses. There is no MTProto method for
7
+ the table — it is assembled from `help.getAppConfig` — which is why every row
8
+ carries `source`.
9
+
10
+ Buying is absent, throughout and by policy. `premium status` prints the
11
+ premium bot and the invoice deep link for a human to open; `premium gift
12
+ send` fetches the payment form, reports the price and stops. PR-10 settled
13
+ this for the `payment` group (`ops/payment.py` names the four methods it will
14
+ not call) and this group inherits it rather than opening a second door onto
15
+ the same money.
16
+
17
+ Telethon is imported inside functions, never at module scope (§2.2).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import Annotated, Any
23
+
24
+ from tlgr.core.errors import UsageError
25
+ from tlgr.core.pagination import PageKind
26
+ from tlgr.core.timefmt import fmt_dt, to_unix
27
+ from tlgr.models.admin import Boost
28
+ from tlgr.models.base import Request
29
+ from tlgr.models.page import Page
30
+ from tlgr.models.peer import PeerRef
31
+ from tlgr.models.premium import (
32
+ GiftCode,
33
+ PremiumFeatures,
34
+ PremiumGiftOption,
35
+ PremiumGiftQuote,
36
+ PremiumLimit,
37
+ PremiumStatus,
38
+ )
39
+ from tlgr.ops import _settings
40
+ from tlgr.ops._common import client
41
+ from tlgr.ops._params import arg, opt
42
+ from tlgr.ops._spec import OpContext, OperationSpec
43
+
44
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
45
+
46
+ #: appConfig suffixes that make a `*_limit_default` / `*_limit_premium` pair.
47
+ _LIMIT_DEFAULT = "_limit_default"
48
+ _LIMIT_PREMIUM = "_limit_premium"
49
+
50
+ #: The boost-level keys, which have no method of their own.
51
+ _LEVEL_SUFFIXES = ("_level_min",)
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # premium status / feature list
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ class StatusReq(Request):
60
+ pass
61
+
62
+
63
+ async def status(ctx: OpContext, req: StatusReq) -> PremiumStatus:
64
+ """My Telegram Premium status, and how a human would buy it.
65
+
66
+ Executing a fiat payment from a CLI is prohibited, and store receipts are
67
+ rejected for third-party api_ids anyway — so the useful answer is the
68
+ deep link, not an error.
69
+ """
70
+ from telethon.tl.functions import help as fn
71
+
72
+ handle = client(ctx)
73
+ me = await handle.get_me()
74
+ config = await _settings.app_config(ctx)
75
+ result = PremiumStatus(
76
+ premium=bool(getattr(me, "premium", False)),
77
+ premium_purchase_blocked=bool(config.get("premium_purchase_blocked", True)),
78
+ premium_bot=str(config.get("premium_bot_username") or "") or None,
79
+ reason=_settings.NO_SPEND,
80
+ )
81
+ try:
82
+ promo = await handle(fn.GetPremiumPromoRequest())
83
+ except Exception as exc: # pragma: no cover - promo is optional
84
+ ctx.warn(f"the Premium promo is unavailable: {exc}")
85
+ return result
86
+ for option in getattr(promo, "period_options", None) or []:
87
+ link = getattr(option, "bot_url", None)
88
+ if link:
89
+ result.invoice_link = str(link)
90
+ break
91
+ return result
92
+
93
+
94
+ SPEC_STATUS = OperationSpec(
95
+ id="premium.status",
96
+ request=StatusReq,
97
+ response=PremiumStatus,
98
+ impl=status,
99
+ summary="My Telegram Premium status (and how to buy it, which tlgr never does)",
100
+ idempotent=True,
101
+ columns=("premium", "premium_purchase_blocked", "premium_bot"),
102
+ headers=("Premium", "Purchase blocked", "Bot"),
103
+ example={"premium": True, "premium_purchase_blocked": True, "premium_bot": "PremiumBot"},
104
+ example_args="premium status",
105
+ covers=("premium.status",),
106
+ tags=frozenset({"agent-safe"}),
107
+ )
108
+
109
+
110
+ class FeatureListReq(Request):
111
+ limits: Annotated[bool, opt("--limits", help="Only the *_limit_default/_premium table.")] = (
112
+ False
113
+ )
114
+ boost_levels: Annotated[
115
+ bool, opt("--boost-levels", help="The boost-level unlock table from appConfig.")
116
+ ] = False
117
+ channel: Annotated[
118
+ PeerRef | None,
119
+ opt("--channel", metavar="CHAT", kind="peer", help="Show this channel's boost level."),
120
+ ] = None
121
+
122
+
123
+ async def feature_list(ctx: OpContext, req: FeatureListReq) -> PremiumFeatures:
124
+ """Premium features, the promo text and the limit table.
125
+
126
+ The limit table is the practically useful half: caption length, upload
127
+ size, folder counts, pinned chats, public usernames. There is no MTProto
128
+ method for the boost-level table either — it is `channel_*_level_min` and
129
+ `group_*_level_min` from appConfig, assembled here.
130
+ """
131
+ from telethon.tl.functions import help as fn
132
+ from telethon.tl.functions import premium as pfn
133
+
134
+ handle = client(ctx)
135
+ config = await _settings.app_config(ctx)
136
+ result = PremiumFeatures()
137
+
138
+ pairs: dict[str, dict[str, int]] = {}
139
+ for key, value in config.items():
140
+ for suffix, side in ((_LIMIT_DEFAULT, "default"), (_LIMIT_PREMIUM, "premium")):
141
+ if key.endswith(suffix):
142
+ try:
143
+ pairs.setdefault(key[: -len(suffix)], {})[side] = int(float(value))
144
+ except (TypeError, ValueError):
145
+ continue
146
+ result.limits = [
147
+ PremiumLimit(name=name, default=sides.get("default", 0), premium=sides.get("premium", 0))
148
+ for name, sides in sorted(pairs.items())
149
+ ]
150
+
151
+ if req.boost_levels or not req.limits:
152
+ result.boost_levels = sorted(
153
+ (
154
+ {"key": key, "level": int(float(value))}
155
+ for key, value in config.items()
156
+ if any(key.endswith(suffix) for suffix in _LEVEL_SUFFIXES) and _is_number(value)
157
+ ),
158
+ key=lambda row: (int(row["level"]), str(row["key"])),
159
+ )
160
+
161
+ if req.channel is not None:
162
+ peer = await _settings.resolve(ctx, req.channel)
163
+ boosts = await handle(pfn.GetBoostsStatusRequest(peer=peer))
164
+ result.channel_level = int(getattr(boosts, "level", 0) or 0)
165
+
166
+ if req.limits or req.boost_levels:
167
+ return result
168
+
169
+ promo = await handle(fn.GetPremiumPromoRequest())
170
+ result.status_text = str(getattr(promo, "status_text", "") or "")
171
+ result.period_options = [
172
+ {
173
+ "months": int(getattr(option, "months", 0) or 0),
174
+ "currency": str(getattr(option, "currency", "") or ""),
175
+ "amount": int(getattr(option, "amount", 0) or 0),
176
+ "bot_url": getattr(option, "bot_url", None),
177
+ }
178
+ for option in getattr(promo, "period_options", None) or []
179
+ ]
180
+ result.video_sections = [str(name) for name in getattr(promo, "video_sections", None) or []]
181
+ return result
182
+
183
+
184
+ def _is_number(value: Any) -> bool:
185
+ try:
186
+ float(value)
187
+ except (TypeError, ValueError):
188
+ return False
189
+ return True
190
+
191
+
192
+ SPEC_FEATURE_LIST = OperationSpec(
193
+ id="premium.feature.list",
194
+ request=FeatureListReq,
195
+ response=PremiumFeatures,
196
+ impl=feature_list,
197
+ summary="Premium features, promo text and the default/premium limit table",
198
+ description=(
199
+ "`--limits` is the part a script needs: it is what decides whether a "
200
+ "caption, an upload or a folder will be accepted before it is sent."
201
+ ),
202
+ aliases=("premium.features",),
203
+ idempotent=True,
204
+ columns=("status_text", "channel_level"),
205
+ headers=("Promo", "Channel level"),
206
+ example={
207
+ "limits": [{"name": "caption_length", "default": 1024, "premium": 2048}],
208
+ "boost_levels": [],
209
+ },
210
+ example_args="premium feature list --limits",
211
+ covers=("content.limits", "premium.boost-level-features", "premium.features-list"),
212
+ tags=frozenset({"agent-safe"}),
213
+ )
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # premium boost list
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ class BoostListReq(Request):
222
+ channel: Annotated[
223
+ PeerRef | None,
224
+ arg(0, metavar="CHANNEL", required=False, kind="peer", help="Omit for my own slots."),
225
+ ] = None
226
+ gifts: Annotated[bool, opt("--gifts", help="Only gift and giveaway boosts.")] = False
227
+ of_user: Annotated[
228
+ PeerRef | None,
229
+ opt("--of-user", metavar="USER", kind="user", help="Narrow to one booster."),
230
+ ] = None
231
+
232
+
233
+ async def boost_list(ctx: OpContext, req: BoostListReq) -> Page[Boost]:
234
+ """My boost slots, or the boosts applied to a channel I administer.
235
+
236
+ The same listing `boost list` answers with, reached from the Premium
237
+ screen: Premium grants `boosts_per_premium` slots and gifting Premium
238
+ adds more, which is a fact about the subscription rather than about a
239
+ channel.
240
+ """
241
+ from tlgr.ops.chat_stats import BoostListReq as StatsBoostListReq
242
+ from tlgr.ops.chat_stats import list_boosts
243
+
244
+ return await list_boosts(
245
+ ctx,
246
+ StatsBoostListReq(
247
+ chat=req.channel,
248
+ user=req.of_user,
249
+ gifts=req.gifts,
250
+ mine=req.channel is None,
251
+ ),
252
+ )
253
+
254
+
255
+ SPEC_BOOST_LIST = OperationSpec(
256
+ id="premium.boost.list",
257
+ request=BoostListReq,
258
+ response=Page[Boost],
259
+ impl=boost_list,
260
+ summary="My boost slots, or the boosts applied to a channel I administer",
261
+ aliases=("boost.slots",),
262
+ paginated=PageKind.PARTICIPANTS,
263
+ idempotent=True,
264
+ columns=("slot", "chat_id", "user_id", "expires", "cooldown_until_date"),
265
+ headers=("Slot", "Chat", "User", "Expires", "Cooldown"),
266
+ example={
267
+ "items": [{"slot": 1, "chat_id": -1001600, "expires": "2026-10-01T00:00:00Z"}],
268
+ "has_more": False,
269
+ },
270
+ example_args="premium boost list",
271
+ covers=("premium.channel-boosts-list", "premium.my-boosts", "stories.boost-status"),
272
+ tags=frozenset({"agent-safe"}),
273
+ )
274
+
275
+
276
+ # ---------------------------------------------------------------------------
277
+ # premium gift list / send
278
+ # ---------------------------------------------------------------------------
279
+
280
+
281
+ class GiftListReq(Request):
282
+ boost_peer: Annotated[
283
+ PeerRef | None,
284
+ opt(
285
+ "--boost-peer", metavar="CHAT", kind="peer", help="Options tied to boosting a channel."
286
+ ),
287
+ ] = None
288
+ single: Annotated[
289
+ bool, opt("--single/--all-options", help="Only options for a single recipient.")
290
+ ] = True
291
+
292
+
293
+ async def gift_list(ctx: OpContext, req: GiftListReq) -> Page[PremiumGiftOption]:
294
+ """Premium gift price options, in Stars and in fiat.
295
+
296
+ A third-party client can only act on the `XTR` (Stars) options; the fiat
297
+ ones exist for the official apps' store flows. Options with `users > 1`
298
+ are giveaway options rather than direct gifts.
299
+ """
300
+ from telethon.tl.functions import payments as fn
301
+
302
+ peer = await _settings.resolve(ctx, req.boost_peer) if req.boost_peer else None
303
+ result = await client(ctx)(fn.GetPremiumGiftCodeOptionsRequest(boost_peer=peer))
304
+ rows = [
305
+ PremiumGiftOption(
306
+ months=int(getattr(option, "months", 0) or 0),
307
+ users=int(getattr(option, "users", 1) or 1),
308
+ currency=str(getattr(option, "currency", "") or ""),
309
+ amount=int(getattr(option, "amount", 0) or 0),
310
+ store_product=getattr(option, "store_product", None),
311
+ )
312
+ for option in result or []
313
+ ]
314
+ if req.single:
315
+ rows = [row for row in rows if row.users == 1]
316
+ return Page(items=rows, has_more=False, total=len(rows))
317
+
318
+
319
+ SPEC_GIFT_LIST = OperationSpec(
320
+ id="premium.gift.list",
321
+ request=GiftListReq,
322
+ response=Page[PremiumGiftOption],
323
+ impl=gift_list,
324
+ summary="Premium gift price options (Stars and fiat), for a user or a channel giveaway",
325
+ aliases=("premium.gift.options",),
326
+ paginated=PageKind.LOCAL,
327
+ idempotent=True,
328
+ columns=("months", "users", "currency", "amount", "store_product"),
329
+ headers=("Months", "Users", "Currency", "Amount", "Store"),
330
+ example={
331
+ "items": [{"months": 3, "users": 1, "currency": "XTR", "amount": 1000}],
332
+ "has_more": False,
333
+ },
334
+ example_args="premium gift list",
335
+ covers=("premium.gift-options",),
336
+ tags=frozenset({"agent-safe"}),
337
+ )
338
+
339
+
340
+ class GiftSendReq(Request):
341
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Who to gift Premium to.")]
342
+ months: Annotated[int | None, opt("--months", metavar="N", help="Subscription length.")] = None
343
+ message: Annotated[
344
+ str | None, opt("--message", metavar="TEXT", help="Note attached to the gift.")
345
+ ] = None
346
+
347
+
348
+ async def gift_send(ctx: OpContext, req: GiftSendReq) -> PremiumGiftQuote:
349
+ """Price gifting Premium to a user — and refuse to buy it.
350
+
351
+ The form is fetched so the price is visible and the recipient is
352
+ validated; `payments.sendStarsForm` is deliberately absent from tlgr's
353
+ whole surface, so `ok` is false and `reason` says why.
354
+ """
355
+ from telethon.tl import types
356
+ from telethon.tl.functions import payments as fn
357
+
358
+ if not req.months:
359
+ raise UsageError(
360
+ "--months is the subscription length; `premium gift list` shows the options",
361
+ field="months",
362
+ )
363
+ user = await _settings.input_user(ctx, req.user, field="user")
364
+ invoice = types.InputInvoicePremiumGiftStars(
365
+ user_id=user,
366
+ months=int(req.months),
367
+ message=(types.TextWithEntities(text=req.message, entities=[]) if req.message else None),
368
+ )
369
+ form = await client(ctx)(fn.GetPaymentFormRequest(invoice=invoice))
370
+ prices = getattr(getattr(form, "invoice", None), "prices", None) or []
371
+ return PremiumGiftQuote(
372
+ user_id=_settings.peer_of(await _settings.resolve(ctx, req.user)),
373
+ months=int(req.months),
374
+ stars=sum(int(getattr(price, "amount", 0) or 0) for price in prices),
375
+ currency=str(getattr(getattr(form, "invoice", None), "currency", "XTR") or "XTR"),
376
+ ok=False,
377
+ reason=_settings.NO_SPEND,
378
+ form_id=getattr(form, "form_id", None),
379
+ )
380
+
381
+
382
+ SPEC_GIFT_SEND = OperationSpec(
383
+ id="premium.gift.send",
384
+ request=GiftSendReq,
385
+ response=PremiumGiftQuote,
386
+ impl=gift_send,
387
+ summary="Price gifting Telegram Premium to a user (tlgr reads the form, never signs it)",
388
+ idempotent=True,
389
+ columns=("user_id", "months", "stars", "currency", "ok"),
390
+ headers=("User", "Months", "Stars", "Currency", "Sent"),
391
+ example={"user_id": 777123, "months": 3, "stars": 1000, "currency": "XTR", "ok": False},
392
+ example_args="premium gift send @alice --months 3",
393
+ covers_partial=("premium.gift-to-user",),
394
+ coverage_note=(
395
+ "The recipient, the length and the price are reported; signing the "
396
+ "payment form is absent from tlgr's whole surface by policy."
397
+ ),
398
+ tags=frozenset({"agent-safe"}),
399
+ )
400
+
401
+
402
+ # ---------------------------------------------------------------------------
403
+ # premium giftcode get
404
+ # ---------------------------------------------------------------------------
405
+
406
+
407
+ def _code_model(slug: str, raw: Any) -> GiftCode:
408
+ """`payments.checkedGiftCode` as a model.
409
+
410
+ The wire says `days`; every client and every price option says *months*,
411
+ so both are reported and neither is invented — `months` is the whole
412
+ months the day count buys.
413
+ """
414
+ date = getattr(raw, "date", None)
415
+ days = getattr(raw, "days", None)
416
+ return GiftCode(
417
+ slug=slug,
418
+ link=f"https://t.me/giftcode/{slug}",
419
+ from_id=_peer_id(getattr(raw, "from_id", None)),
420
+ to_id=getattr(raw, "to_id", None),
421
+ date=fmt_dt(date),
422
+ date_unix=to_unix(date),
423
+ months=int(days) // 30 if days else None,
424
+ days=int(days) if days else None,
425
+ used_date=fmt_dt(getattr(raw, "used_date", None)),
426
+ via_giveaway=bool(getattr(raw, "via_giveaway", False)),
427
+ giveaway_msg_id=getattr(raw, "giveaway_msg_id", None),
428
+ used=getattr(raw, "used_date", None) is not None,
429
+ )
430
+
431
+
432
+ def _peer_id(peer: Any) -> int | None:
433
+ from tlgr.ops._serialize import peer_id_of
434
+
435
+ return peer_id_of(peer) if peer is not None else None
436
+
437
+
438
+ class GiftcodeGetReq(Request):
439
+ slug: Annotated[str, arg(0, metavar="SLUG", help="The gift code, or a t.me/giftcode link.")]
440
+ redeem: Annotated[bool, opt("--redeem", help="Apply the code to this account (free).")] = False
441
+
442
+
443
+ async def giftcode_get(ctx: OpContext, req: GiftcodeGetReq) -> GiftCode:
444
+ """Check a Premium gift code, and optionally redeem it.
445
+
446
+ Redeeming involves no payment: the code was already bought by whoever
447
+ sent it, so this is one of the few `payments.*` writes tlgr performs.
448
+ """
449
+ from telethon.tl.functions import payments as fn
450
+
451
+ handle = client(ctx)
452
+ slug = req.slug.rsplit("/", 1)[-1]
453
+ result = await handle(fn.CheckGiftCodeRequest(slug=slug))
454
+ model = _code_model(slug, result)
455
+ if req.redeem:
456
+ if model.used:
457
+ _already(ctx)
458
+ else:
459
+ await handle(fn.ApplyGiftCodeRequest(slug=slug))
460
+ ctx.emit("giftcode_applied", {"slug": slug})
461
+ model.used = True
462
+ return model
463
+
464
+
465
+ def _already(ctx: OpContext) -> None:
466
+ mark = getattr(ctx, "mark_already", None)
467
+ if callable(mark):
468
+ mark()
469
+
470
+
471
+ SPEC_GIFTCODE_GET = OperationSpec(
472
+ id="premium.giftcode.get",
473
+ request=GiftcodeGetReq,
474
+ response=GiftCode,
475
+ impl=giftcode_get,
476
+ summary="Check a Premium gift code, and optionally redeem it",
477
+ description="Redeeming costs nothing — the code is already paid for.",
478
+ mutating=True,
479
+ idempotent=True,
480
+ rate_class="send",
481
+ columns=("slug", "from_id", "to_id", "months", "used_date", "via_giveaway"),
482
+ headers=("Slug", "From", "To", "Months", "Used", "Giveaway"),
483
+ example={"slug": "abcdef", "from_id": -1001600, "months": 3, "via_giveaway": True},
484
+ example_args="premium giftcode get abcdef",
485
+ covers=("premium.giftcode-apply", "premium.giftcode-check"),
486
+ )
487
+
488
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]