telethon-cli 0.1.0__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.
- telethon_cli/__init__.py +3 -0
- telethon_cli/__main__.py +5 -0
- telethon_cli/app.py +320 -0
- telethon_cli/command_matrix.py +106 -0
- telethon_cli/config.py +195 -0
- telethon_cli/errors.py +2 -0
- telethon_cli/live_smoke.py +465 -0
- telethon_cli/registry.py +119 -0
- telethon_cli/resolver.py +39 -0
- telethon_cli/runtime.py +280 -0
- telethon_cli/serialization.py +141 -0
- telethon_cli/settings.py +81 -0
- telethon_cli-0.1.0.dist-info/METADATA +199 -0
- telethon_cli-0.1.0.dist-info/RECORD +17 -0
- telethon_cli-0.1.0.dist-info/WHEEL +5 -0
- telethon_cli-0.1.0.dist-info/entry_points.txt +2 -0
- telethon_cli-0.1.0.dist-info/top_level.txt +1 -0
telethon_cli/__init__.py
ADDED
telethon_cli/__main__.py
ADDED
telethon_cli/app.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import inspect
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .config import add_client_arguments
|
|
6
|
+
from .errors import CLIError
|
|
7
|
+
from .registry import discover_project_callables, discover_public_commands
|
|
8
|
+
from .resolver import import_dotted_path
|
|
9
|
+
from .runtime import login, logout, run_project_call, run_public_command
|
|
10
|
+
from .serialization import parse_cli_value, parse_json_input, render_output
|
|
11
|
+
from .settings import (
|
|
12
|
+
DEVELOPER_MODE_CONFIRMATION,
|
|
13
|
+
DEVELOPER_MODE_WARNING,
|
|
14
|
+
ProjectSettings,
|
|
15
|
+
ensure_developer_mode_enabled,
|
|
16
|
+
find_project_root,
|
|
17
|
+
get_settings_path,
|
|
18
|
+
load_project_settings,
|
|
19
|
+
save_project_settings,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
MISSING = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _add_output_argument(parser):
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--output",
|
|
29
|
+
choices=("text", "json"),
|
|
30
|
+
default="text",
|
|
31
|
+
help="Output format",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _add_json_arguments(parser, flag_prefix="", dest_prefix=None):
|
|
36
|
+
dest_prefix = flag_prefix if dest_prefix is None else dest_prefix
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--{}args-json".format(flag_prefix),
|
|
39
|
+
dest="{}args_json".format(dest_prefix),
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--{}args-file".format(flag_prefix),
|
|
43
|
+
dest="{}args_file".format(dest_prefix),
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--{}kwargs-json".format(flag_prefix),
|
|
47
|
+
dest="{}kwargs_json".format(dest_prefix),
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--{}kwargs-file".format(flag_prefix),
|
|
51
|
+
dest="{}kwargs_file".format(dest_prefix),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _load_call_arguments(namespace, prefix=""):
|
|
56
|
+
args = parse_json_input(
|
|
57
|
+
getattr(namespace, "{}args_json".format(prefix)),
|
|
58
|
+
getattr(namespace, "{}args_file".format(prefix)),
|
|
59
|
+
default=[],
|
|
60
|
+
)
|
|
61
|
+
kwargs = parse_json_input(
|
|
62
|
+
getattr(namespace, "{}kwargs_json".format(prefix)),
|
|
63
|
+
getattr(namespace, "{}kwargs_file".format(prefix)),
|
|
64
|
+
default={},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if not isinstance(args, list):
|
|
68
|
+
raise CLIError("{}args payload must decode to a list".format(prefix))
|
|
69
|
+
if not isinstance(kwargs, dict):
|
|
70
|
+
raise CLIError("{}kwargs payload must decode to an object".format(prefix))
|
|
71
|
+
return args, kwargs
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _iter_signature_parameters(signature):
|
|
75
|
+
for param in signature.parameters.values():
|
|
76
|
+
if param.name in ("self", "cls"):
|
|
77
|
+
continue
|
|
78
|
+
if param.kind not in (
|
|
79
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
80
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
81
|
+
):
|
|
82
|
+
continue
|
|
83
|
+
yield param
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _merge_flag_arguments(namespace, kwargs, parameter_names):
|
|
87
|
+
for name in parameter_names:
|
|
88
|
+
value = getattr(namespace, "param_{}".format(name))
|
|
89
|
+
if value is not MISSING:
|
|
90
|
+
kwargs[name] = value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _handle_public_command(namespace):
|
|
94
|
+
args, kwargs = _load_call_arguments(namespace)
|
|
95
|
+
_merge_flag_arguments(namespace, kwargs, namespace.parameter_names)
|
|
96
|
+
return run_public_command(namespace, namespace.public_command, args, kwargs)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _handle_call(namespace):
|
|
100
|
+
ensure_developer_mode_enabled("call")
|
|
101
|
+
target = import_dotted_path(namespace.target, project_only=True)
|
|
102
|
+
args, kwargs = _load_call_arguments(namespace)
|
|
103
|
+
init_args, init_kwargs = _load_call_arguments(namespace, prefix="init_")
|
|
104
|
+
return run_project_call(
|
|
105
|
+
namespace,
|
|
106
|
+
target,
|
|
107
|
+
method_name=namespace.method,
|
|
108
|
+
init_args=init_args,
|
|
109
|
+
init_kwargs=init_kwargs,
|
|
110
|
+
args=args,
|
|
111
|
+
kwargs=kwargs,
|
|
112
|
+
client_invoke=namespace.client_invoke,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _handle_registry_public(_namespace):
|
|
117
|
+
return [
|
|
118
|
+
{
|
|
119
|
+
"group": command.group,
|
|
120
|
+
"command": command.cli_name,
|
|
121
|
+
"method": command.name,
|
|
122
|
+
"path": command.dotted_path,
|
|
123
|
+
}
|
|
124
|
+
for command in discover_public_commands()
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _handle_auth_login(namespace):
|
|
129
|
+
return login(namespace)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _handle_auth_logout(namespace):
|
|
133
|
+
return logout(namespace)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _project_settings_payload(settings):
|
|
137
|
+
return {
|
|
138
|
+
"project_root": str(find_project_root()),
|
|
139
|
+
"settings_path": str(get_settings_path()),
|
|
140
|
+
"developer_mode": settings.developer_mode,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _confirm_developer_mode_enable(namespace):
|
|
145
|
+
if namespace.accept_dangerous_features:
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if not sys.stdin.isatty():
|
|
149
|
+
raise CLIError(
|
|
150
|
+
"Enabling developer mode requires explicit confirmation with "
|
|
151
|
+
"--accept-dangerous-features. {}".format(DEVELOPER_MODE_WARNING)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
print(DEVELOPER_MODE_WARNING, file=sys.stderr)
|
|
155
|
+
print(
|
|
156
|
+
"Type '{}' to continue: ".format(DEVELOPER_MODE_CONFIRMATION),
|
|
157
|
+
file=sys.stderr,
|
|
158
|
+
end="",
|
|
159
|
+
)
|
|
160
|
+
response = sys.stdin.readline().strip()
|
|
161
|
+
if response != DEVELOPER_MODE_CONFIRMATION:
|
|
162
|
+
raise CLIError("Developer mode was not enabled")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _handle_settings_show(_namespace):
|
|
166
|
+
return _project_settings_payload(load_project_settings())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _handle_settings_developer_mode(namespace):
|
|
170
|
+
current = load_project_settings()
|
|
171
|
+
if namespace.enable and current.developer_mode:
|
|
172
|
+
return _project_settings_payload(current)
|
|
173
|
+
|
|
174
|
+
if namespace.enable:
|
|
175
|
+
_confirm_developer_mode_enable(namespace)
|
|
176
|
+
settings = ProjectSettings(developer_mode=True)
|
|
177
|
+
else:
|
|
178
|
+
settings = ProjectSettings(developer_mode=False)
|
|
179
|
+
|
|
180
|
+
save_project_settings(settings)
|
|
181
|
+
return _project_settings_payload(settings)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _handle_registry_project(namespace):
|
|
185
|
+
ensure_developer_mode_enabled("registry project")
|
|
186
|
+
records = discover_project_callables()
|
|
187
|
+
if namespace.prefix:
|
|
188
|
+
records = [record for record in records if record.dotted_path.startswith(namespace.prefix)]
|
|
189
|
+
output = []
|
|
190
|
+
for record in records:
|
|
191
|
+
item = {
|
|
192
|
+
"path": record.dotted_path,
|
|
193
|
+
"kind": record.kind,
|
|
194
|
+
}
|
|
195
|
+
if record.method_name:
|
|
196
|
+
item["method"] = record.method_name
|
|
197
|
+
output.append(item)
|
|
198
|
+
return output
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_parser():
|
|
202
|
+
parser = argparse.ArgumentParser(prog="telethon-cli")
|
|
203
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
204
|
+
|
|
205
|
+
public_commands = discover_public_commands()
|
|
206
|
+
grouped = {}
|
|
207
|
+
for command in public_commands:
|
|
208
|
+
grouped.setdefault(command.group, []).append(command)
|
|
209
|
+
|
|
210
|
+
for group, commands in grouped.items():
|
|
211
|
+
group_parser = subparsers.add_parser(group, help="{} commands".format(group))
|
|
212
|
+
group_subparsers = group_parser.add_subparsers(dest="{}_command".format(group))
|
|
213
|
+
for command in commands:
|
|
214
|
+
command_parser = group_subparsers.add_parser(
|
|
215
|
+
command.cli_name,
|
|
216
|
+
help=command.summary or command.name,
|
|
217
|
+
description=command.summary or command.dotted_path,
|
|
218
|
+
)
|
|
219
|
+
add_client_arguments(command_parser)
|
|
220
|
+
_add_output_argument(command_parser)
|
|
221
|
+
_add_json_arguments(command_parser)
|
|
222
|
+
|
|
223
|
+
parameter_names = []
|
|
224
|
+
for param in _iter_signature_parameters(command.signature):
|
|
225
|
+
parameter_names.append(param.name)
|
|
226
|
+
command_parser.add_argument(
|
|
227
|
+
"--{}".format(param.name.replace("_", "-")),
|
|
228
|
+
dest="param_{}".format(param.name),
|
|
229
|
+
default=MISSING,
|
|
230
|
+
type=parse_cli_value,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
command_parser.set_defaults(
|
|
234
|
+
handler=_handle_public_command,
|
|
235
|
+
public_command=command,
|
|
236
|
+
parameter_names=tuple(parameter_names),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
call_parser = subparsers.add_parser("call", help="Invoke any telethon/telethon_generator callable")
|
|
240
|
+
call_parser.add_argument("target", help="Dotted path under telethon or telethon_generator")
|
|
241
|
+
call_parser.add_argument("--method", help="Method name when the target is a class")
|
|
242
|
+
call_parser.add_argument(
|
|
243
|
+
"--client-invoke",
|
|
244
|
+
action="store_true",
|
|
245
|
+
help="Instantiate/call the target and invoke the result through TelegramClient",
|
|
246
|
+
)
|
|
247
|
+
add_client_arguments(call_parser)
|
|
248
|
+
_add_output_argument(call_parser)
|
|
249
|
+
_add_json_arguments(call_parser)
|
|
250
|
+
_add_json_arguments(call_parser, flag_prefix="init-", dest_prefix="init_")
|
|
251
|
+
call_parser.set_defaults(handler=_handle_call)
|
|
252
|
+
|
|
253
|
+
registry_parser = subparsers.add_parser("registry", help="Inspect discovered CLI coverage")
|
|
254
|
+
_add_output_argument(registry_parser)
|
|
255
|
+
registry_subparsers = registry_parser.add_subparsers(dest="registry_command")
|
|
256
|
+
|
|
257
|
+
public_parser = registry_subparsers.add_parser("public", help="List public TelegramClient commands")
|
|
258
|
+
_add_output_argument(public_parser)
|
|
259
|
+
public_parser.set_defaults(handler=_handle_registry_public)
|
|
260
|
+
|
|
261
|
+
project_parser = registry_subparsers.add_parser("project", help="List discovered project callables")
|
|
262
|
+
_add_output_argument(project_parser)
|
|
263
|
+
project_parser.add_argument("--prefix", help="Filter dotted paths by prefix")
|
|
264
|
+
project_parser.set_defaults(handler=_handle_registry_project)
|
|
265
|
+
|
|
266
|
+
login_parser = subparsers.add_parser("login", help="Create or refresh a Telegram session")
|
|
267
|
+
add_client_arguments(login_parser)
|
|
268
|
+
_add_output_argument(login_parser)
|
|
269
|
+
login_parser.set_defaults(handler=_handle_auth_login)
|
|
270
|
+
|
|
271
|
+
logout_parser = subparsers.add_parser("logout", help="Log out and remove the saved Telegram session")
|
|
272
|
+
add_client_arguments(logout_parser)
|
|
273
|
+
_add_output_argument(logout_parser)
|
|
274
|
+
logout_parser.set_defaults(handler=_handle_auth_logout)
|
|
275
|
+
|
|
276
|
+
settings_parser = subparsers.add_parser("settings", help="Manage project settings")
|
|
277
|
+
settings_subparsers = settings_parser.add_subparsers(dest="settings_command")
|
|
278
|
+
|
|
279
|
+
settings_show_parser = settings_subparsers.add_parser("show", help="Show project settings")
|
|
280
|
+
_add_output_argument(settings_show_parser)
|
|
281
|
+
settings_show_parser.set_defaults(handler=_handle_settings_show)
|
|
282
|
+
|
|
283
|
+
developer_mode_parser = settings_subparsers.add_parser(
|
|
284
|
+
"developer-mode",
|
|
285
|
+
help="Enable or disable project-wide developer mode",
|
|
286
|
+
)
|
|
287
|
+
_add_output_argument(developer_mode_parser)
|
|
288
|
+
action_group = developer_mode_parser.add_mutually_exclusive_group(required=True)
|
|
289
|
+
action_group.add_argument("--enable", action="store_true", help="Enable developer mode")
|
|
290
|
+
action_group.add_argument("--disable", action="store_true", help="Disable developer mode")
|
|
291
|
+
developer_mode_parser.add_argument(
|
|
292
|
+
"--accept-dangerous-features",
|
|
293
|
+
action="store_true",
|
|
294
|
+
help="Confirm that you understand developer mode enables dangerous features",
|
|
295
|
+
)
|
|
296
|
+
developer_mode_parser.set_defaults(handler=_handle_settings_developer_mode)
|
|
297
|
+
|
|
298
|
+
return parser
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def main(argv=None):
|
|
302
|
+
parser = build_parser()
|
|
303
|
+
namespace = parser.parse_args(argv)
|
|
304
|
+
if not hasattr(namespace, "handler"):
|
|
305
|
+
parser.print_help(sys.stderr)
|
|
306
|
+
return 1
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
result = namespace.handler(namespace)
|
|
310
|
+
except CLIError as exc:
|
|
311
|
+
print(str(exc), file=sys.stderr)
|
|
312
|
+
return 2
|
|
313
|
+
except KeyboardInterrupt:
|
|
314
|
+
print("Interrupted", file=sys.stderr)
|
|
315
|
+
return 130
|
|
316
|
+
|
|
317
|
+
output = render_output(result, getattr(namespace, "output", "text"))
|
|
318
|
+
if output:
|
|
319
|
+
print(output)
|
|
320
|
+
return 0
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
MODE_LIVE_SAFE = "live-safe"
|
|
5
|
+
MODE_LIVE_DISPOSABLE = "live-disposable"
|
|
6
|
+
MODE_MOCKED = "mocked"
|
|
7
|
+
MODE_EXCLUDED_LIVE = "excluded-live"
|
|
8
|
+
LIVE_MODES = frozenset({MODE_LIVE_SAFE, MODE_LIVE_DISPOSABLE})
|
|
9
|
+
NON_LIVE_MODES = frozenset({MODE_MOCKED, MODE_EXCLUDED_LIVE})
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CommandTestSpec:
|
|
14
|
+
group: str
|
|
15
|
+
cli_name: str
|
|
16
|
+
mode: str
|
|
17
|
+
fixture: str
|
|
18
|
+
cleanup: str
|
|
19
|
+
reason: str = ""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def key(self):
|
|
23
|
+
return (self.group, self.cli_name)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def method_name(self):
|
|
27
|
+
return self.cli_name.replace("-", "_")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _spec(group, cli_name, mode, fixture, cleanup, reason=""):
|
|
31
|
+
return CommandTestSpec(
|
|
32
|
+
group=group,
|
|
33
|
+
cli_name=cli_name,
|
|
34
|
+
mode=mode,
|
|
35
|
+
fixture=fixture,
|
|
36
|
+
cleanup=cleanup,
|
|
37
|
+
reason=reason,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
PUBLIC_COMMAND_MATRIX = (
|
|
42
|
+
_spec("auth", "start", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
|
|
43
|
+
_spec("auth", "sign-in", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
|
|
44
|
+
_spec("auth", "sign-up", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
|
|
45
|
+
_spec("auth", "send-code-request", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
|
|
46
|
+
_spec("auth", "qr-login", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
|
|
47
|
+
_spec("auth", "log-out", MODE_EXCLUDED_LIVE, "none", "none", "auth-state-change"),
|
|
48
|
+
_spec("auth", "edit-2fa", MODE_EXCLUDED_LIVE, "none", "none", "security-state-change"),
|
|
49
|
+
_spec("base", "connect", MODE_LIVE_SAFE, "none", "none"),
|
|
50
|
+
_spec("base", "is-connected", MODE_LIVE_SAFE, "none", "none"),
|
|
51
|
+
_spec("base", "disconnect", MODE_LIVE_SAFE, "none", "none"),
|
|
52
|
+
_spec("base", "set-proxy", MODE_MOCKED, "none", "none", "local-network-config"),
|
|
53
|
+
_spec("messages", "iter-messages", MODE_LIVE_SAFE, "me", "none"),
|
|
54
|
+
_spec("messages", "get-messages", MODE_LIVE_SAFE, "me", "none"),
|
|
55
|
+
_spec("messages", "send-message", MODE_LIVE_DISPOSABLE, "me", "delete-created-message"),
|
|
56
|
+
_spec("messages", "forward-messages", MODE_LIVE_DISPOSABLE, "disposable-message", "delete-created-message"),
|
|
57
|
+
_spec("messages", "edit-message", MODE_LIVE_DISPOSABLE, "disposable-message", "none"),
|
|
58
|
+
_spec("messages", "delete-messages", MODE_LIVE_DISPOSABLE, "disposable-message", "handled-in-command"),
|
|
59
|
+
_spec("messages", "send-read-acknowledge", MODE_LIVE_DISPOSABLE, "disposable-message", "none"),
|
|
60
|
+
_spec("messages", "pin-message", MODE_LIVE_DISPOSABLE, "disposable-message", "restore-unpinned-state"),
|
|
61
|
+
_spec("messages", "unpin-message", MODE_LIVE_DISPOSABLE, "disposable-message", "none"),
|
|
62
|
+
_spec("uploads", "send-file", MODE_LIVE_DISPOSABLE, "temp-file", "delete-created-message"),
|
|
63
|
+
_spec("uploads", "upload-file", MODE_LIVE_DISPOSABLE, "temp-file", "delete-temp-file"),
|
|
64
|
+
_spec("downloads", "download-profile-photo", MODE_LIVE_DISPOSABLE, "me", "delete-temp-file"),
|
|
65
|
+
_spec("downloads", "download-media", MODE_MOCKED, "none", "none", "requires-message-object"),
|
|
66
|
+
_spec("downloads", "download-file", MODE_MOCKED, "none", "none", "requires-input-location"),
|
|
67
|
+
_spec("downloads", "iter-download", MODE_MOCKED, "none", "none", "requires-input-location"),
|
|
68
|
+
_spec("dialogs", "iter-dialogs", MODE_LIVE_SAFE, "none", "none"),
|
|
69
|
+
_spec("dialogs", "get-dialogs", MODE_LIVE_SAFE, "none", "none"),
|
|
70
|
+
_spec("dialogs", "iter-drafts", MODE_LIVE_SAFE, "me", "none"),
|
|
71
|
+
_spec("dialogs", "get-drafts", MODE_LIVE_SAFE, "me", "none"),
|
|
72
|
+
_spec("dialogs", "edit-folder", MODE_EXCLUDED_LIVE, "none", "none", "mutates-dialog-state"),
|
|
73
|
+
_spec("dialogs", "delete-dialog", MODE_EXCLUDED_LIVE, "none", "none", "destructive-dialog-state"),
|
|
74
|
+
_spec("dialogs", "conversation", MODE_MOCKED, "none", "none", "interactive-conversation-loop"),
|
|
75
|
+
_spec("users", "get-me", MODE_LIVE_SAFE, "none", "none"),
|
|
76
|
+
_spec("users", "is-bot", MODE_LIVE_SAFE, "none", "none"),
|
|
77
|
+
_spec("users", "is-user-authorized", MODE_LIVE_SAFE, "none", "none"),
|
|
78
|
+
_spec("users", "get-entity", MODE_LIVE_SAFE, "me", "none"),
|
|
79
|
+
_spec("users", "get-input-entity", MODE_LIVE_SAFE, "me", "none"),
|
|
80
|
+
_spec("users", "get-peer-id", MODE_LIVE_SAFE, "me", "none"),
|
|
81
|
+
_spec("chats", "iter-participants", MODE_MOCKED, "none", "none", "requires-chat-fixture"),
|
|
82
|
+
_spec("chats", "get-participants", MODE_MOCKED, "none", "none", "requires-chat-fixture"),
|
|
83
|
+
_spec("chats", "iter-admin-log", MODE_MOCKED, "none", "none", "requires-admin-chat"),
|
|
84
|
+
_spec("chats", "get-admin-log", MODE_MOCKED, "none", "none", "requires-admin-chat"),
|
|
85
|
+
_spec("chats", "iter-profile-photos", MODE_MOCKED, "none", "none", "requires-peer-fixture"),
|
|
86
|
+
_spec("chats", "get-profile-photos", MODE_MOCKED, "none", "none", "requires-peer-fixture"),
|
|
87
|
+
_spec("chats", "action", MODE_MOCKED, "none", "none", "ephemeral-side-effect"),
|
|
88
|
+
_spec("chats", "edit-admin", MODE_EXCLUDED_LIVE, "none", "none", "mutates-admin-state"),
|
|
89
|
+
_spec("chats", "edit-permissions", MODE_EXCLUDED_LIVE, "none", "none", "mutates-permissions-state"),
|
|
90
|
+
_spec("chats", "kick-participant", MODE_EXCLUDED_LIVE, "none", "none", "destructive-membership-state"),
|
|
91
|
+
_spec("chats", "get-permissions", MODE_MOCKED, "none", "none", "requires-chat-fixture"),
|
|
92
|
+
_spec("chats", "get-stats", MODE_MOCKED, "none", "none", "requires-channel-stats"),
|
|
93
|
+
_spec("updates", "set-receive-updates", MODE_MOCKED, "none", "none", "mutates-session-update-state"),
|
|
94
|
+
_spec("updates", "run-until-disconnected", MODE_MOCKED, "none", "none", "long-running-loop"),
|
|
95
|
+
_spec("updates", "on", MODE_MOCKED, "none", "none", "callback-registration"),
|
|
96
|
+
_spec("updates", "add-event-handler", MODE_MOCKED, "none", "none", "callback-registration"),
|
|
97
|
+
_spec("updates", "remove-event-handler", MODE_MOCKED, "none", "none", "callback-registration"),
|
|
98
|
+
_spec("updates", "list-event-handlers", MODE_MOCKED, "none", "none", "callback-registration"),
|
|
99
|
+
_spec("updates", "catch-up", MODE_MOCKED, "none", "none", "requires-update-queue"),
|
|
100
|
+
_spec("bots", "inline-query", MODE_MOCKED, "none", "none", "requires-inline-bot"),
|
|
101
|
+
_spec("buttons", "build-reply-markup", MODE_MOCKED, "none", "none", "pure-builder"),
|
|
102
|
+
_spec("account", "takeout", MODE_EXCLUDED_LIVE, "none", "none", "account-export-state"),
|
|
103
|
+
_spec("account", "end-takeout", MODE_EXCLUDED_LIVE, "none", "none", "account-export-state"),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
PUBLIC_COMMAND_MATRIX_BY_KEY = {spec.key: spec for spec in PUBLIC_COMMAND_MATRIX}
|
telethon_cli/config.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import shlex
|
|
5
|
+
|
|
6
|
+
from .errors import CLIError
|
|
7
|
+
from .settings import find_project_root
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ENV_API_ID = "TELETHON_API_ID"
|
|
11
|
+
ENV_API_HASH = "TELETHON_API_HASH"
|
|
12
|
+
ENV_SESSION = "TELETHON_SESSION"
|
|
13
|
+
ENV_PASSWORD = "TELETHON_PASSWORD"
|
|
14
|
+
SUPPORTED_ENV_KEYS = (ENV_API_ID, ENV_API_HASH, ENV_SESSION, ENV_PASSWORD)
|
|
15
|
+
DEFAULT_ENV_FILE_NAME = ".env"
|
|
16
|
+
DEFAULT_SESSION_DIR_NAME = ".telethon-cli"
|
|
17
|
+
DEFAULT_SESSION_FILE_NAME = "session"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ClientConfig:
|
|
22
|
+
api_id: int
|
|
23
|
+
api_hash: str
|
|
24
|
+
session: str
|
|
25
|
+
password: str = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def add_client_arguments(parser):
|
|
29
|
+
parser.add_argument("--api-id", dest="api_id", type=int, help="Telegram API ID")
|
|
30
|
+
parser.add_argument("--api-hash", dest="api_hash", help="Telegram API hash")
|
|
31
|
+
parser.add_argument("--session", dest="session", help="Telethon session name or path")
|
|
32
|
+
parser.add_argument("--2fa-password", dest="password", help="Telegram 2FA password")
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--env-file",
|
|
35
|
+
dest="env_file",
|
|
36
|
+
help="Path to a dotenv file with Telethon credentials",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _strip_inline_comment(raw_value):
|
|
41
|
+
in_single = False
|
|
42
|
+
in_double = False
|
|
43
|
+
escaped = False
|
|
44
|
+
|
|
45
|
+
for index, char in enumerate(raw_value):
|
|
46
|
+
if escaped:
|
|
47
|
+
escaped = False
|
|
48
|
+
continue
|
|
49
|
+
if char == "\\":
|
|
50
|
+
escaped = True
|
|
51
|
+
continue
|
|
52
|
+
if char == "'" and not in_double:
|
|
53
|
+
in_single = not in_single
|
|
54
|
+
continue
|
|
55
|
+
if char == '"' and not in_single:
|
|
56
|
+
in_double = not in_double
|
|
57
|
+
continue
|
|
58
|
+
if char == "#" and not in_single and not in_double:
|
|
59
|
+
if index == 0 or raw_value[index - 1].isspace():
|
|
60
|
+
return raw_value[:index].rstrip()
|
|
61
|
+
return raw_value.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_dotenv_value(raw_value, path, line_number):
|
|
65
|
+
value = _strip_inline_comment(raw_value).strip()
|
|
66
|
+
if not value:
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
if value[0] in ("'", '"'):
|
|
70
|
+
try:
|
|
71
|
+
parsed = shlex.split(value, posix=True)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
raise CLIError(
|
|
74
|
+
"Invalid dotenv value for line {} in '{}': {}".format(line_number, path, exc)
|
|
75
|
+
) from exc
|
|
76
|
+
if len(parsed) != 1:
|
|
77
|
+
raise CLIError(
|
|
78
|
+
"Invalid dotenv value for line {} in '{}': {}".format(
|
|
79
|
+
line_number,
|
|
80
|
+
path,
|
|
81
|
+
raw_value.strip(),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return parsed[0]
|
|
85
|
+
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _load_dotenv_file(path):
|
|
90
|
+
values = {}
|
|
91
|
+
try:
|
|
92
|
+
content = path.read_text(encoding="utf-8")
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise CLIError("Unable to read env file '{}': {}".format(path, exc)) from exc
|
|
95
|
+
|
|
96
|
+
for line_number, raw_line in enumerate(content.splitlines(), start=1):
|
|
97
|
+
line = raw_line.strip()
|
|
98
|
+
if not line or line.startswith("#"):
|
|
99
|
+
continue
|
|
100
|
+
if "=" not in raw_line:
|
|
101
|
+
raise CLIError("Invalid dotenv line {} in '{}': {}".format(line_number, path, raw_line))
|
|
102
|
+
|
|
103
|
+
key, raw_value = raw_line.split("=", 1)
|
|
104
|
+
key = key.strip()
|
|
105
|
+
if key.startswith("export "):
|
|
106
|
+
key = key[len("export "):].strip()
|
|
107
|
+
if not key:
|
|
108
|
+
raise CLIError("Invalid dotenv line {} in '{}': {}".format(line_number, path, raw_line))
|
|
109
|
+
if key not in SUPPORTED_ENV_KEYS:
|
|
110
|
+
continue
|
|
111
|
+
values[key] = _parse_dotenv_value(raw_value, path, line_number)
|
|
112
|
+
|
|
113
|
+
return values
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_env_file(namespace):
|
|
117
|
+
explicit_path = getattr(namespace, "env_file", None)
|
|
118
|
+
if explicit_path:
|
|
119
|
+
path = Path(explicit_path).expanduser()
|
|
120
|
+
if not path.is_absolute():
|
|
121
|
+
path = Path.cwd() / path
|
|
122
|
+
path = path.resolve()
|
|
123
|
+
if not path.exists():
|
|
124
|
+
raise CLIError("Env file '{}' does not exist".format(path))
|
|
125
|
+
if path.is_dir():
|
|
126
|
+
raise CLIError("Env file '{}' must be a file".format(path))
|
|
127
|
+
return path
|
|
128
|
+
|
|
129
|
+
candidates = []
|
|
130
|
+
cwd_path = (Path.cwd() / DEFAULT_ENV_FILE_NAME).resolve()
|
|
131
|
+
candidates.append(cwd_path)
|
|
132
|
+
|
|
133
|
+
project_root_path = (find_project_root() / DEFAULT_ENV_FILE_NAME).resolve()
|
|
134
|
+
if project_root_path not in candidates:
|
|
135
|
+
candidates.append(project_root_path)
|
|
136
|
+
|
|
137
|
+
for candidate in candidates:
|
|
138
|
+
if candidate.exists() and candidate.is_file():
|
|
139
|
+
return candidate
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _merge_environment(environ, dotenv_values, dotenv_path):
|
|
144
|
+
merged = dict(environ)
|
|
145
|
+
conflicts = []
|
|
146
|
+
|
|
147
|
+
for key, dotenv_value in dotenv_values.items():
|
|
148
|
+
env_value = environ.get(key)
|
|
149
|
+
if env_value is None:
|
|
150
|
+
merged[key] = dotenv_value
|
|
151
|
+
continue
|
|
152
|
+
if env_value != dotenv_value:
|
|
153
|
+
conflicts.append("{} (environment vs {})".format(key, dotenv_path))
|
|
154
|
+
|
|
155
|
+
if conflicts:
|
|
156
|
+
raise CLIError(
|
|
157
|
+
"Conflicting Telethon client config values: {}".format(", ".join(conflicts))
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return merged
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _default_session_path(start_path=None):
|
|
164
|
+
project_root = find_project_root(start_path)
|
|
165
|
+
return project_root / DEFAULT_SESSION_DIR_NAME / DEFAULT_SESSION_FILE_NAME
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def load_client_config(namespace, environ=None):
|
|
169
|
+
environ = os.environ if environ is None else environ
|
|
170
|
+
dotenv_path = _resolve_env_file(namespace)
|
|
171
|
+
if dotenv_path is not None:
|
|
172
|
+
environ = _merge_environment(environ, _load_dotenv_file(dotenv_path), dotenv_path)
|
|
173
|
+
|
|
174
|
+
api_id = namespace.api_id if getattr(namespace, "api_id", None) is not None else environ.get(ENV_API_ID)
|
|
175
|
+
api_hash = namespace.api_hash if getattr(namespace, "api_hash", None) is not None else environ.get(ENV_API_HASH)
|
|
176
|
+
session = namespace.session if getattr(namespace, "session", None) is not None else environ.get(ENV_SESSION)
|
|
177
|
+
password = namespace.password if getattr(namespace, "password", None) is not None else environ.get(ENV_PASSWORD)
|
|
178
|
+
|
|
179
|
+
missing = []
|
|
180
|
+
if api_id in (None, ""):
|
|
181
|
+
missing.append(ENV_API_ID)
|
|
182
|
+
if not api_hash:
|
|
183
|
+
missing.append(ENV_API_HASH)
|
|
184
|
+
if missing:
|
|
185
|
+
raise CLIError("Missing Telethon client config: {}".format(", ".join(missing)))
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
api_id = int(api_id)
|
|
189
|
+
except (TypeError, ValueError) as exc:
|
|
190
|
+
raise CLIError("{} must be an integer".format(ENV_API_ID)) from exc
|
|
191
|
+
|
|
192
|
+
if not session:
|
|
193
|
+
session = str(_default_session_path())
|
|
194
|
+
|
|
195
|
+
return ClientConfig(api_id=api_id, api_hash=api_hash, session=session, password=password)
|
telethon_cli/errors.py
ADDED