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/runtime.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import getpass
|
|
3
|
+
import inspect
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from telethon import TelegramClient
|
|
8
|
+
|
|
9
|
+
from .config import load_client_config
|
|
10
|
+
from .errors import CLIError
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from telethon.tl.tlobject import TLRequest
|
|
14
|
+
except ImportError: # pragma: no cover - covered once bootstrap runs
|
|
15
|
+
TLRequest = None
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from telethon.errors import SessionPasswordNeededError
|
|
19
|
+
except ImportError: # pragma: no cover - covered once bootstrap runs
|
|
20
|
+
SessionPasswordNeededError = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
NO_AUTO_CONNECT_METHODS = frozenset({
|
|
24
|
+
"connect",
|
|
25
|
+
"disconnect",
|
|
26
|
+
"is_connected",
|
|
27
|
+
"set_proxy",
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _validate_call(target, args, kwargs, label):
|
|
32
|
+
try:
|
|
33
|
+
signature = inspect.signature(target)
|
|
34
|
+
except (TypeError, ValueError):
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
signature.bind(*args, **kwargs)
|
|
39
|
+
except TypeError as exc:
|
|
40
|
+
raise CLIError("Invalid arguments for {}: {}".format(label, exc)) from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _maybe_await(value):
|
|
44
|
+
if inspect.isawaitable(value):
|
|
45
|
+
value = await value
|
|
46
|
+
if hasattr(value, "__aiter__"):
|
|
47
|
+
return [item async for item in value]
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run(value):
|
|
52
|
+
if inspect.isawaitable(value):
|
|
53
|
+
return asyncio.run(value)
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _session_exists(session):
|
|
58
|
+
session_path = Path(session)
|
|
59
|
+
if session_path.exists():
|
|
60
|
+
return True
|
|
61
|
+
if session_path.suffix:
|
|
62
|
+
return False
|
|
63
|
+
return session_path.with_suffix(".session").exists()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ensure_session_directory(session):
|
|
67
|
+
session_path = Path(session)
|
|
68
|
+
directory = session_path.parent
|
|
69
|
+
if not directory:
|
|
70
|
+
return
|
|
71
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _session_file_paths(session):
|
|
75
|
+
session_path = Path(session)
|
|
76
|
+
db_path = session_path if session_path.suffix else session_path.with_suffix(".session")
|
|
77
|
+
return (
|
|
78
|
+
db_path,
|
|
79
|
+
Path(str(db_path) + "-journal"),
|
|
80
|
+
Path(str(db_path) + "-shm"),
|
|
81
|
+
Path(str(db_path) + "-wal"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _remove_session_files(session):
|
|
86
|
+
removed = []
|
|
87
|
+
for path in _session_file_paths(session):
|
|
88
|
+
if not path.exists():
|
|
89
|
+
continue
|
|
90
|
+
path.unlink()
|
|
91
|
+
removed.append(str(path))
|
|
92
|
+
return removed
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _prompt(label, *, secret=False):
|
|
96
|
+
if not sys.stdin.isatty():
|
|
97
|
+
raise CLIError("Interactive authentication requires a TTY")
|
|
98
|
+
if secret:
|
|
99
|
+
return getpass.getpass(label)
|
|
100
|
+
print(label, file=sys.stderr, end="", flush=True)
|
|
101
|
+
return sys.stdin.readline().strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def _authorize_client(client, namespace):
|
|
105
|
+
if await client.is_user_authorized():
|
|
106
|
+
return client
|
|
107
|
+
|
|
108
|
+
if not sys.stdin.isatty():
|
|
109
|
+
raise CLIError(
|
|
110
|
+
"No saved Telegram session was found and interactive login requires a TTY. "
|
|
111
|
+
"Run `telethon-cli login` in an interactive terminal first."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
phone = _prompt("Telegram phone number: ")
|
|
115
|
+
if not phone:
|
|
116
|
+
raise CLIError("Telegram phone number is required")
|
|
117
|
+
|
|
118
|
+
sent = await client.send_code_request(phone)
|
|
119
|
+
code = _prompt("Telegram login code: ")
|
|
120
|
+
if not code:
|
|
121
|
+
raise CLIError("Telegram login code is required")
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
await client.sign_in(phone=phone, code=code, phone_code_hash=sent.phone_code_hash)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
if SessionPasswordNeededError is None or not isinstance(exc, SessionPasswordNeededError):
|
|
127
|
+
raise
|
|
128
|
+
password = load_client_config(namespace).password
|
|
129
|
+
if not password:
|
|
130
|
+
password = _prompt("Telegram 2FA password: ", secret=True)
|
|
131
|
+
if not password:
|
|
132
|
+
raise CLIError("Telegram 2FA password is required")
|
|
133
|
+
await client.sign_in(password=password)
|
|
134
|
+
|
|
135
|
+
if not await client.is_user_authorized():
|
|
136
|
+
raise CLIError("Telegram login failed")
|
|
137
|
+
return client
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def create_client(namespace):
|
|
141
|
+
config = load_client_config(namespace)
|
|
142
|
+
_ensure_session_directory(config.session)
|
|
143
|
+
return TelegramClient(config.session, config.api_id, config.api_hash)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def _authorized_client(namespace, *, allow_interactive_login=True):
|
|
147
|
+
client = create_client(namespace)
|
|
148
|
+
await client.connect()
|
|
149
|
+
if await client.is_user_authorized():
|
|
150
|
+
return client
|
|
151
|
+
|
|
152
|
+
if not allow_interactive_login:
|
|
153
|
+
await client.disconnect()
|
|
154
|
+
raise CLIError(
|
|
155
|
+
"No saved Telegram session was found for '{}'. Run `telethon-cli login` first."
|
|
156
|
+
.format(load_client_config(namespace).session)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
return await _authorize_client(client, namespace)
|
|
161
|
+
except Exception:
|
|
162
|
+
await client.disconnect()
|
|
163
|
+
raise
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def login(namespace):
|
|
167
|
+
return _run(_login_async(namespace))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _login_async(namespace):
|
|
171
|
+
client = await _authorized_client(namespace, allow_interactive_login=True)
|
|
172
|
+
session = load_client_config(namespace).session
|
|
173
|
+
try:
|
|
174
|
+
return {
|
|
175
|
+
"authorized": True,
|
|
176
|
+
"session": session,
|
|
177
|
+
}
|
|
178
|
+
finally:
|
|
179
|
+
await client.disconnect()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def logout(namespace):
|
|
183
|
+
return _run(_logout_async(namespace))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def _logout_async(namespace):
|
|
187
|
+
session = load_client_config(namespace).session
|
|
188
|
+
if not _session_exists(session):
|
|
189
|
+
return {
|
|
190
|
+
"authorized": False,
|
|
191
|
+
"logged_out": False,
|
|
192
|
+
"session": session,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
client = create_client(namespace)
|
|
196
|
+
logged_out = False
|
|
197
|
+
try:
|
|
198
|
+
await client.connect()
|
|
199
|
+
if await client.is_user_authorized():
|
|
200
|
+
logged_out = await client.log_out()
|
|
201
|
+
finally:
|
|
202
|
+
await client.disconnect()
|
|
203
|
+
|
|
204
|
+
removed_files = _remove_session_files(session)
|
|
205
|
+
return {
|
|
206
|
+
"authorized": False,
|
|
207
|
+
"logged_out": bool(logged_out or removed_files),
|
|
208
|
+
"session": session,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
async def _invoke_request_with_client(namespace, request):
|
|
213
|
+
if TLRequest is not None and not isinstance(request, TLRequest):
|
|
214
|
+
raise CLIError("Client invocation expects a TLRequest instance")
|
|
215
|
+
|
|
216
|
+
client = await _authorized_client(namespace)
|
|
217
|
+
try:
|
|
218
|
+
return await client(request)
|
|
219
|
+
finally:
|
|
220
|
+
await client.disconnect()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def _run_public_command_async(namespace, command, args, kwargs):
|
|
224
|
+
if command.name in NO_AUTO_CONNECT_METHODS:
|
|
225
|
+
client = create_client(namespace)
|
|
226
|
+
else:
|
|
227
|
+
client = await _authorized_client(namespace)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
method = getattr(client, command.name)
|
|
231
|
+
_validate_call(method, args, kwargs, command.dotted_path)
|
|
232
|
+
return await _maybe_await(method(*args, **kwargs))
|
|
233
|
+
finally:
|
|
234
|
+
await client.disconnect()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def run_public_command(namespace, command, args, kwargs):
|
|
238
|
+
return _run(_run_public_command_async(namespace, command, args, kwargs))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def run_project_call(namespace, target, *, method_name=None, init_args=None,
|
|
242
|
+
init_kwargs=None, args=None, kwargs=None, client_invoke=False):
|
|
243
|
+
init_args = [] if init_args is None else init_args
|
|
244
|
+
init_kwargs = {} if init_kwargs is None else init_kwargs
|
|
245
|
+
args = [] if args is None else args
|
|
246
|
+
kwargs = {} if kwargs is None else kwargs
|
|
247
|
+
|
|
248
|
+
if inspect.isclass(target):
|
|
249
|
+
_validate_call(target, init_args, init_kwargs, "{}.__init__".format(target.__qualname__))
|
|
250
|
+
instance = target(*init_args, **init_kwargs)
|
|
251
|
+
|
|
252
|
+
if method_name:
|
|
253
|
+
method = getattr(instance, method_name, None)
|
|
254
|
+
if method is None:
|
|
255
|
+
raise CLIError("Class '{}' has no method '{}'".format(target.__qualname__, method_name))
|
|
256
|
+
if not callable(method):
|
|
257
|
+
raise CLIError("'{}' on '{}' is not callable".format(method_name, target.__qualname__))
|
|
258
|
+
_validate_call(method, args, kwargs, "{}.{}".format(target.__qualname__, method_name))
|
|
259
|
+
return _run(method(*args, **kwargs))
|
|
260
|
+
|
|
261
|
+
if client_invoke:
|
|
262
|
+
return _run(_invoke_request_with_client(namespace, instance))
|
|
263
|
+
|
|
264
|
+
return instance
|
|
265
|
+
|
|
266
|
+
if client_invoke:
|
|
267
|
+
if callable(target):
|
|
268
|
+
_validate_call(target, args, kwargs, getattr(target, "__qualname__", repr(target)))
|
|
269
|
+
request = _run(target(*args, **kwargs))
|
|
270
|
+
else:
|
|
271
|
+
request = target
|
|
272
|
+
return _run(_invoke_request_with_client(namespace, request))
|
|
273
|
+
|
|
274
|
+
if callable(target):
|
|
275
|
+
_validate_call(target, args, kwargs, getattr(target, "__qualname__", repr(target)))
|
|
276
|
+
return _run(target(*args, **kwargs))
|
|
277
|
+
|
|
278
|
+
if args or kwargs or init_args or init_kwargs or method_name:
|
|
279
|
+
raise CLIError("Target '{}' is not callable".format(target))
|
|
280
|
+
return target
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .errors import CLIError
|
|
7
|
+
from .resolver import import_dotted_path
|
|
8
|
+
from .settings import ensure_developer_mode_enabled
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from telethon.tl.tlobject import TLObject
|
|
12
|
+
except ImportError: # pragma: no cover - covered once bootstrap runs
|
|
13
|
+
TLObject = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _call_from_spec(spec):
|
|
17
|
+
if not isinstance(spec, dict) or "path" not in spec:
|
|
18
|
+
raise CLIError("$call payload must be an object with a 'path' field")
|
|
19
|
+
|
|
20
|
+
args = materialize_payload(spec.get("args", []))
|
|
21
|
+
kwargs = materialize_payload(spec.get("kwargs", {}))
|
|
22
|
+
if not isinstance(args, list):
|
|
23
|
+
raise CLIError("$call args must decode to a list")
|
|
24
|
+
if not isinstance(kwargs, dict):
|
|
25
|
+
raise CLIError("$call kwargs must decode to an object")
|
|
26
|
+
|
|
27
|
+
target = import_dotted_path(spec["path"], project_only=False)
|
|
28
|
+
try:
|
|
29
|
+
result = target(*args, **kwargs)
|
|
30
|
+
except (AttributeError, ImportError, TypeError, ValueError) as exc:
|
|
31
|
+
raise CLIError("Failed to materialize '$call' for '{}': {}".format(spec["path"], exc)) from exc
|
|
32
|
+
|
|
33
|
+
if inspect.isawaitable(result):
|
|
34
|
+
raise CLIError("Awaitable '$call' values are not supported in JSON payloads")
|
|
35
|
+
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def materialize_payload(value):
|
|
40
|
+
if isinstance(value, list):
|
|
41
|
+
return [materialize_payload(item) for item in value]
|
|
42
|
+
|
|
43
|
+
if isinstance(value, dict):
|
|
44
|
+
if set(value) == {"$ref"}:
|
|
45
|
+
ensure_developer_mode_enabled("JSON $ref materialization")
|
|
46
|
+
return import_dotted_path(value["$ref"], project_only=False)
|
|
47
|
+
if set(value) == {"$call"}:
|
|
48
|
+
ensure_developer_mode_enabled("JSON $call materialization")
|
|
49
|
+
return _call_from_spec(value["$call"])
|
|
50
|
+
return {key: materialize_payload(item) for key, item in value.items()}
|
|
51
|
+
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_json_input(text=None, file_path=None, *, default):
|
|
56
|
+
if text and file_path:
|
|
57
|
+
raise CLIError("Use either inline JSON or a JSON file, not both")
|
|
58
|
+
if text is None and file_path is None:
|
|
59
|
+
return default
|
|
60
|
+
|
|
61
|
+
if file_path:
|
|
62
|
+
try:
|
|
63
|
+
raw = Path(file_path).read_text(encoding="utf-8")
|
|
64
|
+
except OSError as exc:
|
|
65
|
+
raise CLIError("Unable to read JSON file '{}': {}".format(file_path, exc)) from exc
|
|
66
|
+
else:
|
|
67
|
+
raw = text
|
|
68
|
+
try:
|
|
69
|
+
payload = json.loads(raw)
|
|
70
|
+
except json.JSONDecodeError as exc:
|
|
71
|
+
raise CLIError("Invalid JSON payload: {}".format(exc)) from exc
|
|
72
|
+
return materialize_payload(payload)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_cli_value(value):
|
|
76
|
+
try:
|
|
77
|
+
decoded = json.loads(value)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
return value
|
|
80
|
+
return materialize_payload(decoded)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _serialize_iterable(value):
|
|
84
|
+
if isinstance(value, dict):
|
|
85
|
+
return {str(key): serialize_result(item) for key, item in value.items()}
|
|
86
|
+
|
|
87
|
+
if isinstance(value, (list, tuple, set)):
|
|
88
|
+
return [serialize_result(item) for item in value]
|
|
89
|
+
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _serialize_instance(value):
|
|
94
|
+
to_dict = getattr(value, "to_dict", None)
|
|
95
|
+
if callable(to_dict):
|
|
96
|
+
return serialize_result(to_dict())
|
|
97
|
+
|
|
98
|
+
if callable(value):
|
|
99
|
+
module = getattr(value, "__module__", None) or type(value).__module__
|
|
100
|
+
qualname = getattr(value, "__qualname__", type(value).__qualname__)
|
|
101
|
+
return {"__callable__": "{}.{}".format(module, qualname)}
|
|
102
|
+
|
|
103
|
+
if hasattr(value, "__dict__"):
|
|
104
|
+
return {
|
|
105
|
+
"__class__": "{}.{}".format(type(value).__module__, type(value).__qualname__),
|
|
106
|
+
"attributes": serialize_result(vars(value)),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return str(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def serialize_result(value):
|
|
113
|
+
if dataclasses.is_dataclass(value):
|
|
114
|
+
return serialize_result(dataclasses.asdict(value))
|
|
115
|
+
|
|
116
|
+
if value is None or isinstance(value, (bool, int, float, str)):
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
if isinstance(value, bytes):
|
|
120
|
+
return {"__type__": "bytes", "hex": value.hex()}
|
|
121
|
+
|
|
122
|
+
if isinstance(value, Path):
|
|
123
|
+
return str(value)
|
|
124
|
+
|
|
125
|
+
if TLObject and isinstance(value, TLObject):
|
|
126
|
+
return serialize_result(value.to_dict())
|
|
127
|
+
|
|
128
|
+
iterable = _serialize_iterable(value)
|
|
129
|
+
if iterable is not None:
|
|
130
|
+
return iterable
|
|
131
|
+
|
|
132
|
+
return _serialize_instance(value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render_output(value, output_format):
|
|
136
|
+
rendered = serialize_result(value)
|
|
137
|
+
if output_format == "json":
|
|
138
|
+
return json.dumps(rendered, indent=2, sort_keys=True, ensure_ascii=False)
|
|
139
|
+
if isinstance(rendered, str):
|
|
140
|
+
return rendered
|
|
141
|
+
return json.dumps(rendered, indent=2, sort_keys=True, ensure_ascii=False)
|
telethon_cli/settings.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .errors import CLIError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SETTINGS_FILE_NAME = ".telethon-cli.json"
|
|
9
|
+
DEVELOPER_MODE_CONFIRMATION = "enable developer mode"
|
|
10
|
+
DEVELOPER_MODE_WARNING = (
|
|
11
|
+
"Developer mode enables dangerous features, including internal callable "
|
|
12
|
+
"dispatch and JSON materialization helpers. Enable it only if you "
|
|
13
|
+
"understand and accept the risk."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ProjectSettings:
|
|
19
|
+
developer_mode: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_project_root(start_path=None):
|
|
23
|
+
current = Path.cwd() if start_path is None else Path(start_path)
|
|
24
|
+
current = current.resolve()
|
|
25
|
+
if current.is_file():
|
|
26
|
+
current = current.parent
|
|
27
|
+
|
|
28
|
+
for candidate in (current,) + tuple(current.parents):
|
|
29
|
+
if (candidate / ".git").exists() or (candidate / "pyproject.toml").exists():
|
|
30
|
+
return candidate
|
|
31
|
+
return current
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_settings_path(start_path=None):
|
|
35
|
+
return find_project_root(start_path) / SETTINGS_FILE_NAME
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_project_settings(start_path=None):
|
|
39
|
+
path = get_settings_path(start_path)
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return ProjectSettings()
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except OSError as exc:
|
|
46
|
+
raise CLIError("Unable to read settings file '{}': {}".format(path, exc)) from exc
|
|
47
|
+
except json.JSONDecodeError as exc:
|
|
48
|
+
raise CLIError("Invalid settings file '{}': {}".format(path, exc)) from exc
|
|
49
|
+
|
|
50
|
+
if not isinstance(payload, dict):
|
|
51
|
+
raise CLIError("Settings file '{}' must contain a JSON object".format(path))
|
|
52
|
+
|
|
53
|
+
developer_mode = payload.get("developer_mode", False)
|
|
54
|
+
if not isinstance(developer_mode, bool):
|
|
55
|
+
raise CLIError("Setting 'developer_mode' in '{}' must be a boolean".format(path))
|
|
56
|
+
|
|
57
|
+
return ProjectSettings(developer_mode=developer_mode)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save_project_settings(settings, start_path=None):
|
|
61
|
+
path = get_settings_path(start_path)
|
|
62
|
+
payload = {
|
|
63
|
+
"developer_mode": settings.developer_mode,
|
|
64
|
+
}
|
|
65
|
+
try:
|
|
66
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
raise CLIError("Unable to write settings file '{}': {}".format(path, exc)) from exc
|
|
69
|
+
return path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def ensure_developer_mode_enabled(feature_name, start_path=None):
|
|
73
|
+
settings = load_project_settings(start_path)
|
|
74
|
+
if settings.developer_mode:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
raise CLIError(
|
|
78
|
+
"{} is disabled until developer mode is enabled in project settings. "
|
|
79
|
+
"Run `telethon-cli settings developer-mode --enable "
|
|
80
|
+
"--accept-dangerous-features` after reviewing the warning.".format(feature_name)
|
|
81
|
+
)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: telethon-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line interface for Telethon and Telethon internals
|
|
5
|
+
Author: Axisrow
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/axisrow/telethon-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/axisrow/telethon-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/axisrow/telethon-cli/issues
|
|
10
|
+
Keywords: telegram,telethon,cli,api,automation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Communications :: Chat
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
Requires-Dist: Telethon>=1
|
|
27
|
+
|
|
28
|
+
# telethon-cli
|
|
29
|
+
|
|
30
|
+
`telethon-cli` is a separate installable package that exposes:
|
|
31
|
+
|
|
32
|
+
- a stable command tree for the public `TelegramClient` API;
|
|
33
|
+
- a generic dispatcher for internal `telethon` and `telethon_generator` callables;
|
|
34
|
+
- env-first client configuration through `TELETHON_API_ID` and `TELETHON_API_HASH`,
|
|
35
|
+
with optional session overrides.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
Install from PyPI:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
python -m pip install telethon-cli
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development, install the package in editable mode:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Set the required Telethon credentials before running commands. You can export
|
|
52
|
+
them directly in the shell:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export TELETHON_API_ID=...
|
|
56
|
+
export TELETHON_API_HASH=...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
You can also keep them in a `.env` file in the current working directory or at
|
|
60
|
+
the project root:
|
|
61
|
+
|
|
62
|
+
```dotenv
|
|
63
|
+
TELETHON_API_ID=...
|
|
64
|
+
TELETHON_API_HASH=...
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
On first login, `telethon-cli` can create and store a Telethon session in the
|
|
68
|
+
project automatically. You can create it explicitly:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
telethon-cli login
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Typical first-run flow:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
python -m pip install -e .
|
|
78
|
+
telethon-cli login
|
|
79
|
+
telethon-cli users get-me --output json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
To remove the saved session and force a fresh login next time:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
telethon-cli logout
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Or just run the first command in an interactive terminal and let the CLI ask for
|
|
89
|
+
your phone number, login code, and Telegram 2FA password when needed:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
telethon-cli users get-me --output json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
By default the session is stored in `.telethon-cli/session` under the project
|
|
96
|
+
root. You can override it explicitly:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
telethon-cli users get-me --session /path/to/session --output json
|
|
100
|
+
telethon-cli users get-me --env-file /path/to/telethon.env --output json
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`telethon-cli` reads the current process environment and also loads `.env`
|
|
104
|
+
automatically when present. If the same Telethon setting is defined both in the
|
|
105
|
+
environment and in `.env`, and the values differ, the command exits with an
|
|
106
|
+
error instead of silently choosing one. CLI flags such as `--api-id`,
|
|
107
|
+
`--api-hash`, `--session`, and `--2fa-password` still override both.
|
|
108
|
+
|
|
109
|
+
In non-interactive environments, automatic login is not possible. In that case,
|
|
110
|
+
create the session in advance with `telethon-cli login` and reuse it later.
|
|
111
|
+
|
|
112
|
+
Smoke-test the installation:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
telethon-cli --help
|
|
116
|
+
python -m telethon_cli registry public --output json
|
|
117
|
+
telethon-cli users get-me --output json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Dangerous features are gated behind project-wide developer mode. Enable it once
|
|
121
|
+
from settings and explicitly confirm the warning before using `call`,
|
|
122
|
+
`registry project`, `$ref`, or `$call` payload helpers:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
telethon-cli settings developer-mode --enable --accept-dangerous-features
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Examples:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
telethon-cli users get-me --output json
|
|
132
|
+
telethon-cli messages send-message --entity me --message "hello"
|
|
133
|
+
telethon-cli call telethon.utils.parse_phone --args-json '["+1 234 567"]'
|
|
134
|
+
telethon-cli call telethon.tl.functions.help.GetConfigRequest --client-invoke --output json
|
|
135
|
+
telethon-cli settings show --output json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Release Process
|
|
139
|
+
|
|
140
|
+
Build and validate release artifacts locally before uploading:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
rm -rf dist build src/*.egg-info
|
|
144
|
+
python -m pip install --upgrade build twine
|
|
145
|
+
python -m build
|
|
146
|
+
python -m twine check dist/*
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Optional local smoke-test from the built wheel:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
python -m pip install --force-reinstall dist/*.whl
|
|
153
|
+
telethon-cli --help
|
|
154
|
+
python -m telethon_cli registry public --output json
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Upload to TestPyPI first:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
python -m twine upload --repository testpypi dist/*
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Verify installation from TestPyPI:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple telethon-cli
|
|
167
|
+
telethon-cli --help
|
|
168
|
+
python -m telethon_cli registry public --output json
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Then upload the same artifacts to PyPI:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
python -m twine upload dist/*
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
When `twine` prompts for credentials, use username `__token__` and your
|
|
178
|
+
`pypi-...` API token as the password. Keep PyPI tokens out of the repository
|
|
179
|
+
and provide them through the environment or `twine` prompt.
|
|
180
|
+
|
|
181
|
+
For every subsequent release, bump `version` in `pyproject.toml` before
|
|
182
|
+
building. PyPI rejects uploads for versions that already exist.
|
|
183
|
+
|
|
184
|
+
You can also keep PyPI credentials in the project-local `.env` file, because
|
|
185
|
+
`.env` is gitignored in this repository. The release helper script reads `.env`
|
|
186
|
+
automatically and publishes with `twine`:
|
|
187
|
+
|
|
188
|
+
```dotenv
|
|
189
|
+
TWINE_USERNAME=__token__
|
|
190
|
+
TEST_PYPI_TOKEN=pypi-...
|
|
191
|
+
PYPI_TOKEN=pypi-...
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
chmod +x scripts/release_pypi.sh
|
|
196
|
+
./scripts/release_pypi.sh testpypi
|
|
197
|
+
./scripts/release_pypi.sh pypi
|
|
198
|
+
./scripts/release_pypi.sh all
|
|
199
|
+
```
|