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/reaction.py
ADDED
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
"""The `reaction` group: reacting, reading reactions, and the policy around them.
|
|
2
|
+
|
|
3
|
+
`messages.sendReaction` takes the **whole desired state**, not a delta. Adding
|
|
4
|
+
a second reaction means resending the first one too, and removing one means
|
|
5
|
+
resending the others; a client that treats it as "add this emoji" silently
|
|
6
|
+
replaces everything the account had already put on the message. Every write
|
|
7
|
+
here therefore reads my current reactions first, and `--replace` is the
|
|
8
|
+
explicit way to ask for the destructive reading.
|
|
9
|
+
|
|
10
|
+
One spelling for a reaction across the group: a unicode emoji is itself, and a
|
|
11
|
+
custom (Premium) emoji is `custom:<document_id>`. `reaction list` hands back
|
|
12
|
+
exactly what `reaction remove` accepts.
|
|
13
|
+
|
|
14
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Annotated, Any
|
|
20
|
+
|
|
21
|
+
from tlgr.core.errors import NotFoundError, UsageError
|
|
22
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
23
|
+
from tlgr.core.timefmt import fmt_dt, to_unix
|
|
24
|
+
from tlgr.models.base import Request
|
|
25
|
+
from tlgr.models.message import Message
|
|
26
|
+
from tlgr.models.page import Page
|
|
27
|
+
from tlgr.models.peer import PeerRef
|
|
28
|
+
from tlgr.models.reaction import (
|
|
29
|
+
AvailableReaction,
|
|
30
|
+
ChatReactions,
|
|
31
|
+
MessageReactionState,
|
|
32
|
+
PaidReactionResult,
|
|
33
|
+
ReactionPrivacy,
|
|
34
|
+
ReactionPurge,
|
|
35
|
+
ReactionReport,
|
|
36
|
+
ReactionResult,
|
|
37
|
+
ReactionTag,
|
|
38
|
+
ReactionUser,
|
|
39
|
+
TopReactor,
|
|
40
|
+
)
|
|
41
|
+
from tlgr.ops import _send
|
|
42
|
+
from tlgr.ops._common import (
|
|
43
|
+
affected_loop,
|
|
44
|
+
already,
|
|
45
|
+
client,
|
|
46
|
+
ids,
|
|
47
|
+
input_channel,
|
|
48
|
+
is_not_modified,
|
|
49
|
+
window,
|
|
50
|
+
)
|
|
51
|
+
from tlgr.ops._params import arg, choice, opt
|
|
52
|
+
from tlgr.ops._serialize import message_to_model, peer_id_of, reactions_summary
|
|
53
|
+
from tlgr.ops._spec import OpContext, OperationSpec
|
|
54
|
+
|
|
55
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
56
|
+
|
|
57
|
+
#: The prefix that names a custom (Premium) emoji reaction in tlgr's JSON.
|
|
58
|
+
CUSTOM = "custom:"
|
|
59
|
+
|
|
60
|
+
_EXAMPLE_REACT: dict[str, Any] = {
|
|
61
|
+
"chat_id": 777123,
|
|
62
|
+
"msg_id": 12345,
|
|
63
|
+
"emoji": "👍",
|
|
64
|
+
"reacted": True,
|
|
65
|
+
"mine": ["👍"],
|
|
66
|
+
"reactions": {"counts": {"👍": 4}, "mine": ["👍"], "total": 4},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# The one spelling of a reaction
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def name_of(reaction: Any) -> str:
|
|
76
|
+
"""A TL `Reaction` → the string tlgr prints and accepts back."""
|
|
77
|
+
emoticon = getattr(reaction, "emoticon", None)
|
|
78
|
+
if emoticon:
|
|
79
|
+
return str(emoticon)
|
|
80
|
+
document = getattr(reaction, "document_id", None)
|
|
81
|
+
if document is not None:
|
|
82
|
+
return f"{CUSTOM}{document}"
|
|
83
|
+
if type(reaction).__name__ == "ReactionPaid":
|
|
84
|
+
return "stars"
|
|
85
|
+
return "?"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def to_tl(name: str) -> Any:
|
|
89
|
+
"""`"👍"` / `"custom:123"` / `"stars"` → the TL `Reaction` for it."""
|
|
90
|
+
from telethon.tl import types
|
|
91
|
+
|
|
92
|
+
if name == "stars":
|
|
93
|
+
return types.ReactionPaid()
|
|
94
|
+
if name.startswith(CUSTOM):
|
|
95
|
+
try:
|
|
96
|
+
return types.ReactionCustomEmoji(document_id=int(name[len(CUSTOM) :]))
|
|
97
|
+
except ValueError as exc:
|
|
98
|
+
raise UsageError(f"{name!r} is not a custom emoji id", field="custom") from exc
|
|
99
|
+
if not name:
|
|
100
|
+
raise UsageError("a reaction cannot be empty", field="emoji")
|
|
101
|
+
return types.ReactionEmoji(emoticon=name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _wanted(emoji: list[str], custom: list[int]) -> list[str]:
|
|
105
|
+
"""The reactions a caller named, in the order they named them."""
|
|
106
|
+
return [*emoji, *(f"{CUSTOM}{document}" for document in custom)]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _current(ctx: OpContext, peer: Any, chat_id: int, msg_id: int) -> tuple[Any, list[str]]:
|
|
110
|
+
"""`(message, the reactions this account currently holds on it)`.
|
|
111
|
+
|
|
112
|
+
Read before every write because `sendReaction` replaces the set: without
|
|
113
|
+
this, "add 🎉" would silently remove the 👍 that was already there.
|
|
114
|
+
"""
|
|
115
|
+
message = await client(ctx).get_messages(peer, ids=msg_id)
|
|
116
|
+
if message is None:
|
|
117
|
+
raise NotFoundError(f"message {msg_id} was not found in {chat_id}")
|
|
118
|
+
summary = reactions_summary(message)
|
|
119
|
+
return message, list(summary.mine) if summary is not None else []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _result(
|
|
123
|
+
chat_id: int, msg_id: int, message: Any, wanted: list[str], *, reacted: bool
|
|
124
|
+
) -> ReactionResult:
|
|
125
|
+
summary = reactions_summary(message)
|
|
126
|
+
return ReactionResult(
|
|
127
|
+
chat_id=chat_id,
|
|
128
|
+
msg_id=msg_id,
|
|
129
|
+
emoji=wanted[0] if wanted else "",
|
|
130
|
+
reacted=reacted,
|
|
131
|
+
mine=list(summary.mine) if summary is not None else [],
|
|
132
|
+
reactions=summary,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _send_reaction(
|
|
137
|
+
ctx: OpContext,
|
|
138
|
+
peer: Any,
|
|
139
|
+
chat_id: int,
|
|
140
|
+
msg_id: int,
|
|
141
|
+
names: list[str],
|
|
142
|
+
*,
|
|
143
|
+
big: bool = False,
|
|
144
|
+
recent: bool = False,
|
|
145
|
+
reacted: bool,
|
|
146
|
+
primary: list[str],
|
|
147
|
+
) -> ReactionResult:
|
|
148
|
+
"""Send the full desired state, mapping NOT_MODIFIED to `already`."""
|
|
149
|
+
from telethon.tl.functions import messages as fn
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
updates = await client(ctx)(
|
|
153
|
+
fn.SendReactionRequest(
|
|
154
|
+
peer=peer,
|
|
155
|
+
msg_id=msg_id,
|
|
156
|
+
reaction=[to_tl(name) for name in names] or None,
|
|
157
|
+
big=big or None,
|
|
158
|
+
add_to_recent=recent or None,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
if not is_not_modified(exc):
|
|
163
|
+
raise
|
|
164
|
+
already(ctx)
|
|
165
|
+
message, mine = await _current(ctx, peer, chat_id, msg_id)
|
|
166
|
+
result = _result(chat_id, msg_id, message, primary, reacted=reacted)
|
|
167
|
+
result.already = True
|
|
168
|
+
result.mine = mine
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
produced = _send.messages_from_updates(updates, chat_id=chat_id)
|
|
172
|
+
message = produced[0] if produced else None
|
|
173
|
+
summary = message.reactions if message is not None else None
|
|
174
|
+
return ReactionResult(
|
|
175
|
+
chat_id=chat_id,
|
|
176
|
+
msg_id=msg_id,
|
|
177
|
+
emoji=primary[0] if primary else "",
|
|
178
|
+
reacted=reacted,
|
|
179
|
+
mine=list(summary.mine) if summary is not None else [],
|
|
180
|
+
reactions=summary,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# reaction add / remove
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class AddReq(Request):
|
|
190
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
191
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
|
|
192
|
+
emoji: Annotated[
|
|
193
|
+
list[str],
|
|
194
|
+
arg(2, metavar="EMOJI", variadic=True, help="Reactions; omit to clear them all."),
|
|
195
|
+
] = []
|
|
196
|
+
custom: Annotated[
|
|
197
|
+
list[int], opt("--custom", metavar="ID", help="Custom (Premium) emoji id; repeatable.")
|
|
198
|
+
] = []
|
|
199
|
+
big: Annotated[bool, opt("--big", help="Play the big animation.")] = False
|
|
200
|
+
recent: Annotated[bool, opt("--recent", help="Remember it as recently used.")] = False
|
|
201
|
+
replace: Annotated[
|
|
202
|
+
bool, opt("--replace", help="Send exactly this set instead of adding to mine.")
|
|
203
|
+
] = False
|
|
204
|
+
send_as: Annotated[
|
|
205
|
+
PeerRef | None, opt("--send-as", metavar="PEER", kind="peer", help="Paid reactions only.")
|
|
206
|
+
] = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def add(ctx: OpContext, req: AddReq) -> ReactionResult:
|
|
210
|
+
"""React to a message, keeping the reactions I already had.
|
|
211
|
+
|
|
212
|
+
v1's `message react` is this command; it kept one reaction at a time
|
|
213
|
+
because it sent the emoji alone, which is what `sendReaction`'s
|
|
214
|
+
whole-state contract turns into "replace everything". Passing no emoji
|
|
215
|
+
clears my reactions, as it always did.
|
|
216
|
+
"""
|
|
217
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
218
|
+
chat_id = _send.peer_id_of(peer)
|
|
219
|
+
if req.send_as is not None:
|
|
220
|
+
raise UsageError(
|
|
221
|
+
"sendReaction has no send-as field; only a Star reaction can be paid as a "
|
|
222
|
+
"channel — use `tlgr reaction pay --send-as`",
|
|
223
|
+
field="send-as",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
wanted = _wanted(req.emoji, req.custom)
|
|
227
|
+
_, mine = await _current(ctx, peer, chat_id, req.msg_id)
|
|
228
|
+
if not wanted:
|
|
229
|
+
names: list[str] = []
|
|
230
|
+
elif req.replace:
|
|
231
|
+
names = wanted
|
|
232
|
+
else:
|
|
233
|
+
# Ascending `chosen_order`: the ones already there first, the new ones
|
|
234
|
+
# last, which is the order Telegram stores and every client renders.
|
|
235
|
+
names = [*mine, *(name for name in wanted if name not in mine)]
|
|
236
|
+
|
|
237
|
+
return await _send_reaction(
|
|
238
|
+
ctx,
|
|
239
|
+
peer,
|
|
240
|
+
chat_id,
|
|
241
|
+
req.msg_id,
|
|
242
|
+
names,
|
|
243
|
+
big=req.big,
|
|
244
|
+
recent=req.recent,
|
|
245
|
+
reacted=bool(names),
|
|
246
|
+
primary=wanted,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
SPEC_ADD = OperationSpec(
|
|
251
|
+
id="reaction.add",
|
|
252
|
+
request=AddReq,
|
|
253
|
+
response=ReactionResult,
|
|
254
|
+
impl=add,
|
|
255
|
+
summary="React to a message with one or more unicode or custom emoji",
|
|
256
|
+
description=(
|
|
257
|
+
"`sendReaction` carries the whole desired state, so tlgr reads the "
|
|
258
|
+
"reactions this account already has and resends them alongside the "
|
|
259
|
+
"new one; `--replace` sends exactly what was asked for instead. "
|
|
260
|
+
"Reacting twice is `already: true`, not an error."
|
|
261
|
+
),
|
|
262
|
+
aliases=("msg.react", "react.add"),
|
|
263
|
+
legacy_paths=("message react", "msg react"),
|
|
264
|
+
mutating=True,
|
|
265
|
+
idempotent=True,
|
|
266
|
+
rate_class="send",
|
|
267
|
+
columns=("chat_id", "msg_id", "emoji", "reacted"),
|
|
268
|
+
example=_EXAMPLE_REACT,
|
|
269
|
+
example_args="reaction add @alice 12345 👍",
|
|
270
|
+
tags=frozenset({"visible-to-others"}),
|
|
271
|
+
covers=(
|
|
272
|
+
"emoji.interaction",
|
|
273
|
+
"messages-core.reaction-add-remove",
|
|
274
|
+
"messages-core.saved-tags-add",
|
|
275
|
+
"reaction.add-to-recent",
|
|
276
|
+
"reaction.big",
|
|
277
|
+
"reaction.send-custom-emoji",
|
|
278
|
+
"reaction.send-emoji",
|
|
279
|
+
"reaction.send-multiple",
|
|
280
|
+
"reaction.service-messages",
|
|
281
|
+
),
|
|
282
|
+
coverage_note=(
|
|
283
|
+
"`emoji.interaction` (the hearts-and-fireworks tap animation) is the "
|
|
284
|
+
"same gesture from a CLI's point of view: tlgr sends the reaction and "
|
|
285
|
+
"does not replay the per-tap animation payload."
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class RemoveReq(Request):
|
|
291
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
292
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
|
|
293
|
+
emoji: Annotated[
|
|
294
|
+
str, arg(2, metavar="EMOJI", required=False, help="Which one; omit for all of mine.")
|
|
295
|
+
] = ""
|
|
296
|
+
custom: Annotated[
|
|
297
|
+
int | None, opt("--custom", metavar="ID", help="Remove this custom-emoji reaction.")
|
|
298
|
+
] = None
|
|
299
|
+
every: Annotated[bool, opt("--every", help="Remove every reaction of mine.")] = False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def remove(ctx: OpContext, req: RemoveReq) -> ReactionResult:
|
|
303
|
+
"""Remove one of my reactions, or all of them.
|
|
304
|
+
|
|
305
|
+
Removal is the same `sendReaction` with the surviving set; an empty
|
|
306
|
+
vector clears everything.
|
|
307
|
+
"""
|
|
308
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
309
|
+
chat_id = _send.peer_id_of(peer)
|
|
310
|
+
target = f"{CUSTOM}{req.custom}" if req.custom is not None else req.emoji
|
|
311
|
+
message, mine = await _current(ctx, peer, chat_id, req.msg_id)
|
|
312
|
+
|
|
313
|
+
if not mine:
|
|
314
|
+
already(ctx)
|
|
315
|
+
result = _result(chat_id, req.msg_id, message, [target] if target else [], reacted=False)
|
|
316
|
+
result.already = True
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
if req.every or not target:
|
|
320
|
+
names: list[str] = []
|
|
321
|
+
else:
|
|
322
|
+
if target not in mine:
|
|
323
|
+
already(ctx)
|
|
324
|
+
result = _result(chat_id, req.msg_id, message, [target], reacted=False)
|
|
325
|
+
result.already = True
|
|
326
|
+
return result
|
|
327
|
+
names = [name for name in mine if name != target]
|
|
328
|
+
|
|
329
|
+
return await _send_reaction(
|
|
330
|
+
ctx,
|
|
331
|
+
peer,
|
|
332
|
+
chat_id,
|
|
333
|
+
req.msg_id,
|
|
334
|
+
names,
|
|
335
|
+
reacted=False,
|
|
336
|
+
primary=[target] if target else [],
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
SPEC_REMOVE = OperationSpec(
|
|
341
|
+
id="reaction.remove",
|
|
342
|
+
request=RemoveReq,
|
|
343
|
+
response=ReactionResult,
|
|
344
|
+
impl=remove,
|
|
345
|
+
summary="Remove one of my reactions, or all of them",
|
|
346
|
+
description=(
|
|
347
|
+
"Naming no reaction removes all of mine. A reaction that was not "
|
|
348
|
+
"there is `already: true`, so a retry is safe."
|
|
349
|
+
),
|
|
350
|
+
aliases=("message.unreact", "react.remove"),
|
|
351
|
+
mutating=True,
|
|
352
|
+
idempotent=True,
|
|
353
|
+
rate_class="send",
|
|
354
|
+
columns=("chat_id", "msg_id", "emoji", "reacted"),
|
|
355
|
+
example={**_EXAMPLE_REACT, "reacted": False, "mine": []},
|
|
356
|
+
example_args="reaction remove @alice 12345 👍",
|
|
357
|
+
covers=("reaction.remove",),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# reaction list / user list
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class ListReq(Request):
|
|
367
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
368
|
+
msg_id: Annotated[
|
|
369
|
+
list[str], arg(1, metavar="MSG_ID", variadic=True, help="One or more message ids.")
|
|
370
|
+
] = []
|
|
371
|
+
top_senders: Annotated[
|
|
372
|
+
bool, opt("--top-senders", help="Include the Star-reaction leaderboard.")
|
|
373
|
+
] = False
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def reaction_list(ctx: OpContext, req: ListReq) -> Page[MessageReactionState]:
|
|
377
|
+
"""Reaction counts on one or many messages, refreshed from the server.
|
|
378
|
+
|
|
379
|
+
`messages.getMessagesReactions` is the only source of
|
|
380
|
+
`messageReactions.top_reactors`: the leaderboard is not on the plain
|
|
381
|
+
message, so `--top-senders` cannot be answered from a cached copy.
|
|
382
|
+
"""
|
|
383
|
+
from telethon.tl.functions import messages as fn
|
|
384
|
+
|
|
385
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
386
|
+
chat_id = _send.peer_id_of(peer)
|
|
387
|
+
wanted = ids(tuple(req.msg_id))
|
|
388
|
+
if not wanted:
|
|
389
|
+
raise UsageError("at least one message id is required", field="msg_id")
|
|
390
|
+
|
|
391
|
+
updates = await client(ctx)(fn.GetMessagesReactionsRequest(peer=peer, id=wanted))
|
|
392
|
+
by_id: dict[int, Any] = {}
|
|
393
|
+
for update in getattr(updates, "updates", None) or []:
|
|
394
|
+
if type(update).__name__ == "UpdateMessageReactions":
|
|
395
|
+
by_id[int(getattr(update, "msg_id", 0))] = getattr(update, "reactions", None)
|
|
396
|
+
message = getattr(update, "message", None)
|
|
397
|
+
if message is not None and getattr(message, "reactions", None) is not None:
|
|
398
|
+
by_id.setdefault(int(message.id), message.reactions)
|
|
399
|
+
|
|
400
|
+
items: list[MessageReactionState] = []
|
|
401
|
+
for msg_id in wanted:
|
|
402
|
+
raw = by_id.get(msg_id)
|
|
403
|
+
|
|
404
|
+
class _Holder:
|
|
405
|
+
reactions = raw
|
|
406
|
+
|
|
407
|
+
summary = reactions_summary(_Holder())
|
|
408
|
+
items.append(
|
|
409
|
+
MessageReactionState(
|
|
410
|
+
chat_id=chat_id,
|
|
411
|
+
msg_id=msg_id,
|
|
412
|
+
reactions=summary,
|
|
413
|
+
can_see_list=bool(getattr(raw, "can_see_list", False)),
|
|
414
|
+
as_tags=bool(getattr(raw, "reactions_as_tags", False)),
|
|
415
|
+
top_reactors=_reactors(raw) if req.top_senders else [],
|
|
416
|
+
)
|
|
417
|
+
)
|
|
418
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _reactors(raw: Any) -> list[TopReactor]:
|
|
422
|
+
out: list[TopReactor] = []
|
|
423
|
+
for reactor in getattr(raw, "top_reactors", None) or []:
|
|
424
|
+
out.append(
|
|
425
|
+
TopReactor(
|
|
426
|
+
user_id=peer_id_of(getattr(reactor, "peer_id", None)),
|
|
427
|
+
stars=int(getattr(reactor, "count", 0) or 0),
|
|
428
|
+
anonymous=bool(getattr(reactor, "anonymous", False)),
|
|
429
|
+
mine=bool(getattr(reactor, "my", False)),
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
return out
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
SPEC_LIST = OperationSpec(
|
|
436
|
+
id="reaction.list",
|
|
437
|
+
request=ListReq,
|
|
438
|
+
response=Page[MessageReactionState],
|
|
439
|
+
impl=reaction_list,
|
|
440
|
+
summary="Reaction counts on one or many messages",
|
|
441
|
+
description=(
|
|
442
|
+
"`--top-senders` adds the Star-reaction leaderboard, which only "
|
|
443
|
+
"`messages.getMessagesReactions` returns — it is not on the message."
|
|
444
|
+
),
|
|
445
|
+
aliases=("react.list",),
|
|
446
|
+
columns=("msg_id", "can_see_list"),
|
|
447
|
+
example={
|
|
448
|
+
"items": [
|
|
449
|
+
{
|
|
450
|
+
"chat_id": 777123,
|
|
451
|
+
"msg_id": 12345,
|
|
452
|
+
"reactions": {"counts": {"👍": 4}, "total": 4},
|
|
453
|
+
"can_see_list": True,
|
|
454
|
+
}
|
|
455
|
+
],
|
|
456
|
+
"has_more": False,
|
|
457
|
+
},
|
|
458
|
+
example_args="reaction list @alice 12345",
|
|
459
|
+
covers=(
|
|
460
|
+
"messages-core.reactions-count",
|
|
461
|
+
"reaction.bulk-refresh",
|
|
462
|
+
"reaction.paid-leaderboard",
|
|
463
|
+
"reaction.read-summary",
|
|
464
|
+
"stories.available-reactions",
|
|
465
|
+
),
|
|
466
|
+
coverage_note=(
|
|
467
|
+
"`stories.available-reactions` is the same catalogue this group "
|
|
468
|
+
"publishes as `reaction catalog`; the story surface itself is PR-8's."
|
|
469
|
+
),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class UserListReq(Request):
|
|
474
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
475
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
|
|
476
|
+
emoji: Annotated[str | None, opt("--emoji", metavar="EMOJI", help="Only this reaction.")] = None
|
|
477
|
+
custom: Annotated[
|
|
478
|
+
int | None, opt("--custom", metavar="ID", help="Only this custom-emoji reaction.")
|
|
479
|
+
] = None
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def user_list(ctx: OpContext, req: UserListReq) -> Page[ReactionUser]:
|
|
483
|
+
"""Who reacted, per emoji.
|
|
484
|
+
|
|
485
|
+
Only available when `messageReactions.can_see_list` — groups and small
|
|
486
|
+
channels. Pagination is an opaque *string* offset, so the cursor carries
|
|
487
|
+
it verbatim rather than an integer that would restart the walk.
|
|
488
|
+
"""
|
|
489
|
+
from telethon.tl.functions import messages as fn
|
|
490
|
+
|
|
491
|
+
limit, state = window(ctx, "reaction.user.list", PageKind.PARTICIPANTS)
|
|
492
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
493
|
+
filter_name = f"{CUSTOM}{req.custom}" if req.custom is not None else req.emoji
|
|
494
|
+
|
|
495
|
+
result = await client(ctx)(
|
|
496
|
+
fn.GetMessageReactionsListRequest(
|
|
497
|
+
peer=peer,
|
|
498
|
+
id=req.msg_id,
|
|
499
|
+
limit=limit,
|
|
500
|
+
reaction=to_tl(filter_name) if filter_name else None,
|
|
501
|
+
offset=state.get("offset") or None,
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
items = [
|
|
505
|
+
ReactionUser(
|
|
506
|
+
user_id=peer_id_of(getattr(row, "peer_id", None)) or 0,
|
|
507
|
+
reaction=name_of(getattr(row, "reaction", None)),
|
|
508
|
+
date=fmt_dt(getattr(row, "date", None)),
|
|
509
|
+
date_unix=to_unix(getattr(row, "date", None)),
|
|
510
|
+
big=bool(getattr(row, "big", False)),
|
|
511
|
+
unread=bool(getattr(row, "unread", False)),
|
|
512
|
+
mine=bool(getattr(row, "my", False)),
|
|
513
|
+
)
|
|
514
|
+
for row in (getattr(result, "reactions", None) or [])
|
|
515
|
+
]
|
|
516
|
+
next_offset = getattr(result, "next_offset", None)
|
|
517
|
+
return build_page(
|
|
518
|
+
items,
|
|
519
|
+
op="reaction.user.list",
|
|
520
|
+
kind=PageKind.PARTICIPANTS,
|
|
521
|
+
state={"offset": next_offset},
|
|
522
|
+
account=ctx.account,
|
|
523
|
+
has_more=bool(next_offset),
|
|
524
|
+
total=getattr(result, "count", None),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
SPEC_USER_LIST = OperationSpec(
|
|
529
|
+
id="reaction.user.list",
|
|
530
|
+
request=UserListReq,
|
|
531
|
+
response=Page[ReactionUser],
|
|
532
|
+
impl=user_list,
|
|
533
|
+
summary="Who reacted to a message, per emoji",
|
|
534
|
+
description="Only where `can_see_list` is set — groups, and small channels.",
|
|
535
|
+
aliases=("react.who", "reaction.users"),
|
|
536
|
+
paginated=PageKind.PARTICIPANTS,
|
|
537
|
+
columns=("user_id", "reaction", "date"),
|
|
538
|
+
example={
|
|
539
|
+
"items": [{"user_id": 4242, "reaction": "👍", "date": "2026-09-03T09:20:00Z"}],
|
|
540
|
+
"has_more": False,
|
|
541
|
+
},
|
|
542
|
+
example_args="reaction user list @alice 12345",
|
|
543
|
+
covers=("reaction.who-reacted",),
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ---------------------------------------------------------------------------
|
|
548
|
+
# reaction unread list
|
|
549
|
+
# ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
class UnreadListReq(Request):
|
|
553
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
554
|
+
topic: Annotated[
|
|
555
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Only this forum topic.")
|
|
556
|
+
] = None
|
|
557
|
+
direct_to: Annotated[
|
|
558
|
+
PeerRef | None,
|
|
559
|
+
opt("--direct-to", metavar="USER", kind="user", help="Only this monoforum topic."),
|
|
560
|
+
] = None
|
|
561
|
+
read_all: Annotated[bool, opt("--read-all", help="Mark every unread reaction as read.")] = False
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
async def unread_list(ctx: OpContext, req: UnreadListReq) -> Page[Message]:
|
|
565
|
+
"""Messages in this chat carrying reactions I have not seen."""
|
|
566
|
+
from telethon.tl.functions import messages as fn
|
|
567
|
+
|
|
568
|
+
limit, state = window(ctx, "reaction.unread.list", PageKind.PARTICIPANTS)
|
|
569
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
570
|
+
chat_id = _send.peer_id_of(peer)
|
|
571
|
+
saved = await _send.resolve(ctx, req.direct_to) if req.direct_to is not None else None
|
|
572
|
+
|
|
573
|
+
result = await client(ctx)(
|
|
574
|
+
fn.GetUnreadReactionsRequest(
|
|
575
|
+
peer=peer,
|
|
576
|
+
offset_id=int(state.get("offset_id") or 0),
|
|
577
|
+
add_offset=0,
|
|
578
|
+
limit=limit,
|
|
579
|
+
max_id=0,
|
|
580
|
+
min_id=0,
|
|
581
|
+
top_msg_id=req.topic,
|
|
582
|
+
saved_peer_id=saved,
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
items = [
|
|
586
|
+
message_to_model(raw, chat_id=chat_id) for raw in (getattr(result, "messages", None) or [])
|
|
587
|
+
]
|
|
588
|
+
if req.read_all:
|
|
589
|
+
await affected_loop(
|
|
590
|
+
ctx,
|
|
591
|
+
lambda offset: fn.ReadReactionsRequest(
|
|
592
|
+
peer=peer, top_msg_id=req.topic, saved_peer_id=saved
|
|
593
|
+
),
|
|
594
|
+
)
|
|
595
|
+
return build_page(
|
|
596
|
+
items,
|
|
597
|
+
op="reaction.unread.list",
|
|
598
|
+
kind=PageKind.PARTICIPANTS,
|
|
599
|
+
state={"offset_id": items[-1].id if items else 0},
|
|
600
|
+
account=ctx.account,
|
|
601
|
+
limit=limit,
|
|
602
|
+
total=getattr(result, "count", None),
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
SPEC_UNREAD_LIST = OperationSpec(
|
|
607
|
+
id="reaction.unread.list",
|
|
608
|
+
request=UnreadListReq,
|
|
609
|
+
response=Page[Message],
|
|
610
|
+
impl=unread_list,
|
|
611
|
+
summary="Unread reactions in a chat, and optionally mark them read",
|
|
612
|
+
description=(
|
|
613
|
+
"`--read-all` drives `messages.readReactions` until its offset comes "
|
|
614
|
+
"back zero; calling it once clears only the first page of the badge."
|
|
615
|
+
),
|
|
616
|
+
paginated=PageKind.PARTICIPANTS,
|
|
617
|
+
tags=frozenset({"mutating-checked"}),
|
|
618
|
+
columns=("id", "chat_id", "date", "text"),
|
|
619
|
+
example={
|
|
620
|
+
"items": [
|
|
621
|
+
{
|
|
622
|
+
"id": 12345,
|
|
623
|
+
"chat_id": 777123,
|
|
624
|
+
"date": "2026-09-03T09:14:07Z",
|
|
625
|
+
"date_unix": 1788340447,
|
|
626
|
+
"text": "on my way",
|
|
627
|
+
}
|
|
628
|
+
],
|
|
629
|
+
"has_more": False,
|
|
630
|
+
},
|
|
631
|
+
example_args="reaction unread list @alice",
|
|
632
|
+
covers=("reaction.read-all",),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
# reaction catalog
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class CatalogReq(Request):
|
|
642
|
+
top: Annotated[bool, opt("--top", help="Featured / top reactions.")] = False
|
|
643
|
+
recent: Annotated[bool, opt("--recent", help="My recently used reactions.")] = False
|
|
644
|
+
forget: Annotated[bool, opt("--forget", help="Clear the recently-used list.")] = False
|
|
645
|
+
refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
async def catalog(ctx: OpContext, req: CatalogReq) -> Page[AvailableReaction]:
|
|
649
|
+
"""The reaction catalogue: standard, featured, or my recently used.
|
|
650
|
+
|
|
651
|
+
Paged locally: all three endpoints answer with the whole list (hash
|
|
652
|
+
cached), so a server-side offset would be a fiction.
|
|
653
|
+
"""
|
|
654
|
+
from telethon.tl.functions import messages as fn
|
|
655
|
+
|
|
656
|
+
limit, state = window(ctx, "reaction.catalog", PageKind.LOCAL, default=50)
|
|
657
|
+
handle = client(ctx)
|
|
658
|
+
|
|
659
|
+
if req.forget:
|
|
660
|
+
await handle(fn.ClearRecentReactionsRequest())
|
|
661
|
+
|
|
662
|
+
if req.recent:
|
|
663
|
+
source = "recent"
|
|
664
|
+
result = await handle(fn.GetRecentReactionsRequest(limit=100, hash=0))
|
|
665
|
+
rows = [
|
|
666
|
+
AvailableReaction(emoticon=name_of(item), source="recent")
|
|
667
|
+
for item in (getattr(result, "reactions", None) or [])
|
|
668
|
+
]
|
|
669
|
+
elif req.top:
|
|
670
|
+
source = "top"
|
|
671
|
+
result = await handle(fn.GetTopReactionsRequest(limit=100, hash=0))
|
|
672
|
+
rows = [
|
|
673
|
+
AvailableReaction(emoticon=name_of(item), source="top")
|
|
674
|
+
for item in (getattr(result, "reactions", None) or [])
|
|
675
|
+
]
|
|
676
|
+
else:
|
|
677
|
+
source = "available"
|
|
678
|
+
result = await handle(fn.GetAvailableReactionsRequest(hash=0))
|
|
679
|
+
rows = [
|
|
680
|
+
AvailableReaction(
|
|
681
|
+
emoticon=str(getattr(item, "reaction", "")),
|
|
682
|
+
title=str(getattr(item, "title", "") or ""),
|
|
683
|
+
premium=bool(getattr(item, "premium", False)),
|
|
684
|
+
inactive=bool(getattr(item, "inactive", False)),
|
|
685
|
+
source="available",
|
|
686
|
+
static_icon_id=getattr(getattr(item, "static_icon", None), "id", None),
|
|
687
|
+
select_animation_id=getattr(getattr(item, "select_animation", None), "id", None),
|
|
688
|
+
)
|
|
689
|
+
for item in (getattr(result, "reactions", None) or [])
|
|
690
|
+
]
|
|
691
|
+
|
|
692
|
+
offset = int(state.get("offset") or 0)
|
|
693
|
+
window_rows = rows[offset : offset + limit]
|
|
694
|
+
return build_page(
|
|
695
|
+
window_rows,
|
|
696
|
+
op="reaction.catalog",
|
|
697
|
+
kind=PageKind.LOCAL,
|
|
698
|
+
state={"offset": offset + len(window_rows), "source": source},
|
|
699
|
+
account=ctx.account,
|
|
700
|
+
has_more=offset + len(window_rows) < len(rows),
|
|
701
|
+
total=len(rows),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
SPEC_CATALOG = OperationSpec(
|
|
706
|
+
id="reaction.catalog",
|
|
707
|
+
request=CatalogReq,
|
|
708
|
+
response=Page[AvailableReaction],
|
|
709
|
+
impl=catalog,
|
|
710
|
+
summary="The reaction catalogue: standard, featured, or my recently used",
|
|
711
|
+
description=(
|
|
712
|
+
"Three endpoints, one row shape; `source` says which list a row came "
|
|
713
|
+
"from. `--forget` clears the recently-used list first."
|
|
714
|
+
),
|
|
715
|
+
aliases=("react.catalog",),
|
|
716
|
+
paginated=PageKind.LOCAL,
|
|
717
|
+
mutating=False,
|
|
718
|
+
tags=frozenset({"mutating-checked"}),
|
|
719
|
+
columns=("emoticon", "title", "premium", "source"),
|
|
720
|
+
example={
|
|
721
|
+
"items": [{"emoticon": "👍", "title": "Thumbs Up", "source": "available"}],
|
|
722
|
+
"has_more": False,
|
|
723
|
+
},
|
|
724
|
+
example_args="reaction catalog",
|
|
725
|
+
covers=(
|
|
726
|
+
"reaction.available-list",
|
|
727
|
+
"reaction.clear-recent",
|
|
728
|
+
"reaction.recent-list",
|
|
729
|
+
"reaction.top-featured",
|
|
730
|
+
),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ---------------------------------------------------------------------------
|
|
735
|
+
# reaction chat get / set
|
|
736
|
+
# ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _chat_reactions(full: Any, chat_id: int) -> ChatReactions:
|
|
740
|
+
available = getattr(full, "available_reactions", None)
|
|
741
|
+
name = type(available).__name__
|
|
742
|
+
if name == "ChatReactionsAll":
|
|
743
|
+
mode = "all"
|
|
744
|
+
elif name == "ChatReactionsSome":
|
|
745
|
+
mode = "some"
|
|
746
|
+
else:
|
|
747
|
+
mode = "none"
|
|
748
|
+
return ChatReactions(
|
|
749
|
+
chat_id=chat_id,
|
|
750
|
+
mode=mode, # type: ignore[arg-type]
|
|
751
|
+
reactions=[name_of(item) for item in (getattr(available, "reactions", None) or [])],
|
|
752
|
+
allow_custom=bool(getattr(available, "allow_custom", False)),
|
|
753
|
+
reactions_limit=getattr(full, "reactions_limit", None),
|
|
754
|
+
paid_enabled=getattr(full, "paid_reactions_available", None),
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
async def _full_chat(ctx: OpContext, peer: Any) -> Any:
|
|
759
|
+
"""`chatFull`/`channelFull` for a peer — the source of truth for the policy."""
|
|
760
|
+
from telethon.tl import types
|
|
761
|
+
from telethon.tl.functions import channels as ch
|
|
762
|
+
from telethon.tl.functions import messages as fn
|
|
763
|
+
|
|
764
|
+
if isinstance(peer, types.InputPeerChannel):
|
|
765
|
+
result = await client(ctx)(ch.GetFullChannelRequest(channel=input_channel(peer)))
|
|
766
|
+
elif isinstance(peer, types.InputPeerChat):
|
|
767
|
+
result = await client(ctx)(fn.GetFullChatRequest(chat_id=peer.chat_id))
|
|
768
|
+
else:
|
|
769
|
+
raise UsageError(
|
|
770
|
+
"reactions are configured per group or channel, not per private chat", field="chat"
|
|
771
|
+
)
|
|
772
|
+
return getattr(result, "full_chat", None)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
class ChatGetReq(Request):
|
|
776
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
777
|
+
msg_id: Annotated[
|
|
778
|
+
int | None,
|
|
779
|
+
opt("--msg-id", metavar="ID", kind="msg_id", help="Narrow to one message."),
|
|
780
|
+
] = None
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
async def chat_get(ctx: OpContext, req: ChatGetReq) -> ChatReactions:
|
|
784
|
+
"""Which reactions this chat — or this one message — allows."""
|
|
785
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
786
|
+
chat_id = _send.peer_id_of(peer)
|
|
787
|
+
policy = _chat_reactions(await _full_chat(ctx, peer), chat_id)
|
|
788
|
+
if req.msg_id is None:
|
|
789
|
+
return policy
|
|
790
|
+
|
|
791
|
+
policy.msg_id = req.msg_id
|
|
792
|
+
message = await client(ctx).get_messages(peer, ids=req.msg_id)
|
|
793
|
+
if message is None:
|
|
794
|
+
raise NotFoundError(f"message {req.msg_id} was not found in {chat_id}")
|
|
795
|
+
if policy.mode == "all":
|
|
796
|
+
# The per-message answer is the chat policy narrowed by the global
|
|
797
|
+
# catalogue: "everything" means everything Telegram currently ships.
|
|
798
|
+
from telethon.tl.functions import messages as fn
|
|
799
|
+
|
|
800
|
+
available = await client(ctx)(fn.GetAvailableReactionsRequest(hash=0))
|
|
801
|
+
policy.reactions = [
|
|
802
|
+
str(item.reaction)
|
|
803
|
+
for item in (getattr(available, "reactions", None) or [])
|
|
804
|
+
if not getattr(item, "inactive", False)
|
|
805
|
+
]
|
|
806
|
+
return policy
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
SPEC_CHAT_GET = OperationSpec(
|
|
810
|
+
id="reaction.chat.get",
|
|
811
|
+
request=ChatGetReq,
|
|
812
|
+
response=ChatReactions,
|
|
813
|
+
impl=chat_get,
|
|
814
|
+
summary="Which reactions a chat (or one message) allows",
|
|
815
|
+
description=(
|
|
816
|
+
"`chatFull`/`channelFull.available_reactions` is the source of truth. "
|
|
817
|
+
"With `--msg-id` an `all` policy is expanded against the live "
|
|
818
|
+
"catalogue, which is the set that would actually be accepted."
|
|
819
|
+
),
|
|
820
|
+
aliases=("reaction.available",),
|
|
821
|
+
columns=("chat_id", "mode", "reactions_limit"),
|
|
822
|
+
example={"chat_id": -1001234567890, "mode": "some", "reactions": ["👍", "❤"]},
|
|
823
|
+
example_args="reaction chat get @news",
|
|
824
|
+
covers=("reaction.chat-available-get", "reaction.message-available"),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
class ChatSetReq(Request):
|
|
829
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
830
|
+
every: Annotated[bool, opt("--every", help="Allow every reaction.")] = False
|
|
831
|
+
none: Annotated[bool, opt("--none", help="Disable reactions.")] = False
|
|
832
|
+
some: Annotated[
|
|
833
|
+
str | None, opt("--some", metavar="EMOJI,...", help="Allow exactly this set.")
|
|
834
|
+
] = None
|
|
835
|
+
allow_custom: Annotated[bool, opt("--allow-custom", help="Allow custom-emoji reactions.")] = (
|
|
836
|
+
False
|
|
837
|
+
)
|
|
838
|
+
max_unique: Annotated[
|
|
839
|
+
int | None,
|
|
840
|
+
opt("--max-unique", metavar="N", help="Cap unique reactions per message."),
|
|
841
|
+
] = None
|
|
842
|
+
paid: Annotated[str | None, choice("on", "off", help="Star (paid) reactions on a channel.")] = (
|
|
843
|
+
None
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
async def chat_set(ctx: OpContext, req: ChatSetReq) -> ChatReactions:
|
|
848
|
+
"""Set a chat's reaction policy, its unique cap and its Star reactions.
|
|
849
|
+
|
|
850
|
+
`available_reactions` is mandatory on the wire while the other fields are
|
|
851
|
+
optional, so changing only the cap still means resending the whole
|
|
852
|
+
policy: this is a read-modify-write, never a blind overwrite.
|
|
853
|
+
"""
|
|
854
|
+
from telethon.tl import types
|
|
855
|
+
from telethon.tl.functions import messages as fn
|
|
856
|
+
|
|
857
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
858
|
+
chat_id = _send.peer_id_of(peer)
|
|
859
|
+
current = _chat_reactions(await _full_chat(ctx, peer), chat_id)
|
|
860
|
+
|
|
861
|
+
chosen = [flag for flag in (req.every, req.none, bool(req.some)) if flag]
|
|
862
|
+
if len(chosen) > 1:
|
|
863
|
+
raise UsageError("--every, --none and --some are three answers to one question")
|
|
864
|
+
|
|
865
|
+
if req.every:
|
|
866
|
+
available: Any = types.ChatReactionsAll(allow_custom=req.allow_custom or None)
|
|
867
|
+
elif req.none:
|
|
868
|
+
available = types.ChatReactionsNone()
|
|
869
|
+
elif req.some is not None:
|
|
870
|
+
names = [part.strip() for part in req.some.split(",") if part.strip()]
|
|
871
|
+
if not names:
|
|
872
|
+
raise UsageError("--some needs at least one reaction", field="some")
|
|
873
|
+
available = types.ChatReactionsSome(reactions=[to_tl(name) for name in names])
|
|
874
|
+
elif current.mode == "all":
|
|
875
|
+
available = types.ChatReactionsAll(
|
|
876
|
+
allow_custom=(req.allow_custom or current.allow_custom) or None
|
|
877
|
+
)
|
|
878
|
+
elif current.mode == "some":
|
|
879
|
+
available = types.ChatReactionsSome(reactions=[to_tl(name) for name in current.reactions])
|
|
880
|
+
else:
|
|
881
|
+
available = types.ChatReactionsNone()
|
|
882
|
+
|
|
883
|
+
await client(ctx)(
|
|
884
|
+
fn.SetChatAvailableReactionsRequest(
|
|
885
|
+
peer=peer,
|
|
886
|
+
available_reactions=available,
|
|
887
|
+
reactions_limit=req.max_unique,
|
|
888
|
+
paid_enabled=None if req.paid is None else req.paid == "on",
|
|
889
|
+
)
|
|
890
|
+
)
|
|
891
|
+
updated = _chat_reactions(await _full_chat(ctx, peer), chat_id)
|
|
892
|
+
ctx.emit("chat_reactions_set", {"chat_id": chat_id, "mode": updated.mode})
|
|
893
|
+
return updated
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
SPEC_CHAT_SET = OperationSpec(
|
|
897
|
+
id="reaction.chat.set",
|
|
898
|
+
request=ChatSetReq,
|
|
899
|
+
response=ChatReactions,
|
|
900
|
+
impl=chat_set,
|
|
901
|
+
summary="Restrict which reactions a chat allows, cap them, enable Star reactions",
|
|
902
|
+
description=(
|
|
903
|
+
"Needs the `change_info` admin right. Because the wire field is "
|
|
904
|
+
"mandatory, tlgr reads the current policy first and resends it "
|
|
905
|
+
"unchanged when only the cap or the Star switch was asked for."
|
|
906
|
+
),
|
|
907
|
+
mutating=True,
|
|
908
|
+
rate_class="send",
|
|
909
|
+
columns=("chat_id", "mode", "reactions_limit", "paid_enabled"),
|
|
910
|
+
example={"chat_id": -1001234567890, "mode": "some", "reactions": ["👍"], "reactions_limit": 3},
|
|
911
|
+
example_args="reaction chat set @news --some 👍,❤",
|
|
912
|
+
covers=("reaction.chat-available-set", "reaction.chat-unique-limit", "reaction.paid-enable"),
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# ---------------------------------------------------------------------------
|
|
917
|
+
# reaction default get / set
|
|
918
|
+
# ---------------------------------------------------------------------------
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
class DefaultGetReq(Request):
|
|
922
|
+
pass
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
async def default_get(ctx: OpContext, req: DefaultGetReq) -> ReactionTag:
|
|
926
|
+
"""The quick (double-tap) reaction.
|
|
927
|
+
|
|
928
|
+
There is no getter: the value ships in `help.getConfig().reactions_default`,
|
|
929
|
+
which is why it is read here rather than remembered from the last write.
|
|
930
|
+
"""
|
|
931
|
+
from telethon.tl.functions import help as fn
|
|
932
|
+
|
|
933
|
+
config = await client(ctx)(fn.GetConfigRequest())
|
|
934
|
+
reaction = getattr(config, "reactions_default", None)
|
|
935
|
+
if reaction is None:
|
|
936
|
+
return ReactionTag(reaction="")
|
|
937
|
+
return ReactionTag(reaction=name_of(reaction))
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
SPEC_DEFAULT_GET = OperationSpec(
|
|
941
|
+
id="reaction.default.get",
|
|
942
|
+
request=DefaultGetReq,
|
|
943
|
+
response=ReactionTag,
|
|
944
|
+
impl=default_get,
|
|
945
|
+
summary="Show the quick (double-tap) reaction",
|
|
946
|
+
description="Read from `help.getConfig().reactions_default`; there is no dedicated getter.",
|
|
947
|
+
columns=("reaction",),
|
|
948
|
+
example={"reaction": "❤"},
|
|
949
|
+
example_args="reaction default get",
|
|
950
|
+
covers=(),
|
|
951
|
+
covers_partial=(),
|
|
952
|
+
tags=frozenset({"infrastructure"}),
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
class DefaultSetReq(Request):
|
|
957
|
+
emoji: Annotated[
|
|
958
|
+
str, arg(0, metavar="EMOJI", required=False, help="The reaction to make default.")
|
|
959
|
+
] = ""
|
|
960
|
+
custom: Annotated[
|
|
961
|
+
int | None, opt("--custom", metavar="ID", help="Use a custom emoji (Premium).")
|
|
962
|
+
] = None
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
async def default_set(ctx: OpContext, req: DefaultSetReq) -> ReactionTag:
|
|
966
|
+
"""Set the quick (double-tap) reaction."""
|
|
967
|
+
from telethon.tl.functions import messages as fn
|
|
968
|
+
|
|
969
|
+
name = f"{CUSTOM}{req.custom}" if req.custom is not None else req.emoji
|
|
970
|
+
if not name:
|
|
971
|
+
raise UsageError("a reaction is required", field="emoji")
|
|
972
|
+
await client(ctx)(fn.SetDefaultReactionRequest(reaction=to_tl(name)))
|
|
973
|
+
return ReactionTag(reaction=name)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
SPEC_DEFAULT_SET = OperationSpec(
|
|
977
|
+
id="reaction.default.set",
|
|
978
|
+
request=DefaultSetReq,
|
|
979
|
+
response=ReactionTag,
|
|
980
|
+
impl=default_set,
|
|
981
|
+
summary="Set the quick (double-tap) reaction",
|
|
982
|
+
mutating=True,
|
|
983
|
+
idempotent=True,
|
|
984
|
+
columns=("reaction",),
|
|
985
|
+
example={"reaction": "❤"},
|
|
986
|
+
example_args="reaction default set ❤",
|
|
987
|
+
covers=("reaction.quick-default",),
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
# ---------------------------------------------------------------------------
|
|
992
|
+
# reaction tag list / set
|
|
993
|
+
# ---------------------------------------------------------------------------
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
class TagListReq(Request):
|
|
997
|
+
peer: Annotated[
|
|
998
|
+
PeerRef | None,
|
|
999
|
+
opt("--peer", metavar="CHAT", kind="peer", help="Tags used inside one saved dialog."),
|
|
1000
|
+
] = None
|
|
1001
|
+
suggested: Annotated[bool, opt("--suggested", help="Default/suggested tag reactions.")] = False
|
|
1002
|
+
refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
|
|
1003
|
+
rename: Annotated[
|
|
1004
|
+
str | None, opt("--rename", metavar="REACTION=TITLE", help="Name or rename a tag.")
|
|
1005
|
+
] = None
|
|
1006
|
+
clear_title: Annotated[
|
|
1007
|
+
str | None, opt("--clear-title", metavar="REACTION", help="Drop a tag's name.")
|
|
1008
|
+
] = None
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
async def tag_list(ctx: OpContext, req: TagListReq) -> Page[ReactionTag]:
|
|
1012
|
+
"""Saved Messages reaction tags — mine, or the suggested ones.
|
|
1013
|
+
|
|
1014
|
+
Tagging a saved message *is* reacting to it (`reaction add me <id> 📌`);
|
|
1015
|
+
this command only names the tags and reads them back.
|
|
1016
|
+
"""
|
|
1017
|
+
from telethon.tl.functions import messages as fn
|
|
1018
|
+
|
|
1019
|
+
handle = client(ctx)
|
|
1020
|
+
if req.rename is not None or req.clear_title is not None:
|
|
1021
|
+
await _rename_tag(ctx, req)
|
|
1022
|
+
|
|
1023
|
+
if req.suggested:
|
|
1024
|
+
result = await handle(fn.GetDefaultTagReactionsRequest(hash=0))
|
|
1025
|
+
items = [
|
|
1026
|
+
ReactionTag(reaction=name_of(item), suggested=True)
|
|
1027
|
+
for item in (getattr(result, "reactions", None) or [])
|
|
1028
|
+
]
|
|
1029
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
1030
|
+
|
|
1031
|
+
peer = await _send.resolve(ctx, req.peer) if req.peer is not None else None
|
|
1032
|
+
result = await handle(fn.GetSavedReactionTagsRequest(hash=0, peer=peer))
|
|
1033
|
+
items = [
|
|
1034
|
+
ReactionTag(
|
|
1035
|
+
reaction=name_of(getattr(tag, "reaction", None)),
|
|
1036
|
+
title=getattr(tag, "title", None),
|
|
1037
|
+
count=int(getattr(tag, "count", 0) or 0),
|
|
1038
|
+
)
|
|
1039
|
+
for tag in (getattr(result, "tags", None) or [])
|
|
1040
|
+
]
|
|
1041
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
async def _rename_tag(ctx: OpContext, req: TagListReq) -> None:
|
|
1045
|
+
from telethon.tl.functions import messages as fn
|
|
1046
|
+
|
|
1047
|
+
if req.clear_title is not None:
|
|
1048
|
+
await client(ctx)(
|
|
1049
|
+
fn.UpdateSavedReactionTagRequest(reaction=to_tl(req.clear_title), title=None)
|
|
1050
|
+
)
|
|
1051
|
+
return
|
|
1052
|
+
reaction, sep, title = (req.rename or "").partition("=")
|
|
1053
|
+
if not sep:
|
|
1054
|
+
raise UsageError("--rename wants REACTION=TITLE", field="rename")
|
|
1055
|
+
await client(ctx)(
|
|
1056
|
+
fn.UpdateSavedReactionTagRequest(reaction=to_tl(reaction), title=title or None)
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
SPEC_TAG_LIST = OperationSpec(
|
|
1061
|
+
id="reaction.tag.list",
|
|
1062
|
+
request=TagListReq,
|
|
1063
|
+
response=Page[ReactionTag],
|
|
1064
|
+
impl=tag_list,
|
|
1065
|
+
summary="Saved Messages reaction tags (and the suggested ones)",
|
|
1066
|
+
description=(
|
|
1067
|
+
"Premium. Tagging a saved message is reacting to it — "
|
|
1068
|
+
"`tlgr reaction add me <id> 📌` — and this is where the tags are named."
|
|
1069
|
+
),
|
|
1070
|
+
mutating=True,
|
|
1071
|
+
columns=("reaction", "title", "count"),
|
|
1072
|
+
example={"items": [{"reaction": "📌", "title": "invoices", "count": 12}], "has_more": False},
|
|
1073
|
+
example_args="reaction tag list",
|
|
1074
|
+
covers=(
|
|
1075
|
+
"dialogs.saved-tags",
|
|
1076
|
+
"messages-core.saved-tags-manage",
|
|
1077
|
+
"reaction.default-tag-reactions",
|
|
1078
|
+
"reaction.saved-tags-list",
|
|
1079
|
+
),
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
class TagSetReq(Request):
|
|
1084
|
+
emoji: Annotated[str, arg(0, metavar="EMOJI", help="The tag reaction.")]
|
|
1085
|
+
title: Annotated[str, arg(1, metavar="TITLE", required=False, help="The name to give it.")] = ""
|
|
1086
|
+
custom: Annotated[int | None, opt("--custom", metavar="ID", help="A custom-emoji tag.")] = None
|
|
1087
|
+
clear: Annotated[bool, opt("--clear", help="Remove the name.")] = False
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
async def tag_set(ctx: OpContext, req: TagSetReq) -> ReactionTag:
|
|
1091
|
+
"""Name or rename a Saved Messages tag. Names are local to my account."""
|
|
1092
|
+
from telethon.tl.functions import messages as fn
|
|
1093
|
+
|
|
1094
|
+
name = f"{CUSTOM}{req.custom}" if req.custom is not None else req.emoji
|
|
1095
|
+
title = "" if req.clear else req.title
|
|
1096
|
+
await client(ctx)(fn.UpdateSavedReactionTagRequest(reaction=to_tl(name), title=title or None))
|
|
1097
|
+
return ReactionTag(reaction=name, title=title or None)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
SPEC_TAG_SET = OperationSpec(
|
|
1101
|
+
id="reaction.tag.set",
|
|
1102
|
+
request=TagSetReq,
|
|
1103
|
+
response=ReactionTag,
|
|
1104
|
+
impl=tag_set,
|
|
1105
|
+
summary="Name or rename a Saved Messages tag",
|
|
1106
|
+
description="Premium. Omitting the title, or passing `--clear`, removes the name.",
|
|
1107
|
+
mutating=True,
|
|
1108
|
+
idempotent=True,
|
|
1109
|
+
columns=("reaction", "title"),
|
|
1110
|
+
example={"reaction": "📌", "title": "invoices"},
|
|
1111
|
+
example_args="reaction tag set 📌 invoices",
|
|
1112
|
+
covers=("reaction.saved-tag-rename",),
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
# ---------------------------------------------------------------------------
|
|
1117
|
+
# reaction pay / privacy
|
|
1118
|
+
# ---------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _privacy(mode: str, peer: Any) -> Any:
|
|
1122
|
+
from telethon.tl import types
|
|
1123
|
+
|
|
1124
|
+
if mode == "anonymous":
|
|
1125
|
+
return types.PaidReactionPrivacyAnonymous()
|
|
1126
|
+
if mode == "peer":
|
|
1127
|
+
if peer is None:
|
|
1128
|
+
raise UsageError("privacy 'peer' needs --send-as to say which channel", field="send-as")
|
|
1129
|
+
return types.PaidReactionPrivacyPeer(peer=peer)
|
|
1130
|
+
return types.PaidReactionPrivacyDefault()
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _privacy_name(value: Any) -> str:
|
|
1134
|
+
name = type(value).__name__
|
|
1135
|
+
if name.endswith("Anonymous"):
|
|
1136
|
+
return "anonymous"
|
|
1137
|
+
if name.endswith("Peer"):
|
|
1138
|
+
return "peer"
|
|
1139
|
+
return "default"
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
class PayReq(Request):
|
|
1143
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
|
|
1144
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Post id.")]
|
|
1145
|
+
stars: Annotated[int | None, opt("--stars", metavar="N", help="How many Stars to send.")] = None
|
|
1146
|
+
anonymous: Annotated[bool, opt("--anonymous", help="Hide my name on the leaderboard.")] = False
|
|
1147
|
+
send_as: Annotated[
|
|
1148
|
+
PeerRef | None, opt("--send-as", metavar="PEER", kind="peer", help="Pay as my channel.")
|
|
1149
|
+
] = None
|
|
1150
|
+
senders: Annotated[
|
|
1151
|
+
bool, opt("--senders", help="List the peers I may pay as, instead of paying.")
|
|
1152
|
+
] = False
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
async def pay(ctx: OpContext, req: PayReq) -> PaidReactionResult:
|
|
1156
|
+
"""Send a Star (paid) reaction, or list the identities I could pay as.
|
|
1157
|
+
|
|
1158
|
+
This spends the account's Star balance, so there is no default amount and
|
|
1159
|
+
no automatic retry: `--stars` is mandatory and the confirmation is the
|
|
1160
|
+
ordinary destructive-command one. `--senders` is free and never pays.
|
|
1161
|
+
|
|
1162
|
+
`random_id` is not the usual 64 random bits here — the API wants
|
|
1163
|
+
`(unixtime << 32) | random_uint32`, and a plain random value is rejected.
|
|
1164
|
+
"""
|
|
1165
|
+
import os
|
|
1166
|
+
import time
|
|
1167
|
+
|
|
1168
|
+
from telethon.tl.functions import channels as ch
|
|
1169
|
+
from telethon.tl.functions import messages as fn
|
|
1170
|
+
|
|
1171
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1172
|
+
chat_id = _send.peer_id_of(peer)
|
|
1173
|
+
# Resolved before anything is spent: a paid reaction only exists on a
|
|
1174
|
+
# channel post, and finding that out after the payment is not an option.
|
|
1175
|
+
input_channel(peer)
|
|
1176
|
+
|
|
1177
|
+
if req.senders:
|
|
1178
|
+
found = await client(ctx)(ch.GetSendAsRequest(peer=peer, for_paid_reactions=True))
|
|
1179
|
+
return PaidReactionResult(
|
|
1180
|
+
chat_id=chat_id,
|
|
1181
|
+
msg_id=req.msg_id,
|
|
1182
|
+
senders=[
|
|
1183
|
+
pid
|
|
1184
|
+
for pid in (
|
|
1185
|
+
peer_id_of(getattr(item, "peer", item))
|
|
1186
|
+
for item in (getattr(found, "peers", None) or [])
|
|
1187
|
+
)
|
|
1188
|
+
if pid is not None
|
|
1189
|
+
],
|
|
1190
|
+
)
|
|
1191
|
+
|
|
1192
|
+
if not req.stars or req.stars < 1:
|
|
1193
|
+
raise UsageError(
|
|
1194
|
+
"--stars N is required: a paid reaction spends real Stars and tlgr never "
|
|
1195
|
+
"picks the amount",
|
|
1196
|
+
field="stars",
|
|
1197
|
+
)
|
|
1198
|
+
send_as = await _send.resolve(ctx, req.send_as) if req.send_as is not None else None
|
|
1199
|
+
mode = "peer" if send_as is not None else ("anonymous" if req.anonymous else "default")
|
|
1200
|
+
|
|
1201
|
+
updates = await client(ctx)(
|
|
1202
|
+
fn.SendPaidReactionRequest(
|
|
1203
|
+
peer=peer,
|
|
1204
|
+
msg_id=req.msg_id,
|
|
1205
|
+
count=int(req.stars),
|
|
1206
|
+
random_id=(int(time.time()) << 32) | int.from_bytes(os.urandom(4), "big"),
|
|
1207
|
+
private=_privacy(mode, send_as),
|
|
1208
|
+
)
|
|
1209
|
+
)
|
|
1210
|
+
top: list[TopReactor] = []
|
|
1211
|
+
for update in getattr(updates, "updates", None) or []:
|
|
1212
|
+
if type(update).__name__ == "UpdateMessageReactions":
|
|
1213
|
+
top = _reactors(getattr(update, "reactions", None))
|
|
1214
|
+
ctx.emit("reaction_paid", {"chat_id": chat_id, "msg_id": req.msg_id, "stars": req.stars})
|
|
1215
|
+
return PaidReactionResult(
|
|
1216
|
+
chat_id=chat_id,
|
|
1217
|
+
msg_id=req.msg_id,
|
|
1218
|
+
stars_sent=int(req.stars),
|
|
1219
|
+
privacy=mode, # type: ignore[arg-type]
|
|
1220
|
+
top_reactors=top,
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
SPEC_PAY = OperationSpec(
|
|
1225
|
+
id="reaction.pay",
|
|
1226
|
+
request=PayReq,
|
|
1227
|
+
response=PaidReactionResult,
|
|
1228
|
+
impl=pay,
|
|
1229
|
+
summary="Send a Star (paid) reaction to a channel post",
|
|
1230
|
+
description=(
|
|
1231
|
+
"Spends real Stars. `--stars` is mandatory, there is no default "
|
|
1232
|
+
"amount, and a failed payment is never retried automatically. "
|
|
1233
|
+
"`--senders` lists the identities you could pay as without paying."
|
|
1234
|
+
),
|
|
1235
|
+
mutating=True,
|
|
1236
|
+
destructive=True,
|
|
1237
|
+
rate_class="send",
|
|
1238
|
+
columns=("chat_id", "msg_id", "stars_sent"),
|
|
1239
|
+
example={"chat_id": -1001234567890, "msg_id": 12345, "stars_sent": 50},
|
|
1240
|
+
example_args="reaction pay @news 12345 --stars 50",
|
|
1241
|
+
tags=frozenset({"visible-to-others"}),
|
|
1242
|
+
covers=(
|
|
1243
|
+
"messages-core.reaction-paid-star",
|
|
1244
|
+
"reaction.live-story-paid",
|
|
1245
|
+
"reaction.paid-send",
|
|
1246
|
+
"reaction.paid-send-as",
|
|
1247
|
+
),
|
|
1248
|
+
coverage_note=(
|
|
1249
|
+
"`reaction.live-story-paid` is the same Star spend inside a live "
|
|
1250
|
+
"story; the live-story stream itself is control-only for a CLI and "
|
|
1251
|
+
"belongs to the calls group."
|
|
1252
|
+
),
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
class PrivacyGetReq(Request):
|
|
1257
|
+
pass
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
async def privacy_get(ctx: OpContext, req: PrivacyGetReq) -> ReactionPrivacy:
|
|
1261
|
+
"""How my paid reactions are attributed by default.
|
|
1262
|
+
|
|
1263
|
+
Worth calling on startup: `updatePaidReactionPrivacy` only reaches online
|
|
1264
|
+
sessions and is not replayed by `getDifference`.
|
|
1265
|
+
"""
|
|
1266
|
+
from telethon.tl.functions import messages as fn
|
|
1267
|
+
|
|
1268
|
+
value = await client(ctx)(fn.GetPaidReactionPrivacyRequest())
|
|
1269
|
+
inner = getattr(value, "private", None)
|
|
1270
|
+
for update in getattr(value, "updates", None) or []:
|
|
1271
|
+
# The answer is an `updatePaidReactionPrivacy` inside an Updates batch;
|
|
1272
|
+
# reading the envelope as the value itself always says "default".
|
|
1273
|
+
if getattr(update, "private", None) is not None:
|
|
1274
|
+
inner = update.private
|
|
1275
|
+
return ReactionPrivacy(
|
|
1276
|
+
privacy=_privacy_name(inner), # type: ignore[arg-type]
|
|
1277
|
+
peer_id=peer_id_of(getattr(inner, "peer", None)),
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
SPEC_PRIVACY_GET = OperationSpec(
|
|
1282
|
+
id="reaction.privacy.get",
|
|
1283
|
+
request=PrivacyGetReq,
|
|
1284
|
+
response=ReactionPrivacy,
|
|
1285
|
+
impl=privacy_get,
|
|
1286
|
+
summary="Default privacy of my paid reactions",
|
|
1287
|
+
columns=("privacy",),
|
|
1288
|
+
example={"privacy": "default"},
|
|
1289
|
+
example_args="reaction privacy get",
|
|
1290
|
+
covers=("reaction.paid-privacy-get",),
|
|
1291
|
+
)
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
class PrivacySetReq(Request):
|
|
1295
|
+
mode: Annotated[str, arg(0, metavar="MODE", help="default | anonymous | peer.")]
|
|
1296
|
+
chat: Annotated[
|
|
1297
|
+
PeerRef | None, opt("--chat", metavar="CHAT", kind="peer", help="The post's chat.")
|
|
1298
|
+
] = None
|
|
1299
|
+
msg_id: Annotated[
|
|
1300
|
+
int | None, opt("--msg-id", metavar="ID", kind="msg_id", help="The post itself.")
|
|
1301
|
+
] = None
|
|
1302
|
+
send_as: Annotated[
|
|
1303
|
+
PeerRef | None,
|
|
1304
|
+
opt("--send-as", metavar="PEER", kind="peer", help="Attribute them to this channel."),
|
|
1305
|
+
] = None
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
async def privacy_set(ctx: OpContext, req: PrivacySetReq) -> ReactionPrivacy:
|
|
1309
|
+
"""Change how paid reactions are attributed, including ones already sent.
|
|
1310
|
+
|
|
1311
|
+
This rewrites the attribution of Stars already spent on that post, and
|
|
1312
|
+
changes the account-wide default as a side effect — which is why it takes
|
|
1313
|
+
the post rather than being a settings-only toggle.
|
|
1314
|
+
"""
|
|
1315
|
+
from telethon.tl.functions import messages as fn
|
|
1316
|
+
|
|
1317
|
+
if req.mode not in ("default", "anonymous", "peer"):
|
|
1318
|
+
raise UsageError("mode is one of default, anonymous, peer", field="mode")
|
|
1319
|
+
if req.chat is None or req.msg_id is None:
|
|
1320
|
+
raise UsageError(
|
|
1321
|
+
"--chat and --msg-id name the post whose attribution changes", field="msg-id"
|
|
1322
|
+
)
|
|
1323
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1324
|
+
send_as = await _send.resolve(ctx, req.send_as) if req.send_as is not None else None
|
|
1325
|
+
await client(ctx)(
|
|
1326
|
+
fn.TogglePaidReactionPrivacyRequest(
|
|
1327
|
+
peer=peer, msg_id=req.msg_id, private=_privacy(req.mode, send_as)
|
|
1328
|
+
)
|
|
1329
|
+
)
|
|
1330
|
+
return ReactionPrivacy(
|
|
1331
|
+
privacy=req.mode, # type: ignore[arg-type]
|
|
1332
|
+
peer_id=peer_id_of(send_as),
|
|
1333
|
+
msg_id=req.msg_id,
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
SPEC_PRIVACY_SET = OperationSpec(
|
|
1338
|
+
id="reaction.privacy.set",
|
|
1339
|
+
request=PrivacySetReq,
|
|
1340
|
+
response=ReactionPrivacy,
|
|
1341
|
+
impl=privacy_set,
|
|
1342
|
+
summary="Change the privacy of paid reactions, including ones already sent",
|
|
1343
|
+
description="Also changes the account-wide default, which is what the server does.",
|
|
1344
|
+
mutating=True,
|
|
1345
|
+
idempotent=True,
|
|
1346
|
+
columns=("privacy", "msg_id"),
|
|
1347
|
+
example={"privacy": "anonymous", "msg_id": 12345},
|
|
1348
|
+
example_args="reaction privacy set anonymous --chat @news --msg-id 12345",
|
|
1349
|
+
covers=("reaction.paid-privacy-set",),
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
# ---------------------------------------------------------------------------
|
|
1354
|
+
# reaction purge / report
|
|
1355
|
+
# ---------------------------------------------------------------------------
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
class PurgeReq(Request):
|
|
1359
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
1360
|
+
user: Annotated[PeerRef, arg(1, metavar="USER", kind="user", help="Whose reactions.")]
|
|
1361
|
+
msg: Annotated[
|
|
1362
|
+
int | None, opt("--msg", metavar="ID", kind="msg_id", help="Only this message.")
|
|
1363
|
+
] = None
|
|
1364
|
+
every: Annotated[bool, opt("--every", help="Every reaction this member left in the chat.")] = (
|
|
1365
|
+
False
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
async def purge(ctx: OpContext, req: PurgeReq) -> ReactionPurge:
|
|
1370
|
+
"""Delete a member's reactions — on one message, or across the chat.
|
|
1371
|
+
|
|
1372
|
+
Moderation: needs the delete-messages / ban rights. Both the singular and
|
|
1373
|
+
the plural method exist on the wire; which one runs is the difference
|
|
1374
|
+
between `--msg` and `--every`, and asking for neither is a usage error
|
|
1375
|
+
rather than a guess.
|
|
1376
|
+
"""
|
|
1377
|
+
from telethon.tl.functions import messages as fn
|
|
1378
|
+
|
|
1379
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1380
|
+
chat_id = _send.peer_id_of(peer)
|
|
1381
|
+
participant = await _send.resolve(ctx, req.user)
|
|
1382
|
+
user_id = _send.peer_id_of(participant)
|
|
1383
|
+
|
|
1384
|
+
if req.every:
|
|
1385
|
+
await client(ctx)(fn.DeleteParticipantReactionsRequest(peer=peer, participant=participant))
|
|
1386
|
+
scope = "chat"
|
|
1387
|
+
elif req.msg is not None:
|
|
1388
|
+
await client(ctx)(
|
|
1389
|
+
fn.DeleteParticipantReactionRequest(peer=peer, msg_id=req.msg, participant=participant)
|
|
1390
|
+
)
|
|
1391
|
+
scope = "message"
|
|
1392
|
+
else:
|
|
1393
|
+
raise UsageError("name a message with --msg, or pass --every", field="msg")
|
|
1394
|
+
|
|
1395
|
+
ctx.emit("reaction_purged", {"chat_id": chat_id, "user_id": user_id, "scope": scope})
|
|
1396
|
+
return ReactionPurge(
|
|
1397
|
+
chat_id=chat_id,
|
|
1398
|
+
user_id=user_id,
|
|
1399
|
+
msg_id=req.msg,
|
|
1400
|
+
deleted=True,
|
|
1401
|
+
scope=scope, # type: ignore[arg-type]
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
SPEC_PURGE = OperationSpec(
|
|
1406
|
+
id="reaction.purge",
|
|
1407
|
+
request=PurgeReq,
|
|
1408
|
+
response=ReactionPurge,
|
|
1409
|
+
impl=purge,
|
|
1410
|
+
summary="Delete a member's reactions on one message or across a chat",
|
|
1411
|
+
description="Moderation: needs `delete_messages` or ban rights in the chat.",
|
|
1412
|
+
aliases=("react.purge",),
|
|
1413
|
+
mutating=True,
|
|
1414
|
+
destructive=True,
|
|
1415
|
+
rate_class="send",
|
|
1416
|
+
columns=("chat_id", "user_id", "scope", "deleted"),
|
|
1417
|
+
example={"chat_id": -1001234567890, "user_id": 4242, "deleted": True, "scope": "message"},
|
|
1418
|
+
example_args="reaction purge @news @alice --msg 12345",
|
|
1419
|
+
covers=(
|
|
1420
|
+
"messages-core.reaction-delete-from-sender",
|
|
1421
|
+
"reaction.purge-participant-message",
|
|
1422
|
+
),
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
class ReportReq(Request):
|
|
1427
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
1428
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="The post.")]
|
|
1429
|
+
user: Annotated[PeerRef, arg(2, metavar="USER", kind="user", help="Who reacted.")]
|
|
1430
|
+
ban: Annotated[bool, opt("--ban", help="Also block them from replying.")] = False
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
async def report(ctx: OpContext, req: ReportReq) -> ReactionReport:
|
|
1434
|
+
"""Report a reaction on my own post, optionally blocking the member.
|
|
1435
|
+
|
|
1436
|
+
The ban half is `contacts.blockFromReplies`, which is exactly what the GUI
|
|
1437
|
+
does from this menu; the full moderation surface belongs to the group
|
|
1438
|
+
admin commands.
|
|
1439
|
+
"""
|
|
1440
|
+
from telethon.tl.functions import contacts as ct
|
|
1441
|
+
from telethon.tl.functions import messages as fn
|
|
1442
|
+
|
|
1443
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1444
|
+
chat_id = _send.peer_id_of(peer)
|
|
1445
|
+
reaction_peer = await _send.resolve(ctx, req.user)
|
|
1446
|
+
await client(ctx)(
|
|
1447
|
+
fn.ReportReactionRequest(peer=peer, id=req.msg_id, reaction_peer=reaction_peer)
|
|
1448
|
+
)
|
|
1449
|
+
banned = False
|
|
1450
|
+
if req.ban:
|
|
1451
|
+
await client(ctx)(ct.BlockFromRepliesRequest(msg_id=req.msg_id, delete_message=True))
|
|
1452
|
+
banned = True
|
|
1453
|
+
return ReactionReport(
|
|
1454
|
+
ok=True,
|
|
1455
|
+
banned=banned,
|
|
1456
|
+
chat_id=chat_id,
|
|
1457
|
+
msg_id=req.msg_id,
|
|
1458
|
+
user_id=_send.peer_id_of(reaction_peer),
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
SPEC_REPORT = OperationSpec(
|
|
1463
|
+
id="reaction.report",
|
|
1464
|
+
request=ReportReq,
|
|
1465
|
+
response=ReactionReport,
|
|
1466
|
+
impl=report,
|
|
1467
|
+
summary="Report a reaction, optionally blocking the member who left it",
|
|
1468
|
+
description="Only valid on your own posts. Reporting cannot be undone from a client.",
|
|
1469
|
+
mutating=True,
|
|
1470
|
+
rate_class="send",
|
|
1471
|
+
columns=("ok", "banned"),
|
|
1472
|
+
example={"ok": True, "banned": False, "chat_id": -1001234567890, "msg_id": 12345},
|
|
1473
|
+
example_args="reaction report @news 12345 @alice",
|
|
1474
|
+
covers=("groups-channels-admin.report-reaction", "reaction.ban-sender", "reaction.report"),
|
|
1475
|
+
)
|