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.
- tg2llm-0.1.0/.env.example +5 -0
- tg2llm-0.1.0/.gitignore +223 -0
- tg2llm-0.1.0/AGENTS.md +73 -0
- tg2llm-0.1.0/Makefile +35 -0
- tg2llm-0.1.0/PKG-INFO +5 -0
- tg2llm-0.1.0/README.md +285 -0
- tg2llm-0.1.0/pyproject.toml +22 -0
- tg2llm-0.1.0/test_tg.py +160 -0
- tg2llm-0.1.0/tg +3 -0
- tg2llm-0.1.0/tg.py +423 -0
- tg2llm-0.1.0/uv.lock +123 -0
tg2llm-0.1.0/.gitignore
ADDED
|
@@ -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
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
|
+
]
|
tg2llm-0.1.0/test_tg.py
ADDED
|
@@ -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
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" }]
|