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 +15 -0
- package/.env.example +15 -0
- package/.prettierrc +7 -0
- package/README.md +128 -0
- package/bin/slack-cleaner.js +8 -0
- package/package.json +38 -0
- package/src/cli/args.js +150 -0
- package/src/cli/help.js +34 -0
- package/src/cli.js +149 -0
- package/src/config/env.js +111 -0
- package/src/domain/cleanup.js +149 -0
- package/src/domain/filters.js +28 -0
- package/src/domain/matcher.js +141 -0
- package/src/output/preview.js +24 -0
- package/src/output/progress.js +106 -0
- package/src/slack/client.js +169 -0
- package/src/slack/conversations.js +99 -0
- package/src/slack/history.js +49 -0
- package/src/slack/users.js +42 -0
- package/src/types.js +58 -0
- package/src/utils/strings.js +11 -0
- package/src/utils/time.js +44 -0
- package/test/args.test.js +54 -0
- package/test/cleanup.test.js +117 -0
- package/test/client.test.js +67 -0
- package/test/env.test.js +60 -0
- package/test/matcher.test.js +37 -0
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
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).
|
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
|
+
}
|
package/src/cli/args.js
ADDED
|
@@ -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
|
+
};
|
package/src/cli/help.js
ADDED
|
@@ -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
|
+
};
|