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 +5 -0
- slackctl/cli.py +218 -0
- slackctl/exporter.py +251 -0
- slackctl/purger.py +198 -0
- slackctl-0.1.0.dist-info/METADATA +66 -0
- slackctl-0.1.0.dist-info/RECORD +9 -0
- slackctl-0.1.0.dist-info/WHEEL +5 -0
- slackctl-0.1.0.dist-info/entry_points.txt +3 -0
- slackctl-0.1.0.dist-info/top_level.txt +1 -0
slackctl/__init__.py
ADDED
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 @@
|
|
|
1
|
+
slackctl
|