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.
- tlgr/__init__.py +3 -0
- tlgr/__main__.py +6 -0
- tlgr/actions/__init__.py +45 -0
- tlgr/actions/forward.py +74 -0
- tlgr/actions/reply.py +32 -0
- tlgr/cli/__init__.py +259 -0
- tlgr/cli/confirm.py +55 -0
- tlgr/cli/errors.py +84 -0
- tlgr/cli/gen.py +690 -0
- tlgr/cli/globals.py +273 -0
- tlgr/cli/introspect.py +170 -0
- tlgr/cli/params.py +189 -0
- tlgr/cli/render.py +418 -0
- tlgr/core/__init__.py +0 -0
- tlgr/core/accounts.py +384 -0
- tlgr/core/config.py +358 -0
- tlgr/core/custom_tl.py +170 -0
- tlgr/core/errors.py +687 -0
- tlgr/core/eventtypes.py +1170 -0
- tlgr/core/identity.py +127 -0
- tlgr/core/launchd.py +122 -0
- tlgr/core/logging.py +194 -0
- tlgr/core/media.py +134 -0
- tlgr/core/output.py +251 -0
- tlgr/core/pagination.py +227 -0
- tlgr/core/paths.py +360 -0
- tlgr/core/peers.py +427 -0
- tlgr/core/process.py +138 -0
- tlgr/core/signing.py +38 -0
- tlgr/core/systemd.py +96 -0
- tlgr/core/telethon_compat.py +295 -0
- tlgr/core/text.py +211 -0
- tlgr/core/timefmt.py +199 -0
- tlgr/core/tl.py +98 -0
- tlgr/daemon/__init__.py +0 -0
- tlgr/daemon/app.py +869 -0
- tlgr/daemon/dispatch.py +446 -0
- tlgr/daemon/events.py +723 -0
- tlgr/daemon/files.py +431 -0
- tlgr/daemon/idle.py +119 -0
- tlgr/daemon/jobs.py +68 -0
- tlgr/daemon/main.py +161 -0
- tlgr/daemon/peercred.py +75 -0
- tlgr/daemon/policy.py +113 -0
- tlgr/daemon/preauth.py +366 -0
- tlgr/daemon/ratelimit.py +391 -0
- tlgr/daemon/server.py +24 -0
- tlgr/daemon/session.py +648 -0
- tlgr/daemon/sessions.py +274 -0
- tlgr/daemon/singleton.py +114 -0
- tlgr/daemon/stream.py +193 -0
- tlgr/daemon/transfers.py +219 -0
- tlgr/daemon/webhook.py +390 -0
- tlgr/data/catalog_index.json +1 -0
- tlgr/data/parity_waivers.toml +90 -0
- tlgr/filters/__init__.py +42 -0
- tlgr/filters/compose.py +121 -0
- tlgr/filters/content.py +85 -0
- tlgr/filters/context.py +114 -0
- tlgr/filters/message.py +161 -0
- tlgr/filters/temporal.py +87 -0
- tlgr/filters/user.py +36 -0
- tlgr/gateway/__init__.py +1 -0
- tlgr/gateway/config.py +161 -0
- tlgr/gateway/engine.py +215 -0
- tlgr/gateway/event.py +22 -0
- tlgr/jobs/__init__.py +0 -0
- tlgr/jobs/base.py +81 -0
- tlgr/jobs/client.py +37 -0
- tlgr/models/__init__.py +1220 -0
- tlgr/models/admin.py +744 -0
- tlgr/models/auth.py +510 -0
- tlgr/models/base.py +81 -0
- tlgr/models/bot.py +576 -0
- tlgr/models/business.py +265 -0
- tlgr/models/call.py +586 -0
- tlgr/models/config.py +101 -0
- tlgr/models/contact.py +481 -0
- tlgr/models/daemon.py +336 -0
- tlgr/models/dialog.py +626 -0
- tlgr/models/envelope.py +68 -0
- tlgr/models/error.py +30 -0
- tlgr/models/event.py +79 -0
- tlgr/models/export.py +66 -0
- tlgr/models/gift.py +275 -0
- tlgr/models/inline.py +84 -0
- tlgr/models/location.py +115 -0
- tlgr/models/media.py +507 -0
- tlgr/models/message.py +584 -0
- tlgr/models/net.py +232 -0
- tlgr/models/notify.py +105 -0
- tlgr/models/page.py +32 -0
- tlgr/models/payment.py +172 -0
- tlgr/models/peer.py +400 -0
- tlgr/models/poll.py +119 -0
- tlgr/models/premium.py +161 -0
- tlgr/models/privacy.py +93 -0
- tlgr/models/profile.py +217 -0
- tlgr/models/reaction.py +160 -0
- tlgr/models/resolve.py +175 -0
- tlgr/models/settings.py +103 -0
- tlgr/models/stars.py +101 -0
- tlgr/models/sticker.py +243 -0
- tlgr/models/story.py +467 -0
- tlgr/models/sync.py +105 -0
- tlgr/models/todo.py +36 -0
- tlgr/models/webapp.py +89 -0
- tlgr/ops/__init__.py +63 -0
- tlgr/ops/_admin.py +313 -0
- tlgr/ops/_auth.py +599 -0
- tlgr/ops/_bots.py +586 -0
- tlgr/ops/_calls.py +535 -0
- tlgr/ops/_common.py +160 -0
- tlgr/ops/_layer.py +46 -0
- tlgr/ops/_media.py +592 -0
- tlgr/ops/_params.py +212 -0
- tlgr/ops/_rights.py +402 -0
- tlgr/ops/_send.py +593 -0
- tlgr/ops/_serialize.py +667 -0
- tlgr/ops/_settings.py +306 -0
- tlgr/ops/_spec.py +167 -0
- tlgr/ops/_story.py +743 -0
- tlgr/ops/account.py +2604 -0
- tlgr/ops/agent.py +937 -0
- tlgr/ops/auth.py +1282 -0
- tlgr/ops/bot.py +4880 -0
- tlgr/ops/business.py +1520 -0
- tlgr/ops/call.py +1610 -0
- tlgr/ops/chat.py +4025 -0
- tlgr/ops/chat_admin.py +929 -0
- tlgr/ops/chat_extra.py +1061 -0
- tlgr/ops/chat_invite.py +716 -0
- tlgr/ops/chat_manage.py +1691 -0
- tlgr/ops/chat_member.py +1357 -0
- tlgr/ops/chat_stats.py +902 -0
- tlgr/ops/chat_topic.py +905 -0
- tlgr/ops/conference.py +791 -0
- tlgr/ops/config.py +1698 -0
- tlgr/ops/contact.py +2330 -0
- tlgr/ops/daemon.py +1397 -0
- tlgr/ops/draft.py +299 -0
- tlgr/ops/emoji.py +343 -0
- tlgr/ops/events.py +1327 -0
- tlgr/ops/export.py +596 -0
- tlgr/ops/folder.py +1322 -0
- tlgr/ops/gif.py +522 -0
- tlgr/ops/gift.py +1546 -0
- tlgr/ops/giveaway.py +541 -0
- tlgr/ops/inline.py +773 -0
- tlgr/ops/job.py +799 -0
- tlgr/ops/location.py +917 -0
- tlgr/ops/media.py +4495 -0
- tlgr/ops/message.py +3769 -0
- tlgr/ops/net.py +536 -0
- tlgr/ops/notify.py +840 -0
- tlgr/ops/passport.py +464 -0
- tlgr/ops/payment.py +907 -0
- tlgr/ops/poll.py +1078 -0
- tlgr/ops/premium.py +488 -0
- tlgr/ops/privacy.py +794 -0
- tlgr/ops/profile.py +1481 -0
- tlgr/ops/proxy.py +750 -0
- tlgr/ops/reaction.py +1475 -0
- tlgr/ops/resolve.py +1140 -0
- tlgr/ops/search.py +521 -0
- tlgr/ops/settings.py +1066 -0
- tlgr/ops/stars.py +594 -0
- tlgr/ops/sticker.py +1602 -0
- tlgr/ops/story.py +3216 -0
- tlgr/ops/sync.py +788 -0
- tlgr/ops/todo.py +514 -0
- tlgr/ops/user.py +1406 -0
- tlgr/ops/vc.py +2351 -0
- tlgr/ops/webapp.py +717 -0
- tlgr/ops/webhook.py +418 -0
- tlgr/parity.py +386 -0
- tlgr/processors/__init__.py +125 -0
- tlgr/processors/regex.py +26 -0
- tlgr/processors/text.py +56 -0
- tlgr/registry.py +519 -0
- tlgr/schema.py +173 -0
- tlgr/transport/__init__.py +30 -0
- tlgr/transport/autostart.py +293 -0
- tlgr/transport/client.py +805 -0
- tlgr/transport/ndjson.py +44 -0
- tlgr/version.py +31 -0
- tlgr_cli-2.0.1.dist-info/METADATA +957 -0
- tlgr_cli-2.0.1.dist-info/RECORD +192 -0
- tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
- tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
- tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|