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.
Files changed (192) hide show
  1. tlgr/__init__.py +3 -0
  2. tlgr/__main__.py +6 -0
  3. tlgr/actions/__init__.py +45 -0
  4. tlgr/actions/forward.py +74 -0
  5. tlgr/actions/reply.py +32 -0
  6. tlgr/cli/__init__.py +259 -0
  7. tlgr/cli/confirm.py +55 -0
  8. tlgr/cli/errors.py +84 -0
  9. tlgr/cli/gen.py +690 -0
  10. tlgr/cli/globals.py +273 -0
  11. tlgr/cli/introspect.py +170 -0
  12. tlgr/cli/params.py +189 -0
  13. tlgr/cli/render.py +418 -0
  14. tlgr/core/__init__.py +0 -0
  15. tlgr/core/accounts.py +384 -0
  16. tlgr/core/config.py +358 -0
  17. tlgr/core/custom_tl.py +170 -0
  18. tlgr/core/errors.py +687 -0
  19. tlgr/core/eventtypes.py +1170 -0
  20. tlgr/core/identity.py +127 -0
  21. tlgr/core/launchd.py +122 -0
  22. tlgr/core/logging.py +194 -0
  23. tlgr/core/media.py +134 -0
  24. tlgr/core/output.py +251 -0
  25. tlgr/core/pagination.py +227 -0
  26. tlgr/core/paths.py +360 -0
  27. tlgr/core/peers.py +427 -0
  28. tlgr/core/process.py +138 -0
  29. tlgr/core/signing.py +38 -0
  30. tlgr/core/systemd.py +96 -0
  31. tlgr/core/telethon_compat.py +295 -0
  32. tlgr/core/text.py +211 -0
  33. tlgr/core/timefmt.py +199 -0
  34. tlgr/core/tl.py +98 -0
  35. tlgr/daemon/__init__.py +0 -0
  36. tlgr/daemon/app.py +869 -0
  37. tlgr/daemon/dispatch.py +446 -0
  38. tlgr/daemon/events.py +723 -0
  39. tlgr/daemon/files.py +431 -0
  40. tlgr/daemon/idle.py +119 -0
  41. tlgr/daemon/jobs.py +68 -0
  42. tlgr/daemon/main.py +161 -0
  43. tlgr/daemon/peercred.py +75 -0
  44. tlgr/daemon/policy.py +113 -0
  45. tlgr/daemon/preauth.py +366 -0
  46. tlgr/daemon/ratelimit.py +391 -0
  47. tlgr/daemon/server.py +24 -0
  48. tlgr/daemon/session.py +648 -0
  49. tlgr/daemon/sessions.py +274 -0
  50. tlgr/daemon/singleton.py +114 -0
  51. tlgr/daemon/stream.py +193 -0
  52. tlgr/daemon/transfers.py +219 -0
  53. tlgr/daemon/webhook.py +390 -0
  54. tlgr/data/catalog_index.json +1 -0
  55. tlgr/data/parity_waivers.toml +90 -0
  56. tlgr/filters/__init__.py +42 -0
  57. tlgr/filters/compose.py +121 -0
  58. tlgr/filters/content.py +85 -0
  59. tlgr/filters/context.py +114 -0
  60. tlgr/filters/message.py +161 -0
  61. tlgr/filters/temporal.py +87 -0
  62. tlgr/filters/user.py +36 -0
  63. tlgr/gateway/__init__.py +1 -0
  64. tlgr/gateway/config.py +161 -0
  65. tlgr/gateway/engine.py +215 -0
  66. tlgr/gateway/event.py +22 -0
  67. tlgr/jobs/__init__.py +0 -0
  68. tlgr/jobs/base.py +81 -0
  69. tlgr/jobs/client.py +37 -0
  70. tlgr/models/__init__.py +1220 -0
  71. tlgr/models/admin.py +744 -0
  72. tlgr/models/auth.py +510 -0
  73. tlgr/models/base.py +81 -0
  74. tlgr/models/bot.py +576 -0
  75. tlgr/models/business.py +265 -0
  76. tlgr/models/call.py +586 -0
  77. tlgr/models/config.py +101 -0
  78. tlgr/models/contact.py +481 -0
  79. tlgr/models/daemon.py +336 -0
  80. tlgr/models/dialog.py +626 -0
  81. tlgr/models/envelope.py +68 -0
  82. tlgr/models/error.py +30 -0
  83. tlgr/models/event.py +79 -0
  84. tlgr/models/export.py +66 -0
  85. tlgr/models/gift.py +275 -0
  86. tlgr/models/inline.py +84 -0
  87. tlgr/models/location.py +115 -0
  88. tlgr/models/media.py +507 -0
  89. tlgr/models/message.py +584 -0
  90. tlgr/models/net.py +232 -0
  91. tlgr/models/notify.py +105 -0
  92. tlgr/models/page.py +32 -0
  93. tlgr/models/payment.py +172 -0
  94. tlgr/models/peer.py +400 -0
  95. tlgr/models/poll.py +119 -0
  96. tlgr/models/premium.py +161 -0
  97. tlgr/models/privacy.py +93 -0
  98. tlgr/models/profile.py +217 -0
  99. tlgr/models/reaction.py +160 -0
  100. tlgr/models/resolve.py +175 -0
  101. tlgr/models/settings.py +103 -0
  102. tlgr/models/stars.py +101 -0
  103. tlgr/models/sticker.py +243 -0
  104. tlgr/models/story.py +467 -0
  105. tlgr/models/sync.py +105 -0
  106. tlgr/models/todo.py +36 -0
  107. tlgr/models/webapp.py +89 -0
  108. tlgr/ops/__init__.py +63 -0
  109. tlgr/ops/_admin.py +313 -0
  110. tlgr/ops/_auth.py +599 -0
  111. tlgr/ops/_bots.py +586 -0
  112. tlgr/ops/_calls.py +535 -0
  113. tlgr/ops/_common.py +160 -0
  114. tlgr/ops/_layer.py +46 -0
  115. tlgr/ops/_media.py +592 -0
  116. tlgr/ops/_params.py +212 -0
  117. tlgr/ops/_rights.py +402 -0
  118. tlgr/ops/_send.py +593 -0
  119. tlgr/ops/_serialize.py +667 -0
  120. tlgr/ops/_settings.py +306 -0
  121. tlgr/ops/_spec.py +167 -0
  122. tlgr/ops/_story.py +743 -0
  123. tlgr/ops/account.py +2604 -0
  124. tlgr/ops/agent.py +937 -0
  125. tlgr/ops/auth.py +1282 -0
  126. tlgr/ops/bot.py +4880 -0
  127. tlgr/ops/business.py +1520 -0
  128. tlgr/ops/call.py +1610 -0
  129. tlgr/ops/chat.py +4025 -0
  130. tlgr/ops/chat_admin.py +929 -0
  131. tlgr/ops/chat_extra.py +1061 -0
  132. tlgr/ops/chat_invite.py +716 -0
  133. tlgr/ops/chat_manage.py +1691 -0
  134. tlgr/ops/chat_member.py +1357 -0
  135. tlgr/ops/chat_stats.py +902 -0
  136. tlgr/ops/chat_topic.py +905 -0
  137. tlgr/ops/conference.py +791 -0
  138. tlgr/ops/config.py +1698 -0
  139. tlgr/ops/contact.py +2330 -0
  140. tlgr/ops/daemon.py +1397 -0
  141. tlgr/ops/draft.py +299 -0
  142. tlgr/ops/emoji.py +343 -0
  143. tlgr/ops/events.py +1327 -0
  144. tlgr/ops/export.py +596 -0
  145. tlgr/ops/folder.py +1322 -0
  146. tlgr/ops/gif.py +522 -0
  147. tlgr/ops/gift.py +1546 -0
  148. tlgr/ops/giveaway.py +541 -0
  149. tlgr/ops/inline.py +773 -0
  150. tlgr/ops/job.py +799 -0
  151. tlgr/ops/location.py +917 -0
  152. tlgr/ops/media.py +4495 -0
  153. tlgr/ops/message.py +3769 -0
  154. tlgr/ops/net.py +536 -0
  155. tlgr/ops/notify.py +840 -0
  156. tlgr/ops/passport.py +464 -0
  157. tlgr/ops/payment.py +907 -0
  158. tlgr/ops/poll.py +1078 -0
  159. tlgr/ops/premium.py +488 -0
  160. tlgr/ops/privacy.py +794 -0
  161. tlgr/ops/profile.py +1481 -0
  162. tlgr/ops/proxy.py +750 -0
  163. tlgr/ops/reaction.py +1475 -0
  164. tlgr/ops/resolve.py +1140 -0
  165. tlgr/ops/search.py +521 -0
  166. tlgr/ops/settings.py +1066 -0
  167. tlgr/ops/stars.py +594 -0
  168. tlgr/ops/sticker.py +1602 -0
  169. tlgr/ops/story.py +3216 -0
  170. tlgr/ops/sync.py +788 -0
  171. tlgr/ops/todo.py +514 -0
  172. tlgr/ops/user.py +1406 -0
  173. tlgr/ops/vc.py +2351 -0
  174. tlgr/ops/webapp.py +717 -0
  175. tlgr/ops/webhook.py +418 -0
  176. tlgr/parity.py +386 -0
  177. tlgr/processors/__init__.py +125 -0
  178. tlgr/processors/regex.py +26 -0
  179. tlgr/processors/text.py +56 -0
  180. tlgr/registry.py +519 -0
  181. tlgr/schema.py +173 -0
  182. tlgr/transport/__init__.py +30 -0
  183. tlgr/transport/autostart.py +293 -0
  184. tlgr/transport/client.py +805 -0
  185. tlgr/transport/ndjson.py +44 -0
  186. tlgr/version.py +31 -0
  187. tlgr_cli-2.0.1.dist-info/METADATA +957 -0
  188. tlgr_cli-2.0.1.dist-info/RECORD +192 -0
  189. tlgr_cli-2.0.1.dist-info/WHEEL +5 -0
  190. tlgr_cli-2.0.1.dist-info/entry_points.txt +2 -0
  191. tlgr_cli-2.0.1.dist-info/licenses/LICENSE +21 -0
  192. 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
@@ -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