- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Deleting a message deletes the files on it, so --backup now keeps both halves: attachment metadata in the JSONL journal and the bytes themselves in FILE.attachments/<channel>/<ts>-<name>. - normalization threw file metadata away, reducing it to bare ids for files.delete; norm_files now yields both from one pass - Transport was POST+JSON only, so nothing could fetch bytes: add get() and Slack.download(), which retries like _post and lets a 429 park - Slack redirects url_private to a signed CDN URL that rejects an Authorization header it never signed, so downloads drop the token on that hop and use their own socket per hop - the journal stays JSONL on purpose: it is written before each delete, so a single JSON array would make it O(n^2) to append and unrecoverable if the run crashed mid-sweep Search hits carry no file list and Slack has no "files on this message" endpoint (files.info needs an id you already have), so recovering them costs one cached conversations.history call per match. That is gated on --backup, so an ordinary run pays nothing. Nothing in the backup path can abort a delete run: a failed download, an unwritable journal or an unreadable channel is recorded with an error, warned once, and the sweep continues. Check runs for [!] backup: lines. A file already on disk with the right size is reused, so re-running an interrupted sweep costs no download. New --backup-meta-only journals the metadata without fetching. tests/backup_sweep.py (15 cases, now in the runner) covers the journal shape, both sources, reuse, failures, traversal-safe names and the redirect; it opens one loopback socket so the CDN hop is exercised. |
||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| README.md | ||
| slack_sweep.py | ||
slack_sweep
Caution
This project is vibe-coded with AI assistance and has not been tested thoroughly. The author has run it against a real Slack workspace and the basic functionality — listing channels, finding your own messages, deleting them — worked, but that is the extent of it. There is no guarantee for anything: untested paths, edge cases and failure modes are almost certainly still in here, and nothing has been reviewed by a human who is not the AI that wrote it. It deletes messages. Test it on a throwaway workspace first.
A single-file Slack cleanup CLI in pure Python stdlib. Fast, connection-reused, and cache-friendly. Lists channels, finds your own messages with server-side search, and deletes them — dry run by default.
Replaces slack_cleaner2, which downloaded every message in the workspace and filtered
locally. This finds messages with search.messages (from:<@U…> + before:/after:),
so a full sweep costs roughly one API call per 100 hits, and a second run costs zero.
Status
| Works on a real workspace | basic paths only, by the author's own hand |
| Test suite | 6 offline suites against a fake Slack API, all passing |
| Real API calls ever made by the author | a few, on one workspace |
| Dependencies | none (stdlib only) |
| Python | 3.9+ (developed on 3.14) |
| License | AGPL-3.0-or-later |
The tests prove the logic (query building, cache, delete ledger, dedup, scope filtering, retries, confirmation prompt). They cannot prove Slack's actual API behaves the way the tool assumes — only the main commands have actually been exercised against Slack.
Install
No dependencies, no build step.
git clone <your fork> slack_sweep
chmod +x slack_sweep.py
./slack_sweep.py --help
Token
Needs an xoxp user token. A bot token (xoxb) is rejected on purpose: this tool
deletes your messages, and Slack will not let a bot delete a user's messages.
export SLACK_TOKEN=xoxp-...
Scopes needed (all on the user token; the app must be reinstalled after adding one):
| scope | needed for |
|---|---|
chat:write |
chat.delete — the deletion itself. Nothing is deleted without it |
search:read |
the search source — the fast path. Without it you get missing_scope |
channels:read, groups:read, im:read, mpim:read |
channels, --member/-c @person — listing conversations |
users:read |
users.info/users.list/users.lookupByEmail — resolving names, DM labels |
team:read |
the team line in info (team.info) |
channels:history, groups:history, im:history, mpim:history |
--source history and --threads (conversations.history / .replies) |
im:write |
-c @person only — conversations.open for the DM |
pins:read |
--pinned / --no-pinned |
files:write |
--files (files.delete) |
Saving attachments needs no new scope: the bytes come from files.slack.com with your
own token rather than a Web API method. Recovering them on the search source uses the
conversations.history scope you already need for --source history.
Minimal set for the default search-based dry run + real deletes: chat:write,
search:read, users:read. Add the rest as you use the flags above.
--as-user (on by default) first sends chat.delete with as_user=true, which is the
legacy path and wants chat:write:user. Without that scope Slack rejects the argument and
the tool retries plain chat:write, so it works either way — just noisily.
Slack returns missing_scope when the scope is absent, and when the token predates the
scope being added. Re-issue it (/auth/revoke, then reinstall the app). On Enterprise Grid
an admin may also have to approve the scope.
Check what your token can actually do:
./slack_sweep.py info --probe
Usage
# who am I / what workspace (2 API calls, lists nothing unless asked)
./slack_sweep.py info
./slack_sweep.py info --users --limit 50 # opt in to paging the user directory
./slack_sweep.py info --channels # opt in to counting conversations
./slack_sweep.py channels --dm
./slack_sweep.py channels --public --contains deploy
# find my messages
./slack_sweep.py messages --after 2024-01-01
./slack_sweep.py messages -c general --pinned
./slack_sweep.py messages -c @bob --after 30d # DM with bob
# delete them — dry run unless --perform
./slack_sweep.py delete --after 2024-01-01
./slack_sweep.py delete --after 2024-01-01 --perform
./slack_sweep.py delete -c general --threads --files --perform
Safety model
- Default is a dry run.
--performis required to delete anything. - With a TTY,
--performasks you to typedelete.--yesskips it (and a non-TTY pipeline skips it automatically — so be careful in CI). - Deletes are journalled to
ledger.jsonl, so re-running never deletes the same message twice and a cached dry run can be promoted to a real one without re-scanning. --backup FILEappends every match as JSONL before it is deleted, and saves the bytes of its attachments intoFILE.attachments/. See Backup.
--source (how messages are found)
| mode | behaviour |
|---|---|
auto (default) |
one workspace-wide search; conversations.history when you name exactly one -c channel |
search |
search.messages — fast, server-side filters, no file list, replies included |
history |
conversations.history — exact, no dedup, needed for --files |
The workspace-wide path never lists conversations: channel name and kind come from the
search hits themselves, so there is no conversations.list / users.conversations and none
of their rate limits. The trade-off is printed every run: a search hit carries no
"archived" or "is_member" flag, so without --archived those messages are included too.
Pass --member, -c @person or --source history when you need the real channel list.
Backup
Deleting a message deletes the files hanging off it for good, so --backup FILE keeps both
halves of what is about to go: a JSONL journal of every match, and the attachment bytes
themselves.
./slack_sweep.py delete -c general --backup keep.jsonl --perform
keep.jsonl # one JSON object per match
keep.jsonl.attachments/C_GEN/1700000400_000400-notes.txt # <channel>/<ts>-<name>
The journal is written before the message is deleted, so an interrupted run still leaves
a record. Every line has the same ten keys (ts, channel, channel_name, user,
username, text, permalink, pinned, source, attachments); each entry in
attachments carries the file metadata plus where its bytes landed:
{"id": "F1", "name": "notes.txt", "mimetype": "text/plain", "size": 11,
"url_private": "https://files.slack.com/...", "saved": "keep.jsonl.attachments/C_GEN/1700000400_000400-notes.txt",
"bytes": 11}
Things worth knowing:
--backup-meta-onlyjournals the metadata and downloads nothing.url_privatein the journal is enough to fetch the file later while your token still works; the bytes are for when you want a copy that outlives the message.- A re-run does not re-download. A file already on disk with the right size is reused, so re-running the same command to finish an interrupted sweep is free.
- Nothing here aborts a delete run. A file that will not download, a journal that turns
unwritable, a channel you cannot read — each is recorded with an
error, warned about once, and the sweep continues. Check the run for[!] backup:lines. - Search hits carry no file list, and Slack has no "files on this message" endpoint
(
files.infoneeds an id you already have). So on the search source--backupspends one extra cachedconversations.historycall per match to recover them.--source historygets them for free. That cost is why it is gated on--backupand not paid otherwise. - Slack serves private files by redirecting to a signed CDN URL, which must not see your
token. Downloads drop the
Authorizationheader on that hop and retry like any other call.
--threads is history-only
search.messages already returns thread replies, so --threads with the search source is
rejected rather than ignored. Drop it on a search sweep; nothing is lost.
Flags worth knowing
--user me who to match (repeatable); default me. --any-user, --bots, --no-bots
--after / --before YYYY-MM-DD [HH:MM[:SS]], 'now', '30d'/'6M'/'2y', or epoch seconds
--keyword STR client-side text filter (repeatable, OR)
--query Q raw search query, replaces the generated one
--source auto|search|history how messages are found (see above)
--threads thread replies; --source history only, rejected on search
--files also delete files on matches; --source history only
--backup FILE append every match as JSONL to FILE, attachments into FILE.attachments/
--backup-meta-only with --backup: record attachment metadata, do not download the bytes
--pinned/--no-pinned needs pins:read; ~1 call per channel in scope
--name-limit N users.info lookups used to label DMs (default 50, -1 all, 0 = raw ids)
--user-scan N users.list members pulled to resolve a name (default 1000, -1 all)
--max-pages N search pages of <=100 hits (default 10)
--sleep S pause between calls (default 0.4)
--json JSONL on stdout, everything else on stderr
-v cache hits + a call tally
-v/--verbose and friends are global: they go before the subcommand
(slack_sweep.py -v delete -c test), not after.
Big workspaces
Nobody is paged out of the user directory by default. DM labels come from cached
users.info calls capped by --name-limit; a bare name is only searched for in
users.list up to --user-scan members, and the tool says so when it was cut short.
Pass a user id (U…) when you can — it is always free.
Cache
$XDG_CACHE_HOME/slack_sweep, else ~/.cache/slack_sweep. Override with --cache-dir.
auth/ users/ user/ channels/ search/ history/ replies/ pins/ files/ # JSON, sha1-named
ledger.jsonl # append-only delete journal
Namespaces have TTLs (24h for identity/users, 15m for everything else). A warm run makes 0 API calls.
./slack_sweep.py cache path # where
./slack_sweep.py cache stats # per-namespace size and age
./slack_sweep.py cache list # keys, newest first
./slack_sweep.py cache clear pins # or: auth|users|user|channels|search|history|replies|files|all
cache clear does not touch ledger.jsonl. Delete that file by hand if you really
want to re-scan the same timestamps.
Tests
Offline only — a fake Slack API, no token, no network.
python tests/run_tests.py # all 6 suites
python tests/run_tests.py search # one suite
| suite | covers |
|---|---|
smoke_sweep |
every command, search/history, pins, files, ledger, cache, JSON, errors |
search_sweep |
one-query workspace sweep, scope filtering, dedup, no channel list |
edge_sweep |
429/500/network retries, TTLs, scopes, limits, dates, --query |
tty_check |
interactive confirmation: abort and proceed |
big_sweep |
100k-member workspace: lazy user resolution, no directory paging |
backup_sweep |
--backup journal + attachment bytes, search-path recovery, downloads |
backup_sweep is the one suite that opens a socket: a throwaway server on 127.0.0.1
stands in for the signed CDN URL Slack redirects a file download to, so the redirect and the
dropped Authorization header are actually exercised rather than asserted about. Nothing
leaves the machine. wire_sweep (not in the runner — run it by hand) does the same for
connection reuse and gzip.
Disclaimer
Written by AI, tested only lightly by hand, reviewed by nobody who is paid to be right.
There is no guarantee of correctness. It talks to Slack's API and deletes messages
permanently. Test on a throwaway workspace, keep backups (--backup FILE, which saves the
attachments too), and do not trust it with a workspace you would mind losing.
License
AGPL-3.0-or-later. See LICENSE. If you run a modified copy as a service for other people, section 13 applies: offer them the source.
Performance
- One kept-alive TLS connection per run with
gzipreplies (no handshake on every API call), and the connection is rebuilt on errors. - Pacing:
--sleep Sis a minimum gap between request starts, per worker (default 0.4s). A 429 orRetry-Aftermoves that method's deadline forward so it backs off instead of looping. - Parallel deletes:
--jobs Nruns independentchat.delete(andfiles.delete) calls in N workers, while paging/search stays serial. Matches, deletes, and theledger.jsonlremain in the original order; default 1 is fully serial. - Efficient cache stats: entry ages are read from the first bytes (no full
json.loadof every cached file). A warm run costs 0 API calls. - Smarter
as_user: onceas_user=trueis rejected (invalid_arguments), the run stops sending it and falls back to plainchat.deleteimmediately. - Hot paths: precomputed hit scopes, cached channel labels/records during a search stream, and fewer temporary copies reduce CPU/memory on large sweeps.