pytgcli 0.11.0__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.
- pytgcli-0.12.0/.github/CODEOWNERS +1 -0
- pytgcli-0.12.0/.github/ISSUE_TEMPLATE/bug_report.yml +86 -0
- pytgcli-0.12.0/.github/ISSUE_TEMPLATE/config.yml +1 -0
- pytgcli-0.12.0/.github/ISSUE_TEMPLATE/feature_request.yml +35 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/.github/workflows/ci.yml +5 -4
- {pytgcli-0.11.0 → pytgcli-0.12.0}/.github/workflows/publish.yml +2 -2
- {pytgcli-0.11.0 → pytgcli-0.12.0}/PKG-INFO +17 -3
- {pytgcli-0.11.0 → pytgcli-0.12.0}/README.md +15 -2
- {pytgcli-0.11.0 → pytgcli-0.12.0}/pyproject.toml +2 -1
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/auth.py +64 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/cli.py +129 -9
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/client.py +22 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/formatting.py +26 -0
- pytgcli-0.12.0/tests/test_auth.py +308 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/tests/test_cli.py +131 -3
- {pytgcli-0.11.0 → pytgcli-0.12.0}/tests/test_client.py +81 -3
- {pytgcli-0.11.0 → pytgcli-0.12.0}/tests/test_formatting.py +45 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/uv.lock +22 -8
- pytgcli-0.11.0/tests/test_auth.py +0 -161
- {pytgcli-0.11.0 → pytgcli-0.12.0}/.github/dependabot.yml +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/.gitignore +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/.pre-commit-config.yaml +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/.python-version +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/AGENTS.md +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/CLAUDE.md +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/LICENSE +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/install.sh +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/__init__.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/config.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/media.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/session.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/src/tgcli/update.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/tests/__init__.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/tests/conftest.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/tests/test_config.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/tests/test_media.py +0 -0
- {pytgcli-0.11.0 → pytgcli-0.12.0}/tests/test_session.py +0 -0
- {pytgcli-0.11.0 → 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@
|
|
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
|
|
@@ -19,10 +19,11 @@ jobs:
|
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
20
|
strategy:
|
|
21
21
|
matrix:
|
|
22
|
-
python-version: ["3.12", "3.
|
|
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@
|
|
26
|
-
|
|
25
|
+
- uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
|
26
|
+
with:
|
|
27
|
+
python-version: ${{ matrix.python-version }}
|
|
27
28
|
- run: uv sync --group dev
|
|
28
29
|
- run: uv run pytest
|
|
@@ -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@
|
|
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@
|
|
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.
|
|
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
|
|
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.
|
|
@@ -178,14 +189,17 @@ When no substring matches, similar spellings are offered as suggestions without
|
|
|
178
189
|
|
|
179
190
|
| Flag | Description |
|
|
180
191
|
|----------------|----------------------------------------|
|
|
181
|
-
| `--query`/`-q` | Filter messages by text
|
|
192
|
+
| `--query`/`-q` | Filter messages by text (client-side) |
|
|
182
193
|
| `--from` | Filter by sender |
|
|
183
194
|
| `--limit` | Max messages (default 50) |
|
|
195
|
+
| `--scan` | Max messages walked when filtering (default 2000) |
|
|
184
196
|
| `--head` | Oldest messages first |
|
|
185
197
|
| `--after` | Only messages after date (YYYY-MM-DD) |
|
|
186
198
|
| `--before` | Only messages before date (YYYY-MM-DD) |
|
|
187
199
|
| `--pretty` | Rich table output instead of JSONL |
|
|
188
200
|
|
|
201
|
+
`--query` and `--from` filter client-side while walking back through the chat. The walk stops after `--scan` messages unless `--after` bounds it by date; when the cap is hit, matches found so far are printed and a `scan cap reached: ...` line goes to stderr (exit code 0). Narrow with `--after` or raise `--scan`.
|
|
202
|
+
|
|
189
203
|
JSONL fields: `id`, `text`, `chat_name`, `sender_name`, `sender_username`, `sender_id`, `date`, `reply_to_msg_id`, `media_type`, `media_filename`. `media_type` is `null` or one of `photo`, `document`, `video`, `voice`, `sticker`, `webpage`, `other`; `media_filename` is the document's original filename when present. `--pretty` marks attachments with a `[photo]`-style tag.
|
|
190
204
|
|
|
191
205
|
### `tg media <chat> <message_id>`
|
|
@@ -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
|
|
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.
|
|
@@ -154,14 +164,17 @@ When no substring matches, similar spellings are offered as suggestions without
|
|
|
154
164
|
|
|
155
165
|
| Flag | Description |
|
|
156
166
|
|----------------|----------------------------------------|
|
|
157
|
-
| `--query`/`-q` | Filter messages by text
|
|
167
|
+
| `--query`/`-q` | Filter messages by text (client-side) |
|
|
158
168
|
| `--from` | Filter by sender |
|
|
159
169
|
| `--limit` | Max messages (default 50) |
|
|
170
|
+
| `--scan` | Max messages walked when filtering (default 2000) |
|
|
160
171
|
| `--head` | Oldest messages first |
|
|
161
172
|
| `--after` | Only messages after date (YYYY-MM-DD) |
|
|
162
173
|
| `--before` | Only messages before date (YYYY-MM-DD) |
|
|
163
174
|
| `--pretty` | Rich table output instead of JSONL |
|
|
164
175
|
|
|
176
|
+
`--query` and `--from` filter client-side while walking back through the chat. The walk stops after `--scan` messages unless `--after` bounds it by date; when the cap is hit, matches found so far are printed and a `scan cap reached: ...` line goes to stderr (exit code 0). Narrow with `--after` or raise `--scan`.
|
|
177
|
+
|
|
165
178
|
JSONL fields: `id`, `text`, `chat_name`, `sender_name`, `sender_username`, `sender_id`, `date`, `reply_to_msg_id`, `media_type`, `media_filename`. `media_type` is `null` or one of `photo`, `document`, `video`, `voice`, `sticker`, `webpage`, `other`; `media_filename` is the document's original filename when present. `--pretty` marks attachments with a `[photo]`-style tag.
|
|
166
179
|
|
|
167
180
|
### `tg media <chat> <message_id>`
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytgcli"
|
|
7
|
-
version = "0.
|
|
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
|
|
|
@@ -4,12 +4,14 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import subprocess
|
|
7
|
+
import sys
|
|
7
8
|
from datetime import UTC, datetime
|
|
8
9
|
from importlib.metadata import version
|
|
9
10
|
from typing import Annotated
|
|
10
11
|
|
|
11
12
|
import typer
|
|
12
13
|
from rich.console import Console
|
|
14
|
+
from rich.markup import escape
|
|
13
15
|
from telethon.errors import UnauthorizedError
|
|
14
16
|
|
|
15
17
|
|
|
@@ -34,7 +36,11 @@ app = typer.Typer(
|
|
|
34
36
|
)
|
|
35
37
|
)
|
|
36
38
|
auth_app = typer.Typer(
|
|
37
|
-
help=
|
|
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,
|
|
38
44
|
)
|
|
39
45
|
app.add_typer(auth_app, name="auth")
|
|
40
46
|
|
|
@@ -141,8 +147,18 @@ def auth_default(ctx: typer.Context) -> None:
|
|
|
141
147
|
stdout.print("Run `tg auth logout` to log out.")
|
|
142
148
|
return
|
|
143
149
|
|
|
144
|
-
# Step 3: not authenticated,
|
|
145
|
-
|
|
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)
|
|
146
162
|
|
|
147
163
|
|
|
148
164
|
def _run_login() -> None:
|
|
@@ -162,9 +178,59 @@ def _run_login() -> None:
|
|
|
162
178
|
except Exception as e:
|
|
163
179
|
stderr.print(f"[red]Login failed:[/red] {e}")
|
|
164
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
|
+
|
|
191
|
+
stderr.print(
|
|
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:
|
|
165
231
|
stderr.print(
|
|
166
|
-
"\
|
|
167
|
-
"
|
|
232
|
+
"\nRemember Telegram's ToS: unofficial API clients are under\n"
|
|
233
|
+
"observation by Telegram. Normal interactive use (searching,\n"
|
|
168
234
|
"reading your own messages) is fine. Avoid bulk scraping, spamming,\n"
|
|
169
235
|
"or using results for AI/ML model training.\n"
|
|
170
236
|
"Full terms: https://core.telegram.org/api/terms\n"
|
|
@@ -178,6 +244,21 @@ def login() -> None:
|
|
|
178
244
|
_run_login()
|
|
179
245
|
|
|
180
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
|
+
|
|
181
262
|
@auth_app.command()
|
|
182
263
|
def logout() -> None:
|
|
183
264
|
"""Remove the local session."""
|
|
@@ -334,6 +415,11 @@ def chats(
|
|
|
334
415
|
"--from accepts me, @username, bare username, numeric user ID, phone, "
|
|
335
416
|
"or exact display name. Display names can be ambiguous; prefer username "
|
|
336
417
|
"or numeric ID for automation.\n\n"
|
|
418
|
+
"--query and --from filter client-side while walking back through the "
|
|
419
|
+
"chat, so a term with few or no matches would otherwise scan the whole "
|
|
420
|
+
"history. The walk stops after --scan messages (default 2000) unless "
|
|
421
|
+
"--after bounds it by date. When the cap is hit, matches found so far are "
|
|
422
|
+
"printed and a `scan cap reached` line goes to stderr; exit code stays 0.\n\n"
|
|
337
423
|
"Examples: `tg read Team --from takeshi55555`, "
|
|
338
424
|
'`tg read Team --from @takeshi55555`, `tg read Team --from "Takeshi"`.'
|
|
339
425
|
)
|
|
@@ -343,7 +429,15 @@ def read(
|
|
|
343
429
|
str, typer.Argument(help="Chat name, id, @username, phone, or me.")
|
|
344
430
|
],
|
|
345
431
|
query: Annotated[
|
|
346
|
-
str | None,
|
|
432
|
+
str | None,
|
|
433
|
+
typer.Option(
|
|
434
|
+
"--query",
|
|
435
|
+
"-q",
|
|
436
|
+
help=(
|
|
437
|
+
"Filter messages by text (client-side substring match, "
|
|
438
|
+
"case-insensitive). Bounded by --scan or --after."
|
|
439
|
+
),
|
|
440
|
+
),
|
|
347
441
|
] = None,
|
|
348
442
|
from_: Annotated[
|
|
349
443
|
str | None,
|
|
@@ -356,6 +450,17 @@ def read(
|
|
|
356
450
|
),
|
|
357
451
|
] = None,
|
|
358
452
|
limit: Annotated[int, typer.Option(help="Max messages to return.")] = 50,
|
|
453
|
+
scan: Annotated[
|
|
454
|
+
int,
|
|
455
|
+
typer.Option(
|
|
456
|
+
"--scan",
|
|
457
|
+
min=1,
|
|
458
|
+
help=(
|
|
459
|
+
"Max messages to walk when --query or --from is set and --after "
|
|
460
|
+
"is not. Matches found before the cap are still printed."
|
|
461
|
+
),
|
|
462
|
+
),
|
|
463
|
+
] = 2000,
|
|
359
464
|
head: Annotated[
|
|
360
465
|
bool, typer.Option("--head", help="Oldest messages first.")
|
|
361
466
|
] = False,
|
|
@@ -380,6 +485,12 @@ def read(
|
|
|
380
485
|
stderr.print("Expected format: YYYY-MM-DD")
|
|
381
486
|
raise typer.Exit(1)
|
|
382
487
|
|
|
488
|
+
scan_cap: tuple[int, datetime | None] | None = None
|
|
489
|
+
|
|
490
|
+
def _on_scan_cap(scanned: int, last_date: datetime | None) -> None:
|
|
491
|
+
nonlocal scan_cap
|
|
492
|
+
scan_cap = (scanned, last_date)
|
|
493
|
+
|
|
383
494
|
async def _run():
|
|
384
495
|
client = create_client()
|
|
385
496
|
async with client:
|
|
@@ -392,6 +503,8 @@ def read(
|
|
|
392
503
|
after=after_dt,
|
|
393
504
|
before=before_dt,
|
|
394
505
|
reverse=head,
|
|
506
|
+
scan=scan,
|
|
507
|
+
on_scan_cap=_on_scan_cap,
|
|
395
508
|
)
|
|
396
509
|
|
|
397
510
|
try:
|
|
@@ -412,9 +525,7 @@ def read(
|
|
|
412
525
|
|
|
413
526
|
if not results:
|
|
414
527
|
stdout.print("No messages found.")
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
if pretty:
|
|
528
|
+
elif pretty:
|
|
418
529
|
from tgcli.formatting import format_search_results
|
|
419
530
|
|
|
420
531
|
stdout.print(format_search_results(results))
|
|
@@ -424,6 +535,15 @@ def read(
|
|
|
424
535
|
for msg in results:
|
|
425
536
|
print(format_message_jsonl(msg))
|
|
426
537
|
|
|
538
|
+
if scan_cap:
|
|
539
|
+
scanned, last_date = scan_cap
|
|
540
|
+
back_to = last_date.astimezone().strftime("%Y-%m-%d") if last_date else "?"
|
|
541
|
+
print(
|
|
542
|
+
f"scan cap reached: {scanned} messages read back to {back_to}; "
|
|
543
|
+
"narrow with --after or raise --scan",
|
|
544
|
+
file=sys.stderr,
|
|
545
|
+
)
|
|
546
|
+
|
|
427
547
|
|
|
428
548
|
@app.command(
|
|
429
549
|
help=(
|