slack-sieve/slack_export.py
ilia 4b034d19f6
Some checks failed
CI / skip-ci-check (push) Successful in 4s
CI / secret-scan (push) Failing after 2s
CI / python-ci (push) Successful in 25s
Add triage tests, prepare-export, and UI fixes for Levkin migration.
Cherry-pick heuristics, manifest merge, global file search, scrollbar styling, and pytest/Playwright coverage unblock review and filtered export for mmetl.
2026-06-03 15:42:48 -04:00

610 lines
22 KiB
Python

"""Read a standard Slack workspace export directory."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
DEFAULT_EXPORT = Path.home() / "Downloads" / "slack-export"
@dataclass(frozen=True)
class ExportPaths:
root: Path
channels_json: Path
users_json: Path
manifest_json: Path
def resolve_paths(export_root: str | Path | None = None) -> ExportPaths:
root = Path(export_root or DEFAULT_EXPORT).expanduser().resolve()
tool_dir = Path(__file__).resolve().parent
return ExportPaths(
root=root,
channels_json=root / "channels.json",
users_json=root / "users.json",
manifest_json=tool_dir / "manifest.json",
)
def load_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def slack_ts_to_iso(ts: str | float | None) -> str | None:
if ts is None:
return None
try:
seconds = float(str(ts).split(".")[0])
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
except (TypeError, ValueError):
return None
def user_display(user: dict[str, Any] | None) -> str:
if not user:
return "unknown"
profile = user.get("profile") or {}
return (
profile.get("display_name")
or profile.get("real_name")
or user.get("real_name")
or user.get("name")
or user.get("id")
or "unknown"
)
class SlackExport:
def __init__(self, export_root: str | Path | None = None) -> None:
self.paths = resolve_paths(export_root)
if not self.paths.root.is_dir():
raise FileNotFoundError(f"Slack export not found: {self.paths.root}")
self.channels_meta: list[dict[str, Any]] = []
if self.paths.channels_json.is_file():
self.channels_meta = load_json(self.paths.channels_json)
self.users_raw: list[dict[str, Any]] = []
if self.paths.users_json.is_file():
self.users_raw = load_json(self.paths.users_json)
self.users_by_id = {u["id"]: u for u in self.users_raw if u.get("id")}
self.channel_meta_by_name = {
c["name"]: c for c in self.channels_meta if c.get("name")
}
self._file_index: dict[str, dict[str, Any]] | None = None
@staticmethod
def upload_relpath(file_id: str, file_name: str) -> str:
return f"__uploads/{file_id}/{file_name}"
def local_upload_path(self, file_id: str, file_name: str) -> Path:
return self.paths.root / self.upload_relpath(file_id, file_name)
def _register_file_raw(self, f: dict[str, Any]) -> None:
file_id = f.get("id")
file_name = f.get("name")
if not file_id or not file_name:
return
rel = self.upload_relpath(file_id, file_name)
local = self.paths.root / rel
self._file_index[file_id] = {
"id": file_id,
"name": file_name,
"mimetype": f.get("mimetype"),
"filetype": f.get("filetype"),
"url_private_download": f.get("url_private_download") or f.get("url_private"),
"local_relpath": rel,
"local_exists": local.is_file(),
}
def ensure_file_index(self) -> dict[str, dict[str, Any]]:
if self._file_index is not None:
return self._file_index
self._file_index = {}
canvases_path = self.paths.root / "canvases.json"
if canvases_path.is_file():
data = load_json(canvases_path)
if isinstance(data, list):
for entry in data:
if isinstance(entry, dict):
self._register_file_raw(entry)
for ch_dir in self.channel_dirs():
for json_path in ch_dir.glob("*.json"):
data = load_json(json_path)
if not isinstance(data, list):
continue
for msg in data:
if not isinstance(msg, dict):
continue
for raw_file in msg.get("files") or []:
if isinstance(raw_file, dict):
self._register_file_raw(raw_file)
return self._file_index
def file_record(self, file_id: str) -> dict[str, Any] | None:
return self.ensure_file_index().get(file_id)
def fetch_file_bytes(self, file_id: str) -> tuple[bytes, dict[str, Any]]:
"""Read cached upload or download from Slack using export token in URL."""
import urllib.error
import urllib.request
rec = self.file_record(file_id)
if not rec:
raise FileNotFoundError(f"Unknown file id: {file_id}")
local = self.paths.root / rec["local_relpath"]
if local.is_file():
return local.read_bytes(), rec
url = rec.get("url_private_download")
if not url:
raise FileNotFoundError(f"No download URL for {file_id}")
headers: dict[str, str] = {}
if "token=" in url:
token = url.split("token=", 1)[1].split("&")[0]
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=90) as resp:
data = resp.read()
except urllib.error.HTTPError as exc:
raise FileNotFoundError(
f"Slack download failed ({exc.code}). Export token may be expired."
) from exc
local.parent.mkdir(parents=True, exist_ok=True)
local.write_bytes(data)
rec["local_exists"] = True
return data, rec
def channel_dirs(self) -> list[Path]:
skip = {
"__uploads",
"channels.json",
"users.json",
"canvases.json",
"file_conversations.json",
"huddle_transcripts.json",
"integration_logs.json",
"lists.json",
}
dirs = []
for entry in sorted(self.paths.root.iterdir(), key=lambda p: p.name.lower()):
if entry.is_dir() and entry.name not in skip:
dirs.append(entry)
return dirs
def channel_folder(self, name: str) -> Path | None:
folder = self.paths.root / name
return folder if folder.is_dir() else None
def list_channel_date_files(self, channel_name: str) -> list[str]:
folder = self.channel_folder(channel_name)
if not folder:
return []
return sorted(f.stem for f in folder.glob("*.json"))
def read_messages_file(self, channel_name: str, date: str) -> list[dict[str, Any]]:
folder = self.channel_folder(channel_name)
if not folder:
return []
path = folder / f"{date}.json"
if not path.is_file():
return []
data = load_json(path)
return data if isinstance(data, list) else []
def normalize_message(
self, msg: dict[str, Any], channel_name: str, date_file: str
) -> dict[str, Any]:
user_id = msg.get("user") or msg.get("bot_id")
user = self.users_by_id.get(user_id) if user_id else None
subtype = msg.get("subtype")
text = (msg.get("text") or "").strip()
if subtype == "channel_join" and not text:
who = user_display(user)
text = f"{who} joined the channel"
elif subtype == "channel_leave" and not text:
who = user_display(user)
text = f"{who} left the channel"
elif subtype == "channel_topic" and msg.get("topic"):
text = f"set topic: {msg['topic']}"
elif subtype == "channel_purpose" and msg.get("purpose"):
text = f"set purpose: {msg['purpose']}"
elif subtype == "file_share" and msg.get("files"):
names = ", ".join(f.get("name", "file") for f in msg["files"])
text = text or f"shared file(s): {names}"
elif not text and msg.get("attachments"):
text = "[attachment]"
elif not text and msg.get("blocks"):
text = "[rich message]"
files = []
for f in msg.get("files") or []:
file_id = f.get("id")
file_name = f.get("name")
rel = (
self.upload_relpath(file_id, file_name)
if file_id and file_name
else None
)
local_exists = False
if rel:
local_exists = (self.paths.root / rel).is_file()
files.append(
{
"id": file_id,
"name": file_name,
"mimetype": f.get("mimetype"),
"filetype": f.get("filetype"),
"url_private": f.get("url_private"),
"url_private_download": f.get("url_private_download"),
"local_relpath": rel,
"local_exists": local_exists,
}
)
ts = msg.get("ts")
thread_ts = msg.get("thread_ts")
is_thread_reply = bool(
thread_ts and ts and str(thread_ts) != str(ts)
)
thread_root_ts = str(thread_ts) if thread_ts else str(ts or "")
return {
"id": f"{channel_name}:{ts}",
"channel": channel_name,
"date_file": date_file,
"ts": ts,
"time": slack_ts_to_iso(ts),
"user_id": user_id,
"user_name": user_display(user),
"username": (user or {}).get("name"),
"is_bot": bool((user or {}).get("is_bot")) or bool(msg.get("bot_id")),
"subtype": subtype,
"type": msg.get("type"),
"text": text,
"thread_ts": thread_ts,
"thread_root_ts": thread_root_ts,
"is_thread_reply": is_thread_reply,
"is_thread_parent": bool(msg.get("reply_count")),
"reply_count": msg.get("reply_count") or 0,
"files": files,
"reactions": msg.get("reactions") or [],
"raw_has_blocks": bool(msg.get("blocks")),
}
def attach_thread_reply_counts(
self, messages: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""Add thread_reply_count on parent messages from export data."""
counts: dict[str, int] = {}
for msg in messages:
if msg.get("is_thread_reply"):
root = str(msg.get("thread_root_ts", ""))
counts[root] = counts.get(root, 0) + 1
for msg in messages:
ts = str(msg.get("ts", ""))
if msg.get("is_thread_parent") or counts.get(ts, 0) > 0:
msg["thread_reply_count"] = max(
int(msg.get("reply_count") or 0),
counts.get(ts, 0),
)
else:
msg["thread_reply_count"] = 0
return messages
def channel_stats(self, channel_name: str) -> dict[str, Any]:
folder = self.channel_folder(channel_name)
meta = self.channel_meta_by_name.get(channel_name, {})
if not folder:
return {
"name": channel_name,
"exists_in_export": False,
"message_count": 0,
"file_count": 0,
"first_date": None,
"last_date": None,
"is_archived": meta.get("is_archived", False),
"is_general": meta.get("is_general", False),
"purpose": (meta.get("purpose") or {}).get("value", ""),
"topic": (meta.get("topic") or {}).get("value", ""),
"member_count": len(meta.get("members") or []),
"slack_id": meta.get("id"),
}
dates = self.list_channel_date_files(channel_name)
message_count = 0
for date in dates:
message_count += len(self.read_messages_file(channel_name, date))
return {
"name": channel_name,
"exists_in_export": True,
"message_count": message_count,
"file_count": len(dates),
"first_date": dates[0] if dates else None,
"last_date": dates[-1] if dates else None,
"is_archived": meta.get("is_archived", False),
"is_general": meta.get("is_general", False),
"purpose": (meta.get("purpose") or {}).get("value", ""),
"topic": (meta.get("topic") or {}).get("value", ""),
"member_count": len(meta.get("members") or []),
"slack_id": meta.get("id"),
}
def build_summary(self) -> dict[str, Any]:
channels = []
total_messages = 0
for folder in self.channel_dirs():
stats = self.channel_stats(folder.name)
channels.append(stats)
total_messages += stats["message_count"]
users = []
for user in self.users_raw:
users.append(
{
"id": user.get("id"),
"name": user.get("name"),
"real_name": user_display(user),
"is_bot": bool(user.get("is_bot")),
"deleted": bool(user.get("deleted")),
"is_admin": bool(user.get("is_admin")),
}
)
return {
"export_path": str(self.paths.root),
"channel_count": len(channels),
"user_count": len(users),
"total_messages": total_messages,
"channels": channels,
"users": users,
}
@staticmethod
def classify_file(file: dict[str, Any]) -> str:
"""Rough file bucket for search/filter UI."""
mt = (file.get("mimetype") or "").lower()
name = (file.get("name") or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
if mt.startswith("image/") or ext in {
"png",
"jpg",
"jpeg",
"gif",
"webp",
"bmp",
"svg",
"heic",
}:
return "image"
if ext == "pdf" or mt == "application/pdf":
return "pdf"
if ext in {"doc", "docx"} or "word" in mt:
return "doc"
if ext in {"xls", "xlsx", "csv"} or "spreadsheet" in mt or "excel" in mt:
return "spreadsheet"
if ext in {"ppt", "pptx"}:
return "slides"
if ext in {"zip", "rar", "7z", "gz"}:
return "archive"
if ext in {"mp4", "mov", "webm", "avi"} or mt.startswith("video/"):
return "video"
if ext in {"mp3", "m4a", "wav"} or mt.startswith("audio/"):
return "audio"
if ext in {"txt", "md", "log", "json"} or mt.startswith("text/"):
return "text"
return "other"
def message_file_kinds(self, msg: dict[str, Any]) -> set[str]:
return {self.classify_file(f) for f in msg.get("files") or []}
def search_files_global(
self,
query: str = "",
*,
file_type: str = "all",
limit: int = 80,
) -> dict[str, Any]:
"""Find messages with attachments across all channels."""
q = query.strip()
pattern = re.compile(re.escape(q), re.IGNORECASE) if q else None
type_filter = (file_type or "all").strip().lower()
hits: list[dict[str, Any]] = []
for folder in self.channel_dirs():
if len(hits) >= limit:
break
channel_name = folder.name
for date in self.list_channel_date_files(channel_name):
if len(hits) >= limit:
break
for raw in self.read_messages_file(channel_name, date):
normalized = self.normalize_message(raw, channel_name, date)
files = normalized.get("files") or []
if not files:
continue
kinds = self.message_file_kinds(normalized)
if type_filter != "all" and type_filter not in kinds:
continue
if pattern:
hay = " ".join(
[
normalized.get("text") or "",
normalized.get("user_name") or "",
channel_name,
" ".join(f.get("name") or "" for f in files),
]
)
if not pattern.search(hay):
continue
matching = files
if type_filter != "all":
matching = [
f
for f in files
if self.classify_file(f) == type_filter
]
hits.append(
{
**normalized,
"matching_files": matching,
"file_kinds": sorted(kinds),
}
)
if len(hits) >= limit:
break
return {
"query": q,
"file_type": type_filter,
"hits": hits,
"total": len(hits),
}
def search_channels(self, query: str) -> list[dict[str, Any]]:
q = query.strip().lower()
summary = self.build_summary()["channels"]
if not q:
return summary
return [
c
for c in summary
if q in c["name"].lower()
or q in (c.get("purpose") or "").lower()
or q in (c.get("topic") or "").lower()
]
def iter_channel_messages(self, channel_name: str) -> list[dict[str, Any]]:
"""All messages in a channel, oldest first."""
messages: list[dict[str, Any]] = []
for date in self.list_channel_date_files(channel_name):
for raw in self.read_messages_file(channel_name, date):
messages.append(self.normalize_message(raw, channel_name, date))
def sort_key(msg: dict[str, Any]) -> float:
try:
return float(str(msg.get("ts", "0")).split(".")[0])
except (TypeError, ValueError):
return 0.0
messages.sort(key=sort_key)
return self.attach_thread_reply_counts(messages)
def channel_messages_all(self, channel_name: str) -> dict[str, Any]:
messages = self.iter_channel_messages(channel_name)
thread_parents = sum(
1 for m in messages if m.get("is_thread_parent") or m.get("thread_reply_count")
)
with_files = sum(1 for m in messages if m.get("files"))
return {
"channel": channel_name,
"messages": messages,
"total": len(messages),
"thread_parents": thread_parents,
"messages_with_files": with_files,
}
def search_global(
self, query: str, *, limit: int = 80
) -> dict[str, Any]:
pattern = re.compile(re.escape(query.strip()), re.IGNORECASE)
if not query.strip():
return {"query": query, "channels": [], "messages": []}
channel_hits: list[dict[str, Any]] = []
message_hits: list[dict[str, Any]] = []
for ch in self.build_summary()["channels"]:
name = ch["name"]
if pattern.search(name) or pattern.search(ch.get("purpose") or ""):
channel_hits.append(ch)
for folder in self.channel_dirs():
if len(message_hits) >= limit:
break
name = folder.name
for date in self.list_channel_date_files(name):
if len(message_hits) >= limit:
break
for raw in self.read_messages_file(name, date):
normalized = self.normalize_message(raw, name, date)
if pattern.search(normalized.get("text") or ""):
message_hits.append(normalized)
if len(message_hits) >= limit:
break
return {
"query": query,
"channels": channel_hits[:30],
"messages": message_hits,
}
def channel_messages_page(
self,
channel_name: str,
*,
date: str | None = None,
offset: int = 0,
limit: int = 100,
) -> dict[str, Any]:
dates = self.list_channel_date_files(channel_name)
if not dates:
return {
"channel": channel_name,
"dates": [],
"date": None,
"messages": [],
"offset": 0,
"limit": limit,
"total_in_date": 0,
"has_more": False,
}
active_date = date if date in dates else dates[-1]
raw = self.read_messages_file(channel_name, active_date)
normalized = [
self.normalize_message(m, channel_name, active_date) for m in raw
]
page = normalized[offset : offset + limit]
return {
"channel": channel_name,
"dates": dates,
"date": active_date,
"messages": page,
"offset": offset,
"limit": limit,
"total_in_date": len(normalized),
"has_more": offset + limit < len(normalized),
}
def search_messages(
self, channel_name: str, query: str, *, limit: int = 50
) -> list[dict[str, Any]]:
pattern = re.compile(re.escape(query.strip()), re.IGNORECASE)
if not query.strip():
return []
hits: list[dict[str, Any]] = []
for date in reversed(self.list_channel_date_files(channel_name)):
for msg in self.read_messages_file(channel_name, date):
normalized = self.normalize_message(msg, channel_name, date)
if pattern.search(normalized.get("text") or ""):
hits.append(normalized)
if len(hits) >= limit:
return hits
return hits