pytgcli 0.11.1__tar.gz → 0.12.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 (38) hide show
  1. pytgcli-0.12.0/.github/CODEOWNERS +1 -0
  2. pytgcli-0.12.0/.github/ISSUE_TEMPLATE/bug_report.yml +86 -0
  3. pytgcli-0.12.0/.github/ISSUE_TEMPLATE/config.yml +1 -0
  4. pytgcli-0.12.0/.github/ISSUE_TEMPLATE/feature_request.yml +35 -0
  5. {pytgcli-0.11.1 → pytgcli-0.12.0}/.github/workflows/ci.yml +2 -2
  6. {pytgcli-0.11.1 → pytgcli-0.12.0}/.github/workflows/publish.yml +2 -2
  7. {pytgcli-0.11.1 → pytgcli-0.12.0}/PKG-INFO +13 -2
  8. {pytgcli-0.11.1 → pytgcli-0.12.0}/README.md +11 -1
  9. {pytgcli-0.11.1 → pytgcli-0.12.0}/pyproject.toml +2 -1
  10. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/auth.py +64 -0
  11. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/cli.py +85 -5
  12. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/client.py +4 -0
  13. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/formatting.py +26 -0
  14. pytgcli-0.12.0/tests/test_auth.py +308 -0
  15. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/test_cli.py +92 -3
  16. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/test_client.py +10 -2
  17. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/test_formatting.py +45 -0
  18. {pytgcli-0.11.1 → pytgcli-0.12.0}/uv.lock +22 -8
  19. pytgcli-0.11.1/tests/test_auth.py +0 -161
  20. {pytgcli-0.11.1 → pytgcli-0.12.0}/.github/dependabot.yml +0 -0
  21. {pytgcli-0.11.1 → pytgcli-0.12.0}/.gitignore +0 -0
  22. {pytgcli-0.11.1 → pytgcli-0.12.0}/.pre-commit-config.yaml +0 -0
  23. {pytgcli-0.11.1 → pytgcli-0.12.0}/.python-version +0 -0
  24. {pytgcli-0.11.1 → pytgcli-0.12.0}/AGENTS.md +0 -0
  25. {pytgcli-0.11.1 → pytgcli-0.12.0}/CLAUDE.md +0 -0
  26. {pytgcli-0.11.1 → pytgcli-0.12.0}/LICENSE +0 -0
  27. {pytgcli-0.11.1 → pytgcli-0.12.0}/install.sh +0 -0
  28. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/__init__.py +0 -0
  29. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/config.py +0 -0
  30. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/media.py +0 -0
  31. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/session.py +0 -0
  32. {pytgcli-0.11.1 → pytgcli-0.12.0}/src/tgcli/update.py +0 -0
  33. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/__init__.py +0 -0
  34. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/conftest.py +0 -0
  35. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/test_config.py +0 -0
  36. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/test_media.py +0 -0
  37. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/test_session.py +0 -0
  38. {pytgcli-0.11.1 → pytgcli-0.12.0}/tests/test_update.py +0 -0
