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.
- {pytgcli-0.10.0 → pytgcli-0.11.0}/.github/workflows/publish.yml +1 -1
- {pytgcli-0.10.0 → pytgcli-0.11.0}/PKG-INFO +33 -5
- {pytgcli-0.10.0 → pytgcli-0.11.0}/README.md +32 -4
- {pytgcli-0.10.0 → pytgcli-0.11.0}/pyproject.toml +1 -1
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/cli.py +57 -7
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/client.py +58 -6
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/formatting.py +1 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_cli.py +197 -4
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_client.py +177 -1
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_formatting.py +23 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/uv.lock +1 -1
- {pytgcli-0.10.0 → pytgcli-0.11.0}/.github/dependabot.yml +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/.github/workflows/ci.yml +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/.gitignore +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/.pre-commit-config.yaml +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/.python-version +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/AGENTS.md +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/CLAUDE.md +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/LICENSE +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/install.sh +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/__init__.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/auth.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/config.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/media.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/session.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/src/tgcli/update.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/__init__.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/conftest.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_auth.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_config.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_media.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_session.py +0 -0
- {pytgcli-0.10.0 → pytgcli-0.11.0}/tests/test_update.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pytgcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: CLI tool to read Telegram messages from the terminal
|
|
5
5
|
Project-URL: Repository, https://github.com/tksohishi/tgcli
|
|
6
6
|
Author: Takeshi
|
|
@@ -24,13 +24,13 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
|
|
25
25
|
# ✈️ tgcli — Telegram for your terminal and your AI agents.
|
|
26
26
|
|
|
27
|
-
Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram conversations. Structured JSONL output, minimal command surface,
|
|
27
|
+
Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram conversations. Structured JSONL output, minimal command surface, partial chat name matching. Works equally well for humans with `--pretty`.
|
|
28
28
|
|
|
29
29
|
## Features
|
|
30
30
|
|
|
31
31
|
- **JSONL by default** — one JSON object per line; agents parse it natively, scripts pipe it freely
|
|
32
32
|
- **Minimal surface** — a handful of commands; easy for agents to discover and invoke
|
|
33
|
-
- **
|
|
33
|
+
- **Chat name resolution**: exact display names take priority; a unique substring also matches
|
|
34
34
|
- **`--pretty` for humans** — Rich tables when you want to read output yourself
|
|
35
35
|
- **Agent-friendly session storage** — Telethon session key stored in a local `0600` file by default; keychain remains available as a legacy option
|
|
36
36
|
|
|
@@ -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` |
|
|
144
|
-
| `--limit` | Max
|
|
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,
|
|
3
|
+
Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram conversations. Structured JSONL output, minimal command surface, partial chat name matching. Works equally well for humans with `--pretty`.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **JSONL by default** — one JSON object per line; agents parse it natively, scripts pipe it freely
|
|
8
8
|
- **Minimal surface** — a handful of commands; easy for agents to discover and invoke
|
|
9
|
-
- **
|
|
9
|
+
- **Chat name resolution**: exact display names take priority; a unique substring also matches
|
|
10
10
|
- **`--pretty` for humans** — Rich tables when you want to read output yourself
|
|
11
11
|
- **Agent-friendly session storage** — Telethon session key stored in a local `0600` file by default; keychain remains available as a legacy option
|
|
12
12
|
|
|
@@ -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` |
|
|
120
|
-
| `--limit` | Max
|
|
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 |
|
|
@@ -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,
|
|
263
|
+
str | None,
|
|
264
|
+
typer.Option(
|
|
265
|
+
"--filter", help="Case-insensitive substring filter by chat name."
|
|
266
|
+
),
|
|
249
267
|
] = None,
|
|
250
|
-
limit: Annotated[
|
|
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[
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|