pytgcli 0.8.3__tar.gz → 0.9.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.8.3 → pytgcli-0.9.0}/.github/workflows/ci.yml +2 -2
- {pytgcli-0.8.3 → pytgcli-0.9.0}/.github/workflows/publish.yml +5 -5
- {pytgcli-0.8.3 → pytgcli-0.9.0}/PKG-INFO +17 -5
- {pytgcli-0.8.3 → pytgcli-0.9.0}/README.md +16 -4
- {pytgcli-0.8.3 → pytgcli-0.9.0}/pyproject.toml +5 -1
- {pytgcli-0.8.3 → pytgcli-0.9.0}/src/tgcli/auth.py +31 -4
- {pytgcli-0.8.3 → pytgcli-0.9.0}/src/tgcli/cli.py +71 -6
- {pytgcli-0.8.3 → pytgcli-0.9.0}/src/tgcli/client.py +69 -7
- {pytgcli-0.8.3 → pytgcli-0.9.0}/src/tgcli/config.py +34 -3
- {pytgcli-0.8.3 → pytgcli-0.9.0}/src/tgcli/formatting.py +8 -2
- pytgcli-0.9.0/src/tgcli/session.py +75 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/tests/conftest.py +4 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/tests/test_auth.py +65 -4
- {pytgcli-0.8.3 → pytgcli-0.9.0}/tests/test_cli.py +74 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/tests/test_client.py +66 -13
- {pytgcli-0.8.3 → pytgcli-0.9.0}/tests/test_config.py +36 -1
- {pytgcli-0.8.3 → pytgcli-0.9.0}/tests/test_formatting.py +31 -3
- pytgcli-0.9.0/tests/test_session.py +79 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/uv.lock +1 -1
- pytgcli-0.8.3/src/tgcli/session.py +0 -28
- pytgcli-0.8.3/tests/test_session.py +0 -43
- {pytgcli-0.8.3 → pytgcli-0.9.0}/.github/dependabot.yml +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/.gitignore +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/.pre-commit-config.yaml +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/.python-version +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/AGENTS.md +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/CLAUDE.md +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/LICENSE +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/install.sh +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/src/tgcli/__init__.py +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/src/tgcli/update.py +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/tests/__init__.py +0 -0
- {pytgcli-0.8.3 → pytgcli-0.9.0}/tests/test_update.py +0 -0
|
@@ -10,7 +10,7 @@ jobs:
|
|
|
10
10
|
runs-on: ubuntu-latest
|
|
11
11
|
steps:
|
|
12
12
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
13
|
-
- uses: astral-sh/setup-uv@
|
|
13
|
+
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
14
14
|
- run: uv sync --group dev
|
|
15
15
|
- run: uv run ruff check
|
|
16
16
|
- run: uv run ruff format --check
|
|
@@ -22,7 +22,7 @@ jobs:
|
|
|
22
22
|
python-version: ["3.12", "3.13"]
|
|
23
23
|
steps:
|
|
24
24
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
25
|
-
- uses: astral-sh/setup-uv@
|
|
25
|
+
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
26
26
|
- run: uv python install ${{ matrix.python-version }}
|
|
27
27
|
- run: uv sync --group dev
|
|
28
28
|
- run: uv run pytest
|
|
@@ -33,9 +33,9 @@ jobs:
|
|
|
33
33
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
34
34
|
with:
|
|
35
35
|
ref: ${{ github.ref_name }}
|
|
36
|
-
- uses: astral-sh/setup-uv@
|
|
36
|
+
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
37
37
|
- run: uv build
|
|
38
|
-
- uses: actions/upload-artifact@
|
|
38
|
+
- uses: actions/upload-artifact@v7
|
|
39
39
|
with:
|
|
40
40
|
name: dist
|
|
41
41
|
path: dist/*
|
|
@@ -46,7 +46,7 @@ jobs:
|
|
|
46
46
|
permissions:
|
|
47
47
|
contents: write
|
|
48
48
|
steps:
|
|
49
|
-
- uses: actions/download-artifact@
|
|
49
|
+
- uses: actions/download-artifact@v8
|
|
50
50
|
with:
|
|
51
51
|
name: dist
|
|
52
52
|
path: dist
|
|
@@ -72,11 +72,11 @@ jobs:
|
|
|
72
72
|
id-token: write
|
|
73
73
|
contents: read
|
|
74
74
|
steps:
|
|
75
|
-
- uses: actions/download-artifact@
|
|
75
|
+
- uses: actions/download-artifact@v8
|
|
76
76
|
with:
|
|
77
77
|
name: dist
|
|
78
78
|
path: dist
|
|
79
|
-
- uses: astral-sh/setup-uv@
|
|
79
|
+
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
|
80
80
|
- run: uv publish --trusted-publishing always dist/*
|
|
81
81
|
|
|
82
82
|
update-homebrew-tap:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytgcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -31,7 +31,7 @@ Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram
|
|
|
31
31
|
- **Minimal surface** — a handful of commands; easy for agents to discover and invoke
|
|
32
32
|
- **Fuzzy resolution** — chat and user names match by display name (no numeric IDs required)
|
|
33
33
|
- **`--pretty` for humans** — Rich tables when you want to read output yourself
|
|
34
|
-
- **
|
|
34
|
+
- **Agent-friendly session storage** — Telethon session key stored in a local `0600` file by default; keychain remains available as a legacy option
|
|
35
35
|
|
|
36
36
|
## Installation
|
|
37
37
|
|
|
@@ -73,7 +73,7 @@ Create a Telegram API app at [my.telegram.org/apps](https://my.telegram.org/apps
|
|
|
73
73
|
tg auth
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs in with phone number + verification code.
|
|
76
|
+
This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs in with phone number + verification code. The Telegram session is stored at `~/.config/tgcli/session` by default.
|
|
77
77
|
|
|
78
78
|
### 3. Read Messages
|
|
79
79
|
|
|
@@ -123,7 +123,8 @@ Smart entrypoint: creates config if missing, logs in if needed, shows status if
|
|
|
123
123
|
Explicit subcommands:
|
|
124
124
|
|
|
125
125
|
- `tg auth login` - interactive login (phone + code/2FA)
|
|
126
|
-
- `tg auth logout` - remove
|
|
126
|
+
- `tg auth logout` - remove the local session
|
|
127
|
+
- `tg auth migrate-session` - copy a legacy keychain session to the local session file
|
|
127
128
|
- `tg auth status` - show auth state
|
|
128
129
|
|
|
129
130
|
### `tg chats`
|
|
@@ -172,9 +173,20 @@ Config lives at `~/.config/tgcli/config.toml`:
|
|
|
172
173
|
```toml
|
|
173
174
|
api_id = 123456
|
|
174
175
|
api_hash = "your_api_hash"
|
|
176
|
+
session_store = "file"
|
|
175
177
|
```
|
|
176
178
|
|
|
177
|
-
|
|
179
|
+
`session_store` can be `file` or `keychain`. `file` is the default and stores the Telegram session at `~/.config/tgcli/session` with mode `0600`. `keychain` keeps the legacy system keychain behavior; it may trigger OS password prompts and will be removed in a future major release.
|
|
180
|
+
|
|
181
|
+
To migrate an existing keychain session to the file backend:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
tg auth migrate-session
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Add `--delete-keychain` to remove the keychain entry after a successful migration.
|
|
188
|
+
|
|
189
|
+
Alternatively, set `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, and optionally `TGCLI_SESSION_STORE` environment variables.
|
|
178
190
|
|
|
179
191
|
## Contributing
|
|
180
192
|
|
|
@@ -8,7 +8,7 @@ Give AI agents (Claude Code, Codex, Cursor, etc.) direct access to your Telegram
|
|
|
8
8
|
- **Minimal surface** — a handful of commands; easy for agents to discover and invoke
|
|
9
9
|
- **Fuzzy resolution** — chat and user names match by display name (no numeric IDs required)
|
|
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
|
|
|
13
13
|
## Installation
|
|
14
14
|
|
|
@@ -50,7 +50,7 @@ Create a Telegram API app at [my.telegram.org/apps](https://my.telegram.org/apps
|
|
|
50
50
|
tg auth
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs in with phone number + verification code.
|
|
53
|
+
This walks you through setup: saves your API credentials to `~/.config/tgcli/config.toml`, then logs in with phone number + verification code. The Telegram session is stored at `~/.config/tgcli/session` by default.
|
|
54
54
|
|
|
55
55
|
### 3. Read Messages
|
|
56
56
|
|
|
@@ -100,7 +100,8 @@ Smart entrypoint: creates config if missing, logs in if needed, shows status if
|
|
|
100
100
|
Explicit subcommands:
|
|
101
101
|
|
|
102
102
|
- `tg auth login` - interactive login (phone + code/2FA)
|
|
103
|
-
- `tg auth logout` - remove
|
|
103
|
+
- `tg auth logout` - remove the local session
|
|
104
|
+
- `tg auth migrate-session` - copy a legacy keychain session to the local session file
|
|
104
105
|
- `tg auth status` - show auth state
|
|
105
106
|
|
|
106
107
|
### `tg chats`
|
|
@@ -149,9 +150,20 @@ Config lives at `~/.config/tgcli/config.toml`:
|
|
|
149
150
|
```toml
|
|
150
151
|
api_id = 123456
|
|
151
152
|
api_hash = "your_api_hash"
|
|
153
|
+
session_store = "file"
|
|
152
154
|
```
|
|
153
155
|
|
|
154
|
-
|
|
156
|
+
`session_store` can be `file` or `keychain`. `file` is the default and stores the Telegram session at `~/.config/tgcli/session` with mode `0600`. `keychain` keeps the legacy system keychain behavior; it may trigger OS password prompts and will be removed in a future major release.
|
|
157
|
+
|
|
158
|
+
To migrate an existing keychain session to the file backend:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
tg auth migrate-session
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Add `--delete-keychain` to remove the keychain entry after a successful migration.
|
|
165
|
+
|
|
166
|
+
Alternatively, set `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, and optionally `TGCLI_SESSION_STORE` environment variables.
|
|
155
167
|
|
|
156
168
|
## Contributing
|
|
157
169
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytgcli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "CLI tool to read Telegram messages from the terminal"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -44,6 +44,9 @@ tg = "tgcli.cli:app"
|
|
|
44
44
|
[tool.hatch.build.targets.wheel]
|
|
45
45
|
packages = ["src/tgcli"]
|
|
46
46
|
|
|
47
|
+
[tool.uv]
|
|
48
|
+
cache-dir = "tmp/uv-cache"
|
|
49
|
+
|
|
47
50
|
[tool.ruff]
|
|
48
51
|
target-version = "py312"
|
|
49
52
|
|
|
@@ -55,3 +58,4 @@ select = ["E", "F", "I", "S", "UP"]
|
|
|
55
58
|
|
|
56
59
|
[tool.pytest.ini_options]
|
|
57
60
|
asyncio_mode = "auto"
|
|
61
|
+
testpaths = ["tests"]
|
|
@@ -3,11 +3,12 @@ from __future__ import annotations
|
|
|
3
3
|
from telethon.sessions import StringSession
|
|
4
4
|
|
|
5
5
|
from tgcli.client import create_client
|
|
6
|
-
from tgcli.
|
|
6
|
+
from tgcli.config import load_session_store
|
|
7
|
+
from tgcli.session import delete_session, load_session, save_session, session_path
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
async def login() -> None:
|
|
10
|
-
"""Interactive login: phone + code/2FA. Saves
|
|
11
|
+
"""Interactive login: phone + code/2FA. Saves the local session."""
|
|
11
12
|
client = create_client()
|
|
12
13
|
try:
|
|
13
14
|
await client.start(phone=lambda: input("Phone number: "))
|
|
@@ -18,7 +19,7 @@ async def login() -> None:
|
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
async def logout() -> None:
|
|
21
|
-
"""Log out and remove
|
|
22
|
+
"""Log out and remove the local session.
|
|
22
23
|
|
|
23
24
|
Always deletes the local session, even if the remote logout fails.
|
|
24
25
|
"""
|
|
@@ -35,18 +36,43 @@ async def logout() -> None:
|
|
|
35
36
|
delete_session()
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
def migrate_session(*, delete_keychain: bool = False) -> dict[str, object]:
|
|
40
|
+
"""Copy the legacy keychain session to the file backend."""
|
|
41
|
+
legacy_session = load_session(store="keychain")
|
|
42
|
+
if not legacy_session:
|
|
43
|
+
return {
|
|
44
|
+
"migrated": False,
|
|
45
|
+
"deleted_keychain": False,
|
|
46
|
+
"path": session_path(),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
save_session(legacy_session, store="file")
|
|
50
|
+
deleted_keychain = False
|
|
51
|
+
if delete_keychain:
|
|
52
|
+
delete_session(store="keychain")
|
|
53
|
+
deleted_keychain = True
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
"migrated": True,
|
|
57
|
+
"deleted_keychain": deleted_keychain,
|
|
58
|
+
"path": session_path(),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
38
62
|
async def get_status() -> dict:
|
|
39
63
|
"""Return auth status info.
|
|
40
64
|
|
|
41
65
|
Returns dict with keys: authenticated, phone, session_exists.
|
|
42
66
|
"""
|
|
43
|
-
|
|
67
|
+
session_store = load_session_store()
|
|
68
|
+
session_exists = load_session(store=session_store) is not None
|
|
44
69
|
|
|
45
70
|
if not session_exists:
|
|
46
71
|
return {
|
|
47
72
|
"authenticated": False,
|
|
48
73
|
"phone": None,
|
|
49
74
|
"session_exists": False,
|
|
75
|
+
"session_store": session_store,
|
|
50
76
|
}
|
|
51
77
|
|
|
52
78
|
client = create_client()
|
|
@@ -70,4 +96,5 @@ async def get_status() -> dict:
|
|
|
70
96
|
"authenticated": authorized,
|
|
71
97
|
"phone": phone,
|
|
72
98
|
"session_exists": session_exists,
|
|
99
|
+
"session_store": session_store,
|
|
73
100
|
}
|
|
@@ -18,7 +18,18 @@ def _version_callback(value: bool) -> None:
|
|
|
18
18
|
raise typer.Exit()
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
app = typer.Typer(
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
help=(
|
|
23
|
+
"Read Telegram messages from the terminal.\n\n"
|
|
24
|
+
"Default output is JSONL. Use --pretty for human-readable output.\n\n"
|
|
25
|
+
"Message JSONL fields: id, text, chat_name, sender_name, "
|
|
26
|
+
"sender_username, sender_id, date, reply_to_msg_id. "
|
|
27
|
+
"sender_username may be null; sender_id is the most stable sender key.\n\n"
|
|
28
|
+
"Common flow: `tg chats` to find chat names, `tg read <chat>` to fetch "
|
|
29
|
+
"or filter messages, `tg context <chat> <message_id>` to inspect nearby "
|
|
30
|
+
"messages, `tg auth` for login and status."
|
|
31
|
+
)
|
|
32
|
+
)
|
|
22
33
|
auth_app = typer.Typer(
|
|
23
34
|
help="Manage Telegram authentication.", invoke_without_command=True
|
|
24
35
|
)
|
|
@@ -166,7 +177,7 @@ def login() -> None:
|
|
|
166
177
|
|
|
167
178
|
@auth_app.command()
|
|
168
179
|
def logout() -> None:
|
|
169
|
-
"""Remove
|
|
180
|
+
"""Remove the local session."""
|
|
170
181
|
from tgcli.auth import logout as _logout
|
|
171
182
|
|
|
172
183
|
try:
|
|
@@ -177,6 +188,34 @@ def logout() -> None:
|
|
|
177
188
|
raise typer.Exit(1)
|
|
178
189
|
|
|
179
190
|
|
|
191
|
+
@auth_app.command("migrate-session")
|
|
192
|
+
def migrate_session(
|
|
193
|
+
delete_keychain: Annotated[
|
|
194
|
+
bool,
|
|
195
|
+
typer.Option(
|
|
196
|
+
"--delete-keychain",
|
|
197
|
+
help="Delete the legacy keychain session after a successful migration.",
|
|
198
|
+
),
|
|
199
|
+
] = False,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Copy the legacy keychain session to the local session file."""
|
|
202
|
+
from tgcli.auth import migrate_session as _migrate_session
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
result = _migrate_session(delete_keychain=delete_keychain)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
stderr.print(f"[red]Migration failed:[/red] {e}")
|
|
208
|
+
raise typer.Exit(1)
|
|
209
|
+
|
|
210
|
+
if not result["migrated"]:
|
|
211
|
+
stderr.print("No keychain session found.")
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
stdout.print(f"Session migrated to {result['path']}.")
|
|
215
|
+
if result["deleted_keychain"]:
|
|
216
|
+
stdout.print("Deleted legacy keychain session.")
|
|
217
|
+
|
|
218
|
+
|
|
180
219
|
@auth_app.command()
|
|
181
220
|
def status() -> None:
|
|
182
221
|
"""Show current auth state."""
|
|
@@ -246,14 +285,34 @@ def chats(
|
|
|
246
285
|
print(format_chat_jsonl(chat))
|
|
247
286
|
|
|
248
287
|
|
|
249
|
-
@app.command(
|
|
288
|
+
@app.command(
|
|
289
|
+
help=(
|
|
290
|
+
"Read recent messages from a chat. Newest first by default (--head for "
|
|
291
|
+
"oldest).\n\n"
|
|
292
|
+
"Default JSONL includes id, text, chat_name, sender_name, "
|
|
293
|
+
"sender_username, sender_id, date, and reply_to_msg_id. "
|
|
294
|
+
"sender_username may be null; sender_id is the most stable sender key.\n\n"
|
|
295
|
+
"--from accepts me, @username, bare username, numeric user ID, phone, "
|
|
296
|
+
"or exact display name. Display names can be ambiguous; prefer username "
|
|
297
|
+
"or numeric ID for automation.\n\n"
|
|
298
|
+
"Examples: `tg read Team --from takeshi55555`, "
|
|
299
|
+
'`tg read Team --from @takeshi55555`, `tg read Team --from "Takeshi"`.'
|
|
300
|
+
)
|
|
301
|
+
)
|
|
250
302
|
def read(
|
|
251
303
|
chat: Annotated[str, typer.Argument(help="Chat or person to read messages from.")],
|
|
252
304
|
query: Annotated[
|
|
253
305
|
str | None, typer.Option("--query", "-q", help="Filter messages by text.")
|
|
254
306
|
] = None,
|
|
255
307
|
from_: Annotated[
|
|
256
|
-
str | None,
|
|
308
|
+
str | None,
|
|
309
|
+
typer.Option(
|
|
310
|
+
"--from",
|
|
311
|
+
help=(
|
|
312
|
+
"Filter by sender: me, @username, bare username, numeric user ID, "
|
|
313
|
+
"phone, or exact display name."
|
|
314
|
+
),
|
|
315
|
+
),
|
|
257
316
|
] = None,
|
|
258
317
|
limit: Annotated[int, typer.Option(help="Max messages to return.")] = 50,
|
|
259
318
|
head: Annotated[
|
|
@@ -269,7 +328,7 @@ def read(
|
|
|
269
328
|
bool, typer.Option("--pretty", help="Rich table output instead of JSONL.")
|
|
270
329
|
] = False,
|
|
271
330
|
) -> None:
|
|
272
|
-
"""Read recent messages from a chat.
|
|
331
|
+
"""Read recent messages from a chat."""
|
|
273
332
|
from tgcli.client import create_client, read_messages
|
|
274
333
|
|
|
275
334
|
try:
|
|
@@ -322,7 +381,13 @@ def read(
|
|
|
322
381
|
print(format_message_jsonl(msg))
|
|
323
382
|
|
|
324
383
|
|
|
325
|
-
@app.command(
|
|
384
|
+
@app.command(
|
|
385
|
+
help=(
|
|
386
|
+
"View a message with surrounding context.\n\n"
|
|
387
|
+
"Default JSONL uses the same message fields as `read` and adds boolean "
|
|
388
|
+
"flags such as target and replied_to on relevant lines."
|
|
389
|
+
)
|
|
390
|
+
)
|
|
326
391
|
def context(
|
|
327
392
|
chat: str,
|
|
328
393
|
message_id: int,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import re
|
|
3
4
|
from datetime import datetime
|
|
4
5
|
|
|
5
6
|
from telethon import TelegramClient
|
|
@@ -9,11 +10,13 @@ from tgcli.config import TelegramConfig, load_config
|
|
|
9
10
|
from tgcli.formatting import ChatData, MessageData
|
|
10
11
|
from tgcli.session import load_session
|
|
11
12
|
|
|
13
|
+
_USERNAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{4,31}$")
|
|
14
|
+
|
|
12
15
|
|
|
13
16
|
def create_client(config: TelegramConfig | None = None) -> TelegramClient:
|
|
14
17
|
"""Create a TelegramClient using stored session and config."""
|
|
15
18
|
config = config or load_config()
|
|
16
|
-
session_str = load_session() or ""
|
|
19
|
+
session_str = load_session(store=config.session_store) or ""
|
|
17
20
|
return TelegramClient(
|
|
18
21
|
StringSession(session_str),
|
|
19
22
|
config.api_id,
|
|
@@ -31,6 +34,27 @@ def _get_name(entity) -> str:
|
|
|
31
34
|
return " ".join(p for p in parts if p) or "Unknown"
|
|
32
35
|
|
|
33
36
|
|
|
37
|
+
def _get_username(entity) -> str | None:
|
|
38
|
+
if entity is None:
|
|
39
|
+
return None
|
|
40
|
+
return getattr(entity, "username", None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_sender_id(msg, sender) -> int | None:
|
|
44
|
+
sender_id = getattr(msg, "sender_id", None)
|
|
45
|
+
if sender_id is not None:
|
|
46
|
+
return sender_id
|
|
47
|
+
if sender is None:
|
|
48
|
+
return None
|
|
49
|
+
return getattr(sender, "id", None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_entity_reference(value: str) -> bool:
|
|
53
|
+
if value.startswith("@") or value.startswith("+") or value.lstrip("-").isdigit():
|
|
54
|
+
return True
|
|
55
|
+
return bool(_USERNAME_RE.fullmatch(value))
|
|
56
|
+
|
|
57
|
+
|
|
34
58
|
async def _resolve_entity(client: TelegramClient, name: str):
|
|
35
59
|
"""Resolve a name to a Telethon entity.
|
|
36
60
|
|
|
@@ -56,13 +80,51 @@ async def _resolve_entity(client: TelegramClient, name: str):
|
|
|
56
80
|
)
|
|
57
81
|
|
|
58
82
|
|
|
59
|
-
def
|
|
83
|
+
async def _resolve_sender(client: TelegramClient, chat_entity, value: str):
|
|
84
|
+
"""Resolve a sender filter within a chat."""
|
|
85
|
+
if value.lower() == "me":
|
|
86
|
+
return await client.get_me()
|
|
87
|
+
|
|
88
|
+
sender_ref = value.strip()
|
|
89
|
+
if _is_entity_reference(sender_ref):
|
|
90
|
+
try:
|
|
91
|
+
return await client.get_entity(sender_ref)
|
|
92
|
+
except Exception: # noqa: S110
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
participant_search = sender_ref[1:] if sender_ref.startswith("@") else sender_ref
|
|
96
|
+
username_query = participant_search.lower()
|
|
97
|
+
display_matches = []
|
|
98
|
+
async for participant in client.iter_participants(
|
|
99
|
+
chat_entity, search=participant_search
|
|
100
|
+
):
|
|
101
|
+
username = _get_username(participant)
|
|
102
|
+
if username and username.lower() == username_query:
|
|
103
|
+
return participant
|
|
104
|
+
if _get_name(participant).lower() == sender_ref.lower():
|
|
105
|
+
display_matches.append(participant)
|
|
106
|
+
|
|
107
|
+
if len(display_matches) == 1:
|
|
108
|
+
return display_matches[0]
|
|
109
|
+
if len(display_matches) > 1:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f'Found multiple senders named "{value}". Use a username or numeric ID.'
|
|
112
|
+
)
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f'Cannot find sender "{value}" in this chat. Use username, numeric ID, '
|
|
115
|
+
"or exact display name."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _msg_to_data(msg, chat_name: str, sender) -> MessageData:
|
|
60
120
|
return MessageData(
|
|
61
121
|
id=msg.id,
|
|
62
122
|
text=msg.text or "",
|
|
63
123
|
chat_name=chat_name,
|
|
64
|
-
sender_name=
|
|
124
|
+
sender_name=_get_name(sender),
|
|
65
125
|
date=msg.date,
|
|
126
|
+
sender_username=_get_username(sender),
|
|
127
|
+
sender_id=_get_sender_id(msg, sender),
|
|
66
128
|
reply_to_msg_id=msg.reply_to.reply_to_msg_id if msg.reply_to else None,
|
|
67
129
|
)
|
|
68
130
|
|
|
@@ -128,7 +190,7 @@ async def read_messages(
|
|
|
128
190
|
|
|
129
191
|
from_user = None
|
|
130
192
|
if from_:
|
|
131
|
-
from_user = await
|
|
193
|
+
from_user = await _resolve_sender(client, entity, from_)
|
|
132
194
|
|
|
133
195
|
filtering = bool(query or from_user)
|
|
134
196
|
filter_query = query.lower() if query else None
|
|
@@ -156,7 +218,7 @@ async def read_messages(
|
|
|
156
218
|
continue
|
|
157
219
|
|
|
158
220
|
sender = await msg.get_sender()
|
|
159
|
-
results.append(_msg_to_data(msg, chat_name,
|
|
221
|
+
results.append(_msg_to_data(msg, chat_name, sender))
|
|
160
222
|
if len(results) >= limit:
|
|
161
223
|
break
|
|
162
224
|
|
|
@@ -205,7 +267,7 @@ async def get_context(
|
|
|
205
267
|
messages: list[MessageData] = []
|
|
206
268
|
for msg in messages_raw:
|
|
207
269
|
sender = await msg.get_sender()
|
|
208
|
-
messages.append(_msg_to_data(msg, chat_name,
|
|
270
|
+
messages.append(_msg_to_data(msg, chat_name, sender))
|
|
209
271
|
|
|
210
272
|
# Find the replied-to message if applicable
|
|
211
273
|
replied_to = None
|
|
@@ -215,6 +277,6 @@ async def get_context(
|
|
|
215
277
|
reply_msg = await client.get_messages(entity, ids=reply_id)
|
|
216
278
|
if reply_msg:
|
|
217
279
|
sender = await reply_msg.get_sender()
|
|
218
|
-
replied_to = _msg_to_data(reply_msg, chat_name,
|
|
280
|
+
replied_to = _msg_to_data(reply_msg, chat_name, sender)
|
|
219
281
|
|
|
220
282
|
return messages, message_id, replied_to
|
|
@@ -4,14 +4,38 @@ import os
|
|
|
4
4
|
import tomllib
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from pathlib import Path
|
|
7
|
+
from typing import Literal, cast
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
CONFIG_DIR = Path.home() / ".config" / "tgcli"
|
|
10
|
+
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
11
|
+
SessionStore = Literal["file", "keychain"]
|
|
9
12
|
|
|
10
13
|
|
|
11
14
|
@dataclass(frozen=True)
|
|
12
15
|
class TelegramConfig:
|
|
13
16
|
api_id: int
|
|
14
17
|
api_hash: str
|
|
18
|
+
session_store: SessionStore = "file"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_session_store(value: object) -> SessionStore:
|
|
22
|
+
store = str(value or "file").lower()
|
|
23
|
+
if store not in {"file", "keychain"}:
|
|
24
|
+
raise ValueError('session_store must be "file" or "keychain"')
|
|
25
|
+
return cast(SessionStore, store)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_session_store(config_path: Path | None = None) -> SessionStore:
|
|
29
|
+
"""Load the configured session backend without requiring API credentials."""
|
|
30
|
+
path = config_path or CONFIG_PATH
|
|
31
|
+
raw_store: object = None
|
|
32
|
+
|
|
33
|
+
if path.exists():
|
|
34
|
+
with open(path, "rb") as f:
|
|
35
|
+
data = tomllib.load(f)
|
|
36
|
+
raw_store = data.get("session_store")
|
|
37
|
+
|
|
38
|
+
return _parse_session_store(os.environ.get("TGCLI_SESSION_STORE", raw_store))
|
|
15
39
|
|
|
16
40
|
|
|
17
41
|
def load_config(config_path: Path | None = None) -> TelegramConfig:
|
|
@@ -25,6 +49,7 @@ def load_config(config_path: Path | None = None) -> TelegramConfig:
|
|
|
25
49
|
path = config_path or CONFIG_PATH
|
|
26
50
|
api_id: str | None = None
|
|
27
51
|
api_hash: str | None = None
|
|
52
|
+
session_store: SessionStore = "file"
|
|
28
53
|
|
|
29
54
|
if path.exists():
|
|
30
55
|
with open(path, "rb") as f:
|
|
@@ -36,6 +61,8 @@ def load_config(config_path: Path | None = None) -> TelegramConfig:
|
|
|
36
61
|
if raw_hash is not None:
|
|
37
62
|
api_hash = str(raw_hash)
|
|
38
63
|
|
|
64
|
+
session_store = load_session_store(path)
|
|
65
|
+
|
|
39
66
|
if api_id is None:
|
|
40
67
|
api_id = os.environ.get("TELEGRAM_API_ID")
|
|
41
68
|
if api_hash is None:
|
|
@@ -48,7 +75,9 @@ def load_config(config_path: Path | None = None) -> TelegramConfig:
|
|
|
48
75
|
"TELEGRAM_API_ID / TELEGRAM_API_HASH env vars."
|
|
49
76
|
)
|
|
50
77
|
|
|
51
|
-
return TelegramConfig(
|
|
78
|
+
return TelegramConfig(
|
|
79
|
+
api_id=int(api_id), api_hash=api_hash, session_store=session_store
|
|
80
|
+
)
|
|
52
81
|
|
|
53
82
|
|
|
54
83
|
def write_config(api_id: int, api_hash: str, config_path: Path | None = None) -> Path:
|
|
@@ -58,5 +87,7 @@ def write_config(api_id: int, api_hash: str, config_path: Path | None = None) ->
|
|
|
58
87
|
"""
|
|
59
88
|
path = config_path or CONFIG_PATH
|
|
60
89
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
-
path.write_text(
|
|
90
|
+
path.write_text(
|
|
91
|
+
f'api_id = {api_id}\napi_hash = "{api_hash}"\nsession_store = "file"\n'
|
|
92
|
+
)
|
|
62
93
|
return path
|
|
@@ -24,6 +24,8 @@ class MessageData:
|
|
|
24
24
|
chat_name: str
|
|
25
25
|
sender_name: str
|
|
26
26
|
date: datetime
|
|
27
|
+
sender_username: str | None = None
|
|
28
|
+
sender_id: int | None = None
|
|
27
29
|
reply_to_msg_id: int | None = None
|
|
28
30
|
|
|
29
31
|
|
|
@@ -127,6 +129,7 @@ def format_auth_status(
|
|
|
127
129
|
authenticated: bool,
|
|
128
130
|
phone: str | None = None,
|
|
129
131
|
session_exists: bool = False,
|
|
132
|
+
session_store: str = "file",
|
|
130
133
|
) -> Text:
|
|
131
134
|
"""Build a Rich Text for auth status display."""
|
|
132
135
|
output = Text()
|
|
@@ -144,8 +147,11 @@ def format_auth_status(
|
|
|
144
147
|
|
|
145
148
|
output.append("Session: ", style="bold")
|
|
146
149
|
if session_exists:
|
|
147
|
-
|
|
150
|
+
if session_store == "keychain":
|
|
151
|
+
output.append("stored in keychain\n", style="green")
|
|
152
|
+
else:
|
|
153
|
+
output.append("stored locally\n", style="green")
|
|
148
154
|
else:
|
|
149
|
-
output.append("none\n", style="red")
|
|
155
|
+
output.append(f"none ({session_store})\n", style="red")
|
|
150
156
|
|
|
151
157
|
return output
|