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/core/output.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Output formatters for JSON, plain (TSV), and human-readable modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _tsv_escape(value: Any) -> str:
|
|
13
|
+
s = str(value) if value is not None else ""
|
|
14
|
+
return s.replace("\t", " ").replace("\n", " ")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# JSON transforms (--results-only, --select)
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
_ENVELOPE_KEYS = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"next_page_token",
|
|
24
|
+
"nextPageToken",
|
|
25
|
+
"next_cursor",
|
|
26
|
+
"has_more",
|
|
27
|
+
"count",
|
|
28
|
+
"total",
|
|
29
|
+
"query",
|
|
30
|
+
"dry_run",
|
|
31
|
+
"dryRun",
|
|
32
|
+
"op",
|
|
33
|
+
"action",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _unwrap_primary(data: Any) -> Any:
|
|
39
|
+
"""Strip envelope metadata and return only the primary result."""
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
return data
|
|
42
|
+
if "results" in data:
|
|
43
|
+
return data["results"]
|
|
44
|
+
|
|
45
|
+
candidates = [k for k in data if k not in _ENVELOPE_KEYS]
|
|
46
|
+
if len(candidates) == 1:
|
|
47
|
+
return data[candidates[0]]
|
|
48
|
+
|
|
49
|
+
for k in candidates:
|
|
50
|
+
if isinstance(data[k], list):
|
|
51
|
+
return data[k]
|
|
52
|
+
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get_at_path(obj: Any, path: str) -> tuple[Any, bool]:
|
|
57
|
+
"""Traverse dot-delimited path into a nested dict/list."""
|
|
58
|
+
segments = [s.strip() for s in path.split(".") if s.strip()]
|
|
59
|
+
cur = obj
|
|
60
|
+
for seg in segments:
|
|
61
|
+
if isinstance(cur, dict):
|
|
62
|
+
if seg not in cur:
|
|
63
|
+
return None, False
|
|
64
|
+
cur = cur[seg]
|
|
65
|
+
elif isinstance(cur, list):
|
|
66
|
+
try:
|
|
67
|
+
cur = cur[int(seg)]
|
|
68
|
+
except (ValueError, IndexError):
|
|
69
|
+
return None, False
|
|
70
|
+
else:
|
|
71
|
+
return None, False
|
|
72
|
+
return cur, True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _select_fields(data: Any, fields: list[str]) -> Any:
|
|
76
|
+
"""Project only selected fields from data."""
|
|
77
|
+
if isinstance(data, list):
|
|
78
|
+
return [_select_from_item(item, fields) for item in data]
|
|
79
|
+
return _select_from_item(data, fields)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _select_from_item(item: Any, fields: list[str]) -> Any:
|
|
83
|
+
if not isinstance(item, dict):
|
|
84
|
+
return item
|
|
85
|
+
out: dict[str, Any] = {}
|
|
86
|
+
for f in fields:
|
|
87
|
+
val, found = _get_at_path(item, f)
|
|
88
|
+
if found:
|
|
89
|
+
out[f] = val
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def apply_json_transforms(
|
|
94
|
+
data: Any,
|
|
95
|
+
*,
|
|
96
|
+
results_only: bool = False,
|
|
97
|
+
select: str | None = None,
|
|
98
|
+
) -> Any:
|
|
99
|
+
"""Apply --results-only and --select transforms to JSON data."""
|
|
100
|
+
if results_only:
|
|
101
|
+
data = _unwrap_primary(data)
|
|
102
|
+
if select:
|
|
103
|
+
fields = [f.strip() for f in select.split(",") if f.strip()]
|
|
104
|
+
if fields:
|
|
105
|
+
data = _select_fields(data, fields)
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Core output functions
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def output_json(
|
|
115
|
+
data: Any,
|
|
116
|
+
*,
|
|
117
|
+
flood_wait: int | None = None,
|
|
118
|
+
results_only: bool = False,
|
|
119
|
+
select: str | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Write JSON to stdout."""
|
|
122
|
+
if flood_wait:
|
|
123
|
+
if isinstance(data, dict):
|
|
124
|
+
data["flood_wait"] = flood_wait
|
|
125
|
+
else:
|
|
126
|
+
data = {"result": data, "flood_wait": flood_wait}
|
|
127
|
+
|
|
128
|
+
data = apply_json_transforms(data, results_only=results_only, select=select)
|
|
129
|
+
json.dump(data, sys.stdout, default=str, ensure_ascii=False)
|
|
130
|
+
sys.stdout.write("\n")
|
|
131
|
+
sys.stdout.flush()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def output_plain(rows: Sequence[dict[str, Any]], columns: Sequence[str]) -> None:
|
|
135
|
+
"""Write TSV to stdout (no colors, stable for piping)."""
|
|
136
|
+
print("\t".join(columns))
|
|
137
|
+
for row in rows:
|
|
138
|
+
print("\t".join(_tsv_escape(row.get(c)) for c in columns))
|
|
139
|
+
sys.stdout.flush()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def output_human(
|
|
143
|
+
rows: Sequence[dict[str, Any]],
|
|
144
|
+
columns: Sequence[str],
|
|
145
|
+
*,
|
|
146
|
+
headers: Sequence[str] | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Write space-padded columns to stdout (kubectl / docker style)."""
|
|
149
|
+
display_headers = headers or columns
|
|
150
|
+
cells = [[str(row.get(c, "")) for c in columns] for row in rows]
|
|
151
|
+
|
|
152
|
+
widths = [len(h) for h in display_headers]
|
|
153
|
+
for cell_row in cells:
|
|
154
|
+
for i, v in enumerate(cell_row):
|
|
155
|
+
widths[i] = max(widths[i], len(v))
|
|
156
|
+
|
|
157
|
+
gap = " "
|
|
158
|
+
header_line = gap.join(
|
|
159
|
+
h.upper().ljust(w) for h, w in zip(display_headers, widths, strict=False)
|
|
160
|
+
)
|
|
161
|
+
print(header_line.rstrip())
|
|
162
|
+
for cell_row in cells:
|
|
163
|
+
line = gap.join(v.ljust(w) for v, w in zip(cell_row, widths, strict=False))
|
|
164
|
+
print(line.rstrip())
|
|
165
|
+
sys.stdout.flush()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def output_result(
|
|
169
|
+
data: Any,
|
|
170
|
+
*,
|
|
171
|
+
fmt: str = "human",
|
|
172
|
+
columns: Sequence[str] | None = None,
|
|
173
|
+
headers: Sequence[str] | None = None,
|
|
174
|
+
flood_wait: int | None = None,
|
|
175
|
+
results_only: bool = False,
|
|
176
|
+
select: str | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Dispatch to the correct output formatter.
|
|
179
|
+
|
|
180
|
+
*fmt* is one of ``"json"``, ``"plain"``, or ``"human"``.
|
|
181
|
+
For ``"json"`` *data* is emitted as-is.
|
|
182
|
+
For ``"plain"`` and ``"human"`` *data* must be a list of dicts
|
|
183
|
+
and *columns* selects which keys to display.
|
|
184
|
+
"""
|
|
185
|
+
if fmt == "json":
|
|
186
|
+
output_json(data, flood_wait=flood_wait, results_only=results_only, select=select)
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
if not isinstance(data, list):
|
|
190
|
+
data = [data] if isinstance(data, dict) else [{"result": data}]
|
|
191
|
+
|
|
192
|
+
cols = columns or (list(data[0].keys()) if data else ["result"])
|
|
193
|
+
|
|
194
|
+
if fmt == "plain":
|
|
195
|
+
output_plain(data, cols)
|
|
196
|
+
else:
|
|
197
|
+
output_human(data, cols, headers=headers)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def emit(ctx_obj: dict[str, Any], data: Any, **kwargs: Any) -> None:
|
|
201
|
+
"""Convenience wrapper: ``output_result`` with global transforms from *ctx.obj*."""
|
|
202
|
+
kwargs.setdefault("fmt", ctx_obj.get("fmt", "human"))
|
|
203
|
+
kwargs.setdefault("results_only", ctx_obj.get("results_only", False))
|
|
204
|
+
kwargs.setdefault("select", ctx_obj.get("select"))
|
|
205
|
+
output_result(data, **kwargs)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
# Cursor-based pagination helpers
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def encode_cursor(state: dict[str, Any]) -> str:
|
|
214
|
+
"""Encode pagination state as an opaque base64 cursor token."""
|
|
215
|
+
raw = json.dumps(state, separators=(",", ":"), sort_keys=True)
|
|
216
|
+
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def decode_cursor(token: str | None) -> dict[str, Any]:
|
|
220
|
+
"""Decode a cursor token back to pagination state. Returns {} on invalid input."""
|
|
221
|
+
if not token:
|
|
222
|
+
return {}
|
|
223
|
+
try:
|
|
224
|
+
padded = token + "=" * (-len(token) % 4)
|
|
225
|
+
raw = base64.urlsafe_b64decode(padded).decode()
|
|
226
|
+
return json.loads(raw)
|
|
227
|
+
except Exception:
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def add_pagination(
|
|
232
|
+
envelope: dict[str, Any],
|
|
233
|
+
items: list[Any],
|
|
234
|
+
limit: int,
|
|
235
|
+
cursor_state: dict[str, Any],
|
|
236
|
+
has_more: bool | None = None,
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
"""Add ``has_more`` and ``next_cursor`` to a JSON envelope.
|
|
239
|
+
|
|
240
|
+
``len(items) >= limit`` is a heuristic for server-paginated endpoints,
|
|
241
|
+
which cannot know whether more rows exist without asking again. Callers
|
|
242
|
+
that hold the full result set and slice it themselves know the exact
|
|
243
|
+
answer — they pass it as ``has_more`` instead of guessing, so the cursor
|
|
244
|
+
stops being emitted once the list is exhausted.
|
|
245
|
+
"""
|
|
246
|
+
if has_more is None:
|
|
247
|
+
has_more = len(items) >= limit
|
|
248
|
+
envelope["has_more"] = has_more
|
|
249
|
+
if has_more:
|
|
250
|
+
envelope["next_cursor"] = encode_cursor(cursor_state)
|
|
251
|
+
return envelope
|
tlgr/core/pagination.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Opaque, versioned, op-bound cursors.
|
|
2
|
+
|
|
3
|
+
A cursor is state the caller is not supposed to read, edit or reuse elsewhere.
|
|
4
|
+
v1's cursor was plain base64 JSON that decoded to `{}` on any problem — a
|
|
5
|
+
truncated token silently restarted the walk from message 0, which looks like
|
|
6
|
+
"the chat only has these 40 messages" rather than like an error.
|
|
7
|
+
|
|
8
|
+
Here a cursor carries its version, the op it belongs to, an account
|
|
9
|
+
fingerprint and an expiry, and is signed. The HMAC is integrity, not secrecy:
|
|
10
|
+
it exists so that a hand-edited cursor produces `USAGE: invalid cursor`
|
|
11
|
+
instead of a plausible wrong answer.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import binascii
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import time
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, TypeVar
|
|
26
|
+
|
|
27
|
+
import msgspec
|
|
28
|
+
|
|
29
|
+
from tlgr.core.errors import UsageError
|
|
30
|
+
from tlgr.models.page import Page
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CURSOR_VERSION",
|
|
34
|
+
"PageKind",
|
|
35
|
+
"build_page",
|
|
36
|
+
"cursor_key",
|
|
37
|
+
"decode_cursor",
|
|
38
|
+
"encode_cursor",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
CURSOR_VERSION = 1
|
|
42
|
+
|
|
43
|
+
#: Default lifetimes. A LOCAL cursor indexes into a materialised snapshot that
|
|
44
|
+
#: goes stale fast; a server-side offset stays meaningful much longer.
|
|
45
|
+
_TTL_LOCAL = 3600
|
|
46
|
+
_TTL_DEFAULT = 86400
|
|
47
|
+
|
|
48
|
+
T = TypeVar("T")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PageKind(str, Enum):
|
|
52
|
+
"""Which offset state a paginated op carries (ARCHITECTURE §3.5)."""
|
|
53
|
+
|
|
54
|
+
HISTORY = "HISTORY"
|
|
55
|
+
SEARCH = "SEARCH"
|
|
56
|
+
RATE = "RATE"
|
|
57
|
+
DIALOGS = "DIALOGS"
|
|
58
|
+
PARTICIPANTS = "PARTICIPANTS"
|
|
59
|
+
LOCAL = "LOCAL"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
#: PageKinds whose offsets are dates as well as ids, so `--since/--until` mean
|
|
63
|
+
#: something and the generator injects them.
|
|
64
|
+
DATE_OFFSET_KINDS = frozenset({PageKind.HISTORY, PageKind.SEARCH, PageKind.DIALOGS})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _config_dir() -> Path:
|
|
68
|
+
"""Where the cursor key lives. `TLGR_HOME` exists so tests never touch ~."""
|
|
69
|
+
override = os.environ.get("TLGR_HOME", "").strip()
|
|
70
|
+
if override:
|
|
71
|
+
return Path(override)
|
|
72
|
+
from tlgr.core.config import CONFIG_DIR
|
|
73
|
+
|
|
74
|
+
return Path(CONFIG_DIR)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def cursor_key(base: Path | None = None) -> bytes:
|
|
78
|
+
"""The signing key, generated once at 0600.
|
|
79
|
+
|
|
80
|
+
Rotating it invalidates outstanding cursors, which is the correct
|
|
81
|
+
behaviour: they describe a walk of data this installation no longer
|
|
82
|
+
guarantees anything about.
|
|
83
|
+
"""
|
|
84
|
+
directory = base or _config_dir()
|
|
85
|
+
path = directory / "cursor.key"
|
|
86
|
+
try:
|
|
87
|
+
return path.read_bytes()
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
pass
|
|
90
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
key = secrets.token_bytes(32)
|
|
92
|
+
# Write through a private temp file so the key is never briefly world-readable.
|
|
93
|
+
tmp = path.with_suffix(".key.tmp")
|
|
94
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
95
|
+
try:
|
|
96
|
+
os.write(fd, key)
|
|
97
|
+
finally:
|
|
98
|
+
os.close(fd)
|
|
99
|
+
os.replace(tmp, path)
|
|
100
|
+
return key
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def account_fingerprint(account: str) -> str:
|
|
104
|
+
"""First 8 hex of sha256(alias) — enough to catch a cursor crossing accounts.
|
|
105
|
+
|
|
106
|
+
The alias itself is not embedded because a cursor is pasted into shell
|
|
107
|
+
history, logs and bug reports.
|
|
108
|
+
"""
|
|
109
|
+
return hashlib.sha256(account.encode()).hexdigest()[:8]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _b64(data: bytes) -> str:
|
|
113
|
+
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _unb64(text: str) -> bytes:
|
|
117
|
+
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _sign(payload: bytes, key: bytes) -> str:
|
|
121
|
+
return _b64(hmac.new(key, payload, hashlib.sha256).digest()[:16])
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def encode_cursor(
|
|
125
|
+
*,
|
|
126
|
+
op: str,
|
|
127
|
+
kind: PageKind | str,
|
|
128
|
+
state: dict[str, Any],
|
|
129
|
+
account: str = "",
|
|
130
|
+
ttl: int | None = None,
|
|
131
|
+
key: bytes | None = None,
|
|
132
|
+
now: int | None = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
"""Sign pagination *state* into a token bound to this op and account."""
|
|
135
|
+
kind_name = kind.value if isinstance(kind, PageKind) else str(kind)
|
|
136
|
+
if ttl is None:
|
|
137
|
+
ttl = _TTL_LOCAL if kind_name == PageKind.LOCAL.value else _TTL_DEFAULT
|
|
138
|
+
payload = msgspec.json.encode(
|
|
139
|
+
{
|
|
140
|
+
"v": CURSOR_VERSION,
|
|
141
|
+
"op": op,
|
|
142
|
+
"kind": kind_name,
|
|
143
|
+
"acct": account_fingerprint(account),
|
|
144
|
+
"st": state,
|
|
145
|
+
"exp": int(now or time.time()) + ttl,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
return f"{_b64(payload)}.{_sign(payload, key or cursor_key())}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def decode_cursor(
|
|
152
|
+
token: str,
|
|
153
|
+
*,
|
|
154
|
+
op: str,
|
|
155
|
+
kind: PageKind | str | None = None,
|
|
156
|
+
account: str = "",
|
|
157
|
+
key: bytes | None = None,
|
|
158
|
+
now: int | None = None,
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Validate a token and return its state, or raise USAGE.
|
|
161
|
+
|
|
162
|
+
Every rejection is a USAGE error rather than a silent restart: a cursor
|
|
163
|
+
that cannot be trusted must stop the walk, not quietly begin a new one.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def reject(why: str) -> UsageError:
|
|
167
|
+
return UsageError(f"invalid cursor: {why}", field="cursor")
|
|
168
|
+
|
|
169
|
+
head, dot, signature = token.partition(".")
|
|
170
|
+
if not dot:
|
|
171
|
+
raise reject("not a tlgr cursor (missing signature)")
|
|
172
|
+
try:
|
|
173
|
+
payload = _unb64(head)
|
|
174
|
+
except (binascii.Error, ValueError) as exc:
|
|
175
|
+
raise reject("corrupt encoding") from exc
|
|
176
|
+
if not hmac.compare_digest(signature, _sign(payload, key or cursor_key())):
|
|
177
|
+
raise reject("signature does not match (truncated or hand-edited?)")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
data = msgspec.json.decode(payload, type=dict)
|
|
181
|
+
except msgspec.DecodeError as exc:
|
|
182
|
+
raise reject("corrupt payload") from exc
|
|
183
|
+
|
|
184
|
+
if data.get("v") != CURSOR_VERSION:
|
|
185
|
+
raise reject(f"version {data.get('v')!r} is not supported by this tlgr")
|
|
186
|
+
if data.get("op") != op:
|
|
187
|
+
raise reject(f"it belongs to {data.get('op')!r}, not {op!r}")
|
|
188
|
+
if kind is not None:
|
|
189
|
+
want = kind.value if isinstance(kind, PageKind) else str(kind)
|
|
190
|
+
if data.get("kind") != want:
|
|
191
|
+
raise reject(f"it paginates {data.get('kind')!r}, not {want!r}")
|
|
192
|
+
if data.get("acct") != account_fingerprint(account):
|
|
193
|
+
raise reject("it was issued for a different account")
|
|
194
|
+
expiry = data.get("exp")
|
|
195
|
+
if not isinstance(expiry, int) or expiry < int(now or time.time()):
|
|
196
|
+
raise reject("it has expired; start the listing again")
|
|
197
|
+
|
|
198
|
+
state = data.get("st")
|
|
199
|
+
if not isinstance(state, dict):
|
|
200
|
+
raise reject("corrupt state")
|
|
201
|
+
return state
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_page(
|
|
205
|
+
items: list[T],
|
|
206
|
+
*,
|
|
207
|
+
op: str,
|
|
208
|
+
kind: PageKind | str,
|
|
209
|
+
state: dict[str, Any] | None = None,
|
|
210
|
+
account: str = "",
|
|
211
|
+
has_more: bool | None = None,
|
|
212
|
+
limit: int | None = None,
|
|
213
|
+
total: int | None = None,
|
|
214
|
+
key: bytes | None = None,
|
|
215
|
+
) -> Page[T]:
|
|
216
|
+
"""Assemble a `Page[T]`, emitting a cursor only when there is more to fetch.
|
|
217
|
+
|
|
218
|
+
`len(items) >= limit` is the fallback guess for server-paginated endpoints,
|
|
219
|
+
which cannot know whether more rows exist without asking again. A caller
|
|
220
|
+
holding the full set knows the exact answer and passes `has_more`.
|
|
221
|
+
"""
|
|
222
|
+
if has_more is None:
|
|
223
|
+
has_more = limit is not None and len(items) >= limit
|
|
224
|
+
page: Page[T] = Page(items=items, has_more=has_more, total=total)
|
|
225
|
+
if has_more and state is not None:
|
|
226
|
+
page.next_cursor = encode_cursor(op=op, kind=kind, state=state, account=account, key=key)
|
|
227
|
+
return page
|