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/_settings.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""The plumbing the nine settings modules share.
|
|
2
|
+
|
|
3
|
+
`profile`, `privacy`, `notify`, `settings`, `business`, `premium`, `stars`,
|
|
4
|
+
`gift` and `giveaway` are one GUI screen each, but they keep meeting the same
|
|
5
|
+
four problems, and a second copy of any of them is how two commands start
|
|
6
|
+
disagreeing about the same server field:
|
|
7
|
+
|
|
8
|
+
* **a Stars amount is `(amount, nanos)`**, and TON arrives in the same shape
|
|
9
|
+
with nine decimals — collapsing either to a float loses the digit a ledger
|
|
10
|
+
reconciliation needs;
|
|
11
|
+
* **half of this surface replaces a whole constructor**, so the read half of
|
|
12
|
+
a read-modify-write is shared rather than re-derived per flag;
|
|
13
|
+
* **a gift is addressed by a reference string**, and the three spellings
|
|
14
|
+
(`msg:<id>`, `<peer>:<saved_id>`, a bare slug) must parse and *print* the
|
|
15
|
+
same way in every module;
|
|
16
|
+
* **five payments methods are absent from Telethon 1.44**, and the refusal
|
|
17
|
+
has to say which one and why, in one sentence, everywhere.
|
|
18
|
+
|
|
19
|
+
Telethon is imported inside functions, never at module scope (§2.2).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from tlgr.core.errors import NotSupportedError, PermissionError_, UsageError
|
|
27
|
+
from tlgr.core.timefmt import fmt_dt, fmt_unix
|
|
28
|
+
from tlgr.models.peer import Peer, PeerRef
|
|
29
|
+
from tlgr.ops._common import client
|
|
30
|
+
from tlgr.ops._spec import OpContext
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ABSENT_METHODS",
|
|
34
|
+
"NO_SPEND",
|
|
35
|
+
"app_config",
|
|
36
|
+
"client",
|
|
37
|
+
"color_int",
|
|
38
|
+
"color_text",
|
|
39
|
+
"entity_map",
|
|
40
|
+
"gift_ref_text",
|
|
41
|
+
"input_gift",
|
|
42
|
+
"input_user",
|
|
43
|
+
"iso",
|
|
44
|
+
"method_gap",
|
|
45
|
+
"on_off",
|
|
46
|
+
"peer_model",
|
|
47
|
+
"peer_of",
|
|
48
|
+
"refuse_spend",
|
|
49
|
+
"resolve",
|
|
50
|
+
"slug_of",
|
|
51
|
+
"sound_text",
|
|
52
|
+
"sound_value",
|
|
53
|
+
"stars_of",
|
|
54
|
+
"unix",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
#: The one sentence every refusal to move value ends with. PR-10 settled the
|
|
58
|
+
#: policy for the `payment` group (`ops/payment.py`); PR-12 inherits it rather
|
|
59
|
+
#: than opening a second door onto the same money.
|
|
60
|
+
NO_SPEND = (
|
|
61
|
+
"tlgr never spends money: it reads the price and refuses to sign the form. "
|
|
62
|
+
"Complete the purchase in an official client if you want it"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
#: `payments.*` methods this build has no request class for, and what each
|
|
66
|
+
#: one would have answered. Named here so the refusal, the docs and
|
|
67
|
+
#: `agent capabilities` cannot describe the gap three different ways.
|
|
68
|
+
ABSENT_METHODS: dict[str, str] = {
|
|
69
|
+
"payments.canSendStarGift": "whether a specific recipient accepts a specific gift",
|
|
70
|
+
"payments.getStarGiftCraftCandidates": "which of my gifts can be melted into a given one",
|
|
71
|
+
"payments.getStarGiftAttributes": "the full attribute table of a gift type",
|
|
72
|
+
"payments.getStarGiftValueInfo": "the floor price and last sale of a collectible",
|
|
73
|
+
"payments.getPrepaidGiveaways": "a channel's prepaid giveaways as a list of their own",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def method_gap(feature: str, method: str) -> Any:
|
|
78
|
+
"""Refuse a feature whose only MTProto method this Telethon lacks (exit 13).
|
|
79
|
+
|
|
80
|
+
Distinct from `_layer.py`: those are layer-229 *features* with no
|
|
81
|
+
constructor at all, these are individual methods missing from a Telethon
|
|
82
|
+
that otherwise speaks the surface. The command still exists and the rest
|
|
83
|
+
of it still works — only the flag that needs the method refuses.
|
|
84
|
+
"""
|
|
85
|
+
answers = ABSENT_METHODS.get(method, "")
|
|
86
|
+
raise NotSupportedError(
|
|
87
|
+
f"{feature} needs {method}, which Telethon 1.44 has no request class for"
|
|
88
|
+
+ (f" (it is what answers {answers})" if answers else "")
|
|
89
|
+
+ "; the rest of this command works, and the flag starts working with "
|
|
90
|
+
"the Telethon uplift without its spelling changing"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def refuse_spend(what: str) -> Any:
|
|
95
|
+
"""Refuse an operation that would move a financial asset (exit 6)."""
|
|
96
|
+
raise PermissionError_(f"{what}: {NO_SPEND}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Peers
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def resolve(ctx: OpContext, ref: PeerRef | str | None) -> Any:
|
|
105
|
+
"""The `InputPeer` for *ref*, through the account's own resolver."""
|
|
106
|
+
from tlgr.ops import _send
|
|
107
|
+
|
|
108
|
+
return await _send.resolve(ctx, ref)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def input_user(ctx: OpContext, ref: PeerRef | str | None, *, field: str = "user") -> Any:
|
|
112
|
+
"""The `InputUser` a `users.*`/`account.*` request wants."""
|
|
113
|
+
from tlgr.ops import _bots
|
|
114
|
+
|
|
115
|
+
return await _bots.input_user(ctx, ref, field=field)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def peer_of(peer: Any) -> int:
|
|
119
|
+
"""The marked id of a resolved `InputPeer`."""
|
|
120
|
+
from tlgr.ops import _send
|
|
121
|
+
|
|
122
|
+
return _send.peer_id_of(peer)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def peer_model(entity: Any) -> Peer | None:
|
|
126
|
+
"""A `User`/`Chat`/`Channel` as the shared `Peer` shape."""
|
|
127
|
+
if entity is None:
|
|
128
|
+
return None
|
|
129
|
+
from tlgr.ops._serialize import entity_to_peer
|
|
130
|
+
|
|
131
|
+
return entity_to_peer(entity)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def entity_map(result: Any) -> dict[int, Any]:
|
|
135
|
+
"""`{raw id: entity}` for the users *and* chats an answer carried.
|
|
136
|
+
|
|
137
|
+
Every `payments.*` and `account.*` answer in this group ships its peers in
|
|
138
|
+
two parallel vectors; a single map is what lets one lookup fill in a name
|
|
139
|
+
without caring which vector it came from.
|
|
140
|
+
"""
|
|
141
|
+
found: dict[int, Any] = {}
|
|
142
|
+
for name in ("users", "chats"):
|
|
143
|
+
for entity in getattr(result, name, None) or []:
|
|
144
|
+
with_id = getattr(entity, "id", None)
|
|
145
|
+
if with_id is not None:
|
|
146
|
+
found[int(with_id)] = entity
|
|
147
|
+
return found
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Scalars
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def iso(value: Any) -> str | None:
|
|
156
|
+
"""An RFC-3339 string for a datetime or a unix int, or None."""
|
|
157
|
+
if value is None:
|
|
158
|
+
return None
|
|
159
|
+
if isinstance(value, (int, float)):
|
|
160
|
+
return fmt_unix(int(value)) if value else None
|
|
161
|
+
return fmt_dt(value)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def unix(value: Any) -> int | None:
|
|
165
|
+
if value is None:
|
|
166
|
+
return None
|
|
167
|
+
if isinstance(value, (int, float)):
|
|
168
|
+
return int(value) or None
|
|
169
|
+
from tlgr.core.timefmt import to_unix
|
|
170
|
+
|
|
171
|
+
return to_unix(value)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def stars_of(amount: Any) -> tuple[int, int]:
|
|
175
|
+
"""`starsAmount` as `(amount, nanos)`.
|
|
176
|
+
|
|
177
|
+
An int is accepted because half the payments surface still reports a bare
|
|
178
|
+
Star count; the nanos are then genuinely zero rather than unknown.
|
|
179
|
+
"""
|
|
180
|
+
if amount is None:
|
|
181
|
+
return 0, 0
|
|
182
|
+
if isinstance(amount, (int, float)):
|
|
183
|
+
return int(amount), 0
|
|
184
|
+
return int(getattr(amount, "amount", 0) or 0), int(getattr(amount, "nanos", 0) or 0)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def on_off(value: str | None, *, field: str) -> bool | None:
|
|
188
|
+
"""`on`/`off` as a bool, `None` for "the caller did not say"."""
|
|
189
|
+
if value is None:
|
|
190
|
+
return None
|
|
191
|
+
text = value.strip().lower()
|
|
192
|
+
if text in ("on", "true", "yes", "1"):
|
|
193
|
+
return True
|
|
194
|
+
if text in ("off", "false", "no", "0"):
|
|
195
|
+
return False
|
|
196
|
+
raise UsageError(f"--{field.replace('_', '-')} takes on or off", field=field)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def color_int(text: str | None, *, field: str = "color") -> int | None:
|
|
200
|
+
"""`#RRGGBB`, `0xRRGGBB` or a decimal, as the int the API wants."""
|
|
201
|
+
if text is None:
|
|
202
|
+
return None
|
|
203
|
+
raw = str(text).strip().lstrip("#")
|
|
204
|
+
if raw.lower().startswith("0x"):
|
|
205
|
+
raw = raw[2:]
|
|
206
|
+
try:
|
|
207
|
+
return int(raw, 16) if not raw.isdigit() or len(raw) == 6 else int(raw)
|
|
208
|
+
except ValueError as exc:
|
|
209
|
+
raise UsageError(f"{text!r} is not a colour (use #RRGGBB)", field=field) from exc
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def color_text(value: Any) -> str:
|
|
213
|
+
"""An int colour as `#RRGGBB`, which is how a human reads one back."""
|
|
214
|
+
return f"#{int(value or 0) & 0xFFFFFF:06X}"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def sound_value(text: str | None) -> Any:
|
|
218
|
+
"""`default | none | local:<title> | ringtone:<id> | <id>` as a constructor."""
|
|
219
|
+
from telethon.tl import types
|
|
220
|
+
|
|
221
|
+
if text is None:
|
|
222
|
+
return None
|
|
223
|
+
value = text.strip()
|
|
224
|
+
if value in ("none", "off", "silent"):
|
|
225
|
+
return types.NotificationSoundNone()
|
|
226
|
+
if value in ("default", ""):
|
|
227
|
+
return types.NotificationSoundDefault()
|
|
228
|
+
if value.startswith("local:"):
|
|
229
|
+
title = value.split(":", 1)[1]
|
|
230
|
+
return types.NotificationSoundLocal(title=title, data=title)
|
|
231
|
+
if value.startswith("ringtone:"):
|
|
232
|
+
value = value.split(":", 1)[1]
|
|
233
|
+
try:
|
|
234
|
+
return types.NotificationSoundRingtone(id=int(value))
|
|
235
|
+
except ValueError as exc:
|
|
236
|
+
raise UsageError(
|
|
237
|
+
"--sound takes default, none, local:<title> or ringtone:<id>", field="sound"
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def sound_text(value: Any) -> str | None:
|
|
242
|
+
"""The inverse of `sound_value`, so a read can be piped into a write."""
|
|
243
|
+
from tlgr.ops._serialize import _sound
|
|
244
|
+
|
|
245
|
+
return _sound(value)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# Gift references
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def input_gift(ctx: OpContext, ref: str, *, field: str = "ref") -> Any:
|
|
254
|
+
"""One `inputSavedStarGift*` from tlgr's single reference spelling.
|
|
255
|
+
|
|
256
|
+
`msg:<id>` is a gift received in a private chat, `<peer>:<saved_id>` one
|
|
257
|
+
held by a channel, and anything else is a collectible slug (a `t.me/nft/`
|
|
258
|
+
link is accepted and reduced to its slug). Three server constructors, one
|
|
259
|
+
string a caller can copy out of a listing.
|
|
260
|
+
"""
|
|
261
|
+
from telethon.tl import types
|
|
262
|
+
|
|
263
|
+
text = str(ref).strip()
|
|
264
|
+
if not text:
|
|
265
|
+
raise UsageError("give a gift reference", field=field)
|
|
266
|
+
head, sep, tail = text.partition(":")
|
|
267
|
+
if sep and head.lower() in ("msg", "message"):
|
|
268
|
+
if not tail.lstrip("-").isdigit():
|
|
269
|
+
raise UsageError(f"{text!r}: msg:<id> wants a message id", field=field)
|
|
270
|
+
return types.InputSavedStarGiftUser(msg_id=int(tail))
|
|
271
|
+
if sep and tail.lstrip("-").isdigit() and not text.startswith("http"):
|
|
272
|
+
return types.InputSavedStarGiftChat(peer=await resolve(ctx, head), saved_id=int(tail))
|
|
273
|
+
return types.InputSavedStarGiftSlug(slug=slug_of(text))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def slug_of(text: str) -> str:
|
|
277
|
+
"""`t.me/nft/PlushPepe-42` → `PlushPepe-42`; a bare slug passes through."""
|
|
278
|
+
value = str(text).strip()
|
|
279
|
+
for marker in ("/nft/", "t.me/", "tg://nft?slug="):
|
|
280
|
+
if marker in value:
|
|
281
|
+
value = value.split(marker, 1)[1]
|
|
282
|
+
return value.split("?", 1)[0].split("#", 1)[0].strip("/")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def gift_ref_text(raw: Any) -> str:
|
|
286
|
+
"""The reference string for a `savedStarGift` the server just handed us."""
|
|
287
|
+
msg_id = getattr(raw, "msg_id", None)
|
|
288
|
+
if msg_id:
|
|
289
|
+
return f"msg:{msg_id}"
|
|
290
|
+
saved_id = getattr(raw, "saved_id", None)
|
|
291
|
+
if saved_id:
|
|
292
|
+
return f"saved:{saved_id}"
|
|
293
|
+
gift = getattr(raw, "gift", None)
|
|
294
|
+
return str(getattr(gift, "slug", "") or "")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# App config
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def app_config(ctx: OpContext) -> dict[str, Any]:
|
|
303
|
+
"""`help.getAppConfig` as plain Python. Never hardcode a server limit."""
|
|
304
|
+
from tlgr.ops import _media
|
|
305
|
+
|
|
306
|
+
return await _media.app_config(ctx)
|
tlgr/ops/_spec.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""`OperationSpec` — the one artefact per operation.
|
|
2
|
+
|
|
3
|
+
Adding an operation means adding one of these. The Click command, the daemon
|
|
4
|
+
dispatch entry, the JSON Schema, the reference docs and the contract tests are
|
|
5
|
+
all derived from it, so there is no second place for the description of an
|
|
6
|
+
operation to drift away from the first.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Protocol, TypeAlias, runtime_checkable
|
|
15
|
+
|
|
16
|
+
import msgspec
|
|
17
|
+
|
|
18
|
+
from tlgr.core.errors import EXIT_SUCCESS
|
|
19
|
+
from tlgr.core.pagination import PageKind
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Impl",
|
|
23
|
+
"OpContext",
|
|
24
|
+
"OperationSpec",
|
|
25
|
+
"PageKind",
|
|
26
|
+
"Surface",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
RATE_CLASSES = frozenset({"read", "send", "resolve", "bulk", "file", "local"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Surface(str, Enum):
|
|
33
|
+
"""Where an operation runs."""
|
|
34
|
+
|
|
35
|
+
DAEMON = "daemon"
|
|
36
|
+
LOCAL = "local"
|
|
37
|
+
EITHER = "either"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class OpContext(Protocol):
|
|
42
|
+
"""What an implementation is handed besides its request.
|
|
43
|
+
|
|
44
|
+
Deliberately a Protocol: `ops/` must not import `daemon/`, and a test must
|
|
45
|
+
be able to build one without a socket or a Telethon client. The services
|
|
46
|
+
an implementation legitimately needs — the client, the per-account peer
|
|
47
|
+
resolver, the rate limiter, the file pipeline, the event bus — are
|
|
48
|
+
*injected* here rather than imported, which is what keeps the layering
|
|
49
|
+
rule from being a fiction.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
account: str
|
|
53
|
+
dry_run: bool
|
|
54
|
+
request_id: str
|
|
55
|
+
|
|
56
|
+
def warn(self, message: str) -> None:
|
|
57
|
+
"""Add a non-fatal advisory to `meta.warnings`."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
def emit(self, event_type: str, payload: dict[str, Any], **kwargs: Any) -> None:
|
|
61
|
+
"""Echo an action tlgr itself performed onto the event bus (§6.5).
|
|
62
|
+
|
|
63
|
+
Telethon dispatches no `NewMessage` for our own sends, so without this
|
|
64
|
+
a `tlgr watch` never shows what tlgr just did. A context with no bus
|
|
65
|
+
drops it, which is why the signature returns nothing.
|
|
66
|
+
"""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
def mark_already(self) -> None:
|
|
70
|
+
"""Record that the world already looked the way the caller asked for.
|
|
71
|
+
|
|
72
|
+
Part of the contract, not a convenience: COMMANDS.md promises that an
|
|
73
|
+
idempotent no-op reports `already: true` rather than pretending to
|
|
74
|
+
have done something, and an implementation cannot honour that without
|
|
75
|
+
a way to say so.
|
|
76
|
+
"""
|
|
77
|
+
...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
#: `Awaitable[Any] | AsyncIterator[Any]`: a streaming operation is an async
|
|
81
|
+
#: *generator*, which is not awaitable. Registry lint L6 is what keeps the two
|
|
82
|
+
#: kinds honest — `stream=True` must be an async generator and nothing else.
|
|
83
|
+
Impl: TypeAlias = Callable[[Any, Any], Any]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class OperationSpec:
|
|
88
|
+
# ---- identity ----
|
|
89
|
+
id: str
|
|
90
|
+
request: type[msgspec.Struct]
|
|
91
|
+
response: Any
|
|
92
|
+
impl: Impl
|
|
93
|
+
summary: str
|
|
94
|
+
description: str = ""
|
|
95
|
+
#: Canonicalised to `id` before any policy check — in the CLI *and* the
|
|
96
|
+
#: daemon. SEC-04 was exactly this gap: `--enable-commands message`
|
|
97
|
+
#: allowed `message send` but not the `send` alias for the same op.
|
|
98
|
+
aliases: tuple[str, ...] = ()
|
|
99
|
+
# ---- behaviour flags ----
|
|
100
|
+
mutating: bool = False
|
|
101
|
+
destructive: bool = False
|
|
102
|
+
paginated: PageKind | None = None
|
|
103
|
+
stream: bool = False
|
|
104
|
+
needs_account: bool = True
|
|
105
|
+
#: False for a daemon operation that needs no *Telegram* client: reading
|
|
106
|
+
#: the event bus, the flood store, the dead-letter file, the job table.
|
|
107
|
+
#: Without it every one of those would connect an account to answer a
|
|
108
|
+
#: question about the daemon, and `--account all` could not be expressed
|
|
109
|
+
#: at all (there is no single session to acquire).
|
|
110
|
+
needs_client: bool = True
|
|
111
|
+
needs_auth: bool = True
|
|
112
|
+
surface: Surface = Surface.DAEMON
|
|
113
|
+
idempotent: bool = False
|
|
114
|
+
# ---- policy / limits ----
|
|
115
|
+
timeout_s: int = 120
|
|
116
|
+
rate_class: str = "read"
|
|
117
|
+
min_interval_s: float = 0.0
|
|
118
|
+
# ---- presentation ----
|
|
119
|
+
columns: tuple[str, ...] = ()
|
|
120
|
+
headers: tuple[str, ...] = ()
|
|
121
|
+
#: 0 or EXIT_EMPTY. Settles COR-36 centrally: lists exit 0 with an empty
|
|
122
|
+
#: result; only point lookups and harvests that found nothing opt in to 3.
|
|
123
|
+
empty_exit: int = EXIT_SUCCESS
|
|
124
|
+
example: Any = None
|
|
125
|
+
example_args: str = ""
|
|
126
|
+
# ---- parity ----
|
|
127
|
+
covers: tuple[str, ...] = ()
|
|
128
|
+
covers_partial: tuple[str, ...] = ()
|
|
129
|
+
coverage_note: str = ""
|
|
130
|
+
# ---- migration ----
|
|
131
|
+
#: v1 CLI paths this op replaces. They stay invocable, so no documented
|
|
132
|
+
#: command path ever disappears (§12.4).
|
|
133
|
+
legacy_paths: tuple[str, ...] = ()
|
|
134
|
+
since: str = "2.0"
|
|
135
|
+
deprecated: str = ""
|
|
136
|
+
tags: frozenset[str] = field(default_factory=frozenset)
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def group(self) -> str:
|
|
140
|
+
"""The first path segment: `chat` for `chat.member.ban`."""
|
|
141
|
+
return self.id.split(".")[0]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def path(self) -> tuple[str, ...]:
|
|
145
|
+
"""The command path: `("chat", "member", "ban")`."""
|
|
146
|
+
return tuple(self.id.split("."))
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def verb(self) -> str:
|
|
150
|
+
return self.id.split(".")[-1]
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def cli_path(self) -> str:
|
|
154
|
+
"""How a human types it: `chat member ban`."""
|
|
155
|
+
return " ".join(self.path)
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def names(self) -> tuple[str, ...]:
|
|
159
|
+
"""Every name this op answers to, canonical id first, deduplicated.
|
|
160
|
+
|
|
161
|
+
A legacy path usually *is* the canonical path (`agent exit-codes`),
|
|
162
|
+
and an alias may repeat one; both are normal, not a conflict.
|
|
163
|
+
"""
|
|
164
|
+
seen: dict[str, None] = {}
|
|
165
|
+
for name in (self.id, *self.aliases, *(p.replace(" ", ".") for p in self.legacy_paths)):
|
|
166
|
+
seen.setdefault(name, None)
|
|
167
|
+
return tuple(seen)
|