slack_sweep, a single-file Slack cleanup CLI
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
opencode 70f858d33a feat: --backup also saves attachments (metadata + bytes)
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.
2026-10-05 18:17:21 +02:00
tests feat: --backup also saves attachments (metadata + bytes) 2026-10-05 18:17:21 +02:00
.gitignore Initial commit: slack_sweep, a single-file Slack cleanup CLI 2026-10-01 21:17:54 +02:00
LICENSE Initial commit: slack_sweep, a single-file Slack cleanup CLI 2026-10-01 21:17:54 +02:00
README.md feat: --backup also saves attachments (metadata + bytes) 2026-10-05 18:17:21 +02:00
slack_sweep.py feat: --backup also saves attachments (metadata + bytes) 2026-10-05 18:17:21 +02:00

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. --perform is required to delete anything.
  • With a TTY, --perform asks you to type delete. --yes skips 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 FILE appends every match as JSONL before it is deleted, and saves the bytes of its attachments into FILE.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-only journals the metadata and downloads nothing. url_private in 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.info needs an id you already have). So on the search source --backup spends one extra cached conversations.history call per match to recover them. --source history gets them for free. That cost is why it is gated on --backup and not paid otherwise.
  • Slack serves private files by redirecting to a signed CDN URL, which must not see your token. Downloads drop the Authorization header 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 gzip replies (no handshake on every API call), and the connection is rebuilt on errors.
  • Pacing: --sleep S is a minimum gap between request starts, per worker (default 0.4s). A 429 or Retry-After moves that method's deadline forward so it backs off instead of looping.
  • Parallel deletes: --jobs N runs independent chat.delete (and files.delete) calls in N workers, while paging/search stays serial. Matches, deletes, and the ledger.jsonl remain in the original order; default 1 is fully serial.
  • Efficient cache stats: entry ages are read from the first bytes (no full json.load of every cached file). A warm run costs 0 API calls.
  • Smarter as_user: once as_user=true is rejected (invalid_arguments), the run stops sending it and falls back to plain chat.delete immediately.
  • Hot paths: precomputed hit scopes, cached channel labels/records during a search stream, and fewer temporary copies reduce CPU/memory on large sweeps.