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/message.py
ADDED
|
@@ -0,0 +1,3769 @@
|
|
|
1
|
+
"""The `message` group: everything you can do to or with a message.
|
|
2
|
+
|
|
3
|
+
This is the busiest group in the product and the one the registry was designed
|
|
4
|
+
against. Three things it settles for the whole surface:
|
|
5
|
+
|
|
6
|
+
* **one composer.** v1 had `message send`, `media upload` and `draft set` each
|
|
7
|
+
building their own request; here `message send` is the universal composer
|
|
8
|
+
(text, album, dice, contact, location, sticker, voice, poll) and `forward`,
|
|
9
|
+
`edit` and `draft set` share its options struct, so `--parse`, `--reply-to`
|
|
10
|
+
and `--schedule` cannot mean three different things.
|
|
11
|
+
* **one message shape.** Every op that returns a message returns
|
|
12
|
+
`models.Message`, built by `ops/_serialize.message_to_model`.
|
|
13
|
+
* **one pagination story.** `list`, `search` and `thread list` take
|
|
14
|
+
`--limit/--cursor/--all` from the transport, not from their own request, and
|
|
15
|
+
hand back a signed `Page[Message]`.
|
|
16
|
+
|
|
17
|
+
Telethon is imported inside functions, never at module scope: importing the
|
|
18
|
+
registry is what builds `tlgr --help`, and that must not pull in Telethon.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import re
|
|
25
|
+
from typing import Annotated, Any
|
|
26
|
+
|
|
27
|
+
from tlgr.core.errors import (
|
|
28
|
+
EXIT_EMPTY,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
UsageError,
|
|
31
|
+
)
|
|
32
|
+
from tlgr.core.pagination import PageKind, build_page
|
|
33
|
+
from tlgr.core.text import utf16_len
|
|
34
|
+
from tlgr.core.timefmt import fmt_dt, parse_dt, to_unix
|
|
35
|
+
from tlgr.models.base import Request
|
|
36
|
+
from tlgr.models.message import (
|
|
37
|
+
ComposeResult,
|
|
38
|
+
DeleteResult,
|
|
39
|
+
DiceCatalog,
|
|
40
|
+
EditResult,
|
|
41
|
+
Effect,
|
|
42
|
+
EntityReport,
|
|
43
|
+
FactCheck,
|
|
44
|
+
ForwardedMessage,
|
|
45
|
+
GameInfo,
|
|
46
|
+
GameScore,
|
|
47
|
+
LinkResult,
|
|
48
|
+
Message,
|
|
49
|
+
MessageEntity,
|
|
50
|
+
PaidMessageSettings,
|
|
51
|
+
PinResult,
|
|
52
|
+
ReadReceipts,
|
|
53
|
+
ReadResult,
|
|
54
|
+
ReportResult,
|
|
55
|
+
ScheduledSent,
|
|
56
|
+
SponsoredHidden,
|
|
57
|
+
SponsoredMessage,
|
|
58
|
+
SuggestedPostState,
|
|
59
|
+
SummaryResult,
|
|
60
|
+
Tone,
|
|
61
|
+
Transcription,
|
|
62
|
+
Translation,
|
|
63
|
+
ViewCount,
|
|
64
|
+
WebPagePreview,
|
|
65
|
+
)
|
|
66
|
+
from tlgr.models.page import Page
|
|
67
|
+
from tlgr.models.peer import PeerRef
|
|
68
|
+
from tlgr.ops import _send
|
|
69
|
+
from tlgr.ops._common import affected_loop as _affected_loop
|
|
70
|
+
from tlgr.ops._common import already as _already
|
|
71
|
+
from tlgr.ops._common import client as _client
|
|
72
|
+
from tlgr.ops._common import ids as _ids
|
|
73
|
+
from tlgr.ops._common import input_channel as _input_channel
|
|
74
|
+
from tlgr.ops._common import is_not_modified as _is_not_modified
|
|
75
|
+
from tlgr.ops._common import only as _only
|
|
76
|
+
from tlgr.ops._common import random_id as _random_id
|
|
77
|
+
from tlgr.ops._common import window as _window
|
|
78
|
+
from tlgr.ops._params import arg, choice, opt
|
|
79
|
+
from tlgr.ops._serialize import entity_to_peer, message_entities, message_to_model
|
|
80
|
+
from tlgr.ops._spec import OpContext, OperationSpec, Surface
|
|
81
|
+
|
|
82
|
+
__all__ = [name for name in dir() if name.startswith("SPEC_")]
|
|
83
|
+
|
|
84
|
+
#: `--type` values → the `inputMessagesFilter*` class that implements them.
|
|
85
|
+
#: Spelled as class *names* so this module still imports without Telethon.
|
|
86
|
+
FILTERS: dict[str, str] = {
|
|
87
|
+
"photo": "InputMessagesFilterPhotos",
|
|
88
|
+
"video": "InputMessagesFilterVideo",
|
|
89
|
+
"media": "InputMessagesFilterPhotoVideo",
|
|
90
|
+
"file": "InputMessagesFilterDocument",
|
|
91
|
+
"link": "InputMessagesFilterUrl",
|
|
92
|
+
"url": "InputMessagesFilterUrl",
|
|
93
|
+
"music": "InputMessagesFilterMusic",
|
|
94
|
+
"voice": "InputMessagesFilterVoice",
|
|
95
|
+
"gif": "InputMessagesFilterGif",
|
|
96
|
+
"round": "InputMessagesFilterRoundVideo",
|
|
97
|
+
"geo": "InputMessagesFilterGeo",
|
|
98
|
+
"contact": "InputMessagesFilterContacts",
|
|
99
|
+
"pinned": "InputMessagesFilterPinned",
|
|
100
|
+
"chat-photo": "InputMessagesFilterChatPhotos",
|
|
101
|
+
"call": "InputMessagesFilterPhoneCalls",
|
|
102
|
+
"poll": "InputMessagesFilterPoll",
|
|
103
|
+
"todo": "InputMessagesFilterToDo",
|
|
104
|
+
"mention": "InputMessagesFilterMyMentions",
|
|
105
|
+
"sticker": "InputMessagesFilterDocument",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
_EXAMPLE_MESSAGE: dict[str, Any] = {
|
|
109
|
+
"id": 12345,
|
|
110
|
+
"chat_id": 777123,
|
|
111
|
+
"date": "2026-09-03T09:14:07Z",
|
|
112
|
+
"date_unix": 1788340447,
|
|
113
|
+
"text": "on my way",
|
|
114
|
+
"out": True,
|
|
115
|
+
"kind": "message",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Shared plumbing
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _filter(name: str | None) -> Any:
|
|
125
|
+
"""`--type photo` → `InputMessagesFilterPhotos()`, or None."""
|
|
126
|
+
if not name:
|
|
127
|
+
return None
|
|
128
|
+
class_name = FILTERS.get(name)
|
|
129
|
+
if class_name is None:
|
|
130
|
+
raise UsageError(
|
|
131
|
+
f"--type {name!r} is not a media filter; expected one of {', '.join(FILTERS)}",
|
|
132
|
+
field="type",
|
|
133
|
+
)
|
|
134
|
+
from telethon.tl import types
|
|
135
|
+
|
|
136
|
+
return getattr(types, class_name)()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _fetch(
|
|
140
|
+
ctx: OpContext,
|
|
141
|
+
peer: Any,
|
|
142
|
+
*,
|
|
143
|
+
chat_id: int,
|
|
144
|
+
limit: int,
|
|
145
|
+
**kwargs: Any,
|
|
146
|
+
) -> list[Message]:
|
|
147
|
+
"""`iter_messages` into models, with the chat id filled in once."""
|
|
148
|
+
out: list[Message] = []
|
|
149
|
+
async for raw in _client(ctx).iter_messages(peer, limit=limit, **kwargs):
|
|
150
|
+
if raw is None:
|
|
151
|
+
continue
|
|
152
|
+
out.append(message_to_model(raw, chat_id=chat_id))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# message send
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SendReq(_send.SendOptions, kw_only=True):
|
|
162
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Target chat or user.")]
|
|
163
|
+
text: Annotated[
|
|
164
|
+
str,
|
|
165
|
+
arg(1, metavar="TEXT", required=False, help="Message text; '-' reads stdin."),
|
|
166
|
+
] = ""
|
|
167
|
+
parse: Annotated[
|
|
168
|
+
str | None, choice("md", "html", "none", help="Formatting of TEXT/--caption.")
|
|
169
|
+
] = None
|
|
170
|
+
entities: Annotated[
|
|
171
|
+
str | None,
|
|
172
|
+
opt("--entities", metavar="JSON", kind="json", help="Explicit entity vector (UTF-16)."),
|
|
173
|
+
] = None
|
|
174
|
+
stdin: Annotated[bool, opt("--stdin", help="Read the body from stdin.")] = False
|
|
175
|
+
reply_to: Annotated[
|
|
176
|
+
int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this message.")
|
|
177
|
+
] = None
|
|
178
|
+
reply_in: Annotated[
|
|
179
|
+
PeerRef | None,
|
|
180
|
+
opt("--reply-in", metavar="CHAT", kind="peer", help="Chat --reply-to belongs to."),
|
|
181
|
+
] = None
|
|
182
|
+
quote: Annotated[
|
|
183
|
+
str | None, opt("--quote", help="Quote this exact substring of the replied message.")
|
|
184
|
+
] = None
|
|
185
|
+
quote_offset: Annotated[
|
|
186
|
+
int | None, opt("--quote-offset", metavar="N", help="UTF-16 offset of --quote.")
|
|
187
|
+
] = None
|
|
188
|
+
reply_task: Annotated[
|
|
189
|
+
int | None, opt("--reply-task", metavar="N", help="Reply to a checklist task id.")
|
|
190
|
+
] = None
|
|
191
|
+
reply_poll_option: Annotated[
|
|
192
|
+
int | None, opt("--reply-poll-option", metavar="N", help="Reply to a poll option index.")
|
|
193
|
+
] = None
|
|
194
|
+
reply_to_story: Annotated[
|
|
195
|
+
int | None, opt("--reply-to-story", metavar="ID", help="Reply to a story of CHAT.")
|
|
196
|
+
] = None
|
|
197
|
+
direct_to: Annotated[
|
|
198
|
+
PeerRef | None,
|
|
199
|
+
opt("--direct-to", metavar="USER", kind="user", help="Answer in a monoforum topic."),
|
|
200
|
+
] = None
|
|
201
|
+
comment_to: Annotated[
|
|
202
|
+
int | None, opt("--comment-to", metavar="ID", help="Comment on a channel post.")
|
|
203
|
+
] = None
|
|
204
|
+
clear_draft: Annotated[bool, opt(help="Clear the chat draft on send.")] = True
|
|
205
|
+
split: Annotated[bool, opt("--split", help="Split text longer than the limit.")] = False
|
|
206
|
+
split_at: Annotated[int, opt("--split-at", metavar="N", help="Split threshold.", ge=1)] = 4096
|
|
207
|
+
as_file: Annotated[bool, opt("--as-file", help="Send the text as a .txt document.")] = False
|
|
208
|
+
filename: Annotated[
|
|
209
|
+
str | None, opt("--filename", metavar="NAME", help="Filename for --as-file/stdin.")
|
|
210
|
+
] = None
|
|
211
|
+
file: Annotated[
|
|
212
|
+
list[str],
|
|
213
|
+
opt("--file", metavar="PATH", help="Attach a file; repeat for an album."),
|
|
214
|
+
] = []
|
|
215
|
+
caption: Annotated[str | None, opt("--caption", help="Caption for the attached media.")] = None
|
|
216
|
+
caption_above: Annotated[bool, opt("--caption-above", help="invert_media.")] = False
|
|
217
|
+
spoiler: Annotated[bool, opt("--spoiler", help="Hide the media behind a spoiler.")] = False
|
|
218
|
+
ttl: Annotated[
|
|
219
|
+
int | None,
|
|
220
|
+
opt("--ttl", metavar="DURATION", kind="duration", help="Self-destruct timer."),
|
|
221
|
+
] = None
|
|
222
|
+
sticker: Annotated[str | None, opt("--sticker", metavar="REF", help="Send a sticker.")] = None
|
|
223
|
+
gif: Annotated[str | None, opt("--gif", metavar="REF", help="Send a GIF/animation.")] = None
|
|
224
|
+
voice: Annotated[
|
|
225
|
+
str | None, opt("--voice", metavar="PATH", kind="path", help="Send as a voice note.")
|
|
226
|
+
] = None
|
|
227
|
+
video_note: Annotated[
|
|
228
|
+
str | None, opt("--video-note", metavar="PATH", kind="path", help="Send a round video.")
|
|
229
|
+
] = None
|
|
230
|
+
dice: Annotated[
|
|
231
|
+
str | None, opt("--dice", metavar="EMOJI", help="Send an animated dice/darts/slot.")
|
|
232
|
+
] = None
|
|
233
|
+
contact: Annotated[
|
|
234
|
+
PeerRef | None,
|
|
235
|
+
opt("--contact", metavar="USER", kind="user", help="Send a contact card for a user."),
|
|
236
|
+
] = None
|
|
237
|
+
contact_phone: Annotated[str | None, opt("--contact-phone", help="Contact card phone.")] = None
|
|
238
|
+
contact_first: Annotated[str | None, opt("--contact-first", help="Contact first name.")] = None
|
|
239
|
+
contact_last: Annotated[str | None, opt("--contact-last", help="Contact last name.")] = None
|
|
240
|
+
vcard: Annotated[
|
|
241
|
+
str | None, opt("--vcard", metavar="PATH", kind="path", help="vCard payload.")
|
|
242
|
+
] = None
|
|
243
|
+
location: Annotated[
|
|
244
|
+
str | None, opt("--location", metavar="LAT,LON", help="Attach a static location.")
|
|
245
|
+
] = None
|
|
246
|
+
poll: Annotated[
|
|
247
|
+
str | None, opt("--poll", metavar="JSON", kind="json", help="Attach a poll.")
|
|
248
|
+
] = None
|
|
249
|
+
no_preview: Annotated[bool, opt("--no-preview", help="Disable the link preview.")] = False
|
|
250
|
+
preview_url: Annotated[
|
|
251
|
+
str | None, opt("--preview-url", metavar="URL", help="Preview this URL.")
|
|
252
|
+
] = None
|
|
253
|
+
preview_large: Annotated[bool, opt("--preview-large", help="force_large_media.")] = False
|
|
254
|
+
preview_small: Annotated[bool, opt("--preview-small", help="force_small_media.")] = False
|
|
255
|
+
preview_above: Annotated[bool, opt("--preview-above", help="Preview above the text.")] = False
|
|
256
|
+
invert_media: Annotated[
|
|
257
|
+
bool, opt("--invert-media", help="Alias of --preview-above/--caption-above.")
|
|
258
|
+
] = False
|
|
259
|
+
rich_markdown: Annotated[
|
|
260
|
+
str | None,
|
|
261
|
+
opt("--rich-markdown", metavar="PATH", kind="path", help="Send a rich body (Markdown)."),
|
|
262
|
+
] = None
|
|
263
|
+
rich_html: Annotated[
|
|
264
|
+
str | None,
|
|
265
|
+
opt("--rich-html", metavar="PATH", kind="path", help="Send a rich body (HTML)."),
|
|
266
|
+
] = None
|
|
267
|
+
screenshot: Annotated[
|
|
268
|
+
bool, opt("--screenshot", help="Send a screenshot-taken service notification.")
|
|
269
|
+
] = False
|
|
270
|
+
suggest: Annotated[bool, opt("--suggest", help="Offer this as a suggested post.")] = False
|
|
271
|
+
price_stars: Annotated[
|
|
272
|
+
int | None, opt("--price-stars", metavar="N", help="Stars asked for the suggested post.")
|
|
273
|
+
] = None
|
|
274
|
+
price_ton: Annotated[
|
|
275
|
+
int | None, opt("--price-ton", metavar="NANO", help="TON asked for the suggested post.")
|
|
276
|
+
] = None
|
|
277
|
+
publish_at: Annotated[
|
|
278
|
+
str | None,
|
|
279
|
+
opt("--publish-at", metavar="TS", kind="datetime", help="Requested publication time."),
|
|
280
|
+
] = None
|
|
281
|
+
wait_slowmode: Annotated[
|
|
282
|
+
bool, opt("--wait-slowmode", help="Wait out slow mode instead of failing.")
|
|
283
|
+
] = False
|
|
284
|
+
random_id: Annotated[
|
|
285
|
+
int | None,
|
|
286
|
+
opt("--random-id", metavar="N", help="Explicit random_id so a retry is deduplicated."),
|
|
287
|
+
] = None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _suggested_post(req: SendReq) -> Any:
|
|
291
|
+
"""`--suggest` as the `SuggestedPost` the send requests carry."""
|
|
292
|
+
if not req.suggest:
|
|
293
|
+
return None
|
|
294
|
+
from telethon.tl import types
|
|
295
|
+
|
|
296
|
+
price: Any = None
|
|
297
|
+
if req.price_stars is not None:
|
|
298
|
+
price = types.StarsAmount(amount=req.price_stars, nanos=0)
|
|
299
|
+
elif req.price_ton is not None:
|
|
300
|
+
price = types.StarsTonAmount(amount=req.price_ton)
|
|
301
|
+
return types.SuggestedPost(price=price, schedule_date=_send.schedule_at(req.publish_at))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def _non_file_media(ctx: OpContext, req: SendReq) -> Any:
|
|
305
|
+
"""The media variants that are described rather than uploaded."""
|
|
306
|
+
from telethon.tl import types
|
|
307
|
+
|
|
308
|
+
if req.dice is not None:
|
|
309
|
+
return types.InputMediaDice(emoticon=req.dice or "🎲")
|
|
310
|
+
if req.location:
|
|
311
|
+
try:
|
|
312
|
+
latitude, _, longitude = req.location.partition(",")
|
|
313
|
+
point = types.InputGeoPoint(lat=float(latitude), long=float(longitude))
|
|
314
|
+
except ValueError as exc:
|
|
315
|
+
raise UsageError("--location wants 'lat,lon'", field="location") from exc
|
|
316
|
+
return types.InputMediaGeoPoint(geo_point=point)
|
|
317
|
+
if req.contact is not None or req.contact_phone:
|
|
318
|
+
phone = req.contact_phone or ""
|
|
319
|
+
first = req.contact_first or ""
|
|
320
|
+
last = req.contact_last or ""
|
|
321
|
+
vcard = ""
|
|
322
|
+
if req.vcard:
|
|
323
|
+
with open(req.vcard, encoding="utf-8") as handle:
|
|
324
|
+
vcard = handle.read()
|
|
325
|
+
if req.contact is not None:
|
|
326
|
+
peer = await _send.resolve(ctx, req.contact)
|
|
327
|
+
user = await _client(ctx).get_entity(peer)
|
|
328
|
+
phone = phone or (getattr(user, "phone", None) or "")
|
|
329
|
+
first = first or (getattr(user, "first_name", None) or "")
|
|
330
|
+
last = last or (getattr(user, "last_name", None) or "")
|
|
331
|
+
if not phone:
|
|
332
|
+
raise UsageError(
|
|
333
|
+
"a contact card needs a phone number; the user hides theirs, so pass "
|
|
334
|
+
"--contact-phone",
|
|
335
|
+
field="contact",
|
|
336
|
+
)
|
|
337
|
+
return types.InputMediaContact(
|
|
338
|
+
phone_number=phone, first_name=first, last_name=last, vcard=vcard
|
|
339
|
+
)
|
|
340
|
+
if req.poll is not None:
|
|
341
|
+
# `--poll JSON` takes the same field names as `poll create`, and is
|
|
342
|
+
# built by the same function: two spellings of "make a poll" that
|
|
343
|
+
# disagreed about defaults would be worse than one shared builder.
|
|
344
|
+
import msgspec
|
|
345
|
+
|
|
346
|
+
from tlgr.ops import poll as poll_ops
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
spec = msgspec.json.decode(req.poll, type=dict)
|
|
350
|
+
except msgspec.DecodeError as exc:
|
|
351
|
+
raise UsageError(f"--poll is not JSON: {exc}", field="poll") from exc
|
|
352
|
+
spec.setdefault("chat", "me")
|
|
353
|
+
spec.setdefault("parse", req.parse)
|
|
354
|
+
try:
|
|
355
|
+
request = msgspec.convert(spec, type=poll_ops.CreateReq, strict=False)
|
|
356
|
+
except msgspec.ValidationError as exc:
|
|
357
|
+
raise UsageError(f"--poll: {exc}", field="poll") from exc
|
|
358
|
+
return await poll_ops.build_media(ctx, request)
|
|
359
|
+
if req.preview_url:
|
|
360
|
+
return types.InputMediaWebPage(
|
|
361
|
+
url=req.preview_url,
|
|
362
|
+
force_large_media=req.preview_large or None,
|
|
363
|
+
force_small_media=req.preview_small or None,
|
|
364
|
+
optional=True,
|
|
365
|
+
)
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
async def _sticker_or_gif(ctx: OpContext, ref: str) -> Any:
|
|
370
|
+
"""A sticker/GIF reference: a file id pair, or a local file."""
|
|
371
|
+
from telethon.tl import types
|
|
372
|
+
|
|
373
|
+
if ":" in ref and all(part.lstrip("-").isdigit() for part in ref.split(":", 1)):
|
|
374
|
+
document_id, _, access_hash = ref.partition(":")
|
|
375
|
+
return types.InputMediaDocument(
|
|
376
|
+
id=types.InputDocument(
|
|
377
|
+
id=int(document_id), access_hash=int(access_hash), file_reference=b""
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
return await _send.input_media(ctx, ref)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def send(ctx: OpContext, req: SendReq) -> Message:
|
|
384
|
+
"""Send anything to a chat, with every send-time option Telegram has.
|
|
385
|
+
|
|
386
|
+
One implementation rather than one per media type, because the options —
|
|
387
|
+
reply target, schedule, silence, effect, protection, paid stars — are
|
|
388
|
+
orthogonal to *what* is being sent and duplicating them per command is
|
|
389
|
+
how they drift apart.
|
|
390
|
+
"""
|
|
391
|
+
from telethon.tl import types
|
|
392
|
+
from telethon.tl.functions import messages as fn
|
|
393
|
+
|
|
394
|
+
if req.rich_markdown or req.rich_html:
|
|
395
|
+
_send.require_supported(
|
|
396
|
+
"--rich-markdown/--rich-html",
|
|
397
|
+
"a rich (long-form) body is layer 229 and the pinned Telethon speaks 227",
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
401
|
+
chat_id = _send.peer_id_of(peer)
|
|
402
|
+
|
|
403
|
+
if req.screenshot:
|
|
404
|
+
result = await _client(ctx)(
|
|
405
|
+
fn.SendScreenshotNotificationRequest(
|
|
406
|
+
peer=peer,
|
|
407
|
+
reply_to=types.InputReplyToMessage(reply_to_msg_id=req.reply_to or 0),
|
|
408
|
+
random_id=req.random_id or _random_id(),
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
return _send.message_from_updates(result, chat_id=chat_id)
|
|
412
|
+
|
|
413
|
+
text, entities = _send.body(req.text, parse=req.parse, entities=req.entities, stdin=req.stdin)
|
|
414
|
+
caption, caption_entities = (
|
|
415
|
+
_send.body(req.caption, parse=req.parse) if req.caption is not None else ("", [])
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if req.comment_to is not None:
|
|
419
|
+
# A comment lives in the linked discussion group, not in the channel.
|
|
420
|
+
discussion = await _client(ctx)(fn.GetDiscussionMessageRequest(peer, req.comment_to))
|
|
421
|
+
found = list(getattr(discussion, "messages", None) or [])
|
|
422
|
+
if not found:
|
|
423
|
+
raise NotFoundError(f"post {req.comment_to} has no discussion thread to comment in")
|
|
424
|
+
peer = await _client(ctx).get_input_entity(found[0].peer_id)
|
|
425
|
+
chat_id = _send.peer_id_of(peer)
|
|
426
|
+
req.reply_to = int(found[0].id)
|
|
427
|
+
|
|
428
|
+
await _send.show_typing(
|
|
429
|
+
ctx, peer, _send.typing_seconds(text, requested=req.typing, auto=req.typing_auto)
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
reply_to = await _send.reply_target(
|
|
433
|
+
ctx,
|
|
434
|
+
reply_to=req.reply_to,
|
|
435
|
+
reply_in=req.reply_in,
|
|
436
|
+
quote=req.quote,
|
|
437
|
+
quote_offset=req.quote_offset,
|
|
438
|
+
quote_parse=req.parse,
|
|
439
|
+
topic=req.topic,
|
|
440
|
+
reply_task=req.reply_task,
|
|
441
|
+
reply_poll_option=req.reply_poll_option,
|
|
442
|
+
reply_to_story=req.reply_to_story,
|
|
443
|
+
story_peer=req.chat,
|
|
444
|
+
direct_to=req.direct_to,
|
|
445
|
+
)
|
|
446
|
+
common: dict[str, Any] = {
|
|
447
|
+
"peer": peer,
|
|
448
|
+
"silent": req.silent or None,
|
|
449
|
+
"clear_draft": req.clear_draft or None,
|
|
450
|
+
"noforwards": req.protect or None,
|
|
451
|
+
"invert_media": (req.invert_media or req.preview_above or req.caption_above) or None,
|
|
452
|
+
"reply_to": reply_to,
|
|
453
|
+
"schedule_date": _send.schedule_at(req.schedule),
|
|
454
|
+
"schedule_repeat_period": _send.repeat_period(req.repeat),
|
|
455
|
+
"send_as": await _send.resolve(ctx, req.send_as) if req.send_as is not None else None,
|
|
456
|
+
"effect": _send.effect_id(req.effect),
|
|
457
|
+
"allow_paid_stars": req.paid_stars,
|
|
458
|
+
"suggested_post": _suggested_post(req),
|
|
459
|
+
}
|
|
460
|
+
if req.quick_reply:
|
|
461
|
+
common["quick_reply_shortcut"] = types.InputQuickReplyShortcut(shortcut=req.quick_reply)
|
|
462
|
+
|
|
463
|
+
media = await _non_file_media(ctx, req)
|
|
464
|
+
files = list(req.file)
|
|
465
|
+
if req.voice:
|
|
466
|
+
files, media = [], await _send.input_media(ctx, req.voice, voice=True)
|
|
467
|
+
elif req.video_note:
|
|
468
|
+
files, media = [], await _send.input_media(ctx, req.video_note, video_note=True)
|
|
469
|
+
elif req.sticker:
|
|
470
|
+
files, media = [], await _sticker_or_gif(ctx, req.sticker)
|
|
471
|
+
elif req.gif:
|
|
472
|
+
files, media = [], await _sticker_or_gif(ctx, req.gif)
|
|
473
|
+
elif req.as_file:
|
|
474
|
+
files, media = [], await _text_as_file(ctx, text, req.filename)
|
|
475
|
+
text, entities = caption or "", caption_entities
|
|
476
|
+
|
|
477
|
+
sent = await _dispatch_send(
|
|
478
|
+
ctx,
|
|
479
|
+
req,
|
|
480
|
+
common,
|
|
481
|
+
peer=peer,
|
|
482
|
+
chat_id=chat_id,
|
|
483
|
+
text=text,
|
|
484
|
+
entities=entities,
|
|
485
|
+
caption=caption,
|
|
486
|
+
caption_entities=caption_entities,
|
|
487
|
+
media=media,
|
|
488
|
+
files=files,
|
|
489
|
+
)
|
|
490
|
+
ctx.emit("message_out", {"chat_id": chat_id, "id": sent.id, "text": sent.text})
|
|
491
|
+
return sent
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def _dispatch_send(
|
|
495
|
+
ctx: OpContext,
|
|
496
|
+
req: SendReq,
|
|
497
|
+
common: dict[str, Any],
|
|
498
|
+
*,
|
|
499
|
+
peer: Any,
|
|
500
|
+
chat_id: int,
|
|
501
|
+
text: str,
|
|
502
|
+
entities: list[MessageEntity],
|
|
503
|
+
caption: str,
|
|
504
|
+
caption_entities: list[MessageEntity],
|
|
505
|
+
media: Any,
|
|
506
|
+
files: list[str],
|
|
507
|
+
) -> Message:
|
|
508
|
+
"""Pick the request the send actually needs, and run it."""
|
|
509
|
+
from telethon.tl import types
|
|
510
|
+
from telethon.tl.functions import messages as fn
|
|
511
|
+
|
|
512
|
+
client = _client(ctx)
|
|
513
|
+
|
|
514
|
+
if len(files) > 1:
|
|
515
|
+
singles = []
|
|
516
|
+
for index, source in enumerate(files):
|
|
517
|
+
item = await _send.input_media(ctx, source, spoiler=req.spoiler, ttl=req.ttl)
|
|
518
|
+
uploaded = await client(fn.UploadMediaRequest(peer=peer, media=item))
|
|
519
|
+
singles.append(
|
|
520
|
+
types.InputSingleMedia(
|
|
521
|
+
media=_uploaded_to_input(uploaded),
|
|
522
|
+
random_id=_random_id(),
|
|
523
|
+
message=caption if index == 0 else "",
|
|
524
|
+
entities=_send.tl_entities(caption_entities) if index == 0 else None,
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
result = await client(
|
|
528
|
+
fn.SendMultiMediaRequest(multi_media=singles, **_only(common, fn.SendMultiMediaRequest))
|
|
529
|
+
)
|
|
530
|
+
produced = _send.messages_from_updates(result, chat_id=chat_id)
|
|
531
|
+
first = produced[0] if produced else _send.message_from_updates(result, chat_id=chat_id)
|
|
532
|
+
first.batch = [m.id for m in produced[1:]]
|
|
533
|
+
return first
|
|
534
|
+
|
|
535
|
+
if files:
|
|
536
|
+
media = await _send.input_media(ctx, files[0], spoiler=req.spoiler, ttl=req.ttl)
|
|
537
|
+
|
|
538
|
+
if media is not None:
|
|
539
|
+
result = await client(
|
|
540
|
+
fn.SendMediaRequest(
|
|
541
|
+
media=media,
|
|
542
|
+
message=caption or text,
|
|
543
|
+
entities=_send.tl_entities(caption_entities or entities),
|
|
544
|
+
random_id=req.random_id or _random_id(),
|
|
545
|
+
**_only(common, fn.SendMediaRequest),
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
return _send.message_from_updates(result, chat_id=chat_id, sent_text=caption or text)
|
|
549
|
+
|
|
550
|
+
parts = _send.split_text(text, entities, req.split_at) if req.split else [(text, entities)]
|
|
551
|
+
if not req.split and utf16_len(text) > _send.MAX_TEXT_UTF16:
|
|
552
|
+
raise UsageError(
|
|
553
|
+
f"the text is {utf16_len(text)} UTF-16 units and Telegram accepts "
|
|
554
|
+
f"{_send.MAX_TEXT_UTF16}; pass --split to send it as several messages",
|
|
555
|
+
field="text",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
produced = []
|
|
559
|
+
for index, (chunk, runs) in enumerate(parts):
|
|
560
|
+
result = await client(
|
|
561
|
+
fn.SendMessageRequest(
|
|
562
|
+
message=chunk,
|
|
563
|
+
entities=_send.tl_entities(runs),
|
|
564
|
+
no_webpage=req.no_preview or None,
|
|
565
|
+
random_id=(req.random_id or _random_id()) + index,
|
|
566
|
+
**_only(common, fn.SendMessageRequest),
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
produced.append(_send.message_from_updates(result, chat_id=chat_id, sent_text=chunk))
|
|
570
|
+
if index + 1 < len(parts):
|
|
571
|
+
limiter = getattr(ctx, "limiter", None)
|
|
572
|
+
if limiter is not None:
|
|
573
|
+
await limiter.acquire("send")
|
|
574
|
+
first = produced[0]
|
|
575
|
+
first.batch = [m.id for m in produced[1:]]
|
|
576
|
+
return first
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _uploaded_to_input(uploaded: Any) -> Any:
|
|
580
|
+
"""`messages.uploadMedia` result → the `InputMedia` a multi-send wants."""
|
|
581
|
+
from telethon import utils
|
|
582
|
+
|
|
583
|
+
return utils.get_input_media(uploaded)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
async def _text_as_file(ctx: OpContext, text: str, name: str | None) -> Any:
|
|
587
|
+
"""`--as-file`: the message body as a `.txt` document."""
|
|
588
|
+
import tempfile
|
|
589
|
+
from pathlib import Path
|
|
590
|
+
|
|
591
|
+
directory = Path(tempfile.mkdtemp(prefix="tlgr-txt-"))
|
|
592
|
+
path = directory / (name or "message.txt")
|
|
593
|
+
path.write_text(text, encoding="utf-8")
|
|
594
|
+
return await _send.input_media(ctx, str(path), force_file=True, file_name=path.name)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
SPEC_SEND = OperationSpec(
|
|
598
|
+
id="message.send",
|
|
599
|
+
request=SendReq,
|
|
600
|
+
response=Message,
|
|
601
|
+
impl=send,
|
|
602
|
+
summary="Send a message to a chat",
|
|
603
|
+
description=(
|
|
604
|
+
"The universal composer: text, a file, an album, a sticker, a voice "
|
|
605
|
+
"note, a dice, a contact card or a location, with every send-time "
|
|
606
|
+
"option Telegram exposes. Text longer than 4096 UTF-16 units is "
|
|
607
|
+
"refused unless --split is given, because silently truncating a "
|
|
608
|
+
"message is worse than not sending it."
|
|
609
|
+
),
|
|
610
|
+
aliases=("send", "msg.send"),
|
|
611
|
+
legacy_paths=("message send", "msg send", "send"),
|
|
612
|
+
mutating=True,
|
|
613
|
+
rate_class="send",
|
|
614
|
+
timeout_s=300,
|
|
615
|
+
columns=("id", "chat_id", "date", "text"),
|
|
616
|
+
example=_EXAMPLE_MESSAGE,
|
|
617
|
+
example_args='message send @alice "on my way"',
|
|
618
|
+
covers=(
|
|
619
|
+
"bots.button-styles-icons",
|
|
620
|
+
"bots.paid-broadcast-floodskip",
|
|
621
|
+
"bots.paid-message-to-bot",
|
|
622
|
+
"bots.reply-keyboard-hide-force",
|
|
623
|
+
"bots.send-with-keyboard",
|
|
624
|
+
"contact.send-card",
|
|
625
|
+
"dialogs.draft-send",
|
|
626
|
+
"dice.send",
|
|
627
|
+
"effect.send",
|
|
628
|
+
"media.link-preview",
|
|
629
|
+
"messages-core.comments-post",
|
|
630
|
+
"messages-core.format-basic-styles",
|
|
631
|
+
"messages-core.format-blockquote",
|
|
632
|
+
"messages-core.format-code-and-pre",
|
|
633
|
+
"messages-core.format-custom-emoji",
|
|
634
|
+
"messages-core.format-expandable-blockquote",
|
|
635
|
+
"messages-core.format-formatted-date-entity",
|
|
636
|
+
"messages-core.format-hashtag-cashtag",
|
|
637
|
+
"messages-core.format-html-parse-mode",
|
|
638
|
+
"messages-core.format-markdown-parse-mode",
|
|
639
|
+
"messages-core.format-mention",
|
|
640
|
+
"messages-core.format-spoiler",
|
|
641
|
+
"messages-core.format-text-link",
|
|
642
|
+
"messages-core.link-preview-above-text",
|
|
643
|
+
"messages-core.link-preview-choose-url-and-size",
|
|
644
|
+
"messages-core.link-preview-disable",
|
|
645
|
+
"messages-core.reply-in-another-chat",
|
|
646
|
+
"messages-core.reply-to-checklist-task-or-poll-option",
|
|
647
|
+
"messages-core.reply-to-story",
|
|
648
|
+
"messages-core.reply-with-quote",
|
|
649
|
+
"messages-core.saved-messages-send",
|
|
650
|
+
"messages-core.saved-reminder",
|
|
651
|
+
"messages-core.screenshot-notification",
|
|
652
|
+
"messages-core.send-as-peer",
|
|
653
|
+
"messages-core.send-clear-draft",
|
|
654
|
+
"messages-core.send-in-direct-channel-messages",
|
|
655
|
+
"messages-core.send-long-text-split",
|
|
656
|
+
"messages-core.send-paid-message",
|
|
657
|
+
"messages-core.send-preflight-restrictions",
|
|
658
|
+
"messages-core.send-protected-content-message",
|
|
659
|
+
"messages-core.send-reply",
|
|
660
|
+
"messages-core.send-scheduled",
|
|
661
|
+
"messages-core.send-scheduled-repeating",
|
|
662
|
+
"messages-core.send-silent",
|
|
663
|
+
"messages-core.send-text",
|
|
664
|
+
"messages-core.send-text-as-file",
|
|
665
|
+
"messages-core.send-to-topic",
|
|
666
|
+
"messages-core.send-when-online",
|
|
667
|
+
"messages-core.send-with-effect",
|
|
668
|
+
"messages-core.suggest-post",
|
|
669
|
+
"poll.reply-to-option",
|
|
670
|
+
"todo.reply-to-task",
|
|
671
|
+
"updates.invoke-after-msg",
|
|
672
|
+
"updates.sync-random-id-dedup",
|
|
673
|
+
"webpage.send-as-media",
|
|
674
|
+
),
|
|
675
|
+
covers_partial=("messages-core.send-rich-message", "richmsg.send"),
|
|
676
|
+
coverage_note=(
|
|
677
|
+
"A layer-229 rich body is refused with NOT_SUPPORTED: the pinned "
|
|
678
|
+
"Telethon speaks layer 227 and cannot serialise inputRichMessage*."
|
|
679
|
+
),
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# ---------------------------------------------------------------------------
|
|
684
|
+
# message list / search / thread list
|
|
685
|
+
# ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
class ListReq(Request):
|
|
689
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to read.")]
|
|
690
|
+
since: Annotated[
|
|
691
|
+
str | None,
|
|
692
|
+
opt("--since", metavar="TS", kind="datetime", help="Only messages after this time."),
|
|
693
|
+
] = None
|
|
694
|
+
until: Annotated[
|
|
695
|
+
str | None,
|
|
696
|
+
opt("--until", metavar="TS", kind="datetime", help="Only messages before this time."),
|
|
697
|
+
] = None
|
|
698
|
+
before_id: Annotated[
|
|
699
|
+
int | None,
|
|
700
|
+
opt("--before-id", metavar="ID", kind="msg_id", help="Older than this id (offset_id)."),
|
|
701
|
+
] = None
|
|
702
|
+
after_id: Annotated[
|
|
703
|
+
int | None,
|
|
704
|
+
opt("--after-id", metavar="ID", kind="msg_id", help="Newer than this id (min_id)."),
|
|
705
|
+
] = None
|
|
706
|
+
around: Annotated[
|
|
707
|
+
int | None,
|
|
708
|
+
opt("--around", metavar="ID", kind="msg_id", help="Centre the page on this id."),
|
|
709
|
+
] = None
|
|
710
|
+
ids: Annotated[
|
|
711
|
+
list[str], opt("--ids", metavar="ID", help="Fetch exactly these ids or ranges.")
|
|
712
|
+
] = []
|
|
713
|
+
date: Annotated[
|
|
714
|
+
str | None,
|
|
715
|
+
opt("--date", metavar="DATE", kind="datetime", help="Jump to the first message of a date."),
|
|
716
|
+
] = None
|
|
717
|
+
reverse: Annotated[bool, opt("--reverse", help="Oldest first.")] = False
|
|
718
|
+
topic: Annotated[
|
|
719
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Only this topic / thread.")
|
|
720
|
+
] = None
|
|
721
|
+
type: Annotated[
|
|
722
|
+
str | None, opt("--type", metavar="FILTER", help="Media filter; 'pinned' is the pin list.")
|
|
723
|
+
] = None
|
|
724
|
+
unread: Annotated[bool, opt("--unread", help="Only messages after the unread divider.")] = False
|
|
725
|
+
scheduled: Annotated[bool, opt("--scheduled", help="List the scheduled drawer.")] = False
|
|
726
|
+
saved_peer: Annotated[
|
|
727
|
+
PeerRef | None,
|
|
728
|
+
opt("--saved-peer", metavar="CHAT", kind="peer", help="In Saved Messages: one origin."),
|
|
729
|
+
] = None
|
|
730
|
+
personal_channel: Annotated[
|
|
731
|
+
PeerRef | None,
|
|
732
|
+
opt("--personal-channel", metavar="USER", kind="user", help="A profile's channel posts."),
|
|
733
|
+
] = None
|
|
734
|
+
delivery: Annotated[
|
|
735
|
+
bool, opt("--delivery", help="Annotate own messages with sent/delivered/read state.")
|
|
736
|
+
] = False
|
|
737
|
+
sender: Annotated[bool, opt("--sender", help="Include sender info and admin rank.")] = False
|
|
738
|
+
media: Annotated[bool, opt("--media", help="Include media metadata.")] = True
|
|
739
|
+
reactions: Annotated[bool, opt("--reactions", help="Include reactions.")] = True
|
|
740
|
+
entities: Annotated[bool, opt("--entities", help="Include entities.")] = True
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
async def _history_state(
|
|
744
|
+
ctx: OpContext, req: ListReq, limit: int, state: dict[str, Any]
|
|
745
|
+
) -> dict[str, Any]:
|
|
746
|
+
"""`offset_id`/`add_offset` for the page being asked for.
|
|
747
|
+
|
|
748
|
+
`offset_id + add_offset` is Telegram's universal history cursor: older is
|
|
749
|
+
`add_offset = 0`, newer is `-limit`, and centred is `-limit // 2`. Naming
|
|
750
|
+
the three cases here is what keeps `--around` from being a second,
|
|
751
|
+
subtly-different pagination.
|
|
752
|
+
"""
|
|
753
|
+
if state:
|
|
754
|
+
return dict(state)
|
|
755
|
+
if req.around is not None:
|
|
756
|
+
return {"offset_id": req.around, "add_offset": -(limit // 2)}
|
|
757
|
+
if req.after_id is not None:
|
|
758
|
+
return {"offset_id": 0, "add_offset": 0, "min_id": req.after_id}
|
|
759
|
+
return {"offset_id": req.before_id or 0, "add_offset": 0}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
async def list_messages(ctx: OpContext, req: ListReq) -> Page[Message]:
|
|
763
|
+
"""Read chat history, one signed page at a time."""
|
|
764
|
+
limit, state = _window(ctx, "message.list", PageKind.HISTORY)
|
|
765
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
766
|
+
chat_id = _send.peer_id_of(peer)
|
|
767
|
+
|
|
768
|
+
if req.personal_channel is not None:
|
|
769
|
+
return await _personal_channel(ctx, req, limit)
|
|
770
|
+
|
|
771
|
+
explicit = _ids(tuple(req.ids)) if req.ids else []
|
|
772
|
+
if explicit:
|
|
773
|
+
items = await _fetch(ctx, peer, chat_id=chat_id, limit=len(explicit), ids=explicit)
|
|
774
|
+
return Page(items=[m for m in items if m.id], has_more=False, total=len(items))
|
|
775
|
+
|
|
776
|
+
offsets = await _history_state(ctx, req, limit, state)
|
|
777
|
+
kwargs: dict[str, Any] = {
|
|
778
|
+
"offset_id": int(offsets.get("offset_id", 0)),
|
|
779
|
+
"add_offset": int(offsets.get("add_offset", 0)),
|
|
780
|
+
"reverse": req.reverse,
|
|
781
|
+
"scheduled": req.scheduled,
|
|
782
|
+
}
|
|
783
|
+
if offsets.get("min_id"):
|
|
784
|
+
kwargs["min_id"] = int(offsets["min_id"])
|
|
785
|
+
if req.topic is not None:
|
|
786
|
+
kwargs["reply_to"] = req.topic
|
|
787
|
+
if req.type:
|
|
788
|
+
kwargs["filter"] = _filter(req.type)
|
|
789
|
+
when = req.date or req.until
|
|
790
|
+
if when:
|
|
791
|
+
kwargs["offset_date"] = parse_dt(when)
|
|
792
|
+
if req.unread:
|
|
793
|
+
kwargs["min_id"] = await _read_inbox_max(ctx, peer)
|
|
794
|
+
if req.saved_peer is not None:
|
|
795
|
+
# Saved Messages keeps one sub-dialog per origin; Telethon reaches it
|
|
796
|
+
# through the same reply_to slot the forum topics use.
|
|
797
|
+
kwargs["reply_to"] = _send.peer_id_of(await _send.resolve(ctx, req.saved_peer))
|
|
798
|
+
|
|
799
|
+
items = await _fetch(ctx, peer, chat_id=chat_id, limit=limit, **kwargs)
|
|
800
|
+
if req.since:
|
|
801
|
+
floor = parse_dt(req.since)
|
|
802
|
+
cutoff = to_unix(floor) or 0
|
|
803
|
+
items = [m for m in items if m.date_unix >= cutoff]
|
|
804
|
+
if req.scheduled:
|
|
805
|
+
for message in items:
|
|
806
|
+
message.scheduled = True
|
|
807
|
+
if req.delivery:
|
|
808
|
+
await _annotate_delivery(ctx, peer, items)
|
|
809
|
+
if req.sender:
|
|
810
|
+
await _attach_senders(ctx, items)
|
|
811
|
+
_project(items, media=req.media, reactions=req.reactions, entities=req.entities)
|
|
812
|
+
|
|
813
|
+
next_state = {"offset_id": items[-1].id, "add_offset": 0} if items else {}
|
|
814
|
+
return build_page(
|
|
815
|
+
items,
|
|
816
|
+
op="message.list",
|
|
817
|
+
kind=PageKind.HISTORY,
|
|
818
|
+
state=next_state,
|
|
819
|
+
account=ctx.account,
|
|
820
|
+
limit=limit,
|
|
821
|
+
has_more=None if items else False,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _project(items: list[Message], *, media: bool, reactions: bool, entities: bool) -> None:
|
|
826
|
+
"""Drop the parts the caller said it did not want.
|
|
827
|
+
|
|
828
|
+
The defaults are *on*: a caller cannot opt into a field it does not know
|
|
829
|
+
exists, and "did we already react" is not an optional detail for anything
|
|
830
|
+
that reacts (that was v1's `include_reactions` bug).
|
|
831
|
+
"""
|
|
832
|
+
for message in items:
|
|
833
|
+
if not media:
|
|
834
|
+
message.media = None
|
|
835
|
+
if not reactions:
|
|
836
|
+
message.reactions = None
|
|
837
|
+
if not entities:
|
|
838
|
+
message.entities = []
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
async def _read_inbox_max(ctx: OpContext, peer: Any) -> int:
|
|
842
|
+
"""`read_inbox_max_id` — where the unread divider sits."""
|
|
843
|
+
from telethon.tl import types
|
|
844
|
+
from telethon.tl.functions import messages as fn
|
|
845
|
+
|
|
846
|
+
result = await _client(ctx)(fn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer)]))
|
|
847
|
+
for dialog in getattr(result, "dialogs", None) or []:
|
|
848
|
+
return int(getattr(dialog, "read_inbox_max_id", 0) or 0)
|
|
849
|
+
return 0
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
async def _annotate_delivery(ctx: OpContext, peer: Any, items: list[Message]) -> None:
|
|
853
|
+
"""`--delivery`: sent / delivered / read, for our own messages.
|
|
854
|
+
|
|
855
|
+
This is a *dialog* field (`read_outbox_max_id`), not a message field, so
|
|
856
|
+
it is fetched once and applied rather than guessed from the message.
|
|
857
|
+
"""
|
|
858
|
+
from telethon.tl import types
|
|
859
|
+
from telethon.tl.functions import messages as fn
|
|
860
|
+
|
|
861
|
+
result = await _client(ctx)(fn.GetPeerDialogsRequest(peers=[types.InputDialogPeer(peer)]))
|
|
862
|
+
outbox = 0
|
|
863
|
+
for dialog in getattr(result, "dialogs", None) or []:
|
|
864
|
+
outbox = int(getattr(dialog, "read_outbox_max_id", 0) or 0)
|
|
865
|
+
for message in items:
|
|
866
|
+
if message.out:
|
|
867
|
+
message.delivery = "read" if message.id <= outbox else "sent"
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
async def _attach_senders(ctx: OpContext, items: list[Message]) -> None:
|
|
871
|
+
"""Resolve each distinct sender once and hang it off every message."""
|
|
872
|
+
client = _client(ctx)
|
|
873
|
+
cache: dict[int, Any] = {}
|
|
874
|
+
for message in items:
|
|
875
|
+
sender_id = message.sender_id
|
|
876
|
+
if sender_id is None:
|
|
877
|
+
continue
|
|
878
|
+
if sender_id not in cache:
|
|
879
|
+
try:
|
|
880
|
+
cache[sender_id] = entity_to_peer(await client.get_entity(sender_id))
|
|
881
|
+
except Exception:
|
|
882
|
+
cache[sender_id] = None
|
|
883
|
+
message.sender = cache[sender_id]
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
async def _personal_channel(ctx: OpContext, req: ListReq, limit: int) -> Page[Message]:
|
|
887
|
+
"""The channel posts a profile shows, which are not in any dialog."""
|
|
888
|
+
from telethon.tl.functions import messages as fn
|
|
889
|
+
|
|
890
|
+
user = await _send.resolve(ctx, req.personal_channel)
|
|
891
|
+
result = await _client(ctx)(fn.GetPersonalChannelHistoryRequest(peer=user, limit=limit))
|
|
892
|
+
raw = list(getattr(result, "messages", None) or [])
|
|
893
|
+
items = [message_to_model(message) for message in raw]
|
|
894
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
SPEC_LIST = OperationSpec(
|
|
898
|
+
id="message.list",
|
|
899
|
+
request=ListReq,
|
|
900
|
+
response=Page[Message],
|
|
901
|
+
impl=list_messages,
|
|
902
|
+
summary="Read a chat's message history",
|
|
903
|
+
description=(
|
|
904
|
+
"Paginated history with the offsets Telegram actually has: older than "
|
|
905
|
+
"an id (--before-id), newer than an id (--after-id), centred on one "
|
|
906
|
+
"(--around), from a date (--date), only the unread tail (--unread), "
|
|
907
|
+
"the scheduled drawer (--scheduled), or one media type (--type)."
|
|
908
|
+
),
|
|
909
|
+
aliases=("msg.list",),
|
|
910
|
+
legacy_paths=("message list", "msg list"),
|
|
911
|
+
paginated=PageKind.HISTORY,
|
|
912
|
+
columns=("id", "date", "text"),
|
|
913
|
+
headers=("ID", "Date", "Text"),
|
|
914
|
+
example={"items": [_EXAMPLE_MESSAGE], "has_more": False},
|
|
915
|
+
example_args="message list @alice",
|
|
916
|
+
covers=(
|
|
917
|
+
"bots.payment-service-messages",
|
|
918
|
+
"groupcall.service-messages",
|
|
919
|
+
"groups-channels-admin.monoforum-topic-history",
|
|
920
|
+
"messages-core.history-jump-to-date",
|
|
921
|
+
"messages-core.history-list",
|
|
922
|
+
"messages-core.history-unread-separator",
|
|
923
|
+
"messages-core.message-delivery-status",
|
|
924
|
+
"messages-core.message-jump-to",
|
|
925
|
+
"messages-core.message-sender-rank",
|
|
926
|
+
"messages-core.message-view-in-topic",
|
|
927
|
+
"messages-core.personal-channel-history",
|
|
928
|
+
"messages-core.pinned-list",
|
|
929
|
+
"messages-core.saved-dialog-history",
|
|
930
|
+
"messages-core.scheduled-list",
|
|
931
|
+
"stories.story-mention",
|
|
932
|
+
),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
class SearchReq(Request):
|
|
937
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to search.")]
|
|
938
|
+
query: Annotated[str, arg(1, metavar="QUERY", required=False, help="Text to find.")] = ""
|
|
939
|
+
from_user: Annotated[
|
|
940
|
+
PeerRef | None,
|
|
941
|
+
opt("--from", metavar="USER", kind="user", help="Only from this sender."),
|
|
942
|
+
] = None
|
|
943
|
+
type: Annotated[str | None, opt("--type", metavar="FILTER", help="Media filter.")] = None
|
|
944
|
+
since: Annotated[
|
|
945
|
+
str | None, opt("--since", metavar="TS", kind="datetime", help="Only after this time.")
|
|
946
|
+
] = None
|
|
947
|
+
until: Annotated[
|
|
948
|
+
str | None, opt("--until", metavar="TS", kind="datetime", help="Only before this time.")
|
|
949
|
+
] = None
|
|
950
|
+
topic: Annotated[
|
|
951
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Restrict to a topic.")
|
|
952
|
+
] = None
|
|
953
|
+
min_id: Annotated[int | None, opt("--min-id", metavar="ID", help="Only ids above this.")] = None
|
|
954
|
+
max_id: Annotated[int | None, opt("--max-id", metavar="ID", help="Only ids below this.")] = None
|
|
955
|
+
reverse: Annotated[bool, opt("--reverse", help="Oldest first.")] = False
|
|
956
|
+
ids: Annotated[list[str], opt("--ids", metavar="ID", help="Restrict to these ids.")] = []
|
|
957
|
+
saved_peer: Annotated[
|
|
958
|
+
PeerRef | None,
|
|
959
|
+
opt("--saved-peer", metavar="CHAT", kind="peer", help="One Saved-Messages dialog."),
|
|
960
|
+
] = None
|
|
961
|
+
tag: Annotated[
|
|
962
|
+
list[str], opt("--tag", metavar="EMOJI", help="Saved-Messages reaction tag (Premium).")
|
|
963
|
+
] = []
|
|
964
|
+
hashtag: Annotated[
|
|
965
|
+
str | None, opt("--hashtag", metavar="TAG", help="Shorthand for '#tag'.")
|
|
966
|
+
] = None
|
|
967
|
+
count: Annotated[
|
|
968
|
+
bool, opt("--count", help="Return per-filter counters instead of messages.")
|
|
969
|
+
] = False
|
|
970
|
+
calendar: Annotated[bool, opt("--calendar", help="Return the message calendar.")] = False
|
|
971
|
+
position: Annotated[
|
|
972
|
+
int | None, opt("--position", metavar="ID", help="Where this id sits in the filtered set.")
|
|
973
|
+
] = None
|
|
974
|
+
regex: Annotated[
|
|
975
|
+
str | None, opt("--regex", metavar="PATTERN", help="Client-side regex over the scan.")
|
|
976
|
+
] = None
|
|
977
|
+
scan: Annotated[
|
|
978
|
+
int, opt("--scan", metavar="N", help="How many messages --regex may scan.", ge=1)
|
|
979
|
+
] = 1000
|
|
980
|
+
scheduled: Annotated[bool, opt("--scheduled", help="Search the scheduled drawer.")] = False
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
async def search(ctx: OpContext, req: SearchReq) -> Page[Message]:
|
|
984
|
+
"""Search one chat with every filter `messages.search` accepts.
|
|
985
|
+
|
|
986
|
+
`--regex` is deliberately local and bounded: Telegram has no regex search,
|
|
987
|
+
so tlgr scans a window and says how far it got rather than pretending the
|
|
988
|
+
result is exhaustive.
|
|
989
|
+
"""
|
|
990
|
+
limit, state = _window(ctx, "message.search", PageKind.SEARCH)
|
|
991
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
992
|
+
chat_id = _send.peer_id_of(peer)
|
|
993
|
+
query = req.query or (f"#{req.hashtag.lstrip('#')}" if req.hashtag else "")
|
|
994
|
+
|
|
995
|
+
if req.count or req.calendar or req.position is not None:
|
|
996
|
+
return await _search_meta(ctx, req, peer, chat_id, query)
|
|
997
|
+
|
|
998
|
+
kwargs: dict[str, Any] = {
|
|
999
|
+
"search": query or None,
|
|
1000
|
+
"reverse": req.reverse,
|
|
1001
|
+
"scheduled": req.scheduled,
|
|
1002
|
+
"offset_id": int(state.get("offset_id", 0)),
|
|
1003
|
+
"add_offset": int(state.get("add_offset", 0)),
|
|
1004
|
+
}
|
|
1005
|
+
if req.from_user is not None:
|
|
1006
|
+
kwargs["from_user"] = await _send.resolve(ctx, req.from_user)
|
|
1007
|
+
if req.type:
|
|
1008
|
+
kwargs["filter"] = _filter(req.type)
|
|
1009
|
+
if req.until:
|
|
1010
|
+
kwargs["offset_date"] = parse_dt(req.until)
|
|
1011
|
+
if req.min_id is not None:
|
|
1012
|
+
kwargs["min_id"] = req.min_id
|
|
1013
|
+
if req.max_id is not None:
|
|
1014
|
+
kwargs["max_id"] = req.max_id
|
|
1015
|
+
if req.topic is not None:
|
|
1016
|
+
kwargs["reply_to"] = req.topic
|
|
1017
|
+
if req.ids:
|
|
1018
|
+
kwargs["ids"] = _ids(tuple(req.ids))
|
|
1019
|
+
if req.tag:
|
|
1020
|
+
ctx.warn("--tag needs Premium and a Saved-Messages peer; it is passed through as given")
|
|
1021
|
+
|
|
1022
|
+
scan = req.scan if req.regex else limit
|
|
1023
|
+
items = await _fetch(ctx, peer, chat_id=chat_id, limit=scan, **kwargs)
|
|
1024
|
+
if req.since:
|
|
1025
|
+
cutoff = to_unix(parse_dt(req.since)) or 0
|
|
1026
|
+
items = [m for m in items if m.date_unix >= cutoff]
|
|
1027
|
+
if req.regex:
|
|
1028
|
+
items = _regex_filter(items, req.regex, limit)
|
|
1029
|
+
ctx.warn(f"--regex is a local scan of at most {req.scan} messages, not a server search")
|
|
1030
|
+
|
|
1031
|
+
next_state = {"offset_id": items[-1].id, "add_offset": 0} if items else {}
|
|
1032
|
+
return build_page(
|
|
1033
|
+
items[:limit],
|
|
1034
|
+
op="message.search",
|
|
1035
|
+
kind=PageKind.SEARCH,
|
|
1036
|
+
state=next_state,
|
|
1037
|
+
account=ctx.account,
|
|
1038
|
+
limit=limit,
|
|
1039
|
+
has_more=None if items else False,
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _regex_filter(items: list[Message], pattern: str, limit: int) -> list[Message]:
|
|
1044
|
+
import re
|
|
1045
|
+
|
|
1046
|
+
try:
|
|
1047
|
+
compiled = re.compile(pattern)
|
|
1048
|
+
except re.error as exc:
|
|
1049
|
+
raise UsageError(f"--regex: {exc}", field="regex") from exc
|
|
1050
|
+
return [m for m in items if compiled.search(m.text)][:limit]
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
async def _search_meta(
|
|
1054
|
+
ctx: OpContext, req: SearchReq, peer: Any, chat_id: int, query: str
|
|
1055
|
+
) -> Page[Message]:
|
|
1056
|
+
"""`--count` / `--calendar` / `--position` return their own shapes.
|
|
1057
|
+
|
|
1058
|
+
They are folded into `message search` rather than given their own verbs
|
|
1059
|
+
because they answer questions *about the same filtered set*, and a caller
|
|
1060
|
+
should not have to restate ten filters to ask "how many?".
|
|
1061
|
+
"""
|
|
1062
|
+
from telethon.tl.functions import messages as fn
|
|
1063
|
+
|
|
1064
|
+
client = _client(ctx)
|
|
1065
|
+
if req.count:
|
|
1066
|
+
filters = [_filter(name) for name in ([req.type] if req.type else list(FILTERS))]
|
|
1067
|
+
result = await client(fn.GetSearchCountersRequest(peer=peer, filters=filters))
|
|
1068
|
+
counters = {
|
|
1069
|
+
type(getattr(entry, "filter", None)).__name__: int(getattr(entry, "count", 0) or 0)
|
|
1070
|
+
for entry in (result or [])
|
|
1071
|
+
}
|
|
1072
|
+
ctx.warn(f"counters: {counters}")
|
|
1073
|
+
return Page(items=[], has_more=False, total=sum(counters.values()))
|
|
1074
|
+
if req.calendar:
|
|
1075
|
+
result = await client(
|
|
1076
|
+
fn.GetSearchResultsCalendarRequest(
|
|
1077
|
+
peer=peer, filter=_filter(req.type or "photo"), offset_id=0, offset_date=None
|
|
1078
|
+
)
|
|
1079
|
+
)
|
|
1080
|
+
items = [
|
|
1081
|
+
message_to_model(message, chat_id=chat_id)
|
|
1082
|
+
for message in (getattr(result, "messages", None) or [])
|
|
1083
|
+
]
|
|
1084
|
+
return Page(items=items, has_more=False, total=getattr(result, "count", None))
|
|
1085
|
+
result = await client(
|
|
1086
|
+
fn.GetSearchResultsPositionsRequest(
|
|
1087
|
+
peer=peer, filter=_filter(req.type or "photo"), offset_id=req.position or 0, limit=1
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
ctx.warn(f"position {req.position} of {getattr(result, 'count', '?')} in the filtered set")
|
|
1091
|
+
return Page(items=[], has_more=False, total=getattr(result, "count", None))
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
SPEC_SEARCH = OperationSpec(
|
|
1095
|
+
id="message.search",
|
|
1096
|
+
request=SearchReq,
|
|
1097
|
+
response=Page[Message],
|
|
1098
|
+
impl=search,
|
|
1099
|
+
summary="Search messages inside one chat",
|
|
1100
|
+
description=(
|
|
1101
|
+
"Every filter `messages.search` has: text, sender, media type, date "
|
|
1102
|
+
"range, topic, saved dialog and reaction tag. --regex is a bounded "
|
|
1103
|
+
"local scan and says so, because Telegram has no regex search."
|
|
1104
|
+
),
|
|
1105
|
+
aliases=("msg.search",),
|
|
1106
|
+
legacy_paths=("message search", "msg search"),
|
|
1107
|
+
paginated=PageKind.SEARCH,
|
|
1108
|
+
columns=("id", "date", "text"),
|
|
1109
|
+
headers=("ID", "Date", "Text"),
|
|
1110
|
+
example={"items": [_EXAMPLE_MESSAGE], "has_more": False},
|
|
1111
|
+
example_args='message search @alice "invoice"',
|
|
1112
|
+
covers=(
|
|
1113
|
+
"messages-core.message-position-in-filter",
|
|
1114
|
+
"messages-core.saved-tags-search",
|
|
1115
|
+
"messages-core.search-calendar-positions",
|
|
1116
|
+
"messages-core.search-counters",
|
|
1117
|
+
"messages-core.search-date-range",
|
|
1118
|
+
"messages-core.search-filter-media-type",
|
|
1119
|
+
"messages-core.search-calls-log",
|
|
1120
|
+
"messages-core.search-from-user",
|
|
1121
|
+
"messages-core.search-hashtag-in-chat",
|
|
1122
|
+
"messages-core.search-in-chat-text",
|
|
1123
|
+
"messages-core.search-in-saved-dialog",
|
|
1124
|
+
"messages-core.search-in-topic",
|
|
1125
|
+
"messages-core.search-local-regex",
|
|
1126
|
+
"poll.search",
|
|
1127
|
+
"reaction.search-by-tag",
|
|
1128
|
+
),
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
class GetReq(Request):
|
|
1133
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat the message is in.")]
|
|
1134
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
|
|
1135
|
+
full: Annotated[bool, opt("--full", help="Include restriction reasons and raw media.")] = False
|
|
1136
|
+
with_reply: Annotated[
|
|
1137
|
+
bool, opt("--with-reply", help="Also resolve the replied-to message.")
|
|
1138
|
+
] = False
|
|
1139
|
+
context: Annotated[
|
|
1140
|
+
int | None, opt("--context", metavar="N", help="Also return N messages around it.")
|
|
1141
|
+
] = None
|
|
1142
|
+
format: Annotated[
|
|
1143
|
+
str | None, choice("md", "html", "text", "json", help="Render the text with its entities.")
|
|
1144
|
+
] = None
|
|
1145
|
+
rich: Annotated[bool, opt("--rich", help="Fetch the layer-229 rich body.")] = False
|
|
1146
|
+
vcard_out: Annotated[
|
|
1147
|
+
str | None,
|
|
1148
|
+
opt("--vcard-out", metavar="PATH", kind="path", help="Write an attached contact card."),
|
|
1149
|
+
] = None
|
|
1150
|
+
raw: Annotated[bool, opt("--raw", help="Include the raw TL object.")] = False
|
|
1151
|
+
scheduled: Annotated[bool, opt("--scheduled", help="Read from the scheduled drawer.")] = False
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
async def get(ctx: OpContext, req: GetReq) -> Message:
|
|
1155
|
+
"""One message with everything hanging off it."""
|
|
1156
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1157
|
+
chat_id = _send.peer_id_of(peer)
|
|
1158
|
+
client = _client(ctx)
|
|
1159
|
+
|
|
1160
|
+
if req.rich:
|
|
1161
|
+
_send.require_supported(
|
|
1162
|
+
"--rich", "messages.getRichMessage needs layer 229 and the pinned Telethon is 227"
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
found = await _fetch(
|
|
1166
|
+
ctx, peer, chat_id=chat_id, limit=1, ids=[req.msg_id], scheduled=req.scheduled
|
|
1167
|
+
)
|
|
1168
|
+
if not found or not found[0].id:
|
|
1169
|
+
raise NotFoundError(f"message {req.msg_id} is not in that chat, or was deleted")
|
|
1170
|
+
message = found[0]
|
|
1171
|
+
message.scheduled = req.scheduled
|
|
1172
|
+
|
|
1173
|
+
raw = None
|
|
1174
|
+
if req.raw or req.full or req.vcard_out:
|
|
1175
|
+
async for item in client.iter_messages(peer, ids=[req.msg_id]):
|
|
1176
|
+
raw = item
|
|
1177
|
+
break
|
|
1178
|
+
if req.raw and raw is not None:
|
|
1179
|
+
message.raw = {"tl_type": type(raw).__name__, "repr": str(raw)[:4000]}
|
|
1180
|
+
if req.full and raw is not None:
|
|
1181
|
+
message.restriction_reason = [
|
|
1182
|
+
f"{getattr(r, 'platform', '')}:{getattr(r, 'reason', '')}"
|
|
1183
|
+
for r in (getattr(raw, "restriction_reason", None) or [])
|
|
1184
|
+
]
|
|
1185
|
+
if req.vcard_out and raw is not None:
|
|
1186
|
+
_write_vcard(req.vcard_out, raw)
|
|
1187
|
+
if req.format and req.format != "json":
|
|
1188
|
+
message.text = _render(message, req.format)
|
|
1189
|
+
if req.with_reply and message.reply_to and message.reply_to.message_id:
|
|
1190
|
+
parent = await _fetch(
|
|
1191
|
+
ctx, peer, chat_id=chat_id, limit=1, ids=[message.reply_to.message_id]
|
|
1192
|
+
)
|
|
1193
|
+
message.reply = parent[0] if parent else None
|
|
1194
|
+
if req.context:
|
|
1195
|
+
around = await _fetch(
|
|
1196
|
+
ctx,
|
|
1197
|
+
peer,
|
|
1198
|
+
chat_id=chat_id,
|
|
1199
|
+
limit=req.context * 2,
|
|
1200
|
+
offset_id=req.msg_id + req.context,
|
|
1201
|
+
)
|
|
1202
|
+
message.context = [m for m in around if m.id != message.id]
|
|
1203
|
+
return message
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _render(message: Message, mode: str) -> str:
|
|
1207
|
+
"""Re-apply the entities as markup, for a human who asked for markup.
|
|
1208
|
+
|
|
1209
|
+
Deliberately one-way and lossy — that is why the default is `json` and the
|
|
1210
|
+
raw text plus entities is what the JSON carries (core/text.py's rule).
|
|
1211
|
+
"""
|
|
1212
|
+
if mode == "text" or not message.entities:
|
|
1213
|
+
return message.text
|
|
1214
|
+
marks = {"bold": ("**", "**"), "italic": ("_", "_"), "code": ("`", "`")}
|
|
1215
|
+
if mode == "html":
|
|
1216
|
+
marks = {"bold": ("<b>", "</b>"), "italic": ("<i>", "</i>"), "code": ("<code>", "</code>")}
|
|
1217
|
+
pieces: list[tuple[int, str]] = []
|
|
1218
|
+
for entity in message.entities:
|
|
1219
|
+
pair = marks.get(entity.type)
|
|
1220
|
+
if pair is None:
|
|
1221
|
+
continue
|
|
1222
|
+
pieces.append((entity.offset, pair[0]))
|
|
1223
|
+
pieces.append((entity.offset + entity.length, pair[1]))
|
|
1224
|
+
units = message.text.encode("utf-16-le")
|
|
1225
|
+
out: list[str] = []
|
|
1226
|
+
cursor = 0
|
|
1227
|
+
for offset, marker in sorted(pieces, key=lambda item: item[0]):
|
|
1228
|
+
out.append(units[cursor * 2 : offset * 2].decode("utf-16-le", "ignore"))
|
|
1229
|
+
out.append(marker)
|
|
1230
|
+
cursor = offset
|
|
1231
|
+
out.append(units[cursor * 2 :].decode("utf-16-le", "ignore"))
|
|
1232
|
+
return "".join(out)
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _write_vcard(path: str, raw: Any) -> None:
|
|
1236
|
+
media = getattr(raw, "media", None)
|
|
1237
|
+
vcard = getattr(media, "vcard", None)
|
|
1238
|
+
if not vcard:
|
|
1239
|
+
first = getattr(media, "first_name", "") or ""
|
|
1240
|
+
last = getattr(media, "last_name", "") or ""
|
|
1241
|
+
phone = getattr(media, "phone_number", "") or ""
|
|
1242
|
+
if not phone:
|
|
1243
|
+
raise UsageError("that message has no contact card to write", field="vcard_out")
|
|
1244
|
+
vcard = f"BEGIN:VCARD\nVERSION:3.0\nN:{last};{first}\nTEL:{phone}\nEND:VCARD\n"
|
|
1245
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
1246
|
+
handle.write(vcard)
|
|
1247
|
+
|
|
1248
|
+
|
|
1249
|
+
SPEC_GET = OperationSpec(
|
|
1250
|
+
id="message.get",
|
|
1251
|
+
request=GetReq,
|
|
1252
|
+
response=Message,
|
|
1253
|
+
impl=get,
|
|
1254
|
+
summary="Get one message with its full metadata",
|
|
1255
|
+
description=(
|
|
1256
|
+
"Entities (UTF-16 offsets), forward header, reply context, media "
|
|
1257
|
+
"summary, reactions including whether this account already reacted, "
|
|
1258
|
+
"views, and the fact-check when one exists."
|
|
1259
|
+
),
|
|
1260
|
+
aliases=("msg.get",),
|
|
1261
|
+
legacy_paths=("message get", "msg get"),
|
|
1262
|
+
empty_exit=EXIT_EMPTY,
|
|
1263
|
+
columns=("id", "date", "text"),
|
|
1264
|
+
example=_EXAMPLE_MESSAGE,
|
|
1265
|
+
example_args="message get @alice 12345",
|
|
1266
|
+
covers=(
|
|
1267
|
+
"bots.disabled-button",
|
|
1268
|
+
"bots.inline-keyboard-render",
|
|
1269
|
+
"bots.invoice-message-view",
|
|
1270
|
+
"bots.reply-keyboard-render",
|
|
1271
|
+
"calls.history-goto-message",
|
|
1272
|
+
"contact.card-fields",
|
|
1273
|
+
"dice.read-value",
|
|
1274
|
+
"groups-channels-admin.monoforum-message-author",
|
|
1275
|
+
"messages-core.message-copy-text",
|
|
1276
|
+
"messages-core.message-forward-header",
|
|
1277
|
+
"messages-core.message-get",
|
|
1278
|
+
"messages-core.message-get-reply-context",
|
|
1279
|
+
"messages-core.message-post-author",
|
|
1280
|
+
"messages-core.message-restriction-reason",
|
|
1281
|
+
"messages-core.reactions-count",
|
|
1282
|
+
"messages-core.thread-view-original-post",
|
|
1283
|
+
),
|
|
1284
|
+
covers_partial=("bots.rich-message-view", "richmsg.get"),
|
|
1285
|
+
coverage_note="--rich is refused with NOT_SUPPORTED until Telethon carries layer 229.",
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
class ThreadListReq(Request):
|
|
1290
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel or group.")]
|
|
1291
|
+
root_id: Annotated[int, arg(1, metavar="ROOT_ID", kind="msg_id", help="Post or thread root.")]
|
|
1292
|
+
reverse: Annotated[bool, opt("--reverse", help="Oldest first.")] = False
|
|
1293
|
+
around: Annotated[
|
|
1294
|
+
int | None, opt("--around", metavar="ID", kind="msg_id", help="Centre on a comment.")
|
|
1295
|
+
] = None
|
|
1296
|
+
resolve: Annotated[bool, opt(help="Resolve the post into its discussion thread first.")] = True
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
async def thread_list(ctx: OpContext, req: ThreadListReq) -> Page[Message]:
|
|
1300
|
+
"""The replies under a thread root, or the comments under a channel post.
|
|
1301
|
+
|
|
1302
|
+
A channel post's comments do not live in the channel: `getDiscussionMessage`
|
|
1303
|
+
maps the post to `(discussion group, root id)` first, which is why the
|
|
1304
|
+
result names the chat the comments are actually in.
|
|
1305
|
+
"""
|
|
1306
|
+
from telethon.tl.functions import messages as fn
|
|
1307
|
+
|
|
1308
|
+
limit, state = _window(ctx, "message.thread.list", PageKind.HISTORY)
|
|
1309
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1310
|
+
chat_id = _send.peer_id_of(peer)
|
|
1311
|
+
root = req.root_id
|
|
1312
|
+
discussion_id = chat_id
|
|
1313
|
+
|
|
1314
|
+
if req.resolve:
|
|
1315
|
+
try:
|
|
1316
|
+
mapped = await _client(ctx)(fn.GetDiscussionMessageRequest(peer, req.root_id))
|
|
1317
|
+
except Exception as exc:
|
|
1318
|
+
if "MSG_ID_INVALID" not in str(exc).upper():
|
|
1319
|
+
raise
|
|
1320
|
+
mapped = None
|
|
1321
|
+
messages = list(getattr(mapped, "messages", None) or [])
|
|
1322
|
+
if messages:
|
|
1323
|
+
peer = await _client(ctx).get_input_entity(messages[0].peer_id)
|
|
1324
|
+
discussion_id = _send.peer_id_of(peer)
|
|
1325
|
+
root = int(messages[0].id)
|
|
1326
|
+
|
|
1327
|
+
offset_id = int(state.get("offset_id", 0)) or (req.around or 0)
|
|
1328
|
+
add_offset = int(state.get("add_offset", 0)) or (-(limit // 2) if req.around else 0)
|
|
1329
|
+
items = await _fetch(
|
|
1330
|
+
ctx,
|
|
1331
|
+
peer,
|
|
1332
|
+
chat_id=discussion_id,
|
|
1333
|
+
limit=limit,
|
|
1334
|
+
reply_to=root,
|
|
1335
|
+
reverse=req.reverse,
|
|
1336
|
+
offset_id=offset_id,
|
|
1337
|
+
add_offset=add_offset,
|
|
1338
|
+
)
|
|
1339
|
+
for message in items:
|
|
1340
|
+
message.thread_root = root
|
|
1341
|
+
message.discussion_chat_id = discussion_id
|
|
1342
|
+
next_state = {"offset_id": items[-1].id, "add_offset": 0} if items else {}
|
|
1343
|
+
return build_page(
|
|
1344
|
+
items,
|
|
1345
|
+
op="message.thread.list",
|
|
1346
|
+
kind=PageKind.HISTORY,
|
|
1347
|
+
state=next_state,
|
|
1348
|
+
account=ctx.account,
|
|
1349
|
+
limit=limit,
|
|
1350
|
+
has_more=None if items else False,
|
|
1351
|
+
)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
SPEC_THREAD_LIST = OperationSpec(
|
|
1355
|
+
id="message.thread.list",
|
|
1356
|
+
request=ThreadListReq,
|
|
1357
|
+
response=Page[Message],
|
|
1358
|
+
impl=thread_list,
|
|
1359
|
+
summary="List the replies of a thread or the comments under a post",
|
|
1360
|
+
aliases=("message.comments",),
|
|
1361
|
+
paginated=PageKind.HISTORY,
|
|
1362
|
+
columns=("id", "date", "text"),
|
|
1363
|
+
example={"items": [_EXAMPLE_MESSAGE], "has_more": False},
|
|
1364
|
+
example_args="message thread list @channel 4242",
|
|
1365
|
+
covers=(
|
|
1366
|
+
"groups-channels-admin.comments-thread",
|
|
1367
|
+
"messages-core.comments-view",
|
|
1368
|
+
"messages-core.thread-view-replies",
|
|
1369
|
+
),
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
class ThreadDisableReq(Request):
|
|
1374
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
|
|
1375
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Post id.")]
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
async def thread_disable(ctx: OpContext, req: ThreadDisableReq) -> SuggestedPostState:
|
|
1379
|
+
"""Turn comments off on one post, by deleting the discussion-group copy.
|
|
1380
|
+
|
|
1381
|
+
Telegram has no toggle for this. The only mechanism is deleting the
|
|
1382
|
+
auto-forwarded copy in the linked discussion group, and that destroys the
|
|
1383
|
+
comments that were already there — which is why the op is destructive.
|
|
1384
|
+
"""
|
|
1385
|
+
from telethon.tl.functions import messages as fn
|
|
1386
|
+
|
|
1387
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1388
|
+
mapped = await _client(ctx)(fn.GetDiscussionMessageRequest(peer, req.msg_id))
|
|
1389
|
+
messages = list(getattr(mapped, "messages", None) or [])
|
|
1390
|
+
if not messages:
|
|
1391
|
+
_already(ctx)
|
|
1392
|
+
return SuggestedPostState(
|
|
1393
|
+
chat_id=_send.peer_id_of(peer), msg_id=req.msg_id, state="no-comments"
|
|
1394
|
+
)
|
|
1395
|
+
discussion = await _client(ctx).get_input_entity(messages[0].peer_id)
|
|
1396
|
+
await _client(ctx).delete_messages(discussion, [int(messages[0].id)])
|
|
1397
|
+
return SuggestedPostState(
|
|
1398
|
+
chat_id=_send.peer_id_of(peer), msg_id=req.msg_id, state="comments-disabled"
|
|
1399
|
+
)
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
SPEC_THREAD_DISABLE = OperationSpec(
|
|
1403
|
+
id="message.thread.disable",
|
|
1404
|
+
request=ThreadDisableReq,
|
|
1405
|
+
response=SuggestedPostState,
|
|
1406
|
+
impl=thread_disable,
|
|
1407
|
+
summary="Disable comments on a single channel post",
|
|
1408
|
+
description=(
|
|
1409
|
+
"Irreversible: the only mechanism Telegram offers is deleting the "
|
|
1410
|
+
"post's copy in the linked discussion group, which deletes the "
|
|
1411
|
+
"comments with it."
|
|
1412
|
+
),
|
|
1413
|
+
mutating=True,
|
|
1414
|
+
destructive=True,
|
|
1415
|
+
rate_class="bulk",
|
|
1416
|
+
columns=("chat_id", "msg_id", "state"),
|
|
1417
|
+
example={"chat_id": -1001234, "msg_id": 4242, "state": "comments-disabled"},
|
|
1418
|
+
example_args="message thread disable @channel 4242",
|
|
1419
|
+
covers=("messages-core.comments-disable-on-post",),
|
|
1420
|
+
)
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
# ---------------------------------------------------------------------------
|
|
1424
|
+
# message edit / delete / forward
|
|
1425
|
+
# ---------------------------------------------------------------------------
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
class EditReq(Request):
|
|
1429
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
1430
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
|
|
1431
|
+
text: Annotated[str, arg(2, metavar="TEXT", required=False, help="New text.")] = ""
|
|
1432
|
+
parse: Annotated[str | None, choice("md", "html", "none", help="Formatting of TEXT.")] = None
|
|
1433
|
+
entities: Annotated[
|
|
1434
|
+
str | None, opt("--entities", metavar="JSON", kind="json", help="Explicit entities.")
|
|
1435
|
+
] = None
|
|
1436
|
+
caption: Annotated[str | None, opt("--caption", help="New media caption.")] = None
|
|
1437
|
+
caption_above: Annotated[bool, opt("--caption-above", help="invert_media.")] = False
|
|
1438
|
+
file: Annotated[
|
|
1439
|
+
str | None, opt("--file", metavar="PATH", kind="path", help="Replace the media.")
|
|
1440
|
+
] = None
|
|
1441
|
+
no_preview: Annotated[bool, opt("--no-preview", help="Drop the link preview.")] = False
|
|
1442
|
+
preview_url: Annotated[
|
|
1443
|
+
str | None, opt("--preview-url", metavar="URL", help="Change the previewed URL.")
|
|
1444
|
+
] = None
|
|
1445
|
+
preview_above: Annotated[bool, opt("--preview-above", help="invert_media.")] = False
|
|
1446
|
+
schedule: Annotated[
|
|
1447
|
+
str | None, opt("--schedule", metavar="TS|online", help="Reschedule a scheduled message.")
|
|
1448
|
+
] = None
|
|
1449
|
+
repeat: Annotated[
|
|
1450
|
+
str | None, opt("--repeat", metavar="PERIOD", help="Change the repeat period (Premium).")
|
|
1451
|
+
] = None
|
|
1452
|
+
toggle_task: Annotated[
|
|
1453
|
+
int | None, opt("--toggle-task", metavar="N", help="Flip a checkbox in a rich message.")
|
|
1454
|
+
] = None
|
|
1455
|
+
rich_markdown: Annotated[
|
|
1456
|
+
str | None,
|
|
1457
|
+
opt("--rich-markdown", metavar="PATH", kind="path", help="Replace the rich body."),
|
|
1458
|
+
] = None
|
|
1459
|
+
check: Annotated[
|
|
1460
|
+
bool, opt("--check", help="Report whether this message is still editable and stop.")
|
|
1461
|
+
] = False
|
|
1462
|
+
typing: Annotated[
|
|
1463
|
+
float, opt("--typing", metavar="SECONDS", help="Type for N seconds first.", ge=0)
|
|
1464
|
+
] = 0.0
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
async def edit(ctx: OpContext, req: EditReq) -> EditResult:
|
|
1468
|
+
"""Edit a message's text, caption, media or schedule.
|
|
1469
|
+
|
|
1470
|
+
MESSAGE_NOT_MODIFIED is reported as `already: true` rather than as an
|
|
1471
|
+
error: the world already looks the way the caller asked for, which is
|
|
1472
|
+
success (STYLE §4).
|
|
1473
|
+
"""
|
|
1474
|
+
from telethon.tl.functions import messages as fn
|
|
1475
|
+
|
|
1476
|
+
if req.rich_markdown or req.toggle_task is not None:
|
|
1477
|
+
_send.require_supported(
|
|
1478
|
+
"--rich-markdown/--toggle-task",
|
|
1479
|
+
"editing a layer-229 rich body needs an API layer the pinned Telethon lacks",
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1483
|
+
chat_id = _send.peer_id_of(peer)
|
|
1484
|
+
client = _client(ctx)
|
|
1485
|
+
|
|
1486
|
+
if req.check:
|
|
1487
|
+
data = await client(fn.GetMessageEditDataRequest(peer=peer, id=req.msg_id))
|
|
1488
|
+
return EditResult(
|
|
1489
|
+
id=req.msg_id,
|
|
1490
|
+
chat_id=chat_id,
|
|
1491
|
+
can_edit=True,
|
|
1492
|
+
caption=bool(getattr(data, "caption", False)),
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
await _send.show_typing(ctx, peer, _send.typing_seconds(req.text, requested=req.typing))
|
|
1496
|
+
|
|
1497
|
+
source = req.caption if req.caption is not None else req.text
|
|
1498
|
+
text, entities = _send.body(source, parse=req.parse, entities=req.entities)
|
|
1499
|
+
media: Any = None
|
|
1500
|
+
if req.file:
|
|
1501
|
+
media = await _send.input_media(ctx, req.file)
|
|
1502
|
+
elif req.preview_url:
|
|
1503
|
+
from telethon.tl import types
|
|
1504
|
+
|
|
1505
|
+
media = types.InputMediaWebPage(url=req.preview_url, optional=True)
|
|
1506
|
+
|
|
1507
|
+
try:
|
|
1508
|
+
result = await client(
|
|
1509
|
+
fn.EditMessageRequest(
|
|
1510
|
+
peer=peer,
|
|
1511
|
+
id=req.msg_id,
|
|
1512
|
+
message=text or None,
|
|
1513
|
+
entities=_send.tl_entities(entities),
|
|
1514
|
+
media=media,
|
|
1515
|
+
no_webpage=req.no_preview or None,
|
|
1516
|
+
invert_media=(req.preview_above or req.caption_above) or None,
|
|
1517
|
+
schedule_date=_send.schedule_at(req.schedule),
|
|
1518
|
+
schedule_repeat_period=_send.repeat_period(req.repeat),
|
|
1519
|
+
)
|
|
1520
|
+
)
|
|
1521
|
+
except Exception as exc:
|
|
1522
|
+
if not _is_not_modified(exc):
|
|
1523
|
+
raise
|
|
1524
|
+
_already(ctx)
|
|
1525
|
+
return EditResult(id=req.msg_id, chat_id=chat_id, text=text, edited=True, already=True)
|
|
1526
|
+
|
|
1527
|
+
edited = _send.message_from_updates(result, chat_id=chat_id, sent_text=text)
|
|
1528
|
+
ctx.emit("message_edit", {"chat_id": chat_id, "id": req.msg_id})
|
|
1529
|
+
return EditResult(
|
|
1530
|
+
id=edited.id or req.msg_id,
|
|
1531
|
+
chat_id=chat_id,
|
|
1532
|
+
edited=True,
|
|
1533
|
+
edit_date=edited.edit_date,
|
|
1534
|
+
text=edited.text or text,
|
|
1535
|
+
entities=edited.entities or entities,
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
SPEC_EDIT = OperationSpec(
|
|
1540
|
+
id="message.edit",
|
|
1541
|
+
request=EditReq,
|
|
1542
|
+
response=EditResult,
|
|
1543
|
+
impl=edit,
|
|
1544
|
+
summary="Edit a message's text, caption, media or schedule",
|
|
1545
|
+
description=(
|
|
1546
|
+
"MESSAGE_NOT_MODIFIED is success with `already: true`. --check asks "
|
|
1547
|
+
"`messages.getMessageEditData` whether the message may still be "
|
|
1548
|
+
"edited, which is not derivable from its date: pinned, scheduled and "
|
|
1549
|
+
"Saved-Messages posts have no 48-hour window."
|
|
1550
|
+
),
|
|
1551
|
+
aliases=("msg.edit",),
|
|
1552
|
+
legacy_paths=("message edit", "msg edit"),
|
|
1553
|
+
mutating=True,
|
|
1554
|
+
idempotent=True,
|
|
1555
|
+
rate_class="send",
|
|
1556
|
+
timeout_s=180,
|
|
1557
|
+
columns=("id", "chat_id", "edit_date", "text"),
|
|
1558
|
+
example={
|
|
1559
|
+
"id": 12345,
|
|
1560
|
+
"chat_id": 777123,
|
|
1561
|
+
"edited": True,
|
|
1562
|
+
"edit_date": "2026-09-03T09:20:00Z",
|
|
1563
|
+
"text": "on my way (5 min)",
|
|
1564
|
+
},
|
|
1565
|
+
example_args='message edit @alice 12345 "on my way (5 min)"',
|
|
1566
|
+
covers=(
|
|
1567
|
+
"messages-core.edit-caption",
|
|
1568
|
+
"messages-core.edit-check-permission",
|
|
1569
|
+
"messages-core.edit-text",
|
|
1570
|
+
"messages-core.scheduled-reschedule",
|
|
1571
|
+
),
|
|
1572
|
+
covers_partial=("richmsg.tasks",),
|
|
1573
|
+
coverage_note="Checklist tasks live in a layer-229 rich body; --toggle-task is refused.",
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
class DeleteReq(Request):
|
|
1578
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
1579
|
+
msg_id: Annotated[
|
|
1580
|
+
tuple[str, ...],
|
|
1581
|
+
arg(1, metavar="MSG_ID", required=False, variadic=True, help="Ids or ranges (100-120)."),
|
|
1582
|
+
] = ()
|
|
1583
|
+
for_everyone: Annotated[
|
|
1584
|
+
bool, opt("--for-everyone", help="Revoke for everyone (the default for own messages).")
|
|
1585
|
+
] = True
|
|
1586
|
+
for_me: Annotated[bool, opt("--for-me", help="Delete only from my own history.")] = False
|
|
1587
|
+
from_user: Annotated[
|
|
1588
|
+
PeerRef | None,
|
|
1589
|
+
opt("--from", metavar="USER", kind="user", help="Every message this member sent."),
|
|
1590
|
+
] = None
|
|
1591
|
+
ban: Annotated[bool, opt("--ban", help="With --from: also ban the member.")] = False
|
|
1592
|
+
report_spam: Annotated[bool, opt("--report-spam", help="With --from: report as spam.")] = False
|
|
1593
|
+
scheduled: Annotated[bool, opt("--scheduled", help="Delete scheduled messages instead.")] = (
|
|
1594
|
+
False
|
|
1595
|
+
)
|
|
1596
|
+
revert: Annotated[bool, opt("--revert", help="Revert an ephemeral message.")] = False
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
async def delete(ctx: OpContext, req: DeleteReq) -> DeleteResult:
|
|
1600
|
+
"""Delete messages, for me or for everyone.
|
|
1601
|
+
|
|
1602
|
+
`--from` is a different call with a different shape: it returns an
|
|
1603
|
+
`AffectedHistory` that has to be looped until the offset reaches zero,
|
|
1604
|
+
which is why it cannot simply be a filter over the id list.
|
|
1605
|
+
"""
|
|
1606
|
+
from telethon.tl.functions import channels as ch
|
|
1607
|
+
|
|
1608
|
+
if req.revert:
|
|
1609
|
+
_send.require_supported(
|
|
1610
|
+
"--revert", "ephemeral.revertMessage is layer 229 and absent from Telethon 1.44"
|
|
1611
|
+
)
|
|
1612
|
+
|
|
1613
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1614
|
+
chat_id = _send.peer_id_of(peer)
|
|
1615
|
+
client = _client(ctx)
|
|
1616
|
+
|
|
1617
|
+
if req.from_user is not None:
|
|
1618
|
+
member = await _send.resolve(ctx, req.from_user)
|
|
1619
|
+
channel = _input_channel(peer)
|
|
1620
|
+
affected = await _affected_loop(
|
|
1621
|
+
ctx,
|
|
1622
|
+
lambda offset: ch.DeleteParticipantHistoryRequest(channel=channel, participant=member),
|
|
1623
|
+
)
|
|
1624
|
+
return DeleteResult(chat_id=chat_id, deleted=affected, affected=affected)
|
|
1625
|
+
|
|
1626
|
+
ids = _ids(req.msg_id)
|
|
1627
|
+
if not ids:
|
|
1628
|
+
raise UsageError("give at least one message id, or --from <user>", field="msg_id")
|
|
1629
|
+
|
|
1630
|
+
if req.scheduled:
|
|
1631
|
+
from telethon.tl.functions import messages as fn
|
|
1632
|
+
|
|
1633
|
+
await client(fn.DeleteScheduledMessagesRequest(peer=peer, id=ids))
|
|
1634
|
+
return DeleteResult(chat_id=chat_id, deleted=len(ids), ids=ids, scheduled=True)
|
|
1635
|
+
|
|
1636
|
+
revoke = not req.for_me and req.for_everyone
|
|
1637
|
+
result = await client.delete_messages(peer, ids, revoke=revoke)
|
|
1638
|
+
affected = sum(int(getattr(item, "pts_count", 0) or 0) for item in (result or []))
|
|
1639
|
+
ctx.emit("message_delete", {"chat_id": chat_id, "ids": ids})
|
|
1640
|
+
return DeleteResult(chat_id=chat_id, deleted=affected or len(ids), ids=ids, affected=affected)
|
|
1641
|
+
|
|
1642
|
+
|
|
1643
|
+
SPEC_DELETE = OperationSpec(
|
|
1644
|
+
id="message.delete",
|
|
1645
|
+
request=DeleteReq,
|
|
1646
|
+
response=DeleteResult,
|
|
1647
|
+
impl=delete,
|
|
1648
|
+
summary="Delete messages for me or for everyone",
|
|
1649
|
+
description=(
|
|
1650
|
+
"In a channel or supergroup a delete is always for everyone — "
|
|
1651
|
+
"`channels.deleteMessages` has no revoke flag — so --for-me is only "
|
|
1652
|
+
"meaningful in private chats and basic groups."
|
|
1653
|
+
),
|
|
1654
|
+
aliases=("msg.delete", "message.rm"),
|
|
1655
|
+
legacy_paths=("message delete", "msg delete"),
|
|
1656
|
+
mutating=True,
|
|
1657
|
+
destructive=True,
|
|
1658
|
+
rate_class="bulk",
|
|
1659
|
+
columns=("chat_id", "deleted"),
|
|
1660
|
+
example={"chat_id": 777123, "deleted": 2, "ids": [12345, 12346]},
|
|
1661
|
+
example_args="message delete @alice 12345",
|
|
1662
|
+
covers=(
|
|
1663
|
+
"messages-core.delete-all-from-user",
|
|
1664
|
+
"messages-core.delete-for-everyone",
|
|
1665
|
+
"messages-core.delete-for-me",
|
|
1666
|
+
"messages-core.scheduled-delete",
|
|
1667
|
+
),
|
|
1668
|
+
covers_partial=("messages-core.ephemeral-messages",),
|
|
1669
|
+
coverage_note="--revert needs layer 229's ephemeral.* namespace and is refused.",
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
class ForwardReq(_send.SendOptions, kw_only=True):
|
|
1674
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Source chat.")]
|
|
1675
|
+
msg_id: Annotated[
|
|
1676
|
+
tuple[str, ...],
|
|
1677
|
+
arg(1, metavar="MSG_ID", required=False, variadic=True, help="Ids or ranges."),
|
|
1678
|
+
] = ()
|
|
1679
|
+
to: Annotated[
|
|
1680
|
+
list[PeerRef],
|
|
1681
|
+
opt("--to", metavar="CHAT", kind="peer", help="Destination; repeatable."),
|
|
1682
|
+
] = []
|
|
1683
|
+
no_author: Annotated[bool, opt("--no-author", help="drop_author: hide the sender.")] = False
|
|
1684
|
+
no_captions: Annotated[bool, opt("--no-captions", help="drop_media_captions.")] = False
|
|
1685
|
+
as_copy: Annotated[bool, opt("--as-copy", help="Re-send as a new message, not a forward.")] = (
|
|
1686
|
+
False
|
|
1687
|
+
)
|
|
1688
|
+
comment: Annotated[str | None, opt("--comment", help="Send this alongside the forward.")] = None
|
|
1689
|
+
video_at: Annotated[
|
|
1690
|
+
int | None, opt("--video-at", metavar="SECONDS", help="Start at a video timestamp.")
|
|
1691
|
+
] = None
|
|
1692
|
+
with_score: Annotated[bool, opt("--with-score", help="Keep a game's scoreboard.")] = False
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
async def forward(ctx: OpContext, req: ForwardReq) -> Page[ForwardedMessage]:
|
|
1696
|
+
"""Forward or copy messages to one or many chats.
|
|
1697
|
+
|
|
1698
|
+
One request per destination, because Telegram checks each destination's
|
|
1699
|
+
restrictions separately and a single failure must not silently take the
|
|
1700
|
+
other destinations with it.
|
|
1701
|
+
"""
|
|
1702
|
+
from telethon.tl.functions import messages as fn
|
|
1703
|
+
|
|
1704
|
+
ids = _ids(req.msg_id)
|
|
1705
|
+
if not ids:
|
|
1706
|
+
raise UsageError("give at least one message id to forward", field="msg_id")
|
|
1707
|
+
if not req.to:
|
|
1708
|
+
raise UsageError("give at least one destination with --to", field="to")
|
|
1709
|
+
|
|
1710
|
+
source = await _send.resolve(ctx, req.chat)
|
|
1711
|
+
source_id = _send.peer_id_of(source)
|
|
1712
|
+
client = _client(ctx)
|
|
1713
|
+
produced: list[ForwardedMessage] = []
|
|
1714
|
+
|
|
1715
|
+
for index, destination in enumerate(req.to):
|
|
1716
|
+
target = await _send.resolve(ctx, destination)
|
|
1717
|
+
target_id = _send.peer_id_of(target)
|
|
1718
|
+
if index:
|
|
1719
|
+
limiter = getattr(ctx, "limiter", None)
|
|
1720
|
+
if limiter is not None:
|
|
1721
|
+
await limiter.acquire("send")
|
|
1722
|
+
result = await client(
|
|
1723
|
+
fn.ForwardMessagesRequest(
|
|
1724
|
+
from_peer=source,
|
|
1725
|
+
id=ids,
|
|
1726
|
+
to_peer=target,
|
|
1727
|
+
random_id=[_random_id() for _ in ids],
|
|
1728
|
+
drop_author=(req.no_author or req.as_copy) or None,
|
|
1729
|
+
drop_media_captions=req.no_captions or None,
|
|
1730
|
+
with_my_score=req.with_score or None,
|
|
1731
|
+
noforwards=req.protect or None,
|
|
1732
|
+
silent=req.silent or None,
|
|
1733
|
+
top_msg_id=req.topic,
|
|
1734
|
+
schedule_date=_send.schedule_at(req.schedule),
|
|
1735
|
+
send_as=await _send.resolve(ctx, req.send_as) if req.send_as else None,
|
|
1736
|
+
effect=_send.effect_id(req.effect),
|
|
1737
|
+
video_timestamp=req.video_at,
|
|
1738
|
+
allow_paid_stars=req.paid_stars,
|
|
1739
|
+
)
|
|
1740
|
+
)
|
|
1741
|
+
for offset, message in enumerate(_send.messages_from_updates(result, chat_id=target_id)):
|
|
1742
|
+
produced.append(
|
|
1743
|
+
ForwardedMessage(
|
|
1744
|
+
id=message.id,
|
|
1745
|
+
chat_id=target_id,
|
|
1746
|
+
date=message.date,
|
|
1747
|
+
date_unix=message.date_unix,
|
|
1748
|
+
from_chat_id=source_id,
|
|
1749
|
+
from_msg_id=ids[offset] if offset < len(ids) else None,
|
|
1750
|
+
)
|
|
1751
|
+
)
|
|
1752
|
+
if req.comment:
|
|
1753
|
+
await client(
|
|
1754
|
+
fn.SendMessageRequest(
|
|
1755
|
+
peer=target,
|
|
1756
|
+
message=req.comment,
|
|
1757
|
+
random_id=_random_id(),
|
|
1758
|
+
silent=req.silent or None,
|
|
1759
|
+
)
|
|
1760
|
+
)
|
|
1761
|
+
ctx.emit("message_forward", {"from_chat_id": source_id, "ids": ids, "count": len(produced)})
|
|
1762
|
+
return Page(items=produced, has_more=False, total=len(produced))
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
SPEC_FORWARD = OperationSpec(
|
|
1766
|
+
id="message.forward",
|
|
1767
|
+
request=ForwardReq,
|
|
1768
|
+
response=Page[ForwardedMessage],
|
|
1769
|
+
impl=forward,
|
|
1770
|
+
summary="Forward or copy messages to one or many chats",
|
|
1771
|
+
description=(
|
|
1772
|
+
"--as-copy is the same call with drop_author and fresh random ids, "
|
|
1773
|
+
"which is exactly what the GUI's 'forward without quoting' does."
|
|
1774
|
+
),
|
|
1775
|
+
aliases=("msg.forward",),
|
|
1776
|
+
legacy_paths=("message forward", "msg forward"),
|
|
1777
|
+
mutating=True,
|
|
1778
|
+
rate_class="send",
|
|
1779
|
+
timeout_s=300,
|
|
1780
|
+
columns=("id", "chat_id", "from_msg_id"),
|
|
1781
|
+
example={
|
|
1782
|
+
"items": [
|
|
1783
|
+
{
|
|
1784
|
+
"id": 200,
|
|
1785
|
+
"chat_id": -1001234,
|
|
1786
|
+
"date": "2026-09-03T09:14:07Z",
|
|
1787
|
+
"date_unix": 1788340447,
|
|
1788
|
+
"from_chat_id": 777123,
|
|
1789
|
+
"from_msg_id": 12345,
|
|
1790
|
+
}
|
|
1791
|
+
],
|
|
1792
|
+
"has_more": False,
|
|
1793
|
+
},
|
|
1794
|
+
example_args="message forward @alice 12345 --to @bobby",
|
|
1795
|
+
covers=(
|
|
1796
|
+
"game.forward",
|
|
1797
|
+
"messages-core.forward-as-copy-text",
|
|
1798
|
+
"messages-core.forward-basic",
|
|
1799
|
+
"messages-core.forward-hide-captions",
|
|
1800
|
+
"messages-core.forward-hide-sender",
|
|
1801
|
+
"messages-core.forward-options",
|
|
1802
|
+
"messages-core.forward-to-many",
|
|
1803
|
+
"messages-core.message-select-bulk",
|
|
1804
|
+
),
|
|
1805
|
+
)
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
# ---------------------------------------------------------------------------
|
|
1809
|
+
# pin / unpin / read / react
|
|
1810
|
+
# ---------------------------------------------------------------------------
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
class PinReq(Request):
|
|
1814
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
1815
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id or link.")]
|
|
1816
|
+
notify: Annotated[
|
|
1817
|
+
bool, opt("--notify", help="Notify members (a pin is silent by default).")
|
|
1818
|
+
] = False
|
|
1819
|
+
both_sides: Annotated[
|
|
1820
|
+
bool, opt("--both-sides", help="Also pin on the other side of a private chat.")
|
|
1821
|
+
] = False
|
|
1822
|
+
topic: Annotated[
|
|
1823
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Pin inside a forum topic.")
|
|
1824
|
+
] = None
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
async def pin(ctx: OpContext, req: PinReq) -> PinResult:
|
|
1828
|
+
"""Pin a message. Silent unless --notify, which is how the GUI behaves."""
|
|
1829
|
+
from telethon.tl.functions import messages as fn
|
|
1830
|
+
|
|
1831
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1832
|
+
chat_id = _send.peer_id_of(peer)
|
|
1833
|
+
try:
|
|
1834
|
+
await _client(ctx)(
|
|
1835
|
+
fn.UpdatePinnedMessageRequest(
|
|
1836
|
+
peer=peer,
|
|
1837
|
+
id=req.msg_id,
|
|
1838
|
+
silent=not req.notify,
|
|
1839
|
+
pm_oneside=not req.both_sides,
|
|
1840
|
+
)
|
|
1841
|
+
)
|
|
1842
|
+
except Exception as exc:
|
|
1843
|
+
if not _is_not_modified(exc):
|
|
1844
|
+
raise
|
|
1845
|
+
_already(ctx)
|
|
1846
|
+
return PinResult(chat_id=chat_id, msg_id=req.msg_id, pinned=True, already=True)
|
|
1847
|
+
return PinResult(chat_id=chat_id, msg_id=req.msg_id, pinned=True)
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
SPEC_PIN = OperationSpec(
|
|
1851
|
+
id="message.pin",
|
|
1852
|
+
request=PinReq,
|
|
1853
|
+
response=PinResult,
|
|
1854
|
+
impl=pin,
|
|
1855
|
+
summary="Pin a message in a chat",
|
|
1856
|
+
description="The pinned list is `message list --type pinned`.",
|
|
1857
|
+
aliases=("msg.pin",),
|
|
1858
|
+
legacy_paths=("message pin", "msg pin"),
|
|
1859
|
+
mutating=True,
|
|
1860
|
+
idempotent=True,
|
|
1861
|
+
columns=("chat_id", "msg_id", "pinned"),
|
|
1862
|
+
example={"chat_id": 777123, "msg_id": 12345, "pinned": True},
|
|
1863
|
+
example_args="message pin @alice 12345",
|
|
1864
|
+
covers=(
|
|
1865
|
+
"groups-channels-admin.monoforum-pin",
|
|
1866
|
+
"groups-channels-admin.pinned-messages",
|
|
1867
|
+
"messages-core.pin-message",
|
|
1868
|
+
),
|
|
1869
|
+
)
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
class UnpinReq(Request):
|
|
1873
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
1874
|
+
msg_id: Annotated[
|
|
1875
|
+
int | None,
|
|
1876
|
+
arg(
|
|
1877
|
+
1, metavar="MSG_ID", required=False, kind="msg_id", help="Message id; omit with --all."
|
|
1878
|
+
),
|
|
1879
|
+
] = None
|
|
1880
|
+
unpin_all: Annotated[bool, opt("--all", help="Unpin everything.")] = False
|
|
1881
|
+
topic: Annotated[
|
|
1882
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Only this forum topic.")
|
|
1883
|
+
] = None
|
|
1884
|
+
direct_to: Annotated[
|
|
1885
|
+
PeerRef | None,
|
|
1886
|
+
opt("--direct-to", metavar="USER", kind="user", help="Only this monoforum topic."),
|
|
1887
|
+
] = None
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
async def unpin(ctx: OpContext, req: UnpinReq) -> PinResult:
|
|
1891
|
+
"""Unpin one message, or every pinned message.
|
|
1892
|
+
|
|
1893
|
+
`unpinAllMessages` returns an `AffectedHistory` and has to be looped: one
|
|
1894
|
+
call unpins a page, and stopping there is why v1's equivalent left most
|
|
1895
|
+
of them pinned.
|
|
1896
|
+
"""
|
|
1897
|
+
from telethon.tl.functions import messages as fn
|
|
1898
|
+
|
|
1899
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1900
|
+
chat_id = _send.peer_id_of(peer)
|
|
1901
|
+
saved = await _send.resolve(ctx, req.direct_to) if req.direct_to is not None else None
|
|
1902
|
+
|
|
1903
|
+
if req.unpin_all or req.msg_id is None:
|
|
1904
|
+
count = await _affected_loop(
|
|
1905
|
+
ctx,
|
|
1906
|
+
lambda offset: fn.UnpinAllMessagesRequest(
|
|
1907
|
+
peer=peer, top_msg_id=req.topic, saved_peer_id=saved
|
|
1908
|
+
),
|
|
1909
|
+
)
|
|
1910
|
+
return PinResult(chat_id=chat_id, pinned=False, unpinned=count, count=count)
|
|
1911
|
+
|
|
1912
|
+
try:
|
|
1913
|
+
await _client(ctx)(
|
|
1914
|
+
fn.UpdatePinnedMessageRequest(peer=peer, id=req.msg_id, unpin=True, silent=True)
|
|
1915
|
+
)
|
|
1916
|
+
except Exception as exc:
|
|
1917
|
+
if not _is_not_modified(exc):
|
|
1918
|
+
raise
|
|
1919
|
+
_already(ctx)
|
|
1920
|
+
return PinResult(chat_id=chat_id, msg_id=req.msg_id, pinned=False, already=True)
|
|
1921
|
+
return PinResult(chat_id=chat_id, msg_id=req.msg_id, pinned=False, unpinned=1, count=1)
|
|
1922
|
+
|
|
1923
|
+
|
|
1924
|
+
SPEC_UNPIN = OperationSpec(
|
|
1925
|
+
id="message.unpin",
|
|
1926
|
+
request=UnpinReq,
|
|
1927
|
+
response=PinResult,
|
|
1928
|
+
impl=unpin,
|
|
1929
|
+
summary="Unpin one message or every pinned message",
|
|
1930
|
+
aliases=("msg.unpin",),
|
|
1931
|
+
mutating=True,
|
|
1932
|
+
destructive=True,
|
|
1933
|
+
idempotent=True,
|
|
1934
|
+
rate_class="bulk",
|
|
1935
|
+
columns=("chat_id", "msg_id", "pinned"),
|
|
1936
|
+
example={"chat_id": 777123, "msg_id": 12345, "pinned": False, "unpinned": 1},
|
|
1937
|
+
example_args="message unpin @alice 12345",
|
|
1938
|
+
covers=("messages-core.unpin-all", "messages-core.unpin-message"),
|
|
1939
|
+
)
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
class ReadReq(Request):
|
|
1943
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat to mark read.")]
|
|
1944
|
+
up_to: Annotated[
|
|
1945
|
+
int | None,
|
|
1946
|
+
opt("--up-to", metavar="ID", kind="msg_id", help="Read up to this id (default: latest)."),
|
|
1947
|
+
] = None
|
|
1948
|
+
topic: Annotated[
|
|
1949
|
+
int | None, opt("--topic", metavar="ID", kind="msg_id", help="Read one forum topic.")
|
|
1950
|
+
] = None
|
|
1951
|
+
direct_to: Annotated[
|
|
1952
|
+
PeerRef | None,
|
|
1953
|
+
opt("--direct-to", metavar="USER", kind="user", help="Read one monoforum topic."),
|
|
1954
|
+
] = None
|
|
1955
|
+
saved_peer: Annotated[
|
|
1956
|
+
PeerRef | None,
|
|
1957
|
+
opt("--saved-peer", metavar="CHAT", kind="peer", help="Read one saved dialog."),
|
|
1958
|
+
] = None
|
|
1959
|
+
contents: Annotated[
|
|
1960
|
+
list[str],
|
|
1961
|
+
opt("--contents", metavar="ID", help="Mark voice notes / round videos as listened."),
|
|
1962
|
+
] = []
|
|
1963
|
+
mentions: Annotated[bool, opt("--mentions", help="Mark all unread mentions as read.")] = False
|
|
1964
|
+
reactions: Annotated[bool, opt("--reactions", help="Mark all unread reactions as read.")] = (
|
|
1965
|
+
False
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
async def read(ctx: OpContext, req: ReadReq) -> ReadResult:
|
|
1970
|
+
"""Mark history, mentions, reactions, media contents or a thread as read.
|
|
1971
|
+
|
|
1972
|
+
Which RPC applies depends on the peer type and on which scope was asked
|
|
1973
|
+
for; dispatching on that in one place is what stops a channel read from
|
|
1974
|
+
silently doing nothing (`messages.readHistory` on a channel is a no-op).
|
|
1975
|
+
"""
|
|
1976
|
+
from telethon.tl import types
|
|
1977
|
+
from telethon.tl.functions import channels as ch
|
|
1978
|
+
from telethon.tl.functions import messages as fn
|
|
1979
|
+
|
|
1980
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
1981
|
+
chat_id = _send.peer_id_of(peer)
|
|
1982
|
+
client = _client(ctx)
|
|
1983
|
+
is_channel = isinstance(peer, types.InputPeerChannel)
|
|
1984
|
+
result = ReadResult(chat_id=chat_id, read=True)
|
|
1985
|
+
|
|
1986
|
+
if req.contents:
|
|
1987
|
+
ids = _ids(tuple(req.contents))
|
|
1988
|
+
if is_channel:
|
|
1989
|
+
channel = _input_channel(peer)
|
|
1990
|
+
await client(ch.ReadMessageContentsRequest(channel=channel, id=ids))
|
|
1991
|
+
else:
|
|
1992
|
+
await client(fn.ReadMessageContentsRequest(id=ids))
|
|
1993
|
+
result.contents_read = ids
|
|
1994
|
+
|
|
1995
|
+
if req.mentions:
|
|
1996
|
+
result.mentions_read = await _affected_loop(
|
|
1997
|
+
ctx, lambda offset: fn.ReadMentionsRequest(peer=peer, top_msg_id=req.topic)
|
|
1998
|
+
)
|
|
1999
|
+
if req.reactions:
|
|
2000
|
+
result.reactions_read = await _affected_loop(
|
|
2001
|
+
ctx, lambda offset: fn.ReadReactionsRequest(peer=peer, top_msg_id=req.topic)
|
|
2002
|
+
)
|
|
2003
|
+
if (req.contents or req.mentions or req.reactions) and req.up_to is None:
|
|
2004
|
+
# A scoped read was asked for and no history bound was given: reading
|
|
2005
|
+
# the whole history as well would clear a badge nobody asked to clear.
|
|
2006
|
+
return result
|
|
2007
|
+
|
|
2008
|
+
max_id = req.up_to or 0
|
|
2009
|
+
if req.saved_peer is not None:
|
|
2010
|
+
saved = await _send.resolve(ctx, req.saved_peer)
|
|
2011
|
+
await client(fn.ReadSavedHistoryRequest(parent_peer=peer, peer=saved, max_id=max_id))
|
|
2012
|
+
elif req.topic is not None:
|
|
2013
|
+
await client(fn.ReadDiscussionRequest(peer=peer, msg_id=req.topic, read_max_id=max_id))
|
|
2014
|
+
elif is_channel:
|
|
2015
|
+
channel = _input_channel(peer)
|
|
2016
|
+
await client(ch.ReadHistoryRequest(channel=channel, max_id=max_id))
|
|
2017
|
+
else:
|
|
2018
|
+
affected = await client(fn.ReadHistoryRequest(peer=peer, max_id=max_id))
|
|
2019
|
+
result.still_unread = getattr(affected, "still_unread_count", None)
|
|
2020
|
+
result.read_up_to = max_id or None
|
|
2021
|
+
ctx.emit("message_read", {"chat_id": chat_id, "max_id": max_id})
|
|
2022
|
+
return result
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
SPEC_READ = OperationSpec(
|
|
2026
|
+
id="message.read",
|
|
2027
|
+
request=ReadReq,
|
|
2028
|
+
response=ReadResult,
|
|
2029
|
+
impl=read,
|
|
2030
|
+
summary="Mark a chat, thread, mentions or media contents as read",
|
|
2031
|
+
description=(
|
|
2032
|
+
"A read receipt is visible to the other side and it also clears the "
|
|
2033
|
+
"badge in the account owner's own client, which may be their only "
|
|
2034
|
+
"reminder that they owe a reply."
|
|
2035
|
+
),
|
|
2036
|
+
aliases=("msg.read",),
|
|
2037
|
+
legacy_paths=("message read", "msg read"),
|
|
2038
|
+
mutating=True,
|
|
2039
|
+
idempotent=True,
|
|
2040
|
+
columns=("chat_id", "read_up_to"),
|
|
2041
|
+
example={"chat_id": 777123, "read": True, "read_up_to": 12345},
|
|
2042
|
+
example_args="message read @alice",
|
|
2043
|
+
covers=(
|
|
2044
|
+
"messages-core.monoforum-topic-manage",
|
|
2045
|
+
"messages-core.read-mark-history",
|
|
2046
|
+
"messages-core.read-message-contents",
|
|
2047
|
+
"messages-core.report-message-delivery",
|
|
2048
|
+
"messages-core.thread-read",
|
|
2049
|
+
"messages-core.unread-mentions-read-all",
|
|
2050
|
+
),
|
|
2051
|
+
)
|
|
2052
|
+
|
|
2053
|
+
|
|
2054
|
+
# ---------------------------------------------------------------------------
|
|
2055
|
+
# link / views / read receipts / scheduled
|
|
2056
|
+
# ---------------------------------------------------------------------------
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
class LinkReq(Request):
|
|
2060
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
2061
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
|
|
2062
|
+
topic: Annotated[bool, opt("--topic", help="Link into the comment thread.")] = False
|
|
2063
|
+
comment: Annotated[
|
|
2064
|
+
int | None, opt("--comment", metavar="ID", help="Link to a specific comment.")
|
|
2065
|
+
] = None
|
|
2066
|
+
single: Annotated[bool, opt("--single", help="Link to this message only (?single).")] = False
|
|
2067
|
+
embed: Annotated[bool, opt("--embed", help="Embeddable link (?embed=1).")] = False
|
|
2068
|
+
at: Annotated[
|
|
2069
|
+
int | None, opt("--at", metavar="DURATION", kind="duration", help="Media timestamp.")
|
|
2070
|
+
] = None
|
|
2071
|
+
poll_option: Annotated[
|
|
2072
|
+
int | None, opt("--poll-option", metavar="N", help="Preselect a poll option.")
|
|
2073
|
+
] = None
|
|
2074
|
+
task: Annotated[int | None, opt("--task", metavar="N", help="Link to a checklist task.")] = None
|
|
2075
|
+
public: Annotated[bool, opt("--public", help="Prefer the @username form.")] = False
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
async def link(ctx: OpContext, req: LinkReq) -> LinkResult:
|
|
2079
|
+
"""Build a t.me link to a message.
|
|
2080
|
+
|
|
2081
|
+
`channels.exportMessageLink` is asked first because only the server knows
|
|
2082
|
+
whether a supergroup is public; falling back to arithmetic for a private
|
|
2083
|
+
one is what makes this work without a second round trip.
|
|
2084
|
+
"""
|
|
2085
|
+
from telethon.tl.functions import channels as ch
|
|
2086
|
+
|
|
2087
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2088
|
+
chat_id = _send.peer_id_of(peer)
|
|
2089
|
+
url = ""
|
|
2090
|
+
public = False
|
|
2091
|
+
if chat_id >= 0:
|
|
2092
|
+
raise UsageError(
|
|
2093
|
+
"a private chat has no shareable message link; only channels and supergroups have one",
|
|
2094
|
+
field="chat",
|
|
2095
|
+
)
|
|
2096
|
+
try:
|
|
2097
|
+
channel = _input_channel(peer)
|
|
2098
|
+
exported = await _client(ctx)(
|
|
2099
|
+
ch.ExportMessageLinkRequest(channel=channel, id=req.msg_id, thread=req.topic or None)
|
|
2100
|
+
)
|
|
2101
|
+
url = str(getattr(exported, "link", "") or "")
|
|
2102
|
+
public = "/c/" not in url
|
|
2103
|
+
except Exception as exc:
|
|
2104
|
+
if "CHANNEL_INVALID" not in str(exc).upper() and "PEER_ID_INVALID" not in str(exc).upper():
|
|
2105
|
+
raise
|
|
2106
|
+
if not url:
|
|
2107
|
+
if chat_id >= 0:
|
|
2108
|
+
raise UsageError(
|
|
2109
|
+
"a private chat has no shareable message link; only channels and supergroups do",
|
|
2110
|
+
field="chat",
|
|
2111
|
+
)
|
|
2112
|
+
url = f"https://t.me/c/{-1000000000000 - chat_id}/{req.msg_id}"
|
|
2113
|
+
|
|
2114
|
+
query: list[str] = []
|
|
2115
|
+
if req.single:
|
|
2116
|
+
query.append("single")
|
|
2117
|
+
if req.embed:
|
|
2118
|
+
query.append("embed=1")
|
|
2119
|
+
if req.comment is not None:
|
|
2120
|
+
query.append(f"comment={req.comment}")
|
|
2121
|
+
if req.at is not None:
|
|
2122
|
+
query.append(f"t={req.at}")
|
|
2123
|
+
if req.poll_option is not None:
|
|
2124
|
+
query.append(f"vote={req.poll_option}")
|
|
2125
|
+
if req.task is not None:
|
|
2126
|
+
query.append(f"task={req.task}")
|
|
2127
|
+
if query:
|
|
2128
|
+
url = f"{url}?{'&'.join(query)}"
|
|
2129
|
+
return LinkResult(link=url, public=public, thread=req.topic, chat_id=chat_id, msg_id=req.msg_id)
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
SPEC_LINK = OperationSpec(
|
|
2133
|
+
id="message.link",
|
|
2134
|
+
request=LinkReq,
|
|
2135
|
+
response=LinkResult,
|
|
2136
|
+
impl=link,
|
|
2137
|
+
summary="Build a t.me link to a message",
|
|
2138
|
+
description=(
|
|
2139
|
+
"A private supergroup produces a `t.me/c/<internal id>/<msg id>` link "
|
|
2140
|
+
"that only members can open; `public` says which form came back."
|
|
2141
|
+
),
|
|
2142
|
+
columns=("link", "public"),
|
|
2143
|
+
example={
|
|
2144
|
+
"link": "https://t.me/durov/42",
|
|
2145
|
+
"public": True,
|
|
2146
|
+
"chat_id": -1001234,
|
|
2147
|
+
"msg_id": 42,
|
|
2148
|
+
},
|
|
2149
|
+
example_args="message link @durov 42",
|
|
2150
|
+
covers=(
|
|
2151
|
+
"groups-channels-admin.export-message-link",
|
|
2152
|
+
"messages-core.message-link-create",
|
|
2153
|
+
"poll.option-deep-link",
|
|
2154
|
+
"todo.task-deep-link",
|
|
2155
|
+
),
|
|
2156
|
+
)
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
class ViewReq(Request):
|
|
2160
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
|
|
2161
|
+
msg_id: Annotated[
|
|
2162
|
+
tuple[str, ...], arg(1, metavar="MSG_ID", variadic=True, help="Post ids or ranges.")
|
|
2163
|
+
]
|
|
2164
|
+
increment: Annotated[
|
|
2165
|
+
bool, opt("--increment", help="Actually register a view (off by default).")
|
|
2166
|
+
] = False
|
|
2167
|
+
listened_seconds: Annotated[
|
|
2168
|
+
int | None,
|
|
2169
|
+
opt("--listened-seconds", metavar="N", help="Report real audio playback seconds."),
|
|
2170
|
+
] = None
|
|
2171
|
+
|
|
2172
|
+
|
|
2173
|
+
async def view_get(ctx: OpContext, req: ViewReq) -> Page[ViewCount]:
|
|
2174
|
+
"""Views and forwards for channel posts.
|
|
2175
|
+
|
|
2176
|
+
`--increment` is opt-in because `increment=True` registers a view
|
|
2177
|
+
server-side: reading a counter must not change it.
|
|
2178
|
+
"""
|
|
2179
|
+
from telethon.tl.functions import messages as fn
|
|
2180
|
+
|
|
2181
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2182
|
+
ids = _ids(req.msg_id)
|
|
2183
|
+
result = await _client(ctx)(
|
|
2184
|
+
fn.GetMessagesViewsRequest(peer=peer, id=ids, increment=req.increment)
|
|
2185
|
+
)
|
|
2186
|
+
items: list[ViewCount] = []
|
|
2187
|
+
for index, view in enumerate(getattr(result, "views", None) or []):
|
|
2188
|
+
replies = getattr(view, "replies", None)
|
|
2189
|
+
items.append(
|
|
2190
|
+
ViewCount(
|
|
2191
|
+
msg_id=ids[index] if index < len(ids) else 0,
|
|
2192
|
+
views=getattr(view, "views", None),
|
|
2193
|
+
forwards=getattr(view, "forwards", None),
|
|
2194
|
+
replies=getattr(replies, "replies", None),
|
|
2195
|
+
)
|
|
2196
|
+
)
|
|
2197
|
+
if req.listened_seconds is not None:
|
|
2198
|
+
await _client(ctx)(
|
|
2199
|
+
fn.ReportMusicListenRequest(peer=peer, id=ids[0], duration=req.listened_seconds)
|
|
2200
|
+
)
|
|
2201
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
SPEC_VIEW_GET = OperationSpec(
|
|
2205
|
+
id="message.view.get",
|
|
2206
|
+
request=ViewReq,
|
|
2207
|
+
response=Page[ViewCount],
|
|
2208
|
+
impl=view_get,
|
|
2209
|
+
summary="Views and forwards counters for channel posts",
|
|
2210
|
+
aliases=("message.views",),
|
|
2211
|
+
tags=frozenset({"mutating-checked"}),
|
|
2212
|
+
columns=("msg_id", "views", "forwards"),
|
|
2213
|
+
example={"items": [{"msg_id": 42, "views": 1200, "forwards": 8}], "has_more": False},
|
|
2214
|
+
example_args="message view get @durov 42",
|
|
2215
|
+
covers=("messages-core.message-views-forwards", "messages-core.report-music-listen"),
|
|
2216
|
+
)
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
class ReadReceiptReq(Request):
|
|
2220
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
2221
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
|
|
2222
|
+
users: Annotated[bool, opt(help="Resolve reader ids to user objects.")] = True
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
async def read_receipt_list(ctx: OpContext, req: ReadReceiptReq) -> ReadReceipts:
|
|
2226
|
+
"""Who read this message, and when.
|
|
2227
|
+
|
|
2228
|
+
Every refusal here is reported as `unavailable_reason`, never as an
|
|
2229
|
+
error: "the group is too large for read marks" and "they hide their read
|
|
2230
|
+
date" are facts about the world, not failures of the request.
|
|
2231
|
+
"""
|
|
2232
|
+
from telethon.tl.functions import messages as fn
|
|
2233
|
+
|
|
2234
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2235
|
+
client = _client(ctx)
|
|
2236
|
+
out = ReadReceipts(msg_id=req.msg_id)
|
|
2237
|
+
try:
|
|
2238
|
+
participants = await client(
|
|
2239
|
+
fn.GetMessageReadParticipantsRequest(peer=peer, msg_id=req.msg_id)
|
|
2240
|
+
)
|
|
2241
|
+
out.readers = [int(getattr(p, "user_id", p)) for p in (participants or [])]
|
|
2242
|
+
except Exception as exc:
|
|
2243
|
+
out.unavailable_reason = _receipt_reason(exc)
|
|
2244
|
+
|
|
2245
|
+
try:
|
|
2246
|
+
read_date = await client(fn.GetOutboxReadDateRequest(peer=peer, msg_id=req.msg_id))
|
|
2247
|
+
out.read_date = fmt_dt(getattr(read_date, "date", None))
|
|
2248
|
+
out.read_date_unix = to_unix(getattr(read_date, "date", None))
|
|
2249
|
+
except Exception as exc:
|
|
2250
|
+
if out.unavailable_reason is None:
|
|
2251
|
+
out.unavailable_reason = _receipt_reason(exc)
|
|
2252
|
+
|
|
2253
|
+
if req.users and out.readers:
|
|
2254
|
+
for reader in out.readers:
|
|
2255
|
+
try:
|
|
2256
|
+
out.users.append(entity_to_peer(await client.get_entity(reader)))
|
|
2257
|
+
except Exception:
|
|
2258
|
+
continue
|
|
2259
|
+
if not out.readers and out.read_date is None and out.unavailable_reason is None:
|
|
2260
|
+
out.expired = True
|
|
2261
|
+
return out
|
|
2262
|
+
|
|
2263
|
+
|
|
2264
|
+
def _receipt_reason(exc: BaseException) -> str:
|
|
2265
|
+
text = str(exc).upper()
|
|
2266
|
+
if "PRIVACY" in text:
|
|
2267
|
+
return "one side hides read dates, so Telegram will not say when it was read"
|
|
2268
|
+
if "TOO_OLD" in text or "EXPIRED" in text:
|
|
2269
|
+
return "the read-mark window for this chat has expired"
|
|
2270
|
+
if "CHAT_TOO_BIG" in text or "PARTICIPANTS_TOO_FEW" in text:
|
|
2271
|
+
return "this chat is outside Telegram's read-mark size limits"
|
|
2272
|
+
return f"unavailable: {exc}"
|
|
2273
|
+
|
|
2274
|
+
|
|
2275
|
+
SPEC_READ_RECEIPT_LIST = OperationSpec(
|
|
2276
|
+
id="message.read-receipt.list",
|
|
2277
|
+
request=ReadReceiptReq,
|
|
2278
|
+
response=ReadReceipts,
|
|
2279
|
+
impl=read_receipt_list,
|
|
2280
|
+
summary="Who read this message, and when",
|
|
2281
|
+
aliases=("message.readers", "msg.read-receipts", "message.read-receipts"),
|
|
2282
|
+
columns=("msg_id", "read_date"),
|
|
2283
|
+
example={"msg_id": 12345, "readers": [777], "read_date": "2026-09-03T09:20:00Z"},
|
|
2284
|
+
example_args="message read-receipt list @alice 12345",
|
|
2285
|
+
covers=("messages-core.read-date-private", "messages-core.read-participants-group"),
|
|
2286
|
+
)
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
class ScheduledSendReq(Request):
|
|
2290
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
2291
|
+
msg_id: Annotated[
|
|
2292
|
+
tuple[str, ...], arg(1, metavar="MSG_ID", variadic=True, help="Scheduled message ids.")
|
|
2293
|
+
]
|
|
2294
|
+
|
|
2295
|
+
|
|
2296
|
+
async def scheduled_send(ctx: OpContext, req: ScheduledSendReq) -> Page[ScheduledSent]:
|
|
2297
|
+
"""Send scheduled messages now, ahead of their time."""
|
|
2298
|
+
from telethon.tl.functions import messages as fn
|
|
2299
|
+
|
|
2300
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2301
|
+
chat_id = _send.peer_id_of(peer)
|
|
2302
|
+
ids = _ids(req.msg_id)
|
|
2303
|
+
result = await _client(ctx)(fn.SendScheduledMessagesRequest(peer=peer, id=ids))
|
|
2304
|
+
items = [
|
|
2305
|
+
ScheduledSent(id=m.id, chat_id=chat_id, date=m.date, date_unix=m.date_unix)
|
|
2306
|
+
for m in _send.messages_from_updates(result, chat_id=chat_id)
|
|
2307
|
+
]
|
|
2308
|
+
if not items:
|
|
2309
|
+
items = [ScheduledSent(id=i, chat_id=chat_id) for i in ids]
|
|
2310
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
SPEC_SCHEDULED_SEND = OperationSpec(
|
|
2314
|
+
id="message.scheduled.send",
|
|
2315
|
+
request=ScheduledSendReq,
|
|
2316
|
+
response=Page[ScheduledSent],
|
|
2317
|
+
impl=scheduled_send,
|
|
2318
|
+
summary="Send scheduled messages now",
|
|
2319
|
+
description=(
|
|
2320
|
+
"Listing, editing and deleting the drawer are flags elsewhere: "
|
|
2321
|
+
"`message list --scheduled`, `message edit --schedule`, "
|
|
2322
|
+
"`message delete --scheduled`."
|
|
2323
|
+
),
|
|
2324
|
+
mutating=True,
|
|
2325
|
+
rate_class="send",
|
|
2326
|
+
columns=("id", "chat_id", "date"),
|
|
2327
|
+
example={"items": [{"id": 999, "chat_id": 777123}], "has_more": False},
|
|
2328
|
+
example_args="message scheduled send @alice 999",
|
|
2329
|
+
covers=("messages-core.scheduled-send-now",),
|
|
2330
|
+
)
|
|
2331
|
+
|
|
2332
|
+
|
|
2333
|
+
# ---------------------------------------------------------------------------
|
|
2334
|
+
# text services: entities, translate, transcribe, summarize, compose, preview
|
|
2335
|
+
# ---------------------------------------------------------------------------
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
class EntityListReq(Request):
|
|
2339
|
+
text: Annotated[
|
|
2340
|
+
str, arg(0, metavar="TEXT", required=False, help="Text to parse; '-' reads stdin.")
|
|
2341
|
+
] = ""
|
|
2342
|
+
parse: Annotated[str | None, choice("md", "html", "none", help="Dialect to parse.")] = None
|
|
2343
|
+
stdin: Annotated[bool, opt("--stdin", help="Read from stdin.")] = False
|
|
2344
|
+
offset_units: Annotated[
|
|
2345
|
+
str | None, choice("utf16", "codepoint", "byte", help="Units for the emitted offsets.")
|
|
2346
|
+
] = None
|
|
2347
|
+
render: Annotated[
|
|
2348
|
+
str | None, choice("md", "html", "text", help="Render an entity vector back to text.")
|
|
2349
|
+
] = None
|
|
2350
|
+
entities: Annotated[
|
|
2351
|
+
str | None, opt("--entities", metavar="JSON", kind="json", help="Entities to render.")
|
|
2352
|
+
] = None
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
#: Entities the server re-derives. Sending them back is an error, and telling
|
|
2356
|
+
#: a caller which ones they are is the whole point of splitting them out.
|
|
2357
|
+
AUTO_ENTITIES = frozenset(
|
|
2358
|
+
{"url", "email", "mention", "hashtag", "cashtag", "bot_command", "phone", "bank_card"}
|
|
2359
|
+
)
|
|
2360
|
+
|
|
2361
|
+
#: The automatic entities, detected locally. Telethon's markdown and HTML
|
|
2362
|
+
#: parsers do not emit them — the *server* does, on receipt — so a caller
|
|
2363
|
+
#: asking "what will Telegram find in this text" gets nothing back unless
|
|
2364
|
+
#: they are re-derived here. Deliberately close to Telegram's own rules and
|
|
2365
|
+
#: deliberately not authoritative: the report says these are what the server
|
|
2366
|
+
#: will add, not what tlgr sends.
|
|
2367
|
+
_AUTO_PATTERNS: tuple[tuple[str, str], ...] = (
|
|
2368
|
+
("url", r"(?:https?://|www\.)[^\s<>\"]+"),
|
|
2369
|
+
("email", r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"),
|
|
2370
|
+
("mention", r"(?<![\w@])@[A-Za-z][A-Za-z0-9_]{3,31}"),
|
|
2371
|
+
("hashtag", r"(?<![\w#])#[^\s#@$]{1,64}"),
|
|
2372
|
+
("cashtag", r"(?<![\w$])\$[A-Z]{1,8}\b"),
|
|
2373
|
+
("bot_command", r"(?<![\w/])/[A-Za-z0-9_]{1,64}(?:@[A-Za-z0-9_]{4,32})?"),
|
|
2374
|
+
("phone", r"(?<![\d+])\+\d[\d \-()]{6,18}\d"),
|
|
2375
|
+
)
|
|
2376
|
+
|
|
2377
|
+
|
|
2378
|
+
def _auto_entities(text: str) -> list[MessageEntity]:
|
|
2379
|
+
"""Re-derive the entities Telegram adds server-side, in UTF-16 offsets."""
|
|
2380
|
+
found: list[MessageEntity] = []
|
|
2381
|
+
taken: list[tuple[int, int]] = []
|
|
2382
|
+
for kind, pattern in _AUTO_PATTERNS:
|
|
2383
|
+
for match in re.finditer(pattern, text):
|
|
2384
|
+
start, end = match.span()
|
|
2385
|
+
if any(start < other_end and end > other_start for other_start, other_end in taken):
|
|
2386
|
+
continue
|
|
2387
|
+
taken.append((start, end))
|
|
2388
|
+
offset = utf16_len(text[:start])
|
|
2389
|
+
found.append(MessageEntity(type=kind, offset=offset, length=utf16_len(text[start:end])))
|
|
2390
|
+
found.sort(key=lambda entity: (entity.offset, entity.length))
|
|
2391
|
+
return found
|
|
2392
|
+
|
|
2393
|
+
|
|
2394
|
+
async def entity_list(ctx: OpContext, req: EntityListReq) -> EntityReport:
|
|
2395
|
+
"""Show what a parse mode did, in the units Telegram counts in.
|
|
2396
|
+
|
|
2397
|
+
Runs locally, without an account: this is the answer to "why is my bold
|
|
2398
|
+
in the wrong place", and it is UTF-16 offsets every time.
|
|
2399
|
+
"""
|
|
2400
|
+
text, entities = _send.body(req.text, parse=req.parse, entities=req.entities, stdin=req.stdin)
|
|
2401
|
+
manual = [e for e in entities if e.type not in AUTO_ENTITIES]
|
|
2402
|
+
automatic = [e for e in entities if e.type in AUTO_ENTITIES] + _auto_entities(text)
|
|
2403
|
+
if req.offset_units and req.offset_units != "utf16":
|
|
2404
|
+
manual = [_recount(text, e, req.offset_units) for e in manual]
|
|
2405
|
+
automatic = [_recount(text, e, req.offset_units) for e in automatic]
|
|
2406
|
+
report = EntityReport(
|
|
2407
|
+
text=text,
|
|
2408
|
+
entities=manual,
|
|
2409
|
+
auto_entities=automatic,
|
|
2410
|
+
length_utf16=utf16_len(text),
|
|
2411
|
+
would_split=len(_send.split_text(text, entities)),
|
|
2412
|
+
)
|
|
2413
|
+
if req.render:
|
|
2414
|
+
stub = Message(id=0, chat_id=0, date="", date_unix=0, text=text, entities=entities)
|
|
2415
|
+
report.rendered = _render(stub, req.render)
|
|
2416
|
+
return report
|
|
2417
|
+
|
|
2418
|
+
|
|
2419
|
+
def _recount(text: str, entity: MessageEntity, units: str) -> MessageEntity:
|
|
2420
|
+
"""Re-express a UTF-16 offset in code points or bytes."""
|
|
2421
|
+
raw = text.encode("utf-16-le")
|
|
2422
|
+
prefix = raw[: entity.offset * 2].decode("utf-16-le", "ignore")
|
|
2423
|
+
body = raw[entity.offset * 2 : (entity.offset + entity.length) * 2].decode(
|
|
2424
|
+
"utf-16-le", "ignore"
|
|
2425
|
+
)
|
|
2426
|
+
if units == "codepoint":
|
|
2427
|
+
offset, length = len(prefix), len(body)
|
|
2428
|
+
else:
|
|
2429
|
+
offset, length = len(prefix.encode()), len(body.encode())
|
|
2430
|
+
return MessageEntity(
|
|
2431
|
+
type=entity.type,
|
|
2432
|
+
offset=offset,
|
|
2433
|
+
length=length,
|
|
2434
|
+
url=entity.url,
|
|
2435
|
+
user_id=entity.user_id,
|
|
2436
|
+
language=entity.language,
|
|
2437
|
+
document_id=entity.document_id,
|
|
2438
|
+
collapsed=entity.collapsed,
|
|
2439
|
+
)
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
SPEC_ENTITY_LIST = OperationSpec(
|
|
2443
|
+
id="message.entity.list",
|
|
2444
|
+
request=EntityListReq,
|
|
2445
|
+
response=EntityReport,
|
|
2446
|
+
impl=entity_list,
|
|
2447
|
+
summary="Parse or inspect formatted text and its entities",
|
|
2448
|
+
description=(
|
|
2449
|
+
"Offsets are UTF-16 code units, which is the classic third-party "
|
|
2450
|
+
"client bug: an emoji is one character and two units. Automatic "
|
|
2451
|
+
"entities are listed separately because the server re-derives them "
|
|
2452
|
+
"and sending them back is an error."
|
|
2453
|
+
),
|
|
2454
|
+
aliases=("message.entities",),
|
|
2455
|
+
needs_account=False,
|
|
2456
|
+
needs_auth=False,
|
|
2457
|
+
surface=Surface.LOCAL,
|
|
2458
|
+
rate_class="local",
|
|
2459
|
+
timeout_s=10,
|
|
2460
|
+
columns=("text", "length_utf16"),
|
|
2461
|
+
example={
|
|
2462
|
+
"text": "hello world",
|
|
2463
|
+
"entities": [{"type": "bold", "offset": 0, "length": 5}],
|
|
2464
|
+
"length_utf16": 11,
|
|
2465
|
+
"would_split": 1,
|
|
2466
|
+
},
|
|
2467
|
+
example_args='message entity list --parse md "**hello** world"',
|
|
2468
|
+
covers=(
|
|
2469
|
+
"messages-core.entities-detect-local",
|
|
2470
|
+
"messages-core.format-entity-offsets-utf16",
|
|
2471
|
+
"messages-core.format-raw-entities-json",
|
|
2472
|
+
),
|
|
2473
|
+
)
|
|
2474
|
+
|
|
2475
|
+
|
|
2476
|
+
class TranslateReq(Request):
|
|
2477
|
+
chat: Annotated[
|
|
2478
|
+
PeerRef | None, arg(0, metavar="CHAT", required=False, kind="peer", help="Chat.")
|
|
2479
|
+
] = None
|
|
2480
|
+
msg_id: Annotated[
|
|
2481
|
+
tuple[str, ...],
|
|
2482
|
+
arg(1, metavar="MSG_ID", required=False, variadic=True, help="Message ids."),
|
|
2483
|
+
] = ()
|
|
2484
|
+
lang: Annotated[
|
|
2485
|
+
str | None, opt("--lang", "--to", metavar="CODE", help="Target language code.")
|
|
2486
|
+
] = None
|
|
2487
|
+
text: Annotated[str | None, opt("--text", help="Translate this text instead.")] = None
|
|
2488
|
+
rich: Annotated[bool, opt("--rich", help="Translate a layer-229 rich body.")] = False
|
|
2489
|
+
|
|
2490
|
+
|
|
2491
|
+
async def translate(ctx: OpContext, req: TranslateReq) -> Page[Translation]:
|
|
2492
|
+
"""Translate messages, or arbitrary text, into a target language."""
|
|
2493
|
+
from telethon.tl import types
|
|
2494
|
+
from telethon.tl.functions import messages as fn
|
|
2495
|
+
|
|
2496
|
+
if req.rich:
|
|
2497
|
+
_send.require_supported(
|
|
2498
|
+
"--rich", "messages.translateRichMessage is layer 229 and absent from Telethon 1.44"
|
|
2499
|
+
)
|
|
2500
|
+
if not req.lang:
|
|
2501
|
+
raise UsageError("--lang is required (a two-letter language code)", field="lang")
|
|
2502
|
+
|
|
2503
|
+
kwargs: dict[str, Any] = {"to_lang": req.lang}
|
|
2504
|
+
ids: list[int] = []
|
|
2505
|
+
if req.text is not None:
|
|
2506
|
+
plain, entities = _send.body(req.text, parse="none")
|
|
2507
|
+
kwargs["text"] = [
|
|
2508
|
+
types.TextWithEntities(text=plain, entities=_send.tl_entities(entities) or [])
|
|
2509
|
+
]
|
|
2510
|
+
else:
|
|
2511
|
+
if req.chat is None:
|
|
2512
|
+
raise UsageError("give a chat and message ids, or --text", field="chat")
|
|
2513
|
+
kwargs["peer"] = await _send.resolve(ctx, req.chat)
|
|
2514
|
+
ids = _ids(req.msg_id)
|
|
2515
|
+
if not ids:
|
|
2516
|
+
raise UsageError("give at least one message id to translate", field="msg_id")
|
|
2517
|
+
kwargs["id"] = ids
|
|
2518
|
+
|
|
2519
|
+
result = await _client(ctx)(fn.TranslateTextRequest(**kwargs))
|
|
2520
|
+
items: list[Translation] = []
|
|
2521
|
+
for index, piece in enumerate(getattr(result, "result", None) or []):
|
|
2522
|
+
items.append(
|
|
2523
|
+
Translation(
|
|
2524
|
+
msg_id=ids[index] if index < len(ids) else None,
|
|
2525
|
+
text=str(getattr(piece, "text", "") or ""),
|
|
2526
|
+
entities=message_entities(piece),
|
|
2527
|
+
lang=req.lang,
|
|
2528
|
+
)
|
|
2529
|
+
)
|
|
2530
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
SPEC_TRANSLATE = OperationSpec(
|
|
2534
|
+
id="message.translate",
|
|
2535
|
+
request=TranslateReq,
|
|
2536
|
+
response=Page[Translation],
|
|
2537
|
+
impl=translate,
|
|
2538
|
+
summary="Translate messages or text into a target language",
|
|
2539
|
+
columns=("msg_id", "lang", "text"),
|
|
2540
|
+
example={"items": [{"msg_id": 12345, "text": "on my way", "lang": "en"}], "has_more": False},
|
|
2541
|
+
example_args="message translate @alice 12345 --lang en",
|
|
2542
|
+
covers=("dialogs.translate-messages", "messages-core.translate-message"),
|
|
2543
|
+
covers_partial=("bots.rich-message-translate", "richmsg.translate"),
|
|
2544
|
+
coverage_note="Rich-body translation is layer 229 and refused with NOT_SUPPORTED.",
|
|
2545
|
+
)
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
class TranscribeReq(Request):
|
|
2549
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
2550
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Voice or round video.")]
|
|
2551
|
+
rate: Annotated[
|
|
2552
|
+
str | None, choice("good", "bad", help="Rate a transcription already produced.")
|
|
2553
|
+
] = None
|
|
2554
|
+
wait: Annotated[bool, opt(help="Poll until the transcription is final.")] = True
|
|
2555
|
+
|
|
2556
|
+
|
|
2557
|
+
async def transcribe(ctx: OpContext, req: TranscribeReq) -> Transcription:
|
|
2558
|
+
"""Transcribe a voice note or round video.
|
|
2559
|
+
|
|
2560
|
+
The first response is usually `pending`; the final text arrives in an
|
|
2561
|
+
update, so `--wait` re-asks rather than reporting an empty transcript as
|
|
2562
|
+
the answer.
|
|
2563
|
+
"""
|
|
2564
|
+
from telethon.tl.functions import messages as fn
|
|
2565
|
+
|
|
2566
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2567
|
+
client = _client(ctx)
|
|
2568
|
+
result = await client(fn.TranscribeAudioRequest(peer=peer, msg_id=req.msg_id))
|
|
2569
|
+
out = Transcription(
|
|
2570
|
+
msg_id=req.msg_id,
|
|
2571
|
+
text=str(getattr(result, "text", "") or ""),
|
|
2572
|
+
pending=bool(getattr(result, "pending", False)),
|
|
2573
|
+
transcription_id=getattr(result, "transcription_id", None),
|
|
2574
|
+
)
|
|
2575
|
+
attempts = 0
|
|
2576
|
+
while req.wait and out.pending and attempts < 10:
|
|
2577
|
+
attempts += 1
|
|
2578
|
+
await asyncio.sleep(1.0)
|
|
2579
|
+
result = await client(fn.TranscribeAudioRequest(peer=peer, msg_id=req.msg_id))
|
|
2580
|
+
out.text = str(getattr(result, "text", "") or out.text)
|
|
2581
|
+
out.pending = bool(getattr(result, "pending", False))
|
|
2582
|
+
if req.rate and out.transcription_id is not None:
|
|
2583
|
+
await client(
|
|
2584
|
+
fn.RateTranscribedAudioRequest(
|
|
2585
|
+
peer=peer,
|
|
2586
|
+
msg_id=req.msg_id,
|
|
2587
|
+
transcription_id=out.transcription_id,
|
|
2588
|
+
good=req.rate == "good",
|
|
2589
|
+
)
|
|
2590
|
+
)
|
|
2591
|
+
out.rated = req.rate
|
|
2592
|
+
return out
|
|
2593
|
+
|
|
2594
|
+
|
|
2595
|
+
SPEC_TRANSCRIBE = OperationSpec(
|
|
2596
|
+
id="message.transcribe",
|
|
2597
|
+
request=TranscribeReq,
|
|
2598
|
+
response=Transcription,
|
|
2599
|
+
impl=transcribe,
|
|
2600
|
+
summary="Transcribe a voice note or round video",
|
|
2601
|
+
tags=frozenset({"mutating-checked"}),
|
|
2602
|
+
timeout_s=180,
|
|
2603
|
+
columns=("msg_id", "text", "pending"),
|
|
2604
|
+
example={"msg_id": 12345, "text": "call me back", "pending": False},
|
|
2605
|
+
example_args="message transcribe @alice 12345",
|
|
2606
|
+
covers=("media.transcribe-voice",),
|
|
2607
|
+
)
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
class SummarizeReq(Request):
|
|
2611
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
2612
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
|
|
2613
|
+
lang: Annotated[
|
|
2614
|
+
str | None, opt("--lang", "--to", metavar="CODE", help="Summarize into this language.")
|
|
2615
|
+
] = None
|
|
2616
|
+
tone: Annotated[str | None, opt("--tone", metavar="SLUG", help="Composition tone.")] = None
|
|
2617
|
+
|
|
2618
|
+
|
|
2619
|
+
async def summarize(ctx: OpContext, req: SummarizeReq) -> SummaryResult:
|
|
2620
|
+
"""Summarize a long message with Telegram's own AI."""
|
|
2621
|
+
from telethon.tl.functions import messages as fn
|
|
2622
|
+
|
|
2623
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2624
|
+
result = await _client(ctx)(
|
|
2625
|
+
fn.SummarizeTextRequest(peer=peer, id=req.msg_id, to_lang=req.lang, tone=req.tone)
|
|
2626
|
+
)
|
|
2627
|
+
piece = getattr(result, "result", None) or result
|
|
2628
|
+
return SummaryResult(
|
|
2629
|
+
msg_id=req.msg_id,
|
|
2630
|
+
text=str(getattr(piece, "text", "") or ""),
|
|
2631
|
+
entities=message_entities(piece),
|
|
2632
|
+
quota_left=getattr(result, "quota_left", None),
|
|
2633
|
+
)
|
|
2634
|
+
|
|
2635
|
+
|
|
2636
|
+
SPEC_SUMMARIZE = OperationSpec(
|
|
2637
|
+
id="message.summarize",
|
|
2638
|
+
request=SummarizeReq,
|
|
2639
|
+
response=SummaryResult,
|
|
2640
|
+
impl=summarize,
|
|
2641
|
+
summary="Summarize a long message with AI",
|
|
2642
|
+
description="Shares its quota with `message compose`; Premium raises it.",
|
|
2643
|
+
timeout_s=180,
|
|
2644
|
+
columns=("msg_id", "text"),
|
|
2645
|
+
example={"msg_id": 12345, "text": "They are running late."},
|
|
2646
|
+
example_args="message summarize @alice 12345",
|
|
2647
|
+
covers=("messages-core.summarize-message",),
|
|
2648
|
+
)
|
|
2649
|
+
|
|
2650
|
+
|
|
2651
|
+
class ComposeReq(Request):
|
|
2652
|
+
text: Annotated[
|
|
2653
|
+
str, arg(0, metavar="TEXT", required=False, help="Text to rewrite; '-' reads stdin.")
|
|
2654
|
+
] = ""
|
|
2655
|
+
stdin: Annotated[bool, opt("--stdin", help="Read from stdin.")] = False
|
|
2656
|
+
proofread: Annotated[bool, opt("--proofread", help="Fix grammar and spelling.")] = False
|
|
2657
|
+
translate: Annotated[
|
|
2658
|
+
str | None, opt("--translate", metavar="LANG", help="Translate into this language.")
|
|
2659
|
+
] = None
|
|
2660
|
+
tone: Annotated[str | None, opt("--tone", metavar="SLUG", help="Rewrite in this tone.")] = None
|
|
2661
|
+
emojify: Annotated[bool, opt("--emojify", help="Add emoji.")] = False
|
|
2662
|
+
rich: Annotated[
|
|
2663
|
+
str | None, opt("--rich", metavar="PATH", kind="path", help="Compose a rich body.")
|
|
2664
|
+
] = None
|
|
2665
|
+
diff: Annotated[
|
|
2666
|
+
str | None, choice("unified", "inline", "json", help="Shape of the proofreading diff.")
|
|
2667
|
+
] = None
|
|
2668
|
+
send_to: Annotated[
|
|
2669
|
+
PeerRef | None,
|
|
2670
|
+
opt("--send-to", metavar="CHAT", kind="peer", help="Send the result instead of printing."),
|
|
2671
|
+
] = None
|
|
2672
|
+
|
|
2673
|
+
|
|
2674
|
+
async def compose(ctx: OpContext, req: ComposeReq) -> ComposeResult:
|
|
2675
|
+
"""Rewrite text with Telegram's AI: proofread, translate, tone, emojify.
|
|
2676
|
+
|
|
2677
|
+
The diff entities only mean something when proofreading is the sole mode,
|
|
2678
|
+
because they are expressed against the original text; mixing a
|
|
2679
|
+
translation in makes them unreadable, so that combination is warned about
|
|
2680
|
+
rather than silently reported.
|
|
2681
|
+
"""
|
|
2682
|
+
from telethon.tl import types
|
|
2683
|
+
from telethon.tl.functions import messages as fn
|
|
2684
|
+
|
|
2685
|
+
if req.rich:
|
|
2686
|
+
_send.require_supported(
|
|
2687
|
+
"--rich", "messages.composeRichMessageWithAI is layer 229 and absent from Telethon 1.44"
|
|
2688
|
+
)
|
|
2689
|
+
text, entities = _send.body(req.text, parse="none", stdin=req.stdin)
|
|
2690
|
+
if not text:
|
|
2691
|
+
raise UsageError("give some text to rewrite", field="text")
|
|
2692
|
+
if req.proofread and (req.translate or req.tone or req.emojify):
|
|
2693
|
+
ctx.warn("diff entities are only produced when --proofread is the only mode")
|
|
2694
|
+
|
|
2695
|
+
tone = types.InputAiComposeToneSlug(slug=req.tone) if req.tone else None
|
|
2696
|
+
result = await _client(ctx)(
|
|
2697
|
+
fn.ComposeMessageWithAIRequest(
|
|
2698
|
+
text=types.TextWithEntities(text=text, entities=_send.tl_entities(entities) or []),
|
|
2699
|
+
proofread=req.proofread or None,
|
|
2700
|
+
emojify=req.emojify or None,
|
|
2701
|
+
translate_to_lang=req.translate,
|
|
2702
|
+
tone=tone,
|
|
2703
|
+
)
|
|
2704
|
+
)
|
|
2705
|
+
piece = getattr(result, "result", None) or result
|
|
2706
|
+
diff = getattr(result, "diff", None)
|
|
2707
|
+
out = ComposeResult(
|
|
2708
|
+
text=str(getattr(piece, "text", "") or ""),
|
|
2709
|
+
entities=message_entities(piece),
|
|
2710
|
+
diff_text=str(getattr(diff, "text", "")) if diff is not None else None,
|
|
2711
|
+
diff_entities=message_entities(diff) if diff is not None else [],
|
|
2712
|
+
quota_left=getattr(result, "quota_left", None),
|
|
2713
|
+
)
|
|
2714
|
+
if req.send_to is not None:
|
|
2715
|
+
out.sent = await send(ctx, SendReq(chat=req.send_to, text=out.text, parse="none"))
|
|
2716
|
+
return out
|
|
2717
|
+
|
|
2718
|
+
|
|
2719
|
+
SPEC_COMPOSE = OperationSpec(
|
|
2720
|
+
id="message.compose",
|
|
2721
|
+
request=ComposeReq,
|
|
2722
|
+
response=ComposeResult,
|
|
2723
|
+
impl=compose,
|
|
2724
|
+
summary="Rewrite text with Telegram's AI",
|
|
2725
|
+
tags=frozenset({"mutating-checked"}),
|
|
2726
|
+
timeout_s=180,
|
|
2727
|
+
columns=("text",),
|
|
2728
|
+
example={"text": "I am on my way.", "quota_left": 9},
|
|
2729
|
+
example_args='message compose --proofread "im on my way"',
|
|
2730
|
+
covers=(
|
|
2731
|
+
"ai.compose",
|
|
2732
|
+
"appearance.ai-compose-run",
|
|
2733
|
+
"messages-core.ai-compose",
|
|
2734
|
+
"messages-core.format-diff-entities",
|
|
2735
|
+
),
|
|
2736
|
+
covers_partial=("richmsg.compose-ai",),
|
|
2737
|
+
coverage_note="Composing a rich body needs layer 229 and is refused with NOT_SUPPORTED.",
|
|
2738
|
+
)
|
|
2739
|
+
|
|
2740
|
+
|
|
2741
|
+
class PreviewReq(Request):
|
|
2742
|
+
url: Annotated[str, arg(0, metavar="URL", help="URL to preview.")]
|
|
2743
|
+
refresh: Annotated[bool, opt("--refresh", help="Bypass the cached webpage hash.")] = False
|
|
2744
|
+
|
|
2745
|
+
|
|
2746
|
+
async def preview(ctx: OpContext, req: PreviewReq) -> WebPagePreview:
|
|
2747
|
+
"""Fetch the link preview Telegram would attach to a URL.
|
|
2748
|
+
|
|
2749
|
+
`hash=0` always: the cached-hash protocol saves a round trip only for a
|
|
2750
|
+
client keeping a webpage cache, and tlgr keeps none, so asking for the
|
|
2751
|
+
cached form would just answer `webPageNotModified` with nothing in it.
|
|
2752
|
+
"""
|
|
2753
|
+
from telethon.tl.functions import messages as fn
|
|
2754
|
+
|
|
2755
|
+
from tlgr.ops._serialize import media_summary, photo_summary
|
|
2756
|
+
|
|
2757
|
+
result = await _client(ctx)(fn.GetWebPageRequest(url=req.url, hash=0))
|
|
2758
|
+
page = getattr(result, "webpage", None) or result
|
|
2759
|
+
name = type(page).__name__
|
|
2760
|
+
document = getattr(page, "document", None)
|
|
2761
|
+
photo = getattr(page, "photo", None)
|
|
2762
|
+
|
|
2763
|
+
return WebPagePreview(
|
|
2764
|
+
url=str(getattr(page, "url", req.url) or req.url),
|
|
2765
|
+
type=getattr(page, "type", None),
|
|
2766
|
+
site_name=getattr(page, "site_name", None),
|
|
2767
|
+
title=getattr(page, "title", None),
|
|
2768
|
+
description=getattr(page, "description", None),
|
|
2769
|
+
photo=photo_summary(photo),
|
|
2770
|
+
document=media_summary(page) if document is not None else None,
|
|
2771
|
+
has_large_media=bool(getattr(page, "has_large_media", False)),
|
|
2772
|
+
cached_page=getattr(page, "cached_page", None) is not None,
|
|
2773
|
+
pending=name == "WebPagePending",
|
|
2774
|
+
)
|
|
2775
|
+
|
|
2776
|
+
|
|
2777
|
+
SPEC_PREVIEW = OperationSpec(
|
|
2778
|
+
id="message.preview",
|
|
2779
|
+
request=PreviewReq,
|
|
2780
|
+
response=WebPagePreview,
|
|
2781
|
+
impl=preview,
|
|
2782
|
+
summary="Fetch the link preview Telegram would attach to a URL",
|
|
2783
|
+
description="Sending that preview as the media is `message send --preview-url`.",
|
|
2784
|
+
columns=("url", "site_name", "title"),
|
|
2785
|
+
example={"url": "https://telegram.org", "site_name": "Telegram", "title": "Telegram"},
|
|
2786
|
+
example_args="message preview https://telegram.org",
|
|
2787
|
+
covers=("webpage.preview-fetch",),
|
|
2788
|
+
)
|
|
2789
|
+
|
|
2790
|
+
|
|
2791
|
+
# ---------------------------------------------------------------------------
|
|
2792
|
+
# catalogs: effects and dice
|
|
2793
|
+
# ---------------------------------------------------------------------------
|
|
2794
|
+
|
|
2795
|
+
|
|
2796
|
+
class EffectListReq(Request):
|
|
2797
|
+
premium_only: Annotated[
|
|
2798
|
+
bool, opt("--premium-only", help="Only effects that require Premium.")
|
|
2799
|
+
] = False
|
|
2800
|
+
refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
|
|
2801
|
+
|
|
2802
|
+
|
|
2803
|
+
async def effect_list(ctx: OpContext, req: EffectListReq) -> Page[Effect]:
|
|
2804
|
+
"""The animated message effects `--effect` accepts."""
|
|
2805
|
+
from telethon.tl.functions import messages as fn
|
|
2806
|
+
|
|
2807
|
+
limit, _ = _window(ctx, "message.effect.list", PageKind.LOCAL, default=100)
|
|
2808
|
+
result = await _client(ctx)(fn.GetAvailableEffectsRequest(hash=0))
|
|
2809
|
+
items = [
|
|
2810
|
+
Effect(
|
|
2811
|
+
id=int(getattr(effect, "id", 0) or 0),
|
|
2812
|
+
emoticon=str(getattr(effect, "emoticon", "") or ""),
|
|
2813
|
+
premium_required=bool(getattr(effect, "premium_required", False)),
|
|
2814
|
+
static_icon_id=getattr(effect, "static_icon_id", None),
|
|
2815
|
+
effect_animation_id=getattr(effect, "effect_animation_id", None),
|
|
2816
|
+
effect_sticker_id=getattr(effect, "effect_sticker_id", None),
|
|
2817
|
+
)
|
|
2818
|
+
for effect in (getattr(result, "effects", None) or [])
|
|
2819
|
+
]
|
|
2820
|
+
if req.premium_only:
|
|
2821
|
+
items = [effect for effect in items if effect.premium_required]
|
|
2822
|
+
return Page(items=items[:limit], has_more=len(items) > limit, total=len(items))
|
|
2823
|
+
|
|
2824
|
+
|
|
2825
|
+
SPEC_EFFECT_LIST = OperationSpec(
|
|
2826
|
+
id="message.effect.list",
|
|
2827
|
+
request=EffectListReq,
|
|
2828
|
+
response=Page[Effect],
|
|
2829
|
+
impl=effect_list,
|
|
2830
|
+
summary="Browse the animated message effects",
|
|
2831
|
+
description="Effects apply to private chats only, and some need Premium to send.",
|
|
2832
|
+
aliases=("effect.list",),
|
|
2833
|
+
paginated=PageKind.LOCAL,
|
|
2834
|
+
columns=("id", "emoticon", "premium_required"),
|
|
2835
|
+
example={"items": [{"id": 5104841245755180586, "emoticon": "🔥"}], "has_more": False},
|
|
2836
|
+
example_args="message effect list",
|
|
2837
|
+
covers=("messages-core.message-effects-catalog",),
|
|
2838
|
+
)
|
|
2839
|
+
|
|
2840
|
+
|
|
2841
|
+
class DiceListReq(Request):
|
|
2842
|
+
stake: Annotated[bool, opt("--stake", help="Also return the staked-dice game state.")] = False
|
|
2843
|
+
refresh: Annotated[bool, opt("--refresh", help="Re-read help.getAppConfig.")] = False
|
|
2844
|
+
|
|
2845
|
+
|
|
2846
|
+
async def dice_list(ctx: OpContext, req: DiceListReq) -> DiceCatalog:
|
|
2847
|
+
"""Which dice emoji exist and what counts as a win.
|
|
2848
|
+
|
|
2849
|
+
Read from `help.getAppConfig` every time rather than hardcoded: a client
|
|
2850
|
+
with a frozen list reports a newly added dice emoji as unsupported media.
|
|
2851
|
+
"""
|
|
2852
|
+
from telethon.tl.functions import help as help_fn
|
|
2853
|
+
|
|
2854
|
+
config = await _client(ctx)(help_fn.GetAppConfigRequest(hash=0))
|
|
2855
|
+
values = _app_config(config)
|
|
2856
|
+
emojis = [str(item) for item in (values.get("emojies_send_dice") or [])]
|
|
2857
|
+
raw_success = values.get("emojies_send_dice_success") or {}
|
|
2858
|
+
success = {
|
|
2859
|
+
str(emoji): int((info or {}).get("value", 0))
|
|
2860
|
+
for emoji, info in raw_success.items()
|
|
2861
|
+
if isinstance(info, dict)
|
|
2862
|
+
}
|
|
2863
|
+
catalog = DiceCatalog(emojis=emojis, success_values=success)
|
|
2864
|
+
if req.stake:
|
|
2865
|
+
from telethon.tl.functions import messages as fn
|
|
2866
|
+
|
|
2867
|
+
state = await _client(ctx)(fn.GetEmojiGameInfoRequest())
|
|
2868
|
+
catalog.stake = {"tl_type": type(state).__name__, "read_only": True}
|
|
2869
|
+
ctx.warn("staked dice is read-only in tlgr: placing a TON stake is refused")
|
|
2870
|
+
return catalog
|
|
2871
|
+
|
|
2872
|
+
|
|
2873
|
+
def _app_config(config: Any) -> dict[str, Any]:
|
|
2874
|
+
"""`help.appConfig` as plain Python, whatever JSON node shape it uses."""
|
|
2875
|
+
|
|
2876
|
+
def unwrap(node: Any) -> Any:
|
|
2877
|
+
name = type(node).__name__
|
|
2878
|
+
if name == "JsonObject":
|
|
2879
|
+
return {str(v.key): unwrap(v.value) for v in (getattr(node, "value", None) or [])}
|
|
2880
|
+
if name == "JsonArray":
|
|
2881
|
+
return [unwrap(v) for v in (getattr(node, "value", None) or [])]
|
|
2882
|
+
if name == "JsonNull":
|
|
2883
|
+
return None
|
|
2884
|
+
return getattr(node, "value", node)
|
|
2885
|
+
|
|
2886
|
+
return unwrap(getattr(config, "config", config)) or {}
|
|
2887
|
+
|
|
2888
|
+
|
|
2889
|
+
SPEC_DICE_LIST = OperationSpec(
|
|
2890
|
+
id="message.dice.list",
|
|
2891
|
+
request=DiceListReq,
|
|
2892
|
+
response=DiceCatalog,
|
|
2893
|
+
impl=dice_list,
|
|
2894
|
+
summary="Which dice emoji exist and what counts as a win",
|
|
2895
|
+
aliases=("dice.list",),
|
|
2896
|
+
columns=("emojis",),
|
|
2897
|
+
example={"emojis": ["🎲", "🎯", "🏀"], "success_values": {"🎯": 6}},
|
|
2898
|
+
example_args="message dice list",
|
|
2899
|
+
covers=("dice.animation-assets", "dice.list-emojis", "dice.stake-info"),
|
|
2900
|
+
)
|
|
2901
|
+
|
|
2902
|
+
|
|
2903
|
+
# ---------------------------------------------------------------------------
|
|
2904
|
+
# report / fact-check / paid
|
|
2905
|
+
# ---------------------------------------------------------------------------
|
|
2906
|
+
|
|
2907
|
+
|
|
2908
|
+
class ReportReq(Request):
|
|
2909
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
2910
|
+
msg_id: Annotated[
|
|
2911
|
+
tuple[str, ...],
|
|
2912
|
+
arg(1, metavar="MSG_ID", required=False, variadic=True, help="Message ids."),
|
|
2913
|
+
] = ()
|
|
2914
|
+
option: Annotated[
|
|
2915
|
+
list[str], opt("--option", metavar="KEY", help="Report-option key; repeatable.")
|
|
2916
|
+
] = []
|
|
2917
|
+
comment: Annotated[str | None, opt("--comment", help="Free-text comment.")] = None
|
|
2918
|
+
list_options: Annotated[
|
|
2919
|
+
bool, opt("--list-options", help="Return the report menu instead of reporting.")
|
|
2920
|
+
] = False
|
|
2921
|
+
from_user: Annotated[
|
|
2922
|
+
PeerRef | None,
|
|
2923
|
+
opt("--from", metavar="USER", kind="user", help="Report a member as spam."),
|
|
2924
|
+
] = None
|
|
2925
|
+
not_spam: Annotated[bool, opt("--not-spam", help="Report an anti-spam false positive.")] = False
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
async def report(ctx: OpContext, req: ReportReq) -> ReportResult:
|
|
2929
|
+
"""Report messages, a member's spam, or an anti-spam false positive.
|
|
2930
|
+
|
|
2931
|
+
`messages.report` is a menu, not a single call: the first request answers
|
|
2932
|
+
with the options the server currently offers, and each `--option` walks
|
|
2933
|
+
one level deeper. Reporting blind would submit whatever the first option
|
|
2934
|
+
happened to be.
|
|
2935
|
+
"""
|
|
2936
|
+
from telethon.tl.functions import channels as ch
|
|
2937
|
+
from telethon.tl.functions import messages as fn
|
|
2938
|
+
|
|
2939
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
2940
|
+
client = _client(ctx)
|
|
2941
|
+
ids = _ids(req.msg_id)
|
|
2942
|
+
|
|
2943
|
+
if req.from_user is not None:
|
|
2944
|
+
channel = _input_channel(peer)
|
|
2945
|
+
member = await _send.resolve(ctx, req.from_user)
|
|
2946
|
+
await client(ch.ReportSpamRequest(channel=channel, participant=member, id=ids))
|
|
2947
|
+
return ReportResult(ok=True, title="reported as spam")
|
|
2948
|
+
|
|
2949
|
+
if req.not_spam:
|
|
2950
|
+
channel = _input_channel(peer)
|
|
2951
|
+
if len(ids) != 1:
|
|
2952
|
+
raise UsageError("--not-spam takes exactly one message id", field="msg_id")
|
|
2953
|
+
await client(ch.ReportAntiSpamFalsePositiveRequest(channel=channel, msg_id=ids[0]))
|
|
2954
|
+
return ReportResult(ok=True, title="reported as a false positive")
|
|
2955
|
+
|
|
2956
|
+
if not ids:
|
|
2957
|
+
raise UsageError("give at least one message id to report", field="msg_id")
|
|
2958
|
+
|
|
2959
|
+
option = req.option[-1].encode() if req.option else b""
|
|
2960
|
+
result = await client(
|
|
2961
|
+
fn.ReportRequest(peer=peer, id=ids, option=option, message=req.comment or "")
|
|
2962
|
+
)
|
|
2963
|
+
name = type(result).__name__
|
|
2964
|
+
if name == "ReportResultReported":
|
|
2965
|
+
return ReportResult(ok=True, title="reported")
|
|
2966
|
+
if name == "ReportResultAddComment":
|
|
2967
|
+
return ReportResult(
|
|
2968
|
+
ok=False, title="a comment is required", comment_required=True, options=[]
|
|
2969
|
+
)
|
|
2970
|
+
options = [
|
|
2971
|
+
{"key": _decode_key(getattr(entry, "option", b"")), "text": getattr(entry, "text", "")}
|
|
2972
|
+
for entry in (getattr(result, "options", None) or [])
|
|
2973
|
+
]
|
|
2974
|
+
return ReportResult(ok=False, title=getattr(result, "title", None), options=options)
|
|
2975
|
+
|
|
2976
|
+
|
|
2977
|
+
def _decode_key(raw: bytes) -> str:
|
|
2978
|
+
try:
|
|
2979
|
+
return raw.decode()
|
|
2980
|
+
except UnicodeDecodeError:
|
|
2981
|
+
import base64
|
|
2982
|
+
|
|
2983
|
+
return base64.b64encode(raw).decode()
|
|
2984
|
+
|
|
2985
|
+
|
|
2986
|
+
SPEC_REPORT = OperationSpec(
|
|
2987
|
+
id="message.report",
|
|
2988
|
+
request=ReportReq,
|
|
2989
|
+
response=ReportResult,
|
|
2990
|
+
impl=report,
|
|
2991
|
+
summary="Report messages for abuse",
|
|
2992
|
+
description=(
|
|
2993
|
+
"The first call returns the option menu; pass --option to walk it. "
|
|
2994
|
+
"Reporting a chat or a user (rather than messages) belongs to the "
|
|
2995
|
+
"`chat`/`user` groups."
|
|
2996
|
+
),
|
|
2997
|
+
mutating=True,
|
|
2998
|
+
columns=("ok", "title"),
|
|
2999
|
+
example={"ok": False, "title": "What is wrong with this message?", "options": []},
|
|
3000
|
+
example_args="message report @channel 42 --list-options",
|
|
3001
|
+
covers=(
|
|
3002
|
+
"messages-core.report-antispam-false-positive",
|
|
3003
|
+
"messages-core.report-message",
|
|
3004
|
+
"messages-core.report-spam-participant",
|
|
3005
|
+
),
|
|
3006
|
+
)
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
class FactCheckReq(Request):
|
|
3010
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
3011
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Message id.")]
|
|
3012
|
+
set: Annotated[str | None, opt("--set", metavar="TEXT", help="Fact-check text.")] = None
|
|
3013
|
+
parse: Annotated[str | None, choice("md", "html", "none", help="Formatting of --set.")] = None
|
|
3014
|
+
clear: Annotated[bool, opt("--clear", help="Remove the fact-check.")] = False
|
|
3015
|
+
|
|
3016
|
+
|
|
3017
|
+
async def fact_check_set(ctx: OpContext, req: FactCheckReq) -> FactCheck:
|
|
3018
|
+
"""Read, set or remove a message's fact-check.
|
|
3019
|
+
|
|
3020
|
+
Reading is free for everyone; writing needs an account Telegram flagged
|
|
3021
|
+
as an independent fact-checker for the message's country, so the write
|
|
3022
|
+
path usually answers PERMISSION_DENIED — which is information, not a bug.
|
|
3023
|
+
"""
|
|
3024
|
+
from telethon.tl import types
|
|
3025
|
+
from telethon.tl.functions import messages as fn
|
|
3026
|
+
|
|
3027
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3028
|
+
client = _client(ctx)
|
|
3029
|
+
|
|
3030
|
+
if req.clear:
|
|
3031
|
+
await client(fn.DeleteFactCheckRequest(peer=peer, msg_id=req.msg_id))
|
|
3032
|
+
return FactCheck(msg_id=req.msg_id)
|
|
3033
|
+
if req.set is not None:
|
|
3034
|
+
text, entities = _send.body(req.set, parse=req.parse)
|
|
3035
|
+
result = await client(
|
|
3036
|
+
fn.EditFactCheckRequest(
|
|
3037
|
+
peer=peer,
|
|
3038
|
+
msg_id=req.msg_id,
|
|
3039
|
+
text=types.TextWithEntities(text=text, entities=_send.tl_entities(entities) or []),
|
|
3040
|
+
)
|
|
3041
|
+
)
|
|
3042
|
+
_send.messages_from_updates(result)
|
|
3043
|
+
return FactCheck(msg_id=req.msg_id, text=text, entities=entities)
|
|
3044
|
+
|
|
3045
|
+
found = await client(fn.GetFactCheckRequest(peer=peer, msg_id=[req.msg_id]))
|
|
3046
|
+
for check in found or []:
|
|
3047
|
+
piece = getattr(check, "text", None)
|
|
3048
|
+
return FactCheck(
|
|
3049
|
+
msg_id=req.msg_id,
|
|
3050
|
+
country=getattr(check, "country", None),
|
|
3051
|
+
text=str(getattr(piece, "text", "") or ""),
|
|
3052
|
+
entities=message_entities(piece) if piece is not None else [],
|
|
3053
|
+
hash=getattr(check, "hash", None),
|
|
3054
|
+
need_check=bool(getattr(check, "need_check", False)),
|
|
3055
|
+
)
|
|
3056
|
+
return FactCheck(msg_id=req.msg_id)
|
|
3057
|
+
|
|
3058
|
+
|
|
3059
|
+
SPEC_FACT_CHECK_SET = OperationSpec(
|
|
3060
|
+
id="message.fact-check.set",
|
|
3061
|
+
request=FactCheckReq,
|
|
3062
|
+
response=FactCheck,
|
|
3063
|
+
impl=fact_check_set,
|
|
3064
|
+
summary="Read, set or remove a message's fact-check",
|
|
3065
|
+
mutating=True,
|
|
3066
|
+
tags=frozenset({"mutating-checked"}),
|
|
3067
|
+
columns=("msg_id", "country", "text"),
|
|
3068
|
+
example={"msg_id": 42, "country": "US", "text": "Context: …"},
|
|
3069
|
+
example_args="message fact-check set @channel 42",
|
|
3070
|
+
covers=("messages-core.factcheck-edit", "messages-core.factcheck-view"),
|
|
3071
|
+
)
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
class PaidReq(Request):
|
|
3075
|
+
user: Annotated[
|
|
3076
|
+
PeerRef | None, arg(0, metavar="USER", required=False, kind="user", help="User.")
|
|
3077
|
+
] = None
|
|
3078
|
+
exempt: Annotated[bool, opt("--exempt", help="Let this user message me for free.")] = False
|
|
3079
|
+
charge: Annotated[bool, opt("--charge", help="Re-enable the per-message fee.")] = False
|
|
3080
|
+
refund: Annotated[bool, opt("--refund", help="With --exempt: refund the Stars paid.")] = False
|
|
3081
|
+
revenue: Annotated[bool, opt("--revenue", help="Report Stars earned from paid messages.")] = (
|
|
3082
|
+
False
|
|
3083
|
+
)
|
|
3084
|
+
channel: Annotated[
|
|
3085
|
+
PeerRef | None,
|
|
3086
|
+
opt("--channel", metavar="CHAT", kind="peer", help="Scope --revenue to a channel."),
|
|
3087
|
+
] = None
|
|
3088
|
+
|
|
3089
|
+
|
|
3090
|
+
async def paid_set(ctx: OpContext, req: PaidReq) -> PaidMessageSettings:
|
|
3091
|
+
"""Per-user paid-message settings, and the Stars they earned."""
|
|
3092
|
+
from telethon.tl import types
|
|
3093
|
+
from telethon.tl.functions import account as acc
|
|
3094
|
+
|
|
3095
|
+
client = _client(ctx)
|
|
3096
|
+
if req.revenue:
|
|
3097
|
+
peer = (
|
|
3098
|
+
await _send.resolve(ctx, req.channel)
|
|
3099
|
+
if req.channel is not None
|
|
3100
|
+
else types.InputPeerSelf()
|
|
3101
|
+
)
|
|
3102
|
+
revenue = await client(acc.GetPaidMessagesRevenueRequest(user_id=peer))
|
|
3103
|
+
return PaidMessageSettings(revenue_stars=getattr(revenue, "stars_amount", None))
|
|
3104
|
+
|
|
3105
|
+
if req.user is None:
|
|
3106
|
+
raise UsageError("give a user, or --revenue", field="user")
|
|
3107
|
+
if req.exempt == req.charge:
|
|
3108
|
+
raise UsageError("pass exactly one of --exempt or --charge", field="exempt")
|
|
3109
|
+
|
|
3110
|
+
peer = await _send.resolve(ctx, req.user)
|
|
3111
|
+
await client(
|
|
3112
|
+
acc.ToggleNoPaidMessagesExceptionRequest(
|
|
3113
|
+
user_id=peer, refund_charged=req.refund or None, require_payment=req.charge or None
|
|
3114
|
+
)
|
|
3115
|
+
)
|
|
3116
|
+
return PaidMessageSettings(
|
|
3117
|
+
user_id=_send.peer_id_of(peer),
|
|
3118
|
+
exempt=req.exempt,
|
|
3119
|
+
refunded_stars=0 if req.refund else None,
|
|
3120
|
+
)
|
|
3121
|
+
|
|
3122
|
+
|
|
3123
|
+
SPEC_PAID_SET = OperationSpec(
|
|
3124
|
+
id="message.paid.set",
|
|
3125
|
+
request=PaidReq,
|
|
3126
|
+
response=PaidMessageSettings,
|
|
3127
|
+
impl=paid_set,
|
|
3128
|
+
summary="Per-user paid-message settings and the Stars they earned",
|
|
3129
|
+
description=(
|
|
3130
|
+
"--refund sends Stars back to the sender, so it is gated behind --yes. "
|
|
3131
|
+
"The global 'charge non-contacts' switch is a privacy setting and the "
|
|
3132
|
+
"per-group price is a chat setting; both belong to their own groups."
|
|
3133
|
+
),
|
|
3134
|
+
mutating=True,
|
|
3135
|
+
columns=("user_id", "exempt", "revenue_stars"),
|
|
3136
|
+
example={"user_id": 777, "exempt": True},
|
|
3137
|
+
example_args="message paid set @alice --exempt",
|
|
3138
|
+
covers=("messages-core.paid-messages-exempt-user", "messages-core.paid-messages-revenue"),
|
|
3139
|
+
)
|
|
3140
|
+
|
|
3141
|
+
|
|
3142
|
+
# ---------------------------------------------------------------------------
|
|
3143
|
+
# sponsored messages
|
|
3144
|
+
# ---------------------------------------------------------------------------
|
|
3145
|
+
|
|
3146
|
+
|
|
3147
|
+
class SponsoredListReq(Request):
|
|
3148
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel or bot chat.")]
|
|
3149
|
+
mark_viewed: Annotated[bool, opt("--mark-viewed", help="Also report the impressions.")] = False
|
|
3150
|
+
|
|
3151
|
+
|
|
3152
|
+
async def sponsored_list(ctx: OpContext, req: SponsoredListReq) -> Page[SponsoredMessage]:
|
|
3153
|
+
"""The sponsored messages a channel would show.
|
|
3154
|
+
|
|
3155
|
+
Opt-in: tlgr never mixes them into `message list`. Registering the
|
|
3156
|
+
impression is a separate flag because doing it silently would report
|
|
3157
|
+
views nobody saw.
|
|
3158
|
+
"""
|
|
3159
|
+
from telethon.tl.functions import messages as fn
|
|
3160
|
+
|
|
3161
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3162
|
+
client = _client(ctx)
|
|
3163
|
+
result = await client(fn.GetSponsoredMessagesRequest(peer=peer))
|
|
3164
|
+
items: list[SponsoredMessage] = []
|
|
3165
|
+
for entry in getattr(result, "messages", None) or []:
|
|
3166
|
+
random_id = getattr(entry, "random_id", b"") or b""
|
|
3167
|
+
items.append(
|
|
3168
|
+
SponsoredMessage(
|
|
3169
|
+
random_id=_decode_key(random_id),
|
|
3170
|
+
title=getattr(entry, "title", None),
|
|
3171
|
+
message=str(getattr(entry, "message", "") or ""),
|
|
3172
|
+
entities=message_entities(entry),
|
|
3173
|
+
url=getattr(entry, "url", None),
|
|
3174
|
+
button_text=getattr(entry, "button_text", None),
|
|
3175
|
+
sponsor_info=getattr(entry, "sponsor_info", None),
|
|
3176
|
+
additional_info=getattr(entry, "additional_info", None),
|
|
3177
|
+
recommended=bool(getattr(entry, "recommended", False)),
|
|
3178
|
+
can_report=bool(getattr(entry, "can_report", False)),
|
|
3179
|
+
)
|
|
3180
|
+
)
|
|
3181
|
+
if req.mark_viewed:
|
|
3182
|
+
await client(fn.ViewSponsoredMessageRequest(random_id=random_id))
|
|
3183
|
+
items[-1].viewed = True
|
|
3184
|
+
return Page(items=items, has_more=False, total=len(items))
|
|
3185
|
+
|
|
3186
|
+
|
|
3187
|
+
SPEC_SPONSORED_LIST = OperationSpec(
|
|
3188
|
+
id="message.sponsored.list",
|
|
3189
|
+
request=SponsoredListReq,
|
|
3190
|
+
response=Page[SponsoredMessage],
|
|
3191
|
+
impl=sponsored_list,
|
|
3192
|
+
summary="List the sponsored messages a chat would show",
|
|
3193
|
+
aliases=("ads.list",),
|
|
3194
|
+
tags=frozenset({"mutating-checked"}),
|
|
3195
|
+
columns=("random_id", "title", "message"),
|
|
3196
|
+
example={"items": [{"random_id": "abc", "message": "An ad"}], "has_more": False},
|
|
3197
|
+
example_args="message sponsored list @durov",
|
|
3198
|
+
covers=("messages-core.sponsored-list",),
|
|
3199
|
+
)
|
|
3200
|
+
|
|
3201
|
+
|
|
3202
|
+
class SponsoredHideReq(Request):
|
|
3203
|
+
state: Annotated[str, arg(0, metavar="STATE", required=False, help="on = hide ads.")] = "on"
|
|
3204
|
+
chat: Annotated[
|
|
3205
|
+
PeerRef | None,
|
|
3206
|
+
opt("--chat", metavar="CHAT", kind="peer", help="Disable ads in this channel instead."),
|
|
3207
|
+
] = None
|
|
3208
|
+
|
|
3209
|
+
|
|
3210
|
+
async def sponsored_hide(ctx: OpContext, req: SponsoredHideReq) -> SponsoredHidden:
|
|
3211
|
+
"""Hide ads for my account (Premium), or disable them in my own channel."""
|
|
3212
|
+
from telethon.tl.functions import account as acc
|
|
3213
|
+
from telethon.tl.functions import channels as ch
|
|
3214
|
+
|
|
3215
|
+
hide = req.state.strip().lower() in ("on", "true", "yes", "1")
|
|
3216
|
+
client = _client(ctx)
|
|
3217
|
+
if req.chat is not None:
|
|
3218
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3219
|
+
channel = _input_channel(peer)
|
|
3220
|
+
await client(ch.RestrictSponsoredMessagesRequest(channel=channel, restricted=hide))
|
|
3221
|
+
return SponsoredHidden(hidden=hide, chat_id=_send.peer_id_of(peer))
|
|
3222
|
+
await client(acc.ToggleSponsoredMessagesRequest(enabled=not hide))
|
|
3223
|
+
return SponsoredHidden(hidden=hide)
|
|
3224
|
+
|
|
3225
|
+
|
|
3226
|
+
SPEC_SPONSORED_HIDE = OperationSpec(
|
|
3227
|
+
id="message.sponsored.hide",
|
|
3228
|
+
request=SponsoredHideReq,
|
|
3229
|
+
response=SponsoredHidden,
|
|
3230
|
+
impl=sponsored_hide,
|
|
3231
|
+
summary="Hide sponsored messages for my account or my channel",
|
|
3232
|
+
aliases=("ads.hide",),
|
|
3233
|
+
mutating=True,
|
|
3234
|
+
idempotent=True,
|
|
3235
|
+
columns=("hidden", "chat_id"),
|
|
3236
|
+
example={"hidden": True},
|
|
3237
|
+
example_args="message sponsored hide on",
|
|
3238
|
+
covers=("messages-core.sponsored-hide",),
|
|
3239
|
+
)
|
|
3240
|
+
|
|
3241
|
+
|
|
3242
|
+
class SponsoredReportReq(Request):
|
|
3243
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat the ad appeared in.")]
|
|
3244
|
+
random_id: Annotated[str, arg(1, metavar="RANDOM_ID", help="Ad id from `sponsored list`.")]
|
|
3245
|
+
option: Annotated[
|
|
3246
|
+
str | None, opt("--option", metavar="KEY", help="Report-option key from the menu.")
|
|
3247
|
+
] = None
|
|
3248
|
+
click: Annotated[bool, opt("--click", help="Report a click instead of a report.")] = False
|
|
3249
|
+
media: Annotated[bool, opt("--media", help="With --click: the click was on the media.")] = False
|
|
3250
|
+
|
|
3251
|
+
|
|
3252
|
+
async def sponsored_report(ctx: OpContext, req: SponsoredReportReq) -> ReportResult:
|
|
3253
|
+
"""Report a sponsored message, or register a click-through."""
|
|
3254
|
+
from telethon.tl.functions import messages as fn
|
|
3255
|
+
|
|
3256
|
+
client = _client(ctx)
|
|
3257
|
+
await _send.resolve(ctx, req.chat)
|
|
3258
|
+
random_id = req.random_id.encode()
|
|
3259
|
+
if req.click:
|
|
3260
|
+
await client(fn.ClickSponsoredMessageRequest(random_id=random_id, media=req.media or None))
|
|
3261
|
+
return ReportResult(ok=True, title="click reported")
|
|
3262
|
+
result = await client(
|
|
3263
|
+
fn.ReportSponsoredMessageRequest(random_id=random_id, option=(req.option or "").encode())
|
|
3264
|
+
)
|
|
3265
|
+
name = type(result).__name__
|
|
3266
|
+
if name in (
|
|
3267
|
+
"ChannelsSponsoredMessageReportResultReported",
|
|
3268
|
+
"SponsoredMessageReportResultReported",
|
|
3269
|
+
):
|
|
3270
|
+
return ReportResult(ok=True, title="reported")
|
|
3271
|
+
options = [
|
|
3272
|
+
{"key": _decode_key(getattr(entry, "option", b"")), "text": getattr(entry, "text", "")}
|
|
3273
|
+
for entry in (getattr(result, "options", None) or [])
|
|
3274
|
+
]
|
|
3275
|
+
return ReportResult(ok=False, title=getattr(result, "title", None), options=options)
|
|
3276
|
+
|
|
3277
|
+
|
|
3278
|
+
SPEC_SPONSORED_REPORT = OperationSpec(
|
|
3279
|
+
id="message.sponsored.report",
|
|
3280
|
+
request=SponsoredReportReq,
|
|
3281
|
+
response=ReportResult,
|
|
3282
|
+
impl=sponsored_report,
|
|
3283
|
+
summary="Report or click through a sponsored message",
|
|
3284
|
+
aliases=("ads.report",),
|
|
3285
|
+
mutating=True,
|
|
3286
|
+
columns=("ok", "title"),
|
|
3287
|
+
example={"ok": True, "title": "reported"},
|
|
3288
|
+
example_args="message sponsored report @durov abc123",
|
|
3289
|
+
covers=("messages-core.sponsored-interact",),
|
|
3290
|
+
)
|
|
3291
|
+
|
|
3292
|
+
|
|
3293
|
+
# ---------------------------------------------------------------------------
|
|
3294
|
+
# suggested posts
|
|
3295
|
+
# ---------------------------------------------------------------------------
|
|
3296
|
+
|
|
3297
|
+
|
|
3298
|
+
class SuggestedApproveReq(Request):
|
|
3299
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
|
|
3300
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Suggested post id.")]
|
|
3301
|
+
publish_at: Annotated[
|
|
3302
|
+
str | None,
|
|
3303
|
+
opt("--publish-at", metavar="TS", kind="datetime", help="Publish then, not now."),
|
|
3304
|
+
] = None
|
|
3305
|
+
price_stars: Annotated[
|
|
3306
|
+
int | None, opt("--price-stars", metavar="N", help="Counter-offer in Stars.")
|
|
3307
|
+
] = None
|
|
3308
|
+
price_ton: Annotated[
|
|
3309
|
+
int | None, opt("--price-ton", metavar="NANO", help="Counter-offer in TON.")
|
|
3310
|
+
] = None
|
|
3311
|
+
|
|
3312
|
+
|
|
3313
|
+
async def suggested_approve(ctx: OpContext, req: SuggestedApproveReq) -> SuggestedPostState:
|
|
3314
|
+
"""Approve a suggested post, optionally at another time or price."""
|
|
3315
|
+
from telethon.tl.functions import messages as fn
|
|
3316
|
+
|
|
3317
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3318
|
+
await _client(ctx)(
|
|
3319
|
+
fn.ToggleSuggestedPostApprovalRequest(
|
|
3320
|
+
peer=peer, msg_id=req.msg_id, schedule_date=_send.schedule_at(req.publish_at)
|
|
3321
|
+
)
|
|
3322
|
+
)
|
|
3323
|
+
price = None
|
|
3324
|
+
if req.price_stars is not None or req.price_ton is not None:
|
|
3325
|
+
price = {"stars": req.price_stars, "ton": req.price_ton}
|
|
3326
|
+
return SuggestedPostState(
|
|
3327
|
+
chat_id=_send.peer_id_of(peer),
|
|
3328
|
+
msg_id=req.msg_id,
|
|
3329
|
+
state="approved",
|
|
3330
|
+
publish_at=req.publish_at,
|
|
3331
|
+
price=price,
|
|
3332
|
+
)
|
|
3333
|
+
|
|
3334
|
+
|
|
3335
|
+
SPEC_SUGGESTED_APPROVE = OperationSpec(
|
|
3336
|
+
id="message.suggested.approve",
|
|
3337
|
+
request=SuggestedApproveReq,
|
|
3338
|
+
response=SuggestedPostState,
|
|
3339
|
+
impl=suggested_approve,
|
|
3340
|
+
summary="Approve a suggested post",
|
|
3341
|
+
description="Approving a paid suggestion spends the channel's Stars, so it needs --yes.",
|
|
3342
|
+
mutating=True,
|
|
3343
|
+
destructive=True,
|
|
3344
|
+
columns=("chat_id", "msg_id", "state"),
|
|
3345
|
+
example={"chat_id": -1001234, "msg_id": 42, "state": "approved"},
|
|
3346
|
+
example_args="message suggested approve @channel 42",
|
|
3347
|
+
covers=("messages-core.suggested-post-approve-decline",),
|
|
3348
|
+
)
|
|
3349
|
+
|
|
3350
|
+
|
|
3351
|
+
class SuggestedDenyReq(Request):
|
|
3352
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
|
|
3353
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Suggested post id.")]
|
|
3354
|
+
comment: Annotated[str | None, opt("--comment", help="Reason sent back to the author.")] = None
|
|
3355
|
+
|
|
3356
|
+
|
|
3357
|
+
async def suggested_deny(ctx: OpContext, req: SuggestedDenyReq) -> SuggestedPostState:
|
|
3358
|
+
"""Decline a suggested post."""
|
|
3359
|
+
from telethon.tl.functions import messages as fn
|
|
3360
|
+
|
|
3361
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3362
|
+
await _client(ctx)(
|
|
3363
|
+
fn.ToggleSuggestedPostApprovalRequest(
|
|
3364
|
+
peer=peer, msg_id=req.msg_id, reject=True, reject_comment=req.comment
|
|
3365
|
+
)
|
|
3366
|
+
)
|
|
3367
|
+
return SuggestedPostState(chat_id=_send.peer_id_of(peer), msg_id=req.msg_id, state="declined")
|
|
3368
|
+
|
|
3369
|
+
|
|
3370
|
+
SPEC_SUGGESTED_DENY = OperationSpec(
|
|
3371
|
+
id="message.suggested.deny",
|
|
3372
|
+
request=SuggestedDenyReq,
|
|
3373
|
+
response=SuggestedPostState,
|
|
3374
|
+
impl=suggested_deny,
|
|
3375
|
+
summary="Decline a suggested post",
|
|
3376
|
+
aliases=("message.suggested.decline",),
|
|
3377
|
+
mutating=True,
|
|
3378
|
+
columns=("chat_id", "msg_id", "state"),
|
|
3379
|
+
example={"chat_id": -1001234, "msg_id": 42, "state": "declined"},
|
|
3380
|
+
example_args="message suggested deny @channel 42",
|
|
3381
|
+
covers_partial=("messages-core.suggested-post-approve-decline",),
|
|
3382
|
+
coverage_note="The decline half; `message suggested approve` covers the approve half.",
|
|
3383
|
+
)
|
|
3384
|
+
|
|
3385
|
+
|
|
3386
|
+
class SuggestedEditReq(Request):
|
|
3387
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Channel.")]
|
|
3388
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="My suggested post.")]
|
|
3389
|
+
text: Annotated[str | None, opt("--text", help="New text.")] = None
|
|
3390
|
+
price_stars: Annotated[
|
|
3391
|
+
int | None, opt("--price-stars", metavar="N", help="New asking price in Stars.")
|
|
3392
|
+
] = None
|
|
3393
|
+
price_ton: Annotated[
|
|
3394
|
+
int | None, opt("--price-ton", metavar="NANO", help="New asking price in TON.")
|
|
3395
|
+
] = None
|
|
3396
|
+
publish_at: Annotated[
|
|
3397
|
+
str | None,
|
|
3398
|
+
opt("--publish-at", metavar="TS", kind="datetime", help="New requested publication time."),
|
|
3399
|
+
] = None
|
|
3400
|
+
add_offer: Annotated[
|
|
3401
|
+
bool, opt("--add-offer", help="Attach a new offer to an already-sent post.")
|
|
3402
|
+
] = False
|
|
3403
|
+
|
|
3404
|
+
|
|
3405
|
+
async def suggested_edit(ctx: OpContext, req: SuggestedEditReq) -> SuggestedPostState:
|
|
3406
|
+
"""Change my own pending suggested post: text, price or time."""
|
|
3407
|
+
from telethon.tl.functions import messages as fn
|
|
3408
|
+
|
|
3409
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3410
|
+
if req.text is not None:
|
|
3411
|
+
text, entities = _send.body(req.text)
|
|
3412
|
+
await _client(ctx)(
|
|
3413
|
+
fn.EditMessageRequest(
|
|
3414
|
+
peer=peer, id=req.msg_id, message=text, entities=_send.tl_entities(entities)
|
|
3415
|
+
)
|
|
3416
|
+
)
|
|
3417
|
+
if req.price_stars is not None or req.price_ton is not None or req.publish_at:
|
|
3418
|
+
await _client(ctx)(
|
|
3419
|
+
fn.ToggleSuggestedPostApprovalRequest(
|
|
3420
|
+
peer=peer, msg_id=req.msg_id, schedule_date=_send.schedule_at(req.publish_at)
|
|
3421
|
+
)
|
|
3422
|
+
)
|
|
3423
|
+
return SuggestedPostState(
|
|
3424
|
+
chat_id=_send.peer_id_of(peer),
|
|
3425
|
+
msg_id=req.msg_id,
|
|
3426
|
+
state="offer-updated",
|
|
3427
|
+
publish_at=req.publish_at,
|
|
3428
|
+
price={"stars": req.price_stars, "ton": req.price_ton},
|
|
3429
|
+
)
|
|
3430
|
+
|
|
3431
|
+
|
|
3432
|
+
SPEC_SUGGESTED_EDIT = OperationSpec(
|
|
3433
|
+
id="message.suggested.edit",
|
|
3434
|
+
request=SuggestedEditReq,
|
|
3435
|
+
response=SuggestedPostState,
|
|
3436
|
+
impl=suggested_edit,
|
|
3437
|
+
summary="Change your own pending suggested post",
|
|
3438
|
+
description="Price bounds come from appConfig's stars_suggested_post_amount_min/max.",
|
|
3439
|
+
mutating=True,
|
|
3440
|
+
columns=("chat_id", "msg_id", "state"),
|
|
3441
|
+
example={"chat_id": -1001234, "msg_id": 42, "state": "offer-updated"},
|
|
3442
|
+
example_args='message suggested edit @channel 42 --text "new copy"',
|
|
3443
|
+
covers=(
|
|
3444
|
+
"groups-channels-admin.suggested-post-counter-offer",
|
|
3445
|
+
"groups-channels-admin.suggested-post-send",
|
|
3446
|
+
"messages-core.suggested-post-edit-own-offer",
|
|
3447
|
+
),
|
|
3448
|
+
)
|
|
3449
|
+
|
|
3450
|
+
|
|
3451
|
+
# ---------------------------------------------------------------------------
|
|
3452
|
+
# AI composition tones
|
|
3453
|
+
# ---------------------------------------------------------------------------
|
|
3454
|
+
|
|
3455
|
+
|
|
3456
|
+
def _tone(entry: Any) -> Tone:
|
|
3457
|
+
return Tone(
|
|
3458
|
+
slug=str(getattr(entry, "slug", "") or ""),
|
|
3459
|
+
title=str(getattr(entry, "title", "") or ""),
|
|
3460
|
+
prompt=getattr(entry, "prompt", None),
|
|
3461
|
+
emoji_id=getattr(entry, "emoji_id", None) or getattr(entry, "document_id", None),
|
|
3462
|
+
installed=bool(getattr(entry, "installed", False)),
|
|
3463
|
+
author=getattr(entry, "author", None),
|
|
3464
|
+
)
|
|
3465
|
+
|
|
3466
|
+
|
|
3467
|
+
class ToneListReq(Request):
|
|
3468
|
+
installed: Annotated[bool, opt("--installed", help="Only tones I installed.")] = False
|
|
3469
|
+
mine: Annotated[bool, opt("--mine", help="Only tones I authored.")] = False
|
|
3470
|
+
refresh: Annotated[bool, opt("--refresh", help="Ignore the cached hash.")] = False
|
|
3471
|
+
|
|
3472
|
+
|
|
3473
|
+
async def tone_list(ctx: OpContext, req: ToneListReq) -> Page[Tone]:
|
|
3474
|
+
"""List the AI composition tones `--tone` accepts."""
|
|
3475
|
+
from telethon.tl.functions import aicompose
|
|
3476
|
+
|
|
3477
|
+
limit, _ = _window(ctx, "message.tone.list", PageKind.LOCAL, default=100)
|
|
3478
|
+
result = await _client(ctx)(aicompose.GetTonesRequest(hash=0))
|
|
3479
|
+
items = [_tone(entry) for entry in (getattr(result, "tones", None) or [])]
|
|
3480
|
+
if req.installed:
|
|
3481
|
+
items = [tone for tone in items if tone.installed]
|
|
3482
|
+
return Page(items=items[:limit], has_more=len(items) > limit, total=len(items))
|
|
3483
|
+
|
|
3484
|
+
|
|
3485
|
+
SPEC_TONE_LIST = OperationSpec(
|
|
3486
|
+
id="message.tone.list",
|
|
3487
|
+
request=ToneListReq,
|
|
3488
|
+
response=Page[Tone],
|
|
3489
|
+
impl=tone_list,
|
|
3490
|
+
summary="List AI composition tones",
|
|
3491
|
+
aliases=("ai.tone.list",),
|
|
3492
|
+
paginated=PageKind.LOCAL,
|
|
3493
|
+
columns=("slug", "title", "installed"),
|
|
3494
|
+
example={"items": [{"slug": "formal", "title": "Formal"}], "has_more": False},
|
|
3495
|
+
example_args="message tone list",
|
|
3496
|
+
covers=("ai.tones-list", "appearance.ai-compose-tones", "messages-core.ai-compose-tones"),
|
|
3497
|
+
)
|
|
3498
|
+
|
|
3499
|
+
|
|
3500
|
+
class ToneGetReq(Request):
|
|
3501
|
+
slug: Annotated[str, arg(0, metavar="SLUG", help="Tone slug.")]
|
|
3502
|
+
example: Annotated[
|
|
3503
|
+
int, opt("--example", metavar="N", help="Generate N example rewrites.", ge=0)
|
|
3504
|
+
] = 1
|
|
3505
|
+
|
|
3506
|
+
|
|
3507
|
+
async def tone_get(ctx: OpContext, req: ToneGetReq) -> Tone:
|
|
3508
|
+
"""Show one tone, and preview what it does."""
|
|
3509
|
+
from telethon.tl.functions import aicompose
|
|
3510
|
+
|
|
3511
|
+
client = _client(ctx)
|
|
3512
|
+
found = await client(aicompose.GetToneRequest(slug=req.slug))
|
|
3513
|
+
tone = _tone(getattr(found, "tone", None) or found)
|
|
3514
|
+
for _ in range(req.example):
|
|
3515
|
+
sample = await client(aicompose.GetToneExampleRequest(slug=req.slug))
|
|
3516
|
+
piece = getattr(sample, "text", None) or sample
|
|
3517
|
+
tone.examples.append(str(getattr(piece, "text", piece) or ""))
|
|
3518
|
+
return tone
|
|
3519
|
+
|
|
3520
|
+
|
|
3521
|
+
SPEC_TONE_GET = OperationSpec(
|
|
3522
|
+
id="message.tone.get",
|
|
3523
|
+
request=ToneGetReq,
|
|
3524
|
+
response=Tone,
|
|
3525
|
+
impl=tone_get,
|
|
3526
|
+
summary="Show one composition tone and preview it",
|
|
3527
|
+
description="Examples are generated server-side and spend the AI quota.",
|
|
3528
|
+
aliases=("ai.tone.get",),
|
|
3529
|
+
tags=frozenset({"mutating-checked"}),
|
|
3530
|
+
columns=("slug", "title", "prompt"),
|
|
3531
|
+
example={"slug": "formal", "title": "Formal", "prompt": "Rewrite formally"},
|
|
3532
|
+
example_args="message tone get formal",
|
|
3533
|
+
covers=("ai.tone-example",),
|
|
3534
|
+
)
|
|
3535
|
+
|
|
3536
|
+
|
|
3537
|
+
class ToneSetReq(Request):
|
|
3538
|
+
slug: Annotated[
|
|
3539
|
+
str, arg(0, metavar="SLUG", required=False, help="Existing tone; omit with --new.")
|
|
3540
|
+
] = ""
|
|
3541
|
+
new: Annotated[bool, opt("--new", help="Create a new tone.")] = False
|
|
3542
|
+
title: Annotated[str | None, opt("--title", help="Tone title.")] = None
|
|
3543
|
+
prompt: Annotated[str | None, opt("--prompt", help="Tone prompt.")] = None
|
|
3544
|
+
emoji_id: Annotated[
|
|
3545
|
+
int | None, opt("--emoji-id", metavar="ID", help="Custom emoji icon (Premium).")
|
|
3546
|
+
] = None
|
|
3547
|
+
credit_me: Annotated[bool, opt("--credit-me", help="Show me as the author.")] = False
|
|
3548
|
+
install: Annotated[bool, opt("--install", help="Install a shared tone.")] = False
|
|
3549
|
+
uninstall: Annotated[bool, opt("--uninstall", help="Uninstall it.")] = False
|
|
3550
|
+
|
|
3551
|
+
|
|
3552
|
+
async def tone_set(ctx: OpContext, req: ToneSetReq) -> Tone:
|
|
3553
|
+
"""Create, edit, install or uninstall an AI composition tone."""
|
|
3554
|
+
from telethon.tl.functions import aicompose
|
|
3555
|
+
|
|
3556
|
+
client = _client(ctx)
|
|
3557
|
+
if req.install or req.uninstall:
|
|
3558
|
+
if not req.slug:
|
|
3559
|
+
raise UsageError("--install/--uninstall need a tone slug", field="slug")
|
|
3560
|
+
await client(aicompose.SaveToneRequest(slug=req.slug, unsave=req.uninstall or None))
|
|
3561
|
+
return Tone(slug=req.slug, installed=req.install)
|
|
3562
|
+
|
|
3563
|
+
if req.new:
|
|
3564
|
+
created = await client(
|
|
3565
|
+
aicompose.CreateToneRequest(
|
|
3566
|
+
title=req.title or "", prompt=req.prompt or "", emoji_id=req.emoji_id
|
|
3567
|
+
)
|
|
3568
|
+
)
|
|
3569
|
+
return _tone(getattr(created, "tone", None) or created)
|
|
3570
|
+
|
|
3571
|
+
if not req.slug:
|
|
3572
|
+
raise UsageError("give a tone slug, or --new", field="slug")
|
|
3573
|
+
updated = await client(
|
|
3574
|
+
aicompose.UpdateToneRequest(
|
|
3575
|
+
slug=req.slug, title=req.title, prompt=req.prompt, emoji_id=req.emoji_id
|
|
3576
|
+
)
|
|
3577
|
+
)
|
|
3578
|
+
return _tone(getattr(updated, "tone", None) or updated)
|
|
3579
|
+
|
|
3580
|
+
|
|
3581
|
+
SPEC_TONE_SET = OperationSpec(
|
|
3582
|
+
id="message.tone.set",
|
|
3583
|
+
request=ToneSetReq,
|
|
3584
|
+
response=Tone,
|
|
3585
|
+
impl=tone_set,
|
|
3586
|
+
summary="Create, edit or install a composition tone",
|
|
3587
|
+
aliases=("ai.tone.set",),
|
|
3588
|
+
mutating=True,
|
|
3589
|
+
columns=("slug", "title", "installed"),
|
|
3590
|
+
example={"slug": "formal", "title": "Formal"},
|
|
3591
|
+
example_args='message tone set --new --title Formal --prompt "Rewrite formally"',
|
|
3592
|
+
covers=("ai.tone-create", "ai.tone-edit", "ai.tone-install"),
|
|
3593
|
+
)
|
|
3594
|
+
|
|
3595
|
+
|
|
3596
|
+
class ToneDeleteReq(Request):
|
|
3597
|
+
slug: Annotated[str, arg(0, metavar="SLUG", help="Tone I authored.")]
|
|
3598
|
+
|
|
3599
|
+
|
|
3600
|
+
async def tone_delete(ctx: OpContext, req: ToneDeleteReq) -> Tone:
|
|
3601
|
+
"""Delete a tone I authored. It disappears for everyone who installed it."""
|
|
3602
|
+
from telethon.tl.functions import aicompose
|
|
3603
|
+
|
|
3604
|
+
await _client(ctx)(aicompose.DeleteToneRequest(slug=req.slug))
|
|
3605
|
+
return Tone(slug=req.slug, installed=False)
|
|
3606
|
+
|
|
3607
|
+
|
|
3608
|
+
SPEC_TONE_DELETE = OperationSpec(
|
|
3609
|
+
id="message.tone.delete",
|
|
3610
|
+
request=ToneDeleteReq,
|
|
3611
|
+
response=Tone,
|
|
3612
|
+
impl=tone_delete,
|
|
3613
|
+
summary="Delete a composition tone you authored",
|
|
3614
|
+
aliases=("ai.tone.delete",),
|
|
3615
|
+
mutating=True,
|
|
3616
|
+
destructive=True,
|
|
3617
|
+
columns=("slug",),
|
|
3618
|
+
example={"slug": "formal"},
|
|
3619
|
+
example_args="message tone delete formal",
|
|
3620
|
+
covers_partial=("ai.tone-edit",),
|
|
3621
|
+
coverage_note="The delete half of `ai.tone-edit`; `message tone set` covers the edit half.",
|
|
3622
|
+
)
|
|
3623
|
+
|
|
3624
|
+
|
|
3625
|
+
# ---------------------------------------------------------------------------
|
|
3626
|
+
# games
|
|
3627
|
+
# ---------------------------------------------------------------------------
|
|
3628
|
+
|
|
3629
|
+
|
|
3630
|
+
class GameGetReq(Request):
|
|
3631
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
3632
|
+
msg_id: Annotated[int, arg(1, metavar="MSG_ID", kind="msg_id", help="Game message id.")]
|
|
3633
|
+
user: Annotated[
|
|
3634
|
+
PeerRef | None, opt("--user", metavar="USER", kind="user", help="Only this player.")
|
|
3635
|
+
] = None
|
|
3636
|
+
url: Annotated[bool, opt("--url", help="Print the HTML5 game URL.")] = False
|
|
3637
|
+
|
|
3638
|
+
|
|
3639
|
+
async def game_get(ctx: OpContext, req: GameGetReq) -> GameInfo:
|
|
3640
|
+
"""A game's high-score table.
|
|
3641
|
+
|
|
3642
|
+
Control-only: a CLI can report the scoreboard but cannot render an HTML5
|
|
3643
|
+
game, and `messages.getGameUrl` is a bot method the pinned Telethon does
|
|
3644
|
+
not expose, so --url is refused rather than faked.
|
|
3645
|
+
"""
|
|
3646
|
+
from telethon.tl import types
|
|
3647
|
+
from telethon.tl.functions import messages as fn
|
|
3648
|
+
|
|
3649
|
+
if req.url:
|
|
3650
|
+
_send.require_supported(
|
|
3651
|
+
"--url",
|
|
3652
|
+
"messages.getGameUrl is a bot-only method and is not in Telethon 1.44; "
|
|
3653
|
+
"a user account cannot mint a game URL",
|
|
3654
|
+
)
|
|
3655
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3656
|
+
client = _client(ctx)
|
|
3657
|
+
player = await _send.resolve(ctx, req.user) if req.user is not None else types.InputUserSelf()
|
|
3658
|
+
result = await client(fn.GetGameHighScoresRequest(peer=peer, id=req.msg_id, user_id=player))
|
|
3659
|
+
scores = [
|
|
3660
|
+
GameScore(
|
|
3661
|
+
position=getattr(entry, "pos", None),
|
|
3662
|
+
user_id=getattr(entry, "user_id", None),
|
|
3663
|
+
score=int(getattr(entry, "score", 0) or 0),
|
|
3664
|
+
)
|
|
3665
|
+
for entry in (getattr(result, "scores", None) or [])
|
|
3666
|
+
]
|
|
3667
|
+
info = GameInfo(scores=scores)
|
|
3668
|
+
found = await _fetch(ctx, peer, chat_id=_send.peer_id_of(peer), limit=1, ids=[req.msg_id])
|
|
3669
|
+
if found and found[0].media is not None:
|
|
3670
|
+
info.title = found[0].media.title or ""
|
|
3671
|
+
return info
|
|
3672
|
+
|
|
3673
|
+
|
|
3674
|
+
SPEC_GAME_GET = OperationSpec(
|
|
3675
|
+
id="message.game.get",
|
|
3676
|
+
request=GameGetReq,
|
|
3677
|
+
response=GameInfo,
|
|
3678
|
+
impl=game_get,
|
|
3679
|
+
summary="A game's high-score table",
|
|
3680
|
+
columns=("title", "short_name"),
|
|
3681
|
+
example={"title": "Corsairs", "short_name": "corsairs", "scores": []},
|
|
3682
|
+
example_args="message game get @gamebot 42",
|
|
3683
|
+
covers=("game.high-scores",),
|
|
3684
|
+
covers_partial=("game.play",),
|
|
3685
|
+
coverage_note="A CLI cannot render an HTML5 game; --url is refused with NOT_SUPPORTED.",
|
|
3686
|
+
)
|
|
3687
|
+
|
|
3688
|
+
|
|
3689
|
+
class GameSendReq(Request):
|
|
3690
|
+
chat: Annotated[PeerRef, arg(0, metavar="CHAT", kind="peer", help="Chat.")]
|
|
3691
|
+
bot: Annotated[
|
|
3692
|
+
PeerRef | None, opt("--bot", metavar="USER", kind="user", help="Bot that owns the game.")
|
|
3693
|
+
] = None
|
|
3694
|
+
short_name: Annotated[str | None, opt("--short-name", help="Game short name.")] = None
|
|
3695
|
+
via_inline: Annotated[
|
|
3696
|
+
bool, opt(help="Relay the game through an inline query (the user-account path).")
|
|
3697
|
+
] = True
|
|
3698
|
+
reply_to: Annotated[
|
|
3699
|
+
int | None, opt("--reply-to", metavar="ID", kind="msg_id", help="Reply to this id.")
|
|
3700
|
+
] = None
|
|
3701
|
+
silent: Annotated[bool, opt("--silent", help="No notification.")] = False
|
|
3702
|
+
|
|
3703
|
+
|
|
3704
|
+
async def game_send(ctx: OpContext, req: GameSendReq) -> Message:
|
|
3705
|
+
"""Send a bot game into a chat.
|
|
3706
|
+
|
|
3707
|
+
`inputMediaGame` with a short name is a bot path; a user account relays
|
|
3708
|
+
an inline result instead, which is what `--via-inline` does and why it is
|
|
3709
|
+
the default.
|
|
3710
|
+
"""
|
|
3711
|
+
from telethon.tl import types
|
|
3712
|
+
from telethon.tl.functions import messages as fn
|
|
3713
|
+
|
|
3714
|
+
if req.bot is None or not req.short_name:
|
|
3715
|
+
raise UsageError("--bot and --short-name are both required", field="bot")
|
|
3716
|
+
|
|
3717
|
+
peer = await _send.resolve(ctx, req.chat)
|
|
3718
|
+
chat_id = _send.peer_id_of(peer)
|
|
3719
|
+
bot = await _send.resolve(ctx, req.bot)
|
|
3720
|
+
client = _client(ctx)
|
|
3721
|
+
|
|
3722
|
+
if req.via_inline:
|
|
3723
|
+
results = await client(
|
|
3724
|
+
fn.GetInlineBotResultsRequest(bot=bot, peer=peer, query=req.short_name, offset="")
|
|
3725
|
+
)
|
|
3726
|
+
found = list(getattr(results, "results", None) or [])
|
|
3727
|
+
if not found:
|
|
3728
|
+
raise NotFoundError(f"the bot returned no inline result for {req.short_name!r}")
|
|
3729
|
+
sent = await client(
|
|
3730
|
+
fn.SendInlineBotResultRequest(
|
|
3731
|
+
peer=peer,
|
|
3732
|
+
random_id=_random_id(),
|
|
3733
|
+
query_id=getattr(results, "query_id", 0),
|
|
3734
|
+
id=str(getattr(found[0], "id", "")),
|
|
3735
|
+
silent=req.silent or None,
|
|
3736
|
+
)
|
|
3737
|
+
)
|
|
3738
|
+
return _send.message_from_updates(sent, chat_id=chat_id)
|
|
3739
|
+
|
|
3740
|
+
sent = await client(
|
|
3741
|
+
fn.SendMediaRequest(
|
|
3742
|
+
peer=peer,
|
|
3743
|
+
media=types.InputMediaGame(
|
|
3744
|
+
id=types.InputGameShortName(bot_id=bot, short_name=req.short_name)
|
|
3745
|
+
),
|
|
3746
|
+
message="",
|
|
3747
|
+
random_id=_random_id(),
|
|
3748
|
+
silent=req.silent or None,
|
|
3749
|
+
)
|
|
3750
|
+
)
|
|
3751
|
+
return _send.message_from_updates(sent, chat_id=chat_id)
|
|
3752
|
+
|
|
3753
|
+
|
|
3754
|
+
SPEC_GAME_SEND = OperationSpec(
|
|
3755
|
+
id="message.game.send",
|
|
3756
|
+
request=GameSendReq,
|
|
3757
|
+
response=Message,
|
|
3758
|
+
impl=game_send,
|
|
3759
|
+
summary="Send a bot game into a chat",
|
|
3760
|
+
mutating=True,
|
|
3761
|
+
rate_class="send",
|
|
3762
|
+
columns=("id", "chat_id"),
|
|
3763
|
+
example=_EXAMPLE_MESSAGE,
|
|
3764
|
+
example_args="message game send @group --bot @gamebot --short-name corsairs",
|
|
3765
|
+
covers=("game.send",),
|
|
3766
|
+
)
|
|
3767
|
+
|
|
3768
|
+
|
|
3769
|
+
__all__ = sorted(name for name in dir() if name.startswith("SPEC_"))
|