pytgcli 0.7.0__py3-none-any.whl
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.7.0.dist-info/METADATA +173 -0
- pytgcli-0.7.0.dist-info/RECORD +12 -0
- pytgcli-0.7.0.dist-info/WHEEL +4 -0
- pytgcli-0.7.0.dist-info/entry_points.txt +2 -0
- pytgcli-0.7.0.dist-info/licenses/LICENSE +21 -0
- tgcli/__init__.py +0 -0
- tgcli/auth.py +73 -0
- tgcli/cli.py +336 -0
- tgcli/client.py +224 -0
- tgcli/config.py +62 -0
- tgcli/formatting.py +151 -0
- tgcli/session.py +28 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytgcli
|
|
3
|
+
Version: 0.7.0
|
|
4
|
+
Summary: CLI tool to read Telegram messages from the terminal
|
|
5
|
+
Project-URL: Repository, https://github.com/tksohishi/tgcli
|
|
6
|
+
Author: Takeshi
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cli,messages,telegram
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Communications :: Chat
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: keyring>=25
|
|
19
|
+
Requires-Dist: rich>=13
|
|
20
|
+
Requires-Dist: telethon>=1.42
|
|
21
|
+
Requires-Dist: typer>=0.15
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# tgcli — Telegram for your terminal and your AI agents.
|
|
25
|
+
|
|
26
|
+
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
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **JSONL by default** — one JSON object per line; agents parse it natively, scripts pipe it freely
|
|
31
|
+
- **Minimal surface** — a handful of commands; easy for agents to discover and invoke
|
|
32
|
+
- **Fuzzy resolution** — chat and user names match by display name (no numeric IDs required)
|
|
33
|
+
- **`--pretty` for humans** — Rich tables when you want to read output yourself
|
|
34
|
+
- **Secure session storage** — Telethon session key stored in macOS Keychain via `keyring`
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install pytgcli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or install from source:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/tksohishi/tgcli.git
|
|
48
|
+
cd tgcli
|
|
49
|
+
uv tool install .
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
### 1. Get API Credentials
|
|
55
|
+
|
|
56
|
+
Create a Telegram API app at [my.telegram.org/apps](https://my.telegram.org/apps). You'll get an `api_id` and `api_hash`.
|
|
57
|
+
|
|
58
|
+
### 2. Authenticate
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
tg auth
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs in with phone number + verification code.
|
|
65
|
+
|
|
66
|
+
### 3. Read Messages
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
tg read "Alice"
|
|
70
|
+
tg read "Finance Team" --limit 20
|
|
71
|
+
tg read "Finance Team" -q "budget"
|
|
72
|
+
tg read "Finance Team" -q "deadline" --from "Alice" --after 2025-01-01
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 4. View Context
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
tg context "Finance Team" 12345
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Use with AI Agents
|
|
82
|
+
|
|
83
|
+
Once authenticated, any AI coding agent with shell access can use tgcli directly. A few examples:
|
|
84
|
+
|
|
85
|
+
**Ask Claude Code to summarize a group chat:**
|
|
86
|
+
|
|
87
|
+
> "Read the last 30 messages from 'Engineering' and summarize the key decisions."
|
|
88
|
+
|
|
89
|
+
The agent runs `tg read "Engineering" --limit 30`, parses the JSONL, and responds.
|
|
90
|
+
|
|
91
|
+
**Find a past conversation:**
|
|
92
|
+
|
|
93
|
+
> "What did I discuss with Alice last week about the deployment?"
|
|
94
|
+
|
|
95
|
+
The agent runs `tg read "Alice" -q "deployment" --after 2025-02-14` and surfaces the relevant messages.
|
|
96
|
+
|
|
97
|
+
**Pipe into scripts:**
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
tg read "Alerts" --limit 100 | jq 'select(.text | test("ERROR"))'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
No wrapper libraries or API adapters needed. The structured output and simple command surface mean agents can use tgcli out of the box.
|
|
104
|
+
|
|
105
|
+
## Commands
|
|
106
|
+
|
|
107
|
+
### `tg auth`
|
|
108
|
+
|
|
109
|
+
Smart entrypoint: creates config if missing, logs in if needed, shows status if already authenticated.
|
|
110
|
+
|
|
111
|
+
Explicit subcommands:
|
|
112
|
+
|
|
113
|
+
- `tg auth login` - interactive login (phone + code/2FA)
|
|
114
|
+
- `tg auth logout` - remove session from Keychain
|
|
115
|
+
- `tg auth status` - show auth state
|
|
116
|
+
|
|
117
|
+
### `tg chats`
|
|
118
|
+
|
|
119
|
+
List your Telegram chats. Returns JSONL by default.
|
|
120
|
+
|
|
121
|
+
| Flag | Description |
|
|
122
|
+
|------------|------------------------------|
|
|
123
|
+
| `--filter` | Fuzzy filter by chat name |
|
|
124
|
+
| `--limit` | Max chats to list (default 100) |
|
|
125
|
+
| `--pretty` | Rich table output instead of JSONL |
|
|
126
|
+
|
|
127
|
+
### `tg read <chat>`
|
|
128
|
+
|
|
129
|
+
Read recent messages from a chat. Returns JSONL by default, newest first.
|
|
130
|
+
|
|
131
|
+
| Flag | Description |
|
|
132
|
+
|----------------|----------------------------------------|
|
|
133
|
+
| `--query`/`-q` | Filter messages by text |
|
|
134
|
+
| `--from` | Filter by sender |
|
|
135
|
+
| `--limit` | Max messages (default 50) |
|
|
136
|
+
| `--head` | Oldest messages first |
|
|
137
|
+
| `--after` | Only messages after date (YYYY-MM-DD) |
|
|
138
|
+
| `--before` | Only messages before date (YYYY-MM-DD) |
|
|
139
|
+
| `--pretty` | Rich table output instead of JSONL |
|
|
140
|
+
|
|
141
|
+
### `tg context <chat> <message_id>`
|
|
142
|
+
|
|
143
|
+
View a message with surrounding context. Returns JSONL by default.
|
|
144
|
+
|
|
145
|
+
| Flag | Description |
|
|
146
|
+
|-------------|-----------------------------------|
|
|
147
|
+
| `--context` | Messages before/after (default 5) |
|
|
148
|
+
| `--pretty` | Rich text output instead of JSONL |
|
|
149
|
+
|
|
150
|
+
## Configuration
|
|
151
|
+
|
|
152
|
+
Config lives at `~/.config/tgcli/config.toml`:
|
|
153
|
+
|
|
154
|
+
```toml
|
|
155
|
+
api_id = 123456
|
|
156
|
+
api_hash = "your_api_hash"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Alternatively, set `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` environment variables.
|
|
160
|
+
|
|
161
|
+
## Contributing
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
uv sync --group dev
|
|
165
|
+
uv run pytest
|
|
166
|
+
uv run ruff check
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Tests mock Telethon entirely; no real API calls are made.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
tgcli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tgcli/auth.py,sha256=v7NGmalp2JiKJWbIocDZ__BD8BeDISi62IsbGqYmyDM,1988
|
|
3
|
+
tgcli/cli.py,sha256=Wr9vJ1OqrQIfGEmTk3ECrM4v5Wde-IX7GN031LRpgFs,10430
|
|
4
|
+
tgcli/client.py,sha256=pI-hOHU4oCcxF2FWoRQfEkNMkXtVo3Mfjdv6ZvWCft0,6750
|
|
5
|
+
tgcli/config.py,sha256=58GIHkPEffSIAT1q8baBy097IItSjfh1ya1DMpmalG8,1770
|
|
6
|
+
tgcli/formatting.py,sha256=5568iJVA4FgfuQz2JKUDVj1gG2cVxOsDuyRz-3ZooMs,4027
|
|
7
|
+
tgcli/session.py,sha256=svd1axrP6EZXEAMXQSm7OpWOas1GdeTT1iWgUWtHjpo,757
|
|
8
|
+
pytgcli-0.7.0.dist-info/METADATA,sha256=iJCMun85yhQ1z3VTvcXwycNwO7u1ewH7Bj4IW1BUEDw,5051
|
|
9
|
+
pytgcli-0.7.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
pytgcli-0.7.0.dist-info/entry_points.txt,sha256=RZzTkIaOpH6ShabkhHLCmNp5mVmlBFXIGZ_N2IAt8CY,37
|
|
11
|
+
pytgcli-0.7.0.dist-info/licenses/LICENSE,sha256=8_Od5tjv6EKv--zs-_a_Nk4DzMYj_XGrLVZwiMeUCj0,1064
|
|
12
|
+
pytgcli-0.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Takeshi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
tgcli/__init__.py
ADDED
|
File without changes
|
tgcli/auth.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from telethon.sessions import StringSession
|
|
4
|
+
|
|
5
|
+
from tgcli.client import create_client
|
|
6
|
+
from tgcli.session import delete_session, load_session, save_session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def login() -> None:
|
|
10
|
+
"""Interactive login: phone + code/2FA. Saves session to Keychain."""
|
|
11
|
+
client = create_client()
|
|
12
|
+
try:
|
|
13
|
+
await client.start(phone=lambda: input("Phone number: "))
|
|
14
|
+
session_str = StringSession.save(client.session)
|
|
15
|
+
save_session(session_str)
|
|
16
|
+
finally:
|
|
17
|
+
await client.disconnect()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def logout() -> None:
|
|
21
|
+
"""Log out and remove session from Keychain.
|
|
22
|
+
|
|
23
|
+
Always deletes the local session, even if the remote logout fails.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
client = create_client()
|
|
27
|
+
try:
|
|
28
|
+
await client.connect()
|
|
29
|
+
if await client.is_user_authorized():
|
|
30
|
+
await client.log_out()
|
|
31
|
+
finally:
|
|
32
|
+
await client.disconnect()
|
|
33
|
+
except Exception: # noqa: S110
|
|
34
|
+
pass
|
|
35
|
+
delete_session()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def get_status() -> dict:
|
|
39
|
+
"""Return auth status info.
|
|
40
|
+
|
|
41
|
+
Returns dict with keys: authenticated, phone, session_exists.
|
|
42
|
+
"""
|
|
43
|
+
session_exists = load_session() is not None
|
|
44
|
+
|
|
45
|
+
if not session_exists:
|
|
46
|
+
return {
|
|
47
|
+
"authenticated": False,
|
|
48
|
+
"phone": None,
|
|
49
|
+
"session_exists": False,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
client = create_client()
|
|
53
|
+
try:
|
|
54
|
+
await client.connect()
|
|
55
|
+
authorized = await client.is_user_authorized()
|
|
56
|
+
phone = None
|
|
57
|
+
if authorized:
|
|
58
|
+
me = await client.get_me()
|
|
59
|
+
if me and me.phone:
|
|
60
|
+
# Mask phone: show first 3 and last 2 digits
|
|
61
|
+
p = me.phone
|
|
62
|
+
if len(p) > 5:
|
|
63
|
+
phone = p[:3] + "*" * (len(p) - 5) + p[-2:]
|
|
64
|
+
else:
|
|
65
|
+
phone = p
|
|
66
|
+
finally:
|
|
67
|
+
await client.disconnect()
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"authenticated": authorized,
|
|
71
|
+
"phone": phone,
|
|
72
|
+
"session_exists": session_exists,
|
|
73
|
+
}
|
tgcli/cli.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from telethon.errors import UnauthorizedError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _version_callback(value: bool) -> None:
|
|
14
|
+
if value:
|
|
15
|
+
print(version("pytgcli"))
|
|
16
|
+
raise typer.Exit()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="Read Telegram messages from the terminal.")
|
|
20
|
+
auth_app = typer.Typer(
|
|
21
|
+
help="Manage Telegram authentication.", invoke_without_command=True
|
|
22
|
+
)
|
|
23
|
+
app.add_typer(auth_app, name="auth")
|
|
24
|
+
|
|
25
|
+
stdout = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.callback()
|
|
29
|
+
def main(
|
|
30
|
+
_version: Annotated[
|
|
31
|
+
bool | None,
|
|
32
|
+
typer.Option(
|
|
33
|
+
"--version",
|
|
34
|
+
callback=_version_callback,
|
|
35
|
+
is_eager=True,
|
|
36
|
+
help="Show version and exit.",
|
|
37
|
+
),
|
|
38
|
+
] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Read Telegram messages from the terminal."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
stderr = Console(stderr=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@auth_app.callback(invoke_without_command=True)
|
|
47
|
+
def auth_default(ctx: typer.Context) -> None:
|
|
48
|
+
"""Smart auth: configure, login, or show status as needed."""
|
|
49
|
+
if ctx.invoked_subcommand is not None:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
from tgcli.config import CONFIG_PATH, load_config, write_config
|
|
53
|
+
from tgcli.formatting import format_auth_status
|
|
54
|
+
|
|
55
|
+
# Step 1: ensure config exists
|
|
56
|
+
try:
|
|
57
|
+
load_config()
|
|
58
|
+
except SystemExit:
|
|
59
|
+
import webbrowser
|
|
60
|
+
|
|
61
|
+
stderr.print(f"No config found at {CONFIG_PATH}\n")
|
|
62
|
+
stderr.print("You need a Telegram API app to use this tool.")
|
|
63
|
+
typer.prompt(
|
|
64
|
+
"Press Enter to open my.telegram.org/apps", default="", show_default=False
|
|
65
|
+
)
|
|
66
|
+
webbrowser.open("https://my.telegram.org/apps")
|
|
67
|
+
api_id = typer.prompt("\nAPI ID", type=int)
|
|
68
|
+
api_hash = typer.prompt("API Hash", type=str)
|
|
69
|
+
path = write_config(api_id, api_hash)
|
|
70
|
+
stderr.print(f"Config written to {path}\n")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
stderr.print(f"[red]Config error:[/red] {e}")
|
|
73
|
+
stderr.print(f"Check your config at {CONFIG_PATH}")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
# Step 2: check auth state
|
|
77
|
+
from tgcli.auth import get_status
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
info = asyncio.run(get_status())
|
|
81
|
+
except Exception as e:
|
|
82
|
+
stderr.print(f"[red]Error checking status:[/red] {e}")
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
|
|
85
|
+
if info["authenticated"]:
|
|
86
|
+
stdout.print(format_auth_status(**info))
|
|
87
|
+
stdout.print("Run `tg auth logout` to log out.")
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
# Step 3: not authenticated, run login
|
|
91
|
+
_run_login()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_login() -> None:
|
|
95
|
+
"""Shared login flow for both `tg auth` and `tg auth login`."""
|
|
96
|
+
from tgcli.auth import login as _login
|
|
97
|
+
|
|
98
|
+
stderr.print(
|
|
99
|
+
"\nLogging in to Telegram. You'll be asked for your phone number\n"
|
|
100
|
+
"including country code (e.g. +81 90 1234 5678). The + and any\n"
|
|
101
|
+
"spaces/dashes are optional, but the country code is required.\n"
|
|
102
|
+
"Telegram will send a verification code to your account, like\n"
|
|
103
|
+
"logging in on a new device. Your phone number is sent to\n"
|
|
104
|
+
"Telegram's API only; tgcli does not store or transmit it.\n"
|
|
105
|
+
)
|
|
106
|
+
try:
|
|
107
|
+
asyncio.run(_login())
|
|
108
|
+
except Exception as e:
|
|
109
|
+
stderr.print(f"[red]Login failed:[/red] {e}")
|
|
110
|
+
raise typer.Exit(1)
|
|
111
|
+
stderr.print(
|
|
112
|
+
"\nRegarding the ToS warning above: unofficial API clients are\n"
|
|
113
|
+
"under observation by Telegram. Normal interactive use (searching,\n"
|
|
114
|
+
"reading your own messages) is fine. Avoid bulk scraping, spamming,\n"
|
|
115
|
+
"or using results for AI/ML model training.\n"
|
|
116
|
+
"Full terms: https://core.telegram.org/api/terms\n"
|
|
117
|
+
)
|
|
118
|
+
stdout.print("[green]Login successful.[/green]")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@auth_app.command()
|
|
122
|
+
def login() -> None:
|
|
123
|
+
"""Interactive login: phone + verification code (or 2FA password)."""
|
|
124
|
+
_run_login()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@auth_app.command()
|
|
128
|
+
def logout() -> None:
|
|
129
|
+
"""Remove session from Keychain."""
|
|
130
|
+
from tgcli.auth import logout as _logout
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
asyncio.run(_logout())
|
|
134
|
+
stdout.print("Logged out.")
|
|
135
|
+
except Exception as e:
|
|
136
|
+
stderr.print(f"[red]Logout failed:[/red] {e}")
|
|
137
|
+
raise typer.Exit(1)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@auth_app.command()
|
|
141
|
+
def status() -> None:
|
|
142
|
+
"""Show current auth state."""
|
|
143
|
+
from tgcli.auth import get_status
|
|
144
|
+
from tgcli.formatting import format_auth_status
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
info = asyncio.run(get_status())
|
|
148
|
+
except SystemExit as e:
|
|
149
|
+
stderr.print(f"[red]Configuration error:[/red] {e}")
|
|
150
|
+
stderr.print("Run `tg auth` to set up.")
|
|
151
|
+
raise typer.Exit(1)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
stderr.print(f"[red]Error checking status:[/red] {e}")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
|
|
156
|
+
stdout.print(format_auth_status(**info))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_date(value: str) -> datetime:
|
|
160
|
+
return datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=UTC)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def chats(
|
|
165
|
+
filter_: Annotated[
|
|
166
|
+
str | None, typer.Option("--filter", help="Fuzzy filter by chat name.")
|
|
167
|
+
] = None,
|
|
168
|
+
limit: Annotated[int, typer.Option(help="Max chats to list.")] = 100,
|
|
169
|
+
pretty: Annotated[
|
|
170
|
+
bool, typer.Option("--pretty", help="Rich table output.")
|
|
171
|
+
] = False,
|
|
172
|
+
) -> None:
|
|
173
|
+
"""List your Telegram chats."""
|
|
174
|
+
from tgcli.client import create_client, list_chats
|
|
175
|
+
|
|
176
|
+
async def _run():
|
|
177
|
+
client = create_client()
|
|
178
|
+
async with client:
|
|
179
|
+
return await list_chats(client, filter_name=filter_, limit=limit)
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
results = asyncio.run(_run())
|
|
183
|
+
except SystemExit as e:
|
|
184
|
+
stderr.print(f"[red]Configuration error:[/red] {e}")
|
|
185
|
+
stderr.print("Run `tg auth` to set up.")
|
|
186
|
+
raise typer.Exit(1)
|
|
187
|
+
except UnauthorizedError:
|
|
188
|
+
stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
|
|
189
|
+
raise typer.Exit(2)
|
|
190
|
+
except Exception as e:
|
|
191
|
+
stderr.print(f"[red]Failed to list chats:[/red] {e}")
|
|
192
|
+
raise typer.Exit(1)
|
|
193
|
+
|
|
194
|
+
if not results:
|
|
195
|
+
stdout.print("No chats found.")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
if pretty:
|
|
199
|
+
from tgcli.formatting import format_chats_table
|
|
200
|
+
|
|
201
|
+
stdout.print(format_chats_table(results))
|
|
202
|
+
else:
|
|
203
|
+
from tgcli.formatting import format_chat_jsonl
|
|
204
|
+
|
|
205
|
+
for chat in results:
|
|
206
|
+
print(format_chat_jsonl(chat))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.command()
|
|
210
|
+
def read(
|
|
211
|
+
chat: Annotated[str, typer.Argument(help="Chat or person to read messages from.")],
|
|
212
|
+
query: Annotated[
|
|
213
|
+
str | None, typer.Option("--query", "-q", help="Filter messages by text.")
|
|
214
|
+
] = None,
|
|
215
|
+
from_: Annotated[
|
|
216
|
+
str | None, typer.Option("--from", help="Filter by sender.")
|
|
217
|
+
] = None,
|
|
218
|
+
limit: Annotated[int, typer.Option(help="Max messages to return.")] = 50,
|
|
219
|
+
head: Annotated[
|
|
220
|
+
bool, typer.Option("--head", help="Oldest messages first.")
|
|
221
|
+
] = False,
|
|
222
|
+
after: Annotated[
|
|
223
|
+
str | None, typer.Option(help="Only messages after this date (YYYY-MM-DD).")
|
|
224
|
+
] = None,
|
|
225
|
+
before: Annotated[
|
|
226
|
+
str | None, typer.Option(help="Only messages before this date (YYYY-MM-DD).")
|
|
227
|
+
] = None,
|
|
228
|
+
pretty: Annotated[
|
|
229
|
+
bool, typer.Option("--pretty", help="Rich table output instead of JSONL.")
|
|
230
|
+
] = False,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Read recent messages from a chat. Newest first by default (--head for oldest)."""
|
|
233
|
+
from tgcli.client import create_client, read_messages
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
after_dt = _parse_date(after) if after else None
|
|
237
|
+
before_dt = _parse_date(before) if before else None
|
|
238
|
+
except ValueError as e:
|
|
239
|
+
stderr.print(f"[red]Invalid date format:[/red] {e}")
|
|
240
|
+
stderr.print("Expected format: YYYY-MM-DD")
|
|
241
|
+
raise typer.Exit(1)
|
|
242
|
+
|
|
243
|
+
async def _run():
|
|
244
|
+
client = create_client()
|
|
245
|
+
async with client:
|
|
246
|
+
return await read_messages(
|
|
247
|
+
client,
|
|
248
|
+
chat,
|
|
249
|
+
query=query or "",
|
|
250
|
+
from_=from_,
|
|
251
|
+
limit=limit,
|
|
252
|
+
after=after_dt,
|
|
253
|
+
before=before_dt,
|
|
254
|
+
reverse=head,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
results = asyncio.run(_run())
|
|
259
|
+
except SystemExit as e:
|
|
260
|
+
stderr.print(f"[red]Configuration error:[/red] {e}")
|
|
261
|
+
stderr.print("Run `tg auth` to set up.")
|
|
262
|
+
raise typer.Exit(1)
|
|
263
|
+
except UnauthorizedError:
|
|
264
|
+
stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
|
|
265
|
+
raise typer.Exit(2)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
stderr.print(f"[red]Read failed:[/red] {e}")
|
|
268
|
+
raise typer.Exit(1)
|
|
269
|
+
|
|
270
|
+
if not results:
|
|
271
|
+
stdout.print("No messages found.")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
if pretty:
|
|
275
|
+
from tgcli.formatting import format_search_results
|
|
276
|
+
|
|
277
|
+
stdout.print(format_search_results(results))
|
|
278
|
+
else:
|
|
279
|
+
from tgcli.formatting import format_message_jsonl
|
|
280
|
+
|
|
281
|
+
for msg in results:
|
|
282
|
+
print(format_message_jsonl(msg))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.command()
|
|
286
|
+
def context(
|
|
287
|
+
chat: str,
|
|
288
|
+
message_id: int,
|
|
289
|
+
context_size: Annotated[
|
|
290
|
+
int, typer.Option("--context", help="Messages before/after the target.")
|
|
291
|
+
] = 5,
|
|
292
|
+
pretty: Annotated[
|
|
293
|
+
bool, typer.Option("--pretty", help="Rich text output instead of JSONL.")
|
|
294
|
+
] = False,
|
|
295
|
+
) -> None:
|
|
296
|
+
"""View a message with surrounding context."""
|
|
297
|
+
from tgcli.client import create_client, get_context
|
|
298
|
+
|
|
299
|
+
async def _run():
|
|
300
|
+
client = create_client()
|
|
301
|
+
async with client:
|
|
302
|
+
return await get_context(client, chat, message_id, context=context_size)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
messages, target_id, replied_to = asyncio.run(_run())
|
|
306
|
+
except SystemExit as e:
|
|
307
|
+
stderr.print(f"[red]Configuration error:[/red] {e}")
|
|
308
|
+
stderr.print("Run `tg auth` to set up.")
|
|
309
|
+
raise typer.Exit(1)
|
|
310
|
+
except UnauthorizedError:
|
|
311
|
+
stderr.print("[red]Not authenticated.[/red] Run `tg auth login` first.")
|
|
312
|
+
raise typer.Exit(2)
|
|
313
|
+
except Exception as e:
|
|
314
|
+
stderr.print(f"[red]Context fetch failed:[/red] {e}")
|
|
315
|
+
raise typer.Exit(1)
|
|
316
|
+
|
|
317
|
+
if not messages:
|
|
318
|
+
stdout.print("No messages found.")
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
if pretty:
|
|
322
|
+
from tgcli.formatting import format_context
|
|
323
|
+
|
|
324
|
+
stdout.print(format_context(messages, target_id, replied_to=replied_to))
|
|
325
|
+
else:
|
|
326
|
+
from tgcli.formatting import format_message_jsonl
|
|
327
|
+
|
|
328
|
+
replied_to_id = replied_to.id if replied_to else None
|
|
329
|
+
for msg in messages:
|
|
330
|
+
print(
|
|
331
|
+
format_message_jsonl(
|
|
332
|
+
msg,
|
|
333
|
+
target=(msg.id == target_id),
|
|
334
|
+
replied_to=(msg.id == replied_to_id),
|
|
335
|
+
)
|
|
336
|
+
)
|
tgcli/client.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from telethon import TelegramClient
|
|
6
|
+
from telethon.sessions import StringSession
|
|
7
|
+
|
|
8
|
+
from tgcli.config import TelegramConfig, load_config
|
|
9
|
+
from tgcli.formatting import ChatData, MessageData
|
|
10
|
+
from tgcli.session import load_session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_client(config: TelegramConfig | None = None) -> TelegramClient:
|
|
14
|
+
"""Create a TelegramClient using stored session and config."""
|
|
15
|
+
config = config or load_config()
|
|
16
|
+
session_str = load_session() or ""
|
|
17
|
+
return TelegramClient(
|
|
18
|
+
StringSession(session_str),
|
|
19
|
+
config.api_id,
|
|
20
|
+
config.api_hash,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_name(entity) -> str:
|
|
25
|
+
"""Extract a display name from a Telethon entity."""
|
|
26
|
+
if entity is None:
|
|
27
|
+
return "Unknown"
|
|
28
|
+
if hasattr(entity, "title"):
|
|
29
|
+
return entity.title
|
|
30
|
+
parts = [getattr(entity, "first_name", None), getattr(entity, "last_name", None)]
|
|
31
|
+
return " ".join(p for p in parts if p) or "Unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _resolve_entity(client: TelegramClient, name: str):
|
|
35
|
+
"""Resolve a name to a Telethon entity.
|
|
36
|
+
|
|
37
|
+
Handles @usernames, phone numbers, and numeric IDs via get_entity().
|
|
38
|
+
Plain names are matched case-insensitively against dialog names.
|
|
39
|
+
"""
|
|
40
|
+
if name.lower() == "me":
|
|
41
|
+
return await client.get_me()
|
|
42
|
+
|
|
43
|
+
if name.startswith("@") or name.startswith("+") or name.lstrip("-").isdigit():
|
|
44
|
+
try:
|
|
45
|
+
return await client.get_entity(name)
|
|
46
|
+
except Exception: # noqa: S110
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
name_lower = name.lower()
|
|
50
|
+
async for dialog in client.iter_dialogs():
|
|
51
|
+
if dialog.name.lower() == name_lower:
|
|
52
|
+
return dialog.entity
|
|
53
|
+
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f'Cannot find chat "{name}". Use `tg chats --filter` to find exact names.'
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _msg_to_data(msg, chat_name: str, sender_name: str) -> MessageData:
|
|
60
|
+
return MessageData(
|
|
61
|
+
id=msg.id,
|
|
62
|
+
text=msg.text or "",
|
|
63
|
+
chat_name=chat_name,
|
|
64
|
+
sender_name=sender_name,
|
|
65
|
+
date=msg.date,
|
|
66
|
+
reply_to_msg_id=msg.reply_to.reply_to_msg_id if msg.reply_to else None,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _chat_type(entity) -> str:
|
|
71
|
+
"""Determine the chat type from a Telethon entity."""
|
|
72
|
+
cls = type(entity).__name__
|
|
73
|
+
if cls == "User":
|
|
74
|
+
return "user"
|
|
75
|
+
if cls == "Channel":
|
|
76
|
+
if getattr(entity, "megagroup", False):
|
|
77
|
+
return "group"
|
|
78
|
+
return "channel"
|
|
79
|
+
return "group"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def list_chats(
|
|
83
|
+
client: TelegramClient,
|
|
84
|
+
*,
|
|
85
|
+
filter_name: str | None = None,
|
|
86
|
+
limit: int = 100,
|
|
87
|
+
) -> list[ChatData]:
|
|
88
|
+
"""List dialogs, optionally filtered by name substring.
|
|
89
|
+
|
|
90
|
+
limit controls how many dialogs to scan. With filter_name, matches
|
|
91
|
+
are returned from that scanned set.
|
|
92
|
+
"""
|
|
93
|
+
filter_lower = filter_name.lower() if filter_name else None
|
|
94
|
+
results: list[ChatData] = []
|
|
95
|
+
count = 0
|
|
96
|
+
async for dialog in client.iter_dialogs():
|
|
97
|
+
count += 1
|
|
98
|
+
if not filter_lower or filter_lower in dialog.name.lower():
|
|
99
|
+
results.append(
|
|
100
|
+
ChatData(
|
|
101
|
+
name=dialog.name,
|
|
102
|
+
chat_type=_chat_type(dialog.entity),
|
|
103
|
+
unread_count=dialog.unread_count,
|
|
104
|
+
pinned=dialog.pinned,
|
|
105
|
+
date=dialog.date,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
if count >= limit:
|
|
109
|
+
break
|
|
110
|
+
return results
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def read_messages(
|
|
114
|
+
client: TelegramClient,
|
|
115
|
+
chat: str,
|
|
116
|
+
*,
|
|
117
|
+
query: str = "",
|
|
118
|
+
from_: str | None = None,
|
|
119
|
+
limit: int = 50,
|
|
120
|
+
after: datetime | None = None,
|
|
121
|
+
before: datetime | None = None,
|
|
122
|
+
reverse: bool = False,
|
|
123
|
+
) -> list[MessageData]:
|
|
124
|
+
"""Read messages from a chat.
|
|
125
|
+
|
|
126
|
+
Default order is newest first (tail). Set reverse=True for oldest first (head).
|
|
127
|
+
Optional query does client-side text filtering. Optional from_ filters by sender
|
|
128
|
+
(resolved server-side via from_user).
|
|
129
|
+
"""
|
|
130
|
+
entity = await _resolve_entity(client, chat)
|
|
131
|
+
chat_name = _get_name(entity)
|
|
132
|
+
|
|
133
|
+
from_user = None
|
|
134
|
+
if from_:
|
|
135
|
+
from_user = await _resolve_entity(client, from_)
|
|
136
|
+
|
|
137
|
+
filtering = bool(query or from_user)
|
|
138
|
+
filter_query = query.lower() if query else None
|
|
139
|
+
offset_date = before if before and not reverse else None
|
|
140
|
+
|
|
141
|
+
results: list[MessageData] = []
|
|
142
|
+
async for msg in client.iter_messages(
|
|
143
|
+
entity,
|
|
144
|
+
limit=None if filtering else limit,
|
|
145
|
+
offset_date=offset_date,
|
|
146
|
+
reverse=reverse,
|
|
147
|
+
from_user=from_user,
|
|
148
|
+
):
|
|
149
|
+
if before and msg.date and msg.date >= before:
|
|
150
|
+
if reverse:
|
|
151
|
+
break
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if after and msg.date and msg.date < after:
|
|
155
|
+
if reverse:
|
|
156
|
+
continue
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
if filter_query and filter_query not in (msg.text or "").lower():
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
sender = await msg.get_sender()
|
|
163
|
+
results.append(_msg_to_data(msg, chat_name, _get_name(sender)))
|
|
164
|
+
if len(results) >= limit:
|
|
165
|
+
break
|
|
166
|
+
|
|
167
|
+
return results
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def get_context(
|
|
171
|
+
client: TelegramClient,
|
|
172
|
+
chat: str,
|
|
173
|
+
message_id: int,
|
|
174
|
+
context: int = 5,
|
|
175
|
+
) -> tuple[list[MessageData], int, MessageData | None]:
|
|
176
|
+
"""Get a message and surrounding context.
|
|
177
|
+
|
|
178
|
+
Returns (messages, target_id, replied_to_message).
|
|
179
|
+
"""
|
|
180
|
+
entity = await _resolve_entity(client, chat)
|
|
181
|
+
chat_name = _get_name(entity)
|
|
182
|
+
|
|
183
|
+
# Fetch messages around the target: context after + target + context before
|
|
184
|
+
# iter_messages returns newest first, so offset from message_id
|
|
185
|
+
messages_raw = []
|
|
186
|
+
|
|
187
|
+
# Messages after (newer than) the target
|
|
188
|
+
after_msgs = []
|
|
189
|
+
async for msg in client.iter_messages(
|
|
190
|
+
entity,
|
|
191
|
+
min_id=message_id,
|
|
192
|
+
limit=context,
|
|
193
|
+
reverse=True,
|
|
194
|
+
):
|
|
195
|
+
after_msgs.append(msg)
|
|
196
|
+
|
|
197
|
+
# The target message itself + messages before (older than) it
|
|
198
|
+
before_msgs = []
|
|
199
|
+
async for msg in client.iter_messages(
|
|
200
|
+
entity,
|
|
201
|
+
max_id=message_id + 1,
|
|
202
|
+
limit=context + 1,
|
|
203
|
+
):
|
|
204
|
+
before_msgs.append(msg)
|
|
205
|
+
|
|
206
|
+
messages_raw = sorted(after_msgs + before_msgs, key=lambda m: m.id)
|
|
207
|
+
|
|
208
|
+
# Build MessageData list
|
|
209
|
+
messages: list[MessageData] = []
|
|
210
|
+
for msg in messages_raw:
|
|
211
|
+
sender = await msg.get_sender()
|
|
212
|
+
messages.append(_msg_to_data(msg, chat_name, _get_name(sender)))
|
|
213
|
+
|
|
214
|
+
# Find the replied-to message if applicable
|
|
215
|
+
replied_to = None
|
|
216
|
+
target_msg = next((m for m in messages_raw if m.id == message_id), None)
|
|
217
|
+
if target_msg and target_msg.reply_to:
|
|
218
|
+
reply_id = target_msg.reply_to.reply_to_msg_id
|
|
219
|
+
reply_msg = await client.get_messages(entity, ids=reply_id)
|
|
220
|
+
if reply_msg:
|
|
221
|
+
sender = await reply_msg.get_sender()
|
|
222
|
+
replied_to = _msg_to_data(reply_msg, chat_name, _get_name(sender))
|
|
223
|
+
|
|
224
|
+
return messages, message_id, replied_to
|
tgcli/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
CONFIG_PATH = Path.home() / ".config" / "tgcli" / "config.toml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class TelegramConfig:
|
|
13
|
+
api_id: int
|
|
14
|
+
api_hash: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config(config_path: Path | None = None) -> TelegramConfig:
|
|
18
|
+
"""Load Telegram API credentials.
|
|
19
|
+
|
|
20
|
+
Resolution order:
|
|
21
|
+
1. Config TOML
|
|
22
|
+
2. Env vars TELEGRAM_API_ID, TELEGRAM_API_HASH
|
|
23
|
+
3. Error with clear message
|
|
24
|
+
"""
|
|
25
|
+
path = config_path or CONFIG_PATH
|
|
26
|
+
api_id: str | None = None
|
|
27
|
+
api_hash: str | None = None
|
|
28
|
+
|
|
29
|
+
if path.exists():
|
|
30
|
+
with open(path, "rb") as f:
|
|
31
|
+
data = tomllib.load(f)
|
|
32
|
+
raw_id = data.get("api_id")
|
|
33
|
+
raw_hash = data.get("api_hash")
|
|
34
|
+
if raw_id is not None:
|
|
35
|
+
api_id = str(raw_id)
|
|
36
|
+
if raw_hash is not None:
|
|
37
|
+
api_hash = str(raw_hash)
|
|
38
|
+
|
|
39
|
+
if api_id is None:
|
|
40
|
+
api_id = os.environ.get("TELEGRAM_API_ID")
|
|
41
|
+
if api_hash is None:
|
|
42
|
+
api_hash = os.environ.get("TELEGRAM_API_HASH")
|
|
43
|
+
|
|
44
|
+
if not api_id or not api_hash:
|
|
45
|
+
raise SystemExit(
|
|
46
|
+
"Telegram API credentials not found.\n"
|
|
47
|
+
f"Set them in {CONFIG_PATH} or via "
|
|
48
|
+
"TELEGRAM_API_ID / TELEGRAM_API_HASH env vars."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return TelegramConfig(api_id=int(api_id), api_hash=api_hash)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def write_config(api_id: int, api_hash: str, config_path: Path | None = None) -> Path:
|
|
55
|
+
"""Write Telegram API credentials to a TOML config file.
|
|
56
|
+
|
|
57
|
+
Creates parent directories if needed. Returns the path written to.
|
|
58
|
+
"""
|
|
59
|
+
path = config_path or CONFIG_PATH
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
path.write_text(f'api_id = {api_id}\napi_hash = "{api_hash}"\n')
|
|
62
|
+
return path
|
tgcli/formatting.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class ChatData:
|
|
13
|
+
name: str
|
|
14
|
+
chat_type: str
|
|
15
|
+
unread_count: int
|
|
16
|
+
pinned: bool
|
|
17
|
+
date: datetime | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class MessageData:
|
|
22
|
+
id: int
|
|
23
|
+
text: str
|
|
24
|
+
chat_name: str
|
|
25
|
+
sender_name: str
|
|
26
|
+
date: datetime
|
|
27
|
+
reply_to_msg_id: int | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _truncate(text: str, max_lines: int = 3) -> str:
|
|
31
|
+
lines = text.splitlines()
|
|
32
|
+
if len(lines) <= max_lines:
|
|
33
|
+
return text
|
|
34
|
+
return "\n".join(lines[:max_lines]) + " ..."
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_message_jsonl(msg: MessageData, **flags: bool) -> str:
|
|
38
|
+
"""Serialize a MessageData to a single JSON line.
|
|
39
|
+
|
|
40
|
+
Extra boolean flags (e.g. target=True, replied_to=True) are merged
|
|
41
|
+
into the dict before serialization.
|
|
42
|
+
"""
|
|
43
|
+
d = asdict(msg)
|
|
44
|
+
d["date"] = msg.date.isoformat()
|
|
45
|
+
for key, value in flags.items():
|
|
46
|
+
if value:
|
|
47
|
+
d[key] = True
|
|
48
|
+
return json.dumps(d, ensure_ascii=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_search_results(messages: list[MessageData]) -> Table:
|
|
52
|
+
"""Build a Rich Table for search results."""
|
|
53
|
+
table = Table(show_header=True, header_style="bold")
|
|
54
|
+
table.add_column("Date", style="dim", no_wrap=True)
|
|
55
|
+
table.add_column("Chat")
|
|
56
|
+
table.add_column("Sender")
|
|
57
|
+
table.add_column("Message")
|
|
58
|
+
|
|
59
|
+
for msg in messages:
|
|
60
|
+
table.add_row(
|
|
61
|
+
msg.date.strftime("%Y-%m-%d %H:%M"),
|
|
62
|
+
msg.chat_name,
|
|
63
|
+
msg.sender_name,
|
|
64
|
+
_truncate(msg.text),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return table
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_context(
|
|
71
|
+
messages: list[MessageData],
|
|
72
|
+
target_id: int,
|
|
73
|
+
replied_to: MessageData | None = None,
|
|
74
|
+
) -> Text:
|
|
75
|
+
"""Build a Rich Text for context view.
|
|
76
|
+
|
|
77
|
+
The target message is highlighted. If replied_to is provided, it's
|
|
78
|
+
shown above the target with a separator.
|
|
79
|
+
"""
|
|
80
|
+
output = Text()
|
|
81
|
+
|
|
82
|
+
if replied_to:
|
|
83
|
+
output.append(
|
|
84
|
+
f" >> {replied_to.sender_name}: {replied_to.text}\n",
|
|
85
|
+
style="dim italic",
|
|
86
|
+
)
|
|
87
|
+
output.append(" " + "-" * 40 + "\n", style="dim")
|
|
88
|
+
|
|
89
|
+
for msg in messages:
|
|
90
|
+
ts = msg.date.strftime("%H:%M")
|
|
91
|
+
line = f"[{ts}] {msg.sender_name}: {msg.text}\n"
|
|
92
|
+
if msg.id == target_id:
|
|
93
|
+
output.append(line, style="bold yellow")
|
|
94
|
+
else:
|
|
95
|
+
output.append(line)
|
|
96
|
+
|
|
97
|
+
return output
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def format_chat_jsonl(chat: ChatData) -> str:
|
|
101
|
+
"""Serialize a ChatData to a single JSON line."""
|
|
102
|
+
d = asdict(chat)
|
|
103
|
+
if chat.date:
|
|
104
|
+
d["date"] = chat.date.isoformat()
|
|
105
|
+
else:
|
|
106
|
+
d["date"] = None
|
|
107
|
+
return json.dumps(d, ensure_ascii=False)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def format_chats_table(chats: list[ChatData]) -> Table:
|
|
111
|
+
"""Build a Rich Table for chat listing."""
|
|
112
|
+
table = Table(show_header=True, header_style="bold")
|
|
113
|
+
table.add_column("Name")
|
|
114
|
+
table.add_column("Type", style="dim")
|
|
115
|
+
table.add_column("Unread", justify="right")
|
|
116
|
+
table.add_column("Last message", style="dim", no_wrap=True)
|
|
117
|
+
|
|
118
|
+
for chat in chats:
|
|
119
|
+
unread = str(chat.unread_count) if chat.unread_count else ""
|
|
120
|
+
date = chat.date.strftime("%Y-%m-%d") if chat.date else ""
|
|
121
|
+
table.add_row(chat.name, chat.chat_type, unread, date)
|
|
122
|
+
|
|
123
|
+
return table
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def format_auth_status(
|
|
127
|
+
authenticated: bool,
|
|
128
|
+
phone: str | None = None,
|
|
129
|
+
session_exists: bool = False,
|
|
130
|
+
) -> Text:
|
|
131
|
+
"""Build a Rich Text for auth status display."""
|
|
132
|
+
output = Text()
|
|
133
|
+
|
|
134
|
+
if authenticated:
|
|
135
|
+
output.append("Status: ", style="bold")
|
|
136
|
+
output.append("authenticated\n", style="green")
|
|
137
|
+
else:
|
|
138
|
+
output.append("Status: ", style="bold")
|
|
139
|
+
output.append("not authenticated\n", style="red")
|
|
140
|
+
|
|
141
|
+
if phone:
|
|
142
|
+
output.append("Phone: ", style="bold")
|
|
143
|
+
output.append(f"{phone}\n")
|
|
144
|
+
|
|
145
|
+
output.append("Session: ", style="bold")
|
|
146
|
+
if session_exists:
|
|
147
|
+
output.append("stored in Keychain\n", style="green")
|
|
148
|
+
else:
|
|
149
|
+
output.append("none\n", style="red")
|
|
150
|
+
|
|
151
|
+
return output
|
tgcli/session.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import keyring
|
|
4
|
+
from keyring.errors import PasswordDeleteError
|
|
5
|
+
|
|
6
|
+
SERVICE_NAME = "tgcli"
|
|
7
|
+
SESSION_KEY = "telegram_session"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def save_session(session_string: str) -> None:
|
|
11
|
+
"""Store the Telethon StringSession in the system keychain."""
|
|
12
|
+
keyring.set_password(SERVICE_NAME, SESSION_KEY, session_string)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_session() -> str | None:
|
|
16
|
+
"""Load the Telethon StringSession from the system keychain.
|
|
17
|
+
|
|
18
|
+
Returns None if no session is stored.
|
|
19
|
+
"""
|
|
20
|
+
return keyring.get_password(SERVICE_NAME, SESSION_KEY)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def delete_session() -> None:
|
|
24
|
+
"""Remove the stored session from the system keychain."""
|
|
25
|
+
try:
|
|
26
|
+
keyring.delete_password(SERVICE_NAME, SESSION_KEY)
|
|
27
|
+
except PasswordDeleteError:
|
|
28
|
+
pass
|