slackctl 0.1.0__py3-none-any.whl

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.
slackctl/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ slack-wipe: A CLI tool to purge your Slack message history.
3
+ """
4
+
5
+ __version__ = "0.1.0"
slackctl/cli.py ADDED
@@ -0,0 +1,218 @@
1
+ import os
2
+ from pathlib import Path
3
+ import logging
4
+ import sys
5
+ import click
6
+
7
+ from slackctl.purger import SlackPurger
8
+ from slackctl.exporter import SlackExporter
9
+
10
+ def load_dotenv(dotenv_path: str = ".env") -> None:
11
+ """Load environment variables from a .env file if it exists."""
12
+ path = Path(dotenv_path)
13
+ if not path.is_file():
14
+ return
15
+ try:
16
+ with open(path, "r", encoding="utf-8") as f:
17
+ for line in f:
18
+ line = line.strip()
19
+ if not line or line.startswith("#"):
20
+ continue
21
+ if "=" in line:
22
+ key, val = line.split("=", 1)
23
+ key = key.strip()
24
+ val = val.strip()
25
+ if val.startswith('"'):
26
+ end_idx = val.find('"', 1)
27
+ if end_idx != -1:
28
+ val = val[1:end_idx]
29
+ elif val.startswith("'"):
30
+ end_idx = val.find("'", 1)
31
+ if end_idx != -1:
32
+ val = val[1:end_idx]
33
+ else:
34
+ if " #" in val:
35
+ val = val.split(" #", 1)[0].strip()
36
+ elif val.startswith("#"):
37
+ val = ""
38
+ if key and key not in os.environ:
39
+ os.environ[key] = val
40
+ except Exception:
41
+ pass
42
+
43
+ load_dotenv()
44
+
45
+ def setup_logging(log_file: str, verbose: bool) -> logging.Logger:
46
+ """Set up the application logger with console and file destinations."""
47
+ logger = logging.getLogger("slackctl")
48
+ logger.setLevel(logging.DEBUG)
49
+ logger.propagate = False
50
+
51
+ logger.handlers.clear()
52
+
53
+ console_level = logging.DEBUG if verbose else logging.INFO
54
+ console_handler = logging.StreamHandler(sys.stdout)
55
+ console_handler.setLevel(console_level)
56
+ console_formatter = logging.Formatter("[%(levelname)s] %(message)s")
57
+ console_handler.setFormatter(console_formatter)
58
+ logger.addHandler(console_handler)
59
+
60
+ if log_file:
61
+ try:
62
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
63
+ file_handler.setLevel(logging.DEBUG)
64
+ file_formatter = logging.Formatter(
65
+ "%(asctime)s [%(levelname)s] [%(name)s:%(lineno)d] %(message)s"
66
+ )
67
+ file_handler.setFormatter(file_formatter)
68
+ logger.addHandler(file_handler)
69
+ except Exception as e:
70
+ click.echo(f"Warning: Could not configure log file {log_file}: {e}", err=True)
71
+
72
+ return logger
73
+
74
+ @click.group()
75
+ def main():
76
+ """slackctl: A collection of handy CLI utilities for Slack."""
77
+ pass
78
+
79
+ @main.command("purge")
80
+ @click.option(
81
+ "--token", "-t",
82
+ envvar="SLACK_TOKEN",
83
+ help="Slack User OAuth Token (starts with xoxp-). Can also be set via SLACK_TOKEN environment variable.",
84
+ )
85
+ @click.option(
86
+ "--channel", "-c",
87
+ envvar="SLACK_CHANNEL",
88
+ help="Target Slack Channel ID. Can also be set via SLACK_CHANNEL environment variable.",
89
+ )
90
+ @click.option(
91
+ "--dry-run/--no-dry-run",
92
+ default=True,
93
+ show_default=True,
94
+ help="Dry run mode (list messages that would be deleted, without actually deleting them).",
95
+ )
96
+ @click.option(
97
+ "--yes", "-y",
98
+ is_flag=True,
99
+ help="Skip safety confirmation prompt when running in live mode (--no-dry-run).",
100
+ )
101
+ @click.option(
102
+ "--limit", "-l",
103
+ default=200,
104
+ show_default=True,
105
+ help="Max messages to request per fetch request.",
106
+ )
107
+ @click.option(
108
+ "--sleep", "-s",
109
+ default=1.2,
110
+ show_default=True,
111
+ help="Delay in seconds between deletion requests (Slack rate limit is approx 1 req/sec).",
112
+ )
113
+ @click.option(
114
+ "--log-file",
115
+ default="slack_wipe.log",
116
+ show_default=True,
117
+ help="Path to file where detailed logs will be written.",
118
+ )
119
+ @click.option(
120
+ "--verbose", "-v",
121
+ is_flag=True,
122
+ help="Show verbose output in the terminal (including reasons for skipped messages).",
123
+ )
124
+ def purge(token, channel, dry_run, yes, limit, sleep, log_file, verbose):
125
+ """Safely bulk-delete your messages from a Slack channel."""
126
+ logger = setup_logging(log_file, verbose)
127
+
128
+ if not token:
129
+ logger.error("Slack Token is required. Use --token/-t or set SLACK_TOKEN environment variable.")
130
+ sys.exit(1)
131
+
132
+ if not channel:
133
+ logger.error("Slack Channel ID is required. Use --channel/-c or set SLACK_CHANNEL environment variable.")
134
+ sys.exit(1)
135
+
136
+ if not dry_run and not yes:
137
+ msg = f"WARNING: You are about to permanently delete your messages in channel {channel}. This cannot be undone! Continue?"
138
+ if not click.confirm(msg, default=False):
139
+ logger.info("Purge cancelled by user.")
140
+ sys.exit(0)
141
+
142
+ purger = SlackPurger(
143
+ token=token,
144
+ channel_id=channel,
145
+ dry_run=dry_run,
146
+ rate_limit_sleep=sleep,
147
+ logger=logger
148
+ )
149
+
150
+ try:
151
+ purger.purge(limit=limit)
152
+ except Exception as e:
153
+ logger.critical(f"Execution failed: {e}")
154
+ sys.exit(2)
155
+
156
+ @main.command("export")
157
+ @click.option(
158
+ "--token", "-t",
159
+ envvar="SLACK_TOKEN",
160
+ help="Slack User OAuth Token (starts with xoxp-). Can also be set via SLACK_TOKEN environment variable.",
161
+ )
162
+ @click.option(
163
+ "--channel", "-c",
164
+ envvar="SLACK_CHANNEL",
165
+ help="Optional target Slack Channel ID. If omitted, exports every message you ever sent across all channels.",
166
+ )
167
+ @click.option(
168
+ "--output", "-o",
169
+ default="slack_export.html",
170
+ show_default=True,
171
+ help="Path to output file where the export will be saved.",
172
+ )
173
+ @click.option(
174
+ "--format", "-f",
175
+ type=click.Choice(["html", "json", "markdown"], case_sensitive=False),
176
+ default="html",
177
+ show_default=True,
178
+ help="Export output format: 'html' (interactive dashboard), 'json' (raw data), 'markdown' (readable log).",
179
+ )
180
+ @click.option(
181
+ "--log-file",
182
+ default="slack_export.log",
183
+ show_default=True,
184
+ help="Path to file where detailed logs will be written.",
185
+ )
186
+ @click.option(
187
+ "--verbose", "-v",
188
+ is_flag=True,
189
+ help="Show verbose output in the terminal.",
190
+ )
191
+ def export(token, channel, output, format, log_file, verbose):
192
+ """Export all your messages in a specific channel or across all Slack channels."""
193
+ logger = setup_logging(log_file, verbose)
194
+
195
+ if not token:
196
+ logger.error("Slack Token is required. Use --token/-t or set SLACK_TOKEN environment variable.")
197
+ sys.exit(1)
198
+
199
+ exporter = SlackExporter(
200
+ token=token,
201
+ channel_id=channel,
202
+ logger=logger
203
+ )
204
+
205
+ try:
206
+ exporter.export(output_path=output, format_type=format)
207
+ except Exception as e:
208
+ logger.critical(f"Export failed: {e}")
209
+ sys.exit(2)
210
+
211
+ def wipe_compat():
212
+ """Compatibility alias to run 'purge' directly when 'slack-wipe' is called."""
213
+ # Inject 'purge' into argv so Click routes to the purge command
214
+ sys.argv.insert(1, "purge")
215
+ main()
216
+
217
+ if __name__ == "__main__":
218
+ main()
slackctl/exporter.py ADDED
@@ -0,0 +1,251 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ import datetime
5
+ from pathlib import Path
6
+ import requests
7
+
8
+ class SlackExporter:
9
+ """Handles fetching and exporting user messages across all or specific Slack channels."""
10
+
11
+ def __init__(
12
+ self,
13
+ token: str,
14
+ channel_id: str | None = None,
15
+ logger: logging.Logger | None = None
16
+ ):
17
+ self.token = token
18
+ self.channel_id = channel_id
19
+ self.headers = {"Authorization": f"Bearer {token}"}
20
+ self.logger = logger or logging.getLogger(__name__)
21
+ self.user_id = None
22
+ self.username = None
23
+
24
+ def _request_with_retry(self, method: str, url: str, **kwargs) -> dict:
25
+ """Helper to send HTTP requests with support for automatic 429 rate limit retries."""
26
+ max_retries = 5
27
+ for attempt in range(max_retries):
28
+ try:
29
+ resp = requests.request(method, url, **kwargs)
30
+
31
+ if resp.status_code == 429:
32
+ retry_after = int(resp.headers.get("Retry-After", 10))
33
+ self.logger.warning(f"Rate limited (HTTP 429). Retrying after {retry_after} seconds...")
34
+ time.sleep(retry_after)
35
+ continue
36
+
37
+ resp.raise_for_status()
38
+ data = resp.json()
39
+
40
+ if not data.get("ok") and data.get("error") == "ratelimited":
41
+ retry_after = int(resp.headers.get("Retry-After", 10))
42
+ self.logger.warning(f"Rate limited (JSON error). Retrying after {retry_after} seconds...")
43
+ time.sleep(retry_after)
44
+ continue
45
+
46
+ return data
47
+ except requests.exceptions.RequestException as e:
48
+ if attempt == max_retries - 1:
49
+ self.logger.error(f"Network error after {max_retries} attempts: {e}")
50
+ raise
51
+ self.logger.warning(f"Network error: {e}. Retrying in 5 seconds...")
52
+ time.sleep(5)
53
+
54
+ def authenticate(self) -> str:
55
+ """Verify credentials and retrieve User ID."""
56
+ self.logger.info("Connecting to Slack API and authenticating...")
57
+ try:
58
+ data = self._request_with_retry("GET", "https://slack.com/api/auth.test", headers=self.headers)
59
+ if not data.get("ok"):
60
+ raise RuntimeError(f"Authentication failed: {data.get('error')}")
61
+
62
+ self.user_id = data["user_id"]
63
+ self.username = data["user"]
64
+ self.logger.info(f"Authenticated successfully as: {self.username} ({self.user_id})")
65
+ return self.user_id
66
+ except Exception as e:
67
+ self.logger.error(f"Error during authentication: {e}")
68
+ raise
69
+
70
+ def fetch_all_messages(self, limit: int = 100) -> list:
71
+ """Fetch all messages sent by this user using the search API."""
72
+ if not self.user_id:
73
+ self.authenticate()
74
+
75
+ query = f"from:<@{self.user_id}>"
76
+ if self.channel_id:
77
+ query += f" in:<#{self.channel_id}>"
78
+ self.logger.info(f"Searching for messages in channel {self.channel_id}...")
79
+ else:
80
+ self.logger.info("Searching for your messages across all accessible channels...")
81
+
82
+ all_messages = []
83
+ page = 1
84
+
85
+ while True:
86
+ self.logger.debug(f"Querying search page {page}...")
87
+ try:
88
+ data = self._request_with_retry(
89
+ "GET",
90
+ "https://slack.com/api/search.messages",
91
+ headers=self.headers,
92
+ params={
93
+ "query": query,
94
+ "count": min(limit, 100),
95
+ "page": page,
96
+ "sort": "timestamp",
97
+ "sort_dir": "asc"
98
+ }
99
+ )
100
+ except Exception as e:
101
+ self.logger.error(f"Failed to query search API: {e}")
102
+ break
103
+
104
+ if not data.get("ok"):
105
+ self.logger.error(f"Slack API search error: {data.get('error')}")
106
+ break
107
+
108
+ messages_data = data.get("messages", {})
109
+ matches = messages_data.get("matches", [])
110
+
111
+ if not matches:
112
+ break
113
+
114
+ for msg in matches:
115
+ channel_info = msg.get("channel", {})
116
+ ch_id = channel_info.get("id") if isinstance(channel_info, dict) else channel_info
117
+ ch_name = channel_info.get("name") if isinstance(channel_info, dict) else ""
118
+
119
+ all_messages.append({
120
+ "ts": msg.get("ts"),
121
+ "text": msg.get("text", ""),
122
+ "channel_id": ch_id,
123
+ "channel_name": ch_name,
124
+ "permalink": msg.get("permalink", ""),
125
+ "username": msg.get("username", self.username),
126
+ "user_id": msg.get("user", self.user_id)
127
+ })
128
+
129
+ self.logger.info(f"Fetched {len(all_messages)} messages so far...")
130
+
131
+ paging = messages_data.get("paging", {})
132
+ total_pages = paging.get("pages", 1)
133
+ if page >= total_pages:
134
+ break
135
+ page += 1
136
+ time.sleep(0.5)
137
+
138
+ self.logger.info(f"Retrieved {len(all_messages)} messages total.")
139
+ return all_messages
140
+
141
+ def export(self, output_path: str, format_type: str) -> None:
142
+ """Run fetch and format/save to output file."""
143
+ messages = self.fetch_all_messages()
144
+
145
+ if format_type.lower() == "json":
146
+ self._export_json(messages, output_path)
147
+ elif format_type.lower() == "markdown":
148
+ self._export_markdown(messages, output_path)
149
+ else:
150
+ self._export_html(messages, output_path)
151
+
152
+ def _export_json(self, messages: list, path: str) -> None:
153
+ with open(path, "w", encoding="utf-8") as f:
154
+ json.dump({
155
+ "meta": {
156
+ "username": self.username,
157
+ "user_id": self.user_id,
158
+ "exported_at": datetime.datetime.now().isoformat(),
159
+ "total_messages": len(messages)
160
+ },
161
+ "messages": messages
162
+ }, f, indent=2, ensure_ascii=False)
163
+ self.logger.info(f"JSON export saved to: {path}")
164
+
165
+ def _export_markdown(self, messages: list, path: str) -> None:
166
+ messages_sorted = sorted(messages, key=lambda x: float(x.get("ts") or 0))
167
+ lines = [
168
+ f"# Slack Message Export for @{self.username}",
169
+ f"- **User ID**: `{self.user_id}`",
170
+ f"- **Export Date**: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
171
+ f"- **Total Messages**: {len(messages)}",
172
+ "",
173
+ "---",
174
+ ""
175
+ ]
176
+
177
+ grouped = {}
178
+ for m in messages_sorted:
179
+ ch = m.get("channel_name") or m.get("channel_id") or "unknown-channel"
180
+ grouped.setdefault(ch, []).append(m)
181
+
182
+ for channel, msgs in sorted(grouped.items()):
183
+ lines.append(f"## Channel: #{channel}")
184
+ for m in msgs:
185
+ try:
186
+ dt = datetime.datetime.fromtimestamp(float(m["ts"])).strftime("%Y-%m-%d %H:%M:%S")
187
+ except:
188
+ dt = m.get("ts")
189
+
190
+ text = m.get("text", "").replace("\n", " ")
191
+ link = f" ([Link]({m['permalink']}))" if m.get("permalink") else ""
192
+ lines.append(f"- **[{dt}]**: {text}{link}")
193
+ lines.append("")
194
+
195
+ with open(path, "w", encoding="utf-8") as f:
196
+ f.write("\n".join(lines))
197
+ self.logger.info(f"Markdown export saved to: {path}")
198
+
199
+ def _export_html(self, messages: list, path: str) -> None:
200
+ messages_sorted = sorted(messages, key=lambda x: float(x.get("ts") or 0))
201
+
202
+ for m in messages_sorted:
203
+ try:
204
+ m["formatted_date"] = datetime.datetime.fromtimestamp(float(m["ts"])).strftime("%Y-%m-%d %H:%M:%S")
205
+ except:
206
+ m["formatted_date"] = m.get("ts", "")
207
+
208
+ channel_counts = {}
209
+ for m in messages_sorted:
210
+ ch_name = m.get("channel_name") or m.get("channel_id") or "unknown"
211
+ channel_counts[ch_name] = channel_counts.get(ch_name, 0) + 1
212
+
213
+ channels_list = [{"name": name, "count": count} for name, count in sorted(channel_counts.items())]
214
+
215
+ html_content = self._get_html_template(messages_sorted, channels_list)
216
+
217
+ with open(path, "w", encoding="utf-8") as f:
218
+ f.write(html_content)
219
+ self.logger.info(f"Beautiful HTML report saved to: {path}")
220
+
221
+ def _get_html_template(self, messages: list, channels: list) -> str:
222
+ messages_json = json.dumps(messages)
223
+ channels_json = json.dumps(channels)
224
+
225
+ templates_dir = Path(__file__).parent / "templates"
226
+ try:
227
+ with open(templates_dir / "index.html", "r", encoding="utf-8") as f:
228
+ index_html = f.read()
229
+ with open(templates_dir / "style.css", "r", encoding="utf-8") as f:
230
+ style_css = f.read()
231
+ with open(templates_dir / "app.js", "r", encoding="utf-8") as f:
232
+ app_js = f.read()
233
+ except Exception as e:
234
+ self.logger.error(f"Failed to load HTML templates from {templates_dir}: {e}")
235
+ raise
236
+
237
+ template = (
238
+ index_html
239
+ .replace("{{css_content}}", style_css)
240
+ .replace("{{js_content}}", app_js)
241
+ )
242
+
243
+ return (
244
+ template
245
+ .replace("{{username}}", self.username or "")
246
+ .replace("{{user_id}}", self.user_id or "")
247
+ .replace("{{messages_count}}", str(len(messages)))
248
+ .replace("{{channels_count}}", str(len(channels)))
249
+ .replace("{{messages_json}}", messages_json)
250
+ .replace("{{channels_json}}", channels_json)
251
+ )
slackctl/purger.py ADDED
@@ -0,0 +1,198 @@
1
+ import time
2
+ import logging
3
+ import requests
4
+
5
+ class SlackPurger:
6
+ """Handles authentication and deletion of user messages in a Slack channel."""
7
+
8
+ def __init__(
9
+ self,
10
+ token: str,
11
+ channel_id: str,
12
+ dry_run: bool = True,
13
+ rate_limit_sleep: float = 1.2,
14
+ logger: logging.Logger | None = None
15
+ ):
16
+ self.token = token
17
+ self.channel_id = channel_id
18
+ self.dry_run = dry_run
19
+ self.rate_limit_sleep = rate_limit_sleep
20
+ self.headers = {"Authorization": f"Bearer {token}"}
21
+ self.logger = logger or logging.getLogger(__name__)
22
+ self.user_id = None
23
+ self.username = None
24
+
25
+ def _request_with_retry(self, method: str, url: str, **kwargs) -> dict:
26
+ max_retries = 5
27
+ for attempt in range(max_retries):
28
+ try:
29
+ resp = requests.request(method, url, **kwargs)
30
+
31
+ if resp.status_code == 429:
32
+ retry_after = int(resp.headers.get("Retry-After", 10))
33
+ self.logger.warning(f"Rate limited (HTTP 429). Retrying after {retry_after} seconds...")
34
+ time.sleep(retry_after)
35
+ continue
36
+
37
+ resp.raise_for_status()
38
+ data = resp.json()
39
+
40
+ if not data.get("ok") and data.get("error") == "ratelimited":
41
+ retry_after = int(resp.headers.get("Retry-After", 10))
42
+ self.logger.warning(f"Rate limited (JSON error). Retrying after {retry_after} seconds...")
43
+ time.sleep(retry_after)
44
+ continue
45
+
46
+ return data
47
+ except requests.exceptions.RequestException as e:
48
+ if attempt == max_retries - 1:
49
+ self.logger.error(f"Network error after {max_retries} attempts: {e}")
50
+ raise
51
+ self.logger.warning(f"Network error: {e}. Retrying in 5 seconds...")
52
+ time.sleep(5)
53
+
54
+ def authenticate(self) -> str:
55
+ self.logger.info("Connecting to Slack API and authenticating...")
56
+ try:
57
+ data = self._request_with_retry("GET", "https://slack.com/api/auth.test", headers=self.headers)
58
+ if not data.get("ok"):
59
+ raise RuntimeError(f"Authentication failed: {data.get('error')}")
60
+
61
+ self.user_id = data["user_id"]
62
+ self.username = data["user"]
63
+ self.logger.info(f"Authenticated successfully as: {self.username} ({self.user_id})")
64
+ return self.user_id
65
+ except Exception as e:
66
+ self.logger.error(f"Error during authentication: {e}")
67
+ raise
68
+
69
+ def delete_message(self, ts: str) -> dict:
70
+ return self._request_with_retry(
71
+ "POST",
72
+ "https://slack.com/api/chat.delete",
73
+ headers=self.headers,
74
+ json={"channel": self.channel_id, "ts": ts},
75
+ )
76
+
77
+ def purge(self, limit: int = 100) -> dict:
78
+ if not self.user_id:
79
+ self.authenticate()
80
+
81
+ self.logger.info(f"Targeting channel: {self.channel_id}")
82
+ self.logger.info(f"Mode: {'DRY RUN (no messages will be deleted)' if self.dry_run else 'LIVE (messages will be permanently deleted)'}")
83
+
84
+ stats = {
85
+ "deleted": 0,
86
+ "skipped": 0,
87
+ "errors": 0
88
+ }
89
+ processed_ts = set()
90
+ page = 1
91
+
92
+ while True:
93
+ query = f"from:<@{self.user_id}> in:<#{self.channel_id}>"
94
+ self.logger.debug(f"Searching Slack messages with query: '{query}' (page: {page})")
95
+
96
+ try:
97
+ data = self._request_with_retry(
98
+ "GET",
99
+ "https://slack.com/api/search.messages",
100
+ headers=self.headers,
101
+ params={
102
+ "query": query,
103
+ "count": min(limit, 100),
104
+ "page": page,
105
+ "sort": "timestamp",
106
+ "sort_dir": "asc"
107
+ }
108
+ )
109
+ except Exception as e:
110
+ self.logger.error(f"Failed to query search API: {e}")
111
+ break
112
+
113
+ if not data.get("ok"):
114
+ error_msg = data.get("error", "unknown error")
115
+ if error_msg == "missing_scope":
116
+ self.logger.error(
117
+ "Slack API error: missing_scope. "
118
+ "The 'search.messages' API requires the 'search:read' User OAuth scope. "
119
+ "Please go to your Slack App console, add 'search:read' user scope under OAuth & Permissions, "
120
+ "reinstall the app to your workspace, and try again."
121
+ )
122
+ else:
123
+ self.logger.error(f"Slack API search error: {error_msg}")
124
+ break
125
+
126
+ messages_data = data.get("messages", {})
127
+ matches = messages_data.get("matches", [])
128
+
129
+ if not matches:
130
+ self.logger.debug("No search matches found.")
131
+ break
132
+
133
+ self.logger.debug(f"Found {len(matches)} search matches on page {page}.")
134
+
135
+ new_items_processed = 0
136
+ successful_deletions = 0
137
+
138
+ for msg in matches:
139
+ ts = msg.get("ts")
140
+ if not ts:
141
+ continue
142
+
143
+ if ts in processed_ts:
144
+ continue
145
+
146
+ processed_ts.add(ts)
147
+ new_items_processed += 1
148
+
149
+ msg_channel = msg.get("channel", {})
150
+ msg_channel_id = msg_channel.get("id") if isinstance(msg_channel, dict) else msg_channel
151
+ if msg_channel_id != self.channel_id:
152
+ self.logger.debug(f"Skipping search result in different channel: {msg_channel_id}")
153
+ stats["skipped"] += 1
154
+ continue
155
+
156
+ preview = msg.get("text", "")[:60].replace("\n", " ")
157
+
158
+ if self.dry_run:
159
+ self.logger.info(f"[DRY RUN] Would delete message [{ts}]: {preview!r}")
160
+ stats["deleted"] += 1
161
+ else:
162
+ self.logger.info(f"Deleting message [{ts}]: {preview!r}")
163
+ try:
164
+ result = self.delete_message(ts)
165
+ if result.get("ok"):
166
+ self.logger.info(f"Successfully deleted message [{ts}]")
167
+ stats["deleted"] += 1
168
+ successful_deletions += 1
169
+ else:
170
+ err = result.get("error", "unknown error")
171
+ self.logger.error(f"Failed to delete message [{ts}]: {err}")
172
+ stats["errors"] += 1
173
+ except Exception as e:
174
+ self.logger.error(f"Exception deleting message [{ts}]: {e}")
175
+ stats["errors"] += 1
176
+
177
+ time.sleep(self.rate_limit_sleep)
178
+
179
+ # Handle pagination when deleting:
180
+ # - Dry run: advance pages normally.
181
+ # - Live run: deleted items disappear from results, shifting future items to page 1.
182
+ # Only advance the page index if we didn't perform any successful deletions on this page.
183
+ if self.dry_run:
184
+ page += 1
185
+ else:
186
+ if successful_deletions == 0:
187
+ page += 1
188
+ else:
189
+ if new_items_processed == 0:
190
+ self.logger.debug("No new unprocessed matches on this page. Stopping.")
191
+ break
192
+
193
+ self.logger.info("Purge process completed.")
194
+ self.logger.info(
195
+ f"Summary - {'Would delete' if self.dry_run else 'Deleted'}: {stats['deleted']}, "
196
+ f"Skipped: {stats['skipped']}, Errors: {stats['errors']}"
197
+ )
198
+ return stats
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: slackctl
3
+ Version: 0.1.0
4
+ Summary: A collection of handy CLI utilities for Slack (purging messages, exporting history, etc.)
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: requests>=2.31.0
8
+ Requires-Dist: click>=8.1.7
9
+
10
+ # slackctl
11
+
12
+ cli tool to clear out your slack messages or export them to a neat dashboard.
13
+
14
+ ## setup
15
+
16
+ ### slack app permissions
17
+ you need to build a quick slack app to get a token:
18
+ 1. head to https://api.slack.com/apps and create a new app from scratch.
19
+ 2. add these user scopes under oauth and permissions:
20
+ - channels:history
21
+ - groups:history
22
+ - chat:write
23
+ - search:read
24
+ 3. click install to workspace and copy the token starting with xoxp-.
25
+
26
+ ### install
27
+ make sure your python venv is active, then run:
28
+ ```bash
29
+ pip install -e .
30
+ ```
31
+
32
+ ## config
33
+ save your credentials in a `.env` file so you don't have to keep pasting them:
34
+ ```env
35
+ SLACK_TOKEN="xoxp-your-token-here"
36
+ SLACK_CHANNEL="your-channel-id-here"
37
+ ```
38
+
39
+ ## commands
40
+
41
+ ### purge
42
+ deletes your messages from the target channel using slack's search api.
43
+
44
+ dry run (safely checks what would get deleted):
45
+ ```bash
46
+ slackctl purge
47
+ ```
48
+
49
+ live run (actually deletes everything):
50
+ ```bash
51
+ slackctl purge --no-dry-run
52
+ ```
53
+
54
+ ### export
55
+ saves your message history to an offline html dashboard or raw data formats.
56
+
57
+ build html dashboard:
58
+ ```bash
59
+ slackctl export
60
+ ```
61
+
62
+ other formats:
63
+ ```bash
64
+ slackctl export --format markdown -o messages.md
65
+ slackctl export --format json -o messages.json
66
+ ```
@@ -0,0 +1,9 @@
1
+ slackctl/__init__.py,sha256=EgozVJyyk1jp16NC64d_jAXS-WVMJ2NuojkNG6BNyrA,91
2
+ slackctl/cli.py,sha256=53W1cLfYJeHLC8Qy6E511CdmiyHOHgURj6GAKHPJYwg,7006
3
+ slackctl/exporter.py,sha256=fkE9Mn2XQnSyFQXnwhjQ46Z7dqgevYRrQsyjPGgcndc,10043
4
+ slackctl/purger.py,sha256=m_pY4m5RKYqyLHjogARmLZTZNvxkSDZW8sXCVlYe9jc,8015
5
+ slackctl-0.1.0.dist-info/METADATA,sha256=HqPpgrkr1yoOCSCIU1zeCJT8gNw8ILApsZ8hVip0CbU,1506
6
+ slackctl-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
+ slackctl-0.1.0.dist-info/entry_points.txt,sha256=mrtQJ6gCMMkZ7OxMCSe02asV83Ty5seDfXar887QtRo,85
8
+ slackctl-0.1.0.dist-info/top_level.txt,sha256=QIwBVORwrdXnzq5pHeErvZFC20NDUe_bEtnacoFMHJo,9
9
+ slackctl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ slack-wipe = slackctl.cli:wipe_compat
3
+ slackctl = slackctl.cli:main
@@ -0,0 +1 @@
1
+ slackctl