pytgcli 0.9.1__tar.gz → 0.11.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.9.1 → pytgcli-0.11.0}/.github/workflows/ci.yml +2 -2
- {pytgcli-0.9.1 → pytgcli-0.11.0}/.github/workflows/publish.yml +11 -10
- {pytgcli-0.9.1 → pytgcli-0.11.0}/AGENTS.md +1 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/PKG-INFO +56 -6
- {pytgcli-0.9.1 → pytgcli-0.11.0}/README.md +54 -4
- {pytgcli-0.9.1 → pytgcli-0.11.0}/pyproject.toml +1 -1
- {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/cli.py +130 -12
- pytgcli-0.11.0/src/tgcli/client.py +516 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/formatting.py +16 -4
- pytgcli-0.11.0/src/tgcli/media.py +243 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_cli.py +356 -1
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_client.py +177 -1
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_formatting.py +23 -0
- pytgcli-0.11.0/tests/test_media.py +370 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/uv.lock +164 -94
- pytgcli-0.9.1/src/tgcli/client.py +0 -282
- {pytgcli-0.9.1 → pytgcli-0.11.0}/.github/dependabot.yml +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/.gitignore +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/.pre-commit-config.yaml +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/.python-version +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/CLAUDE.md +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/LICENSE +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/install.sh +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/__init__.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/auth.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/config.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/session.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/update.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/__init__.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/conftest.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_auth.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_config.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_session.py +0 -0
- {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_update.py +0 -0
|
@@ -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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
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.13"]
|
|
23
23
|
steps:
|
|
24
24
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
25
|
-
- uses: astral-sh/setup-uv@
|
|
25
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
26
26
|
- run: uv python install ${{ matrix.python-version }}
|
|
27
27
|
- run: uv sync --group dev
|
|
28
28
|
- 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
|
80
80
|
- run: uv publish --trusted-publishing always dist/*
|
|
81
81
|
|
|
82
82
|
update-homebrew-tap:
|
|
@@ -88,14 +88,15 @@ jobs:
|
|
|
88
88
|
contents: read
|
|
89
89
|
env:
|
|
90
90
|
TAP_REPO: tksohishi/homebrew-tap
|
|
91
|
-
TAP_TOKEN: ${{ secrets.TAP_GH_TOKEN }}
|
|
92
91
|
steps:
|
|
93
|
-
- name:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
92
|
+
- name: Mint tap token
|
|
93
|
+
id: tap-token
|
|
94
|
+
uses: actions/create-github-app-token@v3
|
|
95
|
+
with:
|
|
96
|
+
app-id: ${{ secrets.TAP_APP_ID }}
|
|
97
|
+
private-key: ${{ secrets.TAP_APP_PRIVATE_KEY }}
|
|
98
|
+
owner: tksohishi
|
|
99
|
+
repositories: homebrew-tap
|
|
99
100
|
|
|
100
101
|
- name: Resolve PyPI source artifact
|
|
101
102
|
env:
|
|
@@ -121,7 +122,7 @@ jobs:
|
|
|
121
122
|
|
|
122
123
|
- name: Update formula in tap repo
|
|
123
124
|
run: |
|
|
124
|
-
git clone "https://x-access-token:${
|
|
125
|
+
git clone "https://x-access-token:${{ steps.tap-token.outputs.token }}@github.com/${TAP_REPO}.git" tap-repo
|
|
125
126
|
python3 <<'PY'
|
|
126
127
|
import os
|
|
127
128
|
import re
|
|
@@ -9,6 +9,7 @@ src/tgcli/
|
|
|
9
9
|
client.py # Telethon wrapper (search, read, context)
|
|
10
10
|
config.py # Config loading (TOML + env vars)
|
|
11
11
|
formatting.py # Pure output formatting functions (no I/O)
|
|
12
|
+
media.py # Attachment safety rules: extension allowlist, magic bytes (stdlib only)
|
|
12
13
|
session.py # Keychain-backed StringSession via keyring (cross-platform)
|
|
13
14
|
update.py # Update checking, install method detection (stdlib only)
|
|
14
15
|
tests/ # pytest + pytest-asyncio, Telethon fully mocked
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pytgcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -24,13 +24,13 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
# ✈️ tgcli — Telegram for your terminal and your AI agents.
|
|
26
26
|
|
|
27
|
-
Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram conversations. Structured JSONL output, minimal command surface,
|
|
27
|
+
Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram conversations. Structured JSONL output, minimal command surface, partial chat name matching. Works equally well for humans with `--pretty`.
|
|
28
28
|
|
|
29
29
|
## Features
|
|
30
30
|
|
|
31
31
|
- **JSONL by default** — one JSON object per line; agents parse it natively, scripts pipe it freely
|
|
32
32
|
- **Minimal surface** — a handful of commands; easy for agents to discover and invoke
|
|
33
|
-
- **
|
|
33
|
+
- **Chat name resolution**: exact display names take priority; a unique substring also matches
|
|
34
34
|
- **`--pretty` for humans** — Rich tables when you want to read output yourself
|
|
35
35
|
- **Agent-friendly session storage** — Telethon session key stored in a local `0600` file by default; keychain remains available as a legacy option
|
|
36
36
|
|
|
@@ -91,6 +91,12 @@ tg read "Finance Team" -q "deadline" --from "Alice" --after 2025-01-01
|
|
|
91
91
|
tg context "Finance Team" 12345
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
### 5. Download Attachments
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
tg media "Finance Team" 12345 --out ./downloads
|
|
98
|
+
```
|
|
99
|
+
|
|
94
100
|
## Use with AI Agents
|
|
95
101
|
|
|
96
102
|
Once authenticated, any AI coding agent with shell access can use tgcli directly. A few examples:
|
|
@@ -110,9 +116,12 @@ The agent runs `tg read "Alice" -q "deployment" --after 2025-02-14` and surfaces
|
|
|
110
116
|
**Pipe into scripts:**
|
|
111
117
|
|
|
112
118
|
```bash
|
|
119
|
+
set -o pipefail
|
|
113
120
|
tg read "Alerts" --limit 100 | jq 'select(.text | test("ERROR"))'
|
|
114
121
|
```
|
|
115
122
|
|
|
123
|
+
`pipefail` preserves a failed `tg read` exit status when `jq` succeeds. A chat lookup failure exits with code 1 and writes a human-readable error plus one JSON error line to stderr. Agents should check the exit status before treating empty stdout as “no messages.”
|
|
124
|
+
|
|
116
125
|
No wrapper libraries or API adapters needed. The structured output and simple command surface mean agents can use tgcli out of the box.
|
|
117
126
|
|
|
118
127
|
## Commands
|
|
@@ -132,16 +141,41 @@ Explicit subcommands:
|
|
|
132
141
|
|
|
133
142
|
List your Telegram chats. Returns JSONL by default.
|
|
134
143
|
|
|
144
|
+
Each JSON object includes `id`, the Telethon dialog entity's numeric ID. Pass it to `read`, `context`, or `media` to skip name matching entirely:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
tg chats --filter "Alice"
|
|
148
|
+
tg read 123456789 --limit 20
|
|
149
|
+
```
|
|
150
|
+
|
|
135
151
|
| Flag | Description |
|
|
136
152
|
|------------|------------------------------|
|
|
137
|
-
| `--filter` |
|
|
138
|
-
| `--limit` | Max
|
|
153
|
+
| `--filter` | Case-insensitive substring filter by chat name |
|
|
154
|
+
| `--limit` | Max dialogs to scan before filtering (default 100) |
|
|
139
155
|
| `--pretty` | Rich table output instead of JSONL |
|
|
140
156
|
|
|
157
|
+
Increase `--limit` if a chat is outside the scanned set.
|
|
158
|
+
|
|
141
159
|
### `tg read <chat>`
|
|
142
160
|
|
|
143
161
|
Read recent messages from a chat. Returns JSONL by default, newest first.
|
|
144
162
|
|
|
163
|
+
`<chat>` accepts a display name, `@username`, phone number, `me`, or numeric ID. Display names use case-insensitive exact matching first, then substring matching. A single substring match resolves automatically, so `tg read "Alice Morgan"` can open a DM named `Alice Morgan | Example Team`. It prints `Resolved "Alice Morgan" -> "Alice Morgan | Example Team"` on stderr, keeping stdout clean for pipes. Exact matches produce no resolution notice. Multiple substring matches fail with candidate names listed one per line. If no candidates exist, the error suggests `tg chats --filter`.
|
|
164
|
+
|
|
165
|
+
These lookup rules also apply to `context` and `media`. Numeric IDs, including negative group/channel IDs, skip name matching entirely. Put `--` before a negative positional ID so the CLI does not parse it as an option:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
tg read --limit 20 -- -1001234567890
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Lookup failures exit with code 1 and print both a human-readable message and a single JSON line to stderr. For an ambiguous name, the JSON looks like:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{"error": "chat_not_found", "query": "Alice", "candidates": ["Alice Morgan | Example Team", "Alice Chen"]}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
When no substring matches, similar spellings are offered as suggestions without opening a chat. `candidates` is an empty list when there are no suggestions.
|
|
178
|
+
|
|
145
179
|
| Flag | Description |
|
|
146
180
|
|----------------|----------------------------------------|
|
|
147
181
|
| `--query`/`-q` | Filter messages by text |
|
|
@@ -152,6 +186,22 @@ Read recent messages from a chat. Returns JSONL by default, newest first.
|
|
|
152
186
|
| `--before` | Only messages before date (YYYY-MM-DD) |
|
|
153
187
|
| `--pretty` | Rich table output instead of JSONL |
|
|
154
188
|
|
|
189
|
+
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
|
+
|
|
191
|
+
### `tg media <chat> <message_id>`
|
|
192
|
+
|
|
193
|
+
Download the attachment of one message. Prints a single JSON line with `id`, `path`, and `media_type`. Fails when the message has no downloadable media (webpage previews count as no media) or the id is not found.
|
|
194
|
+
|
|
195
|
+
Only known media and document types are saved (images, video, audio, PDF, office files, text). Archives (zip, 7z, tar, gz, rar) need `--allow-archives`. Executables, scripts, installers, shortcuts, disk images, HTML, SVG, and XML are refused by extension and mime type. After the transfer, the file's leading and trailing bytes are checked too: Windows, ELF, and Mach-O binaries, shebang scripts, Windows shortcuts, cabinet and installer packages, DMG and ISO images, and archives or compound files renamed to a document extension are deleted and reported. Office documents with macros, RTF, and CSV are still saved; they are documents, not programs, so open them with the same care as any attachment. Attachments are refused before transfer when the reported size exceeds `--max-size` MB (default 100), and the transfer itself is cut off at that limit.
|
|
196
|
+
|
|
197
|
+
tgcli chooses the saved filename itself: `<message_id>_<sanitized original name>`, or `<media_type>_<message_id>.<ext>` when the message has no filename. It downloads into a staging file it created, never overwrites an existing file, and never follows a symlink in the output directory.
|
|
198
|
+
|
|
199
|
+
| Flag | Description |
|
|
200
|
+
|--------------------|----------------------------------------|
|
|
201
|
+
| `--out` | Directory to save into (default cwd) |
|
|
202
|
+
| `--allow-archives` | Also download zip/7z/tar/gz/rar files |
|
|
203
|
+
| `--max-size` | Size limit in MB (default 100) |
|
|
204
|
+
|
|
155
205
|
### `tg update`
|
|
156
206
|
|
|
157
207
|
Upgrade tgcli to the latest version. Detects the install method and runs the right command (or tells you what to run for Homebrew installs).
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# ✈️ tgcli — Telegram for your terminal and your AI agents.
|
|
2
2
|
|
|
3
|
-
Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram conversations. Structured JSONL output, minimal command surface,
|
|
3
|
+
Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram conversations. Structured JSONL output, minimal command surface, partial chat name matching. Works equally well for humans with `--pretty`.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **JSONL by default** — one JSON object per line; agents parse it natively, scripts pipe it freely
|
|
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
11
|
- **Agent-friendly session storage** — Telethon session key stored in a local `0600` file by default; keychain remains available as a legacy option
|
|
12
12
|
|
|
@@ -67,6 +67,12 @@ tg read "Finance Team" -q "deadline" --from "Alice" --after 2025-01-01
|
|
|
67
67
|
tg context "Finance Team" 12345
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
### 5. Download Attachments
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
tg media "Finance Team" 12345 --out ./downloads
|
|
74
|
+
```
|
|
75
|
+
|
|
70
76
|
## Use with AI Agents
|
|
71
77
|
|
|
72
78
|
Once authenticated, any AI coding agent with shell access can use tgcli directly. A few examples:
|
|
@@ -86,9 +92,12 @@ The agent runs `tg read "Alice" -q "deployment" --after 2025-02-14` and surfaces
|
|
|
86
92
|
**Pipe into scripts:**
|
|
87
93
|
|
|
88
94
|
```bash
|
|
95
|
+
set -o pipefail
|
|
89
96
|
tg read "Alerts" --limit 100 | jq 'select(.text | test("ERROR"))'
|
|
90
97
|
```
|
|
91
98
|
|
|
99
|
+
`pipefail` preserves a failed `tg read` exit status when `jq` succeeds. A chat lookup failure exits with code 1 and writes a human-readable error plus one JSON error line to stderr. Agents should check the exit status before treating empty stdout as “no messages.”
|
|
100
|
+
|
|
92
101
|
No wrapper libraries or API adapters needed. The structured output and simple command surface mean agents can use tgcli out of the box.
|
|
93
102
|
|
|
94
103
|
## Commands
|
|
@@ -108,16 +117,41 @@ Explicit subcommands:
|
|
|
108
117
|
|
|
109
118
|
List your Telegram chats. Returns JSONL by default.
|
|
110
119
|
|
|
120
|
+
Each JSON object includes `id`, the Telethon dialog entity's numeric ID. Pass it to `read`, `context`, or `media` to skip name matching entirely:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
tg chats --filter "Alice"
|
|
124
|
+
tg read 123456789 --limit 20
|
|
125
|
+
```
|
|
126
|
+
|
|
111
127
|
| Flag | Description |
|
|
112
128
|
|------------|------------------------------|
|
|
113
|
-
| `--filter` |
|
|
114
|
-
| `--limit` | Max
|
|
129
|
+
| `--filter` | Case-insensitive substring filter by chat name |
|
|
130
|
+
| `--limit` | Max dialogs to scan before filtering (default 100) |
|
|
115
131
|
| `--pretty` | Rich table output instead of JSONL |
|
|
116
132
|
|
|
133
|
+
Increase `--limit` if a chat is outside the scanned set.
|
|
134
|
+
|
|
117
135
|
### `tg read <chat>`
|
|
118
136
|
|
|
119
137
|
Read recent messages from a chat. Returns JSONL by default, newest first.
|
|
120
138
|
|
|
139
|
+
`<chat>` accepts a display name, `@username`, phone number, `me`, or numeric ID. Display names use case-insensitive exact matching first, then substring matching. A single substring match resolves automatically, so `tg read "Alice Morgan"` can open a DM named `Alice Morgan | Example Team`. It prints `Resolved "Alice Morgan" -> "Alice Morgan | Example Team"` on stderr, keeping stdout clean for pipes. Exact matches produce no resolution notice. Multiple substring matches fail with candidate names listed one per line. If no candidates exist, the error suggests `tg chats --filter`.
|
|
140
|
+
|
|
141
|
+
These lookup rules also apply to `context` and `media`. Numeric IDs, including negative group/channel IDs, skip name matching entirely. Put `--` before a negative positional ID so the CLI does not parse it as an option:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
tg read --limit 20 -- -1001234567890
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Lookup failures exit with code 1 and print both a human-readable message and a single JSON line to stderr. For an ambiguous name, the JSON looks like:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{"error": "chat_not_found", "query": "Alice", "candidates": ["Alice Morgan | Example Team", "Alice Chen"]}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
When no substring matches, similar spellings are offered as suggestions without opening a chat. `candidates` is an empty list when there are no suggestions.
|
|
154
|
+
|
|
121
155
|
| Flag | Description |
|
|
122
156
|
|----------------|----------------------------------------|
|
|
123
157
|
| `--query`/`-q` | Filter messages by text |
|
|
@@ -128,6 +162,22 @@ Read recent messages from a chat. Returns JSONL by default, newest first.
|
|
|
128
162
|
| `--before` | Only messages before date (YYYY-MM-DD) |
|
|
129
163
|
| `--pretty` | Rich table output instead of JSONL |
|
|
130
164
|
|
|
165
|
+
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
|
+
|
|
167
|
+
### `tg media <chat> <message_id>`
|
|
168
|
+
|
|
169
|
+
Download the attachment of one message. Prints a single JSON line with `id`, `path`, and `media_type`. Fails when the message has no downloadable media (webpage previews count as no media) or the id is not found.
|
|
170
|
+
|
|
171
|
+
Only known media and document types are saved (images, video, audio, PDF, office files, text). Archives (zip, 7z, tar, gz, rar) need `--allow-archives`. Executables, scripts, installers, shortcuts, disk images, HTML, SVG, and XML are refused by extension and mime type. After the transfer, the file's leading and trailing bytes are checked too: Windows, ELF, and Mach-O binaries, shebang scripts, Windows shortcuts, cabinet and installer packages, DMG and ISO images, and archives or compound files renamed to a document extension are deleted and reported. Office documents with macros, RTF, and CSV are still saved; they are documents, not programs, so open them with the same care as any attachment. Attachments are refused before transfer when the reported size exceeds `--max-size` MB (default 100), and the transfer itself is cut off at that limit.
|
|
172
|
+
|
|
173
|
+
tgcli chooses the saved filename itself: `<message_id>_<sanitized original name>`, or `<media_type>_<message_id>.<ext>` when the message has no filename. It downloads into a staging file it created, never overwrites an existing file, and never follows a symlink in the output directory.
|
|
174
|
+
|
|
175
|
+
| Flag | Description |
|
|
176
|
+
|--------------------|----------------------------------------|
|
|
177
|
+
| `--out` | Directory to save into (default cwd) |
|
|
178
|
+
| `--allow-archives` | Also download zip/7z/tar/gz/rar files |
|
|
179
|
+
| `--max-size` | Size limit in MB (default 100) |
|
|
180
|
+
|
|
131
181
|
### `tg update`
|
|
132
182
|
|
|
133
183
|
Upgrade tgcli to the latest version. Detects the install method and runs the right command (or tells you what to run for Homebrew installs).
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import json
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
6
7
|
from datetime import UTC, datetime
|
|
@@ -23,11 +24,13 @@ app = typer.Typer(
|
|
|
23
24
|
"Read Telegram messages from the terminal.\n\n"
|
|
24
25
|
"Default output is JSONL. Use --pretty for human-readable output.\n\n"
|
|
25
26
|
"Message JSONL fields: id, text, chat_name, sender_name, "
|
|
26
|
-
"sender_username, sender_id, date, reply_to_msg_id
|
|
27
|
+
"sender_username, sender_id, date, reply_to_msg_id, media_type, "
|
|
28
|
+
"media_filename. "
|
|
27
29
|
"sender_username may be null; sender_id is the most stable sender key.\n\n"
|
|
28
30
|
"Common flow: `tg chats` to find chat names, `tg read <chat>` to fetch "
|
|
29
31
|
"or filter messages, `tg context <chat> <message_id>` to inspect nearby "
|
|
30
|
-
"messages, `tg
|
|
32
|
+
"messages, `tg media <chat> <message_id>` to download an attachment, "
|
|
33
|
+
"`tg auth` for login and status."
|
|
31
34
|
)
|
|
32
35
|
)
|
|
33
36
|
auth_app = typer.Typer(
|
|
@@ -239,17 +242,43 @@ def _parse_date(value: str) -> datetime:
|
|
|
239
242
|
return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
240
243
|
|
|
241
244
|
|
|
245
|
+
def _report_chat_resolution_error(error) -> None:
|
|
246
|
+
typer.echo(str(error), err=True)
|
|
247
|
+
typer.echo(
|
|
248
|
+
json.dumps(
|
|
249
|
+
{
|
|
250
|
+
"error": "chat_not_found",
|
|
251
|
+
"query": error.query,
|
|
252
|
+
"candidates": error.candidates,
|
|
253
|
+
},
|
|
254
|
+
ensure_ascii=False,
|
|
255
|
+
),
|
|
256
|
+
err=True,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
242
260
|
@app.command()
|
|
243
261
|
def chats(
|
|
244
262
|
filter_: Annotated[
|
|
245
|
-
str | None,
|
|
263
|
+
str | None,
|
|
264
|
+
typer.Option(
|
|
265
|
+
"--filter", help="Case-insensitive substring filter by chat name."
|
|
266
|
+
),
|
|
246
267
|
] = None,
|
|
247
|
-
limit: Annotated[
|
|
268
|
+
limit: Annotated[
|
|
269
|
+
int, typer.Option(help="Max dialogs to scan before filtering.")
|
|
270
|
+
] = 100,
|
|
248
271
|
pretty: Annotated[
|
|
249
272
|
bool, typer.Option("--pretty", help="Rich table output.")
|
|
250
273
|
] = False,
|
|
251
274
|
) -> None:
|
|
252
|
-
"""List your Telegram chats.
|
|
275
|
+
"""List your Telegram chats.
|
|
276
|
+
|
|
277
|
+
JSONL includes id (Telethon dialog entity ID), name, chat_type,
|
|
278
|
+
unread_count, pinned, and date. Pass id to read, context, or media to
|
|
279
|
+
skip name matching entirely. Negative group/channel IDs are also accepted;
|
|
280
|
+
put options before -- and the negative ID after it: tg read -- -1001234567890.
|
|
281
|
+
"""
|
|
253
282
|
from tgcli.client import create_client, list_chats
|
|
254
283
|
|
|
255
284
|
async def _run():
|
|
@@ -289,9 +318,19 @@ def chats(
|
|
|
289
318
|
help=(
|
|
290
319
|
"Read recent messages from a chat. Newest first by default (--head for "
|
|
291
320
|
"oldest).\n\n"
|
|
321
|
+
"Chat names match case-insensitively: exact first, then a unique "
|
|
322
|
+
"substring. Substring matches print the resolved name on stderr. "
|
|
323
|
+
"Ambiguous or missing names exit 1 with a human-readable "
|
|
324
|
+
"error and one JSON line on stderr (error, query, candidates).\n\n"
|
|
325
|
+
"Pass an id from `tg chats` to skip name matching entirely. "
|
|
326
|
+
"Negative group/channel IDs are accepted: "
|
|
327
|
+
"`tg read --limit 20 -- -1001234567890`.\n\n"
|
|
292
328
|
"Default JSONL includes id, text, chat_name, sender_name, "
|
|
293
|
-
"sender_username, sender_id, date, and
|
|
294
|
-
"sender_username may be null; sender_id is the most
|
|
329
|
+
"sender_username, sender_id, date, reply_to_msg_id, media_type, and "
|
|
330
|
+
"media_filename. sender_username may be null; sender_id is the most "
|
|
331
|
+
"stable sender key. media_type is null or one of photo, document, video, "
|
|
332
|
+
"voice, sticker, webpage, other; media_filename is the document's "
|
|
333
|
+
"original filename when present.\n\n"
|
|
295
334
|
"--from accepts me, @username, bare username, numeric user ID, phone, "
|
|
296
335
|
"or exact display name. Display names can be ambiguous; prefer username "
|
|
297
336
|
"or numeric ID for automation.\n\n"
|
|
@@ -300,7 +339,9 @@ def chats(
|
|
|
300
339
|
)
|
|
301
340
|
)
|
|
302
341
|
def read(
|
|
303
|
-
chat: Annotated[
|
|
342
|
+
chat: Annotated[
|
|
343
|
+
str, typer.Argument(help="Chat name, id, @username, phone, or me.")
|
|
344
|
+
],
|
|
304
345
|
query: Annotated[
|
|
305
346
|
str | None, typer.Option("--query", "-q", help="Filter messages by text.")
|
|
306
347
|
] = None,
|
|
@@ -329,7 +370,7 @@ def read(
|
|
|
329
370
|
] = False,
|
|
330
371
|
) -> None:
|
|
331
372
|
"""Read recent messages from a chat."""
|
|
332
|
-
from tgcli.client import create_client, read_messages
|
|
373
|
+
from tgcli.client import ChatResolutionError, create_client, read_messages
|
|
333
374
|
|
|
334
375
|
try:
|
|
335
376
|
after_dt = _parse_date(after) if after else None
|
|
@@ -362,6 +403,9 @@ def read(
|
|
|
362
403
|
except UnauthorizedError:
|
|
363
404
|
stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
|
|
364
405
|
raise typer.Exit(2)
|
|
406
|
+
except ChatResolutionError as e:
|
|
407
|
+
_report_chat_resolution_error(e)
|
|
408
|
+
raise typer.Exit(1)
|
|
365
409
|
except Exception as e:
|
|
366
410
|
stderr.print(f"[red]Read failed:[/red] {e}")
|
|
367
411
|
raise typer.Exit(1)
|
|
@@ -384,8 +428,12 @@ def read(
|
|
|
384
428
|
@app.command(
|
|
385
429
|
help=(
|
|
386
430
|
"View a message with surrounding context.\n\n"
|
|
387
|
-
"
|
|
388
|
-
"
|
|
431
|
+
"Chat names use exact matching first, then a unique case-insensitive "
|
|
432
|
+
"substring. Pass an id from `tg chats` to skip name matching. "
|
|
433
|
+
"For negative IDs, put options before -- and the ID after it.\n\n"
|
|
434
|
+
"Default JSONL uses the same message fields as `read` (including "
|
|
435
|
+
"media_type and media_filename) and adds boolean flags such as target "
|
|
436
|
+
"and replied_to on relevant lines."
|
|
389
437
|
)
|
|
390
438
|
)
|
|
391
439
|
def context(
|
|
@@ -399,7 +447,7 @@ def context(
|
|
|
399
447
|
] = False,
|
|
400
448
|
) -> None:
|
|
401
449
|
"""View a message with surrounding context."""
|
|
402
|
-
from tgcli.client import create_client, get_context
|
|
450
|
+
from tgcli.client import ChatResolutionError, create_client, get_context
|
|
403
451
|
|
|
404
452
|
async def _run():
|
|
405
453
|
client = create_client()
|
|
@@ -415,6 +463,9 @@ def context(
|
|
|
415
463
|
except UnauthorizedError:
|
|
416
464
|
stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
|
|
417
465
|
raise typer.Exit(2)
|
|
466
|
+
except ChatResolutionError as e:
|
|
467
|
+
_report_chat_resolution_error(e)
|
|
468
|
+
raise typer.Exit(1)
|
|
418
469
|
except Exception as e:
|
|
419
470
|
stderr.print(f"[red]Context fetch failed:[/red] {e}")
|
|
420
471
|
raise typer.Exit(1)
|
|
@@ -439,3 +490,70 @@ def context(
|
|
|
439
490
|
replied_to=(msg.id == replied_to_id),
|
|
440
491
|
)
|
|
441
492
|
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@app.command(
|
|
496
|
+
help=(
|
|
497
|
+
"Download the attachment of one message.\n\n"
|
|
498
|
+
"Chat names use exact matching first, then a unique case-insensitive "
|
|
499
|
+
"substring. Pass an id from `tg chats` to skip name matching. "
|
|
500
|
+
"For negative IDs, put options before -- and the ID after it.\n\n"
|
|
501
|
+
"Prints a single JSON line with id, path, and media_type. Fails when "
|
|
502
|
+
"the message has no downloadable media (webpage previews count as "
|
|
503
|
+
"no media) or the id is not found. Executables, scripts, installers, "
|
|
504
|
+
"and files whose content is an executable in disguise are refused. "
|
|
505
|
+
"Archives (zip, 7z, tar, ...) are refused unless --allow-archives is set. "
|
|
506
|
+
"The file is saved under a name tgcli picks (<message_id>_<original name>, "
|
|
507
|
+
"or <media_type>_<message_id>.<ext>), never overwriting an existing file."
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
def media(
|
|
511
|
+
chat: str,
|
|
512
|
+
message_id: int,
|
|
513
|
+
out: Annotated[
|
|
514
|
+
str,
|
|
515
|
+
typer.Option("--out", help="Directory to save into (default: cwd)."),
|
|
516
|
+
] = ".",
|
|
517
|
+
allow_archives: Annotated[
|
|
518
|
+
bool,
|
|
519
|
+
typer.Option("--allow-archives", help="Also download zip/7z/tar archives."),
|
|
520
|
+
] = False,
|
|
521
|
+
max_size: Annotated[
|
|
522
|
+
int,
|
|
523
|
+
typer.Option("--max-size", help="Refuse attachments larger than this many MB."),
|
|
524
|
+
] = 100,
|
|
525
|
+
) -> None:
|
|
526
|
+
"""Download the attachment of one message."""
|
|
527
|
+
from tgcli.client import ChatResolutionError, create_client, download_media
|
|
528
|
+
|
|
529
|
+
os.makedirs(out, exist_ok=True)
|
|
530
|
+
|
|
531
|
+
async def _run():
|
|
532
|
+
client = create_client()
|
|
533
|
+
async with client:
|
|
534
|
+
return await download_media(
|
|
535
|
+
client,
|
|
536
|
+
chat,
|
|
537
|
+
message_id,
|
|
538
|
+
out,
|
|
539
|
+
allow_archives=allow_archives,
|
|
540
|
+
max_bytes=max_size * 1024 * 1024,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
try:
|
|
544
|
+
path, media_type = asyncio.run(_run())
|
|
545
|
+
except SystemExit as e:
|
|
546
|
+
stderr.print(f"[red]Configuration error:[/red] {e}")
|
|
547
|
+
stderr.print("Run `tg auth` to set up.")
|
|
548
|
+
raise typer.Exit(1)
|
|
549
|
+
except UnauthorizedError:
|
|
550
|
+
stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
|
|
551
|
+
raise typer.Exit(2)
|
|
552
|
+
except ChatResolutionError as e:
|
|
553
|
+
_report_chat_resolution_error(e)
|
|
554
|
+
raise typer.Exit(1)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
stderr.print(f"[red]Download failed:[/red] {e}")
|
|
557
|
+
raise typer.Exit(1)
|
|
558
|
+
|
|
559
|
+
print(json.dumps({"id": message_id, "path": path, "media_type": media_type}))
|