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.
Files changed (29) hide show
  1. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/.agents/skills/telegram-cli/SKILL.md +3 -1
  2. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/.claude/skills/telegram-cli/SKILL.md +3 -1
  3. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/CHANGELOG.md +6 -0
  4. {telegram_cli_local-0.4.0/telegram_cli_local.egg-info → telegram_cli_local-0.5.0}/PKG-INFO +10 -3
  5. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/README.md +8 -2
  6. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/README.ru.md +8 -2
  7. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/RELEASING.md +2 -2
  8. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/requirements.txt +1 -0
  9. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tcli_skills/SKILL.md +3 -1
  10. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tcli_skills/__init__.py +1 -1
  11. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0/telegram_cli_local.egg-info}/PKG-INFO +10 -3
  12. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/requires.txt +1 -0
  13. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tests/test_features.py +92 -0
  14. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tests/test_tg.py +63 -7
  15. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/tg.py +167 -12
  16. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/AGENTS.md +0 -0
  17. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/CLAUDE.md +0 -0
  18. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/LICENSE +0 -0
  19. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/MANIFEST.in +0 -0
  20. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/pyproject.toml +0 -0
  21. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/scripts/benchmark_crypto.py +0 -0
  22. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/scripts/check_distribution.py +0 -0
  23. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/scripts/check_docs.py +0 -0
  24. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/setup.cfg +0 -0
  25. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/SOURCES.txt +0 -0
  26. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/dependency_links.txt +0 -0
  27. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/entry_points.txt +0 -0
  28. {telegram_cli_local-0.4.0 → telegram_cli_local-0.5.0}/telegram_cli_local.egg-info/top_level.txt +0 -0
  29. {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.4.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 to a user account; add `--bot` for a bot |
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
- Parallel transfers need separate measurement and careful FloodWait handling; they are not enabled.
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 to a user account; add `--bot` for a bot |
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
- Parallel transfers need separate measurement and careful FloodWait handling; they are not enabled.
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` | Вход в аккаунт пользователя; `--bot` для бота |
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
- Параллельные передачи требуют отдельных замеров и обработки FloodWait; они не включены.
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.4.0-py3-none-any.whl
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.4.0`, and publish it.
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.
@@ -1 +1,2 @@
1
1
  Telethon==1.45.0
2
+ qrcode==8.2
@@ -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,3 +1,3 @@
1
1
  """Bundled agent skill installed by tcli skills install."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: telegram-cli-local
3
- Version: 0.4.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 to a user account; add `--bot` for a bot |
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
- Parallel transfers need separate measurement and careful FloodWait handling; they are not enabled.
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,4 +1,5 @@
1
1
  Telethon==1.45.0
2
+ qrcode==8.2
2
3
 
3
4
  [fast]
4
5
  cryptg<0.7,>=0.5.2
@@ -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.TelegramClient") as client, patch("tg.agent_installed", return_value=True):
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.TelegramClient") as client:
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.start.assert_not_awaited()
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="123:synthetic_token"), patch.dict(os.environ, {}, clear=True), \
623
- patch("builtins.print"):
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
- self.assertEqual(client.start.await_args.kwargs["phone"], "+15551234567")
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.TelegramClient", return_value=self.client))
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 telethon import TelegramClient, events, utils
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/2FA, or --bot with a hidden bot token or TG_BOT_TOKEN.")
130
- login.add_argument("--bot", action="store_true", help="sign in with a bot token; API_ID/API_HASH are still required")
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
- phone = input("Phone number with country code: ").strip()
852
- if not re.fullmatch(r"\+\d{7,15}", phone):
853
- raise ValueError("Enter an international phone number, for example +15551234567.")
854
- await client.start(phone=phone,
855
- code_callback=lambda: getpass("Telegram login code: "),
856
- password=lambda: getpass("Two-factor authentication password: "))
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 = TelegramClient(str(session_dir / "account"), api_id, api_hash,
1340
- flood_sleep_threshold=0)
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):