pytgcli 0.10.0__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 (33) hide show
  1. {pytgcli-0.10.0 → pytgcli-0.11.0}/.github/workflows/publish.yml +1 -1
  2. {pytgcli-0.10.0 → pytgcli-0.11.0}/PKG-INFO +33 -5
  3. {pytgcli-0.10.0 → pytgcli-0.11.0}/README.md +32 -4
  4. {pytgcli-0.10.0 → pytgcli-0.11.0}/pyproject.toml +1 -1
  5. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/cli.py +57 -7
  6. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/client.py +58 -6
  7. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/formatting.py +1 -0
  8. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_cli.py +197 -4
  9. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_client.py +177 -1
  10. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_formatting.py +23 -0
  11. {pytgcli-0.10.0 → pytgcli-0.11.0}/uv.lock +1 -1
  12. {pytgcli-0.10.0 → pytgcli-0.11.0}/.github/dependabot.yml +0 -0
  13. {pytgcli-0.10.0 → pytgcli-0.11.0}/.github/workflows/ci.yml +0 -0
  14. {pytgcli-0.10.0 → pytgcli-0.11.0}/.gitignore +0 -0
  15. {pytgcli-0.10.0 → pytgcli-0.11.0}/.pre-commit-config.yaml +0 -0
  16. {pytgcli-0.10.0 → pytgcli-0.11.0}/.python-version +0 -0
  17. {pytgcli-0.10.0 → pytgcli-0.11.0}/AGENTS.md +0 -0
  18. {pytgcli-0.10.0 → pytgcli-0.11.0}/CLAUDE.md +0 -0
  19. {pytgcli-0.10.0 → pytgcli-0.11.0}/LICENSE +0 -0
  20. {pytgcli-0.10.0 → pytgcli-0.11.0}/install.sh +0 -0
  21. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/__init__.py +0 -0
  22. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/auth.py +0 -0
  23. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/config.py +0 -0
  24. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/media.py +0 -0
  25. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/session.py +0 -0
  26. {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/update.py +0 -0
  27. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/__init__.py +0 -0
  28. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/conftest.py +0 -0
  29. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_auth.py +0 -0
  30. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_config.py +0 -0
  31. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_media.py +0 -0
  32. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_session.py +0 -0
  33. {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_update.py +0 -0
@@ -91,7 +91,7 @@ jobs:
91
91
  steps:
92
92
  - name: Mint tap token
93
93
  id: tap-token
94
- uses: actions/create-github-app-token@v2
94
+ uses: actions/create-github-app-token@v3
95
95
  with:
96
96
  app-id: ${{ secrets.TAP_APP_ID }}
97
97
  private-key: ${{ secrets.TAP_APP_PRIVATE_KEY }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytgcli
3
- Version: 0.10.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, 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
 
@@ -116,9 +116,12 @@ The agent runs `tg read "Alice" -q "deployment" --after 2025-02-14` and surfaces
116
116
  **Pipe into scripts:**
117
117
 
118
118
  ```bash
119
+ set -o pipefail
119
120
  tg read "Alerts" --limit 100 | jq 'select(.text | test("ERROR"))'
120
121
  ```
121
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
+
122
125
  No wrapper libraries or API adapters needed. The structured output and simple command surface mean agents can use tgcli out of the box.
123
126
 
124
127
  ## Commands
@@ -138,16 +141,41 @@ Explicit subcommands:
138
141
 
139
142
  List your Telegram chats. Returns JSONL by default.
140
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
+
141
151
  | Flag | Description |
142
152
  |------------|------------------------------|
143
- | `--filter` | Fuzzy filter by chat name |
144
- | `--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) |
145
155
  | `--pretty` | Rich table output instead of JSONL |
146
156
 
157
+ Increase `--limit` if a chat is outside the scanned set.
158
+
147
159
  ### `tg read <chat>`
148
160
 
149
161
  Read recent messages from a chat. Returns JSONL by default, newest first.
150
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
+
151
179
  | Flag | Description |
152
180
  |----------------|----------------------------------------|
153
181
  | `--query`/`-q` | Filter messages by text |
@@ -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
 
@@ -92,9 +92,12 @@ The agent runs `tg read "Alice" -q "deployment" --after 2025-02-14` and surfaces
92
92
  **Pipe into scripts:**
93
93
 
94
94
  ```bash
95
+ set -o pipefail
95
96
  tg read "Alerts" --limit 100 | jq 'select(.text | test("ERROR"))'
96
97
  ```
97
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
+
98
101
  No wrapper libraries or API adapters needed. The structured output and simple command surface mean agents can use tgcli out of the box.
99
102
 
100
103
  ## Commands
@@ -114,16 +117,41 @@ Explicit subcommands:
114
117
 
115
118
  List your Telegram chats. Returns JSONL by default.
116
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
+
117
127
  | Flag | Description |
118
128
  |------------|------------------------------|
119
- | `--filter` | Fuzzy filter by chat name |
120
- | `--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) |
121
131
  | `--pretty` | Rich table output instead of JSONL |
122
132
 
133
+ Increase `--limit` if a chat is outside the scanned set.
134
+
123
135
  ### `tg read <chat>`
124
136
 
125
137
  Read recent messages from a chat. Returns JSONL by default, newest first.
126
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
+
127
155
  | Flag | Description |
128
156
  |----------------|----------------------------------------|
129
157
  | `--query`/`-q` | Filter messages by text |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytgcli"
7
- version = "0.10.0"
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"
@@ -242,17 +242,43 @@ def _parse_date(value: str) -> datetime:
242
242
  return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=UTC)
243
243
 
244
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
+
245
260
  @app.command()
246
261
  def chats(
247
262
  filter_: Annotated[
248
- 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
+ ),
249
267
  ] = None,
250
- 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,
251
271
  pretty: Annotated[
252
272
  bool, typer.Option("--pretty", help="Rich table output.")
253
273
  ] = False,
254
274
  ) -> None:
