tg-bulk-leave 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.
- tg_bulk_leave-0.1.0/.env.example +9 -0
- tg_bulk_leave-0.1.0/.gitattributes +2 -0
- tg_bulk_leave-0.1.0/.github/workflows/ci.yml +75 -0
- tg_bulk_leave-0.1.0/.github/workflows/release.yml +58 -0
- tg_bulk_leave-0.1.0/.gitignore +61 -0
- tg_bulk_leave-0.1.0/CHANGELOG.md +40 -0
- tg_bulk_leave-0.1.0/CONTRIBUTING.md +115 -0
- tg_bulk_leave-0.1.0/LICENSE +21 -0
- tg_bulk_leave-0.1.0/PKG-INFO +251 -0
- tg_bulk_leave-0.1.0/README.md +223 -0
- tg_bulk_leave-0.1.0/SECURITY.md +86 -0
- tg_bulk_leave-0.1.0/config.example.toml +19 -0
- tg_bulk_leave-0.1.0/pyproject.toml +74 -0
- tg_bulk_leave-0.1.0/src/tg_bulk_leave/__init__.py +3 -0
- tg_bulk_leave-0.1.0/src/tg_bulk_leave/__main__.py +6 -0
- tg_bulk_leave-0.1.0/src/tg_bulk_leave/cli.py +548 -0
- tg_bulk_leave-0.1.0/tests/test_tg_bulk_leave.py +908 -0
- tg_bulk_leave-0.1.0/uv.lock +360 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copy to .env and fill in. .env is gitignored — never commit real values.
|
|
2
|
+
# Get credentials at https://my.telegram.org -> API development tools.
|
|
3
|
+
TG_API_ID=1234567
|
|
4
|
+
TG_API_HASH=your_api_hash_here
|
|
5
|
+
|
|
6
|
+
# Optional: where the Telethon session is stored. Defaults to
|
|
7
|
+
# ~/.config/tg_cleanup/tg_cleanup. This file grants full account access —
|
|
8
|
+
# keep it outside the repo.
|
|
9
|
+
# TG_SESSION=/Users/you/.config/tg_cleanup/tg_cleanup
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ci-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
test:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v7.0.1
|
|
26
|
+
|
|
27
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
28
|
+
with:
|
|
29
|
+
python-version: ${{ matrix.python-version }}
|
|
30
|
+
enable-cache: true
|
|
31
|
+
cache-dependency-glob: uv.lock
|
|
32
|
+
|
|
33
|
+
# --locked fails if uv.lock is out of date with pyproject.toml, so a
|
|
34
|
+
# dependency bump can never land unlocked.
|
|
35
|
+
- name: Install
|
|
36
|
+
run: uv sync --locked --dev
|
|
37
|
+
|
|
38
|
+
# The 80% coverage gate lives in [tool.coverage.report] and fails here.
|
|
39
|
+
- name: Test
|
|
40
|
+
run: uv run pytest
|
|
41
|
+
|
|
42
|
+
lint:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v7.0.1
|
|
46
|
+
|
|
47
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
48
|
+
with:
|
|
49
|
+
enable-cache: true
|
|
50
|
+
cache-dependency-glob: uv.lock
|
|
51
|
+
|
|
52
|
+
- name: Install
|
|
53
|
+
run: uv sync --locked --dev
|
|
54
|
+
|
|
55
|
+
# Lint only. There is deliberately no `ruff format` gate: several lines in
|
|
56
|
+
# cli.py are hand-wrapped so the rationale for a safety decision reads well.
|
|
57
|
+
- name: Ruff
|
|
58
|
+
run: uv run ruff check
|
|
59
|
+
|
|
60
|
+
build:
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/checkout@v7.0.1
|
|
64
|
+
|
|
65
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
66
|
+
|
|
67
|
+
- name: Build wheel and sdist
|
|
68
|
+
run: uv build
|
|
69
|
+
|
|
70
|
+
# A broken entry point should fail here, not on a user's first install.
|
|
71
|
+
- name: Smoke-test the built wheel
|
|
72
|
+
run: |
|
|
73
|
+
uv venv /tmp/smoke
|
|
74
|
+
uv pip install --python /tmp/smoke/bin/python dist/*.whl
|
|
75
|
+
/tmp/smoke/bin/tg-bulk-leave --help
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v7.0.1
|
|
15
|
+
|
|
16
|
+
- uses: astral-sh/setup-uv@v9.0.0
|
|
17
|
+
|
|
18
|
+
- name: Build wheel and sdist
|
|
19
|
+
run: uv build
|
|
20
|
+
|
|
21
|
+
- uses: actions/upload-artifact@v7.0.1
|
|
22
|
+
with:
|
|
23
|
+
name: dist
|
|
24
|
+
path: dist/
|
|
25
|
+
|
|
26
|
+
publish:
|
|
27
|
+
needs: build
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
# Trusted Publishing: PyPI verifies this workflow's OIDC identity, so no
|
|
30
|
+
# API token is stored in the repository or anywhere else.
|
|
31
|
+
environment: pypi
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write
|
|
34
|
+
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v7.0.0
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.1
|
|
42
|
+
|
|
43
|
+
github-release:
|
|
44
|
+
needs: publish
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
permissions:
|
|
47
|
+
contents: write
|
|
48
|
+
|
|
49
|
+
steps:
|
|
50
|
+
- uses: actions/download-artifact@v7.0.0
|
|
51
|
+
with:
|
|
52
|
+
name: dist
|
|
53
|
+
path: dist/
|
|
54
|
+
|
|
55
|
+
- uses: softprops/action-gh-release@v3.0.2
|
|
56
|
+
with:
|
|
57
|
+
files: dist/*
|
|
58
|
+
generate_release_notes: true
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# --- Secrets and account data -------------------------------------------
|
|
2
|
+
# The Telethon session is a live credential: full account access, no login code.
|
|
3
|
+
*.session
|
|
4
|
+
*.session-journal
|
|
5
|
+
|
|
6
|
+
# Credentials
|
|
7
|
+
.env
|
|
8
|
+
|
|
9
|
+
# Run reports enumerate every chat the account belongs to
|
|
10
|
+
reports/
|
|
11
|
+
telegram_matches_*.csv
|
|
12
|
+
telegram_left_*.csv
|
|
13
|
+
|
|
14
|
+
# --- Local-only working documents ---------------------------------------
|
|
15
|
+
# Strategy research and the phased roadmap stay out of the public repo.
|
|
16
|
+
PLAN.md
|
|
17
|
+
strategy.md
|
|
18
|
+
|
|
19
|
+
# Agent instructions (both spellings; the filesystem may be case-sensitive)
|
|
20
|
+
CLAUDE.md
|
|
21
|
+
claude.md
|
|
22
|
+
CLAUDE.local.md
|
|
23
|
+
|
|
24
|
+
# Analysis and audit output
|
|
25
|
+
.reports/
|
|
26
|
+
|
|
27
|
+
# --- Python --------------------------------------------------------------
|
|
28
|
+
__pycache__/
|
|
29
|
+
*.py[cod]
|
|
30
|
+
*$py.class
|
|
31
|
+
*.so
|
|
32
|
+
|
|
33
|
+
# Packaging / build
|
|
34
|
+
build/
|
|
35
|
+
dist/
|
|
36
|
+
sdist/
|
|
37
|
+
wheels/
|
|
38
|
+
*.egg-info/
|
|
39
|
+
*.egg
|
|
40
|
+
.eggs/
|
|
41
|
+
MANIFEST
|
|
42
|
+
|
|
43
|
+
# Environments
|
|
44
|
+
.venv/
|
|
45
|
+
venv/
|
|
46
|
+
env/
|
|
47
|
+
ENV/
|
|
48
|
+
|
|
49
|
+
# Test, coverage, and tool caches
|
|
50
|
+
.pytest_cache/
|
|
51
|
+
.ruff_cache/
|
|
52
|
+
.mypy_cache/
|
|
53
|
+
.tox/
|
|
54
|
+
.nox/
|
|
55
|
+
.cache
|
|
56
|
+
.coverage
|
|
57
|
+
.coverage.*
|
|
58
|
+
coverage.xml
|
|
59
|
+
htmlcov/
|
|
60
|
+
*.cover
|
|
61
|
+
.hypothesis/
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-07-27
|
|
8
|
+
|
|
9
|
+
First packaged release. Previously a personal single-file script.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Installable package `tg-bulk-leave` with a console entry point
|
|
14
|
+
(`src/` layout, hatchling build; install with `uv tool install`, `pipx`,
|
|
15
|
+
or `pip`). `python -m tg_bulk_leave` also works.
|
|
16
|
+
- Keywords and the protected list moved out of the source into a TOML config
|
|
17
|
+
file (`config.toml` in the platform config directory, see
|
|
18
|
+
`config.example.toml`), plus repeatable `--keyword`/`--protected` flags and
|
|
19
|
+
`--config PATH`. `--keyword` replaces the config's keywords for that run;
|
|
20
|
+
`--protected` only ever adds protection.
|
|
21
|
+
- Actionable first-run errors: a missing config with no `--keyword` explains
|
|
22
|
+
exactly what to create and where; a typoed config key is rejected instead of
|
|
23
|
+
silently matching nothing.
|
|
24
|
+
- MIT license.
|
|
25
|
+
- Project infrastructure for a public release: GitHub Actions CI (pytest on
|
|
26
|
+
Python 3.11–3.14, `ruff check`, and a build smoke test of the wheel's entry
|
|
27
|
+
point), a tag-triggered release workflow publishing to PyPI via Trusted
|
|
28
|
+
Publishing, `SECURITY.md` documenting the session-file threat model, and
|
|
29
|
+
`CONTRIBUTING.md` recording the safety invariants a change must not break.
|
|
30
|
+
- Dependencies locked in `uv.lock`; the dev toolchain moved from
|
|
31
|
+
`requirements.txt` into a PEP 735 `dev` dependency group (`uv sync --dev`).
|
|
32
|
+
|
|
33
|
+
### Unchanged (deliberately)
|
|
34
|
+
|
|
35
|
+
- Dry run is still the default; `--execute` still requires typing `LEAVE`.
|
|
36
|
+
- Session storage location (`~/.config/tg_cleanup/`, `TG_SESSION` override)
|
|
37
|
+
and its owner-only permission handling.
|
|
38
|
+
- Rate-limit pacing (2.5–5.0s between leaves), the FloodWait abort cap, CSV
|
|
39
|
+
escaping, and per-iteration log flushing. These safety floors are not
|
|
40
|
+
configurable, by design.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Bug reports, fixes, and small features are welcome. Before a large change,
|
|
4
|
+
open an issue — this tool leaves groups irreversibly, so scope creep has real
|
|
5
|
+
consequences.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
git clone https://github.com/soos3d/tg-bulk-leave && cd tg-bulk-leave
|
|
11
|
+
uv sync --dev
|
|
12
|
+
|
|
13
|
+
uv run pytest # coverage gate at 80%; currently ~98%
|
|
14
|
+
uv run ruff check
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
CI runs the suite on Python 3.11–3.14 plus `ruff check` and a build smoke test.
|
|
18
|
+
There is deliberately **no `ruff format` gate** — several lines in `cli.py` are
|
|
19
|
+
hand-wrapped so the rationale for a safety decision reads well. Don't reformat
|
|
20
|
+
files you aren't otherwise changing.
|
|
21
|
+
|
|
22
|
+
`uv.lock` is committed and CI installs with `--locked`, so a dependency change
|
|
23
|
+
must include the regenerated lock.
|
|
24
|
+
|
|
25
|
+
## Tests come first
|
|
26
|
+
|
|
27
|
+
Write the failing test, then the fix. `tests/test_tg_bulk_leave.py` covers the
|
|
28
|
+
logic that decides *what gets left*, and it does so with **real Telethon entity
|
|
29
|
+
types** rather than stubs — a `Chat`, a `ChannelForbidden`, an actual gigagroup
|
|
30
|
+
`Channel` — so classification bugs surface instead of being mocked away. Keep
|
|
31
|
+
it that way. The destructive path uses a `FakeClient`; no test touches the
|
|
32
|
+
network.
|
|
33
|
+
|
|
34
|
+
Several tests are regression tests for defects that shipped: the gigagroup
|
|
35
|
+
dispatch, the confirmation gate, the FloodWait retry cases. Don't weaken them
|
|
36
|
+
to make a change pass.
|
|
37
|
+
|
|
38
|
+
## Invariants
|
|
39
|
+
|
|
40
|
+
**A PR must not change any of these without a failing test that justifies it.**
|
|
41
|
+
Each one is here because getting it wrong cost something real.
|
|
42
|
+
|
|
43
|
+
- **Entity dispatch is type-based, not flag-based.** `is_legacy_group()` uses
|
|
44
|
+
`isinstance(entity, (Chat, ChatForbidden))`. A gigagroup has `broadcast=False`
|
|
45
|
+
*and* `megagroup=False`, so inferring "legacy group" from those flags
|
|
46
|
+
misclassifies it and routes a channel id to `DeleteChatUserRequest` — which
|
|
47
|
+
can act on an entirely unrelated chat. Note that `ChatForbidden` and
|
|
48
|
+
`ChannelForbidden` are **not** subclasses of `Chat`/`Channel`, so all four
|
|
49
|
+
types must be named explicitly.
|
|
50
|
+
|
|
51
|
+
- **`PROTECTED` is checked before keywords** in `matches()`. A protected title
|
|
52
|
+
always wins. Preserve that precedence.
|
|
53
|
+
|
|
54
|
+
- **`--keyword` replaces the config's keywords; `--protected` only extends the
|
|
55
|
+
config's protected list.** Naming targets explicitly should not also match
|
|
56
|
+
everything in the config file, and a command-line flag must never silently
|
|
57
|
+
drop a configured safeguard. Both directions are pinned by tests; changing
|
|
58
|
+
them is a behaviour change, not a refactor.
|
|
59
|
+
|
|
60
|
+
- **Unknown config keys are rejected, not ignored.** A typo like `keyword =`
|
|
61
|
+
silently matching nothing would defeat the point of having a config file.
|
|
62
|
+
"No keywords anywhere" is an actionable `ConfigError`, not a silent no-op.
|
|
63
|
+
|
|
64
|
+
- **`DELAY_MIN`, `DELAY_MAX` (2.5–5.0s) and `MAX_FLOOD_WAIT` are not
|
|
65
|
+
configurable** via file, flag, or environment, and are never lowered. The
|
|
66
|
+
leave loop is never parallelized. Telegram rate-limits bulk leaves hard, and
|
|
67
|
+
the abort cap exists so the tool never silently parks for hours.
|
|
68
|
+
|
|
69
|
+
- **`leave_with_retries()` keeps its retry loop flat.** Do not nest a retry
|
|
70
|
+
inside `except FloodWaitError`: an exception raised inside an `except` block
|
|
71
|
+
is not caught by the sibling `except Exception`, so a third FloodWait — or
|
|
72
|
+
any error on a retry — escaped `leave_all()` and killed the whole run.
|
|
73
|
+
Equally, keep `except FloodWaitTooLong: raise` *ahead* of the catch-all in
|
|
74
|
+
`leave_all()`; it subclasses `RuntimeError`, so without that clause a
|
|
75
|
+
deliberate abort gets silently filed as an ordinary per-chat failure.
|
|
76
|
+
One bad chat must never stop the run.
|
|
77
|
+
|
|
78
|
+
- **`is_already_left()` and `is_defunct_group()` stay separate** — dead is not
|
|
79
|
+
the same as departed. Leaving a chat does *not* remove it from
|
|
80
|
+
`iter_dialogs()`, so presence in the dialog list says nothing about
|
|
81
|
+
membership; membership is read from the `left` flag and the
|
|
82
|
+
`ChatForbidden`/`ChannelForbidden` types. Separately, `deactivated` (deleted)
|
|
83
|
+
and `migrated_to` (upgraded to a supergroup) are `Chat`-only fields and
|
|
84
|
+
*neither sets `left`*, so without that check both get offered as candidates
|
|
85
|
+
and spend rate limit on a leave that cannot succeed. Do not replace either
|
|
86
|
+
with a dialog-list presence check.
|
|
87
|
+
|
|
88
|
+
- **Chat titles are attacker-controlled.** Anything user-facing or written to
|
|
89
|
+
CSV goes through `csv_safe()` — a group can be named `=HYPERLINK(...)` and a
|
|
90
|
+
spreadsheet will execute it.
|
|
91
|
+
|
|
92
|
+
- **Use `report_path()` and `open_report()` for every generated file**, never a
|
|
93
|
+
bare `open()`. Reports list every chat the user belongs to and must be `0600`
|
|
94
|
+
inside a `0700` directory. The per-chat log is flushed after every iteration
|
|
95
|
+
so a crash mid-run still leaves an accurate record — keep the flush.
|
|
96
|
+
|
|
97
|
+
- **Don't drop `secure_session_storage()`** or move the session into the
|
|
98
|
+
working tree. See [SECURITY.md](SECURITY.md) for why the file is pre-created
|
|
99
|
+
and the modes re-applied on every run.
|
|
100
|
+
|
|
101
|
+
- **The delay between leaves is skipped after the last chat only.** Don't
|
|
102
|
+
extend that skip to failures — a failed leave still consumed a request and
|
|
103
|
+
must be paced.
|
|
104
|
+
|
|
105
|
+
- **Dry run stays the default, and `--execute` keeps the typed `LEAVE` gate.**
|
|
106
|
+
|
|
107
|
+
- **Never ship a shared `api_id`/`api_hash`.** Credentials are per-user and
|
|
108
|
+
environment-only. That's a Telegram ToS line, not a preference.
|
|
109
|
+
|
|
110
|
+
## Commits and PRs
|
|
111
|
+
|
|
112
|
+
Conventional-commit subjects (`fix:`, `feat:`, `docs:`, `chore:`, `test:`,
|
|
113
|
+
`refactor:`). Explain *why* in the body when the change touches anything above.
|
|
114
|
+
PRs should describe how you tested; if you ran it against a real account, say
|
|
115
|
+
so and say what you left.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Davide Zambiasi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tg-bulk-leave
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bulk-leave Telegram groups and channels whose title matches a keyword. Dry run by default.
|
|
5
|
+
Project-URL: Homepage, https://github.com/soos3d/tg-bulk-leave
|
|
6
|
+
Project-URL: Repository, https://github.com/soos3d/tg-bulk-leave
|
|
7
|
+
Project-URL: Changelog, https://github.com/soos3d/tg-bulk-leave/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/soos3d/tg-bulk-leave/issues
|
|
9
|
+
Author: Davide Zambiasi
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: bulk-leave,cleanup,cli,leave-groups,telegram,telethon
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Communications :: Chat
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Requires-Dist: platformdirs>=4
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0
|
|
26
|
+
Requires-Dist: telethon<2,>=1.44
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# tg-bulk-leave
|
|
30
|
+
|
|
31
|
+
Bulk-leave Telegram groups and channels whose title matches a keyword.
|
|
32
|
+
|
|
33
|
+
Built for the situation where you've accumulated hundreds of chats — partner
|
|
34
|
+
groups, deal rooms, airdrop channels, one-off intro chats — and want them gone
|
|
35
|
+
without clicking through each one.
|
|
36
|
+
|
|
37
|
+
> **Leaving is irreversible.** For a private group you'd need a fresh invite to
|
|
38
|
+
> get back in. The tool defaults to a dry run and requires an explicit typed
|
|
39
|
+
> confirmation before it touches anything.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
scan all dialogs → title matches a keyword? → title matches protected? → leave
|
|
47
|
+
│ no │ yes
|
|
48
|
+
└── skip ───────────────────┴── skip
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Matching is a **case-insensitive substring** test against the chat title. A chat
|
|
52
|
+
is a candidate if *any* keyword appears in its title, unless *any* protected
|
|
53
|
+
string does — protection always wins.
|
|
54
|
+
|
|
55
|
+
DMs are never touched. Everything runs **locally on your machine**: your
|
|
56
|
+
session and credentials never leave it.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
Requires Python 3.11+.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv tool install tg-bulk-leave # recommended
|
|
64
|
+
# or
|
|
65
|
+
pipx install tg-bulk-leave
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Until the first PyPI release, install from a clone instead:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/soos3d/tg-bulk-leave && cd tg-bulk-leave
|
|
72
|
+
uv tool install . # or: pipx install .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Setup
|
|
76
|
+
|
|
77
|
+
### 1. Get API credentials
|
|
78
|
+
|
|
79
|
+
Go to [my.telegram.org](https://my.telegram.org) → *API development tools* and
|
|
80
|
+
create an app (any title, platform "Desktop" is fine). Note the `api_id` and
|
|
81
|
+
`api_hash`.
|
|
82
|
+
|
|
83
|
+
- The login code arrives **inside the Telegram app**, not by SMS.
|
|
84
|
+
- The `api_hash` is a secret and cannot be revoked — treat it like a password.
|
|
85
|
+
|
|
86
|
+
**Getting a bare "ERROR" on app creation?** This is a known my.telegram.org
|
|
87
|
+
issue ([Telethon #4661](https://github.com/LonamiWebs/Telethon/issues/4661)):
|
|
88
|
+
turn off any VPN/proxy/custom DNS, use a connection whose IP is in the same
|
|
89
|
+
country as your phone number, try another browser or network, and retry later.
|
|
90
|
+
|
|
91
|
+
### 2. Provide the credentials
|
|
92
|
+
|
|
93
|
+
Export them, or put them in a `.env` file in the directory you run from
|
|
94
|
+
(see `.env.example`; exported variables win over `.env`):
|
|
95
|
+
|
|
96
|
+
```ini
|
|
97
|
+
TG_API_ID=1234567
|
|
98
|
+
TG_API_HASH=your_api_hash_here
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 3. Configure keywords
|
|
102
|
+
|
|
103
|
+
Either per run, on the command line:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
tg-bulk-leave --keyword acme --protected "Acme Alumni"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
or persistently in a TOML config file (see [`config.example.toml`](config.example.toml)):
|
|
110
|
+
|
|
111
|
+
```toml
|
|
112
|
+
keywords = ["acme", "airdrop"] # leave chats matching any of these
|
|
113
|
+
protected = ["Acme Alumni"] # ...except these, always
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The file lives in your platform's config directory — `tg-bulk-leave --help`
|
|
117
|
+
prints the exact path (macOS: `~/Library/Application Support/tg-bulk-leave/config.toml`,
|
|
118
|
+
Linux: `~/.config/tg-bulk-leave/config.toml`) — or pass `--config PATH`.
|
|
119
|
+
|
|
120
|
+
Flags and file combine safely: `--keyword` **replaces** the file's keywords for
|
|
121
|
+
that run, while `--protected` only ever **adds** protection — a command-line
|
|
122
|
+
flag can never drop a safeguard you configured.
|
|
123
|
+
|
|
124
|
+
## Usage
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# 1. Dry run — lists every match, leaves nothing. Always start here.
|
|
128
|
+
tg-bulk-leave
|
|
129
|
+
|
|
130
|
+
# 2. Cautious first real run — leave only the first 5 matches.
|
|
131
|
+
tg-bulk-leave --execute --limit 5
|
|
132
|
+
|
|
133
|
+
# 3. The real thing.
|
|
134
|
+
tg-bulk-leave --execute
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`--execute` prints the full list, then requires you to type `LEAVE` (exact
|
|
138
|
+
case) to proceed. Anything else aborts.
|
|
139
|
+
|
|
140
|
+
**Read the dry run properly.** Substring matching casts a wide net, and a short
|
|
141
|
+
keyword gets swallowed by longer words — `"art"` also matches *Smart Contract
|
|
142
|
+
Devs*. Expand the protected list until the dry-run list contains only chats
|
|
143
|
+
you're willing to lose, and use `--limit` for your first real run.
|
|
144
|
+
|
|
145
|
+
### First run
|
|
146
|
+
|
|
147
|
+
You'll be asked for your phone number and the login code Telegram sends you
|
|
148
|
+
(plus your 2FA password, if set). A session file is then written to
|
|
149
|
+
`~/.config/tg_cleanup/` (override with `TG_SESSION`) so later runs skip the
|
|
150
|
+
login.
|
|
151
|
+
|
|
152
|
+
## Output
|
|
153
|
+
|
|
154
|
+
Every run writes timestamped CSVs into `reports/` (created in the directory you
|
|
155
|
+
run from), `0600` inside a `0700` directory:
|
|
156
|
+
|
|
157
|
+
| File | When | Columns |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| `reports/telegram_matches_<stamp>.csv` | every run | title, type, id, username |
|
|
160
|
+
| `reports/telegram_left_<stamp>.csv` | `--execute` only | title, type, id, result |
|
|
161
|
+
|
|
162
|
+
The leave log is flushed after every chat, so if the run dies partway you still
|
|
163
|
+
have an accurate record of what actually happened.
|
|
164
|
+
|
|
165
|
+
**Crashed run?** Just rerun it — chats you've already left are skipped.
|
|
166
|
+
|
|
167
|
+
Note that leaving a group does *not* remove it from your Telegram dialog list;
|
|
168
|
+
the conversation and its history stay until you delete it. Membership is
|
|
169
|
+
therefore checked explicitly via the `left` flag (and the `ChatForbidden` /
|
|
170
|
+
`ChannelForbidden` types), not by absence from the dialog list. Without that
|
|
171
|
+
check a rerun would retry every chat you'd already left.
|
|
172
|
+
|
|
173
|
+
Defunct legacy groups are skipped for the same reason: a deleted group
|
|
174
|
+
(`deactivated`) and one upgraded to a supergroup (`migrated_to`) both linger in
|
|
175
|
+
the dialog list without setting the `left` flag, and neither can be left — the
|
|
176
|
+
supergroup appears separately and holds the real membership.
|
|
177
|
+
|
|
178
|
+
## Rate limiting
|
|
179
|
+
|
|
180
|
+
Telegram throttles bulk leaves hard. The tool sleeps a random 2.5–5.0s between
|
|
181
|
+
chats and backs off when told to. These delays are deliberately **not
|
|
182
|
+
configurable** — lowering them earns a much longer ban.
|
|
183
|
+
|
|
184
|
+
If Telegram demands a wait longer than the abort cap (300s), the run **aborts**
|
|
185
|
+
rather than silently parking for hours — rerun later to continue.
|
|
186
|
+
|
|
187
|
+
## Security
|
|
188
|
+
|
|
189
|
+
The Telethon session file is the sensitive artifact here — **it grants full
|
|
190
|
+
access to your account with no login code**. It is deliberately stored outside
|
|
191
|
+
any repository (`~/.config/tg_cleanup/`) and `*.session` is gitignored. Every
|
|
192
|
+
run re-applies `0700` to that directory and `0600` to the session file, so it
|
|
193
|
+
stays owner-only whatever your umask is — and an existing world-readable
|
|
194
|
+
session from an older version is tightened on the next run. Treat it like a
|
|
195
|
+
password; don't sync it or commit it.
|
|
196
|
+
|
|
197
|
+
Credentials live only in `.env` or the environment, never in source. The
|
|
198
|
+
generated CSVs list every chat you belong to, so they're written `0600` and
|
|
199
|
+
gitignored too.
|
|
200
|
+
|
|
201
|
+
Chat titles are attacker-controlled — anyone can name a group `=HYPERLINK(...)`.
|
|
202
|
+
Titles are escaped before being written to CSV so they can't execute when you
|
|
203
|
+
open the report in a spreadsheet.
|
|
204
|
+
|
|
205
|
+
## Development
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
git clone https://github.com/soos3d/tg-bulk-leave && cd tg-bulk-leave
|
|
209
|
+
uv sync --dev
|
|
210
|
+
|
|
211
|
+
uv run pytest # coverage gate at 80%, configured in pyproject.toml
|
|
212
|
+
uv run ruff check
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the safety invariants a change must
|
|
216
|
+
not break, and [SECURITY.md](SECURITY.md) for the threat model.
|
|
217
|
+
|
|
218
|
+
The suite covers the logic that decides *what gets left*: keyword/protected
|
|
219
|
+
precedence, config loading and flag semantics, entity classification, the
|
|
220
|
+
`DeleteChatUserRequest` vs `LeaveChannelRequest` dispatch, FloodWait backoff
|
|
221
|
+
and abort, CSV escaping, file permissions, and the confirmation gate. Tests use
|
|
222
|
+
real Telethon entity types rather than stubs, so classification bugs surface.
|
|
223
|
+
|
|
224
|
+
One subtlety worth knowing if you touch `leave()`: **legacy groups and channels
|
|
225
|
+
need different API calls**, and you can't tell them apart by flags. A
|
|
226
|
+
*gigagroup* has both `broadcast=False` and `megagroup=False`, so flag-sniffing
|
|
227
|
+
misclassifies it as a legacy group and sends a channel id to
|
|
228
|
+
`DeleteChatUserRequest` — which can act on an unrelated chat. Dispatch on type
|
|
229
|
+
(`isinstance`), and note that `ChatForbidden` / `ChannelForbidden` are *not*
|
|
230
|
+
subclasses of `Chat` / `Channel`.
|
|
231
|
+
|
|
232
|
+
## Roadmap
|
|
233
|
+
|
|
234
|
+
This project is **free, open source, and local-first** — your session never
|
|
235
|
+
leaves your machine, and that stays true.
|
|
236
|
+
|
|
237
|
+
- **Now:** proper packaging (this release), then PyPI publication, CI, and a
|
|
238
|
+
`SECURITY.md`.
|
|
239
|
+
- **Later, maybe:** an optional hosted service for *recurring* cleanup (e.g.
|
|
240
|
+
auto-leave groups with no activity for 30+ days), which needs an always-on
|
|
241
|
+
machine. If that ever ships, it will be a separate opt-in product; the CLI
|
|
242
|
+
remains free and fully local. If you'd want such a service, open an issue —
|
|
243
|
+
that's how demand gets measured.
|
|
244
|
+
|
|
245
|
+
## Requirements
|
|
246
|
+
|
|
247
|
+
Python 3.11+, [Telethon](https://docs.telethon.dev) 1.44.
|
|
248
|
+
|
|
249
|
+
## License
|
|
250
|
+
|
|
251
|
+
[MIT](LICENSE).
|