pytgcli 0.9.0__tar.gz → 0.10.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.0 → pytgcli-0.10.0}/.github/workflows/ci.yml +4 -4
- {pytgcli-0.9.0 → pytgcli-0.10.0}/.github/workflows/publish.yml +13 -12
- pytgcli-0.10.0/.python-version +1 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/AGENTS.md +1 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/PKG-INFO +25 -2
- {pytgcli-0.9.0 → pytgcli-0.10.0}/README.md +22 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/pyproject.toml +2 -1
- {pytgcli-0.9.0 → pytgcli-0.10.0}/src/tgcli/cli.py +74 -6
- {pytgcli-0.9.0 → pytgcli-0.10.0}/src/tgcli/client.py +182 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/src/tgcli/formatting.py +15 -4
- pytgcli-0.10.0/src/tgcli/media.py +243 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/test_cli.py +162 -0
- pytgcli-0.10.0/tests/test_media.py +370 -0
- pytgcli-0.10.0/uv.lock +906 -0
- pytgcli-0.9.0/.python-version +0 -1
- pytgcli-0.9.0/uv.lock +0 -818
- {pytgcli-0.9.0 → pytgcli-0.10.0}/.github/dependabot.yml +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/.gitignore +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/.pre-commit-config.yaml +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/CLAUDE.md +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/LICENSE +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/install.sh +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/src/tgcli/__init__.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/src/tgcli/auth.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/src/tgcli/config.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/src/tgcli/session.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/src/tgcli/update.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/__init__.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/conftest.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/test_auth.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/test_client.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/test_config.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/test_formatting.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/test_session.py +0 -0
- {pytgcli-0.9.0 → pytgcli-0.10.0}/tests/test_update.py +0 -0
|
@@ -9,8 +9,8 @@ jobs:
|
|
|
9
9
|
lint:
|
|
10
10
|
runs-on: ubuntu-latest
|
|
11
11
|
steps:
|
|
12
|
-
- uses: actions/checkout@
|
|
13
|
-
- uses: astral-sh/setup-uv@
|
|
12
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
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
|
|
@@ -21,8 +21,8 @@ jobs:
|
|
|
21
21
|
matrix:
|
|
22
22
|
python-version: ["3.12", "3.13"]
|
|
23
23
|
steps:
|
|
24
|
-
- uses: actions/checkout@
|
|
25
|
-
- uses: astral-sh/setup-uv@
|
|
24
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
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
|
|
@@ -11,7 +11,7 @@ jobs:
|
|
|
11
11
|
permissions:
|
|
12
12
|
contents: read
|
|
13
13
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
14
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
15
15
|
with:
|
|
16
16
|
ref: ${{ github.ref_name }}
|
|
17
17
|
- name: Verify tag matches pyproject version
|
|
@@ -30,10 +30,10 @@ jobs:
|
|
|
30
30
|
permissions:
|
|
31
31
|
contents: read
|
|
32
32
|
steps:
|
|
33
|
-
- uses: actions/checkout@
|
|
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@v2
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
|
@@ -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.10.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
|
|
@@ -12,6 +12,7 @@ Classifier: Environment :: Console
|
|
|
12
12
|
Classifier: License :: OSI Approved :: MIT License
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
16
|
Classifier: Topic :: Communications :: Chat
|
|
16
17
|
Classifier: Typing :: Typed
|
|
17
18
|
Requires-Python: >=3.12
|
|
@@ -90,6 +91,12 @@ tg read "Finance Team" -q "deadline" --from "Alice" --after 2025-01-01
|
|
|
90
91
|
tg context "Finance Team" 12345
|
|
91
92
|
```
|
|
92
93
|
|
|
94
|
+
### 5. Download Attachments
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
tg media "Finance Team" 12345 --out ./downloads
|
|
98
|
+
```
|
|
99
|
+
|
|
93
100
|
## Use with AI Agents
|
|
94
101
|
|
|
95
102
|
Once authenticated, any AI coding agent with shell access can use tgcli directly. A few examples:
|
|
@@ -151,6 +158,22 @@ Read recent messages from a chat. Returns JSONL by default, newest first.
|
|
|
151
158
|
| `--before` | Only messages before date (YYYY-MM-DD) |
|
|
152
159
|
| `--pretty` | Rich table output instead of JSONL |
|
|
153
160
|
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
### `tg media <chat> <message_id>`
|
|
164
|
+
|
|
165
|
+
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.
|
|
166
|
+
|
|
167
|
+
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.
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
| Flag | Description |
|
|
172
|
+
|--------------------|----------------------------------------|
|
|
173
|
+
| `--out` | Directory to save into (default cwd) |
|
|
174
|
+
| `--allow-archives` | Also download zip/7z/tar/gz/rar files |
|
|
175
|
+
| `--max-size` | Size limit in MB (default 100) |
|
|
176
|
+
|
|
154
177
|
### `tg update`
|
|
155
178
|
|
|
156
179
|
Upgrade tgcli to the latest version. Detects the install method and runs the right command (or tells you what to run for Homebrew installs).
|
|
@@ -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:
|
|
@@ -128,6 +134,22 @@ Read recent messages from a chat. Returns JSONL by default, newest first.
|
|
|
128
134
|
| `--before` | Only messages before date (YYYY-MM-DD) |
|
|
129
135
|
| `--pretty` | Rich table output instead of JSONL |
|
|
130
136
|
|
|
137
|
+
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.
|
|
138
|
+
|
|
139
|
+
### `tg media <chat> <message_id>`
|
|
140
|
+
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
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.
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
| Flag | Description |
|
|
148
|
+
|--------------------|----------------------------------------|
|
|
149
|
+
| `--out` | Directory to save into (default cwd) |
|
|
150
|
+
| `--allow-archives` | Also download zip/7z/tar/gz/rar files |
|
|
151
|
+
| `--max-size` | Size limit in MB (default 100) |
|
|
152
|
+
|
|
131
153
|
### `tg update`
|
|
132
154
|
|
|
133
155
|
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.
|
|
7
|
+
version = "0.10.0"
|
|
8
8
|
description = "CLI tool to read Telegram messages from the terminal"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -17,6 +17,7 @@ classifiers = [
|
|
|
17
17
|
"License :: OSI Approved :: MIT License",
|
|
18
18
|
"Programming Language :: Python :: 3.12",
|
|
19
19
|
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Programming Language :: Python :: 3.14",
|
|
20
21
|
"Topic :: Communications :: Chat",
|
|
21
22
|
"Typing :: Typed",
|
|
22
23
|
]
|
|
@@ -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(
|
|
@@ -290,8 +293,11 @@ def chats(
|
|
|
290
293
|
"Read recent messages from a chat. Newest first by default (--head for "
|
|
291
294
|
"oldest).\n\n"
|
|
292
295
|
"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
|
|
296
|
+
"sender_username, sender_id, date, reply_to_msg_id, media_type, and "
|
|
297
|
+
"media_filename. sender_username may be null; sender_id is the most "
|
|
298
|
+
"stable sender key. media_type is null or one of photo, document, video, "
|
|
299
|
+
"voice, sticker, webpage, other; media_filename is the document's "
|
|
300
|
+
"original filename when present.\n\n"
|
|
295
301
|
"--from accepts me, @username, bare username, numeric user ID, phone, "
|
|
296
302
|
"or exact display name. Display names can be ambiguous; prefer username "
|
|
297
303
|
"or numeric ID for automation.\n\n"
|
|
@@ -384,8 +390,9 @@ def read(
|
|
|
384
390
|
@app.command(
|
|
385
391
|
help=(
|
|
386
392
|
"View a message with surrounding context.\n\n"
|
|
387
|
-
"Default JSONL uses the same message fields as `read`
|
|
388
|
-
"flags such as target
|
|
393
|
+
"Default JSONL uses the same message fields as `read` (including "
|
|
394
|
+
"media_type and media_filename) and adds boolean flags such as target "
|
|
395
|
+
"and replied_to on relevant lines."
|
|
389
396
|
)
|
|
390
397
|
)
|
|
391
398
|
def context(
|
|
@@ -439,3 +446,64 @@ def context(
|
|
|
439
446
|
replied_to=(msg.id == replied_to_id),
|
|
440
447
|
)
|
|
441
448
|
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@app.command(
|
|
452
|
+
help=(
|
|
453
|
+
"Download the attachment of one message.\n\n"
|
|
454
|
+
"Prints a single JSON line with id, path, and media_type. Fails when "
|
|
455
|
+
"the message has no downloadable media (webpage previews count as "
|
|
456
|
+
"no media) or the id is not found. Executables, scripts, installers, "
|
|
457
|
+
"and files whose content is an executable in disguise are refused. "
|
|
458
|
+
"Archives (zip, 7z, tar, ...) are refused unless --allow-archives is set. "
|
|
459
|
+
"The file is saved under a name tgcli picks (<message_id>_<original name>, "
|
|
460
|
+
"or <media_type>_<message_id>.<ext>), never overwriting an existing file."
|
|
461
|
+
)
|
|
462
|
+
)
|
|
463
|
+
def media(
|
|
464
|
+
chat: str,
|
|
465
|
+
message_id: int,
|
|
466
|
+
out: Annotated[
|
|
467
|
+
str,
|
|
468
|
+
typer.Option("--out", help="Directory to save into (default: cwd)."),
|
|
469
|
+
] = ".",
|
|
470
|
+
allow_archives: Annotated[
|
|
471
|
+
bool,
|
|
472
|
+
typer.Option("--allow-archives", help="Also download zip/7z/tar archives."),
|
|
473
|
+
] = False,
|
|
474
|
+
max_size: Annotated[
|
|
475
|
+
int,
|
|
476
|
+
typer.Option("--max-size", help="Refuse attachments larger than this many MB."),
|
|
477
|
+
] = 100,
|
|
478
|
+
) -> None:
|
|
479
|
+
"""Download the attachment of one message."""
|
|
480
|
+
from tgcli.client import create_client, download_media
|
|
481
|
+
|
|
482
|
+
os.makedirs(out, exist_ok=True)
|
|
483
|
+
|
|
484
|
+
async def _run():
|
|
485
|
+
client = create_client()
|
|
486
|
+
async with client:
|
|
487
|
+
return await download_media(
|
|
488
|
+
client,
|
|
489
|
+
chat,
|
|
490
|
+
message_id,
|
|
491
|
+
out,
|
|
492
|
+
allow_archives=allow_archives,
|
|
493
|
+
max_bytes=max_size * 1024 * 1024,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
path, media_type = asyncio.run(_run())
|
|
498
|
+
except SystemExit as e:
|
|
499
|
+
stderr.print(f"[red]Configuration error:[/red] {e}")
|
|
500
|
+
stderr.print("Run `tg auth` to set up.")
|
|
501
|
+
raise typer.Exit(1)
|
|
502
|
+
except UnauthorizedError:
|
|
503
|
+
stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
|
|
504
|
+
raise typer.Exit(2)
|
|
505
|
+
except Exception as e:
|
|
506
|
+
stderr.print(f"[red]Download failed:[/red] {e}")
|
|
507
|
+
raise typer.Exit(1)
|
|
508
|
+
|
|
509
|
+
print(json.dumps({"id": message_id, "path": path, "media_type": media_type}))
|
|
@@ -1,13 +1,34 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import zipfile
|
|
4
7
|
from datetime import datetime
|
|
5
8
|
|
|
6
9
|
from telethon import TelegramClient
|
|
7
10
|
from telethon.sessions import StringSession
|
|
11
|
+
from telethon.tl.types import (
|
|
12
|
+
DocumentAttributeAudio,
|
|
13
|
+
DocumentAttributeFilename,
|
|
14
|
+
DocumentAttributeSticker,
|
|
15
|
+
DocumentAttributeVideo,
|
|
16
|
+
MessageMediaDocument,
|
|
17
|
+
MessageMediaPhoto,
|
|
18
|
+
MessageMediaWebPage,
|
|
19
|
+
)
|
|
8
20
|
|
|
9
21
|
from tgcli.config import TelegramConfig, load_config
|
|
10
22
|
from tgcli.formatting import ChatData, MessageData
|
|
23
|
+
from tgcli.media import (
|
|
24
|
+
DEFAULT_MAX_BYTES,
|
|
25
|
+
HEAD_SIZE,
|
|
26
|
+
ISO_MARKER_OFFSET,
|
|
27
|
+
TAIL_SIZE,
|
|
28
|
+
content_reject_reason,
|
|
29
|
+
reject_reason,
|
|
30
|
+
target_filename,
|
|
31
|
+
)
|
|
11
32
|
from tgcli.session import load_session
|
|
12
33
|
|
|
13
34
|
_USERNAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{4,31}$")
|
|
@@ -116,7 +137,33 @@ async def _resolve_sender(client: TelegramClient, chat_entity, value: str):
|
|
|
116
137
|
)
|
|
117
138
|
|
|
118
139
|
|
|
140
|
+
def _media_info(media) -> tuple[str | None, str | None]:
|
|
141
|
+
"""Classify a Telethon media object as (media_type, filename)."""
|
|
142
|
+
if media is None:
|
|
143
|
+
return None, None
|
|
144
|
+
if isinstance(media, MessageMediaPhoto):
|
|
145
|
+
return "photo", None
|
|
146
|
+
if isinstance(media, MessageMediaWebPage):
|
|
147
|
+
return "webpage", None
|
|
148
|
+
if not isinstance(media, MessageMediaDocument):
|
|
149
|
+
return "other", None
|
|
150
|
+
|
|
151
|
+
attrs = getattr(media.document, "attributes", None) or []
|
|
152
|
+
filename = next(
|
|
153
|
+
(a.file_name for a in attrs if isinstance(a, DocumentAttributeFilename)),
|
|
154
|
+
None,
|
|
155
|
+
)
|
|
156
|
+
if any(isinstance(a, DocumentAttributeSticker) for a in attrs):
|
|
157
|
+
return "sticker", filename
|
|
158
|
+
if any(isinstance(a, DocumentAttributeAudio) and a.voice for a in attrs):
|
|
159
|
+
return "voice", filename
|
|
160
|
+
if any(isinstance(a, DocumentAttributeVideo) for a in attrs):
|
|
161
|
+
return "video", filename
|
|
162
|
+
return "document", filename
|
|
163
|
+
|
|
164
|
+
|
|
119
165
|
def _msg_to_data(msg, chat_name: str, sender) -> MessageData:
|
|
166
|
+
media_type, media_filename = _media_info(getattr(msg, "media", None))
|
|
120
167
|
return MessageData(
|
|
121
168
|
id=msg.id,
|
|
122
169
|
text=msg.text or "",
|
|
@@ -126,6 +173,8 @@ def _msg_to_data(msg, chat_name: str, sender) -> MessageData:
|
|
|
126
173
|
sender_username=_get_username(sender),
|
|
127
174
|
sender_id=_get_sender_id(msg, sender),
|
|
128
175
|
reply_to_msg_id=msg.reply_to.reply_to_msg_id if msg.reply_to else None,
|
|
176
|
+
media_type=media_type,
|
|
177
|
+
media_filename=media_filename,
|
|
129
178
|
)
|
|
130
179
|
|
|
131
180
|
|
|
@@ -280,3 +329,136 @@ async def get_context(
|
|
|
280
329
|
replied_to = _msg_to_data(reply_msg, chat_name, sender)
|
|
281
330
|
|
|
282
331
|
return messages, message_id, replied_to
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def download_media(
|
|
335
|
+
client: TelegramClient,
|
|
336
|
+
chat: str,
|
|
337
|
+
message_id: int,
|
|
338
|
+
out_dir: str,
|
|
339
|
+
*,
|
|
340
|
+
allow_archives: bool = False,
|
|
341
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
342
|
+
) -> tuple[str, str]:
|
|
343
|
+
"""Download the attachment of one message into out_dir.
|
|
344
|
+
|
|
345
|
+
Returns (saved_path, media_type). Raises ValueError when the message
|
|
346
|
+
does not exist, has no downloadable media, or the attachment is refused
|
|
347
|
+
by the safety rules in tgcli.media.
|
|
348
|
+
|
|
349
|
+
The transfer goes into a staging file tgcli creates itself (so the
|
|
350
|
+
sender never picks the path), is inspected, and only then linked to
|
|
351
|
+
its final name. Anything that fails on the way is removed.
|
|
352
|
+
"""
|
|
353
|
+
entity = await _resolve_entity(client, chat)
|
|
354
|
+
msg = await client.get_messages(entity, ids=message_id)
|
|
355
|
+
if msg is None:
|
|
356
|
+
raise ValueError(f"Message {message_id} not found in this chat.")
|
|
357
|
+
|
|
358
|
+
media = getattr(msg, "media", None)
|
|
359
|
+
media_type, filename = _media_info(media)
|
|
360
|
+
if media_type in (None, "webpage", "other"):
|
|
361
|
+
raise ValueError(f"Message {message_id} has no downloadable media.")
|
|
362
|
+
|
|
363
|
+
document = getattr(media, "document", None)
|
|
364
|
+
mime_type = getattr(document, "mime_type", None) if document else None
|
|
365
|
+
size = getattr(document, "size", None) if document else None
|
|
366
|
+
if isinstance(size, int) and size > max_bytes:
|
|
367
|
+
raise ValueError(
|
|
368
|
+
f"Refusing to download message {message_id}: "
|
|
369
|
+
f"{size} bytes exceeds the {max_bytes} byte limit."
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
name = target_filename(media_type, message_id, filename, mime_type)
|
|
373
|
+
reason = reject_reason(name, mime_type, allow_archives=allow_archives)
|
|
374
|
+
if reason:
|
|
375
|
+
raise ValueError(f"Refusing to download message {message_id}: {reason}.")
|
|
376
|
+
|
|
377
|
+
staged = os.path.join(out_dir, f".tgcli-{message_id}-{os.getpid()}.part")
|
|
378
|
+
try:
|
|
379
|
+
with open(staged, "xb") as f:
|
|
380
|
+
writer = _BoundedWriter(f, max_bytes)
|
|
381
|
+
await client.download_media(msg, file=writer)
|
|
382
|
+
if writer.written == 0:
|
|
383
|
+
raise ValueError(f"Message {message_id} has no downloadable media.")
|
|
384
|
+
|
|
385
|
+
with open(staged, "rb") as f:
|
|
386
|
+
head = f.read(HEAD_SIZE)
|
|
387
|
+
f.seek(ISO_MARKER_OFFSET)
|
|
388
|
+
iso_marker = f.read(5)
|
|
389
|
+
f.seek(0, os.SEEK_END)
|
|
390
|
+
f.seek(max(0, f.tell() - TAIL_SIZE))
|
|
391
|
+
tail = f.read(TAIL_SIZE)
|
|
392
|
+
zip_members = None
|
|
393
|
+
if zipfile.is_zipfile(staged):
|
|
394
|
+
try:
|
|
395
|
+
with zipfile.ZipFile(staged) as zf:
|
|
396
|
+
zip_members = zf.namelist()
|
|
397
|
+
except zipfile.BadZipFile:
|
|
398
|
+
zip_members = None
|
|
399
|
+
reason = content_reject_reason(
|
|
400
|
+
name,
|
|
401
|
+
head=head,
|
|
402
|
+
tail=tail,
|
|
403
|
+
iso_marker=iso_marker,
|
|
404
|
+
zip_members=zip_members,
|
|
405
|
+
allow_archives=allow_archives,
|
|
406
|
+
)
|
|
407
|
+
if reason:
|
|
408
|
+
raise ValueError(f"Refusing to keep message {message_id}: {reason}.")
|
|
409
|
+
|
|
410
|
+
final = _copy_unique(staged, os.path.join(out_dir, name))
|
|
411
|
+
finally:
|
|
412
|
+
if os.path.lexists(staged):
|
|
413
|
+
os.remove(staged)
|
|
414
|
+
return final, media_type
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class _BoundedWriter:
|
|
418
|
+
"""File wrapper that aborts the transfer once max_bytes is exceeded.
|
|
419
|
+
|
|
420
|
+
Telethon only needs write(), tell(), and flush(). The pre-transfer size
|
|
421
|
+
check relies on the server-reported size; this bounds what actually
|
|
422
|
+
lands on disk regardless of what the sender or server claims.
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
def __init__(self, f, max_bytes: int) -> None:
|
|
426
|
+
self._f = f
|
|
427
|
+
self._max = max_bytes
|
|
428
|
+
self.written = 0
|
|
429
|
+
|
|
430
|
+
def write(self, chunk) -> int:
|
|
431
|
+
self.written += len(chunk)
|
|
432
|
+
if self.written > self._max:
|
|
433
|
+
raise ValueError(f"transfer exceeded the {self._max} byte limit")
|
|
434
|
+
return self._f.write(chunk)
|
|
435
|
+
|
|
436
|
+
def tell(self) -> int:
|
|
437
|
+
return self._f.tell()
|
|
438
|
+
|
|
439
|
+
def flush(self) -> None:
|
|
440
|
+
self._f.flush()
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _copy_unique(src: str, dest: str) -> str:
|
|
444
|
+
"""Copy src to dest, adding a counter if dest already exists.
|
|
445
|
+
|
|
446
|
+
The destination is created with O_EXCL (and O_NOFOLLOW where the
|
|
447
|
+
platform has it), so an existing file or symlink of that name, even a
|
|
448
|
+
dangling one, is never overwritten or followed. A plain copy is used
|
|
449
|
+
instead of a hard link so exFAT and SMB output directories work.
|
|
450
|
+
"""
|
|
451
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
452
|
+
flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
|
453
|
+
stem, ext = os.path.splitext(dest)
|
|
454
|
+
candidate = dest
|
|
455
|
+
for i in range(1, 1000):
|
|
456
|
+
try:
|
|
457
|
+
fd = os.open(candidate, flags, 0o644)
|
|
458
|
+
except FileExistsError:
|
|
459
|
+
candidate = f"{stem} ({i}){ext}"
|
|
460
|
+
continue
|
|
461
|
+
with os.fdopen(fd, "wb") as out, open(src, "rb") as inp:
|
|
462
|
+
shutil.copyfileobj(inp, out)
|
|
463
|
+
return candidate
|
|
464
|
+
raise ValueError(f"Too many files named like {os.path.basename(dest)}.")
|
|
@@ -4,6 +4,7 @@ import json
|
|
|
4
4
|
from dataclasses import asdict, dataclass
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
|
|
7
|
+
from rich.markup import escape
|
|
7
8
|
from rich.table import Table
|
|
8
9
|
from rich.text import Text
|
|
9
10
|
|
|
@@ -27,6 +28,8 @@ class MessageData:
|
|
|
27
28
|
sender_username: str | None = None
|
|
28
29
|
sender_id: int | None = None
|
|
29
30
|
reply_to_msg_id: int | None = None
|
|
31
|
+
media_type: str | None = None
|
|
32
|
+
media_filename: str | None = None
|
|
30
33
|
|
|
31
34
|
|
|
32
35
|
def _truncate(text: str, max_lines: int = 3) -> str:
|
|
@@ -50,6 +53,14 @@ def format_message_jsonl(msg: MessageData, **flags: bool) -> str:
|
|
|
50
53
|
return json.dumps(d, ensure_ascii=False)
|
|
51
54
|
|
|
52
55
|
|
|
56
|
+
def _message_cell(msg: MessageData) -> str:
|
|
57
|
+
text = escape(_truncate(msg.text))
|
|
58
|
+
if msg.media_type:
|
|
59
|
+
marker = escape(f"[{msg.media_type}]")
|
|
60
|
+
return f"{text} {marker}" if text else marker
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
|
|
53
64
|
def format_search_results(messages: list[MessageData]) -> Table:
|
|
54
65
|
"""Build a Rich Table for search results."""
|
|
55
66
|
table = Table(show_header=True, header_style="bold")
|
|
@@ -61,9 +72,9 @@ def format_search_results(messages: list[MessageData]) -> Table:
|
|
|
61
72
|
for msg in messages:
|
|
62
73
|
table.add_row(
|
|
63
74
|
msg.date.strftime("%Y-%m-%d %H:%M"),
|
|
64
|
-
msg.chat_name,
|
|
65
|
-
msg.sender_name,
|
|
66
|
-
|
|
75
|
+
escape(msg.chat_name),
|
|
76
|
+
escape(msg.sender_name),
|
|
77
|
+
_message_cell(msg),
|
|
67
78
|
)
|
|
68
79
|
|
|
69
80
|
return table
|
|
@@ -120,7 +131,7 @@ def format_chats_table(chats: list[ChatData]) -> Table:
|
|
|
120
131
|
for chat in chats:
|
|
121
132
|
unread = str(chat.unread_count) if chat.unread_count else ""
|
|
122
133
|
date = chat.date.strftime("%Y-%m-%d") if chat.date else ""
|
|
123
|
-
table.add_row(chat.name, chat.chat_type, unread, date)
|
|
134
|
+
table.add_row(escape(chat.name), chat.chat_type, unread, date)
|
|
124
135
|
|
|
125
136
|
return table
|
|
126
137
|
|