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.
@@ -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,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
@@ -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).