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/payment.py ADDED
@@ -0,0 +1,907 @@
1
+ """The `payment` group: the whole checkout surface, minus the button that pays.
2
+
3
+ tlgr models payments completely and moves no money. That is a policy, not a
4
+ gap, and the shape of this module is what makes it checkable:
5
+
6
+ * **Reading is implemented.** The form, its prices, its provider, the fields it
7
+ wants, the saved cards it would offer, the receipt afterwards, the Star
8
+ subscriptions on the account. A caller can see everything needed to decide.
9
+ * **Asking someone else to pay is implemented.** `payment invoice export` and
10
+ `payment invoice send` create an invoice — that spends nobody's money, it
11
+ requests somebody else's.
12
+ * **Spending is absent.** `payments.sendPaymentForm`, `sendStarsForm`,
13
+ `validateRequestedInfo`, `fulfillStarsSubscription` — none of them is behind
14
+ a flag, a confirmation or an environment variable. `payment form get`
15
+ reports `payable_here: false` with the reason, so an agent reading the form
16
+ learns *why* rather than discovering it from an exit code.
17
+
18
+ Cancelling a subscription is here, because cancelling costs nothing. Resuming
19
+ one re-enables future charges, which is why the whole command is confirmed.
20
+
21
+ Telethon is imported inside functions, never at module scope (§2.2).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import Annotated, Any
27
+
28
+ from tlgr.core.errors import UsageError
29
+ from tlgr.core.pagination import PageKind, build_page
30
+ from tlgr.core.timefmt import fmt_dt, parse_duration, to_unix
31
+ from tlgr.models.base import Request
32
+ from tlgr.models.page import Page
33
+ from tlgr.models.payment import (
34
+ BankCard,
35
+ Invoice,
36
+ InvoiceLink,
37
+ InvoiceSent,
38
+ PaymentForm,
39
+ PaymentInfo,
40
+ PaymentInfoCleared,
41
+ PriceLine,
42
+ Receipt,
43
+ StarSubscription,
44
+ SubscriptionChange,
45
+ )
46
+ from tlgr.models.peer import PeerRef
47
+ from tlgr.ops import _bots, _send
48
+ from tlgr.ops._common import client, window
49
+ from tlgr.ops._params import arg, choice, opt
50
+ from tlgr.ops._spec import OpContext, OperationSpec
51
+
52
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
53
+
54
+ #: The one sentence every unpayable form carries. Written once so the policy
55
+ #: reads the same in the JSON, in the docs and in the refusal.
56
+ NOT_PAYABLE = (
57
+ "tlgr never spends money: paying a form, validating order info, tipping "
58
+ "and re-fulfilling a lapsed subscription are all absent from the surface"
59
+ )
60
+
61
+
62
+ def _prices(values: Any) -> list[PriceLine]:
63
+ return [
64
+ PriceLine(
65
+ label=str(getattr(price, "label", "") or ""),
66
+ amount=int(getattr(price, "amount", 0) or 0),
67
+ )
68
+ for price in (values or [])
69
+ ]
70
+
71
+
72
+ def _invoice(raw: Any) -> Invoice | None:
73
+ if raw is None:
74
+ return None
75
+ prices = _prices(getattr(raw, "prices", None))
76
+ return Invoice(
77
+ currency=str(getattr(raw, "currency", "") or ""),
78
+ total_amount=sum(price.amount for price in prices),
79
+ prices=prices,
80
+ test=bool(getattr(raw, "test", False)),
81
+ name_requested=bool(getattr(raw, "name_requested", False)),
82
+ phone_requested=bool(getattr(raw, "phone_requested", False)),
83
+ email_requested=bool(getattr(raw, "email_requested", False)),
84
+ shipping_address_requested=bool(getattr(raw, "shipping_address_requested", False)),
85
+ flexible=bool(getattr(raw, "flexible", False)),
86
+ recurring=bool(getattr(raw, "recurring", False)),
87
+ terms_url=getattr(raw, "terms_url", None),
88
+ subscription_period=getattr(raw, "subscription_period", None),
89
+ max_tip_amount=getattr(raw, "max_tip_amount", None),
90
+ suggested_tip_amounts=[int(v) for v in (getattr(raw, "suggested_tip_amounts", None) or [])],
91
+ )
92
+
93
+
94
+ def _saved_credentials(values: Any) -> list[dict[str, Any]]:
95
+ """Saved cards, as a masked title and an id. Never a number."""
96
+ return [
97
+ {"id": str(getattr(entry, "id", "") or ""), "title": str(getattr(entry, "title", "") or "")}
98
+ for entry in (values or [])
99
+ ]
100
+
101
+
102
+ def _saved_info(info: Any) -> dict[str, Any] | None:
103
+ if info is None:
104
+ return None
105
+ address = getattr(info, "shipping_address", None)
106
+ return {
107
+ "name": getattr(info, "name", None),
108
+ "phone": getattr(info, "phone", None),
109
+ "email": getattr(info, "email", None),
110
+ "shipping": (
111
+ {
112
+ "street_line1": getattr(address, "street_line1", None),
113
+ "street_line2": getattr(address, "street_line2", None),
114
+ "city": getattr(address, "city", None),
115
+ "state": getattr(address, "state", None),
116
+ "country_iso2": getattr(address, "country_iso2", None),
117
+ "post_code": getattr(address, "post_code", None),
118
+ }
119
+ if address is not None
120
+ else None
121
+ ),
122
+ }
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # payment form get
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ class FormGetReq(Request):
131
+ message: Annotated[
132
+ str | None,
133
+ opt("--message", metavar="CHAT:MSG_ID", help="Invoice message with a Pay button."),
134
+ ] = None
135
+ slug: Annotated[str | None, opt("--slug", metavar="SLUG", help="Invoice deep-link slug.")] = (
136
+ None
137
+ )
138
+ stars: Annotated[
139
+ int | None, opt("--stars", metavar="N", help="Stars top-up form for N Stars.")
140
+ ] = None
141
+ chat_invite: Annotated[
142
+ str | None,
143
+ opt("--chat-invite", metavar="HASH", help="Star-subscription invite hash."),
144
+ ] = None
145
+ business_transfer: Annotated[
146
+ str | None,
147
+ opt("--business-transfer", metavar="BOT:STARS", help="Business → bot Stars transfer."),
148
+ ] = None
149
+ theme: Annotated[
150
+ str | None, opt("--theme", metavar="PATH", kind="path", help="JSON theme params.")
151
+ ] = None
152
+
153
+
154
+ async def _input_invoice(ctx: OpContext, req: FormGetReq) -> Any:
155
+ """One of the five `inputInvoice*` constructors, from one flag each."""
156
+ from telethon.tl import types
157
+
158
+ chosen = [
159
+ name
160
+ for name in ("message", "slug", "stars", "chat_invite", "business_transfer")
161
+ if getattr(req, name) is not None
162
+ ]
163
+ if len(chosen) != 1:
164
+ raise UsageError(
165
+ "give exactly one of --message, --slug, --stars, --chat-invite or --business-transfer",
166
+ field="slug",
167
+ )
168
+
169
+ if req.slug:
170
+ return types.InputInvoiceSlug(slug=req.slug)
171
+ if req.chat_invite:
172
+ return types.InputInvoiceChatInviteSubscription(hash=req.chat_invite)
173
+ if req.stars is not None:
174
+ return types.InputInvoiceStars(
175
+ purpose=types.InputStorePaymentStarsTopup(
176
+ stars=int(req.stars), currency="USD", amount=0
177
+ )
178
+ )
179
+ if req.business_transfer:
180
+ handle, _, amount = req.business_transfer.rpartition(":")
181
+ if not handle or not amount.isdigit():
182
+ raise UsageError(
183
+ "--business-transfer: expected '<bot>:<stars>'", field="business_transfer"
184
+ )
185
+ return types.InputInvoiceBusinessBotTransferStars(
186
+ bot=await _bots.input_user(ctx, _bots.peer_ref(handle), field="business_transfer"),
187
+ stars=int(amount),
188
+ )
189
+
190
+ chat, _, msg_id = str(req.message).rpartition(":")
191
+ if not chat or not msg_id.strip().lstrip("-").isdigit():
192
+ raise UsageError("--message: expected '<chat>:<msg_id>'", field="message")
193
+ return types.InputInvoiceMessage(
194
+ peer=await _send.resolve(ctx, _bots.peer_ref(chat)), msg_id=int(msg_id)
195
+ )
196
+
197
+
198
+ async def form_get(ctx: OpContext, req: FormGetReq) -> PaymentForm:
199
+ """Read a checkout form without paying for it.
200
+
201
+ Fetching a form charges nothing, even though it creates a server-side
202
+ `form_id` — that id is what a *payment* would then reference, and tlgr
203
+ never sends one. Star forms expire after about ten minutes; `FORM_EXPIRED`
204
+ just means fetch it again.
205
+ """
206
+ from telethon.tl.functions import payments as fn
207
+
208
+ from tlgr.ops.webapp import _theme
209
+
210
+ invoice = await _input_invoice(ctx, req)
211
+ result = await client(ctx)(
212
+ fn.GetPaymentFormRequest(invoice=invoice, theme_params=_theme(req.theme))
213
+ )
214
+ name = type(result).__name__
215
+ raw_invoice = getattr(result, "invoice", None)
216
+ model = _invoice(raw_invoice)
217
+ native = getattr(result, "native_params", None)
218
+
219
+ form = PaymentForm(
220
+ form_kind={"PaymentForm": "form", "PaymentFormStars": "stars"}.get(name, "gift"),
221
+ form_id=int(getattr(result, "form_id", 0) or 0),
222
+ bot_id=getattr(result, "bot_id", None),
223
+ provider_id=getattr(result, "provider_id", None),
224
+ title=getattr(result, "title", None),
225
+ description=getattr(result, "description", None),
226
+ photo=getattr(getattr(result, "photo", None), "url", None),
227
+ invoice=model,
228
+ currency=model.currency if model else "",
229
+ total_amount=model.total_amount if model else 0,
230
+ prices=model.prices if model else [],
231
+ tip_amounts=model.suggested_tip_amounts if model else [],
232
+ recurring=bool(getattr(raw_invoice, "recurring", False)),
233
+ terms_url=getattr(raw_invoice, "terms_url", None),
234
+ subscription_period=getattr(raw_invoice, "subscription_period", None),
235
+ url=getattr(result, "url", None),
236
+ native_provider=getattr(result, "native_provider", None),
237
+ native_params=getattr(native, "data", None),
238
+ additional_methods=[
239
+ {"url": getattr(m, "url", None), "title": getattr(m, "title", None)}
240
+ for m in (getattr(result, "additional_methods", None) or [])
241
+ ],
242
+ saved_info=_saved_info(getattr(result, "saved_info", None)),
243
+ saved_credentials=_saved_credentials(getattr(result, "saved_credentials", None)),
244
+ can_save_credentials=bool(getattr(result, "can_save_credentials", False)),
245
+ password_missing=bool(getattr(result, "password_missing", False)),
246
+ payable_here=False,
247
+ reason=NOT_PAYABLE,
248
+ )
249
+ return form
250
+
251
+
252
+ SPEC_FORM_GET = OperationSpec(
253
+ id="payment.form.get",
254
+ request=FormGetReq,
255
+ response=PaymentForm,
256
+ impl=form_get,
257
+ summary="Read an invoice's checkout form without paying",
258
+ description=(
259
+ "Price, currency, provider, required fields and saved credentials. "
260
+ "`payable_here` is always false and carries the reason: the shape a "
261
+ "caller needs in order to decide is here, and the call that would "
262
+ "charge is deliberately not."
263
+ ),
264
+ aliases=("pay.form.get",),
265
+ columns=("form_kind", "title", "currency", "total_amount"),
266
+ headers=("Kind", "Title", "Currency", "Amount"),
267
+ example={
268
+ "form_kind": "form",
269
+ "form_id": 555,
270
+ "title": "T-shirt",
271
+ "currency": "USD",
272
+ "total_amount": 1999,
273
+ "payable_here": False,
274
+ },
275
+ example_args="payment form get --slug tshirt-123",
276
+ covers=(
277
+ "bots.get-payment-form",
278
+ "bots.invoice-deeplink",
279
+ "bots.invoice-input-kinds",
280
+ "bots.recurring-payment-terms",
281
+ ),
282
+ )
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # payment receipt get
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ class ReceiptGetReq(Request):
291
+ chat: Annotated[
292
+ PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat holding the service message.")
293
+ ]
294
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Service message id.")]
295
+
296
+
297
+ async def receipt_get(ctx: OpContext, req: ReceiptGetReq) -> Receipt:
298
+ """A payment receipt.
299
+
300
+ The id must be the `messageActionPaymentSent` service message, not the
301
+ invoice that preceded it: those are two different messages and Telegram
302
+ only knows the receipt by the first.
303
+ """
304
+ from telethon.tl.functions import payments as fn
305
+
306
+ result = await client(ctx)(
307
+ fn.GetPaymentReceiptRequest(peer=await _send.resolve(ctx, req.chat), msg_id=int(req.msg_id))
308
+ )
309
+ raw_invoice = getattr(result, "invoice", None)
310
+ model = _invoice(raw_invoice)
311
+ date = getattr(result, "date", None)
312
+ return Receipt(
313
+ date=fmt_dt(date),
314
+ date_unix=to_unix(date),
315
+ bot_id=getattr(result, "bot_id", None),
316
+ provider_id=getattr(result, "provider_id", None),
317
+ title=getattr(result, "title", None),
318
+ description=getattr(result, "description", None),
319
+ invoice=model,
320
+ currency=str(getattr(result, "currency", "") or (model.currency if model else "")),
321
+ total_amount=int(getattr(result, "total_amount", 0) or 0),
322
+ tip_amount=getattr(result, "tip_amount", None),
323
+ credentials_title=getattr(result, "credentials_title", None),
324
+ shipping=_shipping(getattr(result, "shipping", None)),
325
+ info=_saved_info(getattr(result, "info", None)),
326
+ transaction_id=getattr(result, "transaction_id", None),
327
+ recurring=bool(getattr(raw_invoice, "recurring", False)),
328
+ )
329
+
330
+
331
+ def _shipping(option: Any) -> dict[str, Any] | None:
332
+ if option is None:
333
+ return None
334
+ return {
335
+ "id": getattr(option, "id", None),
336
+ "title": getattr(option, "title", None),
337
+ "prices": [
338
+ {"label": p.label, "amount": p.amount} for p in _prices(getattr(option, "prices", None))
339
+ ],
340
+ }
341
+
342
+
343
+ SPEC_RECEIPT_GET = OperationSpec(
344
+ id="payment.receipt.get",
345
+ request=ReceiptGetReq,
346
+ response=Receipt,
347
+ impl=receipt_get,
348
+ summary="Show a payment receipt",
349
+ aliases=("pay.receipt",),
350
+ columns=("date", "title", "currency", "total_amount"),
351
+ headers=("Date", "Title", "Currency", "Amount"),
352
+ example={"title": "T-shirt", "currency": "USD", "total_amount": 1999},
353
+ example_args="payment receipt get @shopbot 42",
354
+ covers=("bots.payment-receipt",),
355
+ )
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # payment info get / delete
360
+ # ---------------------------------------------------------------------------
361
+
362
+
363
+ class InfoGetReq(Request):
364
+ clear: Annotated[bool, opt("--clear", help="Clear the selected parts.")] = False
365
+ credentials: Annotated[bool, opt("--credentials", help="Select saved cards.")] = False
366
+ shipping: Annotated[bool, opt("--shipping", help="Select saved shipping info.")] = False
367
+
368
+
369
+ async def info_get(ctx: OpContext, req: InfoGetReq) -> PaymentInfo:
370
+ """My saved order information and saved cards.
371
+
372
+ Card numbers are never here to leak: `paymentSavedCredentialsCard` carries
373
+ an id and a masked title and nothing else, which is the whole reason this
374
+ is safe to print.
375
+ """
376
+ from telethon.tl.functions import payments as fn
377
+
378
+ handle = client(ctx)
379
+ result = await handle(fn.GetSavedInfoRequest())
380
+ info = _saved_info(getattr(result, "saved_info", None)) or {}
381
+ model = PaymentInfo(
382
+ has_saved_credentials=bool(getattr(result, "has_saved_credentials", False)),
383
+ saved_info=info or None,
384
+ name=info.get("name"),
385
+ phone=info.get("phone"),
386
+ email=info.get("email"),
387
+ shipping=info.get("shipping"),
388
+ has_saved_info=bool(info),
389
+ )
390
+ if req.clear:
391
+ await handle(
392
+ fn.ClearSavedInfoRequest(credentials=req.credentials or None, info=req.shipping or None)
393
+ )
394
+ model.cleared = True
395
+ model.has_saved_credentials = model.has_saved_credentials and not req.credentials
396
+ model.has_saved_info = model.has_saved_info and not req.shipping
397
+ return model
398
+
399
+
400
+ SPEC_INFO_GET = OperationSpec(
401
+ id="payment.info.get",
402
+ request=InfoGetReq,
403
+ response=PaymentInfo,
404
+ impl=info_get,
405
+ summary="Show my saved order information and saved cards",
406
+ aliases=("pay.saved-info.get", "settings.payment-info"),
407
+ mutating=True,
408
+ destructive=True,
409
+ tags=frozenset({"mutating-checked"}),
410
+ columns=("has_saved_info", "has_saved_credentials", "cleared"),
411
+ headers=("Info", "Cards", "Cleared"),
412
+ example={"has_saved_credentials": True, "has_saved_info": False},
413
+ example_args="payment info get",
414
+ covers=("bots.saved-payment-info-get", "privacy.clear-payment-info"),
415
+ )
416
+
417
+
418
+ class InfoDeleteReq(Request):
419
+ credentials: Annotated[bool, opt("--credentials", help="Forget saved cards.")] = False
420
+ info: Annotated[bool, opt("--info", help="Forget saved shipping/contact info.")] = False
421
+
422
+
423
+ async def info_delete(ctx: OpContext, req: InfoDeleteReq) -> PaymentInfoCleared:
424
+ """Clear my saved shipping information and/or saved cards.
425
+
426
+ Destructive, but not a money movement: forgetting a card does not spend
427
+ anything, which is why it is one of the few write verbs in this group.
428
+ """
429
+ from telethon.tl.functions import payments as fn
430
+
431
+ if not req.credentials and not req.info:
432
+ raise UsageError("give --credentials and/or --info", field="credentials")
433
+ await client(ctx)(
434
+ fn.ClearSavedInfoRequest(credentials=req.credentials or None, info=req.info or None)
435
+ )
436
+ return PaymentInfoCleared(credentials_cleared=req.credentials, info_cleared=req.info)
437
+
438
+
439
+ SPEC_INFO_DELETE = OperationSpec(
440
+ id="payment.info.delete",
441
+ request=InfoDeleteReq,
442
+ response=PaymentInfoCleared,
443
+ impl=info_delete,
444
+ summary="Clear my saved shipping information and saved cards",
445
+ aliases=("pay.saved-info.clear",),
446
+ mutating=True,
447
+ destructive=True,
448
+ columns=("credentials_cleared", "info_cleared"),
449
+ headers=("Cards", "Info"),
450
+ example={"credentials_cleared": True, "info_cleared": False},
451
+ example_args="payment info delete --credentials",
452
+ covers=("bots.saved-payment-info-clear",),
453
+ )
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # payment card get
458
+ # ---------------------------------------------------------------------------
459
+
460
+
461
+ class CardGetReq(Request):
462
+ number: Annotated[str, arg(0, metavar="NUMBER", help="Card number or BIN.")]
463
+
464
+
465
+ async def card_get(ctx: OpContext, req: CardGetReq) -> BankCard:
466
+ """Look up the issuing bank of a card BIN.
467
+
468
+ A read-only BIN lookup; it enters nothing into a payment flow. The number
469
+ is not echoed back, not logged, and not put on the event bus.
470
+ """
471
+ from telethon.tl.functions import payments as fn
472
+
473
+ result = await client(ctx)(fn.GetBankCardDataRequest(number=req.number.replace(" ", "")))
474
+ return BankCard(
475
+ title=str(getattr(result, "title", "") or ""),
476
+ open_urls=[
477
+ {"name": str(getattr(u, "name", "") or ""), "url": str(getattr(u, "url", "") or "")}
478
+ for u in (getattr(result, "open_urls", None) or [])
479
+ ],
480
+ )
481
+
482
+
483
+ SPEC_CARD_GET = OperationSpec(
484
+ id="payment.card.get",
485
+ request=CardGetReq,
486
+ response=BankCard,
487
+ impl=card_get,
488
+ summary="Look up the issuing bank of a card BIN",
489
+ aliases=("pay.bank-card",),
490
+ columns=("title",),
491
+ headers=("Issuer",),
492
+ example={"title": "Example Bank", "open_urls": []},
493
+ example_args="payment card get 411111",
494
+ covers=("bots.bank-card-data",),
495
+ )
496
+
497
+
498
+ # ---------------------------------------------------------------------------
499
+ # payment invoice export / send
500
+ # ---------------------------------------------------------------------------
501
+
502
+
503
+ class InvoiceExportReq(Request):
504
+ title: Annotated[str, opt("--title", help="Invoice title.")] = ""
505
+ description: Annotated[str, opt("--description", help="Invoice description.")] = ""
506
+ currency: Annotated[
507
+ str, opt("--currency", metavar="ISO", help="ISO currency, or XTR for Stars.")
508
+ ] = ""
509
+ prices: Annotated[str, opt("--prices", metavar="LABEL:AMOUNT,…", help="Price components.")] = ""
510
+ payload: Annotated[str, opt("--payload", metavar="TEXT", help="Opaque bot payload.")] = ""
511
+ provider: Annotated[
512
+ str | None, opt("--provider", metavar="TOKEN", help="Payment provider token (fiat).")
513
+ ] = None
514
+ provider_data: Annotated[
515
+ str | None, opt("--provider-data", metavar="JSON", kind="json", help="Provider JSON.")
516
+ ] = None
517
+ photo: Annotated[str | None, opt("--photo", metavar="URL", help="Invoice photo URL.")] = None
518
+ subscription_period: Annotated[
519
+ str | None,
520
+ opt("--subscription-period", metavar="DURATION", help="Recurring period (Stars only)."),
521
+ ] = None
522
+ tip_max: Annotated[
523
+ int | None, opt("--tip-max", metavar="N", help="Maximum tip the buyer may add.")
524
+ ] = None
525
+ suggested_tips: Annotated[
526
+ str | None, opt("--suggested-tips", metavar="N,…", help="Suggested tip amounts.")
527
+ ] = None
528
+ need: Annotated[
529
+ list[str],
530
+ opt("--need", metavar="FIELD", help="name|phone|email|shipping (repeatable)."),
531
+ ] = []
532
+ flexible: Annotated[bool, opt("--flexible", help="Price depends on the shipping option.")] = (
533
+ False
534
+ )
535
+ recurring_terms: Annotated[
536
+ str | None, opt("--recurring-terms", metavar="URL", help="Terms URL for a recurring one.")
537
+ ] = None
538
+
539
+
540
+ def _price_lines(spec: str) -> list[Any]:
541
+ from telethon.tl import types
542
+
543
+ out: list[Any] = []
544
+ for chunk in spec.split(","):
545
+ label, _, amount = chunk.rpartition(":")
546
+ if not label or not amount.strip().lstrip("-").isdigit():
547
+ raise UsageError("--prices: expected 'Label:1999,Shipping:500'", field="prices")
548
+ out.append(types.LabeledPrice(label=label.strip(), amount=int(amount)))
549
+ if not out:
550
+ raise UsageError("--prices is required", field="prices")
551
+ return out
552
+
553
+
554
+ def _tl_invoice(req: InvoiceExportReq) -> Any:
555
+ from telethon.tl import types
556
+
557
+ need = {n.strip() for n in req.need}
558
+ unknown = need - {"name", "phone", "email", "shipping"}
559
+ if unknown:
560
+ raise UsageError(
561
+ f"--need: {sorted(unknown)[0]!r} is not a field (name, phone, email, shipping)",
562
+ field="need",
563
+ )
564
+ period = int(parse_duration(req.subscription_period) or 0) if req.subscription_period else None
565
+ return types.Invoice(
566
+ currency=req.currency,
567
+ prices=_price_lines(req.prices),
568
+ name_requested="name" in need or None,
569
+ phone_requested="phone" in need or None,
570
+ email_requested="email" in need or None,
571
+ shipping_address_requested="shipping" in need or None,
572
+ flexible=req.flexible or None,
573
+ recurring=bool(req.recurring_terms) or None,
574
+ terms_url=req.recurring_terms,
575
+ subscription_period=period,
576
+ max_tip_amount=req.tip_max,
577
+ suggested_tip_amounts=(
578
+ [int(v) for v in req.suggested_tips.split(",") if v.strip()]
579
+ if req.suggested_tips
580
+ else None
581
+ ),
582
+ )
583
+
584
+
585
+ def _invoice_media(req: InvoiceExportReq, *, extended: Any = None) -> Any:
586
+ from telethon.tl import types
587
+
588
+ if not req.title or not req.description or not req.currency or not req.payload:
589
+ raise UsageError(
590
+ "--title, --description, --currency, --prices and --payload are all required",
591
+ field="title",
592
+ )
593
+ return types.InputMediaInvoice(
594
+ title=req.title,
595
+ description=req.description,
596
+ invoice=_tl_invoice(req),
597
+ payload=_bots.payload_bytes(req.payload, field="payload") or b"",
598
+ provider_data=_bots.data_json(req.provider_data or "{}", field="provider_data"),
599
+ photo=(
600
+ types.InputWebDocument(url=req.photo, size=0, mime_type="image/jpeg", attributes=[])
601
+ if req.photo
602
+ else None
603
+ ),
604
+ provider=req.provider,
605
+ extended_media=extended,
606
+ )
607
+
608
+
609
+ async def invoice_export(ctx: OpContext, req: InvoiceExportReq) -> InvoiceLink:
610
+ """Create an invoice deep link.
611
+
612
+ Creating an invoice is not a money movement — it asks somebody else to
613
+ pay — so it is implemented. A Star *subscription* invoice can only exist
614
+ as a link: `messages.sendMedia` rejects one, which is why there is no
615
+ `--subscription-period` on `payment invoice send`.
616
+ """
617
+ from telethon.tl.functions import payments as fn
618
+
619
+ await _bots.require_bot_session(ctx, "exporting an invoice link")
620
+ result = await client(ctx)(fn.ExportInvoiceRequest(invoice_media=_invoice_media(req)))
621
+ url = str(getattr(result, "url", "") or "")
622
+ return InvoiceLink(url=url, slug=url.rsplit("/", 1)[-1])
623
+
624
+
625
+ SPEC_INVOICE_EXPORT = OperationSpec(
626
+ id="payment.invoice.export",
627
+ request=InvoiceExportReq,
628
+ response=InvoiceLink,
629
+ impl=invoice_export,
630
+ summary="Create an invoice deep link",
631
+ aliases=("pay.invoice.export",),
632
+ mutating=True,
633
+ columns=("url", "slug"),
634
+ headers=("URL", "Slug"),
635
+ example={"url": "https://t.me/$abc123", "slug": "$abc123"},
636
+ example_args=(
637
+ 'payment invoice export --title Shirt --description "A shirt" '
638
+ "--currency USD --prices Shirt:1999 --payload order-1"
639
+ ),
640
+ covers=("bots.bot-subscription-invoice", "bots.export-invoice-link"),
641
+ )
642
+
643
+
644
+ class InvoiceSendReq(Request):
645
+ user: Annotated[PeerRef, arg(0, metavar="USER", kind="user", help="Recipient (private only).")]
646
+ title: Annotated[str, opt("--title", help="Invoice title.")] = ""
647
+ description: Annotated[str, opt("--description", help="Invoice description.")] = ""
648
+ currency: Annotated[
649
+ str, opt("--currency", metavar="ISO", help="ISO currency, or XTR for Stars.")
650
+ ] = ""
651
+ prices: Annotated[str, opt("--prices", metavar="LABEL:AMOUNT,…", help="Price components.")] = ""
652
+ payload: Annotated[str, opt("--payload", metavar="TEXT", help="Opaque bot payload.")] = ""
653
+ provider: Annotated[
654
+ str | None, opt("--provider", metavar="TOKEN", help="Payment provider token (fiat).")
655
+ ] = None
656
+ provider_data: Annotated[
657
+ str | None, opt("--provider-data", metavar="JSON", kind="json", help="Provider JSON.")
658
+ ] = None
659
+ photo: Annotated[str | None, opt("--photo", metavar="URL", help="Invoice photo URL.")] = None
660
+ extended_media: Annotated[
661
+ str | None,
662
+ opt("--extended-media", metavar="PATH", kind="path", help="Paid media behind the invoice."),
663
+ ] = None
664
+ need: Annotated[
665
+ list[str], opt("--need", metavar="FIELD", help="name|phone|email|shipping (repeatable).")
666
+ ] = []
667
+ flexible: Annotated[bool, opt("--flexible", help="Price depends on the shipping option.")] = (
668
+ False
669
+ )
670
+ silent: Annotated[bool, opt("--silent", help="Send without a notification.")] = False
671
+
672
+
673
+ async def invoice_send(ctx: OpContext, req: InvoiceSendReq) -> InvoiceSent:
674
+ """Send an invoice message to a user.
675
+
676
+ Invoices only go to private chats — Telegram refuses anything else — and
677
+ `--extended-media` turns the invoice into paid media, where the file is
678
+ hidden until the buyer pays somebody else's bill, never tlgr's.
679
+ """
680
+ from telethon.tl.functions import messages as fn
681
+
682
+ await _bots.require_bot_session(ctx, "sending an invoice")
683
+ target = await _send.resolve(ctx, req.user)
684
+ if type(target).__name__ not in ("InputPeerUser", "InputPeerSelf"):
685
+ raise UsageError("an invoice can only be sent to a private chat", field="user")
686
+
687
+ export = InvoiceExportReq(
688
+ title=req.title,
689
+ description=req.description,
690
+ currency=req.currency,
691
+ prices=req.prices,
692
+ payload=req.payload,
693
+ provider=req.provider,
694
+ provider_data=req.provider_data,
695
+ photo=req.photo,
696
+ need=req.need,
697
+ flexible=req.flexible,
698
+ )
699
+ extended = await _send.input_media(ctx, req.extended_media) if req.extended_media else None
700
+ media = _invoice_media(export, extended=extended)
701
+ from tlgr.ops._common import random_id
702
+
703
+ updates = await client(ctx)(
704
+ fn.SendMediaRequest(
705
+ peer=target,
706
+ media=media,
707
+ message="",
708
+ random_id=random_id(),
709
+ silent=req.silent or None,
710
+ )
711
+ )
712
+ message = _send.message_from_updates(updates, chat_id=_send.peer_id_of(target))
713
+ prices = _price_lines(req.prices)
714
+ return InvoiceSent(
715
+ chat_id=message.chat_id,
716
+ msg_id=message.id,
717
+ currency=req.currency,
718
+ total_amount=sum(int(getattr(p, "amount", 0) or 0) for p in prices),
719
+ )
720
+
721
+
722
+ SPEC_INVOICE_SEND = OperationSpec(
723
+ id="payment.invoice.send",
724
+ request=InvoiceSendReq,
725
+ response=InvoiceSent,
726
+ impl=invoice_send,
727
+ summary="Send an invoice message to a user",
728
+ tags=frozenset({"visible-to-others"}),
729
+ aliases=("pay.invoice.send",),
730
+ mutating=True,
731
+ rate_class="send",
732
+ columns=("chat_id", "msg_id", "currency", "total_amount"),
733
+ headers=("Chat", "Message", "Currency", "Amount"),
734
+ example={"chat_id": 4242, "msg_id": 12, "currency": "USD", "total_amount": 1999},
735
+ example_args=(
736
+ "payment invoice send @alice --title Shirt --description Shirt "
737
+ "--currency USD --prices Shirt:1999 --payload order-1"
738
+ ),
739
+ covers=("bots.send-invoice-message",),
740
+ )
741
+
742
+
743
+ # ---------------------------------------------------------------------------
744
+ # payment subscription list / set
745
+ # ---------------------------------------------------------------------------
746
+
747
+
748
+ class SubscriptionListReq(Request):
749
+ missing_balance: Annotated[
750
+ bool, opt("--missing-balance", help="Only ones that will lapse for lack of Stars.")
751
+ ] = False
752
+
753
+
754
+ def _subscription(entry: Any) -> StarSubscription:
755
+ until = getattr(entry, "until_date", None)
756
+ pricing = getattr(entry, "pricing", None)
757
+ peer = getattr(entry, "peer", None)
758
+ from tlgr.ops._serialize import peer_id_of
759
+
760
+ return StarSubscription(
761
+ id=str(getattr(entry, "id", "") or ""),
762
+ peer=peer_id_of(peer),
763
+ until_date=fmt_dt(until),
764
+ until_date_unix=to_unix(until),
765
+ pricing=(
766
+ {
767
+ "period": int(getattr(pricing, "period", 0) or 0),
768
+ "amount": int(getattr(pricing, "amount", 0) or 0),
769
+ }
770
+ if pricing is not None
771
+ else None
772
+ ),
773
+ cancelled=bool(getattr(entry, "canceled", False)),
774
+ can_refulfill=bool(getattr(entry, "can_refulfill", False)),
775
+ missing_balance=bool(getattr(entry, "missing_balance", False)),
776
+ invoice_slug=getattr(entry, "invoice_slug", None),
777
+ chat_invite_hash=getattr(entry, "chat_invite_hash", None),
778
+ title=getattr(entry, "title", None),
779
+ photo=getattr(getattr(entry, "photo", None), "url", None),
780
+ )
781
+
782
+
783
+ async def subscription_list(ctx: OpContext, req: SubscriptionListReq) -> Page[StarSubscription]:
784
+ """My Telegram Star subscriptions.
785
+
786
+ `can_refulfill` says the *server* would let a lapsed subscription be
787
+ rejoined. tlgr still will not: re-fulfilling debits Stars, and the row
788
+ carries the flag so a caller learns why rather than getting a bare
789
+ refusal.
790
+ """
791
+ from telethon.tl import types
792
+ from telethon.tl.functions import payments as fn
793
+
794
+ limit, state = window(ctx, "payment.subscription.list", PageKind.RATE, default=50)
795
+ result = await client(ctx)(
796
+ fn.GetStarsSubscriptionsRequest(
797
+ peer=types.InputPeerSelf(),
798
+ offset=str(state.get("offset", "") or ""),
799
+ missing_balance=req.missing_balance or None,
800
+ )
801
+ )
802
+ items = [
803
+ _subscription(entry) for entry in (getattr(result, "subscriptions", None) or [])[:limit]
804
+ ]
805
+ next_offset = str(getattr(result, "subscriptions_next_offset", "") or "")
806
+ return build_page(
807
+ items,
808
+ op="payment.subscription.list",
809
+ kind=PageKind.RATE,
810
+ state={"offset": next_offset},
811
+ account=ctx.account,
812
+ has_more=bool(next_offset),
813
+ total=getattr(result, "subscriptions_missing_balance", None),
814
+ )
815
+
816
+
817
+ SPEC_SUBSCRIPTION_LIST = OperationSpec(
818
+ id="payment.subscription.list",
819
+ request=SubscriptionListReq,
820
+ response=Page[StarSubscription],
821
+ impl=subscription_list,
822
+ summary="List my Telegram Star subscriptions",
823
+ aliases=("pay.subscription.list", "stars.subs.list"),
824
+ paginated=PageKind.RATE,
825
+ columns=("id", "peer", "until_date", "cancelled"),
826
+ headers=("ID", "Peer", "Until", "Cancelled"),
827
+ example={"items": [{"id": "sub1", "peer": 4242, "cancelled": False}], "has_more": False},
828
+ example_args="payment subscription list",
829
+ covers=("bots.stars-subscriptions-list",),
830
+ )
831
+
832
+
833
+ class SubscriptionSetReq(Request):
834
+ subscription_id: Annotated[
835
+ str | None,
836
+ arg(0, metavar="SUBSCRIPTION_ID", required=False, help="My subscription id."),
837
+ ] = None
838
+ auto_renew: Annotated[str, choice("on", "off", help="Resume or cancel auto-renewal.")] = "off"
839
+ user: Annotated[
840
+ PeerRef | None, opt("--user", metavar="USER", kind="user", help="Bot side: the subscriber.")
841
+ ] = None
842
+ charge_id: Annotated[
843
+ str | None, opt("--charge-id", metavar="ID", help="Bot side: provider charge id.")
844
+ ] = None
845
+
846
+
847
+ async def subscription_set(ctx: OpContext, req: SubscriptionSetReq) -> SubscriptionChange:
848
+ """Turn a Star subscription's auto-renewal on or off.
849
+
850
+ Deliberately *not* `payments.fulfillStarsSubscription`: that one pays for
851
+ a lapsed period and is absent from the surface. Cancelling costs nothing;
852
+ resuming re-enables future charges, which is what the confirmation on this
853
+ command is for.
854
+ """
855
+ from telethon.tl import types
856
+ from telethon.tl.functions import payments as fn
857
+
858
+ handle = client(ctx)
859
+ resume = req.auto_renew == "on"
860
+
861
+ if req.user is not None or req.charge_id is not None:
862
+ await _bots.require_bot_session(ctx, "cancelling a user's subscription")
863
+ if req.user is None or not req.charge_id:
864
+ raise UsageError("--user and --charge-id go together", field="charge_id")
865
+ await handle(
866
+ fn.BotCancelStarsSubscriptionRequest(
867
+ user_id=await _bots.input_user(ctx, req.user, field="user"),
868
+ charge_id=req.charge_id,
869
+ restore=resume or None,
870
+ )
871
+ )
872
+ return SubscriptionChange(
873
+ user_id=_send.peer_id_of(await _send.resolve(ctx, req.user)),
874
+ charge_id=req.charge_id,
875
+ cancelled=not resume,
876
+ )
877
+
878
+ if not req.subscription_id:
879
+ raise UsageError(
880
+ "give a subscription id, or --user with --charge-id on a bot session",
881
+ field="subscription_id",
882
+ )
883
+ await handle(
884
+ fn.ChangeStarsSubscriptionRequest(
885
+ peer=types.InputPeerSelf(),
886
+ subscription_id=req.subscription_id,
887
+ canceled=(not resume) or None,
888
+ )
889
+ )
890
+ return SubscriptionChange(subscription_id=req.subscription_id, cancelled=not resume)
891
+
892
+
893
+ SPEC_SUBSCRIPTION_SET = OperationSpec(
894
+ id="payment.subscription.set",
895
+ request=SubscriptionSetReq,
896
+ response=SubscriptionChange,
897
+ impl=subscription_set,
898
+ summary="Turn a Star subscription's auto-renewal on or off",
899
+ aliases=("pay.subscription.set",),
900
+ mutating=True,
901
+ destructive=True,
902
+ columns=("subscription_id", "cancelled"),
903
+ headers=("Subscription", "Cancelled"),
904
+ example={"subscription_id": "sub1", "cancelled": True},
905
+ example_args="payment subscription set sub1 --auto-renew off",
906
+ covers=("bots.bot-cancel-user-subscription", "bots.stars-subscription-cancel"),
907
+ )