telegram-cli-local 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/.agents/skills/telegram-cli/SKILL.md +3 -1
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/.claude/skills/telegram-cli/SKILL.md +3 -1
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/CHANGELOG.md +6 -0
- {telegram_cli_local-0.4.0/telegram_cli_local.egg-info → telegram_cli_local-0.5.0}/PKG-INFO +10 -3
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/README.md +8 -2
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/README.ru.md +8 -2
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/RELEASING.md +2 -2
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/requirements.txt +1 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tcli_skills/SKILL.md +3 -1
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tcli_skills/__init__.py +1 -1
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0/telegram_cli_local.egg-info}/PKG-INFO +10 -3
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/requires.txt +1 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tests/test_features.py +92 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tests/test_tg.py +63 -7
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tg.py +167 -12
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/AGENTS.md +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/CLAUDE.md +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/LICENSE +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/MANIFEST.in +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/pyproject.toml +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/scripts/benchmark_crypto.py +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/scripts/check_distribution.py +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/scripts/check_docs.py +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/setup.cfg +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/SOURCES.txt +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/dependency_links.txt +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/entry_points.txt +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/top_level.txt +0 -0
- {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tests/test_docs.py +0 -0
|
@@ -43,7 +43,7 @@ If unavailable, inspect `tcli help`; do not assume newer commands exist based on
|
|
|
43
43
|
| Cancel specific scheduled messages | `tcli scheduled me --cancel 123 124 --json` |
|
|
44
44
|
| Download one attachment | `tcli download me 123 --output archive.zip --json` |
|
|
45
45
|
| Configure API credentials locally | `tcli config` |
|
|
46
|
-
| Sign in interactively in the user's terminal | `tcli login` |
|
|
46
|
+
| Sign in interactively in the user's terminal | `tcli login` or `tcli login --qr` |
|
|
47
47
|
| Check local setup, optionally connectivity | `tcli doctor` or `tcli doctor --connect` |
|
|
48
48
|
| End this CLI session when requested | `tcli logout` |
|
|
49
49
|
| Refresh supported agent skills offline | `tcli skills install --agent all` |
|
|
@@ -55,6 +55,8 @@ Use `thread` for the parent chain and `--around` for chronological neighbors; th
|
|
|
55
55
|
## Sessions, bots and deletion
|
|
56
56
|
|
|
57
57
|
Select the authorized account with `--session NAME` before or after the command; default is `default`.
|
|
58
|
+
Phone login reports where Telegram sent the code. If it does not arrive, run `tcli login --qr`
|
|
59
|
+
in a terminal and scan it in Telegram mobile under Settings > Devices > Link Desktop Device.
|
|
58
60
|
Do not assume a different named session belongs to the same person or bot. `sessions --json` lists
|
|
59
61
|
local file presence only, without checking identity or authorization; `doctor --connect` shows user/bot type.
|
|
60
62
|
Names use 1-32 lowercase ASCII letters/digits, underscores or hyphens, starting with letter/digit.
|
|
@@ -43,7 +43,7 @@ If unavailable, inspect `tcli help`; do not assume newer commands exist based on
|
|
|
43
43
|
| Cancel specific scheduled messages | `tcli scheduled me --cancel 123 124 --json` |
|
|
44
44
|
| Download one attachment | `tcli download me 123 --output archive.zip --json` |
|
|
45
45
|
| Configure API credentials locally | `tcli config` |
|
|
46
|
-
| Sign in interactively in the user's terminal | `tcli login` |
|
|
46
|
+
| Sign in interactively in the user's terminal | `tcli login` or `tcli login --qr` |
|
|
47
47
|
| Check local setup, optionally connectivity | `tcli doctor` or `tcli doctor --connect` |
|
|
48
48
|
| End this CLI session when requested | `tcli logout` |
|
|
49
49
|
| Refresh supported agent skills offline | `tcli skills install --agent all` |
|
|
@@ -55,6 +55,8 @@ Use `thread` for the parent chain and `--around` for chronological neighbors; th
|
|
|
55
55
|
## Sessions, bots and deletion
|
|
56
56
|
|
|
57
57
|
Select the authorized account with `--session NAME` before or after the command; default is `default`.
|
|
58
|
+
Phone login reports where Telegram sent the code. If it does not arrive, run `tcli login --qr`
|
|
59
|
+
in a terminal and scan it in Telegram mobile under Settings > Devices > Link Desktop Device.
|
|
58
60
|
Do not assume a different named session belongs to the same person or bot. `sessions --json` lists
|
|
59
61
|
local file presence only, without checking identity or authorization; `doctor --connect` shows user/bot type.
|
|
60
62
|
Names use 1-32 lowercase ASCII letters/digits, underscores or hyphens, starting with letter/digit.
|
|
@@ -7,6 +7,12 @@ move them into a versioned section when publishing a release.
|
|
|
7
7
|
|
|
8
8
|
No unreleased changes.
|
|
9
9
|
|
|
10
|
+
## 0.5.0 - 2026-11-11
|
|
11
|
+
|
|
12
|
+
- Show the delivery method chosen by Telegram during phone login.
|
|
13
|
+
- Add `login --qr` for sign-in with an already authorized Telegram mobile app.
|
|
14
|
+
- Pipeline up to four upload blocks per attachment to reduce time spent waiting for Telegram.
|
|
15
|
+
|
|
10
16
|
## 0.4.0 - 2026-10-25
|
|
11
17
|
|
|
12
18
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: telegram-cli-local
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Telegram CLI for user and bot accounts with named sessions
|
|
5
5
|
Author: BYxarek
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -18,6 +18,7 @@ Requires-Python: >=3.10
|
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE
|
|
20
20
|
Requires-Dist: Telethon==1.45.0
|
|
21
|
+
Requires-Dist: qrcode==8.2
|
|
21
22
|
Provides-Extra: fast
|
|
22
23
|
Requires-Dist: cryptg<0.7,>=0.5.2; extra == "fast"
|
|
23
24
|
Dynamic: license-file
|
|
@@ -325,6 +326,10 @@ tcli login
|
|
|
325
326
|
It does not connect to Telegram. Run it again to replace credentials; invalid input leaves existing settings intact.
|
|
326
327
|
|
|
327
328
|
`login` asks for your international phone number, Telegram login code, and 2FA password if enabled.
|
|
329
|
+
The CLI displays the delivery method chosen by Telegram. If Telegram sends an in-app code,
|
|
330
|
+
open the service chat named **Telegram** on an already signed-in device.
|
|
331
|
+
If the code does not arrive, run `tcli login --qr` in a terminal and scan the QR code in the
|
|
332
|
+
Telegram mobile app under **Settings → Devices → Link Desktop Device**.
|
|
328
333
|
Code and password input is hidden. The session is saved for later commands.
|
|
329
334
|
|
|
330
335
|
### 3. Read and send
|
|
@@ -388,7 +393,7 @@ the CLI displays metadata. Deletion and inline media playback are not supported.
|
|
|
388
393
|
| Command | Purpose |
|
|
389
394
|
| --- | --- |
|
|
390
395
|
| `tcli config` | Save or replace API credentials |
|
|
391
|
-
| `tcli login` | Sign in
|
|
396
|
+
| `tcli login` | Sign in by phone or with `--qr`; use `--bot` for a bot |
|
|
392
397
|
| `tcli sessions --json` | List local named session files offline |
|
|
393
398
|
| `tcli delete CHAT ID [ID ...]` | Delete the selected messages for everyone |
|
|
394
399
|
| `tcli chats --limit 100` | List up to 100 chats; default: 50 |
|
|
@@ -569,7 +574,9 @@ For faster workflows, forward existing Telegram attachments instead of downloadi
|
|
|
569
574
|
them again, use `--jsonl` for large history reads, and apply server-side date/sender/media filters
|
|
570
575
|
before filename matching. Keep filename scan bounds as small as the task allows. Disable progress
|
|
571
576
|
with `--no-progress` when measuring transfer speed. Avoid concurrent CLI processes on one session.
|
|
572
|
-
|
|
577
|
+
For local attachments larger than 512 KiB, `send --attach` keeps up to four 512 KiB blocks
|
|
578
|
+
in flight over one Telegram connection. This also applies to individual files in an album.
|
|
579
|
+
Progress counts acknowledged bytes, and the CLI submits the message after all its blocks are saved.
|
|
573
580
|
Resumable downloads already use Telethon's maximum default request size (512 KiB).
|
|
574
581
|
Plain text has no byte-transfer callback: the CLI reports the confirmed message ID after sending.
|
|
575
582
|
|
|
@@ -301,6 +301,10 @@ tcli login
|
|
|
301
301
|
It does not connect to Telegram. Run it again to replace credentials; invalid input leaves existing settings intact.
|
|
302
302
|
|
|
303
303
|
`login` asks for your international phone number, Telegram login code, and 2FA password if enabled.
|
|
304
|
+
The CLI displays the delivery method chosen by Telegram. If Telegram sends an in-app code,
|
|
305
|
+
open the service chat named **Telegram** on an already signed-in device.
|
|
306
|
+
If the code does not arrive, run `tcli login --qr` in a terminal and scan the QR code in the
|
|
307
|
+
Telegram mobile app under **Settings → Devices → Link Desktop Device**.
|
|
304
308
|
Code and password input is hidden. The session is saved for later commands.
|
|
305
309
|
|
|
306
310
|
### 3. Read and send
|
|
@@ -364,7 +368,7 @@ the CLI displays metadata. Deletion and inline media playback are not supported.
|
|
|
364
368
|
| Command | Purpose |
|
|
365
369
|
| --- | --- |
|
|
366
370
|
| `tcli config` | Save or replace API credentials |
|
|
367
|
-
| `tcli login` | Sign in
|
|
371
|
+
| `tcli login` | Sign in by phone or with `--qr`; use `--bot` for a bot |
|
|
368
372
|
| `tcli sessions --json` | List local named session files offline |
|
|
369
373
|
| `tcli delete CHAT ID [ID ...]` | Delete the selected messages for everyone |
|
|
370
374
|
| `tcli chats --limit 100` | List up to 100 chats; default: 50 |
|
|
@@ -545,7 +549,9 @@ For faster workflows, forward existing Telegram attachments instead of downloadi
|
|
|
545
549
|
them again, use `--jsonl` for large history reads, and apply server-side date/sender/media filters
|
|
546
550
|
before filename matching. Keep filename scan bounds as small as the task allows. Disable progress
|
|
547
551
|
with `--no-progress` when measuring transfer speed. Avoid concurrent CLI processes on one session.
|
|
548
|
-
|
|
552
|
+
For local attachments larger than 512 KiB, `send --attach` keeps up to four 512 KiB blocks
|
|
553
|
+
in flight over one Telegram connection. This also applies to individual files in an album.
|
|
554
|
+
Progress counts acknowledged bytes, and the CLI submits the message after all its blocks are saved.
|
|
549
555
|
Resumable downloads already use Telethon's maximum default request size (512 KiB).
|
|
550
556
|
Plain text has no byte-transfer callback: the CLI reports the confirmed message ID after sending.
|
|
551
557
|
|
|
@@ -305,6 +305,10 @@ tcli --help
|
|
|
305
305
|
неверный ввод не перезаписывает существующий файл.
|
|
306
306
|
3. Выполните `tcli login`. Введите телефон в международном формате, код входа
|
|
307
307
|
и пароль двухфакторной защиты, если она включена. Код и пароль вводятся скрыто.
|
|
308
|
+
CLI показывает способ доставки, выбранный Telegram. Если код отправлен в приложение,
|
|
309
|
+
откройте служебный чат **Telegram** на устройстве, где вы уже вошли в аккаунт.
|
|
310
|
+
Если код не приходит, запустите `tcli login --qr` в терминале и отсканируйте QR-код
|
|
311
|
+
в мобильном Telegram через **Настройки → Устройства → Подключить устройство**.
|
|
308
312
|
4. Откройте список чатов и прочитайте нужный диалог:
|
|
309
313
|
|
|
310
314
|
```console
|
|
@@ -365,7 +369,7 @@ tcli download me 123 --output photo.jpg
|
|
|
365
369
|
| `tcli` / `tcli help` | Показывает общую справку, первые шаги и примеры |
|
|
366
370
|
| `tcli help send` | Показывает справку по конкретной команде |
|
|
367
371
|
| `tcli config` | Сохраняет или заменяет API-данные |
|
|
368
|
-
| `tcli login` | Вход
|
|
372
|
+
| `tcli login` | Вход по телефону или через `--qr`; `--bot` для бота |
|
|
369
373
|
| `tcli sessions --json` | Список локальных именованных сессий без сети |
|
|
370
374
|
| `tcli delete CHAT ID [ID ...]` | Удаление выбранных сообщений для всех |
|
|
371
375
|
| `tcli chats --limit 100` | Показывает до 100 чатов; по умолчанию — 50 |
|
|
@@ -552,7 +556,9 @@ Telethon автоматически использует установленн
|
|
|
552
556
|
для большой истории и серверные фильтры даты, отправителя и типа медиа перед поиском по имени файла.
|
|
553
557
|
Ограничивайте объём сканирования необходимым минимумом. Для замера скорости отключайте вывод
|
|
554
558
|
через `--no-progress`. Избегайте одновременных процессов CLI с одной сессией.
|
|
555
|
-
|
|
559
|
+
Для локальных вложений больше 512 КиБ команда `send --attach` держит в работе до четырёх
|
|
560
|
+
блоков по 512 КиБ через одно соединение с Telegram. Это действует и для файлов внутри альбома.
|
|
561
|
+
Индикатор учитывает подтверждённые байты. CLI отправляет сообщение после сохранения всех блоков.
|
|
556
562
|
Докачивание уже использует максимальный стандартный размер запроса Telethon — 512 КиБ.
|
|
557
563
|
У обычного текста нет callback с количеством переданных байтов: после отправки CLI показывает
|
|
558
564
|
подтверждённый ID сообщения.
|
|
@@ -34,7 +34,7 @@ python -X utf8 scripts/check_docs.py
|
|
|
34
34
|
python -X utf8 -m unittest discover -s tests -v
|
|
35
35
|
python -m build
|
|
36
36
|
python -m twine check --strict dist/*
|
|
37
|
-
python scripts/check_distribution.py dist/telegram_cli_local-0.
|
|
37
|
+
python scripts/check_distribution.py dist/telegram_cli_local-0.5.0-py3-none-any.whl
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
Use a clean `dist/` directory for each release to avoid uploading stale versions.
|
|
@@ -49,7 +49,7 @@ It needs access to PyPI to install the wheel's dependencies; it never accesses T
|
|
|
49
49
|
under `Unreleased` for every change; move those entries into a versioned section for the release.
|
|
50
50
|
3. Commit and push to `main`. Wait for the `Test, build, and publish` workflow to pass on Windows
|
|
51
51
|
and Linux with Python 3.10 and 3.14, including the built-wheel installation check.
|
|
52
|
-
4. Create a GitHub Release targeting that commit with a matching tag, for example `v0.
|
|
52
|
+
4. Create a GitHub Release targeting that commit with a matching tag, for example `v0.5.0`, and publish it.
|
|
53
53
|
Draft releases do not upload. The workflow rejects tags that differ from the package version.
|
|
54
54
|
5. Check that the publish job succeeded and that https://pypi.org/project/telegram-cli-local/
|
|
55
55
|
lists the expected version. Test `pipx install telegram-cli-local` on a clean machine.
|
|
@@ -43,7 +43,7 @@ If unavailable, inspect `tcli help`; do not assume newer commands exist based on
|
|
|
43
43
|
| Cancel specific scheduled messages | `tcli scheduled me --cancel 123 124 --json` |
|
|
44
44
|
| Download one attachment | `tcli download me 123 --output archive.zip --json` |
|
|
45
45
|
| Configure API credentials locally | `tcli config` |
|
|
46
|
-
| Sign in interactively in the user's terminal | `tcli login` |
|
|
46
|
+
| Sign in interactively in the user's terminal | `tcli login` or `tcli login --qr` |
|
|
47
47
|
| Check local setup, optionally connectivity | `tcli doctor` or `tcli doctor --connect` |
|
|
48
48
|
| End this CLI session when requested | `tcli logout` |
|
|
49
49
|
| Refresh supported agent skills offline | `tcli skills install --agent all` |
|
|
@@ -55,6 +55,8 @@ Use `thread` for the parent chain and `--around` for chronological neighbors; th
|
|
|
55
55
|
## Sessions, bots and deletion
|
|
56
56
|
|
|
57
57
|
Select the authorized account with `--session NAME` before or after the command; default is `default`.
|
|
58
|
+
Phone login reports where Telegram sent the code. If it does not arrive, run `tcli login --qr`
|
|
59
|
+
in a terminal and scan it in Telegram mobile under Settings > Devices > Link Desktop Device.
|
|
58
60
|
Do not assume a different named session belongs to the same person or bot. `sessions --json` lists
|
|
59
61
|
local file presence only, without checking identity or authorization; `doctor --connect` shows user/bot type.
|
|
60
62
|
Names use 1-32 lowercase ASCII letters/digits, underscores or hyphens, starting with letter/digit.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: telegram-cli-local
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Telegram CLI for user and bot accounts with named sessions
|
|
5
5
|
Author: BYxarek
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -18,6 +18,7 @@ Requires-Python: >=3.10
|
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
License-File: LICENSE
|
|
20
20
|
Requires-Dist: Telethon==1.45.0
|
|
21
|
+
Requires-Dist: qrcode==8.2
|
|
21
22
|
Provides-Extra: fast
|
|
22
23
|
Requires-Dist: cryptg<0.7,>=0.5.2; extra == "fast"
|
|
23
24
|
Dynamic: license-file
|
|
@@ -325,6 +326,10 @@ tcli login
|
|
|
325
326
|
It does not connect to Telegram. Run it again to replace credentials; invalid input leaves existing settings intact.
|
|
326
327
|
|
|
327
328
|
`login` asks for your international phone number, Telegram login code, and 2FA password if enabled.
|
|
329
|
+
The CLI displays the delivery method chosen by Telegram. If Telegram sends an in-app code,
|
|
330
|
+
open the service chat named **Telegram** on an already signed-in device.
|
|
331
|
+
If the code does not arrive, run `tcli login --qr` in a terminal and scan the QR code in the
|
|
332
|
+
Telegram mobile app under **Settings → Devices → Link Desktop Device**.
|
|
328
333
|
Code and password input is hidden. The session is saved for later commands.
|
|
329
334
|
|
|
330
335
|
### 3. Read and send
|
|
@@ -388,7 +393,7 @@ the CLI displays metadata. Deletion and inline media playback are not supported.
|
|
|
388
393
|
| Command | Purpose |
|
|
389
394
|
| --- | --- |
|
|
390
395
|
| `tcli config` | Save or replace API credentials |
|
|
391
|
-
| `tcli login` | Sign in
|
|
396
|
+
| `tcli login` | Sign in by phone or with `--qr`; use `--bot` for a bot |
|
|
392
397
|
| `tcli sessions --json` | List local named session files offline |
|
|
393
398
|
| `tcli delete CHAT ID [ID ...]` | Delete the selected messages for everyone |
|
|
394
399
|
| `tcli chats --limit 100` | List up to 100 chats; default: 50 |
|
|
@@ -569,7 +574,9 @@ For faster workflows, forward existing Telegram attachments instead of downloadi
|
|
|
569
574
|
them again, use `--jsonl` for large history reads, and apply server-side date/sender/media filters
|
|
570
575
|
before filename matching. Keep filename scan bounds as small as the task allows. Disable progress
|
|
571
576
|
with `--no-progress` when measuring transfer speed. Avoid concurrent CLI processes on one session.
|
|
572
|
-
|
|
577
|
+
For local attachments larger than 512 KiB, `send --attach` keeps up to four 512 KiB blocks
|
|
578
|
+
in flight over one Telegram connection. This also applies to individual files in an album.
|
|
579
|
+
Progress counts acknowledged bytes, and the CLI submits the message after all its blocks are saved.
|
|
573
580
|
Resumable downloads already use Telethon's maximum default request size (512 KiB).
|
|
574
581
|
Plain text has no byte-transfer callback: the CLI reports the confirmed message ID after sending.
|
|
575
582
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Offline contract tests for history, metadata, scheduling and safe transfers."""
|
|
2
2
|
import asyncio
|
|
3
3
|
import argparse
|
|
4
|
+
import hashlib
|
|
4
5
|
import io
|
|
5
6
|
import json
|
|
6
7
|
import sqlite3
|
|
@@ -376,6 +377,97 @@ class RpcTests(unittest.IsolatedAsyncioTestCase):
|
|
|
376
377
|
class TransferTests(unittest.IsolatedAsyncioTestCase):
|
|
377
378
|
execute = HistoryTests.execute
|
|
378
379
|
|
|
380
|
+
async def test_send_file_pipelines_parts_and_waits_for_all_acknowledgements(self):
|
|
381
|
+
client = tg.UploadClient(None, 12345, "synthetic")
|
|
382
|
+
peer = tg.types.InputPeerSelf()
|
|
383
|
+
block_size = 512 * 1024
|
|
384
|
+
for full_parts in (5, 20):
|
|
385
|
+
with self.subTest(full_parts=full_parts), TemporaryDirectory() as directory:
|
|
386
|
+
data = b"".join(bytes([index]) * block_size for index in range(full_parts)) + b"tail"
|
|
387
|
+
path = Path(directory) / "sample.bin"
|
|
388
|
+
path.write_bytes(data)
|
|
389
|
+
progress = []
|
|
390
|
+
saved = {}
|
|
391
|
+
first_window = asyncio.Event()
|
|
392
|
+
fifth_started = asyncio.Event()
|
|
393
|
+
active = peak = 0
|
|
394
|
+
big = full_parts == 20
|
|
395
|
+
|
|
396
|
+
async def rpc(request):
|
|
397
|
+
nonlocal active, peak
|
|
398
|
+
if isinstance(request, tg.functions.messages.SendMediaRequest):
|
|
399
|
+
handle = request.media.file
|
|
400
|
+
self.assertEqual(len(saved), full_parts + 1)
|
|
401
|
+
self.assertEqual(handle.parts, len(saved))
|
|
402
|
+
self.assertEqual(handle.name, path.name)
|
|
403
|
+
self.assertEqual(b"".join(saved[index].bytes for index in range(handle.parts)), data)
|
|
404
|
+
self.assertEqual({part.file_id for part in saved.values()}, {handle.id})
|
|
405
|
+
if big:
|
|
406
|
+
self.assertIsInstance(handle, tg.types.InputFileBig)
|
|
407
|
+
self.assertTrue(all(part.file_total_parts == handle.parts for part in saved.values()))
|
|
408
|
+
else:
|
|
409
|
+
self.assertEqual(handle.md5_checksum, hashlib.md5(data).hexdigest())
|
|
410
|
+
return object()
|
|
411
|
+
|
|
412
|
+
method = tg.functions.upload.SaveBigFilePartRequest if big else tg.functions.upload.SaveFilePartRequest
|
|
413
|
+
self.assertIsInstance(request, method)
|
|
414
|
+
index = request.file_part
|
|
415
|
+
active += 1
|
|
416
|
+
peak = max(peak, active)
|
|
417
|
+
if active == 4:
|
|
418
|
+
first_window.set()
|
|
419
|
+
if index == 4:
|
|
420
|
+
fifth_started.set()
|
|
421
|
+
await first_window.wait()
|
|
422
|
+
if index == 0:
|
|
423
|
+
await fifth_started.wait()
|
|
424
|
+
saved[index] = request
|
|
425
|
+
active -= 1
|
|
426
|
+
return True
|
|
427
|
+
|
|
428
|
+
with path.open("rb") as stream, patch.object(tg.UploadClient, "__call__", side_effect=rpc), \
|
|
429
|
+
patch.object(client, "get_input_entity", AsyncMock(return_value=peer)), \
|
|
430
|
+
patch.object(client, "_get_response_message", return_value=fake_message(43)):
|
|
431
|
+
message = await asyncio.wait_for(client.send_file(
|
|
432
|
+
peer, stream, force_document=True, progress_callback=lambda sent, total: progress.append((sent, total))
|
|
433
|
+
), timeout=5)
|
|
434
|
+
self.assertEqual(message.id, 43)
|
|
435
|
+
self.assertEqual(peak, 4)
|
|
436
|
+
self.assertEqual(progress[-1], (len(data), len(data)))
|
|
437
|
+
self.assertEqual(progress, sorted(set(progress)))
|
|
438
|
+
|
|
439
|
+
async def test_upload_failure_cancels_pending_parts_without_sending_media(self):
|
|
440
|
+
client = tg.UploadClient(None, 12345, "synthetic")
|
|
441
|
+
peer = tg.types.InputPeerSelf()
|
|
442
|
+
ready = asyncio.Event()
|
|
443
|
+
started = []
|
|
444
|
+
cancelled = []
|
|
445
|
+
with TemporaryDirectory() as directory:
|
|
446
|
+
path = Path(directory) / "sample.bin"
|
|
447
|
+
path.write_bytes(b"x" * (5 * 512 * 1024))
|
|
448
|
+
|
|
449
|
+
async def rpc(request):
|
|
450
|
+
self.assertIsInstance(request, tg.functions.upload.SaveFilePartRequest)
|
|
451
|
+
index = request.file_part
|
|
452
|
+
started.append(index)
|
|
453
|
+
if len(started) == 4:
|
|
454
|
+
ready.set()
|
|
455
|
+
if index == 0:
|
|
456
|
+
await ready.wait()
|
|
457
|
+
raise tg.FloodWaitError(request, capture=15)
|
|
458
|
+
try:
|
|
459
|
+
await asyncio.Future()
|
|
460
|
+
finally:
|
|
461
|
+
cancelled.append(index)
|
|
462
|
+
|
|
463
|
+
with path.open("rb") as stream, patch.object(tg.UploadClient, "__call__", side_effect=rpc), \
|
|
464
|
+
patch.object(client, "get_input_entity", AsyncMock(return_value=peer)):
|
|
465
|
+
with self.assertRaises(tg.FloodWaitError) as error:
|
|
466
|
+
await asyncio.wait_for(client.send_file(peer, stream, force_document=True), timeout=5)
|
|
467
|
+
self.assertEqual(error.exception.seconds, 15)
|
|
468
|
+
self.assertEqual(started, [0, 1, 2, 3])
|
|
469
|
+
self.assertCountEqual(cancelled, [1, 2, 3])
|
|
470
|
+
|
|
379
471
|
async def test_batch_limits_and_preflight_before_writes(self):
|
|
380
472
|
with TemporaryDirectory() as directory:
|
|
381
473
|
client = FakeClient()
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import unittest
|
|
2
3
|
import json
|
|
3
4
|
import os
|
|
@@ -96,7 +97,7 @@ class SkillsTests(unittest.TestCase):
|
|
|
96
97
|
def test_install_both_is_offline_and_idempotent(self):
|
|
97
98
|
with patch.dict(os.environ, {}, clear=True), patch("sys.argv", ["tcli", "skills", "install"]), \
|
|
98
99
|
patch("tg.sys.stdout.reconfigure"), patch("tg.sys.stderr.reconfigure"), \
|
|
99
|
-
patch("tg.
|
|
100
|
+
patch("tg.UploadClient") as client, patch("tg.agent_installed", return_value=True):
|
|
100
101
|
self.assertEqual(tg.main(), 0)
|
|
101
102
|
client.assert_not_called()
|
|
102
103
|
tg.install_skills("all")
|
|
@@ -354,7 +355,7 @@ class ConfigTests(unittest.TestCase):
|
|
|
354
355
|
patch("tg.sys.stdout.reconfigure"), patch("tg.sys.stderr.reconfigure"), \
|
|
355
356
|
patch("builtins.input", return_value="12345"), \
|
|
356
357
|
patch("tg.getpass", return_value="a" * 32), \
|
|
357
|
-
patch("builtins.print"), patch("tg.
|
|
358
|
+
patch("builtins.print"), patch("tg.UploadClient") as client:
|
|
358
359
|
self.assertEqual(tg.main(), 0)
|
|
359
360
|
client.assert_not_called()
|
|
360
361
|
self.assertEqual(tg.load_credentials(self.path), (12345, "a" * 32))
|
|
@@ -390,11 +391,18 @@ class FakeClient:
|
|
|
390
391
|
self.download_media = AsyncMock()
|
|
391
392
|
self.get_messages = AsyncMock(return_value=[])
|
|
392
393
|
self.start = AsyncMock()
|
|
394
|
+
self.api_id, self.api_hash = 12345, "synthetic_hash"
|
|
395
|
+
self.request = AsyncMock()
|
|
396
|
+
self.sign_in = AsyncMock()
|
|
397
|
+
self.qr_login = AsyncMock()
|
|
393
398
|
self.get_me = AsyncMock(return_value=SimpleNamespace(bot=False, first_name="User", id=1))
|
|
394
399
|
self.log_out = AsyncMock()
|
|
395
400
|
self.is_connected = Mock(return_value=False)
|
|
396
401
|
self.session = SimpleNamespace(save=Mock())
|
|
397
402
|
|
|
403
|
+
async def __call__(self, request):
|
|
404
|
+
return await self.request(request)
|
|
405
|
+
|
|
398
406
|
async def iter_dialogs(self, **kwargs):
|
|
399
407
|
for dialog in self.dialogs:
|
|
400
408
|
yield dialog
|
|
@@ -610,7 +618,7 @@ class CliTests(unittest.IsolatedAsyncioTestCase):
|
|
|
610
618
|
client.is_user_authorized.return_value = False
|
|
611
619
|
with patch("builtins.input", return_value="invalid"), self.assertRaisesRegex(ValueError, "international"):
|
|
612
620
|
await tg.run(client, tg.parser().parse_args(["login"]))
|
|
613
|
-
client.
|
|
621
|
+
client.request.assert_not_awaited()
|
|
614
622
|
client.disconnect.assert_awaited_once()
|
|
615
623
|
|
|
616
624
|
async def test_new_user_and_bot_login(self):
|
|
@@ -618,16 +626,64 @@ class CliTests(unittest.IsolatedAsyncioTestCase):
|
|
|
618
626
|
client = FakeClient()
|
|
619
627
|
client.is_user_authorized.return_value = False
|
|
620
628
|
client.get_me.return_value.bot = bot
|
|
629
|
+
client.request.return_value = tg.types.auth.SentCode(
|
|
630
|
+
tg.types.auth.SentCodeTypeApp(5), "synthetic_code_hash")
|
|
631
|
+
secret = "123:synthetic_token" if bot else "12345"
|
|
632
|
+
errors = io.StringIO()
|
|
621
633
|
with patch("builtins.input", return_value="+15551234567"), \
|
|
622
|
-
patch("tg.getpass", return_value=
|
|
623
|
-
|
|
634
|
+
patch("tg.getpass", return_value=secret), patch.dict(os.environ, {}, clear=True), \
|
|
635
|
+
redirect_stdout(io.StringIO()), redirect_stderr(errors):
|
|
624
636
|
await tg.run(client, tg.parser().parse_args(["login", *(["--bot"] if bot else [])]))
|
|
625
637
|
if bot:
|
|
626
638
|
client.start.assert_awaited_once_with(bot_token="123:synthetic_token")
|
|
627
639
|
else:
|
|
628
|
-
|
|
640
|
+
client.request.assert_awaited_once()
|
|
641
|
+
self.assertEqual(client.request.await_args.args[0].phone_number, "+15551234567")
|
|
642
|
+
client.sign_in.assert_awaited_once_with("+15551234567", code="12345", phone_code_hash="synthetic_code_hash")
|
|
643
|
+
self.assertIn("service chat named Telegram", errors.getvalue())
|
|
629
644
|
client.disconnect.assert_awaited_once()
|
|
630
645
|
|
|
646
|
+
async def test_phone_login_does_not_prompt_for_firebase_sms(self):
|
|
647
|
+
client = FakeClient()
|
|
648
|
+
client.is_user_authorized.return_value = False
|
|
649
|
+
client.request.return_value = tg.types.auth.SentCode(
|
|
650
|
+
tg.types.auth.SentCodeTypeFirebaseSms(5), "synthetic_code_hash")
|
|
651
|
+
with patch("builtins.input", return_value="+15551234567"), patch("tg.getpass") as prompt:
|
|
652
|
+
with self.assertRaisesRegex(tg.CommandError, r"FirebaseSms.*login --qr"):
|
|
653
|
+
await tg.run(client, tg.parser().parse_args(["login"]))
|
|
654
|
+
prompt.assert_not_called()
|
|
655
|
+
|
|
656
|
+
async def test_email_login_submits_email_verification_code(self):
|
|
657
|
+
client = FakeClient()
|
|
658
|
+
client.is_user_authorized.return_value = False
|
|
659
|
+
client.request.side_effect = [
|
|
660
|
+
tg.types.auth.SentCode(tg.types.auth.SentCodeTypeEmailCode("a***@example.com", 5), "synthetic_hash"),
|
|
661
|
+
Mock(),
|
|
662
|
+
]
|
|
663
|
+
with patch("builtins.input", return_value="+15551234567"), patch("tg.getpass", return_value="12345"), \
|
|
664
|
+
redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()) as stderr:
|
|
665
|
+
await tg.run(client, tg.parser().parse_args(["login"]))
|
|
666
|
+
self.assertIn("a***@example.com", stderr.getvalue())
|
|
667
|
+
sign_in = client.request.await_args.args[0]
|
|
668
|
+
self.assertEqual(sign_in.email_verification.code, "12345")
|
|
669
|
+
self.assertEqual(sign_in.phone_code_hash, "synthetic_hash")
|
|
670
|
+
|
|
671
|
+
async def test_qr_login_refreshes_expired_token_and_accepts_two_factor_password(self):
|
|
672
|
+
client = FakeClient()
|
|
673
|
+
client.is_user_authorized.return_value = False
|
|
674
|
+
qr = SimpleNamespace(url="tg://login?token=synthetic", recreate=AsyncMock(),
|
|
675
|
+
wait=AsyncMock(side_effect=[asyncio.TimeoutError(),
|
|
676
|
+
tg.errors.SessionPasswordNeededError(None)]))
|
|
677
|
+
client.qr_login.return_value = qr
|
|
678
|
+
with patch("tg.sys.stdout.isatty", return_value=True), patch("tg.QRCode") as image, \
|
|
679
|
+
patch("tg.getpass", return_value="synthetic_password"), patch("builtins.print"):
|
|
680
|
+
await tg.run(client, tg.parser().parse_args(["login", "--qr"]))
|
|
681
|
+
qr.recreate.assert_awaited_once()
|
|
682
|
+
self.assertEqual(qr.wait.await_count, 2)
|
|
683
|
+
self.assertEqual(image.return_value.print_ascii.call_count, 2)
|
|
684
|
+
client.sign_in.assert_awaited_once_with(password="synthetic_password")
|
|
685
|
+
client.disconnect.assert_awaited_once()
|
|
686
|
+
|
|
631
687
|
async def test_bot_watch_uses_events_without_history_and_removes_handlers(self):
|
|
632
688
|
client = FakeClient()
|
|
633
689
|
client.is_bot.return_value = True
|
|
@@ -659,7 +715,7 @@ class MainTests(unittest.TestCase):
|
|
|
659
715
|
self.output.reconfigure = lambda **kwargs: None
|
|
660
716
|
self.errors.reconfigure = lambda **kwargs: None
|
|
661
717
|
self.client = FakeClient()
|
|
662
|
-
self.factory = self.stack.enter_context(patch("tg.
|
|
718
|
+
self.factory = self.stack.enter_context(patch("tg.UploadClient", return_value=self.client))
|
|
663
719
|
|
|
664
720
|
def main(self, *args):
|
|
665
721
|
with patch("sys.argv", ["tcli", *args]):
|
|
@@ -8,6 +8,9 @@ from contextlib import ExitStack
|
|
|
8
8
|
from fnmatch import fnmatchcase
|
|
9
9
|
from getpass import getpass
|
|
10
10
|
from importlib.resources import files
|
|
11
|
+
import inspect
|
|
12
|
+
import io
|
|
13
|
+
from itertools import islice
|
|
11
14
|
import json
|
|
12
15
|
import os
|
|
13
16
|
from pathlib import Path
|
|
@@ -23,9 +26,10 @@ from http.client import HTTPException
|
|
|
23
26
|
from urllib.request import Request, urlopen
|
|
24
27
|
from urllib.parse import urlsplit
|
|
25
28
|
|
|
26
|
-
from
|
|
29
|
+
from qrcode import QRCode
|
|
30
|
+
from telethon import TelegramClient, errors, events, helpers, utils
|
|
27
31
|
from telethon.errors import FloodWaitError, RPCError
|
|
28
|
-
from telethon.tl import types, functions
|
|
32
|
+
from telethon.tl import custom, types, functions
|
|
29
33
|
from tcli_skills import __version__
|
|
30
34
|
|
|
31
35
|
|
|
@@ -41,6 +45,68 @@ class CommandError(ValueError):
|
|
|
41
45
|
self.error_code = code
|
|
42
46
|
|
|
43
47
|
|
|
48
|
+
class UploadClient(TelegramClient):
|
|
49
|
+
async def upload_file(self, file, *, part_size_kb=None, file_size=None, file_name=None,
|
|
50
|
+
use_cache=None, key=None, iv=None, progress_callback=None):
|
|
51
|
+
part_size = 512 * 1024
|
|
52
|
+
# shortcut: pipeline seekable CLI attachments, extend when another input type needs parallel upload.
|
|
53
|
+
if (isinstance(file, (io.BufferedReader, io.BytesIO)) and isinstance(getattr(file, "name", None), str)
|
|
54
|
+
and file.seekable() and file.tell() == 0 and file_size is None and part_size_kb is None
|
|
55
|
+
and file_name is None and key is None and iv is None):
|
|
56
|
+
file_size = file.seek(0, os.SEEK_END)
|
|
57
|
+
file.seek(0)
|
|
58
|
+
if file_size > part_size:
|
|
59
|
+
return await self.upload_parts(file, file_size, part_size, progress_callback)
|
|
60
|
+
return await super().upload_file(file, part_size_kb=part_size_kb, file_size=file_size,
|
|
61
|
+
file_name=file_name, use_cache=use_cache, key=key, iv=iv,
|
|
62
|
+
progress_callback=progress_callback)
|
|
63
|
+
|
|
64
|
+
async def upload_parts(self, file, size, part_size, progress_callback):
|
|
65
|
+
file_id = helpers.generate_random_long()
|
|
66
|
+
part_count = (size + part_size - 1) // part_size
|
|
67
|
+
big = size > 10 * 1024 * 1024
|
|
68
|
+
digest = None if big else hashlib.md5()
|
|
69
|
+
|
|
70
|
+
async def save_part(index, data):
|
|
71
|
+
if big:
|
|
72
|
+
request = functions.upload.SaveBigFilePartRequest(file_id, index, part_count, data)
|
|
73
|
+
else:
|
|
74
|
+
request = functions.upload.SaveFilePartRequest(file_id, index, data)
|
|
75
|
+
if not await self(request):
|
|
76
|
+
raise CommandError("upload_failed", f"Telegram rejected upload block {index + 1} of {part_count}.")
|
|
77
|
+
return len(data)
|
|
78
|
+
|
|
79
|
+
parts = iter(range(part_count))
|
|
80
|
+
pending = set()
|
|
81
|
+
sent = 0
|
|
82
|
+
try:
|
|
83
|
+
while sent < size:
|
|
84
|
+
for index in islice(parts, 4 - len(pending)):
|
|
85
|
+
length = min(part_size, size - index * part_size)
|
|
86
|
+
data = file.read(length)
|
|
87
|
+
if len(data) != length or (index == part_count - 1 and file.read(1)):
|
|
88
|
+
raise CommandError("file_changed", "The attachment size changed during upload.")
|
|
89
|
+
if digest is not None:
|
|
90
|
+
digest.update(data)
|
|
91
|
+
pending.add(asyncio.create_task(save_part(index, data)))
|
|
92
|
+
done, _ = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
|
93
|
+
sent += sum(task.result() for task in done)
|
|
94
|
+
pending.difference_update(done)
|
|
95
|
+
if progress_callback is not None:
|
|
96
|
+
result = progress_callback(sent, size)
|
|
97
|
+
if inspect.isawaitable(result):
|
|
98
|
+
await result
|
|
99
|
+
finally:
|
|
100
|
+
for task in pending:
|
|
101
|
+
task.cancel()
|
|
102
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
103
|
+
|
|
104
|
+
name = os.path.basename(file.name)
|
|
105
|
+
if big:
|
|
106
|
+
return types.InputFileBig(file_id, part_count, name)
|
|
107
|
+
return custom.InputSizedFile(file_id, part_count, name, md5=digest, size=size)
|
|
108
|
+
|
|
109
|
+
|
|
44
110
|
class CLIParser(argparse.ArgumentParser):
|
|
45
111
|
def error(self, message):
|
|
46
112
|
arguments = sys.argv[1:]
|
|
@@ -126,8 +192,10 @@ Help works without configuration or a Telegram login.''')
|
|
|
126
192
|
commands.add_parser("config", help="Save API credentials locally",
|
|
127
193
|
description="Save or replace API_ID and API_HASH. No Telegram connection required.")
|
|
128
194
|
login = commands.add_parser("login", help="Sign in to a user or bot account",
|
|
129
|
-
description="Sign in with phone/code
|
|
130
|
-
login.
|
|
195
|
+
description="Sign in with phone/code, --qr, or --bot. API_ID/API_HASH are required.")
|
|
196
|
+
login_modes = login.add_mutually_exclusive_group()
|
|
197
|
+
login_modes.add_argument("--bot", action="store_true", help="sign in with a hidden bot token or TG_BOT_TOKEN")
|
|
198
|
+
login_modes.add_argument("--qr", action="store_true", help="scan a terminal QR code from an already signed-in Telegram mobile app")
|
|
131
199
|
sessions = commands.add_parser("sessions", help="List local session files offline; does not check authorization")
|
|
132
200
|
sessions.add_argument("--json", action="store_true", help="output session names and paths as JSON")
|
|
133
201
|
commands.add_parser("logout", help="Revoke this CLI session",
|
|
@@ -834,6 +902,79 @@ async def resolve_chat(client, value, bot=False):
|
|
|
834
902
|
return matches[0]
|
|
835
903
|
|
|
836
904
|
|
|
905
|
+
async def login_user_by_phone(client):
|
|
906
|
+
phone = input("Phone number with country code: ").strip()
|
|
907
|
+
if not re.fullmatch(r"\+[0-9]{7,15}", phone):
|
|
908
|
+
raise ValueError("Enter an international phone number, for example +15551234567.")
|
|
909
|
+
request = functions.auth.SendCodeRequest(phone, client.api_id, client.api_hash, types.CodeSettings())
|
|
910
|
+
for attempt in range(3):
|
|
911
|
+
try:
|
|
912
|
+
sent = await client(request)
|
|
913
|
+
break
|
|
914
|
+
except errors.AuthRestartError:
|
|
915
|
+
if attempt == 2:
|
|
916
|
+
raise
|
|
917
|
+
|
|
918
|
+
if isinstance(sent, types.auth.SentCodePaymentRequired):
|
|
919
|
+
raise CommandError("login_code_unavailable", "Telegram requires payment for code delivery. Try tcli login --qr.")
|
|
920
|
+
if isinstance(sent, types.auth.SentCodeSuccess):
|
|
921
|
+
return
|
|
922
|
+
delivery = sent.type
|
|
923
|
+
if isinstance(delivery, types.auth.SentCodeTypeApp):
|
|
924
|
+
destination = "the service chat named Telegram on an already signed-in device"
|
|
925
|
+
elif isinstance(delivery, (types.auth.SentCodeTypeSms, types.auth.SentCodeTypeSmsWord,
|
|
926
|
+
types.auth.SentCodeTypeSmsPhrase)):
|
|
927
|
+
destination = "SMS"
|
|
928
|
+
elif isinstance(delivery, types.auth.SentCodeTypeCall):
|
|
929
|
+
destination = "a phone call"
|
|
930
|
+
elif isinstance(delivery, types.auth.SentCodeTypeEmailCode):
|
|
931
|
+
destination = f"email ({safe_text(delivery.email_pattern)})"
|
|
932
|
+
elif isinstance(delivery, types.auth.SentCodeTypeFragmentSms):
|
|
933
|
+
destination = f"Fragment ({safe_text(delivery.url)})"
|
|
934
|
+
else:
|
|
935
|
+
channel = type(delivery).__name__.removeprefix("SentCodeType")
|
|
936
|
+
raise CommandError("login_code_unavailable",
|
|
937
|
+
f"Telegram requires {channel} verification. Try tcli login --qr.")
|
|
938
|
+
print(f"Telegram sent the login code via {destination}. If it doesn't arrive, try tcli login --qr.",
|
|
939
|
+
file=sys.stderr, flush=True)
|
|
940
|
+
|
|
941
|
+
for attempt in range(3):
|
|
942
|
+
code = getpass("Telegram login code: ").strip()
|
|
943
|
+
if code:
|
|
944
|
+
try:
|
|
945
|
+
if isinstance(delivery, types.auth.SentCodeTypeEmailCode):
|
|
946
|
+
result = await client(functions.auth.SignInRequest(
|
|
947
|
+
phone, sent.phone_code_hash, email_verification=types.EmailVerificationCode(code)))
|
|
948
|
+
if isinstance(result, types.auth.AuthorizationSignUpRequired):
|
|
949
|
+
raise CommandError("login_signup_required", "Create the account in the Telegram app first.")
|
|
950
|
+
else:
|
|
951
|
+
await client.sign_in(phone, code=code, phone_code_hash=sent.phone_code_hash)
|
|
952
|
+
return
|
|
953
|
+
except (errors.PhoneCodeEmptyError, errors.PhoneCodeInvalidError, errors.CodeInvalidError):
|
|
954
|
+
pass
|
|
955
|
+
except (errors.PhoneCodeExpiredError, errors.PhoneCodeHashEmptyError) as error:
|
|
956
|
+
raise CommandError("login_code_expired", "Request a new code with tcli login or use tcli login --qr.") from error
|
|
957
|
+
if attempt < 2:
|
|
958
|
+
print("Invalid code. Please try again.", file=sys.stderr)
|
|
959
|
+
raise CommandError("login_code_invalid", "Three invalid code entries. Run tcli login again or use tcli login --qr.")
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
async def login_user_by_qr(client):
|
|
963
|
+
if not sys.stdout.isatty():
|
|
964
|
+
raise CommandError("terminal_required", "Run tcli login --qr directly in an interactive terminal.")
|
|
965
|
+
qr = await client.qr_login()
|
|
966
|
+
while True:
|
|
967
|
+
print("On your phone, open Telegram > Settings > Devices > Link Desktop Device and scan:")
|
|
968
|
+
image = QRCode()
|
|
969
|
+
image.add_data(qr.url)
|
|
970
|
+
image.print_ascii(tty=True)
|
|
971
|
+
try:
|
|
972
|
+
await qr.wait()
|
|
973
|
+
return
|
|
974
|
+
except asyncio.TimeoutError:
|
|
975
|
+
await qr.recreate()
|
|
976
|
+
|
|
977
|
+
|
|
837
978
|
async def run(client, args):
|
|
838
979
|
try:
|
|
839
980
|
await client.connect()
|
|
@@ -848,13 +989,27 @@ async def run(client, args):
|
|
|
848
989
|
raise ValueError("Invalid bot token format. Obtain a token from BotFather.")
|
|
849
990
|
await client.start(bot_token=token)
|
|
850
991
|
else:
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
992
|
+
try:
|
|
993
|
+
if args.qr:
|
|
994
|
+
await login_user_by_qr(client)
|
|
995
|
+
else:
|
|
996
|
+
await login_user_by_phone(client)
|
|
997
|
+
except errors.SessionPasswordNeededError:
|
|
998
|
+
for attempt in range(3):
|
|
999
|
+
password = getpass("Two-factor authentication password: ")
|
|
1000
|
+
if password:
|
|
1001
|
+
try:
|
|
1002
|
+
await client.sign_in(password=password)
|
|
1003
|
+
break
|
|
1004
|
+
except errors.PasswordHashInvalidError:
|
|
1005
|
+
if attempt == 2:
|
|
1006
|
+
raise
|
|
1007
|
+
elif attempt == 2:
|
|
1008
|
+
raise ValueError("A two-factor authentication password is required.")
|
|
1009
|
+
print("Invalid password. Please try again.", file=sys.stderr)
|
|
857
1010
|
me = await client.get_me()
|
|
1011
|
+
if me is None:
|
|
1012
|
+
raise CommandError("unauthorized", "Telegram did not authorize this session. Try tcli login again.")
|
|
858
1013
|
if me.bot != args.bot:
|
|
859
1014
|
raise CommandError("session_type_mismatch", "Account type does not match login mode; choose another --session.")
|
|
860
1015
|
print(f"Signed in as {'bot' if me.bot else 'user'} {safe_text(me.first_name)} (ID {me.id}).")
|
|
@@ -1336,8 +1491,8 @@ def main():
|
|
|
1336
1491
|
print("Use tcli doctor --connect to check Telegram connectivity and authorization.")
|
|
1337
1492
|
return 0
|
|
1338
1493
|
session_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
1339
|
-
client =
|
|
1340
|
-
|
|
1494
|
+
client = UploadClient(str(session_dir / "account"), api_id, api_hash,
|
|
1495
|
+
flood_sleep_threshold=0)
|
|
1341
1496
|
asyncio.run(run(client, args))
|
|
1342
1497
|
except KeyboardInterrupt:
|
|
1343
1498
|
if getattr(args, "json", False):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|