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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tg = tgcli.cli:app
@@ -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