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/cli/globals.py ADDED
@@ -0,0 +1,273 @@
1
+ """The global flags, attached to every generated command *and* to the root.
2
+
3
+ `tlgr chat list --json` and `tlgr --json chat list` must both work. In v1 only
4
+ the second did, and the first exited 2 with "No such option" (UX-01), which is
5
+ the single most common thing an agent gets wrong on its first call. The flags
6
+ are therefore declared once here and attached in both places; a command-level
7
+ value wins over a root-level one.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ import click
17
+
18
+ __all__ = [
19
+ "GLOBAL_FLAG_NAMES",
20
+ "CliState",
21
+ "add_global_options",
22
+ "env_bool",
23
+ "resolve_account",
24
+ "state_from",
25
+ ]
26
+
27
+
28
+ def env_bool(key: str) -> bool:
29
+ return os.environ.get(key, "").lower() in ("1", "true", "yes", "y", "on")
30
+
31
+
32
+ def _env_or(key: str, fallback: str = "") -> str:
33
+ return os.environ.get(key, "") or fallback
34
+
35
+
36
+ #: Parameter names the generator must never collide with when deriving flags
37
+ #: from request fields.
38
+ GLOBAL_FLAG_NAMES = frozenset(
39
+ {
40
+ "use_json",
41
+ "use_plain",
42
+ "account",
43
+ "results_only",
44
+ "select_fields",
45
+ "dry_run",
46
+ "force",
47
+ "no_input",
48
+ "flood_wait_max",
49
+ "timeout",
50
+ "verbose",
51
+ "no_daemon_restart",
52
+ "enable_commands",
53
+ "columns",
54
+ "wide",
55
+ "no_header",
56
+ # Pagination is transport-level, not part of any request struct
57
+ # (registry lint L5 forbids those field names), so the generated
58
+ # flags land here and travel to the daemon beside the request.
59
+ "limit",
60
+ "cursor",
61
+ "fetch_all",
62
+ }
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class CliState:
68
+ """The merged view of the global flags for one invocation."""
69
+
70
+ fmt: str = "human"
71
+ account: str = ""
72
+ results_only: bool = False
73
+ select: str | None = None
74
+ dry_run: bool = False
75
+ force: bool = False
76
+ no_input: bool = False
77
+ flood_wait_max: int | None = None
78
+ timeout: float | None = None
79
+ verbose: bool = False
80
+ no_daemon_restart: bool = False
81
+ enable_commands: str = ""
82
+ columns: str | None = None
83
+ wide: bool = False
84
+ no_header: bool = False
85
+ limit: int | None = None
86
+ cursor: str | None = None
87
+ fetch_all: bool = False
88
+ warnings: list[str] = field(default_factory=list)
89
+
90
+ @property
91
+ def json(self) -> bool:
92
+ return self.fmt == "json"
93
+
94
+
95
+ def _flag_options() -> list[Any]:
96
+ """The option decorators, built fresh so Click never shares a Parameter."""
97
+ return [
98
+ click.option(
99
+ "--json", "use_json", is_flag=True, default=None, help="Output JSON to stdout."
100
+ ),
101
+ click.option(
102
+ "--plain", "use_plain", is_flag=True, default=None, help="Output stable TSV for piping."
103
+ ),
104
+ click.option(
105
+ "--account",
106
+ "-a",
107
+ default=None,
108
+ metavar="ALIAS",
109
+ help="Account alias to use. [env: TLGR_ACCOUNT]",
110
+ ),
111
+ click.option(
112
+ "--results-only",
113
+ is_flag=True,
114
+ default=None,
115
+ help="In JSON mode, print only the result (no envelope).",
116
+ ),
117
+ click.option(
118
+ "--select",
119
+ "select_fields",
120
+ default=None,
121
+ metavar="FIELDS",
122
+ help="Project comma-separated fields out of the result (dot paths allowed).",
123
+ ),
124
+ click.option(
125
+ "--columns", default=None, metavar="COLS", help="Override the human/plain columns."
126
+ ),
127
+ click.option("--wide", is_flag=True, default=None, help="Do not truncate any column."),
128
+ click.option("--no-header", is_flag=True, default=None, help="Omit the header row."),
129
+ click.option(
130
+ "--yes", "-y", "force", is_flag=True, default=None, help="Skip confirmations."
131
+ ),
132
+ click.option(
133
+ "--no-input", is_flag=True, default=None, help="Never prompt; fail instead (CI/agent)."
134
+ ),
135
+ click.option(
136
+ "--flood-wait-max",
137
+ type=int,
138
+ default=None,
139
+ help="Max seconds to auto-sleep on a rate limit.",
140
+ ),
141
+ click.option("--timeout", type=float, default=None, help="Client-side timeout in seconds."),
142
+ click.option(
143
+ "--no-daemon-restart",
144
+ is_flag=True,
145
+ default=None,
146
+ help="Never restart the daemon automatically.",
147
+ ),
148
+ click.option(
149
+ "--enable-commands",
150
+ default=None,
151
+ metavar="IDS",
152
+ help="Comma-separated allowlist of operation ids.",
153
+ ),
154
+ click.option("--verbose", "-v", is_flag=True, default=None, help="Verbose logging."),
155
+ ]
156
+
157
+
158
+ def add_global_options(command: Any) -> Any:
159
+ """Attach every global flag to *command*.
160
+
161
+ Defaults are `None` rather than `False` so that "not given here" stays
162
+ distinguishable from "given as false", which is what lets a command-level
163
+ flag override a root-level one without clobbering it.
164
+ """
165
+ for decorator in reversed(_flag_options()):
166
+ command = decorator(command)
167
+ return command
168
+
169
+
170
+ def _merge(target: dict[str, Any], params: dict[str, Any]) -> None:
171
+ """Fold command-level flag values over the root-level ones."""
172
+ for key, value in params.items():
173
+ if value is not None and key in GLOBAL_FLAG_NAMES:
174
+ target[key] = value
175
+
176
+
177
+ def state_from(ctx: click.Context, params: dict[str, Any] | None = None) -> CliState:
178
+ """Build the merged `CliState` for this invocation."""
179
+ merged: dict[str, Any] = dict(ctx.obj or {})
180
+ if params:
181
+ _merge(merged, params)
182
+
183
+ use_json = merged.get("use_json")
184
+ use_plain = merged.get("use_plain")
185
+ if use_json is None:
186
+ use_json = merged.get("json") or env_bool("TLGR_JSON")
187
+ if use_plain is None:
188
+ use_plain = env_bool("TLGR_PLAIN")
189
+ if use_json and use_plain:
190
+ raise click.UsageError("cannot combine --json and --plain")
191
+
192
+ fmt = merged.get("fmt", "human")
193
+ if use_json:
194
+ fmt = "json"
195
+ elif use_plain:
196
+ fmt = "plain"
197
+
198
+ return CliState(
199
+ fmt=fmt,
200
+ account=merged.get("account") or "",
201
+ results_only=bool(merged.get("results_only")),
202
+ select=merged.get("select_fields") or merged.get("select"),
203
+ dry_run=bool(merged.get("dry_run")),
204
+ force=bool(merged.get("force")),
205
+ no_input=bool(merged.get("no_input")),
206
+ flood_wait_max=merged.get("flood_wait_max"),
207
+ timeout=merged.get("timeout"),
208
+ verbose=bool(merged.get("verbose")),
209
+ no_daemon_restart=bool(merged.get("no_daemon_restart")),
210
+ enable_commands=merged.get("enable_commands") or "",
211
+ columns=merged.get("columns"),
212
+ wide=bool(merged.get("wide")),
213
+ no_header=bool(merged.get("no_header")),
214
+ limit=merged.get("limit"),
215
+ cursor=merged.get("cursor"),
216
+ fetch_all=bool(merged.get("fetch_all")),
217
+ )
218
+
219
+
220
+ def _require_account_enabled() -> bool:
221
+ """`TLGR_REQUIRE_ACCOUNT`, else `[defaults] require_account`."""
222
+ env = os.environ.get("TLGR_REQUIRE_ACCOUNT", "").strip().lower()
223
+ if env in ("1", "true", "yes", "on"):
224
+ return True
225
+ if env in ("0", "false", "no", "off"):
226
+ return False
227
+ try:
228
+ from tlgr.core.config import load_app_config
229
+
230
+ return bool(load_app_config().defaults.require_account)
231
+ except Exception:
232
+ return False
233
+
234
+
235
+ def resolve_account(
236
+ state: CliState,
237
+ *,
238
+ positional: str | None = None,
239
+ require: bool | None = None,
240
+ ) -> str:
241
+ """Resolve the account, in one place, in one order.
242
+
243
+ positional → `-a/--account` → `TLGR_ACCOUNT` → `[accounts] default` →
244
+ the active alias. The daemon never picks for you (COR-02); when nothing
245
+ resolves and the operation needs one, that is a USAGE error naming the
246
+ ways to supply it.
247
+ """
248
+ alias = (positional or state.account or _env_or("TLGR_ACCOUNT")).strip()
249
+
250
+ if not alias:
251
+ try:
252
+ from tlgr.core.config import load_app_config
253
+ from tlgr.core.paths import default_base
254
+
255
+ alias = (load_app_config().default_account or "").strip()
256
+ if not alias:
257
+ from tlgr.core.accounts import AccountManager
258
+
259
+ # `default_base()` rather than the `CONFIG_DIR` constant:
260
+ # that one is captured at import, so a `TLGR_HOME` set after
261
+ # the first import was ignored here and the CLI read the
262
+ # *real* home — which is how a development run reaches a
263
+ # production tree at all.
264
+ alias = (AccountManager(default_base()).get_active() or "").strip()
265
+ except Exception:
266
+ alias = ""
267
+
268
+ if not alias and (require or (require is None and _require_account_enabled())):
269
+ raise click.UsageError(
270
+ "No account specified. Pass -a <alias>, set TLGR_ACCOUNT, or set "
271
+ "[accounts] default in config.toml (see: tlgr account list)."
272
+ )
273
+ return alias
tlgr/cli/introspect.py ADDED
@@ -0,0 +1,170 @@
1
+ """Describing the Click tree for `tlgr schema`.
2
+
3
+ The walker itself is v1's, kept because the schema document still has to
4
+ describe every not-yet-migrated command; what changed is where the examples
5
+ come from. v1 hand-maintained `EXAMPLE_RESPONSES` and covered 26 of 93
6
+ commands (COR-33); now an example is the spec's, validated by a test.
7
+
8
+ This lives in `cli/` because only the CLI knows what the CLI looks like;
9
+ `tlgr/schema.py` sits below it and is handed the result.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import click
17
+
18
+ __all__ = ["describe"]
19
+
20
+
21
+ def _param_type_name(param: click.Parameter) -> str:
22
+ """Return a stable type name for a Click parameter."""
23
+ t = param.type
24
+ if isinstance(t, click.Choice):
25
+ return "choice"
26
+ if isinstance(t, click.IntRange):
27
+ return "int"
28
+ if isinstance(t, click.FloatRange):
29
+ return "float"
30
+ name = getattr(t, "name", type(t).__name__)
31
+ return str(name).lower()
32
+
33
+
34
+ def _build_param(param: click.Parameter) -> dict[str, Any]:
35
+ """Serialize a Click parameter to a schema dict."""
36
+ entry: dict[str, Any] = {"name": param.name or ""}
37
+
38
+ if isinstance(param, click.Option):
39
+ entry["type"] = "option"
40
+ opts = list(param.opts) + list(param.secondary_opts)
41
+ entry["flags"] = sorted(opts)
42
+ entry["is_flag"] = getattr(param, "is_flag", False)
43
+ elif isinstance(param, click.Argument):
44
+ entry["type"] = "argument"
45
+ entry["required"] = param.required
46
+ if param.nargs != 1:
47
+ entry["nargs"] = param.nargs
48
+ else:
49
+ entry["type"] = "parameter"
50
+
51
+ entry["param_type"] = _param_type_name(param)
52
+
53
+ if isinstance(param.type, click.Choice):
54
+ entry["choices"] = list(param.type.choices)
55
+
56
+ is_flag = getattr(param, "is_flag", False)
57
+ default = param.default
58
+ if (
59
+ default is not None
60
+ and default != ()
61
+ and not is_flag
62
+ and not (hasattr(default, "__class__") and "Sentinel" in type(default).__name__)
63
+ ):
64
+ entry["default"] = default
65
+
66
+ help_text = getattr(param, "help", None)
67
+ if help_text:
68
+ entry["help"] = help_text
69
+
70
+ if getattr(param, "hidden", False):
71
+ entry["hidden"] = True
72
+
73
+ if getattr(param, "envvar", None):
74
+ envvar = param.envvar
75
+ if isinstance(envvar, str):
76
+ entry["envvar"] = [envvar]
77
+ elif envvar:
78
+ entry["envvar"] = list(envvar)
79
+
80
+ return entry
81
+
82
+
83
+ def _registry_example(full_path: str) -> Any:
84
+ """The example for a command path, from the spec that owns it.
85
+
86
+ Not every command has one yet: an unmigrated v1 group has no spec, and
87
+ saying nothing is better than shipping the stale literal v1 kept here.
88
+ """
89
+ from tlgr.registry import ALIASES, REGISTRY
90
+
91
+ op_id = ALIASES.get(full_path.removeprefix("tlgr ").replace(" ", "."))
92
+ spec = REGISTRY.get(op_id) if op_id else None
93
+ if spec is None or spec.example is None:
94
+ return None
95
+ import msgspec
96
+
97
+ return msgspec.to_builtins(spec.example)
98
+
99
+
100
+ def _build_node(cmd: click.BaseCommand, name: str = "", path: str = "") -> dict[str, Any]:
101
+ """Recursively build a schema node for a command."""
102
+ full_path = f"{path} {name}".strip() if path else name
103
+
104
+ node: dict[str, Any] = {
105
+ "name": name or cmd.name or "",
106
+ "path": full_path,
107
+ }
108
+
109
+ if isinstance(cmd, click.Group):
110
+ node["type"] = "group"
111
+ else:
112
+ node["type"] = "command"
113
+
114
+ if cmd.help:
115
+ node["help"] = cmd.help.split("\n")[0].strip()
116
+
117
+ if getattr(cmd, "hidden", False):
118
+ node["hidden"] = True
119
+
120
+ params = getattr(cmd, "params", [])
121
+ if params:
122
+ node["params"] = [_build_param(p) for p in params if p.name != "help"]
123
+
124
+ example = _registry_example(full_path)
125
+ if example is not None:
126
+ node["example_response"] = example
127
+
128
+ if isinstance(cmd, click.Group):
129
+ sub_names = cmd.list_commands(click.Context(cmd, info_name=name))
130
+ subcommands = []
131
+ for sub_name in sorted(sub_names):
132
+ sub_cmd = cmd.get_command(click.Context(cmd, info_name=name), sub_name)
133
+ if sub_cmd is not None:
134
+ subcommands.append(_build_node(sub_cmd, sub_name, full_path))
135
+ if subcommands:
136
+ node["subcommands"] = subcommands
137
+
138
+ return node
139
+
140
+
141
+ def describe(path: tuple[str, ...] = (), *, include_hidden: bool = False) -> dict[str, Any] | None:
142
+ """The command tree under *path*, or None when the path does not exist."""
143
+ from tlgr.cli import cli as root_cli
144
+
145
+ node: Any = root_cli
146
+ walked = "tlgr"
147
+ for token in path:
148
+ if not isinstance(node, click.Group):
149
+ return None
150
+ sub = node.get_command(click.Context(node, info_name=walked.split()[-1]), token)
151
+ if sub is None:
152
+ return None
153
+ walked = f"{walked} {token}"
154
+ node = sub
155
+
156
+ tree = _build_node(node, name=walked.split()[-1], path="")
157
+ tree["path"] = walked
158
+ if not include_hidden:
159
+ _strip_hidden(tree)
160
+ return tree
161
+
162
+
163
+ def _strip_hidden(node: dict[str, Any]) -> None:
164
+ """Recursively remove hidden commands and params."""
165
+ if "params" in node:
166
+ node["params"] = [p for p in node["params"] if not p.get("hidden")]
167
+ if "subcommands" in node:
168
+ node["subcommands"] = [s for s in node["subcommands"] if not s.get("hidden")]
169
+ for sub in node["subcommands"]:
170
+ _strip_hidden(sub)
tlgr/cli/params.py ADDED
@@ -0,0 +1,189 @@
1
+ """Custom Click parameter types.
2
+
3
+ Each one converts to the *model* value the daemon expects and fails as a
4
+ USAGE error naming the offending field, so a bad argument is reported the same
5
+ way whether it was caught in the CLI or over the wire.
6
+
7
+ Nothing here resolves anything. `@alice` becomes a `PeerRef`, not a user; the
8
+ network is the daemon's business (§4.3, §6.6).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ from typing import Any
16
+
17
+ import click
18
+
19
+ from tlgr.core.text import PARSE_MODES
20
+ from tlgr.core.timefmt import TimeFormatError, fmt_dt, parse_dt, parse_duration
21
+ from tlgr.models.peer import PeerRef, parse_message_link, parse_peer_ref, parse_user_ref
22
+
23
+ __all__ = [
24
+ "DATETIME",
25
+ "DURATION",
26
+ "JSON",
27
+ "MSGREF",
28
+ "PARSE_MODE",
29
+ "PATH",
30
+ "PEER",
31
+ "USER",
32
+ "DateTimeParam",
33
+ "DurationParam",
34
+ "JsonParam",
35
+ "MsgRefParam",
36
+ "PathParam",
37
+ "PeerParam",
38
+ "UserParam",
39
+ "for_kind",
40
+ ]
41
+
42
+
43
+ class PeerParam(click.ParamType):
44
+ """`@username`, an id, `+phone`, `me`/`saved`, or a t.me/tg:// link."""
45
+
46
+ name = "peer"
47
+
48
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
49
+ if isinstance(value, PeerRef):
50
+ return value
51
+ try:
52
+ return parse_peer_ref(str(value))
53
+ except ValueError as exc:
54
+ self.fail(str(exc), param, ctx)
55
+
56
+
57
+ class UserParam(PeerParam):
58
+ """`PEER` minus the forms that can only name a chat."""
59
+
60
+ name = "user"
61
+
62
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
63
+ if isinstance(value, PeerRef):
64
+ return value
65
+ try:
66
+ return parse_user_ref(str(value))
67
+ except ValueError as exc:
68
+ self.fail(str(exc), param, ctx)
69
+
70
+
71
+ class MsgRefParam(click.ParamType):
72
+ """A message id, or a link that carries one.
73
+
74
+ A link is accepted anywhere a `<chat> <msg-id>` pair is expected; the
75
+ generated command fills both and complains if they disagree (STYLE §2).
76
+ """
77
+
78
+ name = "msg_id"
79
+
80
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
81
+ if isinstance(value, int):
82
+ return value
83
+ text = str(value).strip()
84
+ try:
85
+ return int(text)
86
+ except ValueError:
87
+ pass
88
+ link = parse_message_link(text)
89
+ if link is None:
90
+ self.fail(f"{text!r} is neither a message id nor a message link", param, ctx)
91
+ return link[1]
92
+
93
+
94
+ class DurationParam(click.ParamType):
95
+ """`30s 5m 2h 7d 1w forever`. `0` is legal and means "immediately"."""
96
+
97
+ name = "duration"
98
+
99
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
100
+ if value is None or isinstance(value, int):
101
+ return value
102
+ try:
103
+ return parse_duration(str(value))
104
+ except TimeFormatError as exc:
105
+ self.fail(str(exc), param, ctx)
106
+
107
+
108
+ class DateTimeParam(click.ParamType):
109
+ """RFC-3339, `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM`, or a relative `-90m`/`+3d`.
110
+
111
+ Converts to a UTC RFC-3339 string, because that is what crosses the wire;
112
+ a naive value is read in the local zone first (COR-23).
113
+ """
114
+
115
+ name = "datetime"
116
+
117
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
118
+ if value is None:
119
+ return None
120
+ try:
121
+ parsed = parse_dt(str(value))
122
+ except TimeFormatError as exc:
123
+ self.fail(str(exc), param, ctx)
124
+ return fmt_dt(parsed)
125
+
126
+
127
+ class JsonParam(click.ParamType):
128
+ """A JSON literal, or `@path` / `-` to read it from a file or stdin."""
129
+
130
+ name = "json"
131
+
132
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
133
+ if not isinstance(value, str):
134
+ return value
135
+ text = value
136
+ if text == "-":
137
+ text = sys.stdin.read()
138
+ elif text.startswith("@"):
139
+ try:
140
+ with open(text[1:], encoding="utf-8") as handle:
141
+ text = handle.read()
142
+ except OSError as exc:
143
+ self.fail(f"{text[1:]}: {exc.strerror or exc}", param, ctx)
144
+ try:
145
+ json.loads(text)
146
+ except ValueError as exc:
147
+ self.fail(f"not valid JSON: {exc}", param, ctx)
148
+ return text
149
+
150
+
151
+ class PathParam(click.Path):
152
+ """A filesystem path where `-` keeps its stdin/stdout meaning."""
153
+
154
+ name = "path"
155
+
156
+ def convert(self, value: Any, param: Any, ctx: Any) -> Any:
157
+ if value == "-":
158
+ return "-"
159
+ return super().convert(value, param, ctx)
160
+
161
+
162
+ class ParseModeParam(click.Choice):
163
+ def __init__(self) -> None:
164
+ super().__init__(list(PARSE_MODES))
165
+
166
+
167
+ PEER = PeerParam()
168
+ USER = UserParam()
169
+ MSGREF = MsgRefParam()
170
+ DURATION = DurationParam()
171
+ DATETIME = DateTimeParam()
172
+ JSON = JsonParam()
173
+ PATH = PathParam()
174
+ PARSE_MODE = ParseModeParam()
175
+
176
+ #: `kind=` on a request field → the Click type that implements it.
177
+ _BY_KIND: dict[str, click.ParamType] = {
178
+ "peer": PEER,
179
+ "user": USER,
180
+ "msg_id": MSGREF,
181
+ "duration": DURATION,
182
+ "datetime": DATETIME,
183
+ "json": JSON,
184
+ "path": PATH,
185
+ }
186
+
187
+
188
+ def for_kind(kind: str) -> click.ParamType | None:
189
+ return _BY_KIND.get(kind)