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/__init__.py
ADDED
tlgr/__main__.py
ADDED
tlgr/actions/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Registry-based actions for the Gateway pipeline.
|
|
2
|
+
|
|
3
|
+
Every action is an async function registered via ``@register_action``.
|
|
4
|
+
Actions receive an :class:`~tlgr.gateway.event.Event`, the action's config
|
|
5
|
+
from YAML, a :class:`~tlgr.jobs.client.JobClient`, and an optional
|
|
6
|
+
:class:`~tlgr.processors.ProcessorChain`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from tlgr.gateway.event import Event
|
|
15
|
+
from tlgr.jobs.client import JobClient
|
|
16
|
+
from tlgr.processors import ProcessorChain
|
|
17
|
+
|
|
18
|
+
ActionFunc = Callable[
|
|
19
|
+
[Event, Any, JobClient, ProcessorChain | None],
|
|
20
|
+
Awaitable[None],
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_REGISTRY: dict[str, ActionFunc] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def register_action(name: str):
|
|
27
|
+
"""Decorator that registers an action function under *name*."""
|
|
28
|
+
|
|
29
|
+
def decorator(func: ActionFunc) -> ActionFunc:
|
|
30
|
+
_REGISTRY[name] = func
|
|
31
|
+
return func
|
|
32
|
+
|
|
33
|
+
return decorator
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_action(name: str) -> ActionFunc | None:
|
|
37
|
+
return _REGISTRY.get(name)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def list_actions() -> list[str]:
|
|
41
|
+
return list(_REGISTRY.keys())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Import built-in action modules so they self-register.
|
|
45
|
+
from tlgr.actions import forward, reply # noqa: E402, F401
|
tlgr/actions/forward.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Forward action — relay messages to one or more destinations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from telethon import errors
|
|
10
|
+
|
|
11
|
+
from tlgr.actions import register_action
|
|
12
|
+
from tlgr.filters.message import is_forwardable
|
|
13
|
+
from tlgr.gateway.event import Event
|
|
14
|
+
from tlgr.jobs.client import JobClient
|
|
15
|
+
from tlgr.processors import ProcessorChain
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("tlgr.actions.forward")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@register_action("forward")
|
|
21
|
+
async def action_forward(
|
|
22
|
+
event: Event,
|
|
23
|
+
config: Any,
|
|
24
|
+
client: JobClient,
|
|
25
|
+
chain: ProcessorChain | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
if event.source != "telegram":
|
|
28
|
+
log.warning("forward action only supports telegram events")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
message = event.raw.message
|
|
32
|
+
|
|
33
|
+
ok, reason = is_forwardable(message)
|
|
34
|
+
if not ok:
|
|
35
|
+
log.debug("message not forwardable: %s", reason)
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if isinstance(config, str):
|
|
39
|
+
destinations = [config]
|
|
40
|
+
drop_author = False
|
|
41
|
+
elif isinstance(config, dict):
|
|
42
|
+
to = config.get("to", [])
|
|
43
|
+
destinations = to if isinstance(to, list) else [to]
|
|
44
|
+
drop_author = config.get("drop_author", False)
|
|
45
|
+
else:
|
|
46
|
+
log.warning("invalid forward config: %r", config)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
for i, dest_ref in enumerate(destinations):
|
|
50
|
+
try:
|
|
51
|
+
dest_id = await client.resolve_chat(dest_ref)
|
|
52
|
+
|
|
53
|
+
if chain:
|
|
54
|
+
original = message.text or getattr(message, "message", "") or ""
|
|
55
|
+
transformed = chain.apply(original) if original else ""
|
|
56
|
+
if message.media:
|
|
57
|
+
await client.client.send_file(dest_id, message.media, caption=transformed)
|
|
58
|
+
else:
|
|
59
|
+
await client.client.send_message(dest_id, transformed)
|
|
60
|
+
else:
|
|
61
|
+
await client.client.forward_messages(
|
|
62
|
+
dest_id,
|
|
63
|
+
message,
|
|
64
|
+
drop_author=drop_author,
|
|
65
|
+
)
|
|
66
|
+
except errors.ChatWriteForbiddenError:
|
|
67
|
+
log.warning("cannot write to %s", dest_ref)
|
|
68
|
+
except errors.ChannelPrivateError:
|
|
69
|
+
log.warning("channel %s is private", dest_ref)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
log.error("forward to %s failed: %s", dest_ref, e)
|
|
72
|
+
|
|
73
|
+
if i < len(destinations) - 1:
|
|
74
|
+
await asyncio.sleep(0.3)
|
tlgr/actions/reply.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Reply action — send a static text reply to the triggering message."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from tlgr.actions import register_action
|
|
9
|
+
from tlgr.gateway.event import Event
|
|
10
|
+
from tlgr.jobs.client import JobClient
|
|
11
|
+
from tlgr.processors import ProcessorChain
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("tlgr.actions.reply")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@register_action("reply")
|
|
17
|
+
async def action_reply(
|
|
18
|
+
event: Event,
|
|
19
|
+
config: Any,
|
|
20
|
+
client: JobClient,
|
|
21
|
+
chain: ProcessorChain | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
if event.source != "telegram":
|
|
24
|
+
log.warning("reply action only supports telegram events")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
reply_text = str(config) if isinstance(config, str) else config.get("text", str(config))
|
|
28
|
+
|
|
29
|
+
if chain:
|
|
30
|
+
reply_text = chain.apply(reply_text)
|
|
31
|
+
|
|
32
|
+
await event.raw.reply(reply_text)
|
tlgr/cli/__init__.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""CLI entry point using Click with nested command groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from tlgr import __version__
|
|
11
|
+
from tlgr.core.errors import TlgrError, emit_error, exit_code_for
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _env_bool(key: str) -> bool:
|
|
15
|
+
return os.environ.get(key, "").lower() in ("1", "true", "yes", "y", "on")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _env_or(key: str, fallback: str) -> str:
|
|
19
|
+
return os.environ.get(key, "") or fallback
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _registry_op(cmd_name: str, rest: list[str]) -> str | None:
|
|
23
|
+
"""The canonical op id an invocation resolves to, if the registry owns it."""
|
|
24
|
+
from tlgr.registry import ALIASES
|
|
25
|
+
|
|
26
|
+
candidates = [cmd_name]
|
|
27
|
+
if rest and not rest[0].startswith("-"):
|
|
28
|
+
candidates.append(f"{cmd_name}.{rest[0]}")
|
|
29
|
+
for candidate in reversed(candidates):
|
|
30
|
+
found = ALIASES.get(candidate)
|
|
31
|
+
if found is not None:
|
|
32
|
+
return found
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TlgrGroup(click.Group):
|
|
37
|
+
"""Custom group that handles errors, sandboxing, and output formatting."""
|
|
38
|
+
|
|
39
|
+
def invoke(self, ctx: click.Context) -> None:
|
|
40
|
+
try:
|
|
41
|
+
super().invoke(ctx)
|
|
42
|
+
except TlgrError as e:
|
|
43
|
+
use_json = ctx.params.get("json") or (ctx.obj and ctx.obj.get("json"))
|
|
44
|
+
emit_error(e, use_json=bool(use_json))
|
|
45
|
+
sys.exit(exit_code_for(e))
|
|
46
|
+
except (click.ClickException, click.exceptions.Exit, SystemExit):
|
|
47
|
+
raise
|
|
48
|
+
except KeyboardInterrupt:
|
|
49
|
+
sys.exit(130)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
use_json = ctx.params.get("json") or (ctx.obj and ctx.obj.get("json"))
|
|
52
|
+
emit_error(e, use_json=bool(use_json))
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
|
|
55
|
+
def resolve_command(self, ctx: click.Context, args: list[str]) -> tuple:
|
|
56
|
+
"""Override to enforce --enable-commands before dispatching."""
|
|
57
|
+
cmd_name, cmd, rest = super().resolve_command(ctx, args)
|
|
58
|
+
|
|
59
|
+
enabled = ctx.params.get("enable_commands") or ""
|
|
60
|
+
enabled = enabled.strip()
|
|
61
|
+
|
|
62
|
+
# A registry-generated command enforces the allowlist itself, by
|
|
63
|
+
# canonical op id, so `--enable-commands agent.exit-codes` also allows
|
|
64
|
+
# the `exit-codes` alias (SEC-04). The path matching below is v1's and
|
|
65
|
+
# stays only for the groups that are still hand-written.
|
|
66
|
+
if enabled and cmd_name and _registry_op(cmd_name, rest) is not None:
|
|
67
|
+
return cmd_name, cmd, rest
|
|
68
|
+
|
|
69
|
+
if enabled and cmd_name:
|
|
70
|
+
allow = {p.strip().lower() for p in enabled.split(",") if p.strip()}
|
|
71
|
+
if allow and "*" not in allow and "all" not in allow:
|
|
72
|
+
name_l = cmd_name.lower()
|
|
73
|
+
group_allowed = name_l in allow
|
|
74
|
+
has_sub_rules = any(a.startswith(f"{name_l}.") for a in allow)
|
|
75
|
+
if not group_allowed and not has_sub_rules:
|
|
76
|
+
click.echo(
|
|
77
|
+
f"Error: command {cmd_name!r} is not enabled "
|
|
78
|
+
f"(set --enable-commands to allow it)",
|
|
79
|
+
err=True,
|
|
80
|
+
)
|
|
81
|
+
sys.exit(2)
|
|
82
|
+
if has_sub_rules and isinstance(cmd, click.Group) and rest:
|
|
83
|
+
sub_name = rest[0] if rest else None
|
|
84
|
+
if sub_name and not group_allowed:
|
|
85
|
+
full_path = f"{name_l}.{sub_name.lower()}"
|
|
86
|
+
if full_path not in allow:
|
|
87
|
+
click.echo(
|
|
88
|
+
f"Error: command {full_path!r} is not enabled "
|
|
89
|
+
f"(set --enable-commands to allow it)",
|
|
90
|
+
err=True,
|
|
91
|
+
)
|
|
92
|
+
sys.exit(2)
|
|
93
|
+
|
|
94
|
+
return cmd_name, cmd, rest
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.group(cls=TlgrGroup)
|
|
98
|
+
@click.version_option(__version__, prog_name="tlgr")
|
|
99
|
+
@click.option(
|
|
100
|
+
"--json",
|
|
101
|
+
"use_json",
|
|
102
|
+
is_flag=True,
|
|
103
|
+
default=_env_bool("TLGR_JSON"),
|
|
104
|
+
help="Output JSON to stdout.",
|
|
105
|
+
)
|
|
106
|
+
@click.option(
|
|
107
|
+
"--plain",
|
|
108
|
+
"use_plain",
|
|
109
|
+
is_flag=True,
|
|
110
|
+
default=_env_bool("TLGR_PLAIN"),
|
|
111
|
+
help="Output stable TSV for piping.",
|
|
112
|
+
)
|
|
113
|
+
@click.option(
|
|
114
|
+
"--account",
|
|
115
|
+
"-a",
|
|
116
|
+
default=_env_or("TLGR_ACCOUNT", ""),
|
|
117
|
+
help="Account alias to use.",
|
|
118
|
+
)
|
|
119
|
+
@click.option(
|
|
120
|
+
"--enable-commands",
|
|
121
|
+
default=_env_or("TLGR_ENABLE_COMMANDS", ""),
|
|
122
|
+
help="Comma-separated allowlist of commands (e.g. 'message.send,chat.list').",
|
|
123
|
+
)
|
|
124
|
+
@click.option(
|
|
125
|
+
"--results-only",
|
|
126
|
+
is_flag=True,
|
|
127
|
+
default=False,
|
|
128
|
+
help="In JSON mode, emit only the primary result (strip envelope).",
|
|
129
|
+
)
|
|
130
|
+
@click.option(
|
|
131
|
+
"--select",
|
|
132
|
+
"select_fields",
|
|
133
|
+
default=None,
|
|
134
|
+
help="In JSON mode, select comma-separated fields (supports dot paths).",
|
|
135
|
+
)
|
|
136
|
+
@click.option(
|
|
137
|
+
"--dry-run",
|
|
138
|
+
"-n",
|
|
139
|
+
is_flag=True,
|
|
140
|
+
default=False,
|
|
141
|
+
help="Preview destructive operations without executing.",
|
|
142
|
+
)
|
|
143
|
+
@click.option(
|
|
144
|
+
"--force",
|
|
145
|
+
"-y",
|
|
146
|
+
is_flag=True,
|
|
147
|
+
default=False,
|
|
148
|
+
help="Skip confirmations for destructive commands.",
|
|
149
|
+
)
|
|
150
|
+
@click.option(
|
|
151
|
+
"--flood-wait-max",
|
|
152
|
+
type=int,
|
|
153
|
+
default=None,
|
|
154
|
+
help="Max seconds to auto-sleep on rate limit (default from config).",
|
|
155
|
+
)
|
|
156
|
+
@click.option(
|
|
157
|
+
"--no-input",
|
|
158
|
+
is_flag=True,
|
|
159
|
+
default=False,
|
|
160
|
+
help="Never prompt; fail instead (CI/agent mode).",
|
|
161
|
+
)
|
|
162
|
+
@click.option(
|
|
163
|
+
"--verbose",
|
|
164
|
+
"-v",
|
|
165
|
+
is_flag=True,
|
|
166
|
+
default=False,
|
|
167
|
+
help="Enable verbose logging to stderr.",
|
|
168
|
+
)
|
|
169
|
+
@click.pass_context
|
|
170
|
+
def cli(
|
|
171
|
+
ctx: click.Context,
|
|
172
|
+
use_json: bool,
|
|
173
|
+
use_plain: bool,
|
|
174
|
+
account: str | None,
|
|
175
|
+
enable_commands: str,
|
|
176
|
+
results_only: bool,
|
|
177
|
+
select_fields: str | None,
|
|
178
|
+
dry_run: bool,
|
|
179
|
+
flood_wait_max: int | None,
|
|
180
|
+
force: bool,
|
|
181
|
+
no_input: bool,
|
|
182
|
+
verbose: bool,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""tlgr — Full Telegram account control CLI."""
|
|
185
|
+
ctx.ensure_object(dict)
|
|
186
|
+
|
|
187
|
+
# TLGR_AUTO_JSON: default to JSON when stdout is piped and env var is set
|
|
188
|
+
if _env_bool("TLGR_AUTO_JSON") and not use_json and not use_plain:
|
|
189
|
+
if not sys.stdout.isatty():
|
|
190
|
+
use_json = True
|
|
191
|
+
|
|
192
|
+
if use_json and use_plain:
|
|
193
|
+
click.echo("Error: cannot combine --json and --plain", err=True)
|
|
194
|
+
sys.exit(2)
|
|
195
|
+
|
|
196
|
+
if use_json:
|
|
197
|
+
ctx.obj["fmt"] = "json"
|
|
198
|
+
elif use_plain:
|
|
199
|
+
ctx.obj["fmt"] = "plain"
|
|
200
|
+
else:
|
|
201
|
+
ctx.obj["fmt"] = "human"
|
|
202
|
+
|
|
203
|
+
ctx.obj["json"] = use_json
|
|
204
|
+
ctx.obj["account"] = account or ""
|
|
205
|
+
ctx.obj["enable_commands"] = enable_commands
|
|
206
|
+
ctx.obj["results_only"] = results_only
|
|
207
|
+
ctx.obj["select"] = select_fields
|
|
208
|
+
ctx.obj["dry_run"] = dry_run
|
|
209
|
+
# Every command threads this through its own request body now: the
|
|
210
|
+
# generated dispatcher passes `flood_wait_max` on every `/v1/op` call, so
|
|
211
|
+
# the transport-level default the hand-written v1 commands needed (COR-15)
|
|
212
|
+
# went with them.
|
|
213
|
+
ctx.obj["flood_wait_max"] = flood_wait_max
|
|
214
|
+
ctx.obj["force"] = force
|
|
215
|
+
ctx.obj["no_input"] = no_input
|
|
216
|
+
ctx.obj["verbose"] = verbose
|
|
217
|
+
|
|
218
|
+
if verbose:
|
|
219
|
+
import logging
|
|
220
|
+
|
|
221
|
+
logging.basicConfig(
|
|
222
|
+
level=logging.DEBUG, stream=sys.stderr, format="%(levelname)s: %(message)s"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Import and register sub-groups
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
from tlgr.cli.gen import build_click_tree # noqa: E402
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# The generated tree
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def build_cli() -> click.Group:
|
|
238
|
+
"""Install the transport and attach the generated command tree.
|
|
239
|
+
|
|
240
|
+
Every command comes from the registry now. PR-12 deleted the last
|
|
241
|
+
hand-written group, and with it the merge that let a v1 command and a
|
|
242
|
+
generated one share a name: there is one source for what `tlgr` can do,
|
|
243
|
+
so there is nothing left to reconcile.
|
|
244
|
+
"""
|
|
245
|
+
import tlgr.ops # noqa: F401 — importing it is what populates the registry
|
|
246
|
+
from tlgr.cli.gen import set_dispatcher
|
|
247
|
+
from tlgr.transport import make_dispatcher, make_stream_dispatcher
|
|
248
|
+
|
|
249
|
+
# Installing the transport here, rather than importing it in `gen.py`, is
|
|
250
|
+
# what keeps `cli/gen.py` testable with a fake dispatcher and keeps the
|
|
251
|
+
# daemon out of the CLI's import graph.
|
|
252
|
+
set_dispatcher(make_dispatcher(), make_stream_dispatcher())
|
|
253
|
+
|
|
254
|
+
for name, command in build_click_tree().items():
|
|
255
|
+
cli.add_command(command, name)
|
|
256
|
+
return cli
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
build_cli()
|
tlgr/cli/confirm.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""One confirmation path for every destructive operation.
|
|
2
|
+
|
|
3
|
+
v1 asked in some commands, ignored `--no-input` in others, and would block
|
|
4
|
+
forever on a prompt in a pipeline (COR-16). The rules here are absolute:
|
|
5
|
+
|
|
6
|
+
* on a TTY, a destructive op prompts unless `--yes`;
|
|
7
|
+
* off a TTY it *requires* `--yes` and otherwise fails with USAGE (exit 2);
|
|
8
|
+
* `--no-input` never prompts and never blocks, whatever the TTY says.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from tlgr.core.errors import UsageError
|
|
19
|
+
|
|
20
|
+
__all__ = ["confirm"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_tty(stream: Any = None) -> bool:
|
|
24
|
+
stream = stream or sys.stdin
|
|
25
|
+
try:
|
|
26
|
+
return bool(stream.isatty())
|
|
27
|
+
except (AttributeError, ValueError):
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def confirm(
|
|
32
|
+
prompt: str,
|
|
33
|
+
*,
|
|
34
|
+
force: bool = False,
|
|
35
|
+
no_input: bool = False,
|
|
36
|
+
tty: bool | None = None,
|
|
37
|
+
hint: str = "",
|
|
38
|
+
) -> bool:
|
|
39
|
+
"""Ask, or decide without asking. Raises UsageError when it cannot ask."""
|
|
40
|
+
if force:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
interactive = _is_tty() if tty is None else tty
|
|
44
|
+
if no_input or not interactive:
|
|
45
|
+
raise UsageError(
|
|
46
|
+
f"{prompt} — refusing to continue without --yes"
|
|
47
|
+
+ (f" ({hint})" if hint else "")
|
|
48
|
+
+ (
|
|
49
|
+
". stdin is not a terminal, so there is nobody to ask."
|
|
50
|
+
if not interactive
|
|
51
|
+
else ". --no-input was given."
|
|
52
|
+
),
|
|
53
|
+
field="yes",
|
|
54
|
+
)
|
|
55
|
+
return bool(click.confirm(prompt, default=False))
|
tlgr/cli/errors.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Turning an exception into output and an exit status, once.
|
|
2
|
+
|
|
3
|
+
Two rules from §9:
|
|
4
|
+
|
|
5
|
+
* in JSON mode the error object goes to **stdout**, so an agent parsing
|
|
6
|
+
stdout always gets JSON, and a one-line summary goes to stderr;
|
|
7
|
+
* a Click usage error is formatted the same way as any other error in JSON
|
|
8
|
+
mode — v1 let Click print its own English to stderr and exited 2 with no
|
|
9
|
+
JSON at all, which is UX-02.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
from tlgr.core.errors import (
|
|
20
|
+
EXIT_CANCELLED,
|
|
21
|
+
EXIT_USAGE,
|
|
22
|
+
classify,
|
|
23
|
+
error_body_dict,
|
|
24
|
+
exit_code_for,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = ["emit", "exit_status", "handle"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _usage_body(exc: click.UsageError) -> dict[str, Any]:
|
|
31
|
+
body: dict[str, Any] = {
|
|
32
|
+
"code": "USAGE",
|
|
33
|
+
"message": exc.format_message(),
|
|
34
|
+
"error": exc.format_message(),
|
|
35
|
+
"exit_code": EXIT_USAGE,
|
|
36
|
+
}
|
|
37
|
+
if exc.ctx is not None:
|
|
38
|
+
body["usage"] = exc.ctx.get_usage()
|
|
39
|
+
body["command"] = exc.ctx.command_path
|
|
40
|
+
parameter = getattr(exc, "param", None)
|
|
41
|
+
if parameter is not None and getattr(parameter, "name", None):
|
|
42
|
+
body["field"] = parameter.name
|
|
43
|
+
return body
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def body_for(exc: BaseException) -> dict[str, Any]:
|
|
47
|
+
"""The error object printed in JSON mode."""
|
|
48
|
+
if isinstance(exc, click.UsageError):
|
|
49
|
+
return _usage_body(exc)
|
|
50
|
+
return error_body_dict(classify(exc))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def exit_status(exc: BaseException) -> int:
|
|
54
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
55
|
+
return EXIT_CANCELLED
|
|
56
|
+
if isinstance(exc, click.UsageError):
|
|
57
|
+
return exc.exit_code or EXIT_USAGE
|
|
58
|
+
return exit_code_for(exc) if hasattr(exc, "exit_code") else classify(exc).exit_code
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def emit(exc: BaseException, *, use_json: bool = False, op: str = "") -> None:
|
|
62
|
+
"""Write the error out: JSON to stdout when asked, a line to stderr always."""
|
|
63
|
+
body = body_for(exc)
|
|
64
|
+
if use_json:
|
|
65
|
+
import json
|
|
66
|
+
|
|
67
|
+
envelope: dict[str, Any] = {"ok": False, "error": body}
|
|
68
|
+
if op:
|
|
69
|
+
envelope["op"] = op
|
|
70
|
+
json.dump(envelope, sys.stdout, default=str, ensure_ascii=False)
|
|
71
|
+
sys.stdout.write("\n")
|
|
72
|
+
sys.stdout.flush()
|
|
73
|
+
|
|
74
|
+
message = body.get("message") or str(exc)
|
|
75
|
+
hint = body.get("hint") or getattr(exc, "hint", "")
|
|
76
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
77
|
+
if hint:
|
|
78
|
+
print(f" {hint}", file=sys.stderr)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def handle(exc: BaseException, *, use_json: bool = False, op: str = "") -> int:
|
|
82
|
+
"""Emit *exc* and return the status the process should exit with."""
|
|
83
|
+
emit(exc, use_json=use_json, op=op)
|
|
84
|
+
return exit_status(exc)
|