slack-cleaner 0.1.0

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.
package/.editorconfig ADDED
@@ -0,0 +1,15 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ indent_style = space
8
+ indent_size = 2
9
+ trim_trailing_whitespace = true
10
+
11
+ [*.md]
12
+ trim_trailing_whitespace = false
13
+
14
+ [*.json]
15
+ insert_final_newline = false
package/.env.example ADDED
@@ -0,0 +1,15 @@
1
+ # Copy to .env and set one token.
2
+ # Token resolution order:
3
+ # 1) --token CLI flag
4
+ # 2) SLACK_USER_TOKEN
5
+ # 3) SLACK_BOT_TOKEN
6
+ # 4) SLACK_TOKEN
7
+
8
+ # Use a user token (xoxp-...) for deleting user-authored messages when required.
9
+ SLACK_USER_TOKEN=
10
+
11
+ # Or use a bot token (xoxb-...) if scopes and ownership allow deletion.
12
+ SLACK_BOT_TOKEN=
13
+
14
+ # Generic fallback token (used only if the vars above are empty).
15
+ SLACK_TOKEN=
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "printWidth": 100,
3
+ "singleQuote": true,
4
+ "semi": true,
5
+ "trailingComma": "es5",
6
+ "arrowParens": "always"
7
+ }
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # slack-cleaner-node
2
+
3
+ `slack-cleaner-node` is a flag-driven Node.js CLI for safely finding and deleting Slack messages across channels, groups, and conversations, with dry-run by default, rich text-pattern matching, and retry-aware rate limiting.
4
+
5
+ ## Safety model
6
+
7
+ - Default mode is **dry-run**.
8
+ - Use `--perform` to actually delete messages.
9
+ - Use `--limit` to cap deletions while validating filters.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install
15
+ npm link
16
+ ```
17
+
18
+ Then run:
19
+
20
+ ```bash
21
+ slack-cleaner --help
22
+ ```
23
+
24
+ ## Slack token and scopes
25
+
26
+ Use a token that can read history and delete the target messages.
27
+
28
+ This CLI auto-loads a `.env` file from your current working directory.
29
+
30
+ Create it from the example:
31
+
32
+ ```bash
33
+ cp .env.example .env
34
+ ```
35
+
36
+ Set token in `.env` or shell env vars:
37
+
38
+ ```bash
39
+ export SLACK_BOT_TOKEN='xoxb-...'
40
+ # or
41
+ export SLACK_USER_TOKEN='xoxp-...'
42
+ # or
43
+ export SLACK_TOKEN='xoxp-...'
44
+ ```
45
+
46
+ Or pass directly:
47
+
48
+ ```bash
49
+ slack-cleaner --token xoxp-...
50
+ ```
51
+
52
+ Typical scopes you will need depend on conversation types, for example:
53
+
54
+ - `channels:history`, `groups:history`, `im:history`, `mpim:history`
55
+ - `chat:write`
56
+ - `users:read`
57
+ - conversation listing scopes where needed
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ slack-cleaner --token xoxp-... [targets] [filters] [options]
63
+ ```
64
+
65
+ ### Targets
66
+
67
+ - `--channel <name[,name]>` public channels
68
+ - `--group <name[,name]>` private channels
69
+ - `--conversation <id[,id]>` direct conversation IDs
70
+
71
+ At least one target flag is required.
72
+
73
+ ### Filters
74
+
75
+ - `--user <username|user_id[,..]>` only messages by users
76
+ - `--message-type <user|bot>` filter by type
77
+ - `--pattern <regex>` regex on message text, attachments, block text, and related textual fields
78
+ - `--after <date>` oldest date (ISO format supported)
79
+ - `--before <date>` newest date
80
+
81
+ ### Execution
82
+
83
+ - `--perform` execute deletions
84
+ - `--limit <n>` max matched messages to preview/delete
85
+ - `--rate-limit-ms <ms>` minimum delay between Slack API calls (default `250`)
86
+ - `--max-retries <n>` retries on 429/5xx/network failures (default `6`)
87
+ - `--verbose` detailed logs
88
+
89
+ ## Examples
90
+
91
+ Dry-run messages from a user in `#general` after Jan 1, 2024:
92
+
93
+ ```bash
94
+ slack-cleaner --channel general --user alice --after 2024-01-01
95
+ ```
96
+
97
+ Delete up to 50 bot messages matching `deploy failed` in a private channel:
98
+
99
+ ```bash
100
+ slack-cleaner --group ops-alerts --message-type bot --pattern "deploy failed" --limit 50 --perform
101
+ ```
102
+
103
+ Delete from known conversation ID with a slower API cadence:
104
+
105
+ ```bash
106
+ slack-cleaner --conversation C12345678 --rate-limit-ms 800 --perform
107
+ ```
108
+
109
+ Delete with more aggressive retry policy:
110
+
111
+ ```bash
112
+ slack-cleaner --channel general --perform --max-retries 10
113
+ ```
114
+
115
+ ## Notes
116
+
117
+ - Slack may refuse deletes for messages your token cannot remove.
118
+ - When multiple tokens are configured, delete attempts can fall back to secondary tokens on permission-related delete errors.
119
+ - Requests use retry with backoff and `Retry-After` when Slack responds with HTTP 429.
120
+ - If you still hit limits, increase `--rate-limit-ms` or reduce scope per run.
121
+ - During `--perform`, TTY terminals show a self-updating progress line instead of one log line per deleted message.
122
+ - Live progress includes `retries_total=<n>`, `retries_429=<n>`, and retry wait countdown `wait=<seconds>s`.
123
+ - Each run prints `Rate limit retries: <n>` and `Total retries: <n>` at the end.
124
+
125
+ ## Credits
126
+
127
+ - Inspired by [`slack_cleaner2`](https://github.com/sgratzl/slack_cleaner2) by [@sgratzl](https://github.com/sgratzl).
128
+ - Built with implementation assistance from OpenAI Codex (GPT 5.3).
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { run } = require('../src/cli');
4
+
5
+ run(process.argv.slice(2)).catch((error) => {
6
+ console.error(`Error: ${error.message}`);
7
+ process.exitCode = 1;
8
+ });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "slack-cleaner",
3
+ "version": "0.1.0",
4
+ "description": "Flag-based Slack message cleaner CLI for Node.js",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "main": "src/cli.js",
8
+ "bin": {
9
+ "slack-cleaner": "bin/slack-cleaner.js"
10
+ },
11
+ "scripts": {
12
+ "start": "node bin/slack-cleaner.js --help",
13
+ "lint": "find src bin test -name '*.js' -print0 | xargs -0 -n1 node --check",
14
+ "test": "node --test"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": [
20
+ "slack",
21
+ "cleanup",
22
+ "cli",
23
+ "messages"
24
+ ],
25
+ "directories": {
26
+ "test": "test"
27
+ },
28
+ "devDependencies": {},
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/mcdado/slack-cleaner-node.git"
32
+ },
33
+ "author": "David Gasperoni <mcdado@gmail.com>",
34
+ "bugs": {
35
+ "url": "https://github.com/mcdado/slack-cleaner-node/issues"
36
+ },
37
+ "homepage": "https://github.com/mcdado/slack-cleaner-node#readme"
38
+ }
@@ -0,0 +1,150 @@
1
+ /** @typedef {import('../types').ParsedArgs} ParsedArgs */
2
+
3
+ /**
4
+ * @param {string[]} argv
5
+ * @returns {ParsedArgs}
6
+ */
7
+ function parseArgs(argv) {
8
+ /** @type {ParsedArgs} */
9
+ const args = {
10
+ help: false,
11
+ token: undefined,
12
+ channelNames: [],
13
+ groupNames: [],
14
+ conversationIds: [],
15
+ users: [],
16
+ messageType: undefined,
17
+ pattern: undefined,
18
+ before: undefined,
19
+ after: undefined,
20
+ perform: false,
21
+ rateLimitMs: 250,
22
+ maxRetries: 6,
23
+ limit: 0,
24
+ verbose: false,
25
+ };
26
+
27
+ for (let i = 0; i < argv.length; i += 1) {
28
+ const arg = argv[i];
29
+
30
+ switch (arg) {
31
+ case '--help':
32
+ case '-h':
33
+ args.help = true;
34
+ break;
35
+ case '--token':
36
+ args.token = requireValue(argv, ++i, '--token');
37
+ break;
38
+ case '--channel':
39
+ args.channelNames.push(...splitCsv(requireValue(argv, ++i, '--channel')));
40
+ break;
41
+ case '--group':
42
+ args.groupNames.push(...splitCsv(requireValue(argv, ++i, '--group')));
43
+ break;
44
+ case '--conversation':
45
+ args.conversationIds.push(...splitCsv(requireValue(argv, ++i, '--conversation')));
46
+ break;
47
+ case '--user':
48
+ args.users.push(...splitCsv(requireValue(argv, ++i, '--user')));
49
+ break;
50
+ case '--message-type': {
51
+ const value = requireValue(argv, ++i, '--message-type');
52
+ if (!['user', 'bot'].includes(value)) {
53
+ throw new Error('--message-type must be one of: user, bot');
54
+ }
55
+ args.messageType = value;
56
+ break;
57
+ }
58
+ case '--pattern':
59
+ args.pattern = requireValue(argv, ++i, '--pattern');
60
+ break;
61
+ case '--before':
62
+ args.before = parseDate(requireValue(argv, ++i, '--before'), '--before');
63
+ break;
64
+ case '--after':
65
+ args.after = parseDate(requireValue(argv, ++i, '--after'), '--after');
66
+ break;
67
+ case '--perform':
68
+ args.perform = true;
69
+ break;
70
+ case '--rate-limit-ms':
71
+ args.rateLimitMs = parseInteger(
72
+ requireValue(argv, ++i, '--rate-limit-ms'),
73
+ '--rate-limit-ms'
74
+ );
75
+ break;
76
+ case '--max-retries':
77
+ args.maxRetries = parseInteger(requireValue(argv, ++i, '--max-retries'), '--max-retries');
78
+ break;
79
+ case '--limit':
80
+ args.limit = parseInteger(requireValue(argv, ++i, '--limit'), '--limit');
81
+ break;
82
+ case '--verbose':
83
+ args.verbose = true;
84
+ break;
85
+ default:
86
+ throw new Error(`Unknown flag: ${arg}`);
87
+ }
88
+ }
89
+
90
+ return args;
91
+ }
92
+
93
+ /**
94
+ * @param {string} raw
95
+ * @param {string} flag
96
+ * @returns {Date}
97
+ */
98
+ function parseDate(raw, flag) {
99
+ const date = new Date(raw);
100
+ if (Number.isNaN(date.getTime())) {
101
+ throw new Error(`Invalid date for ${flag}: ${raw}`);
102
+ }
103
+ return date;
104
+ }
105
+
106
+ /**
107
+ * @param {string} raw
108
+ * @param {string} flag
109
+ * @returns {number}
110
+ */
111
+ function parseInteger(raw, flag) {
112
+ const value = Number.parseInt(raw, 10);
113
+ if (!Number.isFinite(value) || value < 0) {
114
+ throw new Error(`Invalid numeric value for ${flag}: ${raw}`);
115
+ }
116
+ return value;
117
+ }
118
+
119
+ /**
120
+ * @param {string[]} argv
121
+ * @param {number} index
122
+ * @param {string} flag
123
+ * @returns {string}
124
+ */
125
+ function requireValue(argv, index, flag) {
126
+ const value = argv[index];
127
+ if (!value || value.startsWith('--')) {
128
+ throw new Error(`Missing value for ${flag}`);
129
+ }
130
+ return value;
131
+ }
132
+
133
+ /**
134
+ * @param {string} value
135
+ * @returns {string[]}
136
+ */
137
+ function splitCsv(value) {
138
+ return value
139
+ .split(',')
140
+ .map((item) => item.trim())
141
+ .filter(Boolean);
142
+ }
143
+
144
+ module.exports = {
145
+ parseArgs,
146
+ parseDate,
147
+ parseInteger,
148
+ requireValue,
149
+ splitCsv,
150
+ };
@@ -0,0 +1,34 @@
1
+ function printHelp() {
2
+ console.log(`slack-cleaner (Node.js)\n
3
+ Usage:
4
+ slack-cleaner --token xoxp-... [targets] [filters] [options]
5
+
6
+ Targets:
7
+ --channel <name[,name]> Public channel name(s)
8
+ --group <name[,name]> Private channel name(s)
9
+ --conversation <id[,id]> Conversation ID(s) (e.g. C123, G123, D123)
10
+
11
+ Filters:
12
+ --user <username|user_id[,..]> Keep only messages by these users
13
+ --message-type <user|bot> Filter by message author type
14
+ --pattern <regex> Regex for message text, attachments, and blocks
15
+ --after <date> Oldest message date (ISO-8601)
16
+ --before <date> Newest message date (ISO-8601)
17
+
18
+ Execution:
19
+ --perform Actually delete messages (default is dry-run)
20
+ --limit <n> Limit number of matched messages to delete/preview
21
+ --rate-limit-ms <ms> Minimum delay between Slack API calls (default: 250)
22
+ --max-retries <n> Retries for 429/5xx/network errors (default: 6)
23
+ --verbose Show detailed output
24
+ -h, --help Show this help
25
+
26
+ Environment:
27
+ The CLI auto-loads .env in the current working directory.
28
+ SLACK_USER_TOKEN, SLACK_BOT_TOKEN, or SLACK_TOKEN can be used instead of --token.
29
+ `);
30
+ }
31
+
32
+ module.exports = {
33
+ printHelp,
34
+ };
package/src/cli.js ADDED
@@ -0,0 +1,149 @@
1
+ /* eslint-disable no-console */
2
+ const path = require('node:path');
3
+
4
+ require('./types');
5
+
6
+ const { loadDotEnv, resolveTokens } = require('./config/env');
7
+ const { parseArgs } = require('./cli/args');
8
+ const { printHelp } = require('./cli/help');
9
+ const { createSlackApi } = require('./slack/client');
10
+ const { fetchUsers } = require('./slack/users');
11
+ const { resolveConversations } = require('./slack/conversations');
12
+ const { resolveUserFilters } = require('./domain/filters');
13
+ const { createMessageMatcher } = require('./domain/matcher');
14
+ const { collectMatches, deleteMatches } = require('./domain/cleanup');
15
+ const { printPreview } = require('./output/preview');
16
+ const { createDeletionProgressReporter } = require('./output/progress');
17
+
18
+ /**
19
+ * @param {string[]} argv
20
+ * @returns {Promise<void>}
21
+ */
22
+ async function run(argv) {
23
+ loadDotEnv(path.resolve(process.cwd(), '.env'));
24
+
25
+ const args = parseArgs(argv);
26
+ const tokens = resolveTokens(args.token);
27
+ args.token = tokens[0];
28
+
29
+ if (args.help) {
30
+ printHelp();
31
+ return;
32
+ }
33
+
34
+ if (!args.token) {
35
+ throw new Error('Missing --token. Use --help for usage.');
36
+ }
37
+
38
+ if (
39
+ args.channelNames.length === 0 &&
40
+ args.groupNames.length === 0 &&
41
+ args.conversationIds.length === 0
42
+ ) {
43
+ throw new Error('Specify at least one target with --channel, --group, or --conversation.');
44
+ }
45
+
46
+ const api = createSlackApi(args.token, {
47
+ minRequestIntervalMs: args.rateLimitMs,
48
+ maxRetries: args.maxRetries,
49
+ verbose: args.verbose,
50
+ });
51
+ const deleteApis = tokens.map((token) =>
52
+ createSlackApi(token, {
53
+ minRequestIntervalMs: args.rateLimitMs,
54
+ maxRetries: args.maxRetries,
55
+ verbose: args.verbose,
56
+ })
57
+ );
58
+ const reportRetrySummary = () => {
59
+ const { rateLimitRetries, totalRetries } = getAggregateRetryStats(deleteApis);
60
+ console.log(`Rate limit retries: ${rateLimitRetries}`);
61
+ console.log(`Total retries: ${totalRetries}`);
62
+ };
63
+
64
+ const auth = await api('auth.test');
65
+ if (!auth.ok) {
66
+ throw new Error(auth.error || 'Unable to authenticate with Slack token.');
67
+ }
68
+
69
+ if (args.verbose) {
70
+ console.log(`Authenticated as ${auth.user} (${auth.user_id}) in ${auth.team}`);
71
+ }
72
+
73
+ const users = await fetchUsers(api);
74
+ const conversations = await resolveConversations(api, args, users);
75
+
76
+ if (conversations.length === 0) {
77
+ console.log('No matching conversations found.');
78
+ reportRetrySummary();
79
+ return;
80
+ }
81
+
82
+ const userFilterIds = resolveUserFilters(args.users, users);
83
+ const matcher = createMessageMatcher(args, userFilterIds);
84
+ const { matches, totalScanned } = await collectMatches(api, conversations, matcher, args);
85
+
86
+ console.log(`Conversations scanned: ${conversations.length}`);
87
+ console.log(`Messages scanned: ${totalScanned}`);
88
+ console.log(`Matched messages: ${matches.length}`);
89
+
90
+ if (!args.perform) {
91
+ printPreview(matches, args.limit);
92
+ console.log('Dry-run mode: no messages deleted. Use --perform to execute deletions.');
93
+ reportRetrySummary();
94
+ return;
95
+ }
96
+
97
+ const toDelete = args.limit > 0 ? matches.slice(0, args.limit) : matches;
98
+ if (toDelete.length === 0) {
99
+ console.log('No messages to delete.');
100
+ reportRetrySummary();
101
+ return;
102
+ }
103
+
104
+ const reporter = createDeletionProgressReporter(toDelete.length);
105
+ for (const deleteApi of deleteApis) {
106
+ if (typeof deleteApi.setRetryObserver === 'function') {
107
+ deleteApi.setRetryObserver((seconds) => reporter.setRetryWaitSeconds(seconds));
108
+ }
109
+ }
110
+
111
+ let deleted = 0;
112
+ let failed = 0;
113
+ try {
114
+ ({ deleted, failed } = await deleteMatches(deleteApis, toDelete, reporter));
115
+ } finally {
116
+ for (const deleteApi of deleteApis) {
117
+ if (typeof deleteApi.setRetryObserver === 'function') {
118
+ deleteApi.setRetryObserver(null);
119
+ }
120
+ }
121
+ }
122
+
123
+ console.log(`Deleted messages: ${deleted}/${toDelete.length} (${failed} failed)`);
124
+ reportRetrySummary();
125
+ }
126
+
127
+ /**
128
+ * @param {Array<import('./types').SlackApiFn>} apis
129
+ * @returns {{rateLimitRetries: number, totalRetries: number}}
130
+ */
131
+ function getAggregateRetryStats(apis) {
132
+ let rateLimitRetries = 0;
133
+ let totalRetries = 0;
134
+
135
+ for (const api of apis) {
136
+ const stats =
137
+ typeof api.getStats === 'function'
138
+ ? api.getStats()
139
+ : { rateLimitRetries: 0, totalRetries: 0 };
140
+ rateLimitRetries += stats.rateLimitRetries || 0;
141
+ totalRetries += stats.totalRetries || 0;
142
+ }
143
+
144
+ return { rateLimitRetries, totalRetries };
145
+ }
146
+
147
+ module.exports = {
148
+ run,
149
+ };
@@ -0,0 +1,111 @@
1
+ const fs = require('node:fs');
2
+
3
+ /**
4
+ * Loads .env style key/value pairs into process.env if keys are not already set.
5
+ * @param {string} filePath
6
+ */
7
+ function loadDotEnv(filePath) {
8
+ if (!fs.existsSync(filePath)) {
9
+ return;
10
+ }
11
+
12
+ const raw = fs.readFileSync(filePath, 'utf8');
13
+ const lines = raw.split(/\r?\n/);
14
+
15
+ for (const line of lines) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed || trimmed.startsWith('#')) {
18
+ continue;
19
+ }
20
+
21
+ const separator = trimmed.indexOf('=');
22
+ if (separator <= 0) {
23
+ continue;
24
+ }
25
+
26
+ const key = normalizeEnvKey(trimmed.slice(0, separator).trim());
27
+ const value = stripQuotes(trimmed.slice(separator + 1).trim());
28
+
29
+ if (!key) {
30
+ continue;
31
+ }
32
+
33
+ if (process.env[key] === undefined) {
34
+ process.env[key] = value;
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * @param {string | undefined} cliToken
41
+ * @returns {string | undefined}
42
+ */
43
+ function resolveToken(cliToken) {
44
+ return resolveTokens(cliToken)[0];
45
+ }
46
+
47
+ /**
48
+ * Returns ordered, unique token candidates:
49
+ * 1) CLI token
50
+ * 2) SLACK_USER_TOKEN
51
+ * 3) SLACK_BOT_TOKEN
52
+ * 4) SLACK_TOKEN
53
+ * @param {string | undefined} cliToken
54
+ * @returns {string[]}
55
+ */
56
+ function resolveTokens(cliToken) {
57
+ const candidates = [
58
+ cliToken,
59
+ process.env.SLACK_USER_TOKEN,
60
+ process.env.SLACK_BOT_TOKEN,
61
+ process.env.SLACK_TOKEN,
62
+ ];
63
+ const ordered = [];
64
+ const seen = new Set();
65
+
66
+ for (const candidate of candidates) {
67
+ if (typeof candidate !== 'string') {
68
+ continue;
69
+ }
70
+
71
+ const normalized = candidate.trim();
72
+ if (!normalized || seen.has(normalized)) {
73
+ continue;
74
+ }
75
+
76
+ seen.add(normalized);
77
+ ordered.push(normalized);
78
+ }
79
+
80
+ return ordered;
81
+ }
82
+
83
+ /**
84
+ * @param {string} key
85
+ * @returns {string}
86
+ */
87
+ function normalizeEnvKey(key) {
88
+ return key.replace(/^export\s+/, '').trim();
89
+ }
90
+
91
+ /**
92
+ * @param {string} value
93
+ * @returns {string}
94
+ */
95
+ function stripQuotes(value) {
96
+ if (value.length >= 2) {
97
+ const starts = value[0];
98
+ const ends = value[value.length - 1];
99
+ if ((starts === '"' && ends === '"') || (starts === "'" && ends === "'")) {
100
+ return value.slice(1, -1);
101
+ }
102
+ }
103
+
104
+ return value;
105
+ }
106
+
107
+ module.exports = {
108
+ loadDotEnv,
109
+ resolveToken,
110
+ resolveTokens,
111
+ };