pytgcli 0.8.2__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.
Files changed (34) hide show
  1. {pytgcli-0.8.2 → pytgcli-0.9.0}/.github/workflows/ci.yml +2 -2
  2. pytgcli-0.9.0/.github/workflows/publish.yml +162 -0
  3. {pytgcli-0.8.2 → pytgcli-0.9.0}/PKG-INFO +17 -5
  4. {pytgcli-0.8.2 → pytgcli-0.9.0}/README.md +16 -4
  5. {pytgcli-0.8.2 → pytgcli-0.9.0}/pyproject.toml +5 -1
  6. {pytgcli-0.8.2 → pytgcli-0.9.0}/src/tgcli/auth.py +31 -4
  7. {pytgcli-0.8.2 → pytgcli-0.9.0}/src/tgcli/cli.py +71 -6
  8. {pytgcli-0.8.2 → pytgcli-0.9.0}/src/tgcli/client.py +69 -7
  9. {pytgcli-0.8.2 → pytgcli-0.9.0}/src/tgcli/config.py +34 -3
  10. {pytgcli-0.8.2 → pytgcli-0.9.0}/src/tgcli/formatting.py +8 -2
  11. pytgcli-0.9.0/src/tgcli/session.py +75 -0
  12. {pytgcli-0.8.2 → pytgcli-0.9.0}/tests/conftest.py +4 -0
  13. {pytgcli-0.8.2 → pytgcli-0.9.0}/tests/test_auth.py +65 -4
  14. {pytgcli-0.8.2 → pytgcli-0.9.0}/tests/test_cli.py +74 -0
  15. {pytgcli-0.8.2 → pytgcli-0.9.0}/tests/test_client.py +66 -13
  16. {pytgcli-0.8.2 → pytgcli-0.9.0}/tests/test_config.py +36 -1
  17. {pytgcli-0.8.2 → pytgcli-0.9.0}/tests/test_formatting.py +31 -3
  18. pytgcli-0.9.0/tests/test_session.py +79 -0
  19. {pytgcli-0.8.2 → pytgcli-0.9.0}/uv.lock +1 -1
  20. pytgcli-0.8.2/.github/workflows/publish.yml +0 -80
  21. pytgcli-0.8.2/src/tgcli/session.py +0 -28
  22. pytgcli-0.8.2/tests/test_session.py +0 -43
  23. {pytgcli-0.8.2 → pytgcli-0.9.0}/.github/dependabot.yml +0 -0
  24. {pytgcli-0.8.2 → pytgcli-0.9.0}/.gitignore +0 -0
  25. {pytgcli-0.8.2 → pytgcli-0.9.0}/.pre-commit-config.yaml +0 -0
  26. {pytgcli-0.8.2 → pytgcli-0.9.0}/.python-version +0 -0
  27. {pytgcli-0.8.2 → pytgcli-0.9.0}/AGENTS.md +0 -0
  28. {pytgcli-0.8.2 → pytgcli-0.9.0}/CLAUDE.md +0 -0
  29. {pytgcli-0.8.2 → pytgcli-0.9.0}/LICENSE +0 -0
  30. {pytgcli-0.8.2 → pytgcli-0.9.0}/install.sh +0 -0
  31. {pytgcli-0.8.2 → pytgcli-0.9.0}/src/tgcli/__init__.py +0 -0
  32. {pytgcli-0.8.2 → pytgcli-0.9.0}/src/tgcli/update.py +0 -0
  33. {pytgcli-0.8.2 → pytgcli-0.9.0}/tests/__init__.py +0 -0
  34. {pytgcli-0.8.2 → 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@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
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@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
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
@@ -0,0 +1,162 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ verify:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ steps:
14
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
15
+ with:
16
+ ref: ${{ github.ref_name }}
17
+ - name: Verify tag matches pyproject version
18
+ run: |
19
+ TAG_VERSION="${{ github.ref_name }}"
20
+ TAG_VERSION="${TAG_VERSION#v}"
21
+ PROJECT_VERSION="$(python3 -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")"
22
+ if [ "$TAG_VERSION" != "$PROJECT_VERSION" ]; then
23
+ echo "Tag version ($TAG_VERSION) does not match pyproject version ($PROJECT_VERSION)."
24
+ exit 1
25
+ fi
26
+
27
+ build:
28
+ needs: verify
29
+ runs-on: ubuntu-latest
30
+ permissions:
31
+ contents: read
32
+ steps:
33
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
34
+ with:
35
+ ref: ${{ github.ref_name }}
36
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
37
+ - run: uv build
38
+ - uses: actions/upload-artifact@v7
39
+ with:
40
+ name: dist
41
+ path: dist/*
42
+
43
+ github-release:
44
+ needs: build
45
+ runs-on: ubuntu-latest
46
+ permissions:
47
+ contents: write
48
+ steps:
49
+ - uses: actions/download-artifact@v8
50
+ with:
51
+ name: dist
52
+ path: dist
53
+ - name: Create GitHub release and upload assets
54
+ env:
55
+ GH_TOKEN: ${{ github.token }}
56
+ run: |
57
+ if gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" > /dev/null 2>&1; then
58
+ echo "Release ${{ github.ref_name }} already exists."
59
+ else
60
+ gh release create "${{ github.ref_name }}" \
61
+ --repo "${{ github.repository }}" \
62
+ --title "${{ github.ref_name }}" \
63
+ --notes "Automated release for ${{ github.ref_name }}"
64
+ fi
65
+ gh release upload "${{ github.ref_name }}" dist/* --repo "${{ github.repository }}" --clobber
66
+
67
+ publish-pypi:
68
+ needs: build
69
+ runs-on: ubuntu-latest
70
+ environment: pypi
71
+ permissions:
72
+ id-token: write
73
+ contents: read
74
+ steps:
75
+ - uses: actions/download-artifact@v8
76
+ with:
77
+ name: dist
78
+ path: dist
79
+ - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
80
+ - run: uv publish --trusted-publishing always dist/*
81
+
82
+ update-homebrew-tap:
83
+ needs:
84
+ - github-release
85
+ - publish-pypi
86
+ runs-on: ubuntu-latest
87
+ permissions:
88
+ contents: read
89
+ env:
90
+ TAP_REPO: tksohishi/homebrew-tap
91
+ TAP_TOKEN: ${{ secrets.TAP_GH_TOKEN }}
92
+ steps:
93
+ - name: Validate tap token
94
+ run: |
95
+ if [ -z "${TAP_TOKEN}" ]; then
96
+ echo "TAP_GH_TOKEN is required to update homebrew-tap."
97
+ exit 1
98
+ fi
99
+
100
+ - name: Resolve PyPI source artifact
101
+ env:
102
+ VERSION: ${{ github.ref_name }}
103
+ run: |
104
+ export TAG_VERSION="${VERSION#v}"
105
+ python3 <<'PY'
106
+ import json
107
+ import os
108
+ import urllib.request
109
+
110
+ version = os.environ["TAG_VERSION"]
111
+ url = f"https://pypi.org/pypi/pytgcli/{version}/json"
112
+ with urllib.request.urlopen(url) as response:
113
+ data = json.load(response)
114
+
115
+ sdist = next(item for item in data["urls"] if item["packagetype"] == "sdist")
116
+ with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env_file:
117
+ env_file.write(f"TGCLI_VERSION={version}\n")
118
+ env_file.write(f"TGCLI_SDIST_URL={sdist['url']}\n")
119
+ env_file.write(f"TGCLI_SDIST_SHA256={sdist['digests']['sha256']}\n")
120
+ PY
121
+
122
+ - name: Update formula in tap repo
123
+ run: |
124
+ git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap-repo
125
+ python3 <<'PY'
126
+ import os
127
+ import re
128
+ from pathlib import Path
129
+
130
+ formula_path = Path("tap-repo/Formula/tgcli.rb")
131
+ content = formula_path.read_text(encoding="utf-8")
132
+ content, url_replacements = re.subn(
133
+ r'^ url ".*"$',
134
+ f' url "{os.environ["TGCLI_SDIST_URL"]}"',
135
+ content,
136
+ count=1,
137
+ flags=re.MULTILINE,
138
+ )
139
+ content, sha_replacements = re.subn(
140
+ r'^ sha256 ".*"$',
141
+ f' sha256 "{os.environ["TGCLI_SDIST_SHA256"]}"',
142
+ content,
143
+ count=1,
144
+ flags=re.MULTILINE,
145
+ )
146
+ if url_replacements != 1 or sha_replacements != 1:
147
+ raise RuntimeError("Failed to patch tgcli.rb top-level url/sha256")
148
+ formula_path.write_text(content, encoding="utf-8")
149
+ PY
150
+
151
+ - name: Commit and push formula update
152
+ run: |
153
+ cd tap-repo
154
+ git config user.name "github-actions[bot]"
155
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
156
+ git add Formula/tgcli.rb
157
+ if git diff --cached --quiet; then
158
+ echo "No formula changes to push."
159
+ exit 0
160
+ fi
161
+ git commit -m "chore: update tgcli formula to v${TGCLI_VERSION}"
162
+ git push origin main
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytgcli
3
- Version: 0.8.2
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
- - **Secure session storage** — Telethon session key stored in system keychain via `keyring`
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 session from system keychain
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
- Alternatively, set `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` environment variables.
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
- - **Secure session storage** — Telethon session key stored in system keychain via `keyring`
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 session from system keychain
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
- Alternatively, set `TELEGRAM_API_ID` and `TELEGRAM_API_HASH` environment variables.
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.8.2"
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.session import delete_session, load_session, save_session
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 session to system keychain."""
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 session from system keychain.
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
- session_exists = load_session() is not None
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(help="Read Telegram messages from the terminal.")
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 session from system keychain."""
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, typer.Option("--from", help="Filter by sender.")
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. Newest first by default (--head for oldest)."""
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 _msg_to_data(msg, chat_name: str, sender_name: str) -> MessageData:
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=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 _resolve_entity(client, from_)
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, _get_name(sender)))
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, _get_name(sender)))
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, _get_name(sender))
280
+ replied_to = _msg_to_data(reply_msg, chat_name, sender)
219
281
 
220
282
  return messages, message_id, replied_to