tg2llm 0.1.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.
@@ -0,0 +1,5 @@
1
+ # Copy to .env and fill in from https://my.telegram.org (API development tools)
2
+ TG_API_ID=
3
+ TG_API_HASH=
4
+ # Optional: custom session file location (default: ~/.config/tg-sort/session)
5
+ # TG_SESSION=
@@ -0,0 +1,223 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ *.lcov
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ # Pipfile.lock
97
+
98
+ # UV
99
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # uv.lock
103
+
104
+ # poetry
105
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
107
+ # commonly ignored for libraries.
108
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109
+ # poetry.lock
110
+ # poetry.toml
111
+
112
+ # pdm
113
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
114
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
115
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
116
+ # pdm.lock
117
+ # pdm.toml
118
+ .pdm-python
119
+ .pdm-build/
120
+
121
+ # pixi
122
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
123
+ # pixi.lock
124
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
125
+ # in the .venv directory. It is recommended not to include this directory in version control.
126
+ .pixi/*
127
+ !.pixi/config.toml
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule*
134
+ celerybeat.pid
135
+
136
+ # Redis
137
+ *.rdb
138
+ *.aof
139
+ *.pid
140
+
141
+ # RabbitMQ
142
+ mnesia/
143
+ rabbitmq/
144
+ rabbitmq-data/
145
+
146
+ # ActiveMQ
147
+ activemq-data/
148
+
149
+ # SageMath parsed files
150
+ *.sage.py
151
+
152
+ # Environments
153
+ .env
154
+ .envrc
155
+ .venv
156
+ env/
157
+ venv/
158
+ ENV/
159
+ env.bak/
160
+ venv.bak/
161
+
162
+ # Spyder project settings
163
+ .spyderproject
164
+ .spyproject
165
+
166
+ # Rope project settings
167
+ .ropeproject
168
+
169
+ # mkdocs documentation
170
+ /site
171
+
172
+ # mypy
173
+ .mypy_cache/
174
+ .dmypy.json
175
+ dmypy.json
176
+
177
+ # Pyre type checker
178
+ .pyre/
179
+
180
+ # pytype static type analyzer
181
+ .pytype/
182
+
183
+ # Cython debug symbols
184
+ cython_debug/
185
+
186
+ # PyCharm
187
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
188
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
189
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
190
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
191
+ # .idea/
192
+
193
+ # Abstra
194
+ # Abstra is an AI-powered process automation framework.
195
+ # Ignore directories containing user credentials, local state, and settings.
196
+ # Learn more at https://abstra.io/docs
197
+ .abstra/
198
+
199
+ # Visual Studio Code
200
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
201
+ # can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
202
+ # and can be added to the global gitignore or merged into this file. However, if you prefer, you
203
+ # could uncomment the following to ignore the entire vscode folder
204
+ # .vscode/
205
+ # Temporary file for partial code execution
206
+ tempCodeRunnerFile.py
207
+
208
+ # Ruff stuff:
209
+ .ruff_cache/
210
+
211
+ # PyPI configuration file
212
+ .pypirc
213
+
214
+ # Marimo
215
+ marimo/_static/
216
+ marimo/_lsp/
217
+ __marimo__/
218
+
219
+ # Streamlit
220
+ .streamlit/secrets.toml
221
+
222
+ # Telethon session files (= full account access)
223
+ *.session
tg2llm-0.1.0/AGENTS.md ADDED
@@ -0,0 +1,73 @@
1
+ # AGENTS.md
2
+
3
+ ## Project Overview
4
+
5
+ `tg` is a single-file Python CLI (`tg.py`, ~340 lines) that exposes a Telegram **user account** via MTProto (Telethon) so an AI agent can inventory chats/contacts, read recent messages, and sort chats into folders. All output is JSON; all write paths are additive and dry-runnable. There is deliberately no framework, no package layout, no second module.
6
+
7
+ ## Setup Commands
8
+
9
+ ```bash
10
+ make setup # uv sync — creates .venv, installs telethon + pytest
11
+ make install # system-wide binary via `uv tool install .` (project.scripts: tg = "tg:main")
12
+ ```
13
+
14
+ Credentials: `cp .env.example .env` and fill `TG_API_ID`/`TG_API_HASH` (from my.telegram.org). The Makefile auto-exports `.env`. Live login is interactive and human-only: `make auth` (session lands in `~/.config/tg-sort/session`). Without credentials/login every command exits fast with a JSON error — that is by design, not a bug.
15
+
16
+ ## Verification Gate
17
+
18
+ Every change must pass:
19
+
20
+ ```bash
21
+ make check # ruff check + pytest, in that order
22
+ ```
23
+
24
+ No CI exists yet; the gate is local only.
25
+
26
+ ## Testing Instructions
27
+
28
+ - Run all: `make test` (or `uv run pytest -q`)
29
+ - Single test: `uv run pytest -q test_tg.py::test_move_adds_peer_additively`
30
+ - Tests are **offline by design**: pure helpers are exercised directly, and `move_chat()` runs against `FakeClient` (records every request; returns canned filters). Never add a test that needs a real session or network.
31
+ - New write-path logic (folder mutation, archive, anything sending a request) must get a `--dry-run`-style test asserting **zero requests sent**, plus a happy-path test asserting the exact request type (`functions.messages.UpdateDialogFilterRequest`).
32
+ - TDD: write the failing test first, watch it fail, then implement.
33
+
34
+ ## Code Style
35
+
36
+ - Stdlib only for the CLI (`argparse`, `asyncio`, `contextlib`, `json`). Telethon is the only runtime dependency.
37
+ - One file. Do not split `tg.py` into a package unless it hurts.
38
+ - No comments unless asked; function/variable names carry the meaning.
39
+ - Structure to preserve, top to bottom: constants → `CliError` → output/transport helpers (`out`, `call`, `session`) → pure helpers (`unwrap_filters`, `filter_title`, `find_filter`, `next_filter_id`, `new_filter`, `folder_membership`, `chat_row`, `parse_chat`) → `move_chat` → command handlers → `main()`.
40
+ - Pure helpers must stay Telethon-importable-but-callable without a client — that's what makes them testable.
41
+ - Errors: raise `CliError` with a message; `main()` turns it into `{"error": ...}` + exit 1. Never `sys.exit` deep in helpers.
42
+
43
+ ## Build / Install
44
+
45
+ `uv tool install --upgrade .` builds a wheel (hatchling, `only-include = ["tg.py"]` — flat module, this flag is load-bearing) and links `~/.local/bin/tg`. Rerun after any `tg.py` change for the installed binary to pick it up. Package name is `tg2llm` (avoids PyPI collision with GramJS's `telegram`).
46
+
47
+ ## Telethon Gotchas (the load-bearing knowledge)
48
+
49
+ 1. **`async with client` calls `start()`**, which prompts interactively on stdin when unauthorized — an agent process hangs forever. All non-auth commands use the `session()` contextmanager: `connect()` → `is_user_authorized()` check → fail fast. Only `cmd_auth` may call `start()`.
50
+ 2. **Name collision:** `types.UpdateDialogFilter` is an _update event_; the request is `functions.messages.UpdateDialogFilterRequest`. Wrong namespace = isinstance checks silently fail.
51
+ 3. **Folder titles are `TextWithEntities`** (Folders 2.0), not strings. Always extract text via `filter_title()`; when constructing, pass `types.TextPlain(title)`. Server rejects full-filter updates that drop fields — send the whole `DialogFilter` back, not a partial.
52
+ 4. **Folders are saved views, not containers**: membership = presence in `DialogFilter.include_peers`. "Move" is additive append + `UpdateDialogFilterRequest(id, filter)`. Chats may legitimately live in multiple folders.
53
+ 5. **IDs are marked**: users positive, groups negative, channels/supergroups `-100…`. Compare with `telethon.utils.get_peer_id()` on both sides before any equality check.
54
+ 6. **API drift defense:** `unwrap_filters()` handles both a bare filter list and a wrapper exposing `.filters` — keep it that way when touching folder code.
55
+ 7. FloodWait: all requests go through `call()` which sleeps `e.seconds + 1` and retries once. Don't bypass it.
56
+
57
+ ## LSP Noise
58
+
59
+ The editor reports ~8 type errors in `tg.py` (TextPlain assignability, `start()`/`get_me()` return types, `reversed()` on TotalList). These are Telethon stub-inference gaps, runtime-verified by the test suite. Suppress with narrow `# type: ignore[union-attr]` comments like the tests do — do not "fix" them by restructuring working code.
60
+
61
+ ## Debugging
62
+
63
+ - Smoke path after changes: `./tg --help`, then `tg chats` against a real session.
64
+ - `tg move <chat> --to <folder> --dry-run` is the safe probe for folder logic.
65
+ - Session file `~/.config/tg-sort/session` is the account credential — never read, copy, or commit it; `*.session` is gitignored.
66
+ - To test against a scratch account: `TG_SESSION=~/.config/tg-sort/session-test` isolates the session.
67
+
68
+ ## PR / Change Checklist
69
+
70
+ - `make check` green (lint errors are fixed, never skipped — no "pre-existing" excuses)
71
+ - New logic got a failing-test-first pass
72
+ - Write paths: dry-run test included, request counts asserted
73
+ - README updated if the CLI surface, env vars, or limits changed
tg2llm-0.1.0/Makefile ADDED
@@ -0,0 +1,35 @@
1
+ .PHONY: help setup install uninstall auth test lint fmt check clean
2
+
3
+ -include .env
4
+ export
5
+
6
+ UV ?= uv
7
+
8
+ help: ## Show available targets
9
+ @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
10
+
11
+ setup: ## Install dependencies into .venv (uv sync)
12
+ $(UV) sync
13
+
14
+ install: ## Install tg system-wide via uv tool
15
+ $(UV) tool install --upgrade .
16
+
17
+ uninstall: ## Remove system-wide tg
18
+ $(UV) tool uninstall tg2llm
19
+
20
+ auth: ## One-time interactive Telegram login
21
+ $(UV) run python tg.py auth
22
+
23
+ test: ## Run test suite
24
+ $(UV) run pytest -q
25
+
26
+ lint: ## Run ruff checks
27
+ ruff check tg.py test_tg.py
28
+
29
+ fmt: ## Ruff autofix
30
+ ruff check --fix tg.py test_tg.py
31
+
32
+ check: lint test ## Everything a change must pass
33
+
34
+ clean: ## Remove caches
35
+ rm -rf .pytest_cache .ruff_cache __pycache__
tg2llm-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.5
2
+ Name: tg2llm
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.12
5
+ Requires-Dist: telethon>=1.45.0
tg2llm-0.1.0/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # tg — Telegram sorting CLI for AI agents
2
+
3
+ `tg` is a single-file Python CLI that exposes a **Telegram user account** to AI agents (or humans): it lists contacts, chats and folders, reads recent messages, and sorts chats into folders. All output is JSON on stdout, all errors are JSON on stderr with a non-zero exit code — built to be shell-scripted by an agent loop:
4
+
5
+ ```
6
+ tg chats → tg read <chat> → tg move <chat> --to <folder>
7
+ ```
8
+
9
+ > [!IMPORTANT]
10
+ > This talks **MTProto as your own user account**, not the Bot API. Bots cannot see your contacts, your dialog list, or your folders — a user session is the only way this tool can exist. Automating your own account for reading/organizing is tolerated by Telegram; spamming is not.
11
+
12
+ ## Key features
13
+
14
+ - **Inventory** — all dialogs with type, unread count, folder memberships, archive state and last-message preview in one call
15
+ - **Read** — recent messages of any chat (the classification input for your agent)
16
+ - **Folders** — list, create, and add chats to folders (additive; a chat may live in several folders)
17
+ - **Archive** — one call to archive/unarchive a chat
18
+ - **Agent-proof** — JSON everywhere, `--dry-run` on writes, FloodWait auto-retry, no interactive prompts unless you ask for them
19
+
20
+ ## Tech stack
21
+
22
+ - **Language**: Python 3.12+
23
+ - **Telegram**: [Telethon 1.45](https://docs.telethon.dev/) (MTProto client library)
24
+ - **Packaging**: [uv](https://docs.astral.sh/uv/) + hatchling
25
+ - **Tests**: pytest (pure-logic tests against fake clients — no network)
26
+ - **Lint**: ruff
27
+ - **CLI**: stdlib `argparse`, zero other runtime deps
28
+
29
+ ## Prerequisites
30
+
31
+ - Python 3.12+ and [uv](https://docs.astral.sh/uv/getting_started/install/)
32
+ - A Telegram account
33
+ - An `api_id` / `api_hash` pair — create once at [my.telegram.org](https://my.telegram.org) → _API development tools_
34
+
35
+ ## Getting started (clone → using)
36
+
37
+ ```bash
38
+ git clone <this-repo> && cd Telegram
39
+
40
+ # 1. install deps into .venv
41
+ make setup
42
+
43
+ # 2. configure credentials
44
+ cp .env.example .env # then edit: TG_API_ID, TG_API_HASH
45
+
46
+ # 3. one-time interactive login (phone + code + optional 2FA)
47
+ make auth
48
+
49
+ # 4. optional: install system-wide so `tg` works from anywhere
50
+ make install
51
+
52
+ # 5. use it
53
+ tg chats
54
+ ```
55
+
56
+ The login in step 3 creates a session file at `~/.config/tg-sort/session` — after that, no more prompts, ever.
57
+
58
+ > [!WARNING]
59
+ > The session file **is** your Telegram account. Anyone who can read it can act as you. Keep it where it is, never commit/copy it (`*.session` is already gitignored).
60
+
61
+ <details>
62
+ <summary>No Makefile? Plain commands</summary>
63
+
64
+ ```bash
65
+ uv sync # deps
66
+ cp .env.example .env # edit credentials
67
+ export $(grep -v '^#' .env | xargs) # or export manually
68
+ uv run python tg.py auth # login
69
+ uv tool install . # system-wide
70
+ ```
71
+
72
+ </details>
73
+
74
+ ## Make targets
75
+
76
+ | Target | What it does |
77
+ | ---------------- | --------------------------------------------- |
78
+ | `make setup` | `uv sync` — create/refresh `.venv` |
79
+ | `make auth` | One-time interactive login |
80
+ | `make install` | Install `tg` system-wide (`uv tool install`) |
81
+ | `make uninstall` | Remove the system-wide binary |
82
+ | `make test` | Run the test suite |
83
+ | `make lint` | ruff checks |
84
+ | `make fmt` | ruff autofix |
85
+ | `make check` | lint + test — the gate every change must pass |
86
+ | `make clean` | Remove caches |
87
+
88
+ The Makefile loads `.env` automatically, so `make auth` picks up your credentials without exporting anything.
89
+
90
+ ## CLI reference
91
+
92
+ Every command prints JSON (stdout, `indent=1`). Errors print `{"error": "..."}` to stderr and exit `1`.
93
+
94
+ ### `tg prime`
95
+
96
+ Prints the embedded agent skill: command reference, safety rules (writes require human confirmation + dry-run first), the sort workflow, and an example session. Feed this to your agent once and it knows how to drive the tool:
97
+
98
+ ```bash
99
+ tg prime > /tmp/tg-skill.md
100
+ ```
101
+
102
+ ### `tg auth`
103
+
104
+ One-time interactive login. Prompts for phone number, login code, and 2FA password if set. Prints the logged-in user. Run this once per machine.
105
+
106
+ ### `tg contacts`
107
+
108
+ ```json
109
+ [
110
+ {
111
+ "id": 123456789,
112
+ "name": "Jane Doe",
113
+ "username": "janedoe",
114
+ "phone": "4915012345678"
115
+ }
116
+ ]
117
+ ```
118
+
119
+ ### `tg chats [--folder NAME] [--limit N]`
120
+
121
+ All dialogs with folder memberships (from `--folder`, filtered client-side). This is the agent's inventory.
122
+
123
+ ```json
124
+ [
125
+ {
126
+ "id": -1001701234567,
127
+ "title": "Solana News",
128
+ "type": "channel",
129
+ "unread": 42,
130
+ "folders": ["Crypto"],
131
+ "archived": false,
132
+ "last_message": {
133
+ "date": "2026-09-17 09:12:00+00:00",
134
+ "text": "Mainnet upgrade shipped —"
135
+ }
136
+ }
137
+ ]
138
+ ```
139
+
140
+ IDs are _marked_ peer IDs (negative for groups/channels) — pass them back verbatim to `read`/`move`/`archive`.
141
+
142
+ ### `tg read CHAT [--last N]` (default 20)
143
+
144
+ Recent messages of a chat, oldest first.
145
+
146
+ ```json
147
+ [
148
+ {
149
+ "id": 4812,
150
+ "date": "2026-09-17 08:59:00+00:00",
151
+ "sender_id": 987654321,
152
+ "text": "deploy blocked on the audit, moving to Friday"
153
+ }
154
+ ]
155
+ ```
156
+
157
+ ### `tg folders`
158
+
159
+ ```json
160
+ [
161
+ {
162
+ "id": 5,
163
+ "title": "Crypto",
164
+ "emoticon": null,
165
+ "chats": [-1001701234567, -1009876543210]
166
+ }
167
+ ]
168
+ ```
169
+
170
+ ### `tg create-folder TITLE [--chat CHAT]`
171
+
172
+ Creates a folder (`TITLE` ≤ 12 chars, max 10 folders — Telegram's limits), optionally seeded with one chat.
173
+
174
+ ### `tg move CHAT --to FOLDER [--create] [--dry-run]`
175
+
176
+ Adds a chat to a folder, **additively** — other folder memberships stay untouched. `FOLDER` is matched case-insensitively by title or by id. `--create` creates the folder if missing. `--dry-run` computes and prints the diff without sending anything.
177
+
178
+ ```json
179
+ {
180
+ "chat": -1001701234567,
181
+ "folder": { "id": 6, "title": "AI News" },
182
+ "action": "added",
183
+ "dry_run": false,
184
+ "include_count": { "before": 3, "after": 4 }
185
+ }
186
+ ```
187
+
188
+ `action` is `"added"`, `"created+added"`, or `"already"` (idempotent, sends nothing).
189
+
190
+ ### `tg archive CHAT [--undo]`
191
+
192
+ Moves a chat to the archive folder (or back with `--undo`).
193
+
194
+ ## The agent sorting loop
195
+
196
+ ```bash
197
+ tg chats # 1. inventory + current folders
198
+ tg read -1001701234567 --last 20 # 2. inspect an unsorted chat
199
+ tg move -1001701234567 --to "AI News" --dry-run # 3. preview
200
+ tg move -1001701234567 --to "AI News" --create # 4. apply (creates folder if needed)
201
+ ```
202
+
203
+ Recommended agent policy: dry-run first, batch many moves per run, sleep briefly between calls to stay far away from rate limits.
204
+
205
+ ## Environment variables
206
+
207
+ | Variable | Required | Default | Purpose |
208
+ | ------------- | -------- | --------------------------- | ----------------------------- |
209
+ | `TG_API_ID` | yes | — | API id from my.telegram.org |
210
+ | `TG_API_HASH` | yes | — | API hash from my.telegram.org |
211
+ | `TG_SESSION` | no | `~/.config/tg-sort/session` | Telethon session file path |
212
+
213
+ ## Architecture
214
+
215
+ ### Project layout
216
+
217
+ ```
218
+ ├── tg.py # everything: CLI, Telethon plumbing, pure helpers
219
+ ├── test_tg.py # pytest suite, FakeClient-based — no network
220
+ ├── tg # dev wrapper: .venv python on tg.py (symlink-free)
221
+ ├── Makefile # setup / auth / install / test / lint targets
222
+ ├── pyproject.toml # uv + hatchling; [project.scripts] tg = "tg:main"
223
+ └── .env.example
224
+ ```
225
+
226
+ ### How folder sorting actually works
227
+
228
+ Telegram folders ("dialog filters") are **saved views, not containers**. A folder is a `DialogFilter` object: rule flags (all groups / all unmuted / …) plus explicit `include_peers` / `exclude_peers` lists. Adding a chat to a folder means:
229
+
230
+ 1. fetch all filters — `messages.GetDialogFiltersRequest`
231
+ 2. locate the target folder (by id or title)
232
+ 3. append the chat's `InputPeer` to `include_peers`
233
+ 4. send the whole filter back — `messages.UpdateDialogFilterRequest(id, filter)`
234
+
235
+ Because folders are views, a chat can be in several at once — that's why `move` is additive by design. Exclusive sorting would mean removing the peer from every other filter's `include_peers` first.
236
+
237
+ ### Key pieces in `tg.py`
238
+
239
+ | Piece | Why it exists |
240
+ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
241
+ | `session()` ctx manager | `async with client` in Telethon calls `start()`, which **prompts interactively** if unauthorized — this would hang an agent. `session()` connects, checks `is_user_authorized()`, and fails fast with a clean JSON error instead. |
242
+ | `call()` wrapper | Catches `FloodWaitError`, sleeps the requested seconds, retries once |
243
+ | `filter_title()` | Folder titles are `TextWithEntities` since Folders 2.0 — helper extracts the plain text |
244
+ | `unwrap_filters()` | Different API layers return either a bare list or a wrapper with `.filters` — handled once here |
245
+ | `find_filter()` | Case-insensitive title match _or_ numeric id match |
246
+ | `move_chat()` | The whole sort primitive: resolve entity → locate folder → idempotency check → append peer → send. Pure enough to test against a fake client. |
247
+
248
+ ### Limits enforced (Telegram's, not ours)
249
+
250
+ - 10 folders max, folder titles ≤ 12 chars
251
+ - Marked IDs: users are positive ints, groups `-…`, channels/supergroups `-100…`
252
+
253
+ ## Testing
254
+
255
+ ```bash
256
+ make test # 15 tests
257
+ uv run pytest -q test_tg.py::test_move_dry_run_no_call # single test
258
+ ```
259
+
260
+ The suite covers the pure helpers (filter lookup, title extraction, id allocation, membership) and the `move_chat` primitive against a `FakeClient` that records requests — no network, no account needed. The Telethon I/O shell is verified by the smoke path: `tg chats` on a real session.
261
+
262
+ ## Troubleshooting
263
+
264
+ | Symptom | Fix |
265
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
266
+ | `{"error": "set TG_API_ID and TG_API_HASH..."}` | `cp .env.example .env`, fill in values; export them in shells that don't go through the Makefile |
267
+ | `{"error": "not logged in - run: ./tg auth"}` | Session missing/expired on this machine — `make auth` |
268
+ | Command hangs forever | You're probably calling `tg auth` from a non-interactive context. `auth` is the only command that prompts; run it once from a terminal |
269
+ | `FloodWaitError` in output | The wrapper retries once automatically; if you still see it, slow the agent loop down (sleep between moves) |
270
+ | `tg: command not found` after `make install` | `~/.local/bin` not on `PATH` — add `export PATH="$HOME/.local/bin:$PATH"` |
271
+ | Updates not picked up after code changes | `make install` runs `uv tool install --upgrade .` — rerun it |
272
+ | Login SMS never arrives | Try login code via Telegram app (option appears after the phone step) |
273
+
274
+ ## Security notes
275
+
276
+ - `.env` and session files are gitignored; the session grants full account access — treat it like a password
277
+ - The tool only ever _reads_ chats and _edits your own folder/archive state_. It never sends messages, so it cannot spam on your behalf
278
+ - Revoking access: Telegram → Settings → Devices → terminate the session (then delete `~/.config/tg-sort/session`)
279
+
280
+ ## Uninstall
281
+
282
+ ```bash
283
+ make uninstall # removes the system-wide binary
284
+ rm -rf ~/.config/tg-sort # session + credentials cache
285
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "tg2llm"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.12"
5
+ dependencies = [
6
+ "telethon>=1.45.0",
7
+ ]
8
+
9
+ [project.scripts]
10
+ tg = "tg:main"
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ only-include = ["tg.py"]
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "pytest>=9.1.1",
22
+ ]
@@ -0,0 +1,160 @@
1
+ import asyncio
2
+
3
+ from telethon import functions, types, utils
4
+
5
+ import tg
6
+
7
+ IPU = types.InputPeerUser
8
+
9
+
10
+ def mkfilter(fid, title, include=()):
11
+ return types.DialogFilter(
12
+ id=fid,
13
+ title=title,
14
+ pinned_peers=[],
15
+ include_peers=list(include),
16
+ exclude_peers=[],
17
+ )
18
+
19
+
20
+ class FakeClient:
21
+ def __init__(self, filters):
22
+ self.filters = filters
23
+ self.calls = []
24
+
25
+ async def get_entity(self, chat_id):
26
+ return types.User(id=42, access_hash=7)
27
+
28
+ async def __call__(self, request):
29
+ self.calls.append(request)
30
+ return self.filters
31
+
32
+
33
+ def run(coro):
34
+ return asyncio.run(coro)
35
+
36
+
37
+ def updates(c):
38
+ return [
39
+ r
40
+ for r in c.calls
41
+ if isinstance(r, functions.messages.UpdateDialogFilterRequest)
42
+ ]
43
+
44
+
45
+ # --- unwrap_filters ---------------------------------------------------------
46
+
47
+
48
+ def test_unwrap_filters_vector():
49
+ fs = [mkfilter(2, types.TextPlain("a")), types.DialogFilterDefault()]
50
+ assert tg.unwrap_filters(fs) == fs
51
+
52
+
53
+ def test_unwrap_filters_wrapper():
54
+ class W:
55
+ filters = ["x"]
56
+
57
+ assert tg.unwrap_filters(W()) == ["x"]
58
+
59
+
60
+ # --- titles / lookup --------------------------------------------------------
61
+
62
+
63
+ def test_filter_title_text_with_entities():
64
+ assert tg.filter_title(mkfilter(2, types.TextPlain("News"))) == "News"
65
+
66
+
67
+ def test_filter_title_plain_str_fallback():
68
+ assert tg.filter_title(mkfilter(2, "Raw")) == "Raw"
69
+
70
+
71
+ def test_find_filter_by_name_case_insensitive():
72
+ fs = [types.DialogFilterDefault(), mkfilter(5, types.TextPlain("News"))]
73
+ assert tg.find_filter(fs, "news").id == 5 # type: ignore[union-attr]
74
+
75
+
76
+ def test_find_filter_by_id_string():
77
+ fs = [mkfilter(5, types.TextPlain("News"))]
78
+ assert tg.find_filter(fs, "5").id == 5 # type: ignore[union-attr]
79
+
80
+
81
+ def test_find_filter_missing_returns_none():
82
+ assert tg.find_filter([mkfilter(5, types.TextPlain("News"))], "work") is None
83
+
84
+
85
+ def test_next_filter_id():
86
+ assert tg.next_filter_id([]) == 2
87
+ assert tg.next_filter_id([mkfilter(2, "a"), mkfilter(5, "b")]) == 6
88
+
89
+
90
+ # --- membership / move ------------------------------------------------------
91
+
92
+
93
+ def test_folder_membership_map():
94
+ fs = [mkfilter(5, types.TextPlain("News"), include=[IPU(user_id=1, access_hash=0)])]
95
+ mem = tg.folder_membership(fs)
96
+ assert mem[1] == ["News"]
97
+
98
+
99
+ def test_move_adds_peer_additively():
100
+ f = mkfilter(5, types.TextPlain("News"), include=[IPU(user_id=1, access_hash=0)])
101
+ c = FakeClient([f])
102
+ res = run(tg.move_chat(c, 42, "news"))
103
+ assert res["action"] == "added"
104
+ assert len(updates(c)) == 1
105
+ assert utils.get_peer_id(f.include_peers[1]) == 42
106
+ assert tg.filter_title(f) == "News" # untouched
107
+
108
+
109
+ def test_move_already_member_no_call():
110
+ f = mkfilter(5, types.TextPlain("News"), include=[IPU(user_id=42, access_hash=7)])
111
+ c = FakeClient([f])
112
+ res = run(tg.move_chat(c, 42, "news"))
113
+ assert res["action"] == "already"
114
+ assert updates(c) == []
115
+
116
+
117
+ def test_move_dry_run_no_call():
118
+ f = mkfilter(5, types.TextPlain("News"))
119
+ c = FakeClient([f])
120
+ res = run(tg.move_chat(c, 42, "news", dry=True))
121
+ assert res["action"] == "added" and res["dry_run"] is True
122
+ assert updates(c) == []
123
+
124
+
125
+ def test_move_creates_folder_with_flag():
126
+ c = FakeClient([])
127
+ res = run(tg.move_chat(c, 42, "work", create=True))
128
+ assert res["action"] == "created+added"
129
+ assert len(updates(c)) == 1
130
+ sent = updates(c)[0]
131
+ assert sent.id == 2 and len(sent.filter.include_peers) == 1 # type: ignore[union-attr]
132
+
133
+
134
+ def test_move_missing_folder_raises():
135
+ c = FakeClient([])
136
+ try:
137
+ run(tg.move_chat(c, 42, "work"))
138
+ raise AssertionError("expected CliError")
139
+ except tg.CliError:
140
+ pass
141
+
142
+
143
+ def test_new_folder_title_limit():
144
+ try:
145
+ tg.new_filter(3, "x" * 13)
146
+ raise AssertionError("expected CliError")
147
+ except tg.CliError:
148
+ pass
149
+
150
+
151
+ # --- prime skill ------------------------------------------------------------
152
+
153
+
154
+ def test_prime_skill_safety_rules_present():
155
+ low = tg.PRIME.lower()
156
+ assert "never send a write command without human confirmation" in low
157
+ assert "dry-run" in low and "explicit ok" in low
158
+ for cmd in ("chats", "read", "contacts", "folders", "create-folder", "move", "archive"):
159
+ assert cmd in tg.PRIME
160
+ assert tg.PRIME.startswith("# tg")
tg2llm-0.1.0/tg ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+ DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
3
+ exec "$DIR/.venv/bin/python" "$DIR/tg.py" "$@"
tg2llm-0.1.0/tg.py ADDED
@@ -0,0 +1,423 @@
1
+ #!/usr/bin/env python3
2
+ """tg - Telegram user-account CLI for agents. JSON out.
3
+
4
+ Env: TG_API_ID, TG_API_HASH (https://my.telegram.org), TG_SESSION (optional).
5
+ """
6
+ import argparse
7
+ import asyncio
8
+ import contextlib
9
+ import json
10
+ import os
11
+ import sys
12
+
13
+ from telethon import TelegramClient, functions, types, utils
14
+ from telethon.errors import FloodWaitError
15
+
16
+ ARCHIVE_ID = 1
17
+ MAX_FOLDERS = 10
18
+ MAX_TITLE = 12
19
+ API_ID = int(os.environ.get("TG_API_ID", "0") or 0)
20
+ API_HASH = os.environ.get("TG_API_HASH", "")
21
+ SESSION = os.environ.get(
22
+ "TG_SESSION", os.path.expanduser("~/.config/tg-sort/session")
23
+ )
24
+
25
+ PRIME = """\
26
+ # tg — Telegram folder-sorting skill
27
+
28
+ CLI over a Telegram USER account (MTProto). Reads chats/contacts, sorts chats
29
+ into folders. JSON on stdout; errors {"error": ...} on stderr, exit 1.
30
+
31
+ ## Safety rules (MANDATORY)
32
+
33
+ - Write commands are: create-folder, move (without --dry-run), archive.
34
+ - NEVER send a write command without human confirmation.
35
+ - Before every write: show the human the exact plan (chat titles -> target
36
+ folders), run the intent with --dry-run, present the result, wait for an
37
+ explicit OK. No OK = no write.
38
+ - Read commands (chats, contacts, read, folders, prime) change nothing. Use
39
+ them freely.
40
+
41
+ ## Commands
42
+
43
+ tg chats [--folder F] [--limit N] all dialogs: id, title, type, unread,
44
+ folders, archived, last_message
45
+ tg read CHAT [--last N] N most recent messages, oldest first
46
+ tg contacts contact list
47
+ tg folders folders with member chat ids
48
+ tg create-folder TITLE [--chat C] new folder (TITLE <= 12 chars, max 10)
49
+ tg move CHAT --to F [--create] add CHAT to folder F, ADDITIVE
50
+ [--dry-run: plan only, sends nothing]
51
+ tg archive CHAT [--undo] archive / unarchive a chat
52
+
53
+ Rules:
54
+ - CHAT is a marked id from `tg chats` (users >0, groups <0, channels -100...)
55
+ or @username. Pass ids back verbatim.
56
+ - F matches a folder by title (case-insensitive) or id. --create makes a
57
+ missing folder; folder names max 12 chars.
58
+ - move is additive: a chat may live in several folders. "already" = no-op.
59
+ - Throttle: sleep between writes, honor FloodWait.
60
+
61
+ ## Sort workflow
62
+
63
+ 1. Inventory: tg chats
64
+ 2. Classify: pick chats with empty or wrong folders, inspect content:
65
+ tg read <chat_id> --last 20
66
+ 3. Plan: check existing folders (tg folders), assign each chat a folder
67
+ 4. CONFIRM: show the human the plan + dry-run each move:
68
+ tg move <chat_id> --to "<folder>" --dry-run
69
+ 5. Apply only after explicit human approval:
70
+ tg move <chat_id> --to "<folder>"
71
+ 6. Optionally archive noise channels (same confirm rule):
72
+ tg archive <chat_id>
73
+
74
+ ## Example session
75
+
76
+ $ tg chats
77
+ [
78
+ { "id": -1001701234567, "title": "Solana News", "type": "channel",
79
+ "unread": 42, "folders": [], "archived": false,
80
+ "last_message": { "date": "...", "text": "Mainnet upgrade shipped" } }
81
+ ]
82
+
83
+ $ tg read -1001701234567 --last 3
84
+ [
85
+ { "id": 4812, "date": "...", "sender_id": 987654321,
86
+ "text": "SOL validators: upgrade at epoch 520" }
87
+ ]
88
+
89
+ # -> propose to human: "Solana News" -> folder "Crypto" ; wait for OK
90
+
91
+ $ tg move -1001701234567 --to Crypto --dry-run
92
+ {
93
+ "chat": -1001701234567,
94
+ "folder": { "id": 5, "title": "Crypto" },
95
+ "action": "added",
96
+ "dry_run": true,
97
+ "include_count": { "before": 3, "after": 4 }
98
+ }
99
+
100
+ # human confirms -> apply
101
+ $ tg move -1001701234567 --to Crypto
102
+ """
103
+
104
+
105
+ class CliError(Exception):
106
+ pass
107
+
108
+
109
+ def out(obj):
110
+ print(json.dumps(obj, default=str, ensure_ascii=False, indent=1))
111
+
112
+
113
+ async def call(client, request):
114
+ try:
115
+ return await client(request)
116
+ except FloodWaitError as e:
117
+ await asyncio.sleep(e.seconds + 1)
118
+ return await client(request)
119
+
120
+
121
+ def unwrap_filters(result):
122
+ fs = getattr(result, "filters", None)
123
+ return list(fs) if fs is not None else list(result or [])
124
+
125
+
126
+ def custom_filters(filters):
127
+ return [f for f in filters if isinstance(f, types.DialogFilter)]
128
+
129
+
130
+ def filter_title(f):
131
+ t = getattr(f, "title", "")
132
+ return getattr(t, "text", t)
133
+
134
+
135
+ def find_filter(filters, name_or_id):
136
+ s = str(name_or_id).strip()
137
+ for f in custom_filters(filters):
138
+ if str(f.id) == s or filter_title(f).lower() == s.lower():
139
+ return f
140
+ return None
141
+
142
+
143
+ def next_filter_id(filters):
144
+ ids = [f.id for f in custom_filters(filters)]
145
+ return max(ids, default=1) + 1
146
+
147
+
148
+ def new_filter(fid, title):
149
+ if len(title) > MAX_TITLE:
150
+ raise CliError(f"folder title >{MAX_TITLE} chars: {title!r}")
151
+ return types.DialogFilter(
152
+ id=fid,
153
+ title=types.TextPlain(title),
154
+ pinned_peers=[],
155
+ include_peers=[],
156
+ exclude_peers=[],
157
+ )
158
+
159
+
160
+ def folder_membership(filters):
161
+ mem = {}
162
+ for f in custom_filters(filters):
163
+ for p in f.include_peers or []:
164
+ mem.setdefault(utils.get_peer_id(p), []).append(filter_title(f))
165
+ return mem
166
+
167
+
168
+ def chat_row(d, mem):
169
+ m = d.message
170
+ return {
171
+ "id": d.id,
172
+ "title": d.name,
173
+ "type": "user" if d.is_user else "group" if d.is_group else "channel",
174
+ "unread": d.unread_count,
175
+ "folders": mem.get(d.id, []),
176
+ "archived": bool(getattr(d, "archived", False)),
177
+ "last_message": {
178
+ "date": m.date,
179
+ "text": (getattr(m, "raw_text", "") or "")[:80],
180
+ }
181
+ if m
182
+ else None,
183
+ }
184
+
185
+
186
+ def parse_chat(x):
187
+ x = str(x)
188
+ return int(x) if x.lstrip("-").isdigit() else x
189
+
190
+
191
+ def make_client():
192
+ if not API_ID or not API_HASH:
193
+ raise CliError("set TG_API_ID and TG_API_HASH (from my.telegram.org)")
194
+ os.makedirs(os.path.dirname(SESSION), exist_ok=True)
195
+ return TelegramClient(SESSION, API_ID, API_HASH)
196
+
197
+
198
+ async def must_auth(c):
199
+ if not await c.is_user_authorized():
200
+ raise CliError("not logged in - run: ./tg auth")
201
+
202
+
203
+ @contextlib.asynccontextmanager
204
+ async def session():
205
+ """Connect without start() - no interactive prompt hang for agents."""
206
+ c = make_client()
207
+ await c.connect()
208
+ try:
209
+ await must_auth(c)
210
+ yield c
211
+ finally:
212
+ await c.disconnect()
213
+
214
+
215
+ async def fetch_filters(c):
216
+ return unwrap_filters(await call(c, functions.messages.GetDialogFiltersRequest()))
217
+
218
+
219
+ async def move_chat(c, chat_id, target, create=False, dry=False):
220
+ ent = await c.get_entity(parse_chat(chat_id))
221
+ ip = utils.get_input_peer(ent)
222
+ marked = utils.get_peer_id(ip)
223
+ filters = await fetch_filters(c)
224
+ f = find_filter(filters, target)
225
+ created = False
226
+ if f is None:
227
+ if not create:
228
+ raise CliError(f"no folder {target!r} - use --create")
229
+ if len(custom_filters(filters)) >= MAX_FOLDERS:
230
+ raise CliError(f"folder limit reached ({MAX_FOLDERS})")
231
+ f = new_filter(next_filter_id(filters), str(target))
232
+ created = True
233
+ if marked in {utils.get_peer_id(p) for p in f.include_peers or []}:
234
+ return {"chat": marked, "folder": filter_title(f), "action": "already"}
235
+ before = len(f.include_peers or [])
236
+ f.include_peers = list(f.include_peers or []) + [ip]
237
+ if not dry:
238
+ await call(c, functions.messages.UpdateDialogFilterRequest(id=f.id, filter=f))
239
+ return {
240
+ "chat": marked,
241
+ "folder": {"id": f.id, "title": filter_title(f)},
242
+ "action": ("created+" if created else "") + "added",
243
+ "dry_run": dry,
244
+ "include_count": {"before": before, "after": len(f.include_peers)},
245
+ }
246
+
247
+
248
+ # --- command handlers -------------------------------------------------------
249
+
250
+
251
+ async def cmd_auth(_):
252
+ c = make_client()
253
+ await c.start()
254
+ me = await c.get_me()
255
+ out({"user": me.id, "name": (f"{me.first_name or ''} {me.last_name or ''}").strip()})
256
+ await c.disconnect()
257
+
258
+
259
+ async def cmd_contacts(_):
260
+ async with session() as c:
261
+ await must_auth(c)
262
+ res = await call(c, functions.contacts.GetContactsRequest(hash=0))
263
+ out(
264
+ [
265
+ {
266
+ "id": u.id,
267
+ "name": " ".join(filter(None, [u.first_name, u.last_name])),
268
+ "username": u.username,
269
+ "phone": u.phone,
270
+ }
271
+ for u in getattr(res, "users", [])
272
+ ]
273
+ )
274
+
275
+
276
+ async def cmd_chats(args):
277
+ async with session() as c:
278
+ await must_auth(c)
279
+ mem = folder_membership(await fetch_filters(c))
280
+ rows = []
281
+ async for d in c.iter_dialogs(limit=args.limit):
282
+ rows.append(chat_row(d, mem))
283
+ if args.folder:
284
+ rows = [r for r in rows if args.folder in r["folders"]]
285
+ out(rows)
286
+
287
+
288
+ async def cmd_read(args):
289
+ async with session() as c:
290
+ await must_auth(c)
291
+ msgs = await c.get_messages(parse_chat(args.chat), limit=args.last)
292
+ out(
293
+ [
294
+ {
295
+ "id": m.id,
296
+ "date": m.date,
297
+ "sender_id": m.sender_id,
298
+ "text": (m.raw_text or "")[:200],
299
+ }
300
+ for m in reversed(msgs)
301
+ ]
302
+ )
303
+
304
+
305
+ async def cmd_folders(_):
306
+ async with session() as c:
307
+ await must_auth(c)
308
+ out(
309
+ [
310
+ {
311
+ "id": f.id,
312
+ "title": filter_title(f),
313
+ "emoticon": f.emoticon,
314
+ "chats": [utils.get_peer_id(p) for p in f.include_peers or []],
315
+ }
316
+ for f in custom_filters(await fetch_filters(c))
317
+ ]
318
+ )
319
+
320
+
321
+ async def cmd_create_folder(args):
322
+ async with session() as c:
323
+ await must_auth(c)
324
+ filters = await fetch_filters(c)
325
+ if len(custom_filters(filters)) >= MAX_FOLDERS:
326
+ raise CliError(f"folder limit reached ({MAX_FOLDERS})")
327
+ f = new_filter(next_filter_id(filters), args.title)
328
+ if args.chat:
329
+ ip = utils.get_input_peer(
330
+ await c.get_entity(parse_chat(args.chat))
331
+ )
332
+ f.include_peers = [ip]
333
+ await call(c, functions.messages.UpdateDialogFilterRequest(id=f.id, filter=f))
334
+ out({"created": {"id": f.id, "title": filter_title(f)}})
335
+
336
+
337
+ async def cmd_move(args):
338
+ async with session() as c:
339
+ await must_auth(c)
340
+ out(
341
+ await move_chat(
342
+ c, args.chat, args.to, create=args.create, dry=args.dry_run
343
+ )
344
+ )
345
+
346
+
347
+ async def cmd_prime(_):
348
+ print(PRIME)
349
+
350
+
351
+ async def cmd_archive(args):
352
+ async with session() as c:
353
+ await must_auth(c)
354
+ ip = utils.get_input_peer(await c.get_entity(parse_chat(args.chat)))
355
+ fid = 0 if args.undo else ARCHIVE_ID
356
+ await call(
357
+ c,
358
+ functions.folders.EditPeerFoldersRequest(
359
+ folder_peers=[types.InputFolderPeer(peer=ip, folder_id=fid)]
360
+ ),
361
+ )
362
+ out({"chat": utils.get_peer_id(ip), "folder_id": fid})
363
+
364
+
365
+ # --- CLI --------------------------------------------------------------------
366
+
367
+
368
+ def main(argv=None):
369
+ p = argparse.ArgumentParser(prog="tg", description=__doc__)
370
+ sub = p.add_subparsers(dest="cmd", required=True)
371
+
372
+ sub.add_parser("auth", help="one-time interactive login")
373
+
374
+ sub.add_parser("prime", help="print the agent skill (how to use this CLI)")
375
+
376
+ sub.add_parser("contacts", help="list contacts")
377
+
378
+ sp = sub.add_parser("chats", help="list dialogs + folder memberships")
379
+ sp.add_argument("--folder", help="only chats in this folder (title)")
380
+ sp.add_argument("--limit", type=int, default=None)
381
+
382
+ sp = sub.add_parser("read", help="recent messages of a chat")
383
+ sp.add_argument("chat", help="chat id or @username")
384
+ sp.add_argument("--last", type=int, default=20)
385
+
386
+ sub.add_parser("folders", help="list folders")
387
+
388
+ sp = sub.add_parser("create-folder", help="create a folder")
389
+ sp.add_argument("title", help=f"<={MAX_TITLE} chars")
390
+ sp.add_argument("--chat", help="seed member (id or @username)")
391
+
392
+ sp = sub.add_parser("move", help="add chat to folder (additive)")
393
+ sp.add_argument("chat")
394
+ sp.add_argument("--to", required=True, help="folder title or id")
395
+ sp.add_argument("--create", action="store_true", help="create folder if missing")
396
+ sp.add_argument("--dry-run", action="store_true")
397
+
398
+ sp = sub.add_parser("archive", help="archive a chat")
399
+ sp.add_argument("chat")
400
+ sp.add_argument("--undo", action="store_true", help="unarchive")
401
+
402
+ args = p.parse_args(argv)
403
+ handler = {
404
+ "auth": cmd_auth,
405
+ "prime": cmd_prime,
406
+ "contacts": cmd_contacts,
407
+ "chats": cmd_chats,
408
+ "read": cmd_read,
409
+ "folders": cmd_folders,
410
+ "create-folder": cmd_create_folder,
411
+ "move": cmd_move,
412
+ "archive": cmd_archive,
413
+ }[args.cmd]
414
+ try:
415
+ asyncio.run(handler(args))
416
+ except CliError as e:
417
+ print(json.dumps({"error": str(e)}), file=sys.stderr)
418
+ return 1
419
+ return 0
420
+
421
+
422
+ if __name__ == "__main__":
423
+ sys.exit(main())
tg2llm-0.1.0/uv.lock ADDED
@@ -0,0 +1,123 @@
1
+ version = 1
2
+ revision = 1
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "iniconfig"
16
+ version = "2.3.0"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
21
+ ]
22
+
23
+ [[package]]
24
+ name = "packaging"
25
+ version = "26.3"
26
+ source = { registry = "https://pypi.org/simple" }
27
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412 }
28
+ wheels = [
29
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
30
+ ]
31
+
32
+ [[package]]
33
+ name = "pluggy"
34
+ version = "1.6.0"
35
+ source = { registry = "https://pypi.org/simple" }
36
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
37
+ wheels = [
38
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
39
+ ]
40
+
41
+ [[package]]
42
+ name = "pyaes"
43
+ version = "1.6.1"
44
+ source = { registry = "https://pypi.org/simple" }
45
+ sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536 }
46
+
47
+ [[package]]
48
+ name = "pyasn1"
49
+ version = "0.6.4"
50
+ source = { registry = "https://pypi.org/simple" }
51
+ sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262 }
52
+ wheels = [
53
+ { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410 },
54
+ ]
55
+
56
+ [[package]]
57
+ name = "pygments"
58
+ version = "2.21.0"
59
+ source = { registry = "https://pypi.org/simple" }
60
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329 }
61
+ wheels = [
62
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 },
63
+ ]
64
+
65
+ [[package]]
66
+ name = "pytest"
67
+ version = "9.1.1"
68
+ source = { registry = "https://pypi.org/simple" }
69
+ dependencies = [
70
+ { name = "colorama", marker = "sys_platform == 'win32'" },
71
+ { name = "iniconfig" },
72
+ { name = "packaging" },
73
+ { name = "pluggy" },
74
+ { name = "pygments" },
75
+ ]
76
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 }
77
+ wheels = [
78
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 },
79
+ ]
80
+
81
+ [[package]]
82
+ name = "rsa"
83
+ version = "4.9.1"
84
+ source = { registry = "https://pypi.org/simple" }
85
+ dependencies = [
86
+ { name = "pyasn1" },
87
+ ]
88
+ sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 }
89
+ wheels = [
90
+ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 },
91
+ ]
92
+
93
+ [[package]]
94
+ name = "telethon"
95
+ version = "1.45.0"
96
+ source = { registry = "https://pypi.org/simple" }
97
+ dependencies = [
98
+ { name = "pyaes" },
99
+ { name = "rsa" },
100
+ ]
101
+ sdist = { url = "https://files.pythonhosted.org/packages/d5/07/b23b6cc73cd994763fbd641d196904f56cc966035eda841275b5689c6e5f/telethon-1.45.0.tar.gz", hash = "sha256:4cb7ae269b8ab9dad9fc490b859f0bc2f885121e990f5b20407b1faa0db2a1d9", size = 728057 }
102
+ wheels = [
103
+ { url = "https://files.pythonhosted.org/packages/92/ac/241c09e6905215f225d8088438ceb1f32cc4b4865f3f49068cb402debfd7/telethon-1.45.0-py3-none-any.whl", hash = "sha256:339e24c83aedc9f12cbe389264627edc49779e4289c535aa90aafc4ce62f7972", size = 804098 },
104
+ ]
105
+
106
+ [[package]]
107
+ name = "tg2llm"
108
+ version = "0.1.0"
109
+ source = { editable = "." }
110
+ dependencies = [
111
+ { name = "telethon" },
112
+ ]
113
+
114
+ [package.dev-dependencies]
115
+ dev = [
116
+ { name = "pytest" },
117
+ ]
118
+
119
+ [package.metadata]
120
+ requires-dist = [{ name = "telethon", specifier = ">=1.45.0" }]
121
+
122
+ [package.metadata.requires-dev]
123
+ dev = [{ name = "pytest", specifier = ">=9.1.1" }]