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.
@@ -0,0 +1,149 @@
1
+ const { fetchConversationHistory } = require('../slack/history');
2
+ const { formatSlackTs } = require('../utils/time');
3
+
4
+ /** @typedef {import('../types').MatchedMessage} MatchedMessage */
5
+ const PERMISSION_ERRORS = new Set([
6
+ 'cant_delete_message',
7
+ 'missing_scope',
8
+ 'no_permission',
9
+ 'not_allowed',
10
+ 'not_authed',
11
+ 'invalid_auth',
12
+ 'account_inactive',
13
+ 'token_revoked',
14
+ ]);
15
+
16
+ /**
17
+ * @param {import('../types').SlackApiFn} api
18
+ * @param {Array<{id: string, name: string}>} conversations
19
+ * @param {(message: any) => boolean} matcher
20
+ * @param {import('../types').ParsedArgs} args
21
+ * @returns {Promise<{matches: MatchedMessage[], totalScanned: number}>}
22
+ */
23
+ async function collectMatches(api, conversations, matcher, args) {
24
+ let totalScanned = 0;
25
+ /** @type {MatchedMessage[]} */
26
+ const matches = [];
27
+
28
+ for (const conversation of conversations) {
29
+ const history = await fetchConversationHistory(api, conversation.id, args);
30
+ totalScanned += history.length;
31
+
32
+ for (const message of history) {
33
+ if (matcher(message)) {
34
+ matches.push({ conversation, message });
35
+ }
36
+ }
37
+ }
38
+
39
+ return { matches, totalScanned };
40
+ }
41
+
42
+ /**
43
+ * @param {Array<import('../types').SlackApiFn>} deleteApis
44
+ * @param {MatchedMessage[]} items
45
+ * @param {import('../types').DeletionReporter} reporter
46
+ * @returns {Promise<{deleted: number, failed: number}>}
47
+ */
48
+ async function deleteMatches(deleteApis, items, reporter) {
49
+ if (!Array.isArray(deleteApis) || deleteApis.length === 0) {
50
+ throw new Error('deleteMatches requires at least one API client');
51
+ }
52
+
53
+ let deleted = 0;
54
+ let failed = 0;
55
+ let processed = 0;
56
+
57
+ reporter.start();
58
+ try {
59
+ for (const item of items) {
60
+ const result = await deleteWithFallback(deleteApis, item);
61
+
62
+ if (!result.ok) {
63
+ failed += 1;
64
+ reporter.log(
65
+ `Failed to delete ${item.conversation.name}#${formatSlackTs(item.message.ts)}: ${result.error || 'unknown_error'}`
66
+ );
67
+ } else {
68
+ deleted += 1;
69
+ }
70
+
71
+ processed += 1;
72
+ const stats = getAggregateStats(deleteApis);
73
+ reporter.update({
74
+ processed,
75
+ deleted,
76
+ failed,
77
+ retryRateLimit: stats.rateLimitRetries,
78
+ retryTotal: stats.totalRetries,
79
+ retryWaitSeconds: 0,
80
+ conversationName: item.conversation.name,
81
+ ts: item.message.ts,
82
+ });
83
+ }
84
+ } finally {
85
+ reporter.finish();
86
+ }
87
+
88
+ return { deleted, failed };
89
+ }
90
+
91
+ /**
92
+ * @param {Array<import('../types').SlackApiFn>} deleteApis
93
+ * @param {MatchedMessage} item
94
+ * @returns {Promise<any>}
95
+ */
96
+ async function deleteWithFallback(deleteApis, item) {
97
+ for (let i = 0; i < deleteApis.length; i += 1) {
98
+ const api = deleteApis[i];
99
+ const result = await api('chat.delete', {
100
+ channel: item.conversation.id,
101
+ ts: item.message.ts,
102
+ });
103
+
104
+ if (result.ok || i === deleteApis.length - 1) {
105
+ return result;
106
+ }
107
+
108
+ if (!shouldTryFallback(result.error)) {
109
+ return result;
110
+ }
111
+ }
112
+
113
+ return { ok: false, error: 'unknown_error' };
114
+ }
115
+
116
+ /**
117
+ * @param {string | undefined} errorCode
118
+ * @returns {boolean}
119
+ */
120
+ function shouldTryFallback(errorCode) {
121
+ return typeof errorCode === 'string' && PERMISSION_ERRORS.has(errorCode);
122
+ }
123
+
124
+ /**
125
+ * @param {Array<import('../types').SlackApiFn>} apis
126
+ * @returns {{rateLimitRetries: number, totalRetries: number}}
127
+ */
128
+ function getAggregateStats(apis) {
129
+ let rateLimitRetries = 0;
130
+ let totalRetries = 0;
131
+
132
+ for (const api of apis) {
133
+ const stats =
134
+ typeof api.getStats === 'function'
135
+ ? api.getStats()
136
+ : { rateLimitRetries: 0, totalRetries: 0 };
137
+ rateLimitRetries += stats.rateLimitRetries || 0;
138
+ totalRetries += stats.totalRetries || 0;
139
+ }
140
+
141
+ return { rateLimitRetries, totalRetries };
142
+ }
143
+
144
+ module.exports = {
145
+ collectMatches,
146
+ deleteMatches,
147
+ shouldTryFallback,
148
+ getAggregateStats,
149
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @param {string[]} filters
3
+ * @param {Map<string, {id: string, name: string}>} users
4
+ * @returns {Map<string, string>}
5
+ */
6
+ function resolveUserFilters(filters, users) {
7
+ const result = new Map();
8
+
9
+ for (const filter of filters) {
10
+ if (filter.startsWith('U')) {
11
+ result.set(filter, filter);
12
+ continue;
13
+ }
14
+
15
+ const user = users.get(filter.toLowerCase());
16
+ if (!user) {
17
+ throw new Error(`Unknown user filter: ${filter}`);
18
+ }
19
+
20
+ result.set(user.id, user.id);
21
+ }
22
+
23
+ return result;
24
+ }
25
+
26
+ module.exports = {
27
+ resolveUserFilters,
28
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @param {import('../types').ParsedArgs} args
3
+ * @param {Map<string, string>} userFilterIds
4
+ * @returns {(message: any) => boolean}
5
+ */
6
+ function createMessageMatcher(args, userFilterIds) {
7
+ const regex = args.pattern ? new RegExp(args.pattern) : null;
8
+
9
+ return (message) => {
10
+ if (!message || typeof message !== 'object') return false;
11
+ if (message.subtype === 'message_deleted') return false;
12
+ if (!message.ts) return false;
13
+
14
+ if (args.messageType === 'bot') {
15
+ const isBot = message.subtype === 'bot_message' || Boolean(message.bot_id);
16
+ if (!isBot) return false;
17
+ }
18
+
19
+ if (args.messageType === 'user') {
20
+ const isUser = Boolean(message.user);
21
+ if (!isUser) return false;
22
+ }
23
+
24
+ if (userFilterIds.size > 0) {
25
+ if (!message.user || !userFilterIds.has(message.user)) {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ if (regex) {
31
+ const searchableText = extractSearchableMessageText(message);
32
+ if (!regex.test(searchableText)) {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ return true;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Builds searchable text from message text plus attachments and block structures.
43
+ * @param {any} message
44
+ * @returns {string}
45
+ */
46
+ function extractSearchableMessageText(message) {
47
+ const parts = [];
48
+ const seen = new Set();
49
+
50
+ pushText(parts, seen, message?.text);
51
+ collectTextLikeValues(parts, seen, message?.attachments);
52
+ collectTextLikeValues(parts, seen, message?.blocks);
53
+ collectTextLikeValues(parts, seen, message?.files);
54
+ collectTextLikeValues(parts, seen, message?.metadata);
55
+
56
+ return parts.join('\n');
57
+ }
58
+
59
+ /**
60
+ * Recursively collects string values from text-like keys.
61
+ * @param {string[]} parts
62
+ * @param {Set<string>} seen
63
+ * @param {any} value
64
+ * @param {number} depth
65
+ */
66
+ function collectTextLikeValues(parts, seen, value, depth = 0) {
67
+ if (depth > 12 || value === null || value === undefined) {
68
+ return;
69
+ }
70
+
71
+ if (typeof value === 'string') {
72
+ pushText(parts, seen, value);
73
+ return;
74
+ }
75
+
76
+ if (Array.isArray(value)) {
77
+ for (const item of value) {
78
+ collectTextLikeValues(parts, seen, item, depth + 1);
79
+ }
80
+ return;
81
+ }
82
+
83
+ if (typeof value !== 'object') {
84
+ return;
85
+ }
86
+
87
+ for (const [key, nested] of Object.entries(value)) {
88
+ if (typeof nested === 'string' && isTextLikeKey(key)) {
89
+ pushText(parts, seen, nested);
90
+ continue;
91
+ }
92
+
93
+ if (typeof nested === 'object' && nested !== null) {
94
+ collectTextLikeValues(parts, seen, nested, depth + 1);
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @param {string} key
101
+ * @returns {boolean}
102
+ */
103
+ function isTextLikeKey(key) {
104
+ return (
105
+ key === 'text' ||
106
+ key === 'title' ||
107
+ key === 'value' ||
108
+ key === 'fallback' ||
109
+ key === 'pretext' ||
110
+ key === 'alt_text' ||
111
+ key === 'plain_text' ||
112
+ key.endsWith('_text')
113
+ );
114
+ }
115
+
116
+ /**
117
+ * @param {string[]} parts
118
+ * @param {Set<string>} seen
119
+ * @param {any} value
120
+ */
121
+ function pushText(parts, seen, value) {
122
+ if (typeof value !== 'string') {
123
+ return;
124
+ }
125
+
126
+ const normalized = value.trim();
127
+ if (!normalized || seen.has(normalized)) {
128
+ return;
129
+ }
130
+
131
+ parts.push(normalized);
132
+ seen.add(normalized);
133
+ }
134
+
135
+ module.exports = {
136
+ createMessageMatcher,
137
+ extractSearchableMessageText,
138
+ collectTextLikeValues,
139
+ isTextLikeKey,
140
+ pushText,
141
+ };
@@ -0,0 +1,24 @@
1
+ const { formatSlackTs } = require('../utils/time');
2
+
3
+ /**
4
+ * @param {Array<{conversation: {id: string, name: string}, message: any}>} matches
5
+ * @param {number} limit
6
+ */
7
+ function printPreview(matches, limit) {
8
+ const preview = limit > 0 ? matches.slice(0, limit) : matches;
9
+
10
+ for (const item of preview) {
11
+ const text = String(item.message.text || '')
12
+ .replace(/\s+/g, ' ')
13
+ .slice(0, 120);
14
+ console.log(`- ${item.conversation.name} | ${formatSlackTs(item.message.ts)} | ${text}`);
15
+ }
16
+
17
+ if (preview.length < matches.length) {
18
+ console.log(`... ${matches.length - preview.length} additional matches not shown`);
19
+ }
20
+ }
21
+
22
+ module.exports = {
23
+ printPreview,
24
+ };
@@ -0,0 +1,106 @@
1
+ const { formatSlackTs } = require('../utils/time');
2
+
3
+ /**
4
+ * Creates a live progress reporter for deletion runs.
5
+ * @param {number} total
6
+ * @returns {import('../types').DeletionReporter}
7
+ */
8
+ function createDeletionProgressReporter(total) {
9
+ const useLiveLine = Boolean(process.stdout.isTTY);
10
+ const startedAt = Date.now();
11
+ let lineLength = 0;
12
+ let latestState = {
13
+ processed: 0,
14
+ deleted: 0,
15
+ failed: 0,
16
+ retryRateLimit: 0,
17
+ retryTotal: 0,
18
+ retryWaitSeconds: 0,
19
+ conversationName: '',
20
+ ts: '',
21
+ };
22
+
23
+ /**
24
+ * @param {import('../types').DeletionProgressState} state
25
+ * @returns {string}
26
+ */
27
+ function renderLine(state) {
28
+ const elapsedSec = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
29
+ const rate = (state.processed / elapsedSec).toFixed(1);
30
+ const percent = ((state.processed / total) * 100).toFixed(1);
31
+ const last =
32
+ state.processed > 0 ? ` last=${state.conversationName}@${formatSlackTs(state.ts)}` : '';
33
+ const wait = state.retryWaitSeconds > 0 ? ` wait=${state.retryWaitSeconds}s` : '';
34
+ return `Deleting ${state.processed}/${total} (${percent}%) ok=${state.deleted} fail=${state.failed} retries_total=${state.retryTotal} retries_rate_limit=${state.retryRateLimit}${wait} rate=${rate}/s${last}`;
35
+ }
36
+
37
+ return {
38
+ start() {
39
+ if (!useLiveLine) {
40
+ console.log(`Deleting ${total} message(s)...`);
41
+ }
42
+ },
43
+ /**
44
+ * @param {import('../types').DeletionProgressState} state
45
+ */
46
+ update(state) {
47
+ latestState = {
48
+ ...latestState,
49
+ ...state,
50
+ };
51
+ const line = renderLine(latestState);
52
+
53
+ if (useLiveLine) {
54
+ const padding = Math.max(0, lineLength - line.length);
55
+ process.stdout.write(`\r${line}${' '.repeat(padding)}`);
56
+ lineLength = line.length;
57
+ return;
58
+ }
59
+
60
+ if (state.processed === total || state.processed === 1 || state.processed % 25 === 0) {
61
+ console.log(line);
62
+ }
63
+ },
64
+ /**
65
+ * @param {string} message
66
+ */
67
+ log(message) {
68
+ if (useLiveLine) {
69
+ process.stdout.write('\n');
70
+ lineLength = 0;
71
+ }
72
+ console.error(message);
73
+ if (useLiveLine && latestState.processed > 0) {
74
+ const line = renderLine(latestState);
75
+ process.stdout.write(line);
76
+ lineLength = line.length;
77
+ }
78
+ },
79
+ /**
80
+ * @param {number} seconds
81
+ */
82
+ setRetryWaitSeconds(seconds) {
83
+ latestState = {
84
+ ...latestState,
85
+ retryWaitSeconds: Math.max(0, Math.floor(seconds)),
86
+ };
87
+ const line = renderLine(latestState);
88
+
89
+ if (useLiveLine) {
90
+ const padding = Math.max(0, lineLength - line.length);
91
+ process.stdout.write(`\r${line}${' '.repeat(padding)}`);
92
+ lineLength = line.length;
93
+ return;
94
+ }
95
+ },
96
+ finish() {
97
+ if (useLiveLine) {
98
+ process.stdout.write('\n');
99
+ }
100
+ },
101
+ };
102
+ }
103
+
104
+ module.exports = {
105
+ createDeletionProgressReporter,
106
+ };
@@ -0,0 +1,169 @@
1
+ const { sleep } = require('../utils/time');
2
+
3
+ const SLACK_API_BASE = 'https://slack.com/api';
4
+ const DEFAULT_BACKOFF_MS = 500;
5
+ const MAX_BACKOFF_MS = 30000;
6
+
7
+ /**
8
+ * @param {string} token
9
+ * @param {{minRequestIntervalMs: number, maxRetries: number, verbose: boolean}} options
10
+ * @returns {import('../types').SlackApiFn & {getStats: () => {rateLimitRetries: number, totalRetries: number}, setRetryObserver: (observer: ((seconds: number) => void) | null | undefined) => void}}
11
+ */
12
+ function createSlackApi(token, options) {
13
+ let nextRequestAt = 0;
14
+ let rateLimitRetries = 0;
15
+ let totalRetries = 0;
16
+ /** @type {((seconds: number) => void) | null} */
17
+ let retryObserver = null;
18
+
19
+ /**
20
+ * @param {number} attempt
21
+ * @returns {number}
22
+ */
23
+ function computeBackoffMs(attempt) {
24
+ const base = DEFAULT_BACKOFF_MS * 2 ** Math.max(0, attempt - 1);
25
+ const jitter = Math.floor(Math.random() * 200);
26
+ return Math.min(MAX_BACKOFF_MS, base + jitter);
27
+ }
28
+
29
+ /**
30
+ * Sleeps while reporting countdown seconds for retry wait windows.
31
+ * @param {number} waitMs
32
+ * @returns {Promise<void>}
33
+ */
34
+ async function waitWithRetryObserver(waitMs) {
35
+ let remainingMs = Math.max(0, Math.floor(waitMs));
36
+
37
+ while (remainingMs > 0) {
38
+ if (retryObserver) {
39
+ retryObserver(Math.ceil(remainingMs / 1000));
40
+ }
41
+ const chunkMs = Math.min(1000, remainingMs);
42
+ await sleep(chunkMs);
43
+ remainingMs -= chunkMs;
44
+ }
45
+
46
+ if (retryObserver) {
47
+ retryObserver(0);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Writes verbose retry logs without clobbering live progress lines.
53
+ * @param {string} message
54
+ */
55
+ function logVerboseRetry(message) {
56
+ if (!options.verbose) {
57
+ return;
58
+ }
59
+
60
+ if (retryObserver && process.stdout.isTTY) {
61
+ if (typeof process.stdout.clearLine === 'function') {
62
+ process.stdout.clearLine(0);
63
+ }
64
+ if (typeof process.stdout.cursorTo === 'function') {
65
+ process.stdout.cursorTo(0);
66
+ } else {
67
+ process.stdout.write('\r');
68
+ }
69
+ }
70
+
71
+ console.log(message);
72
+ }
73
+
74
+ /**
75
+ * @param {string} method
76
+ * @param {Record<string, string>} [params]
77
+ */
78
+ const api = async (method, params = {}) => {
79
+ for (let attempt = 0; attempt <= options.maxRetries; attempt += 1) {
80
+ const now = Date.now();
81
+ if (nextRequestAt > now) {
82
+ await sleep(nextRequestAt - now);
83
+ }
84
+
85
+ const url = `${SLACK_API_BASE}/${method}`;
86
+ const body = new URLSearchParams();
87
+ for (const [key, value] of Object.entries(params)) {
88
+ if (value !== undefined && value !== null && value !== '') {
89
+ body.set(key, value);
90
+ }
91
+ }
92
+
93
+ let response;
94
+ try {
95
+ response = await fetch(url, {
96
+ method: 'POST',
97
+ headers: {
98
+ Authorization: `Bearer ${token}`,
99
+ 'Content-Type': 'application/x-www-form-urlencoded',
100
+ },
101
+ body,
102
+ });
103
+ } catch (error) {
104
+ if (attempt >= options.maxRetries) {
105
+ throw error;
106
+ }
107
+
108
+ totalRetries += 1;
109
+ const backoffMs = computeBackoffMs(attempt + 1);
110
+ logVerboseRetry(`Retrying ${method} after network error in ${backoffMs}ms`);
111
+ await waitWithRetryObserver(backoffMs);
112
+ continue;
113
+ }
114
+
115
+ if (response.status === 429) {
116
+ if (attempt >= options.maxRetries) {
117
+ throw new Error(`Slack API rate limit exceeded for ${method} after retries`);
118
+ }
119
+
120
+ totalRetries += 1;
121
+ rateLimitRetries += 1;
122
+ const retryAfterHeader = response.headers.get('retry-after');
123
+ const retryAfterSeconds = Number.parseFloat(retryAfterHeader || '');
124
+ const retryAfterMs =
125
+ Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
126
+ ? Math.ceil(retryAfterSeconds * 1000)
127
+ : computeBackoffMs(attempt + 1);
128
+ const jitterMs = Math.floor(Math.random() * 250);
129
+ const waitMs = retryAfterMs + jitterMs;
130
+
131
+ logVerboseRetry(`Slack 429 on ${method}; retrying in ${waitMs}ms`);
132
+ await waitWithRetryObserver(waitMs);
133
+ continue;
134
+ }
135
+
136
+ if (response.status >= 500) {
137
+ if (attempt >= options.maxRetries) {
138
+ throw new Error(`Slack API HTTP ${response.status} on ${method} after retries`);
139
+ }
140
+
141
+ totalRetries += 1;
142
+ const backoffMs = computeBackoffMs(attempt + 1);
143
+ logVerboseRetry(`Slack API HTTP ${response.status} on ${method}; retrying in ${backoffMs}ms`);
144
+ await waitWithRetryObserver(backoffMs);
145
+ continue;
146
+ }
147
+
148
+ if (!response.ok) {
149
+ throw new Error(`Slack API HTTP ${response.status} on ${method}`);
150
+ }
151
+
152
+ nextRequestAt = Date.now() + options.minRequestIntervalMs;
153
+ return response.json();
154
+ }
155
+
156
+ throw new Error(`Slack API call failed for ${method} after retries`);
157
+ };
158
+
159
+ api.getStats = () => ({ rateLimitRetries, totalRetries });
160
+ api.setRetryObserver = (observer) => {
161
+ retryObserver = typeof observer === 'function' ? observer : null;
162
+ };
163
+ return api;
164
+ }
165
+
166
+ module.exports = {
167
+ createSlackApi,
168
+ SLACK_API_BASE,
169
+ };