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/poll.py ADDED
@@ -0,0 +1,1078 @@
1
+ """The `poll` group: create, read, vote in and close polls and quizzes.
2
+
3
+ Two facts about Telegram's poll API shape everything here.
4
+
5
+ * **An answer is opaque bytes, not an index.** `poll.answers[i].option` is
6
+ whatever the server assigned, and `shuffle_answers` means the order a client
7
+ shows is not the order the server stores. Every command that names an answer
8
+ therefore *refetches the poll first* and resolves the caller's index against
9
+ the server's copy; the bytes come back in `option_b64` so a machine can
10
+ round-trip them.
11
+ * **There is no `stopPoll` method.** Closing a poll is `messages.editMessage`
12
+ with the same poll and `closed=True`, which means the whole constructor has
13
+ to be resent — including the answers, with their original option bytes, or
14
+ every vote already cast would be attached to answers that no longer exist.
15
+
16
+ Telethon is imported inside functions, never at module scope (§2.2).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import base64
23
+ from typing import Annotated, Any
24
+
25
+ from tlgr.core.errors import NotFoundError, NotSupportedError, PermissionError_, UsageError
26
+ from tlgr.core.pagination import PageKind, build_page
27
+ from tlgr.core.timefmt import fmt_dt, parse_dt, parse_duration, to_unix
28
+ from tlgr.models.base import Request
29
+ from tlgr.models.message import Message, MessageEntity
30
+ from tlgr.models.page import Page
31
+ from tlgr.models.peer import PeerRef
32
+ from tlgr.models.poll import Poll, PollOption, PollStats, PollVoter
33
+ from tlgr.ops import _send
34
+ from tlgr.ops._common import (
35
+ affected_loop,
36
+ already,
37
+ client,
38
+ input_channel,
39
+ is_not_modified,
40
+ only,
41
+ random_id,
42
+ window,
43
+ )
44
+ from tlgr.ops._params import arg, opt
45
+ from tlgr.ops._serialize import media_summary, message_to_model
46
+ from tlgr.ops._spec import OpContext, OperationSpec
47
+
48
+ __all__ = [name for name in dir() if name.startswith("SPEC_")]
49
+
50
+ _EXAMPLE_POLL: dict[str, Any] = {
51
+ "chat_id": 777123,
52
+ "msg_id": 12345,
53
+ "poll_id": 5069438842982500000,
54
+ "question": "Lunch?",
55
+ "type": "poll",
56
+ "total_voters": 3,
57
+ "can_vote": True,
58
+ "options": [
59
+ {"index": 0, "text": "Pizza", "option_b64": "AA", "voters": 2, "percent": 66.7},
60
+ {"index": 1, "text": "Sushi", "option_b64": "AQ", "voters": 1, "percent": 33.3},
61
+ ],
62
+ }
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Reading a poll off a message
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def _entities(items: Any) -> list[MessageEntity]:
71
+ """Telethon entities → models, reusing the message serialiser's rules."""
72
+ from tlgr.ops._serialize import message_entities
73
+
74
+ class _Holder:
75
+ entities = list(items or [])
76
+
77
+ return message_entities(_Holder())
78
+
79
+
80
+ def _text_with_entities(value: Any) -> tuple[str, list[MessageEntity]]:
81
+ """`TextWithEntities` → `(text, entities)`, tolerating a bare string."""
82
+ if value is None:
83
+ return "", []
84
+ if isinstance(value, str):
85
+ return value, []
86
+ return str(getattr(value, "text", "") or ""), _entities(getattr(value, "entities", None))
87
+
88
+
89
+ def _b64(raw: bytes | None) -> str:
90
+ return base64.urlsafe_b64encode(raw or b"").decode().rstrip("=")
91
+
92
+
93
+ def poll_model(media: Any, *, chat_id: int = 0, msg_id: int = 0) -> Poll:
94
+ """`MessageMediaPoll` → the one `Poll` shape every command in this group returns.
95
+
96
+ `can_vote` is derived here rather than left to the caller: eligibility is
97
+ a client-side reading of `closed`, `revoting_disabled`, `subscribers_only`
98
+ and `countries_iso2`, and an agent that cannot see it learns the answer by
99
+ sending a vote and reading the RPC error.
100
+ """
101
+ raw = getattr(media, "poll", None)
102
+ results = getattr(media, "results", None)
103
+ question, entities = _text_with_entities(getattr(raw, "question", None))
104
+ close_date = getattr(raw, "close_date", None)
105
+
106
+ voters_by_option: dict[bytes, Any] = {
107
+ bytes(getattr(item, "option", b"")): item
108
+ for item in (getattr(results, "results", None) or [])
109
+ }
110
+ total = int(getattr(results, "total_voters", 0) or 0)
111
+
112
+ options: list[PollOption] = []
113
+ my_votes: list[int] = []
114
+ for index, answer in enumerate(getattr(raw, "answers", None) or []):
115
+ option = bytes(getattr(answer, "option", b"") or b"")
116
+ text, answer_entities = _text_with_entities(getattr(answer, "text", None))
117
+ tally = voters_by_option.get(option)
118
+ voters = int(getattr(tally, "voters", 0) or 0) if tally is not None else None
119
+ chosen = bool(getattr(tally, "chosen", False)) if tally is not None else False
120
+ if chosen:
121
+ my_votes.append(index)
122
+ added_by = getattr(answer, "added_by", None)
123
+ options.append(
124
+ PollOption(
125
+ index=index,
126
+ text=text,
127
+ entities=answer_entities,
128
+ option_b64=_b64(option),
129
+ voters=voters,
130
+ percent=round(100.0 * voters / total, 1) if voters is not None and total else None,
131
+ chosen=chosen,
132
+ correct=getattr(tally, "correct", None) if tally is not None else None,
133
+ added_by=_peer_id(added_by),
134
+ added_date=fmt_dt(getattr(answer, "date", None)),
135
+ media=media_summary(getattr(answer, "media", None)),
136
+ )
137
+ )
138
+
139
+ solution, solution_entities = (
140
+ (
141
+ getattr(results, "solution", None) or "",
142
+ _entities(getattr(results, "solution_entities", None)),
143
+ )
144
+ if results is not None
145
+ else ("", [])
146
+ )
147
+ poll = Poll(
148
+ chat_id=chat_id,
149
+ msg_id=msg_id,
150
+ poll_id=int(getattr(raw, "id", 0) or 0) or None,
151
+ question=question,
152
+ entities=entities,
153
+ type="quiz" if getattr(raw, "quiz", False) else "poll",
154
+ can_vote=True,
155
+ closed=bool(getattr(raw, "closed", False)),
156
+ public_voters=bool(getattr(raw, "public_voters", False)),
157
+ multiple=bool(getattr(raw, "multiple_choice", False)),
158
+ open_answers=bool(getattr(raw, "open_answers", False)),
159
+ revoting_disabled=bool(getattr(raw, "revoting_disabled", False)),
160
+ shuffle=bool(getattr(raw, "shuffle_answers", False)),
161
+ hide_results_until_close=bool(getattr(raw, "hide_results_until_close", False)),
162
+ subscribers_only=bool(getattr(raw, "subscribers_only", False)),
163
+ countries=list(getattr(raw, "countries_iso2", None) or []),
164
+ close_period=getattr(raw, "close_period", None),
165
+ close_date=fmt_dt(close_date),
166
+ close_date_unix=to_unix(close_date),
167
+ total_voters=total,
168
+ my_votes=my_votes,
169
+ options=options,
170
+ recent_voters=[
171
+ pid
172
+ for pid in (_peer_id(peer) for peer in (getattr(results, "recent_voters", None) or []))
173
+ if pid is not None
174
+ ],
175
+ solution=solution or None,
176
+ solution_entities=solution_entities,
177
+ min=bool(getattr(results, "min", False)),
178
+ has_unread_votes=bool(getattr(results, "has_unread_votes", False)),
179
+ can_view_stats=bool(getattr(results, "can_view_stats", False)),
180
+ )
181
+ _derive_can_vote(poll)
182
+ return poll
183
+
184
+
185
+ def _peer_id(peer: Any) -> int | None:
186
+ if peer is None:
187
+ return None
188
+ from tlgr.ops._serialize import peer_id_of
189
+
190
+ return peer_id_of(peer)
191
+
192
+
193
+ def _derive_can_vote(poll: Poll) -> None:
194
+ """Fill `can_vote`/`restriction` — the reason a `sendVote` would be refused."""
195
+ if poll.closed:
196
+ poll.can_vote, poll.restriction = False, "closed"
197
+ elif poll.my_votes and (poll.revoting_disabled or poll.type == "quiz"):
198
+ poll.can_vote, poll.restriction = False, "already-voted"
199
+ elif poll.subscribers_only:
200
+ # The full rule also needs the channel's join date, which is not on
201
+ # the message; reporting the restriction without claiming a verdict is
202
+ # better than guessing one either way.
203
+ poll.restriction = "subscribers-only"
204
+ elif poll.countries:
205
+ poll.restriction = "country-restricted"
206
+
207
+
208
+ async def _fetch(ctx: OpContext, peer: Any, chat_id: int, msg_id: int) -> tuple[Any, Poll]:
209
+ """The message carrying the poll, and the poll on it."""
210
+ message = await client(ctx).get_messages(peer, ids=msg_id)
211
+ media = getattr(message, "media", None) if message is not None else None
212
+ if getattr(media, "poll", None) is None:
213
+ raise NotFoundError(f"message {msg_id} in {chat_id} is not a poll")
214
+ return message, poll_model(media, chat_id=chat_id, msg_id=msg_id)
215
+
216
+
217
+ def _options_for(poll: Poll, indices: list[int]) -> list[bytes]:
218
+ """Caller-typed indices → the server's opaque option bytes."""
219
+ out: list[bytes] = []
220
+ for index in indices:
221
+ if not 0 <= index < len(poll.options):
222
+ raise UsageError(
223
+ f"option {index} does not exist; this poll has {len(poll.options)}",
224
+ field="options",
225
+ )
226
+ out.append(base64.urlsafe_b64decode(poll.options[index].option_b64 + "=="))
227
+ return out
228
+
229
+
230
+ async def _reread(ctx: OpContext, peer: Any, chat_id: int, msg_id: int, updates: Any) -> Poll:
231
+ """The poll as it stands after a mutation.
232
+
233
+ The update batch carries the new `MessageMediaPoll` for every method in
234
+ this group except `sendVote`'s partial results, so reading it out of the
235
+ reply avoids a second round trip; falling back to a refetch is what makes
236
+ the callers uniform.
237
+ """
238
+ for update in getattr(updates, "updates", None) or []:
239
+ media = getattr(getattr(update, "message", None), "media", None)
240
+ if getattr(media, "poll", None) is not None:
241
+ return poll_model(media, chat_id=chat_id, msg_id=msg_id)
242
+ if getattr(update, "poll", None) is not None and not getattr(
243
+ getattr(update, "results", None), "min", False
244
+ ):
245
+ return poll_model(update, chat_id=chat_id, msg_id=msg_id)
246
+ _, poll = await _fetch(ctx, peer, chat_id, msg_id)
247
+ return poll
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # poll create
252
+ # ---------------------------------------------------------------------------
253
+
254
+
255
+ class CreateReq(_send.SendOptions, kw_only=True):
256
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Where to post it.")]
257
+ question: Annotated[str, arg(1, metavar="QUESTION", help="The question.")]
258
+ options: Annotated[
259
+ list[str], arg(2, metavar="OPTION", variadic=True, help="Answers, in order.")
260
+ ] = []
261
+ description: Annotated[
262
+ str | None, opt("--description", metavar="TEXT", help="Poll description (layer 229).")
263
+ ] = None
264
+ multiple: Annotated[bool, opt("--multiple", help="Allow multiple answers.")] = False
265
+ public_voters: Annotated[bool, opt("--public-voters", help="Everyone can see who voted.")] = (
266
+ False
267
+ )
268
+ quiz: Annotated[bool, opt("--quiz", help="Quiz mode.")] = False
269
+ correct: Annotated[
270
+ int | None, opt("--correct", metavar="N", help="Correct option index (quiz).")
271
+ ] = None
272
+ explanation: Annotated[
273
+ str | None, opt("--explanation", metavar="TEXT", help="Quiz solution text.")
274
+ ] = None
275
+ explanation_file: Annotated[
276
+ str | None,
277
+ opt("--explanation-file", metavar="PATH", kind="path", help="Media on the solution."),
278
+ ] = None
279
+ no_revote: Annotated[bool, opt("--no-revote", help="Disallow changing a vote.")] = False
280
+ allow_adding_options: Annotated[
281
+ bool, opt("--allow-adding-options", help="Voters may add answers (open answers).")
282
+ ] = False
283
+ shuffle: Annotated[bool, opt("--shuffle", help="Shuffle answer order per viewer.")] = False
284
+ subscribers_only: Annotated[
285
+ bool, opt("--subscribers-only", help="Only channel subscribers may vote.")
286
+ ] = False
287
+ countries: Annotated[
288
+ str | None, opt("--countries", metavar="ISO2,...", help="Restrict voting to these.")
289
+ ] = None
290
+ duration: Annotated[
291
+ str | None, opt("--duration", metavar="DURATION", help="Auto-close after this long.")
292
+ ] = None
293
+ close_at: Annotated[
294
+ str | None, opt("--close-at", metavar="TS", kind="datetime", help="Auto-close then.")
295
+ ] = None
296
+ hide_results: Annotated[
297
+ bool, opt("--hide-results", help="Hide results until the poll closes.")
298
+ ] = False
299
+ media: Annotated[
300
+ str | None, opt("--media", metavar="PATH|URL", help="Media attached to the poll.")
301
+ ] = None
302
+ option_media: Annotated[
303
+ list[str], opt("--option-media", metavar="N=PATH", help="Media on one answer.")
304
+ ] = []
305
+ parse: Annotated[
306
+ str | None, opt("--parse", metavar="MODE", help="md|html|none for every text.")
307
+ ] = None
308
+ reply_to: Annotated[
309
+ int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
310
+ ] = None
311
+
312
+
313
+ async def build_media(ctx: OpContext, req: CreateReq) -> Any:
314
+ """`CreateReq` → the `InputMediaPoll` a send needs.
315
+
316
+ Public because `message send --poll` builds the same thing from JSON: two
317
+ spellings of "create a poll" that disagreed about defaults would be worse
318
+ than one shared builder.
319
+ """
320
+ from telethon.tl import types
321
+
322
+ if req.description is not None:
323
+ raise NotSupportedError(
324
+ "--description is a layer-229 poll field and the pinned Telethon speaks 227; "
325
+ "post the description as the message text instead"
326
+ )
327
+ if req.quiz and req.multiple:
328
+ raise UsageError("a quiz cannot be multiple-choice", field="quiz")
329
+ if req.quiz and req.correct is None:
330
+ raise UsageError("--quiz needs --correct to say which answer is right", field="correct")
331
+ if req.duration and req.close_at:
332
+ raise UsageError("--duration and --close-at are the same field", field="duration")
333
+ if req.hide_results and not (req.duration or req.close_at):
334
+ raise UsageError(
335
+ "--hide-results only means something with --duration or --close-at",
336
+ field="hide-results",
337
+ )
338
+ if len(req.options) < 2:
339
+ raise UsageError("a poll needs at least two answers", field="options")
340
+ if req.correct is not None and not 0 <= req.correct < len(req.options):
341
+ raise UsageError(f"--correct {req.correct} is not one of the answers", field="correct")
342
+
343
+ per_option = _option_media(req.option_media, len(req.options))
344
+ answers: list[Any] = []
345
+ for index, text in enumerate(req.options):
346
+ plain, entities = _send.body(text, parse=req.parse)
347
+ source = per_option.get(index)
348
+ answers.append(
349
+ types.InputPollAnswer(
350
+ text=types.TextWithEntities(text=plain, entities=_send.tl_entities(entities) or []),
351
+ media=await _send.input_media(ctx, source) if source else None,
352
+ )
353
+ )
354
+
355
+ question, question_entities = _send.body(req.question, parse=req.parse)
356
+ close_date = parse_dt(req.close_at) if req.close_at else None
357
+ poll = types.Poll(
358
+ id=0,
359
+ question=types.TextWithEntities(
360
+ text=question, entities=_send.tl_entities(question_entities) or []
361
+ ),
362
+ answers=answers,
363
+ hash=0,
364
+ closed=None,
365
+ public_voters=req.public_voters or None,
366
+ multiple_choice=req.multiple or None,
367
+ quiz=req.quiz or None,
368
+ open_answers=req.allow_adding_options or None,
369
+ revoting_disabled=req.no_revote or None,
370
+ shuffle_answers=req.shuffle or None,
371
+ hide_results_until_close=req.hide_results or None,
372
+ subscribers_only=req.subscribers_only or None,
373
+ close_period=parse_duration(req.duration) if req.duration else None,
374
+ close_date=close_date,
375
+ countries_iso2=_countries(req.countries),
376
+ )
377
+ solution, solution_entities = (
378
+ _send.body(req.explanation, parse=req.parse) if req.explanation else ("", [])
379
+ )
380
+ return types.InputMediaPoll(
381
+ poll=poll,
382
+ # Layer 227 spells `correct_answers` as a vector of *indices*: an
383
+ # `inputPollAnswer` carries no option bytes at creation time, because
384
+ # the server is the one that assigns them.
385
+ correct_answers=[req.correct] if req.correct is not None else None,
386
+ attached_media=await _send.input_media(ctx, req.media) if req.media else None,
387
+ solution=solution or None,
388
+ solution_entities=(_send.tl_entities(solution_entities) or []) if solution else None,
389
+ solution_media=(
390
+ await _send.input_media(ctx, req.explanation_file) if req.explanation_file else None
391
+ ),
392
+ )
393
+
394
+
395
+ def _option_media(pairs: list[str], count: int) -> dict[int, str]:
396
+ out: dict[int, str] = {}
397
+ for pair in pairs:
398
+ index, sep, path = pair.partition("=")
399
+ if not sep:
400
+ raise UsageError("--option-media wants N=PATH", field="option-media")
401
+ try:
402
+ position = int(index)
403
+ except ValueError as exc:
404
+ raise UsageError(
405
+ f"--option-media: {index!r} is not an index", field="option-media"
406
+ ) from exc
407
+ if not 0 <= position < count:
408
+ raise UsageError(
409
+ f"--option-media {position} is not one of the answers", field="option-media"
410
+ )
411
+ out[position] = path
412
+ return out
413
+
414
+
415
+ def _countries(value: str | None) -> list[str] | None:
416
+ if not value:
417
+ return None
418
+ codes = [part.strip().upper() for part in value.replace(" ", ",").split(",") if part.strip()]
419
+ for code in codes:
420
+ if len(code) != 2 or not code.isalpha():
421
+ raise UsageError(
422
+ f"--countries: {code!r} is not an ISO-3166 alpha-2 code", field="countries"
423
+ )
424
+ return codes or None
425
+
426
+
427
+ async def create(ctx: OpContext, req: CreateReq) -> Poll:
428
+ """Post a poll or quiz, and report the answers with the ids the server gave them.
429
+
430
+ The reply is read back rather than echoed: the option bytes every later
431
+ command needs exist only once the server has assigned them.
432
+ """
433
+ from telethon.tl.functions import messages as fn
434
+
435
+ peer = await _send.resolve(ctx, req.chat)
436
+ chat_id = _send.peer_id_of(peer)
437
+ media = await build_media(ctx, req)
438
+ reply_to = await _send.reply_target(ctx, reply_to=req.reply_to, topic=req.topic)
439
+ values = {
440
+ "peer": peer,
441
+ "media": media,
442
+ "message": "",
443
+ "random_id": random_id(),
444
+ "silent": req.silent or None,
445
+ "noforwards": req.protect or None,
446
+ "reply_to": reply_to,
447
+ "schedule_date": _send.schedule_at(req.schedule),
448
+ "schedule_repeat_period": _send.repeat_period(req.repeat),
449
+ "send_as": await _send.resolve(ctx, req.send_as) if req.send_as is not None else None,
450
+ "effect": _send.effect_id(req.effect),
451
+ "allow_paid_stars": req.paid_stars,
452
+ }
453
+ result = await client(ctx)(fn.SendMediaRequest(**only(values, fn.SendMediaRequest)))
454
+ sent = _send.message_from_updates(result, chat_id=chat_id)
455
+ poll = await _reread(ctx, peer, chat_id, sent.id, result)
456
+ poll.msg_id = sent.id
457
+ ctx.emit("poll_created", {"chat_id": chat_id, "msg_id": sent.id, "question": poll.question})
458
+ return poll
459
+
460
+
461
+ SPEC_CREATE = OperationSpec(
462
+ id="poll.create",
463
+ request=CreateReq,
464
+ response=Poll,
465
+ impl=create,
466
+ summary="Create a poll or quiz with every option the GUI exposes",
467
+ description=(
468
+ "Answers are addressed by index everywhere in this group, and the "
469
+ "opaque identifier the server assigned each one comes back in "
470
+ "`options[].option_b64` for callers that would rather hold the bytes."
471
+ ),
472
+ mutating=True,
473
+ rate_class="send",
474
+ columns=("chat_id", "msg_id", "poll_id", "question"),
475
+ example=_EXAMPLE_POLL,
476
+ example_args="poll create @team 'Lunch?' Pizza Sushi",
477
+ covers=(
478
+ "poll.allow-adding-options",
479
+ "poll.allow-revoting",
480
+ "poll.attached-media",
481
+ "poll.close-period",
482
+ "poll.country-restriction",
483
+ "poll.create-regular",
484
+ "poll.hide-results-until-close",
485
+ "poll.multiple-choice",
486
+ "poll.option-media",
487
+ "poll.public-voters",
488
+ "poll.quiz",
489
+ "poll.quiz-explanation",
490
+ "poll.quiz-explanation-media",
491
+ "poll.send-as",
492
+ "poll.shuffle-options",
493
+ "poll.subscribers-only",
494
+ ),
495
+ coverage_note=(
496
+ "`--description` (layer 229) is refused with NOT_SUPPORTED: the "
497
+ "pinned Telethon's `poll` constructor has no such field."
498
+ ),
499
+ )
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # poll get
504
+ # ---------------------------------------------------------------------------
505
+
506
+
507
+ class GetReq(Request):
508
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
509
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Poll message id.")]
510
+ hash: Annotated[
511
+ int, opt("--hash", metavar="N", help="Last seen poll hash for a cheap not-modified reply.")
512
+ ] = 0
513
+ with_recent_voters: Annotated[
514
+ bool, opt("--with-recent-voters", help="Resolve the recent-voter peers.")
515
+ ] = False
516
+ follow: Annotated[bool, opt("--follow", help="Wait for the poll to close, then report.")] = (
517
+ False
518
+ )
519
+ interval: Annotated[
520
+ str, opt("--interval", metavar="DURATION", help="Refresh interval for --follow.")
521
+ ] = "15s"
522
+ follow_for: Annotated[
523
+ str, opt("--follow-for", metavar="DURATION", help="Give up following after this long.")
524
+ ] = "5m"
525
+
526
+
527
+ async def get(ctx: OpContext, req: GetReq) -> Poll:
528
+ """Poll state and results, with the reason voting is blocked when it is.
529
+
530
+ `--follow` blocks until the poll closes rather than streaming: a streaming
531
+ operation streams unconditionally in this architecture, and `poll get`
532
+ has to keep answering with one object.
533
+ """
534
+ from telethon.tl.functions import messages as fn
535
+
536
+ peer = await _send.resolve(ctx, req.chat)
537
+ chat_id = _send.peer_id_of(peer)
538
+ _, poll = await _fetch(ctx, peer, chat_id, req.msg_id)
539
+
540
+ if poll.min or req.hash:
541
+ refreshed = await client(ctx)(
542
+ fn.GetPollResultsRequest(peer=peer, msg_id=req.msg_id, poll_hash=req.hash)
543
+ )
544
+ poll = await _reread(ctx, peer, chat_id, req.msg_id, refreshed)
545
+
546
+ if req.follow and not poll.closed:
547
+ poll = await _follow(ctx, peer, chat_id, req)
548
+ if req.with_recent_voters and poll.recent_voters:
549
+ # `pollResults.recent_voters` are `min` peers: they mean nothing until
550
+ # they are resolved against a real entity.
551
+ for peer_id in list(poll.recent_voters):
552
+ try:
553
+ await client(ctx).get_entity(peer_id)
554
+ except (ValueError, TypeError):
555
+ ctx.warn(f"recent voter {peer_id} could not be resolved")
556
+ return poll
557
+
558
+
559
+ async def _follow(ctx: OpContext, peer: Any, chat_id: int, req: GetReq) -> Poll:
560
+ """Refresh until the poll closes or the caller's patience runs out."""
561
+ from telethon.tl.functions import messages as fn
562
+
563
+ interval = max(5, int(parse_duration(req.interval) or 15))
564
+ deadline = max(interval, int(parse_duration(req.follow_for) or 300))
565
+ waited = 0
566
+ poll = (await _fetch(ctx, peer, chat_id, req.msg_id))[1]
567
+ while not poll.closed and waited < deadline:
568
+ await asyncio.sleep(interval)
569
+ waited += interval
570
+ limiter = getattr(ctx, "limiter", None)
571
+ if limiter is not None:
572
+ await limiter.acquire("read")
573
+ refreshed = await client(ctx)(
574
+ fn.GetPollResultsRequest(peer=peer, msg_id=req.msg_id, poll_hash=0)
575
+ )
576
+ poll = await _reread(ctx, peer, chat_id, req.msg_id, refreshed)
577
+ if not poll.closed:
578
+ ctx.warn(f"--follow gave up after {waited}s; the poll is still open")
579
+ return poll
580
+
581
+
582
+ SPEC_GET = OperationSpec(
583
+ id="poll.get",
584
+ request=GetReq,
585
+ response=Poll,
586
+ impl=get,
587
+ summary="Poll state and results, with the reason voting is blocked",
588
+ description=(
589
+ "`can_vote` and `restriction` are computed on the client: closed, "
590
+ "already voted on a quiz, subscribers-only or country-restricted. "
591
+ "`--follow` waits for the poll to close instead of streaming, so the "
592
+ "answer is still one object."
593
+ ),
594
+ aliases=("poll.results",),
595
+ columns=("msg_id", "question", "closed", "total_voters", "can_vote"),
596
+ example=_EXAMPLE_POLL,
597
+ example_args="poll get @team 12345",
598
+ covers=(
599
+ "poll.get-results",
600
+ "poll.live-updates",
601
+ "poll.option-properties",
602
+ "poll.recent-voters-preview",
603
+ "poll.vote-restriction-reasons",
604
+ ),
605
+ coverage_note=(
606
+ "`poll.option-properties` and `poll.vote-restriction-reasons` have no "
607
+ "MTProto method at all; both are the client-side derivation this op "
608
+ "publishes as `can_vote`/`restriction` and the per-option flags."
609
+ ),
610
+ timeout_s=360,
611
+ )
612
+
613
+
614
+ # ---------------------------------------------------------------------------
615
+ # poll vote / close
616
+ # ---------------------------------------------------------------------------
617
+
618
+
619
+ class VoteReq(Request):
620
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
621
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Poll message id.")]
622
+ options: Annotated[
623
+ list[int], arg(2, metavar="OPTION", variadic=True, help="Option indices.")
624
+ ] = []
625
+ retract: Annotated[bool, opt("--retract", help="Retract the vote (empty vector).")] = False
626
+
627
+
628
+ async def vote(ctx: OpContext, req: VoteReq) -> Poll:
629
+ """Vote in a poll, or retract the vote by passing no options.
630
+
631
+ Indices are resolved against a freshly fetched copy of the poll because
632
+ `shuffle_answers` makes the display order per-viewer; voting on "the
633
+ second one I saw" would otherwise be a different answer for each client.
634
+ """
635
+ from telethon.tl.functions import messages as fn
636
+
637
+ peer = await _send.resolve(ctx, req.chat)
638
+ chat_id = _send.peer_id_of(peer)
639
+ _, poll = await _fetch(ctx, peer, chat_id, req.msg_id)
640
+ if req.options and req.retract:
641
+ raise UsageError("--retract takes no options", field="retract")
642
+ if not poll.can_vote and not req.retract:
643
+ raise PermissionError_(
644
+ f"this poll cannot be voted in: {poll.restriction or 'closed'}",
645
+ )
646
+ if len(req.options) > 1 and not poll.multiple:
647
+ raise UsageError("this poll accepts one answer", field="options")
648
+
649
+ chosen = _options_for(poll, list(req.options)) if not req.retract else []
650
+ result = await client(ctx)(fn.SendVoteRequest(peer=peer, msg_id=req.msg_id, options=chosen))
651
+ updated = await _reread(ctx, peer, chat_id, req.msg_id, result)
652
+ ctx.emit("poll_vote", {"chat_id": chat_id, "msg_id": req.msg_id, "options": list(req.options)})
653
+ return updated
654
+
655
+
656
+ SPEC_VOTE = OperationSpec(
657
+ id="poll.vote",
658
+ request=VoteReq,
659
+ response=Poll,
660
+ impl=vote,
661
+ summary="Vote in a poll, or retract your vote",
662
+ description=(
663
+ "Indices are resolved against the server's own copy of the poll, so "
664
+ "`--shuffle` cannot make the same index mean two things. An empty "
665
+ "vector retracts, which quizzes and no-revote polls refuse."
666
+ ),
667
+ mutating=True,
668
+ rate_class="send",
669
+ columns=("msg_id", "total_voters", "my_votes"),
670
+ example=_EXAMPLE_POLL,
671
+ example_args="poll vote @team 12345 0",
672
+ covers=("poll.retract-vote", "poll.vote"),
673
+ )
674
+
675
+
676
+ class CloseReq(Request):
677
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
678
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Poll message id.")]
679
+
680
+
681
+ async def close(ctx: OpContext, req: CloseReq) -> Poll:
682
+ """Stop a poll: final results, no more voting.
683
+
684
+ There is no `stopPoll` on the wire. This is `editMessage` carrying the
685
+ same poll with `closed=True`, answers and option bytes included, because
686
+ a rewritten answer list would orphan every vote already cast.
687
+ """
688
+ from telethon.tl import types
689
+ from telethon.tl.functions import messages as fn
690
+
691
+ peer = await _send.resolve(ctx, req.chat)
692
+ chat_id = _send.peer_id_of(peer)
693
+ message, poll = await _fetch(ctx, peer, chat_id, req.msg_id)
694
+ if poll.closed:
695
+ already(ctx)
696
+ poll.already = True
697
+ return poll
698
+
699
+ raw = message.media.poll
700
+ closed = types.Poll(
701
+ id=raw.id,
702
+ question=raw.question,
703
+ answers=raw.answers,
704
+ hash=getattr(raw, "hash", 0) or 0,
705
+ closed=True,
706
+ public_voters=getattr(raw, "public_voters", None),
707
+ multiple_choice=getattr(raw, "multiple_choice", None),
708
+ quiz=getattr(raw, "quiz", None),
709
+ )
710
+ try:
711
+ result = await client(ctx)(
712
+ fn.EditMessageRequest(peer=peer, id=req.msg_id, media=types.InputMediaPoll(poll=closed))
713
+ )
714
+ except Exception as exc:
715
+ if not is_not_modified(exc):
716
+ raise
717
+ already(ctx)
718
+ poll.already = True
719
+ return poll
720
+ updated = await _reread(ctx, peer, chat_id, req.msg_id, result)
721
+ updated.closed = True
722
+ ctx.emit("poll_closed", {"chat_id": chat_id, "msg_id": req.msg_id})
723
+ return updated
724
+
725
+
726
+ SPEC_CLOSE = OperationSpec(
727
+ id="poll.close",
728
+ request=CloseReq,
729
+ response=Poll,
730
+ impl=close,
731
+ summary="Stop a poll so the results are final",
732
+ description=(
733
+ "Closing cannot be undone. An already-closed poll is `already: true`, not an error."
734
+ ),
735
+ aliases=("poll.stop",),
736
+ mutating=True,
737
+ destructive=True,
738
+ idempotent=True,
739
+ rate_class="send",
740
+ columns=("msg_id", "closed", "total_voters"),
741
+ example={**_EXAMPLE_POLL, "closed": True},
742
+ example_args="poll close @team 12345",
743
+ covers=("poll.stop",),
744
+ )
745
+
746
+
747
+ # ---------------------------------------------------------------------------
748
+ # poll option add / remove
749
+ # ---------------------------------------------------------------------------
750
+
751
+
752
+ class OptionAddReq(Request):
753
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
754
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Poll message id.")]
755
+ text: Annotated[str, arg(2, metavar="TEXT", help="The answer to add.")]
756
+ media: Annotated[
757
+ str | None, opt("--media", metavar="PATH", kind="path", help="Media on the new option.")
758
+ ] = None
759
+ parse: Annotated[str | None, opt("--parse", metavar="MODE", help="md|html|none.")] = None
760
+
761
+
762
+ async def option_add(ctx: OpContext, req: OptionAddReq) -> Poll:
763
+ """Add an answer to an open-answer poll.
764
+
765
+ The server assigns the new option's bytes, so the poll is read back: the
766
+ index a caller will vote with does not exist until the round trip is done.
767
+ """
768
+ from telethon.tl import types
769
+ from telethon.tl.functions import messages as fn
770
+
771
+ peer = await _send.resolve(ctx, req.chat)
772
+ chat_id = _send.peer_id_of(peer)
773
+ _, poll = await _fetch(ctx, peer, chat_id, req.msg_id)
774
+ if not poll.open_answers:
775
+ raise PermissionError_(
776
+ "this poll was not created with --allow-adding-options, so no answer may be added"
777
+ )
778
+ plain, entities = _send.body(req.text, parse=req.parse)
779
+ answer = types.InputPollAnswer(
780
+ text=types.TextWithEntities(text=plain, entities=_send.tl_entities(entities) or []),
781
+ media=await _send.input_media(ctx, req.media) if req.media else None,
782
+ )
783
+ result = await client(ctx)(fn.AddPollAnswerRequest(peer=peer, msg_id=req.msg_id, answer=answer))
784
+ return await _reread(ctx, peer, chat_id, req.msg_id, result)
785
+
786
+
787
+ SPEC_OPTION_ADD = OperationSpec(
788
+ id="poll.option.add",
789
+ request=OptionAddReq,
790
+ response=Poll,
791
+ impl=option_add,
792
+ summary="Add an answer to an open-answer poll",
793
+ mutating=True,
794
+ rate_class="send",
795
+ columns=("msg_id", "total_voters"),
796
+ example=_EXAMPLE_POLL,
797
+ example_args="poll option add @team 12345 'Ramen'",
798
+ covers=("poll.add-option",),
799
+ )
800
+
801
+
802
+ class OptionRemoveReq(Request):
803
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
804
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Poll message id.")]
805
+ option: Annotated[int, arg(2, metavar="OPTION", help="Option index to delete.", ge=0)]
806
+
807
+
808
+ async def option_remove(ctx: OpContext, req: OptionRemoveReq) -> Poll:
809
+ """Delete an answer from an open-answer poll, losing its votes."""
810
+ from telethon.tl.functions import messages as fn
811
+
812
+ peer = await _send.resolve(ctx, req.chat)
813
+ chat_id = _send.peer_id_of(peer)
814
+ _, poll = await _fetch(ctx, peer, chat_id, req.msg_id)
815
+ option = _options_for(poll, [req.option])[0]
816
+ result = await client(ctx)(
817
+ fn.DeletePollAnswerRequest(peer=peer, msg_id=req.msg_id, option=option)
818
+ )
819
+ return await _reread(ctx, peer, chat_id, req.msg_id, result)
820
+
821
+
822
+ SPEC_OPTION_REMOVE = OperationSpec(
823
+ id="poll.option.remove",
824
+ request=OptionRemoveReq,
825
+ response=Poll,
826
+ impl=option_remove,
827
+ summary="Delete an answer from an open-answer poll",
828
+ description="The votes cast on that answer go with it. Surviving answers keep their ids.",
829
+ mutating=True,
830
+ destructive=True,
831
+ rate_class="send",
832
+ columns=("msg_id", "total_voters"),
833
+ example=_EXAMPLE_POLL,
834
+ example_args="poll option remove @team 12345 2",
835
+ covers=("poll.delete-option",),
836
+ )
837
+
838
+
839
+ # ---------------------------------------------------------------------------
840
+ # poll voter list
841
+ # ---------------------------------------------------------------------------
842
+
843
+
844
+ class VoterListReq(Request):
845
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
846
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Poll message id.")]
847
+ option: Annotated[int | None, opt("--option", metavar="N", help="Only this option index.")] = (
848
+ None
849
+ )
850
+
851
+
852
+ async def voter_list(ctx: OpContext, req: VoterListReq) -> Page[PollVoter]:
853
+ """Who voted, per option — public polls only.
854
+
855
+ Pagination is `votesList.next_offset`, an opaque *string*: treating it as
856
+ an integer offset (which is what every other listing uses) silently
857
+ restarts the walk at the top.
858
+ """
859
+ from telethon.tl.functions import messages as fn
860
+
861
+ limit, state = window(ctx, "poll.voter.list", PageKind.PARTICIPANTS)
862
+ peer = await _send.resolve(ctx, req.chat)
863
+ chat_id = _send.peer_id_of(peer)
864
+ _, poll = await _fetch(ctx, peer, chat_id, req.msg_id)
865
+ if not poll.public_voters:
866
+ raise PermissionError_("this poll is anonymous; there is no voter list to read")
867
+
868
+ option = _options_for(poll, [req.option])[0] if req.option is not None else None
869
+ by_option = {
870
+ base64.urlsafe_b64decode(item.option_b64 + "=="): item.index for item in poll.options
871
+ }
872
+ result = await client(ctx)(
873
+ fn.GetPollVotesRequest(
874
+ peer=peer,
875
+ id=req.msg_id,
876
+ limit=limit,
877
+ option=option,
878
+ offset=state.get("offset") or None,
879
+ )
880
+ )
881
+ items: list[PollVoter] = []
882
+ for vote_row in getattr(result, "votes", None) or []:
883
+ options = [
884
+ by_option[bytes(raw)]
885
+ for raw in (getattr(vote_row, "options", None) or ([getattr(vote_row, "option", b"")]))
886
+ if bytes(raw) in by_option
887
+ ]
888
+ date = getattr(vote_row, "date", None)
889
+ items.append(
890
+ PollVoter(
891
+ user_id=_peer_id(getattr(vote_row, "peer", None)) or 0,
892
+ option=options[0] if options else req.option,
893
+ options=options,
894
+ date=fmt_dt(date),
895
+ date_unix=to_unix(date),
896
+ )
897
+ )
898
+ next_offset = getattr(result, "next_offset", None)
899
+ return build_page(
900
+ items,
901
+ op="poll.voter.list",
902
+ kind=PageKind.PARTICIPANTS,
903
+ state={"offset": next_offset},
904
+ account=ctx.account,
905
+ has_more=bool(next_offset),
906
+ total=getattr(result, "count", None),
907
+ )
908
+
909
+
910
+ SPEC_VOTER_LIST = OperationSpec(
911
+ id="poll.voter.list",
912
+ request=VoterListReq,
913
+ response=Page[PollVoter],
914
+ impl=voter_list,
915
+ summary="Who voted, per option (public polls)",
916
+ description="Anonymous polls answer with PERMISSION_DENIED — the list does not exist.",
917
+ aliases=("poll.voters",),
918
+ paginated=PageKind.PARTICIPANTS,
919
+ columns=("user_id", "option", "date"),
920
+ example={
921
+ "items": [{"user_id": 4242, "option": 0, "date": "2026-09-03T09:20:00Z"}],
922
+ "has_more": False,
923
+ },
924
+ example_args="poll voter list @team 12345",
925
+ covers=("poll.get-voters",),
926
+ )
927
+
928
+
929
+ # ---------------------------------------------------------------------------
930
+ # poll unread list
931
+ # ---------------------------------------------------------------------------
932
+
933
+
934
+ class UnreadListReq(Request):
935
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
936
+ topic: Annotated[
937
+ int | None, opt("--topic", metavar="ID", kind="msg_id", help="Only this forum topic.")
938
+ ] = None
939
+ read_all: Annotated[bool, opt("--read-all", help="Mark every poll vote as read.")] = False
940
+
941
+
942
+ async def unread_list(ctx: OpContext, req: UnreadListReq) -> Page[Message]:
943
+ """Polls in this chat with votes I have not seen, newest first."""
944
+ from telethon.tl.functions import messages as fn
945
+
946
+ limit, state = window(ctx, "poll.unread.list", PageKind.PARTICIPANTS)
947
+ peer = await _send.resolve(ctx, req.chat)
948
+ chat_id = _send.peer_id_of(peer)
949
+
950
+ result = await client(ctx)(
951
+ fn.GetUnreadPollVotesRequest(
952
+ peer=peer,
953
+ offset_id=int(state.get("offset_id") or 0),
954
+ add_offset=0,
955
+ limit=limit,
956
+ max_id=0,
957
+ min_id=0,
958
+ top_msg_id=req.topic,
959
+ )
960
+ )
961
+ items = [
962
+ message_to_model(raw, chat_id=chat_id) for raw in (getattr(result, "messages", None) or [])
963
+ ]
964
+ if req.read_all:
965
+ await affected_loop(
966
+ ctx, lambda offset: fn.ReadPollVotesRequest(peer=peer, top_msg_id=req.topic)
967
+ )
968
+
969
+ return build_page(
970
+ items,
971
+ op="poll.unread.list",
972
+ kind=PageKind.PARTICIPANTS,
973
+ state={"offset_id": items[-1].id if items else 0},
974
+ account=ctx.account,
975
+ limit=limit,
976
+ total=getattr(result, "count", None),
977
+ )
978
+
979
+
980
+ SPEC_UNREAD_LIST = OperationSpec(
981
+ id="poll.unread.list",
982
+ request=UnreadListReq,
983
+ response=Page[Message],
984
+ impl=unread_list,
985
+ summary="Polls in a chat with votes I have not read yet",
986
+ description=(
987
+ "`--read-all` drives `messages.readPollVotes` until its offset comes "
988
+ "back zero; calling it once clears only the first page of the badge."
989
+ ),
990
+ paginated=PageKind.PARTICIPANTS,
991
+ tags=frozenset({"mutating-checked"}),
992
+ columns=("id", "chat_id", "date", "text"),
993
+ example={
994
+ "items": [
995
+ {
996
+ "id": 12345,
997
+ "chat_id": 777123,
998
+ "date": "2026-09-03T09:14:07Z",
999
+ "date_unix": 1788340447,
1000
+ "text": "Lunch?",
1001
+ }
1002
+ ],
1003
+ "has_more": False,
1004
+ },
1005
+ example_args="poll unread list @team",
1006
+ covers=("poll.read-votes", "poll.unread-votes"),
1007
+ )
1008
+
1009
+
1010
+ # ---------------------------------------------------------------------------
1011
+ # poll stats get
1012
+ # ---------------------------------------------------------------------------
1013
+
1014
+
1015
+ class StatsGetReq(Request):
1016
+ chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
1017
+ msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Poll message id.")]
1018
+ dark: Annotated[bool, opt("--dark", help="Dark-theme graph.")] = False
1019
+
1020
+
1021
+ async def stats_get(ctx: OpContext, req: StatsGetReq) -> PollStats:
1022
+ """The poll's vote-statistics graph, from the channel's stats DC.
1023
+
1024
+ Statistics do not live on the home data centre: the first call answers
1025
+ `STATS_MIGRATE_X` and the request has to be re-issued on that DC through
1026
+ a borrowed sender, exactly as Telethon's own `get_stats` does.
1027
+ """
1028
+ from telethon import errors
1029
+ from telethon.tl.functions import stats as fn
1030
+
1031
+ peer = await _send.resolve(ctx, req.chat)
1032
+ chat_id = _send.peer_id_of(peer)
1033
+ input_channel(peer) # a poll graph only exists for channels/supergroups
1034
+ request = fn.GetPollStatsRequest(peer=peer, msg_id=req.msg_id, dark=req.dark or None)
1035
+ handle = client(ctx)
1036
+ dc_id: int | None = None
1037
+ try:
1038
+ result = await handle(request)
1039
+ except errors.StatsMigrateError as exc:
1040
+ dc_id = int(exc.dc)
1041
+ sender = await handle._borrow_exported_sender(dc_id)
1042
+ try:
1043
+ result = await sender.send(request)
1044
+ finally:
1045
+ await handle._return_exported_sender(sender)
1046
+
1047
+ graph = getattr(result, "votes_graph", None) or result
1048
+ error = getattr(graph, "error", None)
1049
+ if error:
1050
+ raise NotFoundError(f"the poll statistics graph is unavailable: {error}")
1051
+ payload = getattr(getattr(graph, "json", None), "data", None)
1052
+ return PollStats(
1053
+ chat_id=chat_id,
1054
+ msg_id=req.msg_id,
1055
+ graph=payload,
1056
+ token=getattr(graph, "token", None) or getattr(graph, "zoom_token", None),
1057
+ dark=req.dark,
1058
+ dc_id=dc_id,
1059
+ )
1060
+
1061
+
1062
+ SPEC_STATS_GET = OperationSpec(
1063
+ id="poll.stats.get",
1064
+ request=StatsGetReq,
1065
+ response=PollStats,
1066
+ impl=stats_get,
1067
+ summary="Poll vote statistics graph (channel admins)",
1068
+ description=(
1069
+ "Needs `channelFull.can_view_stats`. The graph comes back either "
1070
+ "inline as `graph` or as an asynchronous `token` to load later; both "
1071
+ "are reported rather than one being silently resolved."
1072
+ ),
1073
+ columns=("chat_id", "msg_id", "token"),
1074
+ example={"chat_id": -1001234567890, "msg_id": 12345, "token": "graph-token"},
1075
+ example_args="poll stats get @news 12345",
1076
+ covers=("poll.statistics",),
1077
+ timeout_s=180,
1078
+ )