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/giveaway.py ADDED
@@ -0,0 +1,541 @@
1
+ """The `giveaway` group: joining one, checking a code, launching a prepaid one.
2
+
3
+ Giveaways are a first-class surface in the official clients and they are
4
+ almost entirely free to operate from a CLI, which is why they get a noun of
5
+ their own rather than living under `gift`:
6
+
7
+ * **joining** spends a boost slot I already own, not money;
8
+ * **redeeming a code** activates a subscription somebody else paid for;
9
+ * **launching a prepaid giveaway** spends nothing either — the giveaway was
10
+ bought earlier, and `payments.launchPrepaidGiveaway` only starts it.
11
+
12
+ Buying a *new* giveaway is a payment and is therefore absent, like every
13
+ other purchase in tlgr.
14
+
15
+ `giveaway get` answers the question the public message cannot: am I eligible,
16
+ did I win, and which code did I win. `messageMediaGiveaway` carries the public
17
+ half; `payments.getGiveawayInfo` carries the personal half, and one command
18
+ reports both.
19
+
20
+ Telethon is imported inside functions, never at module scope (§2.2).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Annotated, Any
26
+
27
+ from tlgr.core.errors import UsageError
28
+ from tlgr.core.pagination import PageKind, build_page
29
+ from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
30
+ from tlgr.models.admin import BoostApplied
31
+ from tlgr.models.base import Request
32
+ from tlgr.models.page import Page
33
+ from tlgr.models.peer import PeerRef
34
+ from tlgr.models.premium import (
35
+ GiftCode,
36
+ GiftCodeApplied,
37
+ GiveawayInfo,
38
+ GiveawayLaunched,
39
+ GiveawayWinner,
40
+ PrepaidGiveaway,
41
+ )
42
+ from tlgr.ops import _settings
43
+ from tlgr.ops._common import client, random_id, window
44
+ from tlgr.ops._params import arg, opt
45
+ from tlgr.ops._spec import OpContext, OperationSpec
46
+
47
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # giveaway get
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ class GetReq(Request):
55
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The giveaway's channel.")]
56
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The giveaway message.")]
57
+ winners: Annotated[
58
+ bool, opt("--winners", help="Resolve the winner list from the results message.")
59
+ ] = False
60
+
61
+
62
+ async def get(ctx: OpContext, req: GetReq) -> GiveawayInfo:
63
+ """Giveaway status and results: am I eligible, did I win, who won.
64
+
65
+ Two sources, one answer. The message media says how many winners there
66
+ are, when it ends and which countries are eligible;
67
+ `payments.getGiveawayInfo` says whether *this* account is in it and, if
68
+ it has finished, which gift code it won.
69
+ """
70
+ from telethon.tl.functions import payments as fn
71
+
72
+ handle = client(ctx)
73
+ peer = await _settings.resolve(ctx, req.chat)
74
+ chat_id = _settings.peer_of(peer)
75
+ raw = await handle(fn.GetGiveawayInfoRequest(peer=peer, msg_id=int(req.msg_id)))
76
+ finished = type(raw).__name__.endswith("GiveawayInfoResults")
77
+ info = GiveawayInfo(
78
+ chat_id=chat_id,
79
+ msg_id=int(req.msg_id),
80
+ state="finished" if finished else "ongoing",
81
+ start_date=fmt_dt(getattr(raw, "start_date", None)),
82
+ joined=bool(getattr(raw, "participating", False)),
83
+ disallowed_reason=_disallowed_word(raw),
84
+ winner=bool(getattr(raw, "winner", False)),
85
+ refunded=bool(getattr(raw, "refunded", False)),
86
+ gift_code_slug=getattr(raw, "gift_code_slug", None),
87
+ activated_count=getattr(raw, "activated_count", None),
88
+ until_date=fmt_dt(getattr(raw, "finish_date", None)),
89
+ stars=getattr(raw, "stars_prize", None),
90
+ winners_count=getattr(raw, "winners_count", None),
91
+ )
92
+
93
+ media = await _giveaway_media(ctx, peer, int(req.msg_id))
94
+ if media is not None:
95
+ info.winners_count = (
96
+ info.winners_count
97
+ or getattr(media, "quantity", None)
98
+ or getattr(media, "winners_count", None)
99
+ )
100
+ info.months = getattr(media, "months", None)
101
+ info.only_new_subscribers = bool(getattr(media, "only_new_subscribers", False))
102
+ info.countries = [str(code) for code in getattr(media, "countries_iso2", None) or []]
103
+ info.prize_description = getattr(media, "prize_description", None)
104
+ if info.until_date is None:
105
+ info.until_date = fmt_dt(getattr(media, "until_date", None))
106
+
107
+ if req.winners:
108
+ # Only the *results* media carries the winner vector; an ongoing
109
+ # giveaway has none, and reporting an empty list for one would read
110
+ # as "nobody won" rather than "not drawn yet".
111
+ info.winners = [
112
+ GiveawayWinner(user_id=int(user_id))
113
+ for user_id in getattr(media, "winners", None) or []
114
+ ]
115
+ return info
116
+
117
+
118
+ def _disallowed_word(raw: Any) -> str | None:
119
+ """Why this account cannot take part, as one word.
120
+
121
+ `giveawayInfo` spells the three refusals as three unrelated optional
122
+ fields — a country code, a chat id and a date — so the mapping happens
123
+ once, here, rather than in whatever reads the answer.
124
+ """
125
+ if getattr(raw, "disallowed_country", None):
126
+ return "disallowed-country"
127
+ if getattr(raw, "admin_disallowed_chat_id", None):
128
+ return "admin"
129
+ if getattr(raw, "joined_too_early_date", None):
130
+ return "joined-too-early"
131
+ return None
132
+
133
+
134
+ async def _giveaway_media(ctx: OpContext, peer: Any, msg_id: int) -> Any:
135
+ """The `messageMediaGiveaway*` on the giveaway post, or None."""
136
+ from tlgr.ops import _media
137
+
138
+ try:
139
+ message = await _media.fetch_message(ctx, peer, msg_id)
140
+ except Exception as exc:
141
+ ctx.warn(f"could not read the giveaway message: {exc}")
142
+ return None
143
+ media = getattr(message, "media", None)
144
+ if type(media).__name__ in ("MessageMediaGiveaway", "MessageMediaGiveawayResults"):
145
+ return media
146
+ return None
147
+
148
+
149
+ SPEC_GET = OperationSpec(
150
+ id="giveaway.get",
151
+ request=GetReq,
152
+ response=GiveawayInfo,
153
+ impl=get,
154
+ summary="Giveaway status and results: am I eligible, did I win, who won",
155
+ aliases=("giveaway.info",),
156
+ idempotent=True,
157
+ columns=("state", "joined", "winner", "winners_count", "until_date", "gift_code_slug"),
158
+ headers=("State", "Joined", "Won", "Winners", "Until", "Code"),
159
+ example={
160
+ "chat_id": -1001600,
161
+ "msg_id": 42,
162
+ "state": "ongoing",
163
+ "joined": True,
164
+ "winners_count": 10,
165
+ },
166
+ example_args="giveaway get @mychannel 42",
167
+ covers=(
168
+ "giveaway.info",
169
+ "giveaway.results",
170
+ "groups-channels-admin.giveaway-info",
171
+ "premium.giveaway-info",
172
+ ),
173
+ tags=frozenset({"agent-safe"}),
174
+ )
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # giveaway join
179
+ # ---------------------------------------------------------------------------
180
+
181
+
182
+ class JoinReq(Request):
183
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel to boost.")]
184
+ slots: Annotated[
185
+ list[int], opt("--slots", metavar="N", help="Which of my boost slots to use.")
186
+ ] = []
187
+
188
+
189
+ async def join(ctx: OpContext, req: JoinReq) -> BoostApplied:
190
+ """Join a giveaway by boosting the channel.
191
+
192
+ Joining *is* boosting: the giveaway counts participants by boost. The
193
+ slot is occupied for a month, which is why the command is confirmed, and
194
+ the boost side of it is `boost add` — one implementation, reached from
195
+ the two places the GUI reaches it from.
196
+ """
197
+ from tlgr.ops.chat_stats import BoostAddReq, add_boost
198
+
199
+ return await add_boost(ctx, BoostAddReq(chat=req.chat, slots=list(req.slots)))
200
+
201
+
202
+ SPEC_JOIN = OperationSpec(
203
+ id="giveaway.join",
204
+ request=JoinReq,
205
+ response=BoostApplied,
206
+ impl=join,
207
+ summary="Join a giveaway by boosting the channel",
208
+ description=(
209
+ "Needs Premium (or gifted boost slots). A slot stays occupied for a "
210
+ "month, so `-y` is required off a TTY."
211
+ ),
212
+ mutating=True,
213
+ idempotent=True,
214
+ rate_class="send",
215
+ columns=("chat_id", "level", "boosts", "slots", "already"),
216
+ headers=("Chat", "Level", "Boosts", "Slots", "Already"),
217
+ example={"chat_id": -1001600, "level": 4, "boosts": 15, "slots": [1]},
218
+ example_args="giveaway join @mychannel",
219
+ covers=("giveaway.join-by-boosting",),
220
+ tags=frozenset({"visible-to-others"}),
221
+ )
222
+
223
+
224
+ # ---------------------------------------------------------------------------
225
+ # giveaway list
226
+ # ---------------------------------------------------------------------------
227
+
228
+
229
+ class ListReq(Request):
230
+ chat: Annotated[
231
+ PeerRef | None,
232
+ arg(0, metavar="CHAT", required=False, kind="peer", help="The channel to inspect."),
233
+ ] = None
234
+ prepaid: Annotated[
235
+ bool, opt("--prepaid/--no-prepaid", help="Prepaid giveaways bought for this channel.")
236
+ ] = True
237
+ codes: Annotated[
238
+ bool, opt("--codes", help="My received giveaway gift codes (the inbox side).")
239
+ ] = False
240
+
241
+
242
+ async def list_(ctx: OpContext, req: ListReq) -> Page[PrepaidGiveaway]:
243
+ """Prepaid giveaways on a channel, or the gift codes I have received.
244
+
245
+ `payments.getPrepaidGiveaways` has no request class in Telethon 1.44, but
246
+ `premium.getBoostsStatus` carries the same `prepaid_giveaways` vector —
247
+ so the answer is the server's, from the method this build can send.
248
+ """
249
+ from telethon.tl.functions import premium as fn
250
+
251
+ handle = client(ctx)
252
+ if req.codes:
253
+ return await _received_codes(ctx)
254
+
255
+ if req.chat is None:
256
+ raise UsageError("name a channel, or pass --codes for my received codes", field="chat")
257
+ peer = await _settings.resolve(ctx, req.chat)
258
+ status = await handle(fn.GetBoostsStatusRequest(peer=peer))
259
+ rows = [
260
+ PrepaidGiveaway(
261
+ id=int(getattr(row, "id", 0) or 0),
262
+ quantity=int(getattr(row, "quantity", 0) or 0),
263
+ months=getattr(row, "months", None),
264
+ stars=getattr(row, "stars", None),
265
+ boosts=getattr(row, "boosts", None),
266
+ date=fmt_dt(getattr(row, "date", None)),
267
+ date_unix=to_unix(getattr(row, "date", None)),
268
+ from_chat=_settings.peer_of(peer),
269
+ )
270
+ for row in getattr(status, "prepaid_giveaways", None) or []
271
+ ]
272
+ return Page(items=rows, has_more=False, total=len(rows))
273
+
274
+
275
+ async def _received_codes(ctx: OpContext) -> Page[PrepaidGiveaway]:
276
+ """Gift codes that arrived as `messageActionGiftCode` service messages.
277
+
278
+ There is no "my codes" endpoint: the codes are service messages in the
279
+ account's own history, so they are found by scanning and then checked one
280
+ by one, which is exactly what an official client does.
281
+ """
282
+ from telethon.tl.functions import payments as fn
283
+
284
+ from tlgr.ops.chat import ListReq as ChatListReq
285
+ from tlgr.ops.chat import list_chats
286
+
287
+ handle = client(ctx)
288
+ limit, _ = window(ctx, "giveaway.list", PageKind.LOCAL, default=20)
289
+ rows: list[PrepaidGiveaway] = []
290
+ dialogs = await list_chats(ctx, ChatListReq())
291
+ for dialog in dialogs.items:
292
+ message = getattr(dialog, "last_message", None)
293
+ action = getattr(message, "action", None)
294
+ slug = getattr(action, "slug", None) if action is not None else None
295
+ if not slug:
296
+ continue
297
+ try:
298
+ checked = await handle(fn.CheckGiftCodeRequest(slug=slug))
299
+ except Exception as exc:
300
+ ctx.warn(f"could not check the code {slug}: {exc}")
301
+ continue
302
+ days = getattr(checked, "days", None)
303
+ rows.append(
304
+ PrepaidGiveaway(
305
+ id=0,
306
+ quantity=1,
307
+ months=int(days) // 30 if days else None,
308
+ slug=slug,
309
+ used=getattr(checked, "used_date", None) is not None,
310
+ date=fmt_dt(getattr(checked, "date", None)),
311
+ date_unix=to_unix(getattr(checked, "date", None)),
312
+ from_chat=getattr(dialog.chat, "id", None) if dialog.chat else None,
313
+ )
314
+ )
315
+ if len(rows) >= limit:
316
+ break
317
+ return build_page(
318
+ rows, op="giveaway.list", kind=PageKind.LOCAL, has_more=False, total=len(rows)
319
+ )
320
+
321
+
322
+ SPEC_LIST = OperationSpec(
323
+ id="giveaway.list",
324
+ request=ListReq,
325
+ response=Page[PrepaidGiveaway],
326
+ impl=list_,
327
+ summary="Prepaid giveaways available on a channel, and the gift codes I received",
328
+ description=(
329
+ "The prepaid list comes from `premium.getBoostsStatus`, which carries "
330
+ "the same vector as the absent `payments.getPrepaidGiveaways`. "
331
+ "`--codes` scans for `messageActionGiftCode` service messages and "
332
+ "checks each slug, because there is no 'my codes' endpoint."
333
+ ),
334
+ paginated=PageKind.LOCAL,
335
+ idempotent=True,
336
+ columns=("id", "quantity", "months", "stars", "slug", "used"),
337
+ headers=("Id", "Winners", "Months", "Stars", "Code", "Used"),
338
+ example={
339
+ "items": [{"id": 77, "quantity": 10, "months": 3, "from_chat": -1001600}],
340
+ "has_more": False,
341
+ },
342
+ example_args="giveaway list @mychannel",
343
+ covers=("giveaway.gift-code-received", "giveaway.list-prepaid"),
344
+ tags=frozenset({"agent-safe"}),
345
+ )
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # giveaway start
350
+ # ---------------------------------------------------------------------------
351
+
352
+
353
+ class StartReq(Request):
354
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="The channel.")]
355
+ prepaid_id: Annotated[int, arg(1, metavar="PREPAID_ID", help="From `giveaway list <chat>`.")]
356
+ winners: Annotated[int | None, opt("--winners", metavar="N", help="Number of winners.")] = None
357
+ until: Annotated[
358
+ str | None, opt("--until", metavar="WHEN", kind="datetime", help="Draw date.")
359
+ ] = None
360
+ only_new: Annotated[
361
+ bool, opt("--only-new", help="Only subscribers who joined after the start.")
362
+ ] = False
363
+ public_winners: Annotated[
364
+ bool, opt("--public-winners", help="Show the winner list when it ends.")
365
+ ] = False
366
+ countries: Annotated[
367
+ str | None, opt("--countries", metavar="LIST", help="ISO-2 codes, comma separated.")
368
+ ] = None
369
+ also_chat: Annotated[
370
+ list[str],
371
+ opt("--also-chat", metavar="CHAT", help="Another channel a participant must join."),
372
+ ] = []
373
+ prize: Annotated[str | None, opt("--prize", metavar="TEXT", help="Prize description.")] = None
374
+
375
+
376
+ async def start(ctx: OpContext, req: StartReq) -> GiveawayLaunched:
377
+ """Launch a giveaway that was already paid for.
378
+
379
+ Launching a prepaid giveaway is **not** a payment, which is why it is
380
+ here at all: the Stars or the fiat were spent when the giveaway was
381
+ bought, and this only starts it. Creating a new (bought) giveaway is a
382
+ purchase and is absent.
383
+ """
384
+ from telethon.tl import types
385
+ from telethon.tl.functions import payments as fn
386
+
387
+ handle = client(ctx)
388
+ peer = await _settings.resolve(ctx, req.chat)
389
+ config = await _settings.app_config(ctx)
390
+ countries = [part.strip().upper() for part in (req.countries or "").split(",") if part.strip()]
391
+ max_countries = int(config.get("giveaway_countries_max") or 0)
392
+ if max_countries and len(countries) > max_countries:
393
+ raise UsageError(f"--countries takes at most {max_countries} entries", field="countries")
394
+ extra = [await _settings.resolve(ctx, ref) for ref in req.also_chat]
395
+ max_peers = int(config.get("giveaway_add_peers_max") or 0)
396
+ if max_peers and len(extra) > max_peers:
397
+ raise UsageError(f"--also-chat takes at most {max_peers} channels", field="also_chat")
398
+
399
+ until = parse_dt(req.until) if req.until else None
400
+ purpose = types.InputStorePaymentPremiumGiveaway(
401
+ boost_peer=peer,
402
+ until_date=until,
403
+ currency="XTR",
404
+ amount=0,
405
+ only_new_subscribers=req.only_new or None,
406
+ winners_are_visible=req.public_winners or None,
407
+ additional_peers=extra or None,
408
+ countries_iso2=countries or None,
409
+ prize_description=req.prize,
410
+ random_id=random_id(),
411
+ )
412
+ result = await handle(
413
+ fn.LaunchPrepaidGiveawayRequest(peer=peer, giveaway_id=int(req.prepaid_id), purpose=purpose)
414
+ )
415
+ msg_id = next(
416
+ (
417
+ int(getattr(getattr(update, "message", None), "id", 0) or 0)
418
+ for update in getattr(result, "updates", None) or []
419
+ if getattr(update, "message", None) is not None
420
+ ),
421
+ None,
422
+ )
423
+ chat_id = _settings.peer_of(peer)
424
+ ctx.emit("giveaway_started", {"chat_id": chat_id, "prepaid_id": int(req.prepaid_id)})
425
+ return GiveawayLaunched(
426
+ chat_id=chat_id,
427
+ prepaid_id=int(req.prepaid_id),
428
+ msg_id=msg_id,
429
+ winners_count=int(req.winners or 0),
430
+ until_date=fmt_dt(until),
431
+ )
432
+
433
+
434
+ SPEC_START = OperationSpec(
435
+ id="giveaway.start",
436
+ request=StartReq,
437
+ response=GiveawayLaunched,
438
+ impl=start,
439
+ summary="Launch a giveaway that was already paid for (prepaid)",
440
+ description=(
441
+ "Not a payment: the giveaway was bought earlier and this only starts "
442
+ "it. Creating a new, bought giveaway is a purchase and is absent."
443
+ ),
444
+ aliases=("giveaway.launch",),
445
+ mutating=True,
446
+ rate_class="send",
447
+ columns=("chat_id", "prepaid_id", "msg_id", "winners_count", "until_date"),
448
+ headers=("Chat", "Prepaid", "Post", "Winners", "Until"),
449
+ example={"chat_id": -1001600, "prepaid_id": 77, "msg_id": 42, "winners_count": 10},
450
+ example_args="giveaway start @mychannel 77 --winners 10 --until +7d",
451
+ covers=("groups-channels-admin.giveaway-prepaid-launch", "premium.giveaway-create"),
452
+ tags=frozenset({"visible-to-others"}),
453
+ )
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # giveaway code check / apply
458
+ # ---------------------------------------------------------------------------
459
+
460
+
461
+ class CodeCheckReq(Request):
462
+ slug: Annotated[str, arg(0, metavar="SLUG", help="The code, or a t.me/giftcode link.")]
463
+
464
+
465
+ async def code_check(ctx: OpContext, req: CodeCheckReq) -> GiftCode:
466
+ """Check a gift code before using it.
467
+
468
+ Also how a giveaway admin identifies a winner: `to_id` is who the code
469
+ was issued to, which the public results message does not always say.
470
+ """
471
+ from tlgr.ops.premium import GiftcodeGetReq, giftcode_get
472
+
473
+ return await giftcode_get(ctx, GiftcodeGetReq(slug=req.slug))
474
+
475
+
476
+ SPEC_CODE_CHECK = OperationSpec(
477
+ id="giveaway.code.check",
478
+ request=CodeCheckReq,
479
+ response=GiftCode,
480
+ impl=code_check,
481
+ summary="Check a gift code / t.me/giftcode link before using it",
482
+ aliases=("giftcode.check",),
483
+ idempotent=True,
484
+ columns=("slug", "from_id", "to_id", "date", "months", "used_date", "via_giveaway"),
485
+ headers=("Slug", "From", "To", "Date", "Months", "Used", "Giveaway"),
486
+ example={"slug": "abcdef", "from_id": -1001600, "to_id": 777123, "months": 3},
487
+ example_args="giveaway code check abcdef",
488
+ covers=("giftcode.check",),
489
+ tags=frozenset({"agent-safe"}),
490
+ )
491
+
492
+
493
+ class CodeApplyReq(Request):
494
+ slug: Annotated[str, arg(0, metavar="SLUG", help="The code, or a t.me/giftcode link.")]
495
+
496
+
497
+ async def code_apply(ctx: OpContext, req: CodeApplyReq) -> GiftCodeApplied:
498
+ """Redeem a gift code, activating the Premium subscription it carries.
499
+
500
+ Costs nothing: the code was paid for by whoever gave it. A code that is
501
+ already used answers `already: true` rather than failing.
502
+ """
503
+ from telethon.tl.functions import payments as fn
504
+
505
+ handle = client(ctx)
506
+ slug = req.slug.rsplit("/", 1)[-1]
507
+ checked = await handle(fn.CheckGiftCodeRequest(slug=slug))
508
+ if getattr(checked, "used_date", None) is not None:
509
+ mark = getattr(ctx, "mark_already", None)
510
+ if callable(mark):
511
+ mark()
512
+ return GiftCodeApplied(
513
+ slug=slug,
514
+ applied=False,
515
+ months=getattr(checked, "months", None),
516
+ already=True,
517
+ )
518
+ await handle(fn.ApplyGiftCodeRequest(slug=slug))
519
+ ctx.emit("giftcode_applied", {"slug": slug})
520
+ return GiftCodeApplied(slug=slug, applied=True, months=getattr(checked, "months", None))
521
+
522
+
523
+ SPEC_CODE_APPLY = OperationSpec(
524
+ id="giveaway.code.apply",
525
+ request=CodeApplyReq,
526
+ response=GiftCodeApplied,
527
+ impl=code_apply,
528
+ summary="Redeem a gift code (activates the Premium subscription it carries)",
529
+ description="Free: the code is already paid for, so this is not a purchase.",
530
+ aliases=("giftcode.apply",),
531
+ mutating=True,
532
+ idempotent=True,
533
+ rate_class="send",
534
+ columns=("slug", "applied", "months", "already"),
535
+ headers=("Slug", "Applied", "Months", "Already"),
536
+ example={"slug": "abcdef", "applied": True, "months": 3},
537
+ example_args="giveaway code apply abcdef",
538
+ covers=("giftcode.apply", "groups-channels-admin.gift-code-redeem"),
539
+ )
540
+
541
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]