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.
Files changed (34) hide show
  1. {pytgcli-0.9.1 → pytgcli-0.11.0}/.github/workflows/ci.yml +2 -2
  2. {pytgcli-0.9.1 → pytgcli-0.11.0}/.github/workflows/publish.yml +11 -10
  3. {pytgcli-0.9.1 → pytgcli-0.11.0}/AGENTS.md +1 -0
  4. {pytgcli-0.9.1 → pytgcli-0.11.0}/PKG-INFO +56 -6
  5. {pytgcli-0.9.1 → pytgcli-0.11.0}/README.md +54 -4
  6. {pytgcli-0.9.1 → pytgcli-0.11.0}/pyproject.toml +1 -1
  7. {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/cli.py +130 -12
  8. pytgcli-0.11.0/src/tgcli/client.py +516 -0
  9. {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/formatting.py +16 -4
  10. pytgcli-0.11.0/src/tgcli/media.py +243 -0
  11. {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_cli.py +356 -1
  12. {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_client.py +177 -1
  13. {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_formatting.py +23 -0
  14. pytgcli-0.11.0/tests/test_media.py +370 -0
  15. {pytgcli-0.9.1 → pytgcli-0.11.0}/uv.lock +164 -94
  16. pytgcli-0.9.1/src/tgcli/client.py +0 -282
  17. {pytgcli-0.9.1 → pytgcli-0.11.0}/.github/dependabot.yml +0 -0
  18. {pytgcli-0.9.1 → pytgcli-0.11.0}/.gitignore +0 -0
  19. {pytgcli-0.9.1 → pytgcli-0.11.0}/.pre-commit-config.yaml +0 -0
  20. {pytgcli-0.9.1 → pytgcli-0.11.0}/.python-version +0 -0
  21. {pytgcli-0.9.1 → pytgcli-0.11.0}/CLAUDE.md +0 -0
  22. {pytgcli-0.9.1 → pytgcli-0.11.0}/LICENSE +0 -0
  23. {pytgcli-0.9.1 → pytgcli-0.11.0}/install.sh +0 -0
  24. {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/__init__.py +0 -0
  25. {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/auth.py +0 -0
  26. {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/config.py +0 -0
  27. {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/session.py +0 -0
  28. {pytgcli-0.9.1 → pytgcli-0.11.0}/src/tgcli/update.py +0 -0
  29. {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/__init__.py +0 -0
  30. {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/conftest.py +0 -0
  31. {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_auth.py +0 -0
  32. {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_config.py +0 -0
  33. {pytgcli-0.9.1 → pytgcli-0.11.0}/tests/test_session.py +0 -0
  34. {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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
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: Validate tap token
94
- run: |
95
- if [ -z "${TAP_TOKEN}" ]; then
96
- echo "TAP_GH_TOKEN is required to update homebrew-tap."
97
- exit 1
98
- fi
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:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap-repo
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: pytgcli
3
- Version: 0.9.1
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, fuzzy name resolution. Works equally well for humans with `--pretty`.
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
- - **Fuzzy resolution** — chat and user names match by display name (no numeric IDs required)
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` | Fuzzy filter by chat name |
138
- | `--limit` | Max chats to list (default 100) |
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, fuzzy name resolution. Works equally well for humans with `--pretty`.
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
- - **Fuzzy resolution** — chat and user names match by display name (no numeric IDs required)
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` | Fuzzy filter by chat name |
114
- | `--limit` | Max chats to list (default 100) |
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).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytgcli"
7
- version = "0.9.1"
7
+ version = "0.11.0"
8
8
  description = "CLI tool to read Telegram messages from the terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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 auth` for login and status."
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, typer.Option("--filter", help="Fuzzy filter by chat name.")
263
+ str | None,
264
+ typer.Option(
265
+ "--filter", help="Case-insensitive substring filter by chat name."
266
+ ),
246
267
  ] = None,
247
- limit: Annotated[int, typer.Option(help="Max chats to list.")] = 100,
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 reply_to_msg_id. "
294
- "sender_username may be null; sender_id is the most stable sender key.\n\n"
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[str, typer.Argument(help="Chat or person to read messages from.")],
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
- "Default JSONL uses the same message fields as `read` and adds boolean "
388
- "flags such as target and replied_to on relevant lines."
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}))