@@ -0,0 +1 @@
1
+ * @tksohishi
@@ -0,0 +1,86 @@
1
+ name: Bug report
2
+ description: Something in tgcli doesn't work as expected
3
+ labels: [bug]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for reporting! If tgcli is useful to you, a ⭐ on the repo helps other people find it.
9
+
10
+ tgcli doesn't accept pull requests. A precise report is the fastest way to a fix.
11
+ - type: textarea
12
+ id: problem
13
+ attributes:
14
+ label: What happened
15
+ description: What you ran, what you expected, and what happened instead.
16
+ placeholder: |
17
+ I ran `tg auth login`, entered my phone number, and the verification code never arrived.
18
+ validations:
19
+ required: true
20
+ - type: textarea
21
+ id: reproduce
22
+ attributes:
23
+ label: Steps to reproduce
24
+ description: The exact commands, in order. Replace chat names and IDs with placeholders if they are private.
25
+ render: shell
26
+ validations:
27
+ required: true
28
+ - type: textarea
29
+ id: output
30
+ attributes:
31
+ label: Output
32
+ description: The full output, including errors on stderr. Leave out QR codes (as text or screenshots) and tg://login URLs, and remove phone numbers, session strings, API hashes, verification codes, passwords and message contents.
33
+ render: text
34
+ - type: dropdown
35
+ id: impact
36
+ attributes:
37
+ label: Impact
38
+ options:
39
+ - Blocks me completely (can't log in or use tgcli at all)
40
+ - One command or feature is broken, others work
41
+ - Wrong or confusing output, with a workaround
42
+ - Minor annoyance
43
+ validations:
44
+ required: true
45
+ - type: input
46
+ id: version
47
+ attributes:
48
+ label: tgcli version
49
+ description: Output of `tg --version`
50
+ validations:
51
+ required: true
52
+ - type: dropdown
53
+ id: install
54
+ attributes:
55
+ label: Install method
56
+ options:
57
+ - uv tool install
58
+ - Homebrew
59
+ - pip / pipx
60
+ - From source
61
+ validations:
62
+ required: true
63
+ - type: input
64
+ id: os
65
+ attributes:
66
+ label: OS and Python version
67
+ placeholder: macOS 26.1, Python 3.13
68
+ validations:
69
+ required: true
70
+ - type: dropdown
71
+ id: host
72
+ attributes:
73
+ label: Where tgcli runs
74
+ description: Telegram treats datacenter IPs differently from home connections.
75
+ options:
76
+ - Personal computer on a home or office network
77
+ - VPS or cloud server
78
+ - CI or container
79
+ - Other
80
+ validations:
81
+ required: true
82
+ - type: textarea
83
+ id: context
84
+ attributes:
85
+ label: Anything else
86
+ description: Session store (file or keychain), whether 2FA is on, proxies or VPNs, and anything you already tried.
@@ -0,0 +1 @@
1
+ blank_issues_enabled: false
@@ -0,0 +1,35 @@
1
+ name: Feature request
2
+ description: Suggest something tgcli should do
3
+ labels: [enhancement]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for the idea! If tgcli is useful to you, a ⭐ on the repo helps other people find it.
9
+ - type: textarea
10
+ id: problem
11
+ attributes:
12
+ label: Problem
13
+ description: What you are trying to do and why tgcli can't do it today. Describe the need, not only the solution.
14
+ validations:
15
+ required: true
16
+ - type: textarea
17
+ id: proposal
18
+ attributes:
19
+ label: Proposed behavior
20
+ description: The command, flag, or output you have in mind, if any.
21
+ - type: dropdown
22
+ id: importance
23
+ attributes:
24
+ label: How much it matters
25
+ options:
26
+ - I can't use tgcli for my use case without it
27
+ - It would save me real time
28
+ - Nice to have
29
+ validations:
30
+ required: true
31
+ - type: textarea
32
+ id: context
33
+ attributes:
34
+ label: Context
35
+ description: How you use tgcli (by hand, from an AI agent, in scripts), and any workaround you use now.
@@ -10,7 +10,7 @@ jobs:
10
10
  runs-on: ubuntu-latest
11
11
  steps:
12
12
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
13
- - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
13
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
14
14
  - run: uv sync --group dev
15
15
  - run: uv run ruff check
16
16
  - run: uv run ruff format --check
@@ -22,7 +22,7 @@ jobs:
22
22
  python-version: ["3.12", "3.14"]
23
23
  steps:
24
24
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
25
- - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
25
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
26
26
  with:
27
27
  python-version: ${{ matrix.python-version }}
28
28
  - run: uv sync --group dev
@@ -33,7 +33,7 @@ jobs:
33
33
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
34
34
  with:
35
35
  ref: ${{ github.ref_name }}
36
- - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
36
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
37
37
  - run: uv build
38
38
  - uses: actions/upload-artifact@v7
39
39
  with:
@@ -76,7 +76,7 @@ jobs:
76
76
  with:
77
77
  name: dist
78
78
  path: dist
79
- - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
79
+ - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
80
80
  - run: uv publish --trusted-publishing always dist/*
81
81
 
82
82
  update-homebrew-tap:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytgcli
3
- Version: 0.11.1
3
+ Version: 0.12.0
4
4
  Summary: CLI tool to read Telegram messages from the terminal
5
5
  Project-URL: Repository, https://github.com/tksohishi/tgcli
6
6
  Author: Takeshi
@@ -17,6 +17,7 @@ Classifier: Topic :: Communications :: Chat
17
17
  Classifier: Typing :: Typed
18
18
  Requires-Python: >=3.12
19
19
  Requires-Dist: keyring>=25
20
+ Requires-Dist: qrcode>=8
20
21
  Requires-Dist: rich>=13
21
22
  Requires-Dist: telethon>=1.42
22
23
  Requires-Dist: typer>=0.15
@@ -32,6 +33,7 @@ Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram
32
33
  - **Minimal surface** — a handful of commands; easy for agents to discover and invoke
33
34
  - **Chat name resolution**: exact display names take priority; a unique substring also matches
34
35
  - **`--pretty` for humans** — Rich tables when you want to read output yourself
36
+ - **QR login**: authorize by scanning a code from a device that is already logged in, for when Telegram never delivers a verification code
35
37
  - **Agent-friendly session storage** — Telethon session key stored in a local `0600` file by default; keychain remains available as a legacy option
36
38
 
37
39
  ## Installation
@@ -74,7 +76,7 @@ Create a Telegram API app at [my.telegram.org/apps](https://my.telegram.org/apps
74
76
  tg auth
75
77
  ```
76
78
 
77
- This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs in with phone number + verification code. The Telegram session is stored at `~/.config/tgcli/session` by default.
79
+ This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs you in by QR code (scanned from a device where you are already logged in) or by phone number + verification code. If the verification code never arrives, which Telegram does for some IP ranges such as VPS hosts, use the QR method. The Telegram session is stored at `~/.config/tgcli/session` by default.
78
80
 
79
81
  ### 3. Read Messages
80
82
 
@@ -132,11 +134,20 @@ Smart entrypoint: creates config if missing, logs in if needed, shows status if
132
134
 
133
135
  Explicit subcommands:
134
136
 
137
+ - `tg auth qr` - log in by scanning a QR code in Telegram > Settings > Devices > Link Desktop Device
135
138
  - `tg auth login` - interactive login (phone + code/2FA)
136
139
  - `tg auth logout` - remove the local session
137
140
  - `tg auth migrate-session` - copy a legacy keychain session to the local session file
138
141
  - `tg auth status` - show auth state
139
142
 
143
+ #### `tg auth qr`
144
+
145
+ Prints a QR code to stderr, plus a `tg://login` URL you can open instead if Telegram Desktop runs on the same machine. The code refreshes whenever it expires, until it is scanned or `--timeout` runs out. Accounts with Two-Step Verification are asked for that password after the scan, with up to 3 tries.
146
+
147
+ | Flag | Description |
148
+ |-------------|-----------------------------------------------------------|
149
+ | `--timeout` | Seconds to wait for a scan before giving up (default 300) |
150
+
140
151
  ### `tg chats`
141
152
 
142
153
  List your Telegram chats. Returns JSONL by default.
@@ -8,6 +8,7 @@ Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram
8
8
  - **Minimal surface** — a handful of commands; easy for agents to discover and invoke
9
9
  - **Chat name resolution**: exact display names take priority; a unique substring also matches
10
10
  - **`--pretty` for humans** — Rich tables when you want to read output yourself
11
+ - **QR login**: authorize by scanning a code from a device that is already logged in, for when Telegram never delivers a verification code
11
12
  - **Agent-friendly session storage** — Telethon session key stored in a local `0600` file by default; keychain remains available as a legacy option
12
13
 
13
14
  ## Installation
@@ -50,7 +51,7 @@ Create a Telegram API app at [my.telegram.org/apps](https://my.telegram.org/apps
50
51
  tg auth
51
52
  ```
52
53
 
53
- This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs in with phone number + verification code. The Telegram session is stored at `~/.config/tgcli/session` by default.
54
+ This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs you in by QR code (scanned from a device where you are already logged in) or by phone number + verification code. If the verification code never arrives, which Telegram does for some IP ranges such as VPS hosts, use the QR method. The Telegram session is stored at `~/.config/tgcli/session` by default.
54
55
 
55
56
  ### 3. Read Messages
56
57
 
@@ -108,11 +109,20 @@ Smart entrypoint: creates config if missing, logs in if needed, shows status if
108
109
 
109
110
  Explicit subcommands:
110
111
 
112
+ - `tg auth qr` - log in by scanning a QR code in Telegram > Settings > Devices > Link Desktop Device
111
113
  - `tg auth login` - interactive login (phone + code/2FA)
112
114
  - `tg auth logout` - remove the local session
113
115
  - `tg auth migrate-session` - copy a legacy keychain session to the local session file
114
116
  - `tg auth status` - show auth state
115
117
 
118
+ #### `tg auth qr`
119
+
120
+ Prints a QR code to stderr, plus a `tg://login` URL you can open instead if Telegram Desktop runs on the same machine. The code refreshes whenever it expires, until it is scanned or `--timeout` runs out. Accounts with Two-Step Verification are asked for that password after the scan, with up to 3 tries.
121
+
122
+ | Flag | Description |
123
+ |-------------|-----------------------------------------------------------|
124
+ | `--timeout` | Seconds to wait for a scan before giving up (default 300) |
125
+
116
126
  ### `tg chats`
117
127
 
118
128
  List your Telegram chats. Returns JSONL by default.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytgcli"
7
- version = "0.11.1"
7
+ version = "0.12.0"
8
8
  description = "CLI tool to read Telegram messages from the terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,6 +26,7 @@ dependencies = [
26
26
  "telethon>=1.42",
27
27
  "keyring>=25",
28
28
  "rich>=13",
29
+ "qrcode>=8",
29
30
  ]
30
31
 
31
32
  [project.urls]
@@ -1,6 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import time
4
+ from collections.abc import Callable
5
+ from datetime import UTC, datetime
6
+
7
+ from telethon.errors import PasswordHashInvalidError, SessionPasswordNeededError
3
8
  from telethon.sessions import StringSession
9
+ from telethon.tl.functions.account import GetPasswordRequest
10
+ from telethon.tl.types.auth import LoginTokenMigrateTo, LoginTokenSuccess
4
11
 
5
12
  from tgcli.client import create_client
6
13
  from tgcli.config import load_session_store
@@ -18,6 +25,63 @@ async def login() -> None:
18
25
  await client.disconnect()
19
26
 
20
27
 
28
+ async def login_qr(
29
+ on_url: Callable[[str], None],
30
+ password: Callable[[str | None], str],
31
+ timeout: float = 300.0,
32
+ ) -> None:
33
+ """QR login approved from an already authorized device. Saves the local session.
34
+
35
+ on_url is called with each tg://login URL, again whenever the token rotates.
36
+ password is called with the account's password hint, if it has one.
37
+ """
38
+ client = create_client()
39
+ try:
40
+ await client.connect()
41
+ if not await client.is_user_authorized():
42
+ await _wait_for_qr_scan(client, on_url, password, timeout)
43
+ save_session(StringSession.save(client.session))
44
+ finally:
45
+ await client.disconnect()
46
+
47
+
48
+ async def _wait_for_qr_scan(client, on_url, password, timeout: float) -> None:
49
+ qr = await client.qr_login()
50
+ deadline = time.monotonic() + timeout
51
+ try:
52
+ while True:
53
+ on_url(qr.url)
54
+ ttl = (qr.expires - datetime.now(UTC)).total_seconds()
55
+ remaining = deadline - time.monotonic()
56
+ try:
57
+ # recreate() returns the same token until it expires: wait out the TTL.
58
+ await qr.wait(timeout=max(1.0, min(ttl, remaining)))
59
+ return
60
+ except TimeoutError:
61
+ if time.monotonic() >= deadline:
62
+ raise TimeoutError(
63
+ f"No device scanned the QR code within {timeout:.0f}s."
64
+ ) from None
65
+ await qr.recreate()
66
+ # A scan that lands after wait() gave up is answered by recreate() instead.
67
+ # QRLogin exposes that answer only as _resp.
68
+ if isinstance(qr._resp, LoginTokenSuccess):
69
+ return
70
+ if isinstance(qr._resp, LoginTokenMigrateTo):
71
+ raise RuntimeError(
72
+ "The code was scanned while it refreshed. Run `tg auth qr` again."
73
+ )
74
+ except SessionPasswordNeededError:
75
+ hint = (await client(GetPasswordRequest())).hint
76
+ for attempts_left in reversed(range(3)):
77
+ try:
78
+ await client.sign_in(password=password(hint))
79
+ return
80
+ except PasswordHashInvalidError:
81
+ if not attempts_left:
82
+ raise
83
+
84
+
21
85
  async def logout() -> None:
22
86
  """Log out and remove the local session.
23
87
 
@@ -11,6 +11,7 @@ from typing import Annotated
11
11
 
12
12
  import typer
13
13
  from rich.console import Console
14
+ from rich.markup import escape
14
15
  from telethon.errors import UnauthorizedError
15
16
 
16
17
 
@@ -35,7 +36,11 @@ app = typer.Typer(
35
36
  )
36
37
  )
37
38
  auth_app = typer.Typer(
38
- help="Manage Telegram authentication.", invoke_without_command=True
39
+ help=(
40
+ "Manage Telegram authentication.\n\n"
41
+ "Use `tg auth qr` when the phone verification code never arrives."
42
+ ),
43
+ invoke_without_command=True,
39
44
  )
40
45
  app.add_typer(auth_app, name="auth")
41
46
 
@@ -142,8 +147,18 @@ def auth_default(ctx: typer.Context) -> None:
142
147
  stdout.print("Run `tg auth logout` to log out.")
143
148
  return
144
149
 
145
- # Step 3: not authenticated, run login
146
- _run_login()
150
+ # Step 3: not authenticated, pick a login method
151
+ stderr.print("Choose a login method:")
152
+ stderr.print(r" \[1] QR code, scanned from a device already logged in")
153
+ stderr.print(r" \[2] Phone number + verification code")
154
+ method = typer.prompt("Method", default="1").strip().lower()
155
+ if method in {"1", "qr"}:
156
+ _run_qr_login()
157
+ elif method in {"2", "phone", "code"}:
158
+ _run_login()
159
+ else:
160
+ stderr.print("[red]Choose 1 or 2.[/red]")
161
+ raise typer.Exit(1)
147
162
 
148
163
 
149
164
  def _run_login() -> None:
@@ -163,9 +178,59 @@ def _run_login() -> None:
163
178
  except Exception as e:
164
179
  stderr.print(f"[red]Login failed:[/red] {e}")
165
180
  raise typer.Exit(1)
181
+ _print_login_success()
182
+
183
+
184
+ def _run_qr_login(timeout: float = 300.0) -> None:
185
+ """Shared QR login flow for both `tg auth` and `tg auth qr`."""
186
+ from getpass import getpass
187
+
188
+ from tgcli.auth import login_qr
189
+ from tgcli.formatting import format_qr
190
+
166
191
  stderr.print(
167
- "\nRegarding the ToS warning above: unofficial API clients are\n"
168
- "under observation by Telegram. Normal interactive use (searching,\n"
192
+ "\nOn a device where you are logged in, open Telegram > Settings >\n"
193
+ "Devices > Link Desktop Device and scan the code below. If Telegram\n"
194
+ "Desktop runs on this machine, you can open the tg://login URL instead.\n"
195
+ )
196
+
197
+ shown = 0
198
+
199
+ def show(url: str) -> None:
200
+ nonlocal shown
201
+ if shown:
202
+ stderr.print("[dim]Code expired. Fresh code:[/dim]")
203
+ shown += 1
204
+ stderr.print(format_qr(url))
205
+ stderr.print(f"[dim]{url}[/dim]")
206
+ stderr.print("[dim]Waiting for a scan. Ctrl-C aborts.[/dim]")
207
+
208
+ asked = 0
209
+
210
+ def password(hint: str | None) -> str:
211
+ nonlocal asked
212
+ if asked:
213
+ stderr.print(f"[red]Wrong password.[/red] {3 - asked} attempt(s) left.")
214
+ elif hint:
215
+ stderr.print(f"Password hint: {escape(hint)}")
216
+ asked += 1
217
+ return getpass("Two-Step Verification password: ")
218
+
219
+ try:
220
+ asyncio.run(login_qr(show, password, timeout=timeout))
221
+ except KeyboardInterrupt:
222
+ stderr.print("\nAborted.")
223
+ raise typer.Exit(1)
224
+ except Exception as e:
225
+ stderr.print(f"[red]Login failed:[/red] {e}")
226
+ raise typer.Exit(1)
227
+ _print_login_success()
228
+
229
+
230
+ def _print_login_success() -> None:
231
+ stderr.print(
232
+ "\nRemember Telegram's ToS: unofficial API clients are under\n"
233
+ "observation by Telegram. Normal interactive use (searching,\n"
169
234
  "reading your own messages) is fine. Avoid bulk scraping, spamming,\n"
170
235
  "or using results for AI/ML model training.\n"
171
236
  "Full terms: https://core.telegram.org/api/terms\n"
@@ -179,6 +244,21 @@ def login() -> None:
179
244
  _run_login()
180
245
 
181
246
 
247
+ @auth_app.command("qr")
248
+ def qr(
249
+ timeout: Annotated[
250
+ float,
251
+ typer.Option(min=1, help="Seconds to wait for a scan before giving up."),
252
+ ] = 300.0,
253
+ ) -> None:
254
+ """Log in by scanning a QR code from a device already logged in.
255
+
256
+ Use this when the phone verification code never arrives, which Telegram
257
+ does for some IP ranges such as VPS hosts.
258
+ """
259
+ _run_qr_login(timeout)
260
+
261
+
182
262
  @auth_app.command()
183
263
  def logout() -> None:
184
264
  """Remove the local session."""
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import platform
5
6
  import re
6
7
  import shutil
7
8
  import sys
@@ -9,6 +10,7 @@ import zipfile
9
10
  from collections.abc import Callable
10
11
  from datetime import datetime
11
12
  from difflib import get_close_matches
13
+ from importlib.metadata import version
12
14
 
13
15
  from telethon import TelegramClient
14
16
  from telethon.errors import UsernameInvalidError
@@ -67,6 +69,8 @@ def create_client(config: TelegramConfig | None = None) -> TelegramClient:
67
69
  StringSession(session_str),
68
70
  config.api_id,
69
71
  config.api_hash,
72
+ device_model=platform.node().removesuffix(".local") or None,
73
+ app_version=version("pytgcli"),
70
74
  )
71
75
 
72
76
 
@@ -167,3 +167,29 @@ def format_auth_status(
167
167
  output.append(f"none ({session_store})\n", style="red")
168
168
 
169
169
  return output
170
+
171
+
172
+ _HALF_BLOCKS = {
173
+ (True, True): "█",
174
+ (True, False): "▀",
175
+ (False, True): "▄",
176
+ (False, False): " ",
177
+ }
178
+
179
+
180
+ def format_qr(data: str) -> Text:
181
+ """Render data as a terminal QR code, two module rows per line."""
182
+ import qrcode
183
+
184
+ qr = qrcode.QRCode(border=4, error_correction=qrcode.constants.ERROR_CORRECT_L)
185
+ qr.add_data(data)
186
+ qr.make(fit=True)
187
+ matrix = qr.get_matrix()
188
+ if len(matrix) % 2:
189
+ matrix.append([False] * len(matrix[0]))
190
+ lines = [
191
+ "".join(_HALF_BLOCKS[(t, b)] for t, b in zip(top, bottom, strict=True))
192
+ for top, bottom in zip(matrix[::2], matrix[1::2], strict=True)
193
+ ]
194
+ # Pinned colors: dark themes would otherwise invert the code, which scanners reject.
195
+ return Text("\n".join(lines), style="black on white")