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/gift.py ADDED
@@ -0,0 +1,1546 @@
1
+ """The `gift` group: the catalogue, the gifts a profile holds, collectibles.
2
+
3
+ A gift is addressed by a **reference**, and `ref` is the same string
4
+ everywhere here: `msg:<id>` for one received in a private chat,
5
+ `<peer>:<saved_id>` for one a channel holds, or a bare collectible slug (a
6
+ `t.me/nft/<slug>` link is accepted). One spelling, so a `ref` read out of
7
+ `gift list` can be handed straight to `gift set`, `gift convert`, `gift
8
+ transfer` or `gift upgrade` without a lookup table.
9
+
10
+ The dividing line in this group is **cost**, not danger:
11
+
12
+ * free operations are performed — displaying, pinning, wearing, converting a
13
+ gift back into Stars, a free transfer, a prepaid upgrade, listing a
14
+ collectible for sale, declining an offer, crafting;
15
+ * anything whose completion needs a payment form signed is priced and
16
+ refused, with `refused_reason` saying so. `payments.sendStarsForm` is
17
+ absent from tlgr's whole surface (`ops/payment.py`), and this group does
18
+ not open a second door onto it.
19
+
20
+ `gift craft` is the one free operation that still needs `--yes`: it burns
21
+ every input gift whatever the outcome, which is `rm -rf` semantics without a
22
+ payment anywhere near it.
23
+
24
+ Telethon is imported inside functions, never at module scope (§2.2).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Annotated, Any
30
+
31
+ from tlgr.core.errors import NotFoundError, UsageError
32
+ from tlgr.core.pagination import PageKind, build_page
33
+ from tlgr.core.timefmt import fmt_dt, fmt_unix, parse_dt, to_unix
34
+ from tlgr.models.base import Request
35
+ from tlgr.models.gift import (
36
+ GiftAttribute,
37
+ GiftAuction,
38
+ GiftAuctionState,
39
+ GiftCollection,
40
+ GiftConverted,
41
+ GiftCrafted,
42
+ GiftDisplay,
43
+ GiftListing,
44
+ GiftOfferResolved,
45
+ GiftTransferred,
46
+ GiftUpgraded,
47
+ GiftVariant,
48
+ OwnedGift,
49
+ ResaleGift,
50
+ StarGift,
51
+ UniqueGift,
52
+ )
53
+ from tlgr.models.page import Page
54
+ from tlgr.models.peer import PeerRef
55
+ from tlgr.ops import _settings
56
+ from tlgr.ops._common import client, window
57
+ from tlgr.ops._params import arg, opt
58
+ from tlgr.ops._spec import OpContext, OperationSpec
59
+
60
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Shared serialisation
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def _attribute(raw: Any) -> GiftAttribute:
69
+ from tlgr.ops._serialize import peer_id_of
70
+
71
+ name = type(raw).__name__.removeprefix("StarGiftAttribute")
72
+ kind = {
73
+ "Model": "model",
74
+ "Pattern": "pattern",
75
+ "Backdrop": "backdrop",
76
+ "OriginalDetails": "original-details",
77
+ }.get(name, name.lower())
78
+ rarity = getattr(raw, "rarity", None)
79
+ document = getattr(raw, "document", None)
80
+ message = getattr(raw, "message", None)
81
+ sender = getattr(raw, "sender_id", None)
82
+ recipient = getattr(raw, "recipient_id", None)
83
+ return GiftAttribute(
84
+ kind=kind,
85
+ name=str(getattr(raw, "name", "") or ""),
86
+ document_id=getattr(document, "id", None),
87
+ rarity_permille=getattr(rarity, "permille", None),
88
+ backdrop_id=getattr(raw, "backdrop_id", None),
89
+ center_color=getattr(raw, "center_color", None),
90
+ edge_color=getattr(raw, "edge_color", None),
91
+ pattern_color=getattr(raw, "pattern_color", None),
92
+ text_color=getattr(raw, "text_color", None),
93
+ crafted=bool(getattr(raw, "crafted", False)),
94
+ sender_id=peer_id_of(sender) if sender is not None else None,
95
+ recipient_id=peer_id_of(recipient) if recipient is not None else None,
96
+ message=getattr(message, "text", None),
97
+ date=fmt_dt(getattr(raw, "date", None)),
98
+ )
99
+
100
+
101
+ def _unique(raw: Any, known: dict[int, Any] | None = None) -> UniqueGift:
102
+ from tlgr.ops._serialize import peer_id_of
103
+
104
+ owner = getattr(raw, "owner_id", None)
105
+ owner_id = peer_id_of(owner) if owner is not None else None
106
+ resale = getattr(raw, "resell_amount", None) or []
107
+ stars = next((a for a in resale if type(a).__name__ == "StarsAmount"), None)
108
+ ton = next((a for a in resale if type(a).__name__ == "StarsTonAmount"), None)
109
+ slug = str(getattr(raw, "slug", "") or "")
110
+ return UniqueGift(
111
+ slug=slug,
112
+ gift_id=int(getattr(raw, "gift_id", 0) or 0),
113
+ id=int(getattr(raw, "id", 0) or 0),
114
+ title=str(getattr(raw, "title", "") or ""),
115
+ num=int(getattr(raw, "num", 0) or 0),
116
+ owner_id=owner_id,
117
+ owner=_settings.peer_model((known or {}).get(abs(owner_id)) if owner_id else None),
118
+ owner_name=getattr(raw, "owner_name", None),
119
+ owner_address=getattr(raw, "owner_address", None),
120
+ gift_address=getattr(raw, "gift_address", None),
121
+ availability_issued=getattr(raw, "availability_issued", None),
122
+ availability_total=getattr(raw, "availability_total", None),
123
+ attributes=[_attribute(a) for a in getattr(raw, "attributes", None) or []],
124
+ resell_stars=_settings.stars_of(stars)[0] if stars is not None else None,
125
+ resell_ton=_settings.stars_of(ton)[0] if ton is not None else None,
126
+ resale_ton_only=bool(getattr(raw, "resale_ton_only", False)),
127
+ value_stars=getattr(raw, "value_amount", None),
128
+ value_currency=getattr(raw, "value_currency", None),
129
+ value_usd=getattr(raw, "value_usd_amount", None),
130
+ hosted=getattr(raw, "host_id", None) is not None,
131
+ burned=bool(getattr(raw, "burned", False)),
132
+ crafted=bool(getattr(raw, "crafted", False)),
133
+ theme_available=bool(getattr(raw, "theme_available", False)),
134
+ peer_color_available=getattr(raw, "peer_color", None) is not None,
135
+ offer_min_stars=getattr(raw, "offer_min_stars", None),
136
+ craft_chance_permille=getattr(raw, "craft_chance_permille", None),
137
+ link=f"https://t.me/nft/{slug}" if slug else None,
138
+ )
139
+
140
+
141
+ def _catalog_gift(raw: Any) -> StarGift:
142
+ return StarGift(
143
+ gift_id=int(getattr(raw, "id", 0) or 0),
144
+ title=str(getattr(raw, "title", "") or ""),
145
+ stars=int(getattr(raw, "stars", 0) or 0),
146
+ convert_stars=getattr(raw, "convert_stars", None),
147
+ upgrade_stars=getattr(raw, "upgrade_stars", None),
148
+ limited=bool(getattr(raw, "limited", False)),
149
+ sold_out=bool(getattr(raw, "sold_out", False)),
150
+ birthday=bool(getattr(raw, "birthday", False)),
151
+ require_premium=bool(getattr(raw, "require_premium", False)),
152
+ availability_remains=getattr(raw, "availability_remains", None),
153
+ availability_total=getattr(raw, "availability_total", None),
154
+ availability_resale=getattr(raw, "availability_resale", None),
155
+ first_sale_date=fmt_dt(getattr(raw, "first_sale_date", None)),
156
+ last_sale_date=fmt_dt(getattr(raw, "last_sale_date", None)),
157
+ document_id=getattr(getattr(raw, "sticker", None), "id", None),
158
+ resell_min_stars=getattr(raw, "resell_min_stars", None),
159
+ per_user_total=getattr(raw, "per_user_total", None),
160
+ per_user_remains=getattr(raw, "per_user_remains", None),
161
+ )
162
+
163
+
164
+ def _owned(raw: Any, known: dict[int, Any]) -> OwnedGift:
165
+ from tlgr.ops._serialize import peer_id_of
166
+
167
+ gift = getattr(raw, "gift", None)
168
+ unique = _unique(gift, known) if type(gift).__name__ == "StarGiftUnique" else None
169
+ sender = getattr(raw, "from_id", None)
170
+ from_id = peer_id_of(sender) if sender is not None else None
171
+ message = getattr(raw, "message", None)
172
+ date = getattr(raw, "date", None)
173
+ return OwnedGift(
174
+ ref=_settings.gift_ref_text(raw),
175
+ kind="collectible" if unique is not None else "gift",
176
+ gift_id=int(getattr(gift, "id", 0) or 0) or None,
177
+ slug=unique.slug if unique is not None else None,
178
+ title=str(getattr(gift, "title", "") or ""),
179
+ num=getattr(raw, "gift_num", None) or (unique.num if unique is not None else None),
180
+ from_id=from_id,
181
+ from_peer=_settings.peer_model(known.get(abs(from_id)) if from_id else None),
182
+ name_hidden=bool(getattr(raw, "name_hidden", False)),
183
+ message=getattr(message, "text", None),
184
+ date=fmt_dt(date),
185
+ date_unix=to_unix(date),
186
+ msg_id=getattr(raw, "msg_id", None),
187
+ saved_id=getattr(raw, "saved_id", None),
188
+ pinned=bool(getattr(raw, "pinned_to_top", False)),
189
+ displayed=not bool(getattr(raw, "unsaved", False)),
190
+ refunded=bool(getattr(raw, "refunded", False)),
191
+ can_upgrade=bool(getattr(raw, "can_upgrade", False)),
192
+ convert_stars=getattr(raw, "convert_stars", None),
193
+ upgrade_stars=getattr(raw, "upgrade_stars", None),
194
+ transfer_stars=getattr(raw, "transfer_stars", None),
195
+ can_export_at=fmt_unix(getattr(raw, "can_export_at", None) or 0) or None,
196
+ can_transfer_at=fmt_unix(getattr(raw, "can_transfer_at", None) or 0) or None,
197
+ can_resell_at=fmt_unix(getattr(raw, "can_resell_at", None) or 0) or None,
198
+ can_craft_at=fmt_unix(getattr(raw, "can_craft_at", None) or 0) or None,
199
+ collection_ids=[int(v) for v in getattr(raw, "collection_id", None) or []],
200
+ hosted=unique.hosted if unique is not None else False,
201
+ attributes=unique.attributes if unique is not None else [],
202
+ unique=unique,
203
+ resell_stars=unique.resell_stars if unique is not None else None,
204
+ resell_ton=unique.resell_ton if unique is not None else None,
205
+ )
206
+
207
+
208
+ async def _peer_or_self(ctx: OpContext, ref: PeerRef | None) -> Any:
209
+ from telethon.tl import types
210
+
211
+ if ref is None:
212
+ return types.InputPeerSelf()
213
+ return await _settings.resolve(ctx, ref)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # gift catalog
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ class CatalogReq(Request):
222
+ until: Annotated[
223
+ PeerRef | None,
224
+ opt("--until", metavar="PEER", kind="peer", help="Annotate each gift with can_send."),
225
+ ] = None
226
+ limited: Annotated[bool, opt("--limited", help="Only limited-supply gifts.")] = False
227
+ available: Annotated[bool, opt("--available", help="Hide sold-out gifts.")] = False
228
+ refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
229
+
230
+
231
+ async def catalog(ctx: OpContext, req: CatalogReq) -> Page[StarGift]:
232
+ """Browse the gifts on sale.
233
+
234
+ Price discovery is free and automatic; *buying* one is not here, like
235
+ every other purchase. `--until` would annotate each row with whether the
236
+ named recipient accepts it, and needs `payments.canSendStarGift`, which
237
+ Telethon 1.44 has no request class for.
238
+ """
239
+ from telethon.tl.functions import payments as fn
240
+
241
+ if req.until is not None:
242
+ _settings.method_gap("gift catalog --until", "payments.canSendStarGift")
243
+
244
+ limit, state = window(ctx, "gift.catalog", PageKind.LOCAL, default=20)
245
+ result = await client(ctx)(fn.GetStarGiftsRequest(hash=0))
246
+ rows = [_catalog_gift(gift) for gift in getattr(result, "gifts", None) or []]
247
+ if req.limited:
248
+ rows = [row for row in rows if row.limited]
249
+ if req.available:
250
+ rows = [row for row in rows if not row.sold_out]
251
+ offset = int(state.get("offset", 0) or 0)
252
+ window_rows = rows[offset : offset + limit]
253
+ return build_page(
254
+ window_rows,
255
+ op="gift.catalog",
256
+ kind=PageKind.LOCAL,
257
+ state={"offset": offset + len(window_rows)},
258
+ account=ctx.account,
259
+ has_more=offset + len(window_rows) < len(rows),
260
+ total=len(rows),
261
+ )
262
+
263
+
264
+ SPEC_CATALOG = OperationSpec(
265
+ id="gift.catalog",
266
+ request=CatalogReq,
267
+ response=Page[StarGift],
268
+ impl=catalog,
269
+ summary="Browse the gifts on sale",
270
+ description=(
271
+ "Reading the catalogue is free; buying from it is absent, like every "
272
+ "purchase in tlgr. `--until` needs `payments.canSendStarGift`, which "
273
+ "this Telethon has no request class for, and refuses with exit 13."
274
+ ),
275
+ paginated=PageKind.LOCAL,
276
+ idempotent=True,
277
+ columns=("gift_id", "title", "stars", "limited", "sold_out", "availability_remains"),
278
+ headers=("Id", "Title", "Stars", "Limited", "Sold out", "Left"),
279
+ example={
280
+ "items": [{"gift_id": 5100, "title": "Plush Pepe", "stars": 500, "limited": True}],
281
+ "has_more": False,
282
+ },
283
+ example_args="gift catalog --available",
284
+ covers=("gift.catalog", "gifts.catalog"),
285
+ tags=frozenset({"agent-safe"}),
286
+ )
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # gift list / get
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ class ListReq(Request):
295
+ chat: Annotated[
296
+ PeerRef | None,
297
+ arg(0, metavar="PEER", required=False, kind="peer", help="Whose profile; default me."),
298
+ ] = None
299
+ collection: Annotated[
300
+ int | None, opt("--collection", metavar="ID", help="Only this collection.")
301
+ ] = None
302
+ sort: Annotated[str, opt("--sort", metavar="ORDER", help="date | value.")] = "date"
303
+ exclude_unsaved: Annotated[
304
+ bool, opt("--exclude-unsaved", help="Hide gifts not displayed on the profile.")
305
+ ] = False
306
+ exclude_unique: Annotated[bool, opt("--exclude-unique", help="Hide collectibles.")] = False
307
+ exclude_hosted: Annotated[
308
+ bool, opt("--exclude-hosted", help="Hide TON-hosted collectibles.")
309
+ ] = False
310
+ only_unique: Annotated[bool, opt("--only-unique", help="Only collectibles.")] = False
311
+
312
+
313
+ async def list_(ctx: OpContext, req: ListReq) -> Page[OwnedGift]:
314
+ """Gifts a profile holds — mine, or somebody else's.
315
+
316
+ Another profile's gifts are only visible when they chose to display them,
317
+ so an empty list is not evidence that they have none.
318
+ """
319
+ from telethon.tl.functions import payments as fn
320
+
321
+ limit, state = window(ctx, "gift.list", PageKind.RATE, default=20)
322
+ peer = await _peer_or_self(ctx, req.chat)
323
+ result = await client(ctx)(
324
+ fn.GetSavedStarGiftsRequest(
325
+ peer=peer,
326
+ offset=str(state.get("offset", "") or ""),
327
+ limit=limit,
328
+ exclude_unsaved=req.exclude_unsaved or None,
329
+ exclude_unique=req.exclude_unique or None,
330
+ exclude_hosted=req.exclude_hosted or None,
331
+ exclude_unlimited=None,
332
+ sort_by_value=(req.sort == "value") or None,
333
+ collection_id=req.collection,
334
+ )
335
+ )
336
+ known = _settings.entity_map(result)
337
+ rows = [_owned(row, known) for row in getattr(result, "gifts", None) or []]
338
+ if req.only_unique:
339
+ rows = [row for row in rows if row.kind == "collectible"]
340
+ next_offset = str(getattr(result, "next_offset", "") or "")
341
+ return build_page(
342
+ rows,
343
+ op="gift.list",
344
+ kind=PageKind.RATE,
345
+ state={"offset": next_offset},
346
+ account=ctx.account,
347
+ has_more=bool(next_offset),
348
+ total=getattr(result, "count", None),
349
+ )
350
+
351
+
352
+ SPEC_LIST = OperationSpec(
353
+ id="gift.list",
354
+ request=ListReq,
355
+ response=Page[OwnedGift],
356
+ impl=list_,
357
+ summary="Gifts received by a profile (mine or someone else's)",
358
+ description=(
359
+ "`ref` is the handle every other gift command takes: `msg:<id>`, "
360
+ "`<peer>:<saved_id>` or a collectible slug."
361
+ ),
362
+ paginated=PageKind.RATE,
363
+ idempotent=True,
364
+ columns=("ref", "kind", "title", "num", "from_id", "displayed", "pinned"),
365
+ headers=("Ref", "Kind", "Title", "#", "From", "Shown", "Pinned"),
366
+ example={
367
+ "items": [{"ref": "msg:120", "kind": "gift", "title": "Plush Pepe", "convert_stars": 250}],
368
+ "has_more": False,
369
+ },
370
+ example_args="gift list",
371
+ covers=("gift.hosted", "gift.received-list"),
372
+ tags=frozenset({"agent-safe"}),
373
+ )
374
+
375
+
376
+ class GetReq(Request):
377
+ ref: Annotated[str, arg(0, metavar="REF", help="msg:<id>, <peer>:<saved_id>, or a slug.")]
378
+
379
+
380
+ async def get(ctx: OpContext, req: GetReq) -> OwnedGift:
381
+ """One owned gift, with every time gate the server publishes.
382
+
383
+ "Can I transfer this?" has three answers — yes, not yet (and here is
384
+ when), never — so `can_transfer_at`, `can_resell_at`, `can_export_at`,
385
+ `can_craft_at` and `can_upgrade` are all reported rather than collapsed
386
+ into one boolean.
387
+ """
388
+ from telethon.tl.functions import payments as fn
389
+
390
+ handle = client(ctx)
391
+ stargift = await _settings.input_gift(ctx, req.ref)
392
+ if type(stargift).__name__ == "InputSavedStarGiftSlug":
393
+ result = await handle(fn.GetUniqueStarGiftRequest(slug=stargift.slug))
394
+ known = _settings.entity_map(result)
395
+ unique = _unique(getattr(result, "gift", None), known)
396
+ return OwnedGift(
397
+ ref=req.ref,
398
+ kind="collectible",
399
+ gift_id=unique.gift_id,
400
+ slug=unique.slug,
401
+ title=unique.title,
402
+ num=unique.num,
403
+ attributes=unique.attributes,
404
+ unique=unique,
405
+ hosted=unique.hosted,
406
+ resell_stars=unique.resell_stars,
407
+ resell_ton=unique.resell_ton,
408
+ )
409
+ result = await handle(fn.GetSavedStarGiftRequest(stargift=[stargift]))
410
+ known = _settings.entity_map(result)
411
+ gifts = getattr(result, "gifts", None) or []
412
+ if not gifts:
413
+ raise NotFoundError(f"no gift matches {req.ref!r}")
414
+ return _owned(gifts[0], known)
415
+
416
+
417
+ SPEC_GET = OperationSpec(
418
+ id="gift.get",
419
+ request=GetReq,
420
+ response=OwnedGift,
421
+ impl=get,
422
+ summary="Details of one owned gift, including every time gate",
423
+ idempotent=True,
424
+ columns=("ref", "kind", "title", "convert_stars", "can_transfer_at", "can_resell_at"),
425
+ headers=("Ref", "Kind", "Title", "Convert", "Transfer at", "Resell at"),
426
+ example={"ref": "msg:120", "kind": "gift", "title": "Plush Pepe", "convert_stars": 250},
427
+ example_args="gift get msg:120",
428
+ covers=("gift.get-one", "gifts.get-one"),
429
+ tags=frozenset({"agent-safe"}),
430
+ )
431
+
432
+
433
+ # ---------------------------------------------------------------------------
434
+ # gift set
435
+ # ---------------------------------------------------------------------------
436
+
437
+
438
+ class SetReq(Request):
439
+ ref: Annotated[tuple[str, ...], arg(0, metavar="REF", variadic=True, help="Gift references.")]
440
+ peer: Annotated[
441
+ PeerRef | None,
442
+ opt("--peer", metavar="PEER", kind="peer", help="Profile the gift lives on."),
443
+ ] = None
444
+ save: Annotated[bool, opt("--save", help="Display it on the profile.")] = False
445
+ unsave: Annotated[bool, opt("--unsave", help="Hide it from the profile.")] = False
446
+ pin: Annotated[bool, opt("--pin", help="Pin it to the top of the profile.")] = False
447
+ unpin: Annotated[bool, opt("--unpin", help="Unpin it.")] = False
448
+ pin_order: Annotated[
449
+ str | None,
450
+ opt("--pin-order", metavar="LIST", help="Replace the whole pinned set, in this order."),
451
+ ] = None
452
+ wear: Annotated[bool, opt("--wear", help="Wear the collectible as my emoji status.")] = False
453
+ wear_off: Annotated[bool, opt("--wear-off", help="Stop wearing it.")] = False
454
+ until: Annotated[
455
+ str | None, opt("--until", metavar="WHEN", kind="datetime", help="Wear until this time.")
456
+ ] = None
457
+
458
+
459
+ async def set_(ctx: OpContext, req: SetReq) -> GiftDisplay:
460
+ """Show, hide, pin or wear a gift.
461
+
462
+ Pinning is a *set* operation on the server — `toggleStarGiftsPinnedToTop`
463
+ replaces the pinned collection — so `--pin` reads the current set and
464
+ adds to it, and `--pin-order` is the raw replace for when you mean it.
465
+ """
466
+ from telethon.tl import types
467
+ from telethon.tl.functions import account as afn
468
+ from telethon.tl.functions import payments as fn
469
+
470
+ handle = client(ctx)
471
+ peer = await _peer_or_self(ctx, req.peer)
472
+ result = GiftDisplay(ref=req.ref[0] if req.ref else "", refs=list(req.ref))
473
+
474
+ if req.save or req.unsave:
475
+ for ref in req.ref:
476
+ await handle(
477
+ fn.SaveStarGiftRequest(
478
+ stargift=await _settings.input_gift(ctx, ref), unsave=req.unsave or None
479
+ )
480
+ )
481
+ result.displayed = not req.unsave
482
+
483
+ if req.pin or req.unpin or req.pin_order is not None:
484
+ wanted: list[str]
485
+ if req.pin_order is not None:
486
+ wanted = [part.strip() for part in req.pin_order.split(",") if part.strip()]
487
+ else:
488
+ page = await list_(ctx, ListReq(chat=req.peer))
489
+ pinned = [row.ref for row in page.items if row.pinned]
490
+ wanted = (
491
+ [ref for ref in pinned if ref not in req.ref] + list(req.ref)
492
+ if req.pin
493
+ else [ref for ref in pinned if ref not in req.ref]
494
+ )
495
+ await handle(
496
+ fn.ToggleStarGiftsPinnedToTopRequest(
497
+ peer=peer,
498
+ stargift=[await _settings.input_gift(ctx, ref) for ref in wanted],
499
+ )
500
+ )
501
+ result.pinned = bool(req.pin)
502
+
503
+ if req.wear or req.wear_off:
504
+ if req.wear_off:
505
+ await handle(afn.UpdateEmojiStatusRequest(emoji_status=types.EmojiStatusEmpty()))
506
+ result.worn = False
507
+ else:
508
+ gift = await get(ctx, GetReq(ref=req.ref[0]))
509
+ if gift.unique is None:
510
+ raise UsageError("only a collectible can be worn as an emoji status", field="ref")
511
+ until = parse_dt(req.until) if req.until else None
512
+ await handle(
513
+ afn.UpdateEmojiStatusRequest(
514
+ emoji_status=types.InputEmojiStatusCollectible(
515
+ collectible_id=gift.unique.id, until=until
516
+ )
517
+ )
518
+ )
519
+ result.worn = True
520
+ result.until = fmt_dt(until)
521
+
522
+ if result.displayed is None and result.pinned is None and result.worn is None:
523
+ raise UsageError(
524
+ "give --save/--unsave, --pin/--unpin/--pin-order or --wear/--wear-off",
525
+ field="save",
526
+ )
527
+ ctx.emit("gift_display", {"refs": list(req.ref)})
528
+ return result
529
+
530
+
531
+ SPEC_SET = OperationSpec(
532
+ id="gift.set",
533
+ request=SetReq,
534
+ response=GiftDisplay,
535
+ impl=set_,
536
+ summary="Profile display state of a gift: show/hide, pin, or wear it as my emoji status",
537
+ aliases=(
538
+ "gift.save",
539
+ "gift.unsave",
540
+ "gift.show",
541
+ "gift.hide",
542
+ "gift.pin",
543
+ "gift.unpin",
544
+ "gift.wear",
545
+ ),
546
+ mutating=True,
547
+ idempotent=True,
548
+ rate_class="send",
549
+ columns=("ref", "displayed", "pinned", "worn", "until"),
550
+ headers=("Ref", "Shown", "Pinned", "Worn", "Until"),
551
+ example={"ref": "msg:120", "displayed": True},
552
+ example_args="gift set msg:120 --save",
553
+ covers=(
554
+ "gift.as-emoji-status",
555
+ "gift.display-toggle",
556
+ "gift.pin",
557
+ "gifts.drop-original-details",
558
+ "gifts.save-unsave",
559
+ ),
560
+ tags=frozenset({"visible-to-others"}),
561
+ )
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # gift convert / upgrade / transfer / craft
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ class ConvertReq(Request):
570
+ ref: Annotated[str, arg(0, metavar="REF", help="The gift to convert.")]
571
+
572
+
573
+ async def convert(ctx: OpContext, req: ConvertReq) -> GiftConverted:
574
+ """Convert a received gift back into Stars.
575
+
576
+ Destructive but free: the gift is destroyed and Stars are credited, which
577
+ is why it is confirmed like a spend even though it earns.
578
+ """
579
+ from telethon.tl.functions import payments as fn
580
+
581
+ handle = client(ctx)
582
+ before = await get(ctx, GetReq(ref=req.ref))
583
+ await handle(fn.ConvertStarGiftRequest(stargift=await _settings.input_gift(ctx, req.ref)))
584
+ ctx.emit("gift_converted", {"ref": req.ref})
585
+ balance = None
586
+ try:
587
+ from tlgr.ops.stars import BalanceGetReq, balance_get
588
+
589
+ balance = (await balance_get(ctx, BalanceGetReq())).stars
590
+ except Exception as exc: # pragma: no cover - the balance is a nicety
591
+ ctx.warn(f"could not read the Stars balance afterwards: {exc}")
592
+ return GiftConverted(
593
+ ref=req.ref, stars_received=before.convert_stars or 0, balance_after=balance
594
+ )
595
+
596
+
597
+ SPEC_CONVERT = OperationSpec(
598
+ id="gift.convert",
599
+ request=ConvertReq,
600
+ response=GiftConverted,
601
+ impl=convert,
602
+ summary="Convert a received gift back into Stars",
603
+ description="Free, and irreversible: the gift is gone and cannot be un-converted.",
604
+ mutating=True,
605
+ destructive=True,
606
+ rate_class="send",
607
+ columns=("ref", "stars_received", "balance_after"),
608
+ headers=("Ref", "Stars", "Balance"),
609
+ example={"ref": "msg:120", "stars_received": 250, "balance_after": 500},
610
+ example_args="gift convert msg:120",
611
+ covers=("gift.convert-to-stars",),
612
+ )
613
+
614
+
615
+ class UpgradeReq(Request):
616
+ ref: Annotated[str, arg(0, metavar="REF", help="The gift to upgrade.")]
617
+ keep_original_details: Annotated[
618
+ bool, opt("--keep-original-details", help="Keep the sender and message on it.")
619
+ ] = False
620
+
621
+
622
+ async def upgrade(ctx: OpContext, req: UpgradeReq) -> GiftUpgraded:
623
+ """Upgrade a gift into a collectible, on the free or prepaid path.
624
+
625
+ When the upgrade is prepaid — `savedStarGift.upgrade_stars` is set, or
626
+ the service message carried `prepaid_upgrade` — it is a plain method
627
+ call and tlgr makes it. When it would need a payment form signed, tlgr
628
+ prints the price and refuses, because signing forms is absent from the
629
+ whole surface.
630
+ """
631
+ from telethon.tl.functions import payments as fn
632
+
633
+ handle = client(ctx)
634
+ before = await get(ctx, GetReq(ref=req.ref))
635
+ if before.upgrade_stars is None and not before.can_upgrade:
636
+ return GiftUpgraded(
637
+ ref=req.ref,
638
+ upgraded=False,
639
+ price_stars=before.upgrade_stars,
640
+ refused_reason=(
641
+ "this gift's upgrade is not prepaid, so it needs a payment form. "
642
+ + _settings.NO_SPEND
643
+ ),
644
+ )
645
+ result = await handle(
646
+ fn.UpgradeStarGiftRequest(
647
+ stargift=await _settings.input_gift(ctx, req.ref),
648
+ keep_original_details=req.keep_original_details or None,
649
+ )
650
+ )
651
+ unique = _find_unique(result)
652
+ ctx.emit("gift_upgraded", {"ref": req.ref})
653
+ return GiftUpgraded(
654
+ ref=req.ref,
655
+ slug=unique.slug if unique is not None else None,
656
+ num=unique.num if unique is not None else None,
657
+ attributes=unique.attributes if unique is not None else [],
658
+ upgraded=True,
659
+ price_stars=before.upgrade_stars,
660
+ )
661
+
662
+
663
+ def _find_unique(updates: Any) -> UniqueGift | None:
664
+ """The `starGiftUnique` an `Updates` container carries, if any."""
665
+ for update in getattr(updates, "updates", None) or []:
666
+ for holder in (update, getattr(update, "message", None)):
667
+ action = getattr(holder, "action", None)
668
+ gift = getattr(action, "gift", None) or getattr(holder, "gift", None)
669
+ if type(gift).__name__ == "StarGiftUnique":
670
+ return _unique(gift)
671
+ return None
672
+
673
+
674
+ SPEC_UPGRADE = OperationSpec(
675
+ id="gift.upgrade",
676
+ request=UpgradeReq,
677
+ response=GiftUpgraded,
678
+ impl=upgrade,
679
+ summary="Upgrade a gift into a collectible (free/prepaid path only)",
680
+ mutating=True,
681
+ rate_class="send",
682
+ columns=("ref", "slug", "num", "upgraded", "price_stars", "refused_reason"),
683
+ headers=("Ref", "Slug", "#", "Upgraded", "Price", "Why not"),
684
+ example={"ref": "msg:120", "slug": "PlushPepe-42", "num": 42, "upgraded": True},
685
+ example_args="gift upgrade msg:120",
686
+ covers=("gift.upgrade", "gifts.upgrade", "gifts.upgrade-preview"),
687
+ )
688
+
689
+
690
+ class TransferReq(Request):
691
+ ref: Annotated[str, arg(0, metavar="REF", help="The collectible to transfer.")]
692
+ chat: Annotated[PeerRef, arg(1, metavar="PEER", kind="peer", help="Who to transfer it to.")]
693
+
694
+
695
+ async def transfer(ctx: OpContext, req: TransferReq) -> GiftTransferred:
696
+ """Transfer a collectible to another peer, when the transfer is free.
697
+
698
+ A paid transfer goes through `inputInvoiceStarGiftTransfer` and a payment
699
+ form; tlgr prints the price and refuses that path, as it does everywhere
700
+ else.
701
+ """
702
+ from telethon.tl.functions import payments as fn
703
+
704
+ handle = client(ctx)
705
+ before = await get(ctx, GetReq(ref=req.ref))
706
+ to_peer = await _settings.resolve(ctx, req.chat)
707
+ if before.transfer_stars:
708
+ return GiftTransferred(
709
+ ref=req.ref,
710
+ to=_settings.peer_of(to_peer),
711
+ transferred=False,
712
+ price_stars=before.transfer_stars,
713
+ can_transfer_at=before.can_transfer_at,
714
+ refused_reason="this transfer costs Stars. " + _settings.NO_SPEND,
715
+ )
716
+ await handle(
717
+ fn.TransferStarGiftRequest(stargift=await _settings.input_gift(ctx, req.ref), to_id=to_peer)
718
+ )
719
+ ctx.emit("gift_transferred", {"ref": req.ref, "to": _settings.peer_of(to_peer)})
720
+ return GiftTransferred(
721
+ ref=req.ref,
722
+ to=_settings.peer_of(to_peer),
723
+ transferred=True,
724
+ can_transfer_at=before.can_transfer_at,
725
+ )
726
+
727
+
728
+ SPEC_TRANSFER = OperationSpec(
729
+ id="gift.transfer",
730
+ request=TransferReq,
731
+ response=GiftTransferred,
732
+ impl=transfer,
733
+ summary="Transfer a collectible gift to another peer (free transfers only)",
734
+ mutating=True,
735
+ destructive=True,
736
+ rate_class="send",
737
+ columns=("ref", "to", "transferred", "price_stars", "refused_reason"),
738
+ headers=("Ref", "To", "Done", "Price", "Why not"),
739
+ example={"ref": "PlushPepe-42", "to": 777123, "transferred": True},
740
+ example_args="gift transfer PlushPepe-42 @alice",
741
+ covers=("gift.transfer",),
742
+ tags=frozenset({"visible-to-others"}),
743
+ )
744
+
745
+
746
+ class CraftReq(Request):
747
+ ref: Annotated[
748
+ tuple[str, ...],
749
+ arg(0, metavar="REF", required=False, variadic=True, help="The gifts to melt down."),
750
+ ] = ()
751
+ candidates: Annotated[
752
+ int | None,
753
+ opt("--candidates", metavar="GIFT_ID", help="List my gifts usable to craft this one."),
754
+ ] = None
755
+
756
+
757
+ async def craft(ctx: OpContext, req: CraftReq) -> GiftCrafted:
758
+ """Craft (combine) collectible gifts.
759
+
760
+ `payments.craftStarGift` burns **every** input gift regardless of the
761
+ outcome, which is why this is destructive and confirmed even though it
762
+ costs nothing: a failed craft is still four gifts gone.
763
+ """
764
+ from telethon.tl.functions import payments as fn
765
+
766
+ if req.candidates is not None:
767
+ _settings.method_gap("gift craft --candidates", "payments.getStarGiftCraftCandidates")
768
+ if not req.ref:
769
+ raise UsageError("give the gift references to melt down", field="ref")
770
+
771
+ handle = client(ctx)
772
+ inputs = [await _settings.input_gift(ctx, ref) for ref in req.ref]
773
+ result = await handle(fn.CraftStarGiftRequest(stargift=inputs))
774
+ unique = _find_unique(result)
775
+ ctx.emit("gift_crafted", {"burned": list(req.ref)})
776
+ return GiftCrafted(
777
+ ref=f"slug:{unique.slug}" if unique is not None else None,
778
+ slug=unique.slug if unique is not None else None,
779
+ burned=list(req.ref),
780
+ crafted=True,
781
+ )
782
+
783
+
784
+ SPEC_CRAFT = OperationSpec(
785
+ id="gift.craft",
786
+ request=CraftReq,
787
+ response=GiftCrafted,
788
+ impl=craft,
789
+ summary="Craft (combine) collectible gifts",
790
+ description=(
791
+ "Every input gift is burned whatever the outcome, so `--yes` is "
792
+ "required and `--dry-run` prints exactly what would be consumed."
793
+ ),
794
+ mutating=True,
795
+ destructive=True,
796
+ rate_class="send",
797
+ columns=("slug", "burned", "crafted"),
798
+ headers=("Result", "Burned", "Crafted"),
799
+ example={"slug": "PlushPepe-77", "burned": ["msg:120", "msg:121"], "crafted": True},
800
+ example_args="gift craft msg:120 msg:121",
801
+ covers=("gift.craft", "gifts.craft"),
802
+ covers_partial=("gift.craft-candidates",),
803
+ coverage_note=(
804
+ "Listing the eligible ingredients needs `payments.getStarGiftCraftCandidates`, "
805
+ "which Telethon 1.44 has no request class for; the flag refuses with exit 13."
806
+ ),
807
+ )
808
+
809
+
810
+ # ---------------------------------------------------------------------------
811
+ # gift resale list / set, offer approve
812
+ # ---------------------------------------------------------------------------
813
+
814
+
815
+ class ResaleListReq(Request):
816
+ gift_id: Annotated[int, arg(0, metavar="GIFT_ID", help="The gift type to browse.")]
817
+ sort: Annotated[str, opt("--sort", metavar="ORDER", help="price | num | date.")] = "price"
818
+ ton_only: Annotated[bool, opt("--ton-only", help="Only TON listings.")] = False
819
+ stars_only: Annotated[bool, opt("--stars-only", help="Only Stars listings.")] = False
820
+ attr: Annotated[
821
+ tuple[str, ...],
822
+ opt("--attr", metavar="KIND=ID", help="Filter by model/pattern/backdrop."),
823
+ ] = ()
824
+
825
+
826
+ async def resale_list(ctx: OpContext, req: ResaleListReq) -> Page[ResaleGift]:
827
+ """Browse the collectible marketplace for one gift.
828
+
829
+ Reading prices is free; buying from the marketplace is a purchase and is
830
+ absent, so this is where a price check ends.
831
+ """
832
+ from telethon.tl import types
833
+ from telethon.tl.functions import payments as fn
834
+
835
+ limit, state = window(ctx, "gift.resale.list", PageKind.RATE, default=20)
836
+ attributes = []
837
+ for entry in req.attr:
838
+ kind, _, value = entry.partition("=")
839
+ if not value.isdigit():
840
+ raise UsageError("--attr takes model=<id>, pattern=<id> or backdrop=<id>", field="attr")
841
+ builder = {
842
+ "model": types.StarGiftAttributeIdModel,
843
+ "pattern": types.StarGiftAttributeIdPattern,
844
+ "backdrop": types.StarGiftAttributeIdBackdrop,
845
+ }.get(kind.strip().lower())
846
+ if builder is None:
847
+ raise UsageError("--attr takes model=, pattern= or backdrop=", field="attr")
848
+ attributes.append(
849
+ builder(backdrop_id=int(value))
850
+ if kind.strip().lower() == "backdrop"
851
+ else builder(document_id=int(value))
852
+ )
853
+
854
+ result = await client(ctx)(
855
+ fn.GetResaleStarGiftsRequest(
856
+ gift_id=int(req.gift_id),
857
+ offset=str(state.get("offset", "") or ""),
858
+ limit=limit,
859
+ sort_by_price=(req.sort == "price") or None,
860
+ sort_by_num=(req.sort == "num") or None,
861
+ stars_only=req.stars_only or None,
862
+ attributes=attributes or None,
863
+ )
864
+ )
865
+ known = _settings.entity_map(result)
866
+ rows = []
867
+ for gift in getattr(result, "gifts", None) or []:
868
+ unique = _unique(gift, known)
869
+ if req.ton_only and unique.resell_ton is None:
870
+ continue
871
+ rows.append(
872
+ ResaleGift(
873
+ slug=unique.slug,
874
+ num=unique.num,
875
+ price_stars=unique.resell_stars,
876
+ price_ton=unique.resell_ton,
877
+ seller_id=unique.owner_id,
878
+ attributes=unique.attributes,
879
+ )
880
+ )
881
+ next_offset = str(getattr(result, "next_offset", "") or "")
882
+ return build_page(
883
+ rows,
884
+ op="gift.resale.list",
885
+ kind=PageKind.RATE,
886
+ state={"offset": next_offset},
887
+ account=ctx.account,
888
+ has_more=bool(next_offset),
889
+ total=getattr(result, "count", None),
890
+ )
891
+
892
+
893
+ SPEC_RESALE_LIST = OperationSpec(
894
+ id="gift.resale.list",
895
+ request=ResaleListReq,
896
+ response=Page[ResaleGift],
897
+ impl=resale_list,
898
+ summary="Browse the collectible marketplace for one gift",
899
+ paginated=PageKind.RATE,
900
+ idempotent=True,
901
+ columns=("slug", "num", "price_stars", "price_ton", "seller_id"),
902
+ headers=("Slug", "#", "Stars", "TON", "Seller"),
903
+ example={
904
+ "items": [{"slug": "PlushPepe-42", "num": 42, "price_stars": 12000}],
905
+ "has_more": False,
906
+ },
907
+ example_args="gift resale list 5100",
908
+ covers=("gift.resale-browse", "gifts.resale-price"),
909
+ tags=frozenset({"agent-safe"}),
910
+ )
911
+
912
+
913
+ class ResaleSetReq(Request):
914
+ ref: Annotated[str, arg(0, metavar="REF", help="My collectible.")]
915
+ stars: Annotated[int | None, opt("--stars", metavar="N", help="Asking price in Stars.")] = None
916
+ ton: Annotated[int | None, opt("--ton", metavar="NANO", help="Asking price in nanotons.")] = (
917
+ None
918
+ )
919
+ unlist: Annotated[bool, opt("--unlist", help="Take it off the market.")] = False
920
+
921
+
922
+ async def resale_set(ctx: OpContext, req: ResaleSetReq) -> GiftListing:
923
+ """Put one of my collectibles up for sale, or take it off the market.
924
+
925
+ Listing is free — it earns rather than spends — and the sale itself
926
+ happens when a buyer pays, which is somebody else's payment form and not
927
+ tlgr's.
928
+ """
929
+ from telethon.tl import types
930
+ from telethon.tl.functions import payments as fn
931
+
932
+ handle = client(ctx)
933
+ if req.unlist:
934
+ amount: Any = types.StarsAmount(amount=0, nanos=0)
935
+ elif req.ton is not None:
936
+ amount = types.StarsTonAmount(amount=int(req.ton))
937
+ elif req.stars is not None:
938
+ amount = types.StarsAmount(amount=int(req.stars), nanos=0)
939
+ else:
940
+ raise UsageError("give --stars, --ton, or --unlist", field="stars")
941
+
942
+ before = await get(ctx, GetReq(ref=req.ref))
943
+ await handle(
944
+ fn.UpdateStarGiftPriceRequest(
945
+ stargift=await _settings.input_gift(ctx, req.ref), resell_amount=amount
946
+ )
947
+ )
948
+ ctx.emit("gift_listed", {"ref": req.ref, "unlist": req.unlist})
949
+ return GiftListing(
950
+ ref=req.ref,
951
+ listed=not req.unlist,
952
+ price_stars=None if req.unlist else req.stars,
953
+ price_ton=None if req.unlist else req.ton,
954
+ can_resell_at=before.can_resell_at,
955
+ )
956
+
957
+
958
+ SPEC_RESALE_SET = OperationSpec(
959
+ id="gift.resale.set",
960
+ request=ResaleSetReq,
961
+ response=GiftListing,
962
+ impl=resale_set,
963
+ summary="Put one of my collectibles up for sale, or take it off the market",
964
+ mutating=True,
965
+ idempotent=True,
966
+ rate_class="send",
967
+ columns=("ref", "listed", "price_stars", "price_ton", "can_resell_at"),
968
+ headers=("Ref", "Listed", "Stars", "TON", "Sellable at"),
969
+ example={"ref": "PlushPepe-42", "listed": True, "price_stars": 12000},
970
+ example_args="gift resale set PlushPepe-42 --stars 12000",
971
+ covers=("gift.resale-list-mine", "gifts.resale-buy"),
972
+ tags=frozenset({"visible-to-others"}),
973
+ )
974
+
975
+
976
+ class OfferApproveReq(Request):
977
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where the offer arrived.")]
978
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The offer message.")]
979
+ deny: Annotated[bool, opt("--deny", help="Decline the offer (always free).")] = False
980
+
981
+
982
+ async def offer_approve(ctx: OpContext, req: OfferApproveReq) -> GiftOfferResolved:
983
+ """Decline somebody's offer to buy my collectible — or report the price.
984
+
985
+ Declining is free and is performed. Accepting sells the asset for Stars:
986
+ that is a financial transfer, and tlgr reports the offer instead of
987
+ completing it, in the same way it never signs a payment form.
988
+ """
989
+ from telethon.tl.functions import payments as fn
990
+
991
+ handle = client(ctx)
992
+ await _settings.resolve(ctx, req.chat)
993
+ if not req.deny:
994
+ return GiftOfferResolved(
995
+ msg_id=int(req.msg_id),
996
+ state="refused",
997
+ reason="accepting an offer sells the collectible for Stars. " + _settings.NO_SPEND,
998
+ )
999
+ await handle(fn.ResolveStarGiftOfferRequest(offer_msg_id=int(req.msg_id), decline=True))
1000
+ ctx.emit("gift_offer", {"msg_id": int(req.msg_id), "declined": True})
1001
+ return GiftOfferResolved(msg_id=int(req.msg_id), state="declined")
1002
+
1003
+
1004
+ SPEC_OFFER_APPROVE = OperationSpec(
1005
+ id="gift.offer.approve",
1006
+ request=OfferApproveReq,
1007
+ response=GiftOfferResolved,
1008
+ impl=offer_approve,
1009
+ summary="Decline an offer to buy my collectible (accepting sells an asset and is refused)",
1010
+ aliases=("gift.offer.deny", "gift.offer.decline"),
1011
+ mutating=True,
1012
+ destructive=True,
1013
+ rate_class="send",
1014
+ columns=("msg_id", "state", "price_stars", "reason"),
1015
+ headers=("Message", "State", "Stars", "Why"),
1016
+ example={"msg_id": 512, "state": "declined"},
1017
+ example_args="gift offer approve @alice 512 --deny",
1018
+ covers_partial=("gift.offer-resolve", "gifts.purchase-offer"),
1019
+ coverage_note=(
1020
+ "Declining is performed; accepting transfers an asset for money and is "
1021
+ "reported rather than done, like every other value transfer in tlgr."
1022
+ ),
1023
+ )
1024
+
1025
+
1026
+ # ---------------------------------------------------------------------------
1027
+ # gift unique get / variant list
1028
+ # ---------------------------------------------------------------------------
1029
+
1030
+
1031
+ class UniqueGetReq(Request):
1032
+ slug: Annotated[str, arg(0, metavar="SLUG", help="A slug or a t.me/nft/ link.")]
1033
+ value: Annotated[bool, opt("--value", help="Include the valuation the gift carries.")] = False
1034
+
1035
+
1036
+ async def unique_get(ctx: OpContext, req: UniqueGetReq) -> UniqueGift:
1037
+ """Look up a collectible by link or slug.
1038
+
1039
+ The full valuation breakdown — floor price, last sale — needs
1040
+ `payments.getStarGiftValueInfo`, which this Telethon has no request class
1041
+ for; the gift itself carries `value_amount`/`value_currency`, and that is
1042
+ what `--value` reports, with a warning saying what is missing.
1043
+ """
1044
+ from telethon.tl.functions import payments as fn
1045
+
1046
+ result = await client(ctx)(fn.GetUniqueStarGiftRequest(slug=_settings.slug_of(req.slug)))
1047
+ known = _settings.entity_map(result)
1048
+ gift = _unique(getattr(result, "gift", None), known)
1049
+ if req.value:
1050
+ ctx.warn(
1051
+ "the floor price and last sale need payments.getStarGiftValueInfo, which "
1052
+ "Telethon 1.44 has no request class for; the gift's own valuation is reported"
1053
+ )
1054
+ return gift
1055
+
1056
+
1057
+ SPEC_UNIQUE_GET = OperationSpec(
1058
+ id="gift.unique.get",
1059
+ request=UniqueGetReq,
1060
+ response=UniqueGift,
1061
+ impl=unique_get,
1062
+ summary="Look up a collectible by link or slug, with the valuation it carries",
1063
+ idempotent=True,
1064
+ columns=("slug", "num", "title", "owner_id", "value_stars", "resell_stars"),
1065
+ headers=("Slug", "#", "Title", "Owner", "Value", "For sale"),
1066
+ example={"slug": "PlushPepe-42", "num": 42, "title": "Plush Pepe", "value_stars": 15000},
1067
+ example_args="gift unique get PlushPepe-42",
1068
+ covers=("gift.unique-info", "gifts.unique-info"),
1069
+ covers_partial=("gift.unique-value",),
1070
+ coverage_note=(
1071
+ "The gift's own `value_amount`/`value_currency` are reported; the floor "
1072
+ "price and last sale need `payments.getStarGiftValueInfo`, absent from "
1073
+ "Telethon 1.44."
1074
+ ),
1075
+ tags=frozenset({"agent-safe"}),
1076
+ )
1077
+
1078
+
1079
+ class VariantListReq(Request):
1080
+ gift_id: Annotated[int, arg(0, metavar="GIFT_ID", help="The gift type.")]
1081
+ preview: Annotated[
1082
+ bool, opt("--preview", help="Sample attributes an upgrade could produce.")
1083
+ ] = False
1084
+ craft_only: Annotated[
1085
+ bool, opt("--craft-only", help="Only variants reachable by crafting.")
1086
+ ] = False
1087
+
1088
+
1089
+ async def variant_list(ctx: OpContext, req: VariantListReq) -> Page[GiftVariant]:
1090
+ """Possible collectible variants of a gift, and their rarities.
1091
+
1092
+ `payments.getStarGiftUpgradePreview` is what this build can send; the
1093
+ full attribute table (`payments.getStarGiftAttributes`) has no request
1094
+ class here, so `--craft-only`, which only that method can answer,
1095
+ refuses rather than returning a filtered guess.
1096
+ """
1097
+ from telethon.tl.functions import payments as fn
1098
+
1099
+ if req.craft_only:
1100
+ _settings.method_gap("gift variant list --craft-only", "payments.getStarGiftAttributes")
1101
+
1102
+ limit, state = window(ctx, "gift.variant.list", PageKind.LOCAL, default=20)
1103
+ result = await client(ctx)(fn.GetStarGiftUpgradePreviewRequest(gift_id=int(req.gift_id)))
1104
+ rows = [
1105
+ GiftVariant(
1106
+ kind=attribute.kind,
1107
+ name=attribute.name,
1108
+ document_id=attribute.document_id,
1109
+ rarity_permille=attribute.rarity_permille,
1110
+ sample=True,
1111
+ )
1112
+ for attribute in (
1113
+ _attribute(raw) for raw in getattr(result, "sample_attributes", None) or []
1114
+ )
1115
+ ]
1116
+ offset = int(state.get("offset", 0) or 0)
1117
+ page_rows = rows[offset : offset + limit]
1118
+ return build_page(
1119
+ page_rows,
1120
+ op="gift.variant.list",
1121
+ kind=PageKind.LOCAL,
1122
+ state={"offset": offset + len(page_rows)},
1123
+ account=ctx.account,
1124
+ has_more=offset + len(page_rows) < len(rows),
1125
+ total=len(rows),
1126
+ )
1127
+
1128
+
1129
+ SPEC_VARIANT_LIST = OperationSpec(
1130
+ id="gift.variant.list",
1131
+ request=VariantListReq,
1132
+ response=Page[GiftVariant],
1133
+ impl=variant_list,
1134
+ summary="Possible collectible variants of a gift, and a preview of an upgrade",
1135
+ aliases=("gift.variants",),
1136
+ paginated=PageKind.LOCAL,
1137
+ idempotent=True,
1138
+ columns=("kind", "name", "rarity_permille", "document_id", "sample"),
1139
+ headers=("Kind", "Name", "Rarity ‰", "Document", "Sample"),
1140
+ example={
1141
+ "items": [{"kind": "model", "name": "Golden", "rarity_permille": 5, "sample": True}],
1142
+ "has_more": False,
1143
+ },
1144
+ example_args="gift variant list 5100 --preview",
1145
+ covers=("gift.upgrade-preview",),
1146
+ covers_partial=("gift.upgrade-attributes",),
1147
+ coverage_note=(
1148
+ "The upgrade preview is the sample the server offers; the exhaustive "
1149
+ "attribute table needs `payments.getStarGiftAttributes`, absent from "
1150
+ "Telethon 1.44."
1151
+ ),
1152
+ tags=frozenset({"agent-safe"}),
1153
+ )
1154
+
1155
+
1156
+ # ---------------------------------------------------------------------------
1157
+ # gift collection list / create / edit / delete
1158
+ # ---------------------------------------------------------------------------
1159
+
1160
+
1161
+ def _collection(raw: Any, order: int) -> GiftCollection:
1162
+ return GiftCollection(
1163
+ id=int(getattr(raw, "collection_id", 0) or 0),
1164
+ title=str(getattr(raw, "title", "") or ""),
1165
+ count=int(getattr(raw, "gifts_count", 0) or 0),
1166
+ icon_document_id=getattr(getattr(raw, "icon", None), "id", None),
1167
+ order=order,
1168
+ )
1169
+
1170
+
1171
+ class CollectionListReq(Request):
1172
+ chat: Annotated[
1173
+ PeerRef | None,
1174
+ arg(0, metavar="PEER", required=False, kind="peer", help="Whose profile; default me."),
1175
+ ] = None
1176
+ refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
1177
+
1178
+
1179
+ async def collection_list(ctx: OpContext, req: CollectionListReq) -> Page[GiftCollection]:
1180
+ """Gift collections on a profile, in display order."""
1181
+ from telethon.tl.functions import payments as fn
1182
+
1183
+ peer = await _peer_or_self(ctx, req.chat)
1184
+ result = await client(ctx)(fn.GetStarGiftCollectionsRequest(peer=peer, hash=0))
1185
+ rows = [
1186
+ _collection(raw, index)
1187
+ for index, raw in enumerate(getattr(result, "collections", None) or [])
1188
+ ]
1189
+ return Page(items=rows, has_more=False, total=len(rows))
1190
+
1191
+
1192
+ SPEC_COLLECTION_LIST = OperationSpec(
1193
+ id="gift.collection.list",
1194
+ request=CollectionListReq,
1195
+ response=Page[GiftCollection],
1196
+ impl=collection_list,
1197
+ summary="Gift collections on a profile",
1198
+ paginated=PageKind.LOCAL,
1199
+ idempotent=True,
1200
+ columns=("id", "title", "count", "order"),
1201
+ headers=("Id", "Title", "Gifts", "Order"),
1202
+ example={"items": [{"id": 1, "title": "Favourites", "count": 4}], "has_more": False},
1203
+ example_args="gift collection list",
1204
+ covers=("gift.collections-list", "gifts.collections"),
1205
+ tags=frozenset({"agent-safe"}),
1206
+ )
1207
+
1208
+
1209
+ class CollectionCreateReq(Request):
1210
+ chat: Annotated[PeerRef, arg(0, metavar="PEER", kind="peer", help="Whose profile.")]
1211
+ title: Annotated[str, arg(1, metavar="TITLE", help="The collection's name.")]
1212
+ refs: Annotated[
1213
+ tuple[str, ...],
1214
+ arg(2, metavar="REF", required=False, variadic=True, help="Gifts to put in it."),
1215
+ ] = ()
1216
+
1217
+
1218
+ async def collection_create(ctx: OpContext, req: CollectionCreateReq) -> GiftCollection:
1219
+ """Create a gift collection. `stargifts_collections_max` bounds how many."""
1220
+ from telethon.tl.functions import payments as fn
1221
+
1222
+ peer = await _settings.resolve(ctx, req.chat)
1223
+ result = await client(ctx)(
1224
+ fn.CreateStarGiftCollectionRequest(
1225
+ peer=peer,
1226
+ title=req.title,
1227
+ stargift=[await _settings.input_gift(ctx, ref) for ref in req.refs],
1228
+ )
1229
+ )
1230
+ ctx.emit("gift_collection", {"title": req.title})
1231
+ return _collection(result, 0)
1232
+
1233
+
1234
+ SPEC_COLLECTION_CREATE = OperationSpec(
1235
+ id="gift.collection.create",
1236
+ request=CollectionCreateReq,
1237
+ response=GiftCollection,
1238
+ impl=collection_create,
1239
+ summary="Create a gift collection",
1240
+ mutating=True,
1241
+ rate_class="send",
1242
+ columns=("id", "title", "count"),
1243
+ headers=("Id", "Title", "Gifts"),
1244
+ example={"id": 1, "title": "Favourites", "count": 2},
1245
+ example_args="gift collection create me Favourites msg:120 msg:121",
1246
+ covers=("gift.collection-create",),
1247
+ )
1248
+
1249
+
1250
+ class CollectionEditReq(Request):
1251
+ chat: Annotated[PeerRef, arg(0, metavar="PEER", kind="peer", help="Whose profile.")]
1252
+ id: Annotated[
1253
+ int | None,
1254
+ arg(1, metavar="ID", required=False, help="Collection id; omit with --order-collections."),
1255
+ ] = None
1256
+ title: Annotated[str | None, opt("--title", metavar="TEXT", help="New title.")] = None
1257
+ add: Annotated[str | None, opt("--add", metavar="LIST", help="Gifts to add.")] = None
1258
+ remove: Annotated[str | None, opt("--remove", metavar="LIST", help="Gifts to remove.")] = None
1259
+ order: Annotated[
1260
+ str | None, opt("--order", metavar="LIST", help="New order of the gifts inside it.")
1261
+ ] = None
1262
+ order_collections: Annotated[
1263
+ str | None,
1264
+ opt("--order-collections", metavar="LIST", help="New order of the collections."),
1265
+ ] = None
1266
+
1267
+
1268
+ async def collection_edit(ctx: OpContext, req: CollectionEditReq) -> GiftCollection:
1269
+ """Rename a collection, add or remove gifts, or reorder either level.
1270
+
1271
+ Removing a gift from a collection never deletes the gift — the two are
1272
+ different operations and only `gift convert` destroys anything.
1273
+ """
1274
+ from telethon.tl.functions import payments as fn
1275
+
1276
+ handle = client(ctx)
1277
+ peer = await _settings.resolve(ctx, req.chat)
1278
+
1279
+ if req.order_collections is not None:
1280
+ order = [int(p) for p in req.order_collections.split(",") if p.strip().isdigit()]
1281
+ if not order:
1282
+ raise UsageError("--order-collections wants every collection id", field="order")
1283
+ await handle(fn.ReorderStarGiftCollectionsRequest(peer=peer, order=order))
1284
+ return GiftCollection(id=order[0], title="", count=0, order=0)
1285
+
1286
+ if req.id is None:
1287
+ raise UsageError("give a collection id, or --order-collections", field="id")
1288
+
1289
+ async def refs(value: str | None) -> list[Any] | None:
1290
+ if value is None:
1291
+ return None
1292
+ return [
1293
+ await _settings.input_gift(ctx, part.strip())
1294
+ for part in value.split(",")
1295
+ if part.strip()
1296
+ ]
1297
+
1298
+ result = await handle(
1299
+ fn.UpdateStarGiftCollectionRequest(
1300
+ peer=peer,
1301
+ collection_id=int(req.id),
1302
+ title=req.title,
1303
+ add_stargift=await refs(req.add),
1304
+ delete_stargift=await refs(req.remove),
1305
+ order=await refs(req.order),
1306
+ )
1307
+ )
1308
+ ctx.emit("gift_collection_edit", {"id": int(req.id)})
1309
+ return _collection(result, 0)
1310
+
1311
+
1312
+ SPEC_COLLECTION_EDIT = OperationSpec(
1313
+ id="gift.collection.edit",
1314
+ request=CollectionEditReq,
1315
+ response=GiftCollection,
1316
+ impl=collection_edit,
1317
+ summary="Rename a collection, add or remove gifts, or reorder either level",
1318
+ mutating=True,
1319
+ rate_class="send",
1320
+ columns=("id", "title", "count", "order"),
1321
+ headers=("Id", "Title", "Gifts", "Order"),
1322
+ example={"id": 1, "title": "Favourites", "count": 3},
1323
+ example_args="gift collection edit me 1 --add msg:122",
1324
+ covers=("gift.collection-reorder", "gift.collection-update"),
1325
+ )
1326
+
1327
+
1328
+ class CollectionDeleteReq(Request):
1329
+ chat: Annotated[PeerRef, arg(0, metavar="PEER", kind="peer", help="Whose profile.")]
1330
+ id: Annotated[int, arg(1, metavar="ID", help="The collection to delete.")]
1331
+
1332
+
1333
+ async def collection_delete(ctx: OpContext, req: CollectionDeleteReq) -> GiftCollection:
1334
+ """Delete a gift collection. The gifts themselves are untouched."""
1335
+ from telethon.tl.functions import payments as fn
1336
+
1337
+ peer = await _settings.resolve(ctx, req.chat)
1338
+ await client(ctx)(fn.DeleteStarGiftCollectionRequest(peer=peer, collection_id=int(req.id)))
1339
+ ctx.emit("gift_collection_deleted", {"id": int(req.id)})
1340
+ return GiftCollection(id=int(req.id), title="", count=0)
1341
+
1342
+
1343
+ SPEC_COLLECTION_DELETE = OperationSpec(
1344
+ id="gift.collection.delete",
1345
+ request=CollectionDeleteReq,
1346
+ response=GiftCollection,
1347
+ impl=collection_delete,
1348
+ summary="Delete a gift collection (the gifts stay)",
1349
+ mutating=True,
1350
+ destructive=True,
1351
+ rate_class="send",
1352
+ columns=("id", "title"),
1353
+ headers=("Id", "Title"),
1354
+ example={"id": 1, "title": ""},
1355
+ example_args="gift collection delete me 1",
1356
+ covers=("gift.collection-delete",),
1357
+ )
1358
+
1359
+
1360
+ # ---------------------------------------------------------------------------
1361
+ # gift auction list / get
1362
+ # ---------------------------------------------------------------------------
1363
+
1364
+
1365
+ class AuctionListReq(Request):
1366
+ won: Annotated[bool, opt("--won", help="Gifts I acquired in auctions.")] = False
1367
+ gift_id: Annotated[
1368
+ int | None, opt("--gift-id", metavar="ID", help="With --won: only this gift type.")
1369
+ ] = None
1370
+
1371
+
1372
+ async def auction_list(ctx: OpContext, req: AuctionListReq) -> Page[GiftAuction]:
1373
+ """Gift auctions I am bidding in, or gifts I won in one.
1374
+
1375
+ Read-only, and not because of an API gap: a bid cannot be retracted, so
1376
+ tlgr never places one.
1377
+ """
1378
+ from telethon.tl.functions import payments as fn
1379
+
1380
+ handle = client(ctx)
1381
+ if req.won:
1382
+ if req.gift_id is None:
1383
+ raise UsageError("--won needs --gift-id <id>", field="gift_id")
1384
+ result = await handle(fn.GetStarGiftAuctionAcquiredGiftsRequest(gift_id=int(req.gift_id)))
1385
+ rows = [
1386
+ GiftAuction(
1387
+ auction=str(req.gift_id),
1388
+ gift_id=int(req.gift_id),
1389
+ my_bid=int(getattr(row, "bid_amount", 0) or 0) or None,
1390
+ ends_at=fmt_dt(getattr(row, "date", None)),
1391
+ state="won",
1392
+ )
1393
+ for row in getattr(result, "gifts", None) or []
1394
+ ]
1395
+ return Page(items=rows, has_more=False, total=len(rows))
1396
+
1397
+ result = await handle(fn.GetStarGiftActiveAuctionsRequest(hash=0))
1398
+ rows = []
1399
+ for raw in getattr(result, "auctions", None) or []:
1400
+ gift = getattr(raw, "gift", None)
1401
+ state = getattr(raw, "state", None)
1402
+ user = getattr(raw, "user_state", None)
1403
+ gift_id = int(getattr(gift, "gift_id", 0) or getattr(gift, "id", 0) or 0)
1404
+ slug = getattr(gift, "slug", None)
1405
+ rows.append(
1406
+ GiftAuction(
1407
+ auction=slug or str(gift_id),
1408
+ gift_id=gift_id or None,
1409
+ slug=slug,
1410
+ my_bid=getattr(user, "bid_amount", None),
1411
+ min_bid=getattr(state, "min_bid_amount", None),
1412
+ ends_at=fmt_dt(getattr(state, "end_date", None)),
1413
+ state=_state_word(state),
1414
+ )
1415
+ )
1416
+ return Page(items=rows, has_more=False, total=len(rows))
1417
+
1418
+
1419
+ def _state_word(state: Any) -> str:
1420
+ """`starGiftAuctionState*` as one word. `finished` always wins a race."""
1421
+ name = type(state).__name__
1422
+ if name == "StarGiftAuctionStateFinished":
1423
+ return "finished"
1424
+ if name == "StarGiftAuctionStateNotModified":
1425
+ return "not-modified"
1426
+ return "active"
1427
+
1428
+
1429
+ SPEC_AUCTION_LIST = OperationSpec(
1430
+ id="gift.auction.list",
1431
+ request=AuctionListReq,
1432
+ response=Page[GiftAuction],
1433
+ impl=auction_list,
1434
+ summary="Gift auctions: the ones I am bidding in, and the gifts I won",
1435
+ description="Read-only on purpose: a bid cannot be retracted, so tlgr never places one.",
1436
+ paginated=PageKind.LOCAL,
1437
+ idempotent=True,
1438
+ columns=("auction", "gift_id", "slug", "my_bid", "min_bid", "ends_at", "state"),
1439
+ headers=("Auction", "Gift", "Slug", "My bid", "Min bid", "Ends", "State"),
1440
+ example={
1441
+ "items": [{"auction": "PlushPepe-42", "my_bid": 5000, "min_bid": 5500}],
1442
+ "has_more": False,
1443
+ },
1444
+ example_args="gift auction list",
1445
+ covers=("auction.acquired-gifts", "auction.active-list", "gifts.auctions"),
1446
+ tags=frozenset({"agent-safe"}),
1447
+ )
1448
+
1449
+
1450
+ class AuctionGetReq(Request):
1451
+ auction: Annotated[str, arg(0, metavar="AUCTION", help="A gift id or a collectible slug.")]
1452
+ with_position: Annotated[
1453
+ bool, opt("--with-position", help="Estimate my position in the ladder.")
1454
+ ] = False
1455
+ watch: Annotated[
1456
+ bool, opt("--watch", help="Keep the subscription alive and stream updates.")
1457
+ ] = False
1458
+ version: Annotated[int, opt("--version", metavar="N", help="Last seen state version.")] = 0
1459
+
1460
+
1461
+ async def auction_get(ctx: OpContext, req: AuctionGetReq) -> Any:
1462
+ """Auction state, bid ladder and my position — optionally as a stream.
1463
+
1464
+ `getStarGiftAuctionState` doubles as an update subscription that lasts
1465
+ `timeout` seconds, so `--watch` re-invokes it to stay subscribed. A new
1466
+ state is applied only when `version` increases, and a finished state
1467
+ always wins — otherwise a slow reply can overwrite a newer one.
1468
+ """
1469
+ from telethon.tl import types
1470
+ from telethon.tl.functions import payments as fn
1471
+
1472
+ handle = client(ctx)
1473
+ text = req.auction.strip()
1474
+ auction = (
1475
+ types.InputStarGiftAuction(gift_id=int(text))
1476
+ if text.isdigit()
1477
+ else types.InputStarGiftAuctionSlug(slug=_settings.slug_of(text))
1478
+ )
1479
+ version = int(req.version)
1480
+ seen = -1
1481
+ while True:
1482
+ raw = await handle(fn.GetStarGiftAuctionStateRequest(auction=auction, version=version))
1483
+ state = _auction_state(text, raw, with_position=req.with_position)
1484
+ # A state only counts when its version *increased*; a finished state
1485
+ # always wins, because a slow reply must not overwrite the end.
1486
+ last = not req.watch or state.finished
1487
+ if state.finished or state.version > seen:
1488
+ seen = state.version
1489
+ version = state.version
1490
+ yield Page(items=[state], has_more=not last)
1491
+ if last:
1492
+ return
1493
+
1494
+
1495
+ def _auction_state(name: str, raw: Any, *, with_position: bool = False) -> GiftAuctionState:
1496
+ """`payments.starGiftAuctionState` flattened.
1497
+
1498
+ The server nests three things — the gift, the auction and *my* side of
1499
+ it — and the numbers a bidder wants are spread across all three.
1500
+ """
1501
+ inner = getattr(raw, "state", None)
1502
+ user = getattr(raw, "user_state", None)
1503
+ word = _state_word(inner)
1504
+ position = None
1505
+ if with_position:
1506
+ bid = getattr(user, "bid_amount", None)
1507
+ if bid is not None:
1508
+ levels = getattr(inner, "bid_levels", None) or []
1509
+ position = 1 + sum(
1510
+ 1 for level in levels if int(getattr(level, "amount", 0) or 0) > int(bid)
1511
+ )
1512
+ return GiftAuctionState(
1513
+ auction=name,
1514
+ state=word,
1515
+ version=int(getattr(inner, "version", 0) or 0),
1516
+ min_bid_amount=getattr(inner, "min_bid_amount", None),
1517
+ my_bid=getattr(user, "bid_amount", None),
1518
+ position=position,
1519
+ ends_at=fmt_dt(getattr(inner, "end_date", None)),
1520
+ timeout=getattr(raw, "timeout", None),
1521
+ finished=word == "finished",
1522
+ )
1523
+
1524
+
1525
+ SPEC_AUCTION_GET = OperationSpec(
1526
+ id="gift.auction.get",
1527
+ request=AuctionGetReq,
1528
+ response=Page[GiftAuctionState],
1529
+ impl=auction_get,
1530
+ summary="Auction state, bid ladder and my position",
1531
+ stream=True,
1532
+ idempotent=True,
1533
+ columns=("auction", "state", "version", "min_bid_amount", "my_bid", "position", "ends_at"),
1534
+ headers=("Auction", "State", "Version", "Min bid", "My bid", "Place", "Ends"),
1535
+ example={
1536
+ "items": [
1537
+ {"auction": "PlushPepe-42", "state": "active", "version": 3, "min_bid_amount": 5500}
1538
+ ],
1539
+ "has_more": False,
1540
+ },
1541
+ example_args="gift auction get PlushPepe-42",
1542
+ covers=("auction.position-estimate", "auction.state"),
1543
+ tags=frozenset({"agent-safe"}),
1544
+ )
1545
+
1546
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]