255
- """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
+ """
256
282
  from tgcli.client import create_client, list_chats
257
283
 
258
284
  async def _run():
@@ -292,6 +318,13 @@ def chats(
292
318
  help=(
293
319
  "Read recent messages from a chat. Newest first by default (--head for "
294
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"
295
328
  "Default JSONL includes id, text, chat_name, sender_name, "
296
329
  "sender_username, sender_id, date, reply_to_msg_id, media_type, and "
297
330
  "media_filename. sender_username may be null; sender_id is the most "
@@ -306,7 +339,9 @@ def chats(
306
339
  )
307
340
  )
308
341
  def read(
309
- 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
+ ],
310
345
  query: Annotated[
311
346
  str | None, typer.Option("--query", "-q", help="Filter messages by text.")
312
347
  ] = None,
@@ -335,7 +370,7 @@ def read(
335
370
  ] = False,
336
371
  ) -> None:
337
372
  """Read recent messages from a chat."""
338
- from tgcli.client import create_client, read_messages
373
+ from tgcli.client import ChatResolutionError, create_client, read_messages
339
374
 
340
375
  try:
341
376
  after_dt = _parse_date(after) if after else None
@@ -368,6 +403,9 @@ def read(
368
403
  except UnauthorizedError:
369
404
  stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
370
405
  raise typer.Exit(2)
406
+ except ChatResolutionError as e:
407
+ _report_chat_resolution_error(e)
408
+ raise typer.Exit(1)
371
409
  except Exception as e:
372
410
  stderr.print(f"[red]Read failed:[/red] {e}")
373
411
  raise typer.Exit(1)
@@ -390,6 +428,9 @@ def read(
390
428
  @app.command(
391
429
  help=(
392
430
  "View a message with surrounding context.\n\n"
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"
393
434
  "Default JSONL uses the same message fields as `read` (including "
394
435
  "media_type and media_filename) and adds boolean flags such as target "
395
436
  "and replied_to on relevant lines."
@@ -406,7 +447,7 @@ def context(
406
447
  ] = False,
407
448
  ) -> None:
408
449
  """View a message with surrounding context."""
409
- from tgcli.client import create_client, get_context
450
+ from tgcli.client import ChatResolutionError, create_client, get_context
410
451
 
411
452
  async def _run():
412
453
  client = create_client()
@@ -422,6 +463,9 @@ def context(
422
463
  except UnauthorizedError:
423
464
  stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
424
465
  raise typer.Exit(2)
466
+ except ChatResolutionError as e:
467
+ _report_chat_resolution_error(e)
468
+ raise typer.Exit(1)
425
469
  except Exception as e:
426
470
  stderr.print(f"[red]Context fetch failed:[/red] {e}")
427
471
  raise typer.Exit(1)
@@ -451,6 +495,9 @@ def context(
451
495
  @app.command(
452
496
  help=(
453
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"
454
501
  "Prints a single JSON line with id, path, and media_type. Fails when "
455
502
  "the message has no downloadable media (webpage previews count as "
456
503
  "no media) or the id is not found. Executables, scripts, installers, "
@@ -477,7 +524,7 @@ def media(
477
524
  ] = 100,
478
525
  ) -> None:
479
526
  """Download the attachment of one message."""
480
- from tgcli.client import create_client, download_media
527
+ from tgcli.client import ChatResolutionError, create_client, download_media
481
528
 
482
529
  os.makedirs(out, exist_ok=True)
483
530
 
@@ -502,6 +549,9 @@ def media(
502
549
  except UnauthorizedError:
503
550
  stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
504
551
  raise typer.Exit(2)
552
+ except ChatResolutionError as e:
553
+ _report_chat_resolution_error(e)
554
+ raise typer.Exit(1)
505
555
  except Exception as e:
506
556
  stderr.print(f"[red]Download failed:[/red] {e}")
507
557
  raise typer.Exit(1)
@@ -1,14 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import os
4
5
  import re
5
6
  import shutil
7
+ import sys
6
8
  import zipfile
7
9
  from datetime import datetime
10
+ from difflib import get_close_matches
8
11
 
9
12
  from telethon import TelegramClient
13
+ from telethon.errors import UsernameInvalidError
10
14
  from telethon.sessions import StringSession
11
15
  from telethon.tl.types import (
16
+ ChatEmpty,
12
17
  DocumentAttributeAudio,
13
18
  DocumentAttributeFilename,
14
19
  DocumentAttributeSticker,
@@ -34,6 +39,25 @@ from tgcli.session import load_session
34
39
  _USERNAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{4,31}$")
35
40
 
36
41
 
42
+ class ChatResolutionError(ValueError):
43
+ def __init__(self, query: str, candidates: list[str]) -> None:
44
+ self.query = query
45
+ self.candidates = candidates
46
+ message = f"Cannot find chat {json.dumps(query, ensure_ascii=False)}. "
47
+ if candidates:
48
+ message += (
49
+ "Did you mean:\n"
50
+ + "\n".join(
51
+ json.dumps(candidate, ensure_ascii=False)
52
+ for candidate in candidates
53
+ )
54
+ + "?"
55
+ )
56
+ else:
57
+ message += "Use `tg chats --filter` to find exact names."
58
+ super().__init__(message)
59
+
60
+
37
61
  def create_client(config: TelegramConfig | None = None) -> TelegramClient:
38
62
  """Create a TelegramClient using stored session and config."""
39
63
  config = config or load_config()
@@ -80,25 +104,52 @@ async def _resolve_entity(client: TelegramClient, name: str):
80
104
  """Resolve a name to a Telethon entity.
81
105
 
82
106
  Handles @usernames, phone numbers, and numeric IDs via get_entity().
83
- Plain names are matched case-insensitively against dialog names.
107
+ Plain names prefer exact matches, then unique case-insensitive substrings.
84
108
  """
85
109
  if name.lower() == "me":
86
110
  return await client.get_me()
87
111
 
88
- if name.startswith("@") or name.startswith("+") or name.lstrip("-").isdigit():
112
+ if re.fullmatch(r"-?[0-9]+", name):
113
+ chat_id = int(name)
114
+ try:
115
+ entity = await client.get_entity(chat_id)
116
+ if not isinstance(entity, ChatEmpty):
117
+ return entity
118
+ except ValueError:
119
+ pass
120
+ # StringSession does not persist the entity cache across invocations.
121
+ async for dialog in client.iter_dialogs():
122
+ if (dialog.entity.id if chat_id >= 0 else dialog.id) == chat_id:
123
+ return dialog.entity
124
+ raise ChatResolutionError(name, [])
125
+
126
+ if name.startswith("@") or name.startswith("+"):
89
127
  try:
90
128
  return await client.get_entity(name)
91
- except Exception: # noqa: S110
129
+ except (ValueError, UsernameInvalidError):
92
130
  pass
93
131
 
94
132
  name_lower = name.lower()
133
+ dialogs = []
95
134
  async for dialog in client.iter_dialogs():
96
135
  if dialog.name.lower() == name_lower:
97
136
  return dialog.entity
137
+ dialogs.append(dialog)
138
+
139
+ matches = [dialog for dialog in dialogs if name_lower in dialog.name.lower()]
140
+ if len(matches) == 1:
141
+ print(
142
+ f"Resolved {json.dumps(name, ensure_ascii=False)} -> "
143
+ f"{json.dumps(matches[0].name, ensure_ascii=False)}",
144
+ file=sys.stderr,
145
+ )
146
+ return matches[0].entity
147
+ if matches:
148
+ raise ChatResolutionError(name, [dialog.name for dialog in matches])
98
149
 
99
- raise ValueError(
100
- f'Cannot find chat "{name}". Use `tg chats --filter` to find exact names.'
101
- )
150
+ close_names = get_close_matches(name_lower, [d.name.lower() for d in dialogs])
151
+ candidates = [d.name for d in dialogs if d.name.lower() in close_names]
152
+ raise ChatResolutionError(name, candidates)
102
153
 
103
154
 
104
155
  async def _resolve_sender(client: TelegramClient, chat_entity, value: str):
@@ -207,6 +258,7 @@ async def list_chats(
207
258
  if not filter_lower or filter_lower in dialog.name.lower():
208
259
  results.append(
209
260
  ChatData(
261
+ id=dialog.entity.id,
210
262
  name=dialog.name,
211
263
  chat_type=_chat_type(dialog.entity),
212
264
  unread_count=dialog.unread_count,
@@ -11,6 +11,7 @@ from rich.text import Text
11
11
 
12
12
  @dataclass(frozen=True)
13
13
  class ChatData:
14
+ id: int
14
15
  name: str
15
16
  chat_type: str
16
17
  unread_count: int
@@ -1,14 +1,17 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import re
4
5
  from datetime import UTC, datetime
6
+ from types import SimpleNamespace
5
7
  from unittest.mock import AsyncMock, MagicMock, patch
6
8
 
9
+ import pytest
7
10
  from telethon.errors import UnauthorizedError
8
11
  from typer.testing import CliRunner
9
12
 
10
13
  from tgcli.cli import app
11
- from tgcli.formatting import MessageData
14
+ from tgcli.formatting import ChatData, MessageData
12
15
 
13
16
  runner = CliRunner()
14
17
 
@@ -190,6 +193,49 @@ class TestAuthSmart:
190
193
 
191
194
 
192
195
  class TestChats:
196
+ @patch("tgcli.client.create_client")
197
+ @patch("tgcli.client.list_chats", new_callable=AsyncMock)
198
+ def test_chats_jsonl_includes_id(self, mock_list_chats, mock_create):
199
+ mock_create.return_value = AsyncMock()
200
+ mock_list_chats.return_value = [
201
+ ChatData(
202
+ id=123,
203
+ name="Mira Vale | Example",
204
+ chat_type="user",
205
+ unread_count=0,
206
+ pinned=False,
207
+ date=None,
208
+ )
209
+ ]
210
+
211
+ result = runner.invoke(app, ["chats"])
212
+
213
+ assert result.exit_code == 0
214
+ assert json.loads(result.stdout)["id"] == 123
215
+ assert result.stderr == ""
216
+
217
+ @patch("tgcli.client.create_client")
218
+ def test_chats_lists_entity_id_from_dialog(self, mock_create):
219
+ async def dialogs(**kwargs):
220
+ yield SimpleNamespace(
221
+ id=-1000000000123,
222
+ name="Mira Vale | Example",
223
+ entity=SimpleNamespace(id=123),
224
+ unread_count=0,
225
+ pinned=False,
226
+ date=None,
227
+ )
228
+
229
+ client = AsyncMock()
230
+ client.iter_dialogs = MagicMock(side_effect=dialogs)
231
+ mock_create.return_value = client
232
+
233
+ result = runner.invoke(app, ["chats", "--filter", "mira"])
234
+
235
+ assert result.exit_code == 0
236
+ assert json.loads(result.stdout)["id"] == 123
237
+ assert result.stderr == ""
238
+
193
239
  @patch("tgcli.client.create_client")
194
240
  @patch("tgcli.client.list_chats", new_callable=AsyncMock)
195
241
  def test_chats_unauthorized_exits_2(self, mock_list_chats, mock_create):
@@ -600,7 +646,150 @@ class TestContext:
600
646
  assert "Configuration error" in result.output
601
647
 
602
648
 
649
+ class TestChatResolutionFailures:
650
+ @pytest.mark.parametrize("exact", [False, True])
651
+ @patch("tgcli.client.create_client")
652
+ def test_read_resolution_notice_keeps_stdout_jsonl(self, mock_create, exact):
653
+ name = "Mira Vale" if exact else "Mira Vale | Example"
654
+ entity = SimpleNamespace(id=123, first_name=name, last_name=None)
655
+ message = SimpleNamespace(
656
+ id=10,
657
+ text="hello",
658
+ date=datetime(2025, 6, 15, tzinfo=UTC),
659
+ reply_to=None,
660
+ media=None,
661
+ sender_id=123,
662
+ get_sender=AsyncMock(return_value=entity),
663
+ )
664
+
665
+ async def dialogs():
666
+ yield SimpleNamespace(name=name, entity=entity)
667
+
668
+ async def messages(*args, **kwargs):
669
+ yield message
670
+
671
+ client = AsyncMock()
672
+ client.iter_dialogs = MagicMock(side_effect=dialogs)
673
+ client.iter_messages = MagicMock(side_effect=messages)
674
+ mock_create.return_value = client
675
+
676
+ result = runner.invoke(app, ["read", "Mira Vale"])
677
+
678
+ assert result.exit_code == 0
679
+ assert json.loads(result.stdout)["chat_name"] == name
680
+ assert result.stderr == (
681
+ "" if exact else 'Resolved "Mira Vale" -> "Mira Vale | Example"\n'
682
+ )
683
+
684
+ @pytest.mark.parametrize("command", ["read", "context", "media"])
685
+ @pytest.mark.parametrize(
686
+ ("query", "names", "candidates"),
687
+ [
688
+ (
689
+ "Mira Vale",
690
+ ["Mira Vale | Example", "Mira Vale | Studio"],
691
+ ["Mira Vale | Example", "Mira Vale | Studio"],
692
+ ),
693
+ ("zzzzzz", ["Project Alpha"], []),
694
+ ("mira vlae", ["Mira Vale"], ["Mira Vale"]),
695
+ ],
696
+ )
697
+ @patch("tgcli.client.create_client")
698
+ def test_actual_resolver_errors_emit_json_to_stderr(
699
+ self, mock_create, command, query, names, candidates
700
+ ):
701
+ async def dialogs():
702
+ for index, name in enumerate(names):
703
+ yield SimpleNamespace(name=name, entity=SimpleNamespace(id=index))
704
+
705
+ client = AsyncMock()
706
+ client.iter_dialogs = MagicMock(side_effect=dialogs)
707
+ mock_create.return_value = client
708
+ args = [command, query]
709
+ if command != "read":
710
+ args.append("10")
711
+
712
+ result = runner.invoke(app, args)
713
+
714
+ assert result.exit_code == 1
715
+ assert result.stdout == ""
716
+ assert f'Cannot find chat "{query}"' in result.stderr
717
+ json_lines = [
718
+ line for line in result.stderr.splitlines() if line.startswith("{")
719
+ ]
720
+ assert len(json_lines) == 1
721
+ assert json.loads(json_lines[0]) == {
722
+ "error": "chat_not_found",
723
+ "query": query,
724
+ "candidates": candidates,
725
+ }
726
+ human_lines = result.stderr.splitlines()[:-1]
727
+ for candidate in candidates:
728
+ assert json.dumps(candidate) in [line.rstrip("?") for line in human_lines]
729
+ client.iter_dialogs.assert_called_once_with()
730
+
731
+ @pytest.mark.parametrize("command", ["read", "context", "media"])
732
+ @patch("tgcli.client.create_client")
733
+ def test_structured_error_preserves_long_and_special_names(
734
+ self, mock_create, command
735
+ ):
736
+ from tgcli.client import ChatResolutionError
737
+
738
+ query = 'Mira "[red]" \\ 雪\n' + "long name " * 30
739
+ candidates = [query + " Example", query + " Studio"]
740
+ mock_create.return_value = AsyncMock()
741
+ operation = {
742
+ "read": "read_messages",
743
+ "context": "get_context",
744
+ "media": "download_media",
745
+ }[command]
746
+ args = [command, query] + ([] if command == "read" else ["10"])
747
+ with patch(
748
+ f"tgcli.client.{operation}",
749
+ new_callable=AsyncMock,
750
+ side_effect=ChatResolutionError(query, candidates),
751
+ ):
752
+ result = runner.invoke(app, args)
753
+
754
+ assert result.exit_code == 1
755
+ assert result.stdout == ""
756
+ lines = [line for line in result.stderr.splitlines() if line.startswith("{")]
757
+ assert len(lines) == 1
758
+ assert json.loads(lines[0]) == {
759
+ "error": "chat_not_found",
760
+ "query": query,
761
+ "candidates": candidates,
762
+ }
763
+
764
+ @pytest.mark.parametrize("chat_id", ["123", "-123", "-1000000000123"])
765
+ @patch("tgcli.client.create_client")
766
+ def test_numeric_chat_id_reaches_resolver(self, mock_create, chat_id):
767
+ async def messages(*args, **kwargs):
768
+ if False:
769
+ yield
770
+
771
+ client = AsyncMock()
772
+ client.iter_messages = MagicMock(side_effect=messages)
773
+ mock_create.return_value = client
774
+
775
+ result = runner.invoke(app, ["read", "--", chat_id])
776
+
777
+ assert result.exit_code == 0
778
+ client.get_entity.assert_awaited_once_with(int(chat_id))
779
+ client.iter_dialogs.assert_not_called()
780
+
781
+
603
782
  class TestHelp:
783
+ @pytest.mark.parametrize("command", ["read", "chats"])
784
+ def test_help_describes_substring_and_numeric_ids(self, command):
785
+ result = runner.invoke(app, [command, "--help"])
786
+
787
+ assert result.exit_code == 0
788
+ plain = re.sub(r"\x1b\[[0-9;]*m", "", result.output).lower()
789
+ assert "substring" in plain
790
+ assert "id" in plain
791
+ assert "name matching" in plain
792
+
604
793
  def test_main_help(self):
605
794
  result = runner.invoke(app, ["--help"])
606
795
 
@@ -641,9 +830,13 @@ class TestHelp:
641
830
 
642
831
  assert result.exit_code == 0
643
832
  assert "media_type" in result.output
644
- assert "--out" in result.output
645
- assert "--allow-archives" in result.output
646
- assert "no downloadable media" in result.output
833
+ # CI renders help with color, and Rich splits option names into
834
+ # several style spans, so strip ANSI codes before matching.
835
+ plain = re.sub(r"\x1b\[[0-9;]*m", "", result.output)
836
+ assert "--out" in plain
837
+ assert "--allow-archives" in plain
838
+ assert "--max-size" in plain
839
+ assert "no downloadable media" in plain
647
840
 
648
841
  def test_auth_help(self):
649
842
  result = runner.invoke(app, ["auth", "--help"])
@@ -6,7 +6,14 @@ from unittest.mock import AsyncMock, MagicMock, patch
6
6
 
7
7
  import pytest
8
8
 
9
- from tgcli.client import create_client, get_context, read_messages
9
+ from tgcli.client import (
10
+ ChatResolutionError,
11
+ _resolve_entity,
12
+ create_client,
13
+ get_context,
14
+ list_chats,
15
+ read_messages,
16
+ )
10
17
 
11
18
  _next_entity_id = 0
12
19
 
@@ -73,6 +80,175 @@ class TestCreateClient:
73
80
  mock_tc.assert_called_once_with(mock_ss.return_value, 123, "abc")
74
81
 
75
82
 
83
+ class TestResolveEntity:
84
+ @pytest.fixture()
85
+ def client(self):
86
+ client = AsyncMock()
87
+ client.get_entity.side_effect = ValueError("not found")
88
+ client.iter_dialogs = _mock_iter_dialogs()
89
+ return client
90
+
91
+ async def test_exact_match_precedes_substrings(self, client, capsys):
92
+ dialogs = [_mock_dialog("Mira Vale | Example"), _mock_dialog("Mira Vale")]
93
+ client.iter_dialogs = MagicMock(return_value=_async_iter(dialogs))
94
+
95
+ assert await _resolve_entity(client, "mIRA vALE") is dialogs[1].entity
96
+ client.iter_dialogs.assert_called_once_with()
97
+ client.get_entity.assert_not_awaited()
98
+ assert capsys.readouterr() == ("", "")
99
+
100
+ async def test_unique_substring_is_case_insensitive(self, client, capsys):
101
+ dialogs = [_mock_dialog("Other"), _mock_dialog("Mira Vale | Example")]
102
+ client.iter_dialogs = MagicMock(return_value=_async_iter(dialogs))
103
+
104
+ assert await _resolve_entity(client, "mIRA vALE") is dialogs[1].entity
105
+ client.iter_dialogs.assert_called_once_with()
106
+ captured = capsys.readouterr()
107
+ assert captured.out == ""
108
+ assert captured.err == 'Resolved "mIRA vALE" -> "Mira Vale | Example"\n'
109
+
110
+ async def test_resolution_notice_escapes_names_on_one_line(self, client, capsys):
111
+ query = 'Mira "[red]" \\ 雪\n'
112
+ client.iter_dialogs = _mock_iter_dialogs(query + "Example")
113
+
114
+ await _resolve_entity(client, query)
115
+
116
+ captured = capsys.readouterr()
117
+ assert captured.out == ""
118
+ assert captured.err == (
119
+ 'Resolved "Mira \\"[red]\\" \\\\ 雪\\n" -> '
120
+ '"Mira \\"[red]\\" \\\\ 雪\\nExample"\n'
121
+ )
122
+
123
+ async def test_ambiguous_substring_lists_candidates(self, client):
124
+ candidates = ["Mira Vale | Example", "Mira Vale | Studio"]
125
+ client.iter_dialogs = _mock_iter_dialogs(*candidates)
126
+
127
+ with pytest.raises(ChatResolutionError) as caught:
128
+ await _resolve_entity(client, "Mira Vale")
129
+
130
+ assert caught.value.query == "Mira Vale"
131
+ assert caught.value.candidates == candidates
132
+ assert 'Cannot find chat "Mira Vale". Did you mean:' in str(caught.value)
133
+ assert '\n"Mira Vale | Example"' in str(caught.value)
134
+ assert '\n"Mira Vale | Studio"' in str(caught.value)
135
+ client.iter_dialogs.assert_called_once_with()
136
+
137
+ async def test_no_match_keeps_discovery_hint(self, client):
138
+ client.iter_dialogs = _mock_iter_dialogs("Project Alpha")
139
+
140
+ with pytest.raises(ChatResolutionError) as caught:
141
+ await _resolve_entity(client, "zzzzzz")
142
+
143
+ assert caught.value.query == "zzzzzz"
144
+ assert caught.value.candidates == []
145
+ assert 'Cannot find chat "zzzzzz".' in str(caught.value)
146
+ assert "Use `tg chats --filter`" in str(caught.value)
147
+ client.iter_dialogs.assert_called_once_with()
148
+
149
+ async def test_near_match_suggested_but_never_resolved(self, client):
150
+ client.iter_dialogs = _mock_iter_dialogs("Mira Vale", "Project Alpha")
151
+
152
+ with pytest.raises(ChatResolutionError) as caught:
153
+ await _resolve_entity(client, "mira vlae")
154
+
155
+ assert caught.value.candidates == ["Mira Vale"]
156
+ assert "Did you mean:" in str(caught.value)
157
+ client.iter_dialogs.assert_called_once_with()
158
+
159
+ @pytest.mark.parametrize("query", ["@mira_example", "+12025550123"])
160
+ async def test_direct_reference_precedes_names(self, client, query):
161
+ client.get_entity.side_effect = None
162
+ entity = client.get_entity.return_value
163
+
164
+ assert await _resolve_entity(client, query) is entity
165
+ client.get_entity.assert_awaited_once_with(query)
166
+ client.iter_dialogs.assert_not_called()
167
+
168
+ async def test_me_uses_current_user(self, client):
169
+ assert await _resolve_entity(client, "ME") is client.get_me.return_value
170
+ client.get_me.assert_awaited_once_with()
171
+ client.iter_dialogs.assert_not_called()
172
+
173
+ async def test_invalid_username_becomes_resolution_error(self, client):
174
+ from telethon.errors import UsernameInvalidError
175
+
176
+ client.get_entity.side_effect = UsernameInvalidError(None)
177
+
178
+ with pytest.raises(ChatResolutionError) as caught:
179
+ await _resolve_entity(client, "@invalid_username")
180
+
181
+ assert caught.value.query == "@invalid_username"
182
+ assert caught.value.candidates == []
183
+
184
+ @pytest.mark.parametrize("query", ["123", "-123", "-1000000000123"])
185
+ async def test_numeric_reference_is_passed_as_integer(self, client, query):
186
+ client.get_entity.side_effect = None
187
+
188
+ assert await _resolve_entity(client, query) is client.get_entity.return_value
189
+ client.get_entity.assert_awaited_once_with(int(query))
190
+ client.iter_dialogs.assert_not_called()
191
+
192
+ @pytest.mark.parametrize("query", ["123", "-123", "-1000000000123"])
193
+ async def test_uncached_id_resolves_from_dialog(self, client, query):
194
+ dialog = _mock_dialog("Mira Vale")
195
+ dialog.entity.id = 123
196
+ dialog.id = int(query) if query.startswith("-") else -123
197
+ client.iter_dialogs = MagicMock(return_value=_async_iter([dialog]))
198
+
199
+ assert await _resolve_entity(client, query) is dialog.entity
200
+ client.get_entity.assert_awaited_once_with(int(query))
201
+ client.iter_dialogs.assert_called_once_with()
202
+
203
+ async def test_missing_numeric_id_never_matches_name(self, client):
204
+ dialog = _mock_dialog("999999999")
205
+ dialog.entity.id = 123
206
+ dialog.id = -123
207
+ client.iter_dialogs = MagicMock(return_value=_async_iter([dialog]))
208
+
209
+ with pytest.raises(ChatResolutionError) as caught:
210
+ await _resolve_entity(client, "999999999")
211
+
212
+ assert caught.value.candidates == []
213
+
214
+ async def test_empty_chat_is_a_resolution_failure(self, client):
215
+ from telethon.tl.types import ChatEmpty
216
+
217
+ client.get_entity.side_effect = None
218
+ client.get_entity.return_value = ChatEmpty(id=123)
219
+
220
+ with pytest.raises(ChatResolutionError) as caught:
221
+ await _resolve_entity(client, "-123")
222
+
223
+ assert caught.value.query == "-123"
224
+ assert caught.value.candidates == []
225
+
226
+ async def test_authorization_errors_are_not_resolution_errors(self, client):
227
+ from telethon.errors import UnauthorizedError
228
+
229
+ client.get_entity.side_effect = UnauthorizedError(None, None)
230
+ with pytest.raises(UnauthorizedError):
231
+ await _resolve_entity(client, "123")
232
+ client.iter_dialogs.assert_not_called()
233
+
234
+
235
+ class TestListChats:
236
+ async def test_json_id_uses_entity_id_and_filter_matches_substring(self):
237
+ dialog = _mock_dialog("Mira Vale | Example")
238
+ dialog.entity.id = 123
239
+ dialog.id = -1000000000123
240
+ dialog.unread_count = 2
241
+ dialog.date = None
242
+ client = AsyncMock()
243
+ client.iter_dialogs = MagicMock(return_value=_async_iter([dialog]))
244
+
245
+ chats = await list_chats(client, filter_name="mIRA vALE")
246
+
247
+ assert len(chats) == 1
248
+ assert chats[0].id == 123
249
+ assert chats[0].name == dialog.name
250
+
251
+
76
252
  class TestReadMessages:
77
253
  @pytest.fixture()
78
254
  def client(self):
@@ -6,7 +6,9 @@ from io import StringIO
6
6
  from rich.console import Console
7
7
 
8
8
  from tgcli.formatting import (
9
+ ChatData,
9
10
  format_auth_status,
11
+ format_chat_jsonl,
10
12
  format_context,
11
13
  format_message_jsonl,
12
14
  format_search_results,
@@ -19,6 +21,27 @@ def _render(renderable) -> str:
19
21
  return buf.getvalue()
20
22
 
21
23
 
24
+ class TestFormatChatJsonl:
25
+ def test_includes_numeric_entity_id(self):
26
+ chat = ChatData(
27
+ id=123,
28
+ name="Mira Vale | Example",
29
+ chat_type="user",
30
+ unread_count=0,
31
+ pinned=False,
32
+ date=None,
33
+ )
34
+
35
+ assert json.loads(format_chat_jsonl(chat)) == {
36
+ "id": 123,
37
+ "name": "Mira Vale | Example",
38
+ "chat_type": "user",
39
+ "unread_count": 0,
40
+ "pinned": False,
41
+ "date": None,
42
+ }
43
+
44
+
22
45
  class TestFormatMessageJsonl:
23
46
  def test_basic_serialization(self, make_message):
24
47
  msg = make_message(
@@ -667,7 +667,7 @@ wheels = [
667
667
 
668
668
  [[package]]
669
669
  name = "pytgcli"
670
- version = "0.10.0"
670
+ version = "0.11.0"
671
671
  source = { editable = "." }
672
672
  dependencies = [
673
673
  { name = "keyring" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes