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/_layer.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""What this build genuinely cannot do, said once and said the same way.
|
|
2
|
+
|
|
3
|
+
Telethon 1.44 speaks MTProto layer 227. Two server features tlgr's command
|
|
4
|
+
surface names — Communities and the `ephemeral.*` welcome messages — are
|
|
5
|
+
layer 229 and have no TL classes to call. The commands are registered anyway
|
|
6
|
+
and refuse with `NOT_SUPPORTED` (exit 13 — "tlgr cannot do this", not "the
|
|
7
|
+
operation failed", §7.3), because a command that is absent teaches an agent
|
|
8
|
+
nothing while a command that refuses with a reason teaches it exactly what is
|
|
9
|
+
missing and when it will arrive.
|
|
10
|
+
|
|
11
|
+
`ARCHITECTURE §6.14` describes the escape hatch (`core/custom_tl.py`) that
|
|
12
|
+
would close these: hand-written `TLRequest` subclasses invoked with an
|
|
13
|
+
explicit layer. Nothing on the P0/P1 path needs it, so the gap is declared
|
|
14
|
+
rather than hand-rolled.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from tlgr.core.errors import NotSupportedError
|
|
20
|
+
|
|
21
|
+
__all__ = ["LAYER", "NEEDED_LAYER", "community_gap", "welcome_gap"]
|
|
22
|
+
|
|
23
|
+
#: The layer the installed Telethon speaks.
|
|
24
|
+
LAYER = 227
|
|
25
|
+
|
|
26
|
+
#: The layer these features arrived in.
|
|
27
|
+
NEEDED_LAYER = 229
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _refuse(command: str, methods: str, feature: str) -> None:
|
|
31
|
+
raise NotSupportedError(
|
|
32
|
+
f"{command} needs {methods}, which arrived in MTProto layer {NEEDED_LAYER}; "
|
|
33
|
+
f"Telethon 1.44 speaks layer {LAYER}, so tlgr has no request class to send. "
|
|
34
|
+
f"The {feature} surface is reserved: the command shape is settled and will "
|
|
35
|
+
"start working with the layer uplift, without a flag or a path changing"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def community_gap(command: str, methods: str) -> None:
|
|
40
|
+
"""Refuse a `communities.*` command (layer 229)."""
|
|
41
|
+
_refuse(command, methods, "Community")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def welcome_gap(command: str, methods: str) -> None:
|
|
45
|
+
"""Refuse an `ephemeral.*` welcome-message command (layer 229)."""
|
|
46
|
+
_refuse(command, methods, "welcome-message")
|
tlgr/ops/_media.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""What the four media modules share: references, documents and set names.
|
|
2
|
+
|
|
3
|
+
Three things are hard about media and all three are reference handling:
|
|
4
|
+
|
|
5
|
+
* **a `file_reference` expires.** Every `InputDocument`/`InputPhoto` tlgr
|
|
6
|
+
builds is derived from an object fetched *now* — a message re-read, a
|
|
7
|
+
sticker set re-fetched, the saved-GIF list re-listed — because a stale
|
|
8
|
+
reference fails with `FILE_REFERENCE_EXPIRED` and the fix is never a retry,
|
|
9
|
+
it is a re-fetch (§api file-references).
|
|
10
|
+
* **a document does not say what it is.** `MessageMediaDocument` is the same
|
|
11
|
+
class for a thumbs-up sticker, a voice note, a GIF and a PDF; the answer is
|
|
12
|
+
in the attributes, and only after *all* of them are collected.
|
|
13
|
+
* **a sticker set has four spellings** — short name, t.me link, numeric id and
|
|
14
|
+
the system sets the clients use internally — and every command in the group
|
|
15
|
+
accepts all four.
|
|
16
|
+
|
|
17
|
+
Telethon is imported inside functions, never at module scope: importing the
|
|
18
|
+
registry builds `tlgr --help`, and that must not pull in Telethon (§2.2).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from tlgr.core.errors import NotFoundError, UsageError
|
|
27
|
+
from tlgr.core.pagination import PageKind, decode_cursor
|
|
28
|
+
from tlgr.core.timefmt import fmt_dt, to_unix
|
|
29
|
+
from tlgr.models.media import MediaFile, MediaSize
|
|
30
|
+
from tlgr.models.sticker import SavedGif, Sticker, StickerSet
|
|
31
|
+
from tlgr.ops._spec import OpContext
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"MEDIA_FILTERS",
|
|
35
|
+
"SYSTEM_SETS",
|
|
36
|
+
"app_config",
|
|
37
|
+
"b64",
|
|
38
|
+
"client",
|
|
39
|
+
"document_of",
|
|
40
|
+
"file_id_of",
|
|
41
|
+
"gif_model",
|
|
42
|
+
"input_document",
|
|
43
|
+
"input_photo",
|
|
44
|
+
"media_file",
|
|
45
|
+
"media_filter",
|
|
46
|
+
"set_link",
|
|
47
|
+
"sticker_model",
|
|
48
|
+
"sticker_set_model",
|
|
49
|
+
"sticker_set_ref",
|
|
50
|
+
"window",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
#: `--type` → the `inputMessagesFilter*` that implements a shared-media tab.
|
|
54
|
+
#: `chat-photo` is the group/channel avatar history: chats have no
|
|
55
|
+
#: `photos.getUserPhotos` equivalent, so the filter is the only way to it.
|
|
56
|
+
MEDIA_FILTERS: dict[str, str] = {
|
|
57
|
+
"photo": "InputMessagesFilterPhotos",
|
|
58
|
+
"video": "InputMessagesFilterVideo",
|
|
59
|
+
"media": "InputMessagesFilterPhotoVideo",
|
|
60
|
+
"file": "InputMessagesFilterDocument",
|
|
61
|
+
"link": "InputMessagesFilterUrl",
|
|
62
|
+
"music": "InputMessagesFilterMusic",
|
|
63
|
+
"voice": "InputMessagesFilterVoice",
|
|
64
|
+
"gif": "InputMessagesFilterGif",
|
|
65
|
+
"round": "InputMessagesFilterRoundVideo",
|
|
66
|
+
"chat-photo": "InputMessagesFilterChatPhotos",
|
|
67
|
+
"all": "InputMessagesFilterEmpty",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#: The sets a client holds without ever installing them: dice faces, the
|
|
71
|
+
#: animated-emoji assets, the default status and topic-icon packs.
|
|
72
|
+
SYSTEM_SETS: dict[str, str] = {
|
|
73
|
+
"animated-emoji": "InputStickerSetAnimatedEmoji",
|
|
74
|
+
"emoji-animations": "InputStickerSetAnimatedEmojiAnimations",
|
|
75
|
+
"premium-gifts": "InputStickerSetPremiumGifts",
|
|
76
|
+
"generic-animations": "InputStickerSetEmojiGenericAnimations",
|
|
77
|
+
"default-statuses": "InputStickerSetEmojiDefaultStatuses",
|
|
78
|
+
"channel-statuses": "InputStickerSetEmojiChannelDefaultStatuses",
|
|
79
|
+
"topic-icons": "InputStickerSetEmojiDefaultTopicIcons",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def client(ctx: OpContext) -> Any:
|
|
84
|
+
handle = getattr(ctx, "client", None)
|
|
85
|
+
if handle is None: # pragma: no cover - the daemon always supplies one
|
|
86
|
+
raise UsageError("this operation needs a connected account")
|
|
87
|
+
return handle
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def window(ctx: OpContext, op: str, kind: PageKind, default: int = 50) -> tuple[int, Any]:
|
|
91
|
+
"""`(limit, cursor state)` — `--limit`/`--cursor` are transport-level (L5)."""
|
|
92
|
+
limit = int(getattr(ctx, "limit", None) or default)
|
|
93
|
+
if limit < 1:
|
|
94
|
+
raise UsageError("--limit must be at least 1", field="limit")
|
|
95
|
+
token = getattr(ctx, "cursor", None)
|
|
96
|
+
state: dict[str, Any] = {}
|
|
97
|
+
if token:
|
|
98
|
+
state = decode_cursor(token, op=op, kind=kind, account=ctx.account)
|
|
99
|
+
return min(limit, 1000), state
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def b64(data: Any) -> str | None:
|
|
103
|
+
"""Bytes as base64, because JSON has none and a caller round-trips these."""
|
|
104
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
105
|
+
return None
|
|
106
|
+
return base64.b64encode(bytes(data)).decode()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def unb64(text: str | None) -> bytes:
|
|
110
|
+
if not text:
|
|
111
|
+
return b""
|
|
112
|
+
try:
|
|
113
|
+
return base64.b64decode(text)
|
|
114
|
+
except (ValueError, TypeError) as exc:
|
|
115
|
+
raise UsageError(f"{text!r} is not base64", field="file_reference") from exc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def media_filter(name: str | None) -> Any:
|
|
119
|
+
"""`--type photo` → `InputMessagesFilterPhotos()`."""
|
|
120
|
+
class_name = MEDIA_FILTERS.get(name or "media")
|
|
121
|
+
if class_name is None:
|
|
122
|
+
raise UsageError(
|
|
123
|
+
f"--type {name!r} is not a media tab; expected one of {', '.join(MEDIA_FILTERS)}",
|
|
124
|
+
field="type",
|
|
125
|
+
)
|
|
126
|
+
from telethon.tl import types
|
|
127
|
+
|
|
128
|
+
return getattr(types, class_name)()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Documents
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def document_of(media: Any) -> Any:
|
|
137
|
+
"""The `Document` behind whatever wrapper is holding it."""
|
|
138
|
+
if media is None:
|
|
139
|
+
return None
|
|
140
|
+
if type(media).__name__ == "Document":
|
|
141
|
+
return media
|
|
142
|
+
return getattr(media, "document", None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def photo_of(media: Any) -> Any:
|
|
146
|
+
if media is None:
|
|
147
|
+
return None
|
|
148
|
+
if type(media).__name__ == "Photo":
|
|
149
|
+
return media
|
|
150
|
+
return getattr(media, "photo", None)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def input_document(document: Any) -> Any:
|
|
154
|
+
"""`InputDocument` from a live `Document`, reference and all."""
|
|
155
|
+
from telethon.tl import types
|
|
156
|
+
|
|
157
|
+
if document is None:
|
|
158
|
+
raise NotFoundError("this media carries no document")
|
|
159
|
+
if type(document).__name__ == "InputDocument":
|
|
160
|
+
return document
|
|
161
|
+
return types.InputDocument(
|
|
162
|
+
id=int(getattr(document, "id", 0)),
|
|
163
|
+
access_hash=int(getattr(document, "access_hash", 0) or 0),
|
|
164
|
+
file_reference=getattr(document, "file_reference", b"") or b"",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def input_photo(photo: Any) -> Any:
|
|
169
|
+
from telethon.tl import types
|
|
170
|
+
|
|
171
|
+
if photo is None:
|
|
172
|
+
raise NotFoundError("this media carries no photo")
|
|
173
|
+
if type(photo).__name__ == "InputPhoto":
|
|
174
|
+
return photo
|
|
175
|
+
return types.InputPhoto(
|
|
176
|
+
id=int(getattr(photo, "id", 0)),
|
|
177
|
+
access_hash=int(getattr(photo, "access_hash", 0) or 0),
|
|
178
|
+
file_reference=getattr(photo, "file_reference", b"") or b"",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def file_id_of(media: Any) -> str | None:
|
|
183
|
+
"""`telethon.utils.pack_bot_file_id` — a convenience handle, not an identity.
|
|
184
|
+
|
|
185
|
+
The Bot-API-shaped string carries **no** `file_reference`, so it is only
|
|
186
|
+
usable while tlgr can refresh one for it (`media file-id get`). Returning
|
|
187
|
+
None rather than raising: a media type that has no packed form is normal.
|
|
188
|
+
"""
|
|
189
|
+
if media is None:
|
|
190
|
+
return None
|
|
191
|
+
try:
|
|
192
|
+
from telethon import utils
|
|
193
|
+
|
|
194
|
+
return utils.pack_bot_file_id(media) or None
|
|
195
|
+
except Exception:
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def attributes_of(document: Any) -> dict[str, Any]:
|
|
200
|
+
"""Every `DocumentAttribute*` collapsed into one dict.
|
|
201
|
+
|
|
202
|
+
Collected before anything is decided: a GIF carries Video **and**
|
|
203
|
+
Animated and a video sticker carries Video **and** Sticker, so
|
|
204
|
+
"first attribute wins" gets both of them wrong (v1's bug, kept fixed).
|
|
205
|
+
"""
|
|
206
|
+
facts: dict[str, Any] = {}
|
|
207
|
+
for attribute in getattr(document, "attributes", None) or []:
|
|
208
|
+
name = type(attribute).__name__
|
|
209
|
+
if name == "DocumentAttributeSticker":
|
|
210
|
+
facts["sticker"] = getattr(attribute, "alt", "") or ""
|
|
211
|
+
facts["mask"] = bool(getattr(attribute, "mask", False))
|
|
212
|
+
facts["stickerset"] = getattr(attribute, "stickerset", None)
|
|
213
|
+
elif name == "DocumentAttributeCustomEmoji":
|
|
214
|
+
facts["custom_emoji"] = getattr(attribute, "alt", "") or ""
|
|
215
|
+
facts["free"] = bool(getattr(attribute, "free", False))
|
|
216
|
+
facts["text_color"] = bool(getattr(attribute, "text_color", False))
|
|
217
|
+
facts["stickerset"] = getattr(attribute, "stickerset", None)
|
|
218
|
+
elif name == "DocumentAttributeAudio":
|
|
219
|
+
facts["voice"] = bool(getattr(attribute, "voice", False))
|
|
220
|
+
facts["duration"] = getattr(attribute, "duration", None)
|
|
221
|
+
facts["title"] = getattr(attribute, "title", None)
|
|
222
|
+
facts["performer"] = getattr(attribute, "performer", None)
|
|
223
|
+
facts["waveform"] = getattr(attribute, "waveform", None) is not None
|
|
224
|
+
elif name == "DocumentAttributeVideo":
|
|
225
|
+
facts["round"] = bool(getattr(attribute, "round_message", False))
|
|
226
|
+
facts["video"] = True
|
|
227
|
+
facts["duration"] = getattr(attribute, "duration", None)
|
|
228
|
+
facts["width"] = getattr(attribute, "w", None)
|
|
229
|
+
facts["height"] = getattr(attribute, "h", None)
|
|
230
|
+
facts["supports_streaming"] = bool(getattr(attribute, "supports_streaming", False))
|
|
231
|
+
facts["nosound"] = bool(getattr(attribute, "nosound", False))
|
|
232
|
+
elif name == "DocumentAttributeAnimated":
|
|
233
|
+
facts["animated"] = True
|
|
234
|
+
elif name == "DocumentAttributeImageSize":
|
|
235
|
+
facts["width"] = getattr(attribute, "w", None)
|
|
236
|
+
facts["height"] = getattr(attribute, "h", None)
|
|
237
|
+
elif name == "DocumentAttributeFilename":
|
|
238
|
+
facts["file_name"] = getattr(attribute, "file_name", None)
|
|
239
|
+
return facts
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def kind_of(document: Any, facts: dict[str, Any] | None = None) -> str:
|
|
243
|
+
"""`sticker`/`voice`/`video_note`/`gif`/`video`/`audio`/`file`."""
|
|
244
|
+
facts = attributes_of(document) if facts is None else facts
|
|
245
|
+
mime = getattr(document, "mime_type", None)
|
|
246
|
+
if "custom_emoji" in facts:
|
|
247
|
+
return "sticker"
|
|
248
|
+
if "sticker" in facts:
|
|
249
|
+
return "sticker"
|
|
250
|
+
if facts.get("voice"):
|
|
251
|
+
return "voice"
|
|
252
|
+
if facts.get("round"):
|
|
253
|
+
return "video_note"
|
|
254
|
+
if facts.get("animated") or mime == "image/gif":
|
|
255
|
+
return "gif"
|
|
256
|
+
if facts.get("video"):
|
|
257
|
+
return "video"
|
|
258
|
+
if "duration" in facts and not facts.get("video"):
|
|
259
|
+
return "audio"
|
|
260
|
+
return "file"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def photo_sizes(photo: Any) -> list[MediaSize]:
|
|
264
|
+
"""Every `PhotoSize` variant, with the inline bytes where there are any."""
|
|
265
|
+
out: list[MediaSize] = []
|
|
266
|
+
for size in getattr(photo, "sizes", None) or []:
|
|
267
|
+
name = type(size).__name__
|
|
268
|
+
out.append(
|
|
269
|
+
MediaSize(
|
|
270
|
+
type=str(getattr(size, "type", "") or ""),
|
|
271
|
+
width=getattr(size, "w", None),
|
|
272
|
+
height=getattr(size, "h", None),
|
|
273
|
+
size=getattr(size, "size", None),
|
|
274
|
+
bytes_b64=b64(getattr(size, "bytes", None))
|
|
275
|
+
if name in ("PhotoStrippedSize", "PhotoPathSize")
|
|
276
|
+
else None,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
for size in getattr(photo, "video_sizes", None) or []:
|
|
280
|
+
out.append(
|
|
281
|
+
MediaSize(
|
|
282
|
+
type=str(getattr(size, "type", "") or ""),
|
|
283
|
+
width=getattr(size, "w", None),
|
|
284
|
+
height=getattr(size, "h", None),
|
|
285
|
+
size=getattr(size, "size", None),
|
|
286
|
+
video=True,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return out
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def media_file(document: Any) -> MediaFile | None:
|
|
293
|
+
if document is None:
|
|
294
|
+
return None
|
|
295
|
+
return MediaFile(
|
|
296
|
+
doc_id=int(getattr(document, "id", 0) or 0),
|
|
297
|
+
access_hash=getattr(document, "access_hash", None),
|
|
298
|
+
file_reference_b64=b64(getattr(document, "file_reference", None)),
|
|
299
|
+
dc_id=getattr(document, "dc_id", None),
|
|
300
|
+
mime=getattr(document, "mime_type", None),
|
|
301
|
+
size=getattr(document, "size", None),
|
|
302
|
+
file_id=file_id_of(document),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def sticker_model(
|
|
307
|
+
document: Any,
|
|
308
|
+
*,
|
|
309
|
+
index: int | None = None,
|
|
310
|
+
set_short_name: str | None = None,
|
|
311
|
+
set_title: str | None = None,
|
|
312
|
+
keywords: list[str] | None = None,
|
|
313
|
+
premium: bool = False,
|
|
314
|
+
) -> Sticker:
|
|
315
|
+
"""One sticker or custom-emoji document as the row every list prints."""
|
|
316
|
+
facts = attributes_of(document)
|
|
317
|
+
stickerset = facts.get("stickerset")
|
|
318
|
+
return Sticker(
|
|
319
|
+
doc_id=int(getattr(document, "id", 0) or 0),
|
|
320
|
+
access_hash=getattr(document, "access_hash", None),
|
|
321
|
+
file_reference_b64=b64(getattr(document, "file_reference", None)),
|
|
322
|
+
emoji=facts.get("custom_emoji") or facts.get("sticker") or None,
|
|
323
|
+
keywords=list(keywords or []),
|
|
324
|
+
index=index,
|
|
325
|
+
set_id=getattr(stickerset, "id", None),
|
|
326
|
+
set_short_name=set_short_name or getattr(stickerset, "short_name", None),
|
|
327
|
+
set=set_title,
|
|
328
|
+
mime=getattr(document, "mime_type", None),
|
|
329
|
+
size=getattr(document, "size", None),
|
|
330
|
+
width=facts.get("width"),
|
|
331
|
+
height=facts.get("height"),
|
|
332
|
+
dc_id=getattr(document, "dc_id", None),
|
|
333
|
+
premium=premium,
|
|
334
|
+
mask=bool(facts.get("mask")),
|
|
335
|
+
custom_emoji="custom_emoji" in facts,
|
|
336
|
+
free=facts.get("free"),
|
|
337
|
+
text_color=facts.get("text_color"),
|
|
338
|
+
file_id=file_id_of(document),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def gif_model(document: Any, index: int | None = None) -> SavedGif:
|
|
343
|
+
facts = attributes_of(document)
|
|
344
|
+
return SavedGif(
|
|
345
|
+
doc_id=int(getattr(document, "id", 0) or 0),
|
|
346
|
+
access_hash=getattr(document, "access_hash", None),
|
|
347
|
+
file_reference_b64=b64(getattr(document, "file_reference", None)),
|
|
348
|
+
index=index,
|
|
349
|
+
mime=getattr(document, "mime_type", None),
|
|
350
|
+
size=getattr(document, "size", None),
|
|
351
|
+
duration=int(facts["duration"]) if facts.get("duration") is not None else None,
|
|
352
|
+
width=facts.get("width"),
|
|
353
|
+
height=facts.get("height"),
|
|
354
|
+
file_id=file_id_of(document),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# Sticker sets
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def set_link(short_name: str, kind: str = "sticker") -> str | None:
|
|
364
|
+
"""`t.me/addstickers/<name>`, or `addemoji` for an emoji set.
|
|
365
|
+
|
|
366
|
+
String formatting, not a request: the share link has never needed one.
|
|
367
|
+
"""
|
|
368
|
+
if not short_name:
|
|
369
|
+
return None
|
|
370
|
+
path = "addemoji" if kind == "emoji" else "addstickers"
|
|
371
|
+
return f"https://t.me/{path}/{short_name}"
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _short_name_from_link(text: str) -> str | None:
|
|
375
|
+
lowered = text.lower()
|
|
376
|
+
for marker in ("addstickers/", "addemoji/", "stickerset/"):
|
|
377
|
+
if marker in lowered:
|
|
378
|
+
tail = text[lowered.index(marker) + len(marker) :]
|
|
379
|
+
return tail.split("?")[0].split("/")[0].strip()
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def sticker_set_ref(text: str, *, field: str = "set") -> Any:
|
|
384
|
+
"""Any of the four spellings of a set as one `InputStickerSet`.
|
|
385
|
+
|
|
386
|
+
A bare numeric id is deliberately **not** accepted: `InputStickerSetID`
|
|
387
|
+
needs an access hash the id alone does not carry, and guessing one
|
|
388
|
+
produces `STICKERSET_INVALID` rather than a useful message. `id:hash`
|
|
389
|
+
is accepted for a caller that has both (that is what `sticker set list`
|
|
390
|
+
prints), and everything else must be a short name or a link.
|
|
391
|
+
"""
|
|
392
|
+
from telethon.tl import types
|
|
393
|
+
|
|
394
|
+
value = (text or "").strip()
|
|
395
|
+
if not value:
|
|
396
|
+
raise UsageError("a sticker set is required", field=field)
|
|
397
|
+
|
|
398
|
+
lowered = value.lower()
|
|
399
|
+
if lowered.startswith("dice:"):
|
|
400
|
+
emoticon = value.split(":", 1)[1].strip()
|
|
401
|
+
if not emoticon:
|
|
402
|
+
raise UsageError("dice: needs an emoji, e.g. dice:🎲", field=field)
|
|
403
|
+
return types.InputStickerSetDice(emoticon=emoticon)
|
|
404
|
+
system = SYSTEM_SETS.get(lowered)
|
|
405
|
+
if system is not None:
|
|
406
|
+
return getattr(types, system)()
|
|
407
|
+
|
|
408
|
+
link = _short_name_from_link(value)
|
|
409
|
+
if link:
|
|
410
|
+
return types.InputStickerSetShortName(short_name=link)
|
|
411
|
+
|
|
412
|
+
if ":" in value:
|
|
413
|
+
head, _, tail = value.partition(":")
|
|
414
|
+
if head.lstrip("-").isdigit() and tail.lstrip("-").isdigit():
|
|
415
|
+
return types.InputStickerSetID(id=int(head), access_hash=int(tail))
|
|
416
|
+
if value.lstrip("-").isdigit():
|
|
417
|
+
raise UsageError(
|
|
418
|
+
f"{value!r} is a set id without its access hash; name the set by its short "
|
|
419
|
+
"name or its t.me link, or pass <id>:<access_hash>",
|
|
420
|
+
field=field,
|
|
421
|
+
)
|
|
422
|
+
return types.InputStickerSetShortName(short_name=value.lstrip("@"))
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def set_kind(set_obj: Any) -> str:
|
|
426
|
+
if getattr(set_obj, "emojis", False):
|
|
427
|
+
return "emoji"
|
|
428
|
+
if getattr(set_obj, "masks", False):
|
|
429
|
+
return "mask"
|
|
430
|
+
return "sticker"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def sticker_set_model(set_obj: Any, *, covers: list[Any] | None = None) -> StickerSet:
|
|
434
|
+
"""A `StickerSet` TL object as the metadata row every listing prints."""
|
|
435
|
+
kind = set_kind(set_obj)
|
|
436
|
+
short_name = str(getattr(set_obj, "short_name", "") or "")
|
|
437
|
+
thumbs = getattr(set_obj, "thumbs", None) or []
|
|
438
|
+
thumb = None
|
|
439
|
+
if thumbs:
|
|
440
|
+
first = thumbs[0]
|
|
441
|
+
thumb = MediaFile(
|
|
442
|
+
doc_id=int(getattr(set_obj, "thumb_document_id", 0) or 0),
|
|
443
|
+
dc_id=getattr(set_obj, "thumb_dc_id", None),
|
|
444
|
+
size=getattr(first, "size", None),
|
|
445
|
+
)
|
|
446
|
+
return StickerSet(
|
|
447
|
+
id=int(getattr(set_obj, "id", 0) or 0),
|
|
448
|
+
access_hash=getattr(set_obj, "access_hash", None),
|
|
449
|
+
short_name=short_name,
|
|
450
|
+
title=str(getattr(set_obj, "title", "") or ""),
|
|
451
|
+
count=int(getattr(set_obj, "count", 0) or 0),
|
|
452
|
+
type=kind,
|
|
453
|
+
installed=getattr(set_obj, "installed_date", None) is not None,
|
|
454
|
+
archived=bool(getattr(set_obj, "archived", False)),
|
|
455
|
+
official=bool(getattr(set_obj, "official", False)),
|
|
456
|
+
creator=bool(getattr(set_obj, "creator", False)),
|
|
457
|
+
text_color=bool(getattr(set_obj, "text_color", False)),
|
|
458
|
+
installed_date=fmt_dt(getattr(set_obj, "installed_date", None)),
|
|
459
|
+
thumb=thumb,
|
|
460
|
+
link=set_link(short_name, kind),
|
|
461
|
+
covers=[sticker_model(doc) for doc in (covers or [])],
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def covered_set(covered: Any) -> StickerSet:
|
|
466
|
+
"""`stickerSetCovered` / `stickerSetMultiCovered` → one `StickerSet`."""
|
|
467
|
+
inner = getattr(covered, "set", covered)
|
|
468
|
+
covers = list(getattr(covered, "covers", None) or [])
|
|
469
|
+
single = getattr(covered, "cover", None)
|
|
470
|
+
if single is not None:
|
|
471
|
+
covers = [single]
|
|
472
|
+
return sticker_set_model(inner, covers=covers)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def fetch_set(ctx: OpContext, ref: Any, *, field: str = "set") -> Any:
|
|
476
|
+
"""`messages.getStickerSet` for any spelling, with a NOT_FOUND on a miss."""
|
|
477
|
+
from telethon.tl.functions import messages as fn
|
|
478
|
+
|
|
479
|
+
stickerset = ref if not isinstance(ref, str) else sticker_set_ref(ref, field=field)
|
|
480
|
+
try:
|
|
481
|
+
return await client(ctx)(fn.GetStickerSetRequest(stickerset=stickerset, hash=0))
|
|
482
|
+
except Exception as exc:
|
|
483
|
+
if "STICKERSET_INVALID" in str(exc).upper():
|
|
484
|
+
raise NotFoundError(f"no sticker set matches {ref!r}") from exc
|
|
485
|
+
raise
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
async def resolve_stickers(
|
|
489
|
+
ctx: OpContext,
|
|
490
|
+
refs: list[str],
|
|
491
|
+
*,
|
|
492
|
+
from_message: tuple[Any, int] | None = None,
|
|
493
|
+
) -> list[Any]:
|
|
494
|
+
"""`<set>/<index>`, `<set>/<emoji>` or a document id → live `Document`s.
|
|
495
|
+
|
|
496
|
+
Always through a fresh `messages.getStickerSet`: an `InputDocument` built
|
|
497
|
+
from a cached id carries a dead `file_reference`, and every
|
|
498
|
+
`faveSticker`/`saveRecentSticker`/`removeStickerFromSet` call would fail
|
|
499
|
+
with `FILE_REFERENCE_EXPIRED` a few hours after the id was printed.
|
|
500
|
+
"""
|
|
501
|
+
out: list[Any] = []
|
|
502
|
+
if from_message is not None:
|
|
503
|
+
peer, message_id = from_message
|
|
504
|
+
message = await fetch_message(ctx, peer, message_id)
|
|
505
|
+
document = document_of(getattr(message, "media", None))
|
|
506
|
+
if document is None:
|
|
507
|
+
raise NotFoundError(f"message {message_id} carries no sticker")
|
|
508
|
+
out.append(document)
|
|
509
|
+
|
|
510
|
+
cache: dict[str, Any] = {}
|
|
511
|
+
for ref in refs:
|
|
512
|
+
value = str(ref).strip()
|
|
513
|
+
if "/" in value:
|
|
514
|
+
set_name, _, selector = value.rpartition("/")
|
|
515
|
+
if set_name not in cache:
|
|
516
|
+
cache[set_name] = await fetch_set(ctx, set_name, field="sticker")
|
|
517
|
+
documents = list(getattr(cache[set_name], "documents", None) or [])
|
|
518
|
+
out.append(_pick_sticker(documents, selector, value))
|
|
519
|
+
continue
|
|
520
|
+
raise UsageError(
|
|
521
|
+
f"{value!r} is not a sticker; use <set>/<index>, <set>/<emoji> or --from-message",
|
|
522
|
+
field="sticker",
|
|
523
|
+
)
|
|
524
|
+
if not out:
|
|
525
|
+
raise UsageError("no sticker was given", field="sticker")
|
|
526
|
+
return out
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _pick_sticker(documents: list[Any], selector: str, spelling: str) -> Any:
|
|
530
|
+
if selector.lstrip("-").isdigit():
|
|
531
|
+
index = int(selector)
|
|
532
|
+
if not 0 <= index < len(documents):
|
|
533
|
+
raise NotFoundError(f"{spelling}: the set has {len(documents)} stickers")
|
|
534
|
+
return documents[index]
|
|
535
|
+
for document in documents:
|
|
536
|
+
if attributes_of(document).get("sticker") == selector:
|
|
537
|
+
return document
|
|
538
|
+
if attributes_of(document).get("custom_emoji") == selector:
|
|
539
|
+
return document
|
|
540
|
+
raise NotFoundError(f"{spelling}: no sticker in the set carries that emoji")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
# Messages
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
async def fetch_message(ctx: OpContext, peer: Any, message_id: int) -> Any:
|
|
549
|
+
"""Re-read one message so its `file_reference` is current.
|
|
550
|
+
|
|
551
|
+
Every byte-touching path starts here. Telethon refreshes a reference
|
|
552
|
+
mid-download only when it was handed a `Message`; nothing refreshes one
|
|
553
|
+
for a send, so tlgr fetches the message itself and builds the input media
|
|
554
|
+
from what came back.
|
|
555
|
+
"""
|
|
556
|
+
found = await client(ctx).get_messages(peer, ids=[int(message_id)])
|
|
557
|
+
message = (found or [None])[0]
|
|
558
|
+
if message is None or not getattr(message, "id", 0):
|
|
559
|
+
raise NotFoundError(f"message {message_id} does not exist here")
|
|
560
|
+
return message
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def message_dates(message: Any) -> tuple[str, int]:
|
|
564
|
+
date = getattr(message, "date", None)
|
|
565
|
+
return fmt_dt(date) or "", to_unix(date) or 0
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
async def app_config(ctx: OpContext) -> dict[str, Any]:
|
|
569
|
+
"""`help.getAppConfig` as plain Python. Never hardcode a server limit."""
|
|
570
|
+
from telethon.tl.functions import help as help_fn
|
|
571
|
+
|
|
572
|
+
config = await client(ctx)(help_fn.GetAppConfigRequest(hash=0))
|
|
573
|
+
|
|
574
|
+
def unwrap(node: Any) -> Any:
|
|
575
|
+
name = type(node).__name__
|
|
576
|
+
if name == "JsonObject":
|
|
577
|
+
return {str(v.key): unwrap(v.value) for v in (getattr(node, "value", None) or [])}
|
|
578
|
+
if name == "JsonArray":
|
|
579
|
+
return [unwrap(v) for v in (getattr(node, "value", None) or [])]
|
|
580
|
+
if name == "JsonNull":
|
|
581
|
+
return None
|
|
582
|
+
return getattr(node, "value", node)
|
|
583
|
+
|
|
584
|
+
return unwrap(getattr(config, "config", config)) or {}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def config_int(values: dict[str, Any], name: str, default: int = 0) -> int:
|
|
588
|
+
raw = values.get(name, default)
|
|
589
|
+
try:
|
|
590
|
+
return int(raw)
|
|
591
|
+
except (TypeError, ValueError):
|
|
592
|
+
return default
|