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/_serialize.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""Telethon object → model. One function per shape, and only one.
|
|
2
|
+
|
|
3
|
+
v1 assembled a message dict in three places with slightly different keys, so
|
|
4
|
+
`message list`, `message get` and the dialog preview disagreed about what a
|
|
5
|
+
message looks like. Everything here is duck-typed on the attributes Telethon
|
|
6
|
+
objects carry, which keeps the functions importable (and testable) without
|
|
7
|
+
Telethon and without a client.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from tlgr.core.timefmt import fmt_dt, to_unix
|
|
16
|
+
from tlgr.models.dialog import ActionBar, ChatTheme, ChatWallpaper, NotifySettings
|
|
17
|
+
from tlgr.models.message import (
|
|
18
|
+
Button,
|
|
19
|
+
MediaSummary,
|
|
20
|
+
Message,
|
|
21
|
+
MessageEntity,
|
|
22
|
+
ReactionSummary,
|
|
23
|
+
ReplyHeader,
|
|
24
|
+
ReplyMarkup,
|
|
25
|
+
ServiceAction,
|
|
26
|
+
)
|
|
27
|
+
from tlgr.models.peer import Peer, Photo
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"action_bar",
|
|
31
|
+
"chat_theme",
|
|
32
|
+
"entity_to_peer",
|
|
33
|
+
"marked_id",
|
|
34
|
+
"media_summary",
|
|
35
|
+
"message_entities",
|
|
36
|
+
"message_to_model",
|
|
37
|
+
"notify_settings",
|
|
38
|
+
"peer_id_of",
|
|
39
|
+
"photo_summary",
|
|
40
|
+
"reactions_summary",
|
|
41
|
+
"reply_markup",
|
|
42
|
+
"service_action",
|
|
43
|
+
"tl_snake",
|
|
44
|
+
"wallpaper",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_CHANNEL_MARK = -1000000000000
|
|
48
|
+
|
|
49
|
+
#: TL media class → the `MediaKind` it maps to. The document branch decides
|
|
50
|
+
#: for itself (see `media_summary`); this table is only for the media types
|
|
51
|
+
#: that are what their class name says they are.
|
|
52
|
+
_MEDIA_KINDS = {
|
|
53
|
+
"MessageMediaPhoto": "photo",
|
|
54
|
+
"MessageMediaContact": "contact",
|
|
55
|
+
"MessageMediaGeo": "geo",
|
|
56
|
+
"MessageMediaGeoLive": "geo_live",
|
|
57
|
+
"MessageMediaVenue": "venue",
|
|
58
|
+
"MessageMediaPoll": "poll",
|
|
59
|
+
"MessageMediaDice": "dice",
|
|
60
|
+
"MessageMediaGame": "game",
|
|
61
|
+
"MessageMediaInvoice": "invoice",
|
|
62
|
+
"MessageMediaWebPage": "webpage",
|
|
63
|
+
"MessageMediaStory": "story",
|
|
64
|
+
"MessageMediaGiveaway": "giveaway",
|
|
65
|
+
"MessageMediaGiveawayResults": "giveaway",
|
|
66
|
+
"MessageMediaPaidMedia": "paid",
|
|
67
|
+
"MessageMediaToDo": "todo",
|
|
68
|
+
"MessageMediaUnsupported": "unsupported",
|
|
69
|
+
"MessageMediaEmpty": "unsupported",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tl_snake(name: str, prefix: str = "") -> str:
|
|
74
|
+
"""`MessageActionChatAddUser` → `chat_add_user`."""
|
|
75
|
+
name = name.removeprefix(prefix)
|
|
76
|
+
out: list[str] = []
|
|
77
|
+
for index, char in enumerate(name):
|
|
78
|
+
if char.isupper() and index:
|
|
79
|
+
out.append("_")
|
|
80
|
+
out.append(char.lower())
|
|
81
|
+
return "".join(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def marked_id(raw_id: int, kind: str) -> int:
|
|
85
|
+
"""The marked id from `utils.get_peer_id`, without importing Telethon.
|
|
86
|
+
|
|
87
|
+
Every peer id on the wire is marked (COR-10): `chat get` returning `123`
|
|
88
|
+
and `chat list` returning `-100…123` for the same channel is the bug this
|
|
89
|
+
closes.
|
|
90
|
+
"""
|
|
91
|
+
if raw_id < 0:
|
|
92
|
+
return raw_id
|
|
93
|
+
if kind in ("channel", "supergroup"):
|
|
94
|
+
return _CHANNEL_MARK - raw_id
|
|
95
|
+
if kind == "group":
|
|
96
|
+
return -raw_id
|
|
97
|
+
return raw_id
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def peer_id_of(peer: Any) -> int | None:
|
|
101
|
+
"""A `PeerUser`/`PeerChat`/`PeerChannel` as its marked id, or None.
|
|
102
|
+
|
|
103
|
+
The same arithmetic `utils.get_peer_id` does, kept here so a serialiser
|
|
104
|
+
can read a raw `from_id` without a client and without importing Telethon
|
|
105
|
+
(§2.2 keeps that import out of everything below `ops/`).
|
|
106
|
+
"""
|
|
107
|
+
if peer is None:
|
|
108
|
+
return None
|
|
109
|
+
for attribute, kind in (
|
|
110
|
+
("user_id", "user"),
|
|
111
|
+
("chat_id", "group"),
|
|
112
|
+
("channel_id", "channel"),
|
|
113
|
+
):
|
|
114
|
+
value = getattr(peer, attribute, None)
|
|
115
|
+
if isinstance(value, int):
|
|
116
|
+
return marked_id(value, kind)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _photo(obj: Any) -> Photo | None:
|
|
121
|
+
if obj is None:
|
|
122
|
+
return None
|
|
123
|
+
identifier = getattr(obj, "photo_id", None) or getattr(obj, "id", None)
|
|
124
|
+
if identifier is None:
|
|
125
|
+
return None
|
|
126
|
+
stripped = getattr(obj, "stripped_thumb", None)
|
|
127
|
+
return Photo(
|
|
128
|
+
id=int(identifier),
|
|
129
|
+
has_video=bool(getattr(obj, "has_video", False)),
|
|
130
|
+
stripped_thumb_b64=base64.b64encode(stripped).decode() if stripped else None,
|
|
131
|
+
dc_id=getattr(obj, "dc_id", None),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
#: The photo shape, exported so an op can build one without reaching for a
|
|
136
|
+
#: private name.
|
|
137
|
+
photo_summary = _photo
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def entity_to_peer(entity: Any) -> Peer:
|
|
141
|
+
"""A Telethon User/Chat/Channel as the `Peer` every response embeds."""
|
|
142
|
+
name = type(entity).__name__
|
|
143
|
+
raw_id = int(getattr(entity, "id", 0) or 0)
|
|
144
|
+
usernames = [
|
|
145
|
+
u.username
|
|
146
|
+
for u in (getattr(entity, "usernames", None) or [])
|
|
147
|
+
if getattr(u, "username", None)
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
if name in ("User", "UserEmpty"):
|
|
151
|
+
if getattr(entity, "is_self", False):
|
|
152
|
+
kind, title = "saved", "Saved Messages"
|
|
153
|
+
else:
|
|
154
|
+
kind = "bot" if getattr(entity, "bot", False) else "user"
|
|
155
|
+
first = getattr(entity, "first_name", "") or ""
|
|
156
|
+
last = getattr(entity, "last_name", "") or ""
|
|
157
|
+
title = f"{first} {last}".strip()
|
|
158
|
+
elif name in ("Chat", "ChatForbidden", "ChatEmpty"):
|
|
159
|
+
kind, title = "group", getattr(entity, "title", "") or ""
|
|
160
|
+
elif name in ("Channel", "ChannelForbidden"):
|
|
161
|
+
kind = "supergroup" if getattr(entity, "megagroup", False) else "channel"
|
|
162
|
+
title = getattr(entity, "title", "") or ""
|
|
163
|
+
else:
|
|
164
|
+
kind, title = "unknown", str(getattr(entity, "title", "") or "")
|
|
165
|
+
|
|
166
|
+
return Peer(
|
|
167
|
+
id=marked_id(raw_id, kind),
|
|
168
|
+
raw_id=raw_id,
|
|
169
|
+
kind=kind, # type: ignore[arg-type]
|
|
170
|
+
title=title,
|
|
171
|
+
username=getattr(entity, "username", None),
|
|
172
|
+
usernames=usernames,
|
|
173
|
+
is_self=bool(getattr(entity, "is_self", False)),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def media_summary(media: Any) -> MediaSummary | None:
|
|
178
|
+
"""What the media IS, from the attributes it already carries.
|
|
179
|
+
|
|
180
|
+
Ported from v1's `media_details()` with its logic intact: attributes are
|
|
181
|
+
*collected* and the kind decided afterwards, because a GIF carries Video
|
|
182
|
+
AND Animated and a video sticker carries Video AND Sticker — "first
|
|
183
|
+
attribute wins" gets both of them wrong.
|
|
184
|
+
"""
|
|
185
|
+
if media is None:
|
|
186
|
+
return None
|
|
187
|
+
tl_type = type(media).__name__
|
|
188
|
+
|
|
189
|
+
if getattr(media, "photo", None) is not None and not hasattr(media, "document"):
|
|
190
|
+
photo = media.photo
|
|
191
|
+
return MediaSummary(
|
|
192
|
+
kind="photo",
|
|
193
|
+
tl_type=tl_type,
|
|
194
|
+
spoiler=bool(getattr(media, "spoiler", False)),
|
|
195
|
+
ttl_seconds=getattr(media, "ttl_seconds", None),
|
|
196
|
+
dc_id=getattr(photo, "dc_id", None),
|
|
197
|
+
thumbs=[
|
|
198
|
+
str(getattr(size, "type", ""))
|
|
199
|
+
for size in (getattr(photo, "sizes", None) or [])
|
|
200
|
+
if getattr(size, "type", None)
|
|
201
|
+
],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
document = getattr(media, "document", None)
|
|
205
|
+
if document is None:
|
|
206
|
+
summary = MediaSummary(
|
|
207
|
+
kind=_MEDIA_KINDS.get(tl_type, "unsupported"), # type: ignore[arg-type]
|
|
208
|
+
tl_type=tl_type,
|
|
209
|
+
downloadable=False,
|
|
210
|
+
spoiler=bool(getattr(media, "spoiler", False)),
|
|
211
|
+
)
|
|
212
|
+
_fill_non_file(summary, media)
|
|
213
|
+
return summary
|
|
214
|
+
|
|
215
|
+
mime = getattr(document, "mime_type", None)
|
|
216
|
+
summary = MediaSummary(
|
|
217
|
+
kind="file",
|
|
218
|
+
tl_type=tl_type,
|
|
219
|
+
mime_type=mime,
|
|
220
|
+
size=getattr(document, "size", None),
|
|
221
|
+
dc_id=getattr(document, "dc_id", None),
|
|
222
|
+
spoiler=bool(getattr(media, "spoiler", False)),
|
|
223
|
+
ttl_seconds=getattr(media, "ttl_seconds", None),
|
|
224
|
+
thumbs=[
|
|
225
|
+
str(getattr(size, "type", ""))
|
|
226
|
+
for size in (getattr(document, "thumbs", None) or [])
|
|
227
|
+
if getattr(size, "type", None)
|
|
228
|
+
],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
sticker = voice = audio = video = video_note = animated = False
|
|
232
|
+
for attribute in getattr(document, "attributes", None) or []:
|
|
233
|
+
name = type(attribute).__name__
|
|
234
|
+
if name == "DocumentAttributeSticker":
|
|
235
|
+
sticker = True
|
|
236
|
+
summary.alt = getattr(attribute, "alt", None) or None
|
|
237
|
+
stickerset = getattr(attribute, "stickerset", None)
|
|
238
|
+
short_name = getattr(stickerset, "short_name", None)
|
|
239
|
+
if short_name:
|
|
240
|
+
summary.sticker_set = short_name
|
|
241
|
+
elif name == "DocumentAttributeAudio":
|
|
242
|
+
voice = bool(getattr(attribute, "voice", False))
|
|
243
|
+
audio = not voice
|
|
244
|
+
summary.duration = getattr(attribute, "duration", None)
|
|
245
|
+
summary.performer = getattr(attribute, "performer", None)
|
|
246
|
+
summary.title = getattr(attribute, "title", None)
|
|
247
|
+
summary.waveform = getattr(attribute, "waveform", None) is not None
|
|
248
|
+
elif name == "DocumentAttributeVideo":
|
|
249
|
+
video_note = bool(getattr(attribute, "round_message", False))
|
|
250
|
+
video = not video_note
|
|
251
|
+
summary.duration = getattr(attribute, "duration", None)
|
|
252
|
+
summary.width = getattr(attribute, "w", None)
|
|
253
|
+
summary.height = getattr(attribute, "h", None)
|
|
254
|
+
summary.round = video_note
|
|
255
|
+
summary.supports_streaming = bool(getattr(attribute, "supports_streaming", False))
|
|
256
|
+
elif name == "DocumentAttributeAnimated":
|
|
257
|
+
animated = True
|
|
258
|
+
elif name == "DocumentAttributeImageSize":
|
|
259
|
+
summary.width = getattr(attribute, "w", None)
|
|
260
|
+
summary.height = getattr(attribute, "h", None)
|
|
261
|
+
elif name == "DocumentAttributeFilename":
|
|
262
|
+
summary.file_name = getattr(attribute, "file_name", None)
|
|
263
|
+
|
|
264
|
+
if sticker:
|
|
265
|
+
summary.kind = "sticker"
|
|
266
|
+
elif voice:
|
|
267
|
+
summary.kind = "voice"
|
|
268
|
+
elif video_note:
|
|
269
|
+
summary.kind = "video_note"
|
|
270
|
+
elif animated or mime == "image/gif":
|
|
271
|
+
summary.kind = "gif"
|
|
272
|
+
summary.is_animated = True
|
|
273
|
+
elif video:
|
|
274
|
+
summary.kind = "video"
|
|
275
|
+
elif audio:
|
|
276
|
+
summary.kind = "audio"
|
|
277
|
+
return summary
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _fill_non_file(summary: MediaSummary, media: Any) -> None:
|
|
281
|
+
"""Flatten the non-file media variants onto the summary."""
|
|
282
|
+
geo = getattr(media, "geo", None)
|
|
283
|
+
if geo is not None:
|
|
284
|
+
summary.latitude = getattr(geo, "lat", None)
|
|
285
|
+
summary.longitude = getattr(geo, "long", None)
|
|
286
|
+
summary.venue_title = getattr(media, "title", None)
|
|
287
|
+
summary.contact_phone = getattr(media, "phone_number", None)
|
|
288
|
+
first = getattr(media, "first_name", None)
|
|
289
|
+
if first is not None:
|
|
290
|
+
last = getattr(media, "last_name", "") or ""
|
|
291
|
+
summary.contact_name = f"{first} {last}".strip()
|
|
292
|
+
summary.dice_emoji = getattr(media, "emoticon", None)
|
|
293
|
+
summary.dice_value = getattr(media, "value", None)
|
|
294
|
+
webpage = getattr(media, "webpage", None)
|
|
295
|
+
if webpage is not None:
|
|
296
|
+
summary.webpage_url = getattr(webpage, "url", None)
|
|
297
|
+
summary.webpage_title = getattr(webpage, "title", None)
|
|
298
|
+
story_id = getattr(media, "id", None)
|
|
299
|
+
if summary.kind == "story" and story_id is not None:
|
|
300
|
+
summary.story_id = int(story_id)
|
|
301
|
+
stars = getattr(media, "stars_amount", None)
|
|
302
|
+
if stars is not None:
|
|
303
|
+
summary.paid_stars = int(stars)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def reactions_summary(message: Any) -> ReactionSummary | None:
|
|
307
|
+
"""Compact reaction state, including whether WE already reacted.
|
|
308
|
+
|
|
309
|
+
Returns None when the message carries no reactions, so the field only
|
|
310
|
+
appears where it means something. `mine` comes from
|
|
311
|
+
`ReactionCount.chosen_order`, which Telegram sets only for this account —
|
|
312
|
+
without it the only way to learn that a reaction is already there is to
|
|
313
|
+
send one and read MESSAGE_NOT_MODIFIED as a failure.
|
|
314
|
+
"""
|
|
315
|
+
raw = getattr(message, "reactions", None)
|
|
316
|
+
if not raw:
|
|
317
|
+
return None
|
|
318
|
+
counts: dict[str, int] = {}
|
|
319
|
+
mine: list[str] = []
|
|
320
|
+
for result in getattr(raw, "results", None) or []:
|
|
321
|
+
reaction = getattr(result, "reaction", None)
|
|
322
|
+
emoji = getattr(reaction, "emoticon", None)
|
|
323
|
+
if emoji is None:
|
|
324
|
+
# Custom (premium) reactions carry only a document id; naming them
|
|
325
|
+
# is better than dropping them silently.
|
|
326
|
+
document = getattr(reaction, "document_id", None)
|
|
327
|
+
emoji = f"custom:{document}" if document is not None else "?"
|
|
328
|
+
counts[emoji] = counts.get(emoji, 0) + int(getattr(result, "count", 0) or 0)
|
|
329
|
+
if getattr(result, "chosen_order", None) is not None:
|
|
330
|
+
mine.append(emoji)
|
|
331
|
+
if not counts:
|
|
332
|
+
return None
|
|
333
|
+
return ReactionSummary(
|
|
334
|
+
counts=counts,
|
|
335
|
+
mine=mine,
|
|
336
|
+
total=sum(counts.values()),
|
|
337
|
+
can_see_list=getattr(raw, "can_see_list", None),
|
|
338
|
+
as_tags=bool(getattr(raw, "reactions_as_tags", False)),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def message_entities(message: Any) -> list[MessageEntity]:
|
|
343
|
+
"""Formatting runs, with their UTF-16 offsets untouched."""
|
|
344
|
+
out: list[MessageEntity] = []
|
|
345
|
+
for entity in getattr(message, "entities", None) or []:
|
|
346
|
+
user_id = getattr(entity, "user_id", None)
|
|
347
|
+
out.append(
|
|
348
|
+
MessageEntity(
|
|
349
|
+
type=tl_snake(type(entity).__name__, "MessageEntity"),
|
|
350
|
+
offset=int(getattr(entity, "offset", 0)),
|
|
351
|
+
length=int(getattr(entity, "length", 0)),
|
|
352
|
+
url=getattr(entity, "url", None),
|
|
353
|
+
user_id=getattr(user_id, "user_id", user_id),
|
|
354
|
+
language=getattr(entity, "language", None),
|
|
355
|
+
document_id=getattr(entity, "document_id", None),
|
|
356
|
+
collapsed=getattr(entity, "collapsed", None),
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def service_action(action: Any) -> ServiceAction | None:
|
|
363
|
+
"""A service message is an event, not "a message with empty text"."""
|
|
364
|
+
if action is None:
|
|
365
|
+
return None
|
|
366
|
+
tl_type = type(action).__name__
|
|
367
|
+
users = getattr(action, "users", None) or []
|
|
368
|
+
single = getattr(action, "user_id", None)
|
|
369
|
+
if single is not None:
|
|
370
|
+
users = [*users, single]
|
|
371
|
+
return ServiceAction(
|
|
372
|
+
type=tl_snake(tl_type, "MessageAction"),
|
|
373
|
+
tl_type=tl_type,
|
|
374
|
+
user_ids=[int(u) for u in users],
|
|
375
|
+
title=getattr(action, "title", None),
|
|
376
|
+
photo=_photo(getattr(action, "photo", None)),
|
|
377
|
+
duration=getattr(action, "duration", None),
|
|
378
|
+
call_id=getattr(action, "call_id", None),
|
|
379
|
+
ttl_seconds=getattr(action, "period", None) or getattr(action, "ttl_seconds", None),
|
|
380
|
+
boosts=getattr(action, "boosts", None),
|
|
381
|
+
stars=getattr(action, "stars", None),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _reply_header(message: Any) -> ReplyHeader | None:
|
|
386
|
+
reply = getattr(message, "reply_to", None)
|
|
387
|
+
if reply is None:
|
|
388
|
+
message_id = getattr(message, "reply_to_msg_id", None)
|
|
389
|
+
return ReplyHeader(message_id=message_id) if message_id else None
|
|
390
|
+
peer = getattr(reply, "reply_to_peer_id", None)
|
|
391
|
+
return ReplyHeader(
|
|
392
|
+
message_id=getattr(reply, "reply_to_msg_id", None),
|
|
393
|
+
peer_id=getattr(peer, "channel_id", None) or getattr(peer, "user_id", None),
|
|
394
|
+
top_message_id=getattr(reply, "reply_to_top_id", None),
|
|
395
|
+
forum_topic=bool(getattr(reply, "forum_topic", False)),
|
|
396
|
+
quote_text=getattr(reply, "quote_text", None),
|
|
397
|
+
quote_offset=getattr(reply, "quote_offset", None),
|
|
398
|
+
story_id=getattr(reply, "story_id", None),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _forward(message: Any) -> Any:
|
|
403
|
+
from tlgr.models.message import Forward
|
|
404
|
+
|
|
405
|
+
forward = getattr(message, "fwd_from", None) or getattr(message, "forward", None)
|
|
406
|
+
if forward is None:
|
|
407
|
+
return None
|
|
408
|
+
peer = getattr(forward, "from_id", None)
|
|
409
|
+
from_id = None
|
|
410
|
+
if peer is not None:
|
|
411
|
+
for attribute, kind in (
|
|
412
|
+
("user_id", "user"),
|
|
413
|
+
("channel_id", "channel"),
|
|
414
|
+
("chat_id", "group"),
|
|
415
|
+
):
|
|
416
|
+
value = getattr(peer, attribute, None)
|
|
417
|
+
if value is not None:
|
|
418
|
+
from_id = marked_id(int(value), kind)
|
|
419
|
+
break
|
|
420
|
+
return Forward(
|
|
421
|
+
from_id=from_id,
|
|
422
|
+
from_name=getattr(forward, "from_name", None),
|
|
423
|
+
date=fmt_dt(getattr(forward, "date", None)),
|
|
424
|
+
channel_post_id=getattr(forward, "channel_post", None),
|
|
425
|
+
post_author=getattr(forward, "post_author", None),
|
|
426
|
+
saved_from_msg_id=getattr(forward, "saved_from_msg_id", None),
|
|
427
|
+
imported=bool(getattr(forward, "imported", False)),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _text_of(message: Any) -> str:
|
|
432
|
+
"""The message body, whether or not the object is bound to a client.
|
|
433
|
+
|
|
434
|
+
Telethon's `Message.text` applies the client's parse mode and returns
|
|
435
|
+
`None` when there is no client — which is every message tlgr builds from
|
|
436
|
+
a raw `Updates` reply or receives through a fake. Falling back to
|
|
437
|
+
`raw_text`/`message` is the difference between `message send` reporting
|
|
438
|
+
the text it sent and reporting an empty string.
|
|
439
|
+
"""
|
|
440
|
+
for attribute in ("text", "raw_text", "message"):
|
|
441
|
+
value = getattr(message, attribute, None)
|
|
442
|
+
if isinstance(value, str) and value:
|
|
443
|
+
return value
|
|
444
|
+
return ""
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _sender_of(message: Any) -> int | None:
|
|
448
|
+
"""Who sent it, computed from `from_id` when Telethon has not.
|
|
449
|
+
|
|
450
|
+
`Message.sender_id` is filled in by `_finish_init`, which only runs for a
|
|
451
|
+
client-bound message. Every message tlgr builds from a raw `Updates` reply
|
|
452
|
+
therefore reports no sender unless it is derived here, and "who sent this"
|
|
453
|
+
is not an optional field.
|
|
454
|
+
"""
|
|
455
|
+
known = getattr(message, "sender_id", None)
|
|
456
|
+
if isinstance(known, int):
|
|
457
|
+
return known
|
|
458
|
+
peer = getattr(message, "from_id", None) or getattr(message, "peer_id", None)
|
|
459
|
+
return peer_id_of(peer)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def message_to_model(
|
|
463
|
+
message: Any, *, chat_id: int | None = None, link: str | None = None
|
|
464
|
+
) -> Message:
|
|
465
|
+
"""The one function that turns a Telethon message into a `Message`."""
|
|
466
|
+
action = getattr(message, "action", None)
|
|
467
|
+
date = getattr(message, "date", None)
|
|
468
|
+
if chat_id is None:
|
|
469
|
+
chat_id = int(getattr(message, "chat_id", 0) or 0)
|
|
470
|
+
return Message(
|
|
471
|
+
id=int(getattr(message, "id", 0)),
|
|
472
|
+
chat_id=chat_id,
|
|
473
|
+
date=fmt_dt(date) or "",
|
|
474
|
+
date_unix=to_unix(date) or 0,
|
|
475
|
+
text=_text_of(message),
|
|
476
|
+
out=bool(getattr(message, "out", False)),
|
|
477
|
+
kind="service" if action is not None else "message",
|
|
478
|
+
sender_id=_sender_of(message),
|
|
479
|
+
post_author=getattr(message, "post_author", None),
|
|
480
|
+
via_bot_id=getattr(message, "via_bot_id", None),
|
|
481
|
+
from_rank=getattr(message, "from_rank", None) if hasattr(message, "from_rank") else None,
|
|
482
|
+
entities=message_entities(message),
|
|
483
|
+
media=media_summary(getattr(message, "media", None)),
|
|
484
|
+
reactions=reactions_summary(message),
|
|
485
|
+
forward=_forward(message),
|
|
486
|
+
reply_to=_reply_header(message),
|
|
487
|
+
action=service_action(action),
|
|
488
|
+
grouped_id=getattr(message, "grouped_id", None),
|
|
489
|
+
edit_date=fmt_dt(getattr(message, "edit_date", None)),
|
|
490
|
+
pinned=bool(getattr(message, "pinned", False)),
|
|
491
|
+
silent=bool(getattr(message, "silent", False)),
|
|
492
|
+
noforwards=bool(getattr(message, "noforwards", False)),
|
|
493
|
+
mentioned=bool(getattr(message, "mentioned", False)),
|
|
494
|
+
media_unread=bool(getattr(message, "media_unread", False)),
|
|
495
|
+
ttl_period=getattr(message, "ttl_period", None),
|
|
496
|
+
effect_id=getattr(message, "effect", None),
|
|
497
|
+
views=getattr(message, "views", None),
|
|
498
|
+
forwards=getattr(message, "forwards", None),
|
|
499
|
+
edit_hide=bool(getattr(message, "edit_hide", False)),
|
|
500
|
+
reply_markup=reply_markup(getattr(message, "reply_markup", None)),
|
|
501
|
+
link=link,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def reply_markup(markup: Any) -> ReplyMarkup | None:
|
|
506
|
+
"""A message's keyboard, in the schema `bot press` addresses buttons in.
|
|
507
|
+
|
|
508
|
+
PR-1 declared the shape and left it unfilled, which made the two P0 ids
|
|
509
|
+
about rendering a keyboard true only on paper: a caller could not see a
|
|
510
|
+
button, so a caller could not press one. The `n` on each button is the
|
|
511
|
+
flat row-major index `bot press --button <n>` takes, printed rather than
|
|
512
|
+
left to be counted.
|
|
513
|
+
"""
|
|
514
|
+
from tlgr.ops._bots import BUTTON_TYPES
|
|
515
|
+
|
|
516
|
+
kinds = {
|
|
517
|
+
"ReplyInlineMarkup": "inline",
|
|
518
|
+
"ReplyKeyboardMarkup": "keyboard",
|
|
519
|
+
"ReplyKeyboardHide": "hide",
|
|
520
|
+
"ReplyKeyboardForceReply": "force_reply",
|
|
521
|
+
}
|
|
522
|
+
kind = kinds.get(type(markup).__name__)
|
|
523
|
+
if kind is None:
|
|
524
|
+
return None
|
|
525
|
+
|
|
526
|
+
rows: list[list[Button]] = []
|
|
527
|
+
index = 0
|
|
528
|
+
for row in getattr(markup, "rows", None) or []:
|
|
529
|
+
built: list[Button] = []
|
|
530
|
+
for button in getattr(row, "buttons", None) or []:
|
|
531
|
+
data = getattr(button, "data", None)
|
|
532
|
+
user_id = getattr(button, "user_id", None)
|
|
533
|
+
built.append(
|
|
534
|
+
Button(
|
|
535
|
+
text=str(getattr(button, "text", "") or ""),
|
|
536
|
+
type=BUTTON_TYPES.get(type(button).__name__, "unsupported"),
|
|
537
|
+
n=index,
|
|
538
|
+
data_b64=base64.b64encode(data).decode() if data else None,
|
|
539
|
+
url=getattr(button, "url", None),
|
|
540
|
+
query=getattr(button, "query", None),
|
|
541
|
+
user_id=int(user_id) if isinstance(user_id, int) else None,
|
|
542
|
+
requires_password=bool(getattr(button, "requires_password", False)),
|
|
543
|
+
same_peer=bool(getattr(button, "same_peer", False)),
|
|
544
|
+
copy_text=getattr(button, "copy_text", None),
|
|
545
|
+
button_id=getattr(button, "button_id", None),
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
index += 1
|
|
549
|
+
rows.append(built)
|
|
550
|
+
return ReplyMarkup(
|
|
551
|
+
kind=kind, # type: ignore[arg-type]
|
|
552
|
+
rows=rows,
|
|
553
|
+
resize=bool(getattr(markup, "resize", False)) or None,
|
|
554
|
+
single_use=bool(getattr(markup, "single_use", False)) or None,
|
|
555
|
+
selective=bool(getattr(markup, "selective", False)) or None,
|
|
556
|
+
persistent=bool(getattr(markup, "persistent", False)) or None,
|
|
557
|
+
placeholder=getattr(markup, "placeholder", None),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
# The chat-list shapes
|
|
563
|
+
# ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
#: `NotificationSound*` → the string `chat notify set --sound` accepts back.
|
|
567
|
+
def _sound(value: Any) -> str | None:
|
|
568
|
+
name = type(value).__name__
|
|
569
|
+
if value is None:
|
|
570
|
+
return None
|
|
571
|
+
if name == "NotificationSoundDefault":
|
|
572
|
+
return "default"
|
|
573
|
+
if name == "NotificationSoundNone":
|
|
574
|
+
return "none"
|
|
575
|
+
if name == "NotificationSoundLocal":
|
|
576
|
+
return f"local:{getattr(value, 'title', '') or ''}"
|
|
577
|
+
if name == "NotificationSoundRingtone":
|
|
578
|
+
return str(getattr(value, "id", "") or "")
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def notify_settings(raw: Any) -> NotifySettings | None:
|
|
583
|
+
"""`peerNotifySettings` as a model, keeping the tri-state intact.
|
|
584
|
+
|
|
585
|
+
An unset field means "inherit the scope default", which is a third state
|
|
586
|
+
next to on and off. It is preserved as `None` rather than folded into
|
|
587
|
+
`False`, because folding it is how a chat that inherits a muted scope
|
|
588
|
+
gets reported as unmuted.
|
|
589
|
+
"""
|
|
590
|
+
if raw is None:
|
|
591
|
+
return None
|
|
592
|
+
until = getattr(raw, "mute_until", None)
|
|
593
|
+
unix = to_unix(until)
|
|
594
|
+
return NotifySettings(
|
|
595
|
+
muted=bool(unix and unix > 0),
|
|
596
|
+
mute_until=fmt_dt(until),
|
|
597
|
+
mute_until_unix=unix,
|
|
598
|
+
silent=getattr(raw, "silent", None),
|
|
599
|
+
show_previews=getattr(raw, "show_previews", None),
|
|
600
|
+
# `peerNotifySettings` splits the sound three ways (iOS, Android,
|
|
601
|
+
# other); `other_sound` is the desktop/API one and the only sensible
|
|
602
|
+
# single answer for a CLI.
|
|
603
|
+
sound=_sound(getattr(raw, "other_sound", None) or getattr(raw, "sound", None)),
|
|
604
|
+
stories_muted=getattr(raw, "stories_muted", None),
|
|
605
|
+
stories_hide_sender=getattr(raw, "stories_hide_sender", None),
|
|
606
|
+
stories_sound=_sound(getattr(raw, "stories_sound", None)),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def action_bar(raw: Any, *, chat_id: int = 0) -> ActionBar:
|
|
611
|
+
"""`peerSettings` as the action bar a client draws above a chat."""
|
|
612
|
+
return ActionBar(
|
|
613
|
+
chat_id=chat_id,
|
|
614
|
+
report_spam=bool(getattr(raw, "report_spam", False)),
|
|
615
|
+
add_contact=bool(getattr(raw, "add_contact", False)),
|
|
616
|
+
block_contact=bool(getattr(raw, "block_contact", False)),
|
|
617
|
+
share_contact=bool(getattr(raw, "share_contact", False)),
|
|
618
|
+
need_contacts_exception=bool(getattr(raw, "need_contacts_exception", False)),
|
|
619
|
+
report_geo=bool(getattr(raw, "report_geo", False)),
|
|
620
|
+
autoarchived=bool(getattr(raw, "autoarchived", False)),
|
|
621
|
+
invite_members=bool(getattr(raw, "invite_members", False)),
|
|
622
|
+
request_chat_title=getattr(raw, "request_chat_title", None),
|
|
623
|
+
request_chat_date=fmt_dt(getattr(raw, "request_chat_date", None)),
|
|
624
|
+
request_chat_broadcast=bool(getattr(raw, "request_chat_broadcast", False)),
|
|
625
|
+
business_bot_id=getattr(raw, "business_bot_id", None),
|
|
626
|
+
business_bot_paused=bool(getattr(raw, "business_bot_paused", False)),
|
|
627
|
+
business_bot_can_reply=bool(getattr(raw, "business_bot_can_reply", False)),
|
|
628
|
+
charge_paid_message_stars=getattr(raw, "charge_paid_message_stars", None),
|
|
629
|
+
registration_month=getattr(raw, "registration_month", None),
|
|
630
|
+
phone_country=getattr(raw, "phone_country", None),
|
|
631
|
+
name_change_date=fmt_dt(getattr(raw, "name_change_date", None)),
|
|
632
|
+
photo_change_date=fmt_dt(getattr(raw, "photo_change_date", None)),
|
|
633
|
+
geo_distance=getattr(raw, "geo_distance", None),
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def chat_theme(raw: Any) -> ChatTheme | None:
|
|
638
|
+
"""A `chatTheme` / `theme` / unique-gift theme as one shape."""
|
|
639
|
+
if raw is None:
|
|
640
|
+
return None
|
|
641
|
+
emoticon = getattr(raw, "emoticon", None)
|
|
642
|
+
gift = getattr(raw, "gift", None) or getattr(raw, "unique_gift", None)
|
|
643
|
+
return ChatTheme(
|
|
644
|
+
emoticon=emoticon,
|
|
645
|
+
gift_id=getattr(gift, "id", None) if gift is not None else getattr(raw, "gift_id", None),
|
|
646
|
+
gift_slug=getattr(gift, "slug", None) if gift is not None else getattr(raw, "slug", None),
|
|
647
|
+
title=str(getattr(raw, "title", "") or getattr(gift, "title", "") or emoticon or ""),
|
|
648
|
+
premium_required=bool(getattr(raw, "premium_required", False)),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def wallpaper(raw: Any) -> ChatWallpaper | None:
|
|
653
|
+
"""A `wallPaper` (or `wallPaperNoFile`) reduced to what a CLI can echo."""
|
|
654
|
+
if raw is None:
|
|
655
|
+
return None
|
|
656
|
+
settings = getattr(raw, "settings", None)
|
|
657
|
+
return ChatWallpaper(
|
|
658
|
+
id=getattr(raw, "id", None),
|
|
659
|
+
slug=getattr(raw, "slug", None),
|
|
660
|
+
dark=bool(getattr(raw, "dark", False)),
|
|
661
|
+
pattern=bool(getattr(raw, "pattern", False)),
|
|
662
|
+
blur=bool(getattr(settings, "blur", False)),
|
|
663
|
+
intensity=getattr(settings, "intensity", None),
|
|
664
|
+
background_color=getattr(settings, "background_color", None),
|
|
665
|
+
second_background_color=getattr(settings, "second_background_color", None),
|
|
666
|
+
emoticon=getattr(settings, "emoticon", None),
|
|
667
|
+
)
|