pytgcli 0.9.1__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.
Files changed (33) hide show
  1. {pytgcli-0.9.1 → pytgcli-0.10.0}/.github/workflows/ci.yml +2 -2
  2. {pytgcli-0.9.1 → pytgcli-0.10.0}/.github/workflows/publish.yml +11 -10
  3. {pytgcli-0.9.1 → pytgcli-0.10.0}/AGENTS.md +1 -0
  4. {pytgcli-0.9.1 → pytgcli-0.10.0}/PKG-INFO +24 -2
  5. {pytgcli-0.9.1 → pytgcli-0.10.0}/README.md +22 -0
  6. {pytgcli-0.9.1 → pytgcli-0.10.0}/pyproject.toml +1 -1
  7. {pytgcli-0.9.1 → pytgcli-0.10.0}/src/tgcli/cli.py +74 -6
  8. {pytgcli-0.9.1 → pytgcli-0.10.0}/src/tgcli/client.py +182 -0
  9. {pytgcli-0.9.1 → pytgcli-0.10.0}/src/tgcli/formatting.py +15 -4
  10. pytgcli-0.10.0/src/tgcli/media.py +243 -0
  11. {pytgcli-0.9.1 → pytgcli-0.10.0}/tests/test_cli.py +162 -0
  12. pytgcli-0.10.0/tests/test_media.py +370 -0
  13. {pytgcli-0.9.1 → pytgcli-0.10.0}/uv.lock +164 -94
  14. {pytgcli-0.9.1 → pytgcli-0.10.0}/.github/dependabot.yml +0 -0
  15. {pytgcli-0.9.1 → pytgcli-0.10.0}/.gitignore +0 -0
  16. {pytgcli-0.9.1 → pytgcli-0.10.0}/.pre-commit-config.yaml +0 -0
  17. {pytgcli-0.9.1 → pytgcli-0.10.0}/.python-version +0 -0
  18. {pytgcli-0.9.1 → pytgcli-0.10.0}/CLAUDE.md +0 -0
  19. {pytgcli-0.9.1 → pytgcli-0.10.0}/LICENSE +0 -0
  20. {pytgcli-0.9.1 → pytgcli-0.10.0}/install.sh +0 -0
  21. {pytgcli-0.9.1 → pytgcli-0.10.0}/src/tgcli/__init__.py +0 -0
  22. {pytgcli-0.9.1 → pytgcli-0.10.0}/src/tgcli/auth.py +0 -0
  23. {pytgcli-0.9.1 → pytgcli-0.10.0}/src/tgcli/config.py +0 -0
  24. {pytgcli-0.9.1 → pytgcli-0.10.0}/src/tgcli/session.py +0 -0
  25. {pytgcli-0.9.1 → pytgcli-0.10.0}/src/tgcli/update.py +0 -0
  26. {pytgcli-0.9.1 → pytgcli-0.10.0}/tests/__init__.py +0 -0
  27. {pytgcli-0.9.1 → pytgcli-0.10.0}/tests/conftest.py +0 -0
  28. {pytgcli-0.9.1 → pytgcli-0.10.0}/tests/test_auth.py +0 -0
  29. {pytgcli-0.9.1 → pytgcli-0.10.0}/tests/test_client.py +0 -0
  30. {pytgcli-0.9.1 → pytgcli-0.10.0}/tests/test_config.py +0 -0
  31. {pytgcli-0.9.1 → pytgcli-0.10.0}/tests/test_formatting.py +0 -0
  32. {pytgcli-0.9.1 → pytgcli-0.10.0}/tests/test_session.py +0 -0
  33. {pytgcli-0.9.1 → pytgcli-0.10.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@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:${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.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
@@ -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:
@@ -152,6 +158,22 @@ Read recent messages from a chat. Returns JSONL by default, newest first.
152
158
  | `--before` | Only messages before date (YYYY-MM-DD) |
153
159
  | `--pretty` | Rich table output instead of JSONL |
154
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
+
155
177
  ### `tg update`
156
178
 
157
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.9.1"
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"
@@ -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(
@@ -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 reply_to_msg_id. "
294
- "sender_username may be null; sender_id is the most stable sender key.\n\n"
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` and adds boolean "
388
- "flags such as target and replied_to on relevant lines."
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
- _truncate(msg.text),
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