Add triage tests, prepare-export, and UI fixes for Levkin migration.
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

Cherry-pick heuristics, manifest merge, global file search, scrollbar styling, and pytest/Playwright coverage unblock review and filtered export for mmetl.
This commit is contained in:
ilia 2026-06-03 15:42:42 -04:00
parent 88b7082f4f
commit 4b034d19f6
48 changed files with 6904 additions and 126 deletions

16
.coveragerc Normal file
View File

@ -0,0 +1,16 @@
[run]
source =
slack_export
server
omit =
tests/*
scripts/*
[report]
exclude_lines =
pragma: no cover
if __name__ == .__main__.:
raise SystemExit
def main
show_missing = True
fail_under = 85

7
.gitignore vendored
View File

@ -1,7 +1,14 @@
manifest.json
prepared-export/
node_modules/
test-results/
playwright-report/
playwright/.cache/
.index-cache.json
__pycache__/
*.pyc
.DS_Store
.venv/
venv/
.coverage
.pytest_cache/

View File

@ -1,4 +1,4 @@
.PHONY: help review hydrate preset-skips
.PHONY: help review hydrate preset-skips test test-unit test-e2e fixture
help: ## Show targets
@grep -E '^[a-zA-Z0-9_-]+:.*##' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@ -13,3 +13,36 @@ hydrate: ## Download attachments from official Slack export URLs
preset-skips: ## Apply presets/levkin-skips.json channel skips to manifest.json
@python3 scripts/preset-skips.py
cherry-pick: ## Reset picks and re-run scripts/auto-cherry-pick.py
@python3 scripts/auto-cherry-pick.py
prepare-export: ## Build prepared-export/ from manifest.json
@python3 scripts/prepare-export.py
fixture: ## Build tests/fixtures/mini-export from your Slack export
@python3 scripts/build-test-fixture.py
test: test-unit test-e2e ## Run 100+ pytest + Playwright tests
test-unit: fixture ## Pytest (export, cherry-pick, API)
@test -d .venv || python3 -m venv .venv
@.venv/bin/pip install -q -r requirements-dev.txt
@.venv/bin/python -m pytest tests/ -v --ignore=tests/e2e --cov --cov-report=term-missing:skip-covered
test-cov: test-unit ## Alias with coverage report
test-e2e: fixture ## Playwright UI tests
@npm install --no-save 2>/dev/null || npm install
@npx playwright install chromium
@SLACK_IMPORT_PORT=18765 npm run test:e2e
test-e2e-headed: fixture ## Playwright with visible browser
@npm install --no-save 2>/dev/null || npm install
@npx playwright install chromium
@SLACK_IMPORT_PORT=18765 npx playwright test --headed
test-e2e-ui: fixture ## Playwright interactive UI runner
@npm install --no-save 2>/dev/null || npm install
@npx playwright install chromium
@SLACK_IMPORT_PORT=18765 npx playwright test --ui

View File

@ -34,6 +34,7 @@ Selections are saved to `manifest.json` (gitignored).
| `make review` | Start the web UI on port 8765 |
| `make hydrate` | Copy export + download attachments via embedded `xoxe` URLs |
| `make preset-skips` | Mark preset channels as skip in `manifest.json` |
| `make prepare-export` | Write `prepared-export/` (filtered Slack shape for mmetl) |
## Environment
@ -58,8 +59,8 @@ Edit `presets/levkin-skips.json` (or copy it) for workspace-specific channels to
## Mattermost import (after review)
1. Build a filtered export from `manifest.json` (prepare-export — planned).
2. [mmetl](https://github.com/mattermost/mmetl) `transform slack`
1. `make prepare-export` — writes `prepared-export/` from `manifest.json` (pytest-covered; not yet run through mmetl/mmctl).
2. [mmetl](https://github.com/mattermost/mmetl) `transform slack` on `prepared-export/`
3. [mmctl](https://docs.mattermost.com/administration-guide/manage/mmctl-command-line-tool.html) `import`
Docs: [Migrate from Slack](https://docs.mattermost.com/administration-guide/onboard/migrate-from-slack.html)

View File

@ -2,7 +2,8 @@
**Repo:** https://git.levkin.ca/ilia/slack-sieve (public)
**Clone:** `git clone git@git.levkin.ca:ilia/slack-sieve.git`
**Last updated:** 2026-05-29
**Vikunja:** [todo.levkin.ca → Homelab → slack-sieve](https://todo.levkin.ca) (`SLK`)
**Last updated:** 2026-06-03
---
@ -17,7 +18,7 @@ Official Slack export (JSON)
→ make hydrate # __uploads/ via xoxe URLs in export
→ make preset-skips # optional noise-channel skips
→ make review # manifest.json (gitignored)
prepare-export # TODO — filtered Slack-shaped folder/zip
make prepare-export # prepared-export/ (filtered Slack shape)
→ mmetl transform slack
→ mmctl import
```
@ -46,6 +47,9 @@ make review # http://127.0.0.1:8765
make hydrate # re-download attachments if needed
make preset-skips # apply presets/levkin-skips.json (safe to re-run; keeps your channel decisions)
make cherry-pick # bulk data/files picks on priority channels (optional)
make prepare-export # after review — filtered export for mmetl
make test # pytest + Playwright
```
---
@ -119,16 +123,21 @@ Copy and edit for another workspace.
---
## TODO (next implementer)
## Open tasks (source of truth)
1. **`scripts/prepare-export.py`** — read `manifest.json`, emit filtered Slack export dir/zip (`__uploads/` + selected JSON days, thread parents included).
2. **Pilot import** — one small channel through `mmetl``mmctl` on Mattermost VM; verify threads, files, users.
3. **User mapping** — optional `fetch-emails` or Slack ID → Mattermost username map for `mmetl`.
4. **Makefile** `make prepare-export` + doc section in README.
Tracked in **Vikunja → Homelab → slack-sieve**. Update here when scope changes.
| P | Task | Owner | Status |
|---|------|-------|--------|
| **P1** | **Channel review in UI**`make review`, finish 14 undecided + 3 partial ([order](#suggested-review-order)); save manifest | @you | ⏳ **next** |
| **P1** | Pilot import: one channel via `make prepare-export``mmetl``mmctl` on MM VM `10.0.10.237` | @agent | ⏳ blocked on review |
| **P2** | User mapping: Slack ID → Mattermost username for `mmetl` | @agent | backlog |
| **P3** | Re-hydrate 13 files that returned HTTP 401 (expired export token) | @you | optional |
| — | `scripts/prepare-export.py` + `make prepare-export` | @agent | ✅ done |
### Mattermost / homelab
- SSO: Authentik — see ansible `docs/guides/mattermost-authentik-gitlab-oauth.md`
- SSO: [authentik-apps.md#mattermost](https://git.levkin.ca/ilia/ansible/src/branch/master/docs/guides/authentik-apps.md#mattermost) (ansible)
- Import docs: https://docs.mattermost.com/administration-guide/onboard/migrate-from-slack.html
---
@ -143,6 +152,10 @@ slack-sieve/
scripts/
hydrate-export.sh
preset-skips.py
auto-cherry-pick.py
prepare-export.py
build-test-fixture.py
tests/ # pytest + Playwright (mini-export fixture)
presets/
levkin-skips.json
manifest.json # gitignored — your work in progress

76
package-lock.json generated Normal file
View File

@ -0,0 +1,76 @@
{
"name": "slack-sieve",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "slack-sieve",
"devDependencies": {
"@playwright/test": "^1.49.0"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "slack-sieve",
"private": true,
"scripts": {
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.49.0"
}
}

36
playwright.config.ts Normal file
View File

@ -0,0 +1,36 @@
import { defineConfig } from "@playwright/test";
import path from "path";
const repo = __dirname;
const port = process.env.SLACK_IMPORT_PORT || "18765";
export default defineConfig({
testDir: "tests/e2e",
timeout: 30_000,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: "on-first-retry",
},
webServer: {
command: [
"python3",
"server.py",
"--export",
path.join(repo, "tests/fixtures/mini-export"),
"--manifest",
path.join(repo, "tests/fixtures/test-manifest.json"),
"--host",
"127.0.0.1",
"--port",
port,
].join(" "),
url: `http://127.0.0.1:${port}/api/health`,
reuseExistingServer: !process.env.CI,
env: {
...process.env,
SLACK_IMPORT_QUIET: "1",
SLACK_REVIEW_URL: `http://127.0.0.1:${port}`,
},
},
});

2
requirements-dev.txt Normal file
View File

@ -0,0 +1,2 @@
pytest>=8.0
pytest-cov>=4.0

439
scripts/auto-cherry-pick.py Normal file
View File

@ -0,0 +1,439 @@
#!/usr/bin/env python3
"""Bulk cherry-pick data-rich messages (files, facts) — not conversation."""
from __future__ import annotations
import json
import re
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = REPO_ROOT / "manifest.json"
DEFAULT_EXPORT = Path.home() / "Downloads/Levkin Slack export enriched"
SKIP_SUBTYPES = {
"channel_join",
"channel_leave",
"channel_topic",
"channel_purpose",
"channel_name",
"channel_archive",
"channel_unarchive",
"group_join",
"group_leave",
}
RETURN_CHANNELS = {"29wilson", "520steeles"}
NOISE_TEXT = re.compile(
r"^(ok|okay|thanks|thx|ty|lol|lmao|\+1|done|yes|no|yep|nope|sure|cool|nice|got it|"
r"will do|sounds good|perfect|great|👍|👌)\.?!?$",
re.IGNORECASE,
)
# Concrete facts — not decision/planning chatter that merely mentions a topic word.
DATA_URL = re.compile(r"https?://|www\.", re.IGNORECASE)
DATA_EMAIL = re.compile(r"\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b", re.IGNORECASE)
DATA_PHONE = re.compile(
r"(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b|\b\d{3}[-.\s]\d{3}[-.\s]\d{4}\b"
)
DATA_MONEY = re.compile(
r"\$\s*[\d,]+(?:\.\d{2})?|\b[\d,]+\s*(?:cad|usd|dollars?)\b", re.IGNORECASE
)
DATA_DATE = re.compile(
r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|"
r"(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{1,2}(?:,?\s+\d{4})?)\b",
re.IGNORECASE,
)
DATA_ADDRESS = re.compile(
r"\b\d{1,5}\s+\w+(?:\s+\w+){0,3}\s+"
r"(?:st|street|ave|avenue|rd|road|dr|drive|blvd|way|crt|court|lane|ln|unit|apt|#)\b",
re.IGNORECASE,
)
DATA_ACCOUNT = re.compile(
r"\b(?:account|policy|invoice|receipt|confirmation|booking|reference|"
r"transit|etransfer|sin|passport)\s*#?\s*[:#]?\s*[\w-]{4,}\b",
re.IGNORECASE,
)
CHAT_PATTERNS = re.compile(
r"\b(?:should we|what do you think|let me know|any thoughts|sounds good|"
r"i think we|we could|maybe we|let's\b|can you\b|do you want|"
r"worth it|decided to|going to\b|planning to|wondering if)\b",
re.IGNORECASE,
)
NAME_LIST_TOPIC = re.compile(
r"\b(?:baby\s+names?|girl\s+names?|boy\s+names?|name\s+list|names?\s+we\s+like|"
r"favorite\s+names?|shortlist|hebrew\s+name|yiddish\s+name|middle\s+name)\b",
re.IGNORECASE,
)
INFORMATIVE_FILE = re.compile(
r"invoice|receipt|lease|statement|bill|contract|insurance|tax|form|report|"
r"prescription|diagnosis|lab|result|scan|document|agreement|deed|mortgage|"
r"hoa|permit|inspection|warranty|quote|estimate|spreadsheet|\.pdf|\.doc|\.xls",
re.IGNORECASE,
)
CASUAL_IMAGE = re.compile(
r"^image\.|screenshot|slack-img|photo|img_\d|snap|slack-image|mvimg",
re.IGNORECASE,
)
PROPERTY_CHANNELS = {
"284richmond",
"45ritson",
"122harmony",
"6keefer",
"153niagara",
"banking",
"realestate",
"airbnb",
"york",
"viewings",
}
KIDS_CHANNELS = {
"zoey",
"izik",
"parenting",
"moishe",
"baby",
"after-school-activities",
}
FAMILY_CHANNELS = {"levkin", "medical", "purchase"}
AUTO_CHANNELS = PROPERTY_CHANNELS | KIDS_CHANNELS | FAMILY_CHANNELS
IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic"}
def draft_group_key(name: str) -> str:
n = name.lower().strip()
n = re.sub(r"^draft[\s_\-]+", "", n)
n = re.sub(r"[\s_\-]*(final|v\d+|rev\d*|\(\d+\)|copy)", "", n)
n = re.sub(r"\.[a-z0-9]{1,5}$", "", n)
n = re.sub(r"\s+", " ", n).strip()
return n or name.lower()
def is_image(file: dict) -> bool:
mt = (file.get("mimetype") or "").lower()
if mt.startswith("image/"):
return True
name = (file.get("name") or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
return ext in IMAGE_EXTS
def is_doc(file: dict) -> bool:
name = (file.get("name") or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
return ext in {"pdf", "doc", "docx", "xls", "xlsx", "csv", "ppt", "pptx"}
def is_draft_file(file: dict) -> bool:
return "draft" in (file.get("name") or "").lower()
_DATA_PATTERNS = (
DATA_URL,
DATA_EMAIL,
DATA_PHONE,
DATA_MONEY,
DATA_DATE,
DATA_ADDRESS,
DATA_ACCOUNT,
)
def data_signal_count(text: str) -> int:
return sum(1 for p in _DATA_PATTERNS if p.search(text))
def has_data_signals(text: str) -> bool:
return data_signal_count(text) > 0
def text_is_factual(text: str) -> bool:
"""Require hard facts, not a lone link in chat."""
if not text or not text.strip():
return False
if looks_conversational(text):
return False
n = data_signal_count(text)
if n >= 2:
return True
if DATA_PHONE.search(text) or DATA_MONEY.search(text) or DATA_EMAIL.search(text):
return True
if DATA_ADDRESS.search(text) and DATA_DATE.search(text):
return True
return False
def looks_conversational(text: str) -> bool:
if not text or NOISE_TEXT.match(text.strip()):
return True
if CHAT_PATTERNS.search(text):
return True
if text.count("?") >= 2 and len(text) < 500:
return True
# Short back-and-forth without hard facts.
if len(text) < 80 and not has_data_signals(text):
return True
return False
def is_name_list_message(text: str) -> bool:
if not text or not text.strip():
return False
if NAME_LIST_TOPIC.search(text):
return True
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
if len(lines) >= 4:
name_lines = sum(
1
for ln in lines
if re.match(r"^[\s•\-\d.]*[A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,})?\s*$", ln)
)
if name_lines >= 3:
return True
return False
def informative_filename(name: str) -> bool:
return bool(INFORMATIVE_FILE.search(name or ""))
def informative_image(file: dict, text: str) -> bool:
name = file.get("name") or ""
if CASUAL_IMAGE.search(name):
return text_is_factual(text)
if informative_filename(name):
return True
return text_is_factual(text)
def build_latest_draft_winners(export, channel: str) -> tuple[set[str], dict[str, set[str]]]:
groups: dict[str, list[tuple[float, str, str]]] = defaultdict(list)
for msg in export.iter_channel_messages(channel):
for f in msg.get("files") or []:
name = f.get("name") or ""
if not is_draft_file(f):
continue
try:
ts = float(str(msg.get("ts", "0")).split(".")[0])
except (TypeError, ValueError):
ts = 0.0
fid = f.get("id") or name
groups[draft_group_key(name)].append((ts, msg["id"], fid))
msg_ids: set[str] = set()
files_by_msg: dict[str, set[str]] = defaultdict(set)
for items in groups.values():
_ts, msg_id, fid = max(items, key=lambda x: x[0])
msg_ids.add(msg_id)
files_by_msg[msg_id].add(fid)
return msg_ids, files_by_msg
def should_import(
msg: dict,
channel: str,
*,
latest_draft_msgs: set[str],
) -> bool:
subtype = msg.get("subtype")
text = (msg.get("text") or "").strip()
files = msg.get("files") or []
msg_id = msg["id"]
if subtype in SKIP_SUBTYPES and not files:
return False
if msg.get("is_bot") and not files:
return False
# --- Attachments (primary signal) ---
if files:
if msg_id in latest_draft_msgs:
return True
for f in files:
if is_draft_file(f):
continue
if is_doc(f):
return True
if informative_filename(f.get("name") or ""):
return True
if is_image(f) and informative_image(f, text):
return True
return False
# --- Text without files: facts only, not chat ---
if channel in KIDS_CHANNELS and is_name_list_message(text):
return True
return text_is_factual(text)
def msg_selection(
msg: dict,
*,
latest_draft_files: set[str] | None = None,
) -> dict:
text = (msg.get("text") or "").strip()
files: dict[str, bool] = {}
for f in msg.get("files") or []:
fid = f.get("id") or f.get("name")
if not fid:
continue
if is_draft_file(f):
if latest_draft_files and fid in latest_draft_files:
files[fid] = True
continue
if is_doc(f) or informative_filename(f.get("name") or ""):
files[fid] = True
elif is_image(f) and informative_image(f, text):
files[fid] = True
return {
"action": "import",
"checked": True,
"merge_target": "",
"notes": "auto-cherry-pick",
"files": files,
}
def channel_entry_skip(notes: str) -> dict:
return {
"import": False,
"status": "skip",
"reviewed": True,
"import_mode": "all",
"notes": notes,
"mattermost_channel": "",
"mattermost_team": "",
"messages": {},
}
def channel_shell() -> dict:
return {
"import": True,
"status": "partial",
"reviewed": True,
"import_mode": "pick",
"notes": "auto-cherry-pick — data/files only",
"mattermost_channel": "",
"mattermost_team": "",
"messages": {},
}
def reset_all_picks(manifest: dict) -> None:
channels = manifest.setdefault("channels", {})
for name, entry in channels.items():
if not isinstance(entry, dict):
continue
entry["messages"] = {}
if name in RETURN_CHANNELS:
channels[name] = channel_entry_skip(
"Returned — not importing (29wilson / 520steeles)"
)
continue
if entry.get("status") == "skip":
continue
entry["import"] = False
entry["status"] = "undecided"
entry["reviewed"] = False
entry["import_mode"] = "all"
def main() -> int:
dry_run = "--dry-run" in sys.argv
reset_only = "--reset" in sys.argv
export_path = DEFAULT_EXPORT
for arg in sys.argv[1:]:
if not arg.startswith("-") and Path(arg).expanduser().is_dir():
export_path = Path(arg).expanduser().resolve()
sys.path.insert(0, str(REPO_ROOT))
from slack_export import SlackExport
export = SlackExport(export_path)
manifest: dict = {"channels": {}, "users": {}}
if MANIFEST_PATH.is_file():
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
reset_all_picks(manifest)
print("Cleared picks — data/files only mode.")
for name in sorted(RETURN_CHANNELS):
print(f" #{name} → skip")
if reset_only:
if not dry_run:
manifest["updated_at"] = datetime.now(tz=timezone.utc).isoformat()
MANIFEST_PATH.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return 0
channels = manifest.setdefault("channels", {})
stats: dict[str, int] = {}
for ch_name in sorted(AUTO_CHANNELS):
if ch_name in RETURN_CHANNELS:
continue
folder = export.channel_folder(ch_name)
if not folder:
continue
entry = channels.setdefault(ch_name, channel_shell())
entry["messages"] = {}
if entry.get("status") == "import" and entry.get("import_mode", "all") != "pick":
print(f"#{ch_name}: whole-channel import — left unchanged")
continue
entry["import"] = True
entry["status"] = "partial"
entry["reviewed"] = True
entry["import_mode"] = "pick"
latest_draft_msgs, draft_files_by_msg = build_latest_draft_winners(export, ch_name)
messages = entry.setdefault("messages", {})
picked = 0
for msg in export.iter_channel_messages(ch_name):
if not should_import(msg, ch_name, latest_draft_msgs=latest_draft_msgs):
continue
messages[msg["id"]] = msg_selection(
msg, latest_draft_files=draft_files_by_msg.get(msg["id"])
)
picked += 1
stats[ch_name] = picked
print(f"#{ch_name}: {picked} msgs")
total = sum(stats.values())
print(f"\nTotal: {total} messages (data/files only)")
if dry_run:
print("(dry-run — manifest not written)")
return 0
manifest["updated_at"] = datetime.now(tz=timezone.utc).isoformat()
note = manifest.get("global_notes") or ""
stamp = "auto-cherry-pick v3 — data/files only, no conversation"
if stamp not in note:
manifest["global_notes"] = (note + "\n" + stamp).strip()
MANIFEST_PATH.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Wrote {MANIFEST_PATH}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Copy a small slice of the real Slack export into tests/fixtures/mini-export."""
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
DEFAULT_SRC = Path.home() / "Downloads/Levkin Slack export enriched"
DST = REPO / "tests" / "fixtures" / "mini-export"
CHANNELS = ("medical", "zoey", "153niagara")
MAX_DAY_FILES = 4
def main() -> int:
src = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else DEFAULT_SRC
if not src.is_dir():
print(f"Export not found: {src}", file=sys.stderr)
return 1
if DST.exists():
shutil.rmtree(DST)
DST.mkdir(parents=True)
channels = json.loads((src / "channels.json").read_text(encoding="utf-8"))
users = json.loads((src / "users.json").read_text(encoding="utf-8"))
kept = [c for c in channels if c.get("name") in CHANNELS]
(DST / "channels.json").write_text(
json.dumps(kept, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
(DST / "users.json").write_text(
json.dumps(users[:25], indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
total_msgs = 0
for name in CHANNELS:
sdir = src / name
ddir = DST / name
if not sdir.is_dir():
print(f" skip missing channel #{name}", file=sys.stderr)
continue
ddir.mkdir()
for jf in sorted(sdir.glob("*.json"))[:MAX_DAY_FILES]:
shutil.copy2(jf, ddir / jf.name)
data = json.loads(jf.read_text(encoding="utf-8"))
if isinstance(data, list):
total_msgs += len(data)
print(f"Wrote {DST}")
print(f" channels: {', '.join('#' + c['name'] for c in kept)}")
print(f" ~{total_msgs} messages (cap {MAX_DAY_FILES} day files per channel)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

223
scripts/prepare-export.py Normal file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Build a filtered Slack-shaped export from manifest.json for mmetl/mmctl."""
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = REPO_ROOT / "manifest.json"
DEFAULT_EXPORT = Path.home() / "Downloads/Levkin Slack export enriched"
DEFAULT_OUT = REPO_ROOT / "prepared-export"
def message_key(channel: str, ts: str | float) -> str:
return f"{channel}:{ts}"
def channel_included(entry: dict) -> bool:
status = entry.get("status")
if status == "skip":
return False
if status == "import" and entry.get("import_mode", "all") != "pick":
return True
if status in ("import", "partial"):
picks = entry.get("messages") or {}
return any(
(sel.get("action") in ("import", "merge") or sel.get("checked"))
for sel in picks.values()
if isinstance(sel, dict)
)
return False
def message_included(entry: dict, key: str) -> bool:
status = entry.get("status")
if status == "skip":
return False
if status == "import" and entry.get("import_mode", "all") != "pick":
return True
picks = entry.get("messages") or {}
sel = picks.get(key)
if not sel:
return False
if sel.get("action") == "skip":
return False
return sel.get("action") in ("import", "merge") or bool(sel.get("checked"))
def filter_files_on_message(entry: dict, key: str, raw: dict) -> dict:
"""Return a copy of the Slack message with only manifest-selected files."""
picks = entry.get("messages") or {}
sel = picks.get(key) or {}
file_flags = sel.get("files") if isinstance(sel.get("files"), dict) else {}
files = raw.get("files")
if not files:
return raw
out = dict(raw)
kept = []
for f in files:
if not isinstance(f, dict):
continue
fid = f.get("id") or f.get("name")
if not fid:
continue
if file_flags and file_flags.get(fid) is False:
continue
kept.append(f)
if not kept:
out.pop("files", None)
else:
out["files"] = kept
return out
def prepare_export(
export_root: Path,
manifest: dict,
out_root: Path,
*,
export_mod=None,
) -> dict[str, int]:
"""
Write filtered export to out_root. Returns per-channel message counts.
"""
sys.path.insert(0, str(REPO_ROOT))
from slack_export import SlackExport, load_json
export = export_mod or SlackExport(export_root)
out_root = out_root.resolve()
if out_root.exists():
shutil.rmtree(out_root)
out_root.mkdir(parents=True)
channels_meta = load_json(export.paths.channels_json)
if not isinstance(channels_meta, list):
channels_meta = []
manifest_channels = manifest.get("channels") or {}
included_names: set[str] = set()
stats: dict[str, int] = {}
for ch in channels_meta:
name = ch.get("name")
if not name:
continue
entry = manifest_channels.get(name) or {}
if not channel_included(entry):
continue
included_names.add(name)
# channels.json subset
filtered_meta = [c for c in channels_meta if c.get("name") in included_names]
(out_root / "channels.json").write_text(
json.dumps(filtered_meta, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
# users.json — copy whole (mmetl needs user mapping)
users_src = export.paths.users_json
if users_src.is_file():
shutil.copy2(users_src, out_root / "users.json")
uploads_out = out_root / "__uploads"
uploads_src = export.paths.root / "__uploads"
for name in sorted(included_names):
entry = manifest_channels[name]
ch_out = out_root / name
ch_out.mkdir(parents=True)
count = 0
for date in export.list_channel_date_files(name):
raw_list = export.read_messages_file(name, date)
if not isinstance(raw_list, list):
continue
kept: list[dict] = []
for raw in raw_list:
if not isinstance(raw, dict):
continue
ts = raw.get("ts")
if ts is None:
continue
key = message_key(name, ts)
if not message_included(entry, key):
continue
msg = filter_files_on_message(entry, key, raw)
kept.append(msg)
count += 1
for f in msg.get("files") or []:
if not isinstance(f, dict):
continue
fid = f.get("id")
fname = f.get("name")
if fid and fname and uploads_src.is_dir():
src = uploads_src / fid / fname
if src.is_file():
dest = uploads_out / fid / fname
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
if kept:
(ch_out / f"{date}.json").write_text(
json.dumps(kept, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
stats[name] = count
summary = {
"channels": len(stats),
"messages": sum(stats.values()),
"per_channel": stats,
}
(out_root / "prepare-export-summary.json").write_text(
json.dumps(summary, indent=2) + "\n",
encoding="utf-8",
)
return stats
def main() -> int:
export_path = DEFAULT_EXPORT
out_path = DEFAULT_OUT
manifest_path = MANIFEST_PATH
args = sys.argv[1:]
i = 0
while i < len(args):
if args[i] == "--export" and i + 1 < len(args):
export_path = Path(args[i + 1]).expanduser()
i += 2
elif args[i] == "--out" and i + 1 < len(args):
out_path = Path(args[i + 1]).expanduser()
i += 2
elif args[i] == "--manifest" and i + 1 < len(args):
manifest_path = Path(args[i + 1]).expanduser()
i += 2
elif not args[i].startswith("-"):
export_path = Path(args[i]).expanduser()
i += 1
else:
i += 1
export_path = export_path.resolve()
if not export_path.is_dir():
print(f"Export not found: {export_path}", file=sys.stderr)
return 1
if not manifest_path.is_file():
print(f"Manifest not found: {manifest_path}", file=sys.stderr)
return 1
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
stats = prepare_export(export_path, manifest, out_path.resolve())
total = sum(stats.values())
print(f"Wrote {out_path}")
print(f" {len(stats)} channels, {total} messages")
for name, n in sorted(stats.items()):
print(f" #{name}: {n}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -315,6 +315,16 @@ class ReviewHandler(BaseHTTPRequestHandler):
limit = min(int((qs.get("limit") or ["80"])[0]), 200)
return self._send_json(self.export.search_global(query, limit=limit))
if path == "/api/search-files":
query = (qs.get("q") or [""])[0]
file_type = (qs.get("type") or ["all"])[0]
limit = min(int((qs.get("limit") or ["80"])[0]), 200)
return self._send_json(
self.export.search_files_global(
query, file_type=file_type, limit=limit
)
)
if path.startswith("/api/channels/") and path.endswith("/search"):
channel_name = path.split("/")[3]
query = (qs.get("q") or [""])[0]
@ -386,6 +396,9 @@ class ReviewHandler(BaseHTTPRequestHandler):
saved_messages = entry.get("messages") or entry.get("message_selections")
if not isinstance(saved_messages, dict):
saved_messages = {}
prev = current["channels"].get(name) or {}
prev_messages = prev.get("messages") if isinstance(prev.get("messages"), dict) else {}
merged_messages = {**prev_messages, **saved_messages}
current["channels"][name] = {
"import": status in ("import", "partial"),
"status": status,
@ -395,7 +408,7 @@ class ReviewHandler(BaseHTTPRequestHandler):
"notes": entry.get("notes") or "",
"mattermost_channel": entry.get("mattermost_channel") or "",
"mattermost_team": entry.get("mattermost_team") or "",
"messages": saved_messages,
"messages": merged_messages,
}
if isinstance(body.get("users"), dict):
@ -424,9 +437,19 @@ def main() -> int:
)
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--manifest",
default=os.environ.get("SLACK_MANIFEST_PATH"),
help="Path to manifest.json (default: repo manifest.json)",
)
args = parser.parse_args()
paths = resolve_paths(args.export)
manifest_path = (
Path(args.manifest).expanduser().resolve()
if args.manifest
else paths.manifest_json
)
try:
export = SlackExport(paths.root)
except FileNotFoundError as exc:
@ -440,13 +463,13 @@ def main() -> int:
handler_cls = type(
"BoundReviewHandler",
(ReviewHandler,),
{"export": export, "manifest_path": paths.manifest_json},
{"export": export, "manifest_path": manifest_path},
)
server = ThreadingHTTPServer((args.host, args.port), handler_cls)
print(f"Slack import review UI: http://{args.host}:{args.port}")
print(f"Export: {paths.root}")
print(f"Manifest: {paths.manifest_json}")
print(f"Manifest: {manifest_path}")
try:
server.serve_forever()
except KeyboardInterrupt:

View File

@ -169,6 +169,7 @@ class SlackExport:
def channel_dirs(self) -> list[Path]:
skip = {
"__uploads",
"channels.json",
"users.json",
"canvases.json",
@ -373,6 +374,108 @@ class SlackExport:
"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"]

View File

@ -12,11 +12,65 @@
--skip-bg: rgba(224, 108, 117, 0.12);
--undecided: #f0b429;
--highlight: rgba(91, 141, 239, 0.35);
--scrollbar-track: #1a2030;
--scrollbar-thumb: #5a6a88;
--scrollbar-thumb-hover: #7a8aab;
font-family: "SF Pro Text", "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; }
/* Scrollbars — visible on dark panels (Firefox + WebKit) */
.list,
.messages,
.search-results,
.preview-doc-scroll,
.panel.right,
.messages-wrap {
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
.list::-webkit-scrollbar,
.messages::-webkit-scrollbar,
.search-results::-webkit-scrollbar,
.preview-doc-scroll::-webkit-scrollbar,
.panel.right::-webkit-scrollbar,
.messages-wrap::-webkit-scrollbar {
width: 11px;
height: 11px;
}
.list::-webkit-scrollbar-track,
.messages::-webkit-scrollbar-track,
.search-results::-webkit-scrollbar-track,
.preview-doc-scroll::-webkit-scrollbar-track,
.panel.right::-webkit-scrollbar-track,
.messages-wrap::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 6px;
}
.list::-webkit-scrollbar-thumb,
.messages::-webkit-scrollbar-thumb,
.search-results::-webkit-scrollbar-thumb,
.preview-doc-scroll::-webkit-scrollbar-thumb,
.panel.right::-webkit-scrollbar-thumb,
.messages-wrap::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 6px;
border: 2px solid var(--scrollbar-track);
}
.list::-webkit-scrollbar-thumb:hover,
.messages::-webkit-scrollbar-thumb:hover,
.search-results::-webkit-scrollbar-thumb:hover,
.preview-doc-scroll::-webkit-scrollbar-thumb:hover,
.panel.right::-webkit-scrollbar-thumb:hover,
.messages-wrap::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-hover);
}
body {
margin: 0;
background: var(--bg);
@ -62,8 +116,25 @@ header.topbar {
max-width: 480px;
}
.global-search-wrap input {
width: 100%;
.global-search-row {
display: flex;
gap: 0.35rem;
align-items: center;
}
.global-search-row select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
padding: 0.4rem 0.35rem;
font-size: 0.78rem;
max-width: 5.5rem;
}
.global-search-row input {
flex: 1;
min-width: 0;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
@ -72,6 +143,34 @@ header.topbar {
font-size: 0.9rem;
}
.message-filters select {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 0.3rem 0.4rem;
font-size: 0.78rem;
}
.btn-mini {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.72rem;
padding: 0.28rem 0.45rem;
cursor: pointer;
}
.btn-mini:hover {
border-color: var(--accent);
}
.btn-mini.active {
border-color: var(--import);
background: var(--import-bg);
}
.search-results {
position: absolute;
top: calc(100% + 4px);

View File

@ -33,6 +33,8 @@ const state = {
const els = {
stats: document.getElementById("stats"),
globalSearch: document.getElementById("global-search"),
globalSearchMode: document.getElementById("global-search-mode"),
globalFileType: document.getElementById("global-file-type"),
globalSearchResults: document.getElementById("global-search-results"),
sidebarSearch: document.getElementById("sidebar-search"),
filterStatus: document.getElementById("filter-status"),
@ -43,6 +45,9 @@ const els = {
channelMeta: document.getElementById("channel-meta"),
importModePick: document.getElementById("import-mode-pick"),
messageSearch: document.getElementById("message-search"),
filterAttachmentType: document.getElementById("filter-attachment-type"),
btnSelectImages: document.getElementById("btn-select-images"),
btnSelectFiles: document.getElementById("btn-select-files"),
filterHideBots: document.getElementById("filter-hide-bots"),
filterHideSystem: document.getElementById("filter-hide-system"),
filterOnlySelected: document.getElementById("filter-only-selected"),
@ -115,6 +120,22 @@ function isImageFile(file) {
return ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "heic"].includes(ext);
}
function fileKind(file) {
const mt = (file.mimetype || "").toLowerCase();
const name = (file.name || "").toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() : "";
if (isImageFile(file)) return "image";
if (ext === "pdf" || mt === "application/pdf") return "pdf";
if (["doc", "docx"].includes(ext) || mt.includes("word")) return "doc";
if (["xls", "xlsx", "csv"].includes(ext) || mt.includes("spreadsheet") || mt.includes("excel"))
return "spreadsheet";
return "other";
}
function messageKinds(msg) {
return new Set((msg.files || []).map(fileKind));
}
function fileExtLabel(name) {
const ext = (name || "").split(".").pop()?.toUpperCase() || "";
if (!ext || ext.length > 6) return "FILE";
@ -383,20 +404,45 @@ function userEntry(id) {
return state.userManifest[id];
}
function channelUsesCherryPick(ch) {
return ch.import_mode === "pick" || ch.status === "partial";
}
function effectiveMsgAction(channelName, msg) {
const ch = channelEntry(channelName);
const sel = ch.messages[msg.id];
const explicit = sel?.action;
if (explicit && explicit !== "inherit") return explicit;
if (explicit === "import" || explicit === "merge" || explicit === "skip") {
return explicit;
}
if (ch.import_mode === "pick") {
return sel?.import === true || sel?.checked ? "import" : "skip";
if (channelUsesCherryPick(ch)) {
return "skip";
}
if (ch.status === "import") return "import";
if (ch.status === "skip") return "skip";
return "skip";
}
function clearMsgSelection(channelName, msgId) {
const ch = channelEntry(channelName);
if (ch.messages?.[msgId]) {
delete ch.messages[msgId];
}
}
function setMsgImport(channelName, msg, on) {
if (!on) {
clearMsgSelection(channelName, msg.id);
return;
}
ensureCherryPick();
const sel = msgEntry(channelName, msg.id);
sel.action = "import";
sel.checked = true;
sel.merge_target = "";
}
function isMsgSelected(channelName, msg) {
const action = effectiveMsgAction(channelName, msg);
return action === "import" || action === "merge";
@ -423,6 +469,22 @@ function reviewedCount() {
return state.channels.filter((c) => channelEntry(c.name).reviewed).length;
}
function channelPickCount(name) {
const msgs = channelEntry(name).messages || {};
return Object.values(msgs).filter(
(m) => m.action === "import" || m.action === "merge"
).length;
}
function channelHasPicks(name) {
const entry = channelEntry(name);
return (
entry.status === "partial" ||
entry.import_mode === "pick" ||
channelPickCount(name) > 0
);
}
function visibleChannelList() {
const q = els.sidebarSearch.value.trim().toLowerCase();
const statusFilter = els.filterStatus.value;
@ -432,7 +494,9 @@ function visibleChannelList() {
const entry = channelEntry(ch.name);
const status = entry.status === "partial" ? "partial" : entry.status;
if (hideArchived && ch.is_archived) return false;
if (statusFilter !== "all" && status !== statusFilter) return false;
if (statusFilter === "partial") {
if (!channelHasPicks(ch.name)) return false;
} else if (statusFilter !== "all" && status !== statusFilter) return false;
if (!q) return true;
return (
ch.name.toLowerCase().includes(q) ||
@ -499,8 +563,13 @@ function renderSidebar() {
btn.type = "button";
btn.className =
"list-item" + (state.selectedChannel === ch.name ? " active" : "");
const picks = channelPickCount(ch.name);
const mode =
entry.import_mode === "pick" ? " · cherry-pick" : "";
entry.import_mode === "pick" || picks > 0
? picks > 0
? ` · ${formatNumber(picks)} picked`
: " · cherry-pick"
: "";
btn.innerHTML = `
<span class="status-dot ${dotStatus}"></span>
<div class="body">
@ -608,13 +677,19 @@ function ensureCherryPick() {
function setMessageAction(msg, action, { advance = false } = {}) {
if (!state.selectedChannel || !msg) return;
ensureCherryPick();
const sel = msgEntry(state.selectedChannel, msg.id);
sel.action = action;
sel.checked = action === "import" || action === "merge";
if (action !== "merge") sel.merge_target = "";
if (action === "import" || action === "merge") {
ensureCherryPick();
const sel = msgEntry(state.selectedChannel, msg.id);
sel.action = action;
sel.checked = true;
if (action !== "merge") sel.merge_target = "";
} else {
clearMsgSelection(state.selectedChannel, msg.id);
}
renderAllMessages();
renderSidebar();
updateTriageBar();
scheduleAutosave();
if (advance) {
@ -632,8 +707,7 @@ function toggleFocusedImport() {
const msg = state.allMessages.find((m) => m.id === state.focusMessageId);
if (!msg || !state.selectedChannel) return;
const sel = msgEntry(state.selectedChannel, msg.id);
const currentlyOn =
sel.action === "import" || sel.action === "merge" || sel.checked;
const currentlyOn = isMsgSelected(state.selectedChannel, msg);
setMessageAction(msg, currentlyOn ? "skip" : "import", { advance: false });
}
@ -716,6 +790,15 @@ function messagePassesFilters(msg) {
if (els.filterOnlySelected.checked && state.selectedChannel) {
if (!isMsgSelected(state.selectedChannel, msg)) return false;
}
const att = els.filterAttachmentType?.value || "all";
if (att !== "all") {
const kinds = messageKinds(msg);
if (att === "any") {
if (!msg.files?.length) return false;
} else if (!kinds.has(att)) {
return false;
}
}
return true;
}
@ -723,7 +806,7 @@ function messageRowHtml(msg, q) {
const ch = state.selectedChannel;
const action = effectiveMsgAction(ch, msg);
const sel = msgEntry(ch, msg.id);
const checked = action === "import" || action === "merge" || sel.checked;
const checked = action === "import" || action === "merge";
const classes = ["message"];
if (msg.is_thread_reply) classes.push("thread-reply");
@ -796,6 +879,7 @@ function renderAllMessages() {
els.filterCount.textContent = `${shown} / ${state.allMessages.length} shown`;
renderSelectionSummary();
updateMessageNavStatus();
updateBulkSelectButtons();
if (state.focusMessageId) {
const row = els.messages.querySelector(
@ -808,20 +892,21 @@ function renderAllMessages() {
function openMessageDetail(msg) {
state.selectedMessageId = msg.id;
const ch = state.selectedChannel;
const sel = msgEntry(ch, msg.id);
const saved = channelEntry(ch).messages[msg.id];
const isOn = isMsgSelected(ch, msg);
els.messageDetail.classList.remove("hidden");
els.msgPreview.textContent = `${msg.user_name}: ${(msg.text || "[no text]").slice(0, 200)}`;
els.msgAction.value =
sel.action && sel.action !== "inherit"
? sel.action
: isMsgSelected(ch, msg)
saved?.action && saved.action !== "inherit"
? saved.action
: isOn
? "import"
: "inherit";
els.msgMergeTarget.value = sel.merge_target || "";
els.msgMergeTarget.value = saved?.merge_target || "";
els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge");
els.msgNotes.value = sel.notes || "";
els.msgNotes.value = saved?.notes || "";
if (msg.is_thread_parent || msg.thread_reply_count > 0) {
els.btnThreadAll.classList.remove("hidden");
@ -846,7 +931,7 @@ function openMessageDetail(msg) {
els.fileToggles.innerHTML = "<label>Attachments</label>";
for (const f of msg.files) {
const fid = f.id || f.name;
const on = sel.files[fid] !== false;
const on = isOn && saved?.files?.[fid] !== false;
const url = fileUploadUrl(f);
const row = document.createElement("div");
row.className = "file-toggle-row";
@ -876,10 +961,14 @@ function applyThreadImport(rootMsg, action) {
String(msg.ts) === rootTs ||
String(msg.thread_root_ts) === rootTs
) {
const sel = msgEntry(ch, msg.id);
sel.action = action;
sel.checked = action === "import";
if (action !== "merge") sel.merge_target = "";
if (action === "import" || action === "merge") {
const sel = msgEntry(ch, msg.id);
sel.action = action;
sel.checked = true;
if (action !== "merge") sel.merge_target = "";
} else {
clearMsgSelection(ch, msg.id);
}
}
}
if (channelEntry(ch).import_mode !== "pick") {
@ -893,25 +982,28 @@ function applyThreadImport(rootMsg, action) {
function applyMessageDetailFromForm() {
if (!state.selectedChannel || !state.selectedMessageId) return;
const sel = msgEntry(state.selectedChannel, state.selectedMessageId);
sel.action = els.msgAction.value;
sel.merge_target =
els.msgAction.value === "merge" ? els.msgMergeTarget.value.trim() : "";
sel.notes = els.msgNotes.value;
sel.checked = sel.action === "import" || sel.action === "merge";
const action = els.msgAction.value;
const msg = state.allMessages.find((m) => m.id === state.selectedMessageId);
if (!msg) return;
els.fileToggles.querySelectorAll("[data-file-id]").forEach((inp) => {
sel.files[inp.dataset.fileId] = inp.checked;
});
const ch = channelEntry(state.selectedChannel);
if (sel.action !== "inherit") {
ch.import_mode = "pick";
ch.status = "partial";
ch.reviewed = true;
els.importModePick.checked = true;
if (action === "skip" || action === "inherit") {
clearMsgSelection(state.selectedChannel, state.selectedMessageId);
} else {
ensureCherryPick();
const sel = msgEntry(state.selectedChannel, state.selectedMessageId);
sel.action = action;
sel.checked = action === "import" || action === "merge";
sel.merge_target = action === "merge" ? els.msgMergeTarget.value.trim() : "";
sel.notes = els.msgNotes.value;
els.fileToggles.querySelectorAll("[data-file-id]").forEach((inp) => {
if (!sel.files) sel.files = {};
sel.files[inp.dataset.fileId] = inp.checked;
});
}
renderAllMessages();
renderSidebar();
updateTriageBar();
scheduleAutosave();
}
@ -1038,60 +1130,166 @@ function skipChannel() {
persistNotes();
}
function syncGlobalSearchModeUi() {
const filesMode = els.globalSearchMode.value === "files";
els.globalFileType.classList.toggle("hidden", !filesMode);
els.globalSearch.placeholder = filesMode
? "Search attachments in all channels… (optional keywords)"
: "Search channels & messages… (press /)";
}
async function jumpToSearchHit(ch, msgId) {
els.globalSearchResults.classList.add("hidden");
els.globalSearch.value = "";
await selectChannel(ch);
if (msgId) {
const msg = state.allMessages.find((m) => m.id === msgId);
if (msg) {
requestAnimationFrame(() => {
focusMessage(msg.id);
openMessageDetail(msg);
document
.querySelector(
`[data-id="${msgId.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`
)
?.scrollIntoView({ block: "center" });
});
}
}
}
async function runGlobalSearch(query) {
if (!query.trim()) {
const filesMode = els.globalSearchMode.value === "files";
if (!filesMode && !query.trim()) {
els.globalSearchResults.classList.add("hidden");
return;
}
const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=60`);
const parts = [];
if (data.channels?.length) {
parts.push('<div class="search-group"><h4>Channels</h4>');
for (const ch of data.channels.slice(0, 12)) {
parts.push(
`<button type="button" class="search-hit" data-channel="${escapeHtml(ch.name)}">#${escapeHtml(ch.name)}</button>`
);
if (filesMode) {
const ft = els.globalFileType.value || "all";
const data = await api(
`/api/search-files?q=${encodeURIComponent(query)}&type=${encodeURIComponent(ft)}&limit=60`
);
if (data.hits?.length) {
const label =
ft === "all" ? "Attachments" : `${ft} files`;
parts.push(`<div class="search-group"><h4>${escapeHtml(label)}</h4>`);
for (const m of data.hits.slice(0, 25)) {
const names = (m.matching_files || m.files || [])
.map((f) => f.name)
.filter(Boolean)
.slice(0, 2)
.join(", ");
parts.push(
`<button type="button" class="search-hit" data-channel="${escapeHtml(m.channel)}" data-msg="${escapeHtml(m.id)}">
<strong>#${escapeHtml(m.channel)}</strong> · ${escapeHtml(m.user_name)}
<div class="sub">${escapeHtml(names || (m.text || "").slice(0, 80))}</div>
</button>`
);
}
parts.push("</div>");
}
parts.push("</div>");
}
if (data.messages?.length) {
parts.push('<div class="search-group"><h4>Messages</h4>');
for (const m of data.messages.slice(0, 20)) {
parts.push(
`<button type="button" class="search-hit" data-channel="${escapeHtml(m.channel)}" data-msg="${escapeHtml(m.id)}">
<strong>#${escapeHtml(m.channel)}</strong> · ${escapeHtml(m.user_name)}
<div class="sub">${escapeHtml((m.text || "").slice(0, 100))}</div>
</button>`
);
} else {
const data = await api(`/api/search?q=${encodeURIComponent(query)}&limit=60`);
if (data.channels?.length) {
parts.push('<div class="search-group"><h4>Channels</h4>');
for (const ch of data.channels.slice(0, 12)) {
parts.push(
`<button type="button" class="search-hit" data-channel="${escapeHtml(ch.name)}">#${escapeHtml(ch.name)}</button>`
);
}
parts.push("</div>");
}
if (data.messages?.length) {
parts.push('<div class="search-group"><h4>Messages</h4>');
for (const m of data.messages.slice(0, 20)) {
parts.push(
`<button type="button" class="search-hit" data-channel="${escapeHtml(m.channel)}" data-msg="${escapeHtml(m.id)}">
<strong>#${escapeHtml(m.channel)}</strong> · ${escapeHtml(m.user_name)}
<div class="sub">${escapeHtml((m.text || "").slice(0, 100))}</div>
</button>`
);
}
parts.push("</div>");
}
parts.push("</div>");
}
els.globalSearchResults.innerHTML =
parts.join("") || '<div class="empty" style="padding:1rem">No results</div>';
els.globalSearchResults.classList.remove("hidden");
els.globalSearchResults.querySelectorAll(".search-hit").forEach((btn) => {
btn.addEventListener("click", async () => {
const ch = btn.dataset.channel;
els.globalSearchResults.classList.add("hidden");
els.globalSearch.value = "";
await selectChannel(ch);
const msgId = btn.dataset.msg;
if (msgId) {
const msg = state.allMessages.find((m) => m.id === msgId);
if (msg) {
requestAnimationFrame(() => {
openMessageDetail(msg);
document.querySelector(`[data-id="${msgId}"]`)?.scrollIntoView({
block: "center",
});
});
}
}
});
btn.addEventListener("click", () => jumpToSearchHit(btn.dataset.channel, btn.dataset.msg));
});
}
function visibleFileMessages(imagesOnly) {
return visibleMessages().filter((msg) => {
const files = msg.files || [];
if (!files.length) return false;
if (imagesOnly && !files.some(isImageFile)) return false;
return true;
});
}
function selectVisibleFileMessages({ imagesOnly = false } = {}) {
if (!state.selectedChannel) return;
ensureCherryPick();
const targets = visibleFileMessages(imagesOnly);
const allSelected =
targets.length > 0 &&
targets.every((msg) => isMsgSelected(state.selectedChannel, msg));
let n = 0;
for (const msg of targets) {
const sel = msgEntry(state.selectedChannel, msg.id);
if (allSelected) {
clearMsgSelection(state.selectedChannel, msg.id);
} else {
setMsgImport(state.selectedChannel, msg, true);
const sel = msgEntry(state.selectedChannel, msg.id);
for (const f of msg.files || []) {
const fid = f.id || f.name;
if (!fid) continue;
if (imagesOnly && !isImageFile(f)) continue;
sel.files[fid] = true;
}
}
n += 1;
}
renderAllMessages();
renderSidebar();
updateTriageBar();
scheduleAutosave();
const label = imagesOnly ? "image" : "file";
const verb = allSelected ? "Cleared" : "Selected";
els.saveStatus.textContent = `${verb} ${n} ${label} message(s) in view`;
updateBulkSelectButtons();
setTimeout(() => {
els.saveStatus.textContent = "";
}, 2500);
}
function updateBulkSelectButtons() {
if (!els.btnSelectImages || !els.btnSelectFiles) return;
const imgTargets = visibleFileMessages(true);
const fileTargets = visibleFileMessages(false);
const imgAll =
imgTargets.length > 0 &&
imgTargets.every((m) => isMsgSelected(state.selectedChannel, m));
const fileAll =
fileTargets.length > 0 &&
fileTargets.every((m) => isMsgSelected(state.selectedChannel, m));
els.btnSelectImages.textContent = imgAll ? "Clear images in view" : "Select images in view";
els.btnSelectFiles.textContent = fileAll ? "Clear files in view" : "Select files in view";
els.btnSelectImages.classList.toggle("active", imgAll);
els.btnSelectFiles.classList.toggle("active", fileAll);
}
function selectVisibleImageMessages() {
selectVisibleFileMessages({ imagesOnly: true });
}
function scheduleAutosave() {
clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(() => saveManifest(true), 600);
@ -1131,11 +1329,15 @@ async function loadChannels() {
e.import = !!ch.import;
e.status = ch.status || (ch.import ? "import" : "undecided");
e.reviewed = !!ch.reviewed || ["import", "skip", "partial"].includes(e.status);
e.import_mode = ch.import_mode || "all";
e.import_mode =
ch.import_mode === "pick" || ch.status === "partial" ? "pick" : ch.import_mode || "all";
if (ch.status === "partial" && e.import_mode !== "pick") {
e.import_mode = "pick";
}
e.notes = ch.notes || "";
e.mattermost_channel = ch.mattermost_channel || "";
e.mattermost_team = ch.mattermost_team || "";
if (ch.message_selections) e.messages = ch.message_selections;
// Message picks come from loadManifest() only — avoid clobbering on parallel load.
}
}
@ -1149,6 +1351,7 @@ async function loadManifest() {
function onMessageListClick(e) {
if (!state.selectedChannel) return;
if (e.target.closest(".msg-check")) return;
const previewBtn = e.target.closest(
".image-preview, .file-doc-preview, .file-open-link[data-preview-file-id], .file-open-link[data-preview-url]"
@ -1165,27 +1368,6 @@ function onMessageListClick(e) {
return;
}
const check = e.target.closest("[data-msg-check]");
if (check) {
e.stopPropagation();
const row = check.closest(".message");
const msg = state.allMessages.find((m) => m.id === row?.dataset.id);
if (!msg) return;
const sel = msgEntry(state.selectedChannel, msg.id);
if (check.checked) {
sel.action = "import";
sel.checked = true;
} else {
sel.action = "skip";
sel.checked = false;
}
ensureCherryPick();
renderAllMessages();
scheduleAutosave();
focusMessage(msg.id, { scroll: false });
return;
}
const row = e.target.closest(".message");
if (!row) return;
const msg = state.allMessages.find((m) => m.id === row.dataset.id);
@ -1212,6 +1394,7 @@ function bindEvents() {
[
els.messageSearch,
els.filterAttachmentType,
els.filterHideBots,
els.filterHideSystem,
els.filterOnlySelected,
@ -1220,6 +1403,22 @@ function bindEvents() {
el.addEventListener("change", renderAllMessages);
});
els.btnSelectImages?.addEventListener("click", selectVisibleImageMessages);
els.btnSelectFiles?.addEventListener("click", () => selectVisibleFileMessages());
els.globalSearchMode.addEventListener("change", () => {
syncGlobalSearchModeUi();
const q = els.globalSearch.value;
if (els.globalSearchMode.value === "files" || q.trim()) {
runGlobalSearch(q);
} else {
els.globalSearchResults.classList.add("hidden");
}
});
els.globalFileType.addEventListener("change", () => {
if (els.globalSearchMode.value === "files") runGlobalSearch(els.globalSearch.value);
});
syncGlobalSearchModeUi();
els.importModePick.addEventListener("change", () => {
if (!state.selectedChannel) return;
const entry = channelEntry(state.selectedChannel);
@ -1244,6 +1443,24 @@ function bindEvents() {
els.btnMsgPrev.addEventListener("click", goPrevMessage);
els.messages.addEventListener("click", onMessageListClick);
els.messages.addEventListener("change", (e) => {
const input = e.target.closest("input[data-msg-check]");
if (!input || !state.selectedChannel) return;
e.stopPropagation();
const row = input.closest(".message");
const msg = state.allMessages.find((m) => m.id === row?.dataset.id);
if (!msg) return;
setMsgImport(state.selectedChannel, msg, input.checked);
renderAllMessages();
renderSidebar();
updateTriageBar();
scheduleAutosave();
if (state.selectedMessageId === msg.id) {
openMessageDetail(msg);
} else {
focusMessage(msg.id, { scroll: false });
}
});
els.msgAction.addEventListener("change", () => {
els.msgMergeTarget.classList.toggle("hidden", els.msgAction.value !== "merge");
@ -1266,7 +1483,7 @@ function bindEvents() {
els.globalSearch.addEventListener("input", () => {
clearTimeout(state.globalSearchTimer);
const q = els.globalSearch.value;
if (!q.trim()) {
if (!q.trim() && els.globalSearchMode.value !== "files") {
els.globalSearchResults.classList.add("hidden");
return;
}
@ -1358,7 +1575,13 @@ async function init() {
bindEvents();
try {
const summary = await api("/api/summary");
await Promise.all([loadManifest(), loadChannels(), api("/api/users").then((d) => { state.users = d.users; })]);
await loadManifest();
await Promise.all([
loadChannels(),
api("/api/users").then((d) => {
state.users = d.users;
}),
]);
renderStats(summary);
renderSidebar();
renderSelectionSummary();

View File

@ -12,12 +12,29 @@
<h1>Slack import review</h1>
<div class="stats" id="stats"></div>
<div class="global-search-wrap">
<input
type="search"
id="global-search"
placeholder="Search channels & messages… (press /)"
autocomplete="off"
/>
<div class="global-search-row">
<select id="global-search-mode" title="Search scope">
<option value="text">Text</option>
<option value="files">Files (all channels)</option>
</select>
<select id="global-file-type" class="hidden" title="Attachment type">
<option value="all">All types</option>
<option value="image">Images</option>
<option value="pdf">PDF</option>
<option value="doc">Word</option>
<option value="spreadsheet">Spreadsheets</option>
<option value="slides">Slides</option>
<option value="video">Video</option>
<option value="text">Text files</option>
<option value="other">Other</option>
</select>
<input
type="search"
id="global-search"
placeholder="Search channels & messages… (press /)"
autocomplete="off"
/>
</div>
<div class="search-results hidden" id="global-search-results"></div>
</div>
<div class="actions">
@ -33,7 +50,7 @@
<option value="all">All</option>
<option value="undecided">Undecided</option>
<option value="import">Import</option>
<option value="partial">Cherry-pick</option>
<option value="partial">Has picks</option>
<option value="skip">Skip</option>
</select>
<label class="chk"><input type="checkbox" id="filter-hide-archived" /> Hide archived</label>
@ -73,6 +90,16 @@
<span class="msg-nav-status" id="msg-nav-status"></span>
</div>
<input type="search" id="message-search" placeholder="Filter messages…" />
<select id="filter-attachment-type" title="Show messages with this attachment type">
<option value="all">All msgs</option>
<option value="any">Has any file</option>
<option value="image">Has image</option>
<option value="pdf">Has PDF</option>
<option value="doc">Has doc</option>
<option value="spreadsheet">Has spreadsheet</option>
</select>
<button type="button" class="btn-mini" id="btn-select-images" title="Toggle import for visible image messages">Select images in view</button>
<button type="button" class="btn-mini" id="btn-select-files" title="Toggle import for visible file attachments">Select files in view</button>
<label class="chk"><input type="checkbox" id="filter-hide-bots" /> Hide bots</label>
<label class="chk"><input type="checkbox" id="filter-hide-system" checked /> Hide system</label>
<label class="chk"><input type="checkbox" id="filter-only-selected" /> Only selected</label>

69
tests/README.md Normal file
View File

@ -0,0 +1,69 @@
# Tests
## Fixture (seeded from your export)
`tests/fixtures/mini-export/` is a small slice of the real Levkin export:
- `#medical`, `#zoey`, `#153niagara` (first 4 day-files each, ~54 messages)
- Regenerate after export changes:
```bash
make fixture
# or
python3 scripts/build-test-fixture.py "/path/to/export"
```
## Python (unit + API)
```bash
make test-unit
```
Covers:
- `slack_export.py` — parse, search, file types
- `scripts/auto-cherry-pick.py` — drafts, data-only rules, return channels
- `server.py` — HTTP API against mini-export
Uses `.venv` automatically.
## Playwright (UI)
```bash
make test-e2e
```
Covers:
- Channel list, filters (**All** / **Has picks**)
- Cherry-pick checkbox on/off
- Detail panel **Skip**
- **Only selected** filter
- Global **Files** search
Runs server on port **18765** with `tests/fixtures/test-manifest.json`.
## All
```bash
make test
```
## Coverage
```bash
make test-unit # includes --cov report
```
Current targets (~**98%** Python, **11** Playwright tests):
| Module | Coverage | Notes |
|--------|----------|-------|
| `slack_export.py` | ~98% | canvases index, HTTP 401, search limits |
| `server.py` | ~98% | logging when not quiet, empty DOCX body |
| `scripts/auto-cherry-pick.py` | via `test_auto_cherry_pick_main.py` | full `main()` on fixture |
| `static/app.js` | Playwright e2e | bulk select, text search, save |
**100** pytest cases + **11** e2e. Manifest is copied per session and reset before each API test so POST cases do not leak state.
Server runs **in-process** during pytest; Playwright uses port **18765**.

138
tests/conftest.py Normal file
View File

@ -0,0 +1,138 @@
"""Pytest fixtures — mini-export seeded from real Levkin Slack data."""
from __future__ import annotations
import importlib.util
import json
import shutil
import socket
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
FIXTURE_EXPORT = REPO_ROOT / "tests" / "fixtures" / "mini-export"
TEST_MANIFEST = REPO_ROOT / "tests" / "fixtures" / "test-manifest.json"
def _load_cherry_pick_module():
path = REPO_ROOT / "scripts" / "auto-cherry-pick.py"
spec = importlib.util.spec_from_file_location("auto_cherry_pick", path)
mod = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="session")
def cherry_pick():
return _load_cherry_pick_module()
@pytest.fixture(scope="session")
def fixture_export_path() -> Path:
assert FIXTURE_EXPORT.is_dir(), (
f"Run: python3 scripts/build-test-fixture.py ({FIXTURE_EXPORT})"
)
return FIXTURE_EXPORT
@pytest.fixture
def export(fixture_export_path):
sys.path.insert(0, str(REPO_ROOT))
from slack_export import SlackExport
return SlackExport(fixture_export_path)
@pytest.fixture
def empty_manifest(fixture_export_path, tmp_path):
data = {
"version": 1,
"export_path": str(fixture_export_path),
"updated_at": None,
"global_notes": "",
"channels": {},
"users": {},
}
path = tmp_path / "manifest.json"
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return path, data
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_health(url: str, timeout: float = 8.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with urllib.request.urlopen(url, timeout=1) as resp:
if resp.status == 200:
return
except (urllib.error.URLError, TimeoutError):
time.sleep(0.15)
raise RuntimeError(f"Server did not start: {url}")
@pytest.fixture(scope="session")
def review_server(tmp_path_factory):
"""Start review server in-process (for coverage) with isolated manifest copy."""
import os
import threading
from http.server import ThreadingHTTPServer
sys.path.insert(0, str(REPO_ROOT))
from server import ReviewHandler
from slack_export import SlackExport
port = _free_port()
manifest = tmp_path_factory.mktemp("manifest") / "manifest.json"
shutil.copy(TEST_MANIFEST, manifest)
export = SlackExport(FIXTURE_EXPORT)
handler_cls = type(
"BoundReviewHandler",
(ReviewHandler,),
{"export": export, "manifest_path": manifest},
)
os.environ["SLACK_IMPORT_QUIET"] = "1"
httpd = ThreadingHTTPServer(("127.0.0.1", port), handler_cls)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{port}"
try:
_wait_health(f"{base}/api/health")
yield {"base": base, "port": port, "httpd": httpd, "manifest_path": manifest}
finally:
httpd.shutdown()
@pytest.fixture
def api(review_server):
# Reset manifest so POST tests cannot leak state across cases.
shutil.copy(TEST_MANIFEST, review_server["manifest_path"])
base = review_server["base"]
def get(path: str) -> dict:
with urllib.request.urlopen(f"{base}{path}", timeout=5) as resp:
return json.loads(resp.read().decode())
def post(path: str, body: dict) -> dict:
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{base}{path}",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read().decode())
return {"get": get, "post": post, "base": base}

139
tests/e2e/review-ui.spec.ts Normal file
View File

@ -0,0 +1,139 @@
import { test, expect } from "@playwright/test";
const BASE = process.env.SLACK_REVIEW_URL || "http://127.0.0.1:18765";
async function waitForAppReady(page: import("@playwright/test").Page) {
await page.goto(BASE);
await expect(page.locator("#channel-title")).not.toHaveText("Select a channel", {
timeout: 15_000,
});
}
async function showAllChannels(page: import("@playwright/test").Page) {
await page.locator("#filter-status").selectOption("all");
await expect(page.locator("#sidebar-list .list-item").first()).toBeVisible();
}
async function openChannel(page: import("@playwright/test").Page, name: string) {
await showAllChannels(page);
const btn = page.locator("#sidebar-list button").filter({ hasText: name });
await btn.first().click();
}
test.describe("Review UI", () => {
test.beforeAll(async ({ request }) => {
const res = await request.get(`${BASE}/api/health`);
expect(res.ok()).toBeTruthy();
});
test("loads channel list and stats", async ({ page }) => {
await waitForAppReady(page);
await expect(page.locator("h1")).toHaveText("Slack import review");
await expect(page.locator("#stats")).not.toBeEmpty();
await expect(page.locator("#sidebar-list .list-item").first()).toBeVisible({
timeout: 10_000,
});
});
test("opens medical channel and shows messages", async ({ page }) => {
await waitForAppReady(page);
await openChannel(page, "medical");
await expect(page.locator("#channel-title")).toHaveText("#medical");
await expect(page.locator(".message").first()).toBeVisible({ timeout: 10_000 });
});
test("cherry-pick checkbox toggles on and off", async ({ page }) => {
await waitForAppReady(page);
await openChannel(page, "medical");
await page.waitForSelector(".message", { timeout: 10_000 });
const row = page.locator(".message").first();
const checkbox = row.locator("input[data-msg-check]");
await checkbox.check();
await expect(row).toHaveClass(/sel-import/);
await checkbox.uncheck();
await expect(row).not.toHaveClass(/sel-import/);
});
test("detail panel skip clears selection", async ({ page }) => {
await waitForAppReady(page);
await openChannel(page, "medical");
await page.waitForSelector(".message", { timeout: 10_000 });
const row = page.locator(".message").first();
await row.click();
await expect(page.locator("#message-detail")).toBeVisible();
await page.locator("#msg-action").selectOption("import");
await expect(row).toHaveClass(/sel-import/);
await page.locator("#msg-action").selectOption("skip");
await expect(row).not.toHaveClass(/sel-import/);
});
test("filter only selected hides unchecked", async ({ page }) => {
await waitForAppReady(page);
await openChannel(page, "zoey");
await page.waitForSelector(".message", { timeout: 10_000 });
const before = await page.locator(".message").count();
const checkbox = page.locator(".message").first().locator("input[data-msg-check]");
await checkbox.check();
await page.locator("#filter-only-selected").check();
const after = await page.locator(".message").count();
expect(after).toBeLessThanOrEqual(before);
expect(after).toBeGreaterThanOrEqual(1);
});
test("global file search mode returns hits", async ({ page }) => {
await waitForAppReady(page);
await page.locator("#global-search-mode").selectOption("files");
await page.locator("#global-file-type").selectOption("image");
await page.waitForTimeout(400);
const results = page.locator("#global-search-results .search-hit");
const count = await results.count();
expect(count).toBeGreaterThanOrEqual(0);
});
test("sidebar Has picks filter", async ({ page }) => {
await waitForAppReady(page);
await page.locator("#filter-status").selectOption("partial");
const items = page.locator("#sidebar-list .list-item");
await expect(items.first()).toBeVisible();
const count = await items.count();
expect(count).toBeGreaterThanOrEqual(1);
});
test("import mode pick checkbox enables cherry-pick", async ({ page }) => {
await waitForAppReady(page);
await openChannel(page, "medical");
await page.locator("#import-mode-pick").check();
await expect(page.locator("#import-mode-pick")).toBeChecked();
});
test("save button shows confirmation", async ({ page }) => {
await waitForAppReady(page);
await page.locator("#btn-save").click();
await expect(page.locator("#save-status")).toContainText("Saved", { timeout: 3000 });
});
test("global text search finds channel", async ({ page }) => {
await waitForAppReady(page);
await page.locator("#global-search-mode").selectOption("text");
await page.locator("#global-search").fill("niagara");
await page.waitForTimeout(400);
await expect(page.locator("#global-search-results .search-hit").first()).toBeVisible();
});
test("select files in view toggles", async ({ page }) => {
await waitForAppReady(page);
await openChannel(page, "153niagara");
await page.waitForSelector(".message", { timeout: 10_000 });
await page.locator("#filter-attachment-type").selectOption("any");
const btn = page.locator("#btn-select-files");
await btn.click();
await expect(btn).toContainText("Clear", { timeout: 3000 });
await btn.click();
await expect(btn).toContainText("Select", { timeout: 3000 });
});
});

View File

@ -0,0 +1,17 @@
[
{
"subtype": "channel_join",
"user": "U2HHNN1HQ",
"text": "<@U2HHNN1HQ> has joined the channel",
"type": "message",
"ts": "1475689651.000002"
},
{
"subtype": "channel_join",
"user": "U2J30FCLW",
"text": "<@U2J30FCLW> has joined the channel",
"inviter": "U2HHNN1HQ",
"type": "message",
"ts": "1475689652.000003"
}
]

View File

@ -0,0 +1,198 @@
[
{
"text": "<@U2HHNN1HQ|jedi_jew> shared a file: <https:\/\/levkine.slack.com\/files\/jedi_jew\/F2N37HDQA\/Proof_read|Proof read>",
"files": [
{
"id": "F049MQBLJBF",
"created": 1476210524,
"timestamp": 1476210524,
"mimetype": "application\/vnd.slack-docs",
"filetype": "quip",
"pretty_type": "Canvas",
"user": "U2HHNN1HQ",
"user_team": "T2HHNN1FU",
"editable": true,
"size": 1039,
"mode": "quip",
"is_external": false,
"external_type": "",
"is_public": true,
"public_url_shared": false,
"display_as_bot": false,
"username": "",
"name": "Proof_read",
"title": "Proof read",
"is_modified_by_ai": false,
"url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF",
"permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049MQBLJBF-8ae09c2c9a",
"url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF\/mobile",
"quip_thread_id": "EYc9AAYAZZK",
"updated": 1476210573,
"update_notification": null,
"canvas_readtime": 0.84945804897631,
"is_starred": false,
"skipped_shares": true,
"is_restricted_sharing_enabled": false,
"has_rich_preview": false,
"file_access": "visible",
"access": "owner",
"org_or_workspace_access": "read",
"canvas_creator_id": "U2HHNN1HQ",
"team_pref_version_history_enabled": true,
"canvas_printing_enabled": true,
"is_ai_suggested": false
}
],
"upload": false,
"user": "U2HHNN1HQ",
"display_as_bot": false,
"blocks": [
{
"type": "rich_text",
"block_id": "2ktK",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "user",
"user_id": "U2HHNN1HQ"
},
{
"type": "text",
"text": " shared a file: "
},
{
"type": "link",
"url": "https:\/\/levkine.slack.com\/files\/jedi_jew\/F2N37HDQA\/Proof_read",
"text": "Proof read"
}
]
}
]
}
],
"type": "message",
"ts": "1476210563.000002"
},
{
"subtype": "file_comment",
"text": "<@U2HHNN1HQ> commented on <@U2HHNN1HQ>s file <https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF|https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF>: You can proof read in the post",
"comment": {
"id": "Fc2N2M6J5A",
"created": 1476210563,
"timestamp": 1476210563,
"user": "U2HHNN1HQ",
"is_intro": false,
"comment": "You can proof read in the post"
},
"is_intro": false,
"file": {
"id": "F049MQBLJBF",
"created": 1476210524,
"timestamp": 1476210524,
"mimetype": "application\/vnd.slack-docs",
"filetype": "quip",
"pretty_type": "Canvas",
"user": "U2HHNN1HQ",
"user_team": "T2HHNN1FU",
"editable": true,
"size": 1039,
"mode": "quip",
"is_external": false,
"external_type": "",
"is_public": true,
"public_url_shared": false,
"display_as_bot": false,
"username": "",
"name": "Proof_read",
"title": "Proof read",
"is_modified_by_ai": false,
"url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF",
"permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049MQBLJBF-8ae09c2c9a",
"url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF\/mobile",
"quip_thread_id": "EYc9AAYAZZK",
"updated": 1476210573,
"update_notification": null,
"canvas_readtime": 0.84945804897631,
"is_starred": false,
"skipped_shares": true,
"is_restricted_sharing_enabled": false,
"has_rich_preview": false,
"file_access": "visible",
"access": "owner",
"org_or_workspace_access": "read",
"canvas_creator_id": "U2HHNN1HQ",
"team_pref_version_history_enabled": true,
"canvas_printing_enabled": true,
"is_ai_suggested": false
},
"files": [
{
"id": "F049MQBLJBF",
"created": 1476210524,
"timestamp": 1476210524,
"mimetype": "application\/vnd.slack-docs",
"filetype": "quip",
"pretty_type": "Canvas",
"user": "U2HHNN1HQ",
"user_team": "T2HHNN1FU",
"editable": true,
"size": 1039,
"mode": "quip",
"is_external": false,
"external_type": "",
"is_public": true,
"public_url_shared": false,
"display_as_bot": false,
"username": "",
"name": "Proof_read",
"title": "Proof read",
"is_modified_by_ai": false,
"url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049MQBLJBF\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF",
"permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049MQBLJBF-8ae09c2c9a",
"url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF\/mobile",
"quip_thread_id": "EYc9AAYAZZK",
"updated": 1476210573,
"update_notification": null,
"canvas_readtime": 0.84945804897631,
"is_starred": false,
"skipped_shares": true,
"is_restricted_sharing_enabled": false,
"has_rich_preview": false,
"file_access": "visible",
"access": "owner",
"org_or_workspace_access": "read",
"canvas_creator_id": "U2HHNN1HQ",
"team_pref_version_history_enabled": true,
"canvas_printing_enabled": true,
"is_ai_suggested": false
}
],
"team": "T00",
"blocks": [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "<@U2HHNN1HQ> commented on <@U2HHNN1HQ>s file <https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF|https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049MQBLJBF>: You can proof read in the post"
}
]
}
]
}
],
"type": "message",
"ts": "1476210563.000003"
}
]

View File

@ -0,0 +1,309 @@
[
{
"text": "<@U2J30FCLW|ira_feeds> shared a file: <https:\/\/levkine.slack.com\/files\/ira_feeds\/F2R3Y6L9L\/draft_-_hot_plate|draft - hot plate>",
"files": [
{
"id": "F049QLYJZSN",
"created": 1476833849,
"timestamp": 1476833849,
"mimetype": "application\/vnd.slack-docs",
"filetype": "quip",
"pretty_type": "Canvas",
"user": "U2J30FCLW",
"user_team": "T2HHNN1FU",
"editable": true,
"size": 483,
"mode": "quip",
"is_external": false,
"external_type": "",
"is_public": true,
"public_url_shared": false,
"display_as_bot": false,
"username": "",
"name": "draft_-_hot_plate",
"title": "draft - hot plate",
"is_modified_by_ai": false,
"url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN",
"permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049QLYJZSN-1ded605162",
"url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN\/mobile",
"quip_thread_id": "QeY9AAYD2SZ",
"updated": 1476833915,
"update_notification": null,
"canvas_readtime": 0.40626254516259,
"is_starred": false,
"skipped_shares": true,
"is_restricted_sharing_enabled": false,
"has_rich_preview": false,
"file_access": "visible",
"access": "write",
"org_or_workspace_access": "write",
"canvas_creator_id": "U2J30FCLW",
"team_pref_version_history_enabled": true,
"canvas_printing_enabled": true,
"is_ai_suggested": false
}
],
"upload": false,
"user": "U2J30FCLW",
"display_as_bot": false,
"blocks": [
{
"type": "rich_text",
"block_id": "NzVx",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "user",
"user_id": "U2J30FCLW"
},
{
"type": "text",
"text": " shared a file: "
},
{
"type": "link",
"url": "https:\/\/levkine.slack.com\/files\/ira_feeds\/F2R3Y6L9L\/draft_-_hot_plate",
"text": "draft - hot plate"
}
]
}
]
}
],
"type": "message",
"ts": "1476833882.000005"
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1476833895.000006",
"text": "boom chikadee",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "93iAJ",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "boom chikadee"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1476833908.000007",
"text": "i dont have their emails on me, do you want to send it out?",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "H8ho",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "i dont have their emails on me, do you want to send it out?"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1476834078.000008",
"text": "sure",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "SbFFK",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "sure"
}
]
}
]
}
]
},
{
"subtype": "file_comment",
"text": "<@U2HHNN1HQ> commented on <@U2J30FCLW>s file <https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN|https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN>: perfect",
"comment": {
"id": "Fc2R4FEFRQ",
"created": 1476834088,
"timestamp": 1476834088,
"user": "U2HHNN1HQ",
"is_intro": false,
"comment": "perfect"
},
"is_intro": false,
"file": {
"id": "F049QLYJZSN",
"created": 1476833849,
"timestamp": 1476833849,
"mimetype": "application\/vnd.slack-docs",
"filetype": "quip",
"pretty_type": "Canvas",
"user": "U2J30FCLW",
"user_team": "T2HHNN1FU",
"editable": true,
"size": 483,
"mode": "quip",
"is_external": false,
"external_type": "",
"is_public": true,
"public_url_shared": false,
"display_as_bot": false,
"username": "",
"name": "draft_-_hot_plate",
"title": "draft - hot plate",
"is_modified_by_ai": false,
"url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN",
"permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049QLYJZSN-1ded605162",
"url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN\/mobile",
"quip_thread_id": "QeY9AAYD2SZ",
"updated": 1476833915,
"update_notification": null,
"canvas_readtime": 0.40626254516259,
"is_starred": false,
"skipped_shares": true,
"is_restricted_sharing_enabled": false,
"has_rich_preview": false,
"file_access": "visible",
"access": "write",
"org_or_workspace_access": "write",
"canvas_creator_id": "U2J30FCLW",
"team_pref_version_history_enabled": true,
"canvas_printing_enabled": true,
"is_ai_suggested": false
},
"files": [
{
"id": "F049QLYJZSN",
"created": 1476833849,
"timestamp": 1476833849,
"mimetype": "application\/vnd.slack-docs",
"filetype": "quip",
"pretty_type": "Canvas",
"user": "U2J30FCLW",
"user_team": "T2HHNN1FU",
"editable": true,
"size": 483,
"mode": "quip",
"is_external": false,
"external_type": "",
"is_public": true,
"public_url_shared": false,
"display_as_bot": false,
"username": "",
"name": "draft_-_hot_plate",
"title": "draft - hot plate",
"is_modified_by_ai": false,
"url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F049QLYJZSN\/download\/canvas?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"permalink": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN",
"permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F049QLYJZSN-1ded605162",
"url_static_preview": "https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN\/mobile",
"quip_thread_id": "QeY9AAYD2SZ",
"updated": 1476833915,
"update_notification": null,
"canvas_readtime": 0.40626254516259,
"is_starred": false,
"skipped_shares": true,
"is_restricted_sharing_enabled": false,
"has_rich_preview": false,
"file_access": "visible",
"access": "write",
"org_or_workspace_access": "write",
"canvas_creator_id": "U2J30FCLW",
"team_pref_version_history_enabled": true,
"canvas_printing_enabled": true,
"is_ai_suggested": false
}
],
"team": "T00",
"blocks": [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "<@U2HHNN1HQ> commented on <@U2J30FCLW>s file <https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN|https:\/\/levkine.slack.com\/docs\/T2HHNN1FU\/F049QLYJZSN>: perfect"
}
]
}
]
}
],
"type": "message",
"ts": "1476834088.000009"
}
]

View File

@ -0,0 +1,298 @@
[
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1477341477.000002",
"text": "someone from easyhome just came by for kerry",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "KaUr\/",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "someone from easyhome just came by for kerry"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1477341924.000003",
"text": "and?",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "cbcz",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "and?"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1477342010.000004",
"text": "he didnt say anything. i mentioned she has aliases, thought maybe if we give them to easyhome, they can track her down and it might be easier for us to collect on what she owes",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "31r",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "he didnt say anything. i mentioned she has aliases, thought maybe if we give them to easyhome, they can track her down and it might be easier for us to collect on what she owes"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1477342047.000005",
"text": "i emailed the collection agency today",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "uUcSc",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "i emailed the collection agency today"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1477342051.000006",
"text": "did they ask about the bed?",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "KyL8",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "did they ask about the bed?"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1477342059.000007",
"text": "nope, and i didnt say anything",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "Z+r2R",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "nope, and i didnt say anything"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1477342065.000008",
"text": "good",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "kRTUp",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "good"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1477342068.000009",
"text": "i really want to keep it",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "F2F9",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "i really want to keep it"
}
]
}
]
}
]
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View File

@ -0,0 +1 @@
fixture preview text

View File

@ -0,0 +1,68 @@
[
{
"id": "C0121TJUSUD",
"name": "zoey",
"created": 1587214815,
"creator": "U2HHNN1HQ",
"is_archived": false,
"is_general": false,
"members": [
"U2HHNN1HQ",
"U2J30FCLW"
],
"topic": {
"value": "",
"creator": "",
"last_set": 0
},
"purpose": {
"value": "",
"creator": "",
"last_set": 0
}
},
{
"id": "C2KPK5G8N",
"name": "153niagara",
"created": 1475689652,
"creator": "U2HHNN1HQ",
"is_archived": false,
"is_general": false,
"members": [
"U2HHNN1HQ",
"U2J30FCLW"
],
"topic": {
"value": "",
"creator": "",
"last_set": 0
},
"purpose": {
"value": "",
"creator": "",
"last_set": 0
}
},
{
"id": "C2KQHKAV8",
"name": "medical",
"created": 1475691337,
"creator": "U2HHNN1HQ",
"is_archived": false,
"is_general": false,
"members": [
"U2HHNN1HQ",
"U2J30FCLW"
],
"topic": {
"value": "",
"creator": "",
"last_set": 0
},
"purpose": {
"value": "",
"creator": "",
"last_set": 0
}
}
]

View File

@ -0,0 +1,17 @@
[
{
"subtype": "channel_join",
"user": "U2HHNN1HQ",
"text": "<@U2HHNN1HQ> has joined the channel",
"type": "message",
"ts": "1475691337.000002"
},
{
"subtype": "channel_join",
"user": "U2J30FCLW",
"text": "<@U2J30FCLW> has joined the channel",
"inviter": "U2HHNN1HQ",
"type": "message",
"ts": "1475691337.000003"
}
]

View File

@ -0,0 +1,261 @@
[
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1476122672.000002",
"text": "subclinical hypothyroidism d\/t autoimmunity = hashimoto's thyroiditis.",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "xcov",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "subclinical hypothyroidism d\/t autoimmunity = hashimoto's thyroiditis."
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1476122755.000003",
"text": "alterations in thyroid functioning, in this case hypothyroidism manifest as reduced appetite, low energy, chronic infections (hello - persistent GI infections), weight loss, skin issues, CONSTIPATION, and literally all of my other symptoms",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "ME3ih",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "alterations in thyroid functioning, in this case hypothyroidism manifest as reduced appetite, low energy, chronic infections (hello - persistent GI infections), weight loss, skin issues, CONSTIPATION, and literally all of my other symptoms"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1476122785.000004",
"text": "So how do we fix it?",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "Ojdhx",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "So how do we fix it?"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1476122816.000005",
"text": "its a chicken and egg thing - dont know if the hashimoto's or the GI issues came first so need to fix both at same time",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "9+Oie",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "its a chicken and egg thing - dont know if the hashimoto's or the GI issues came first so need to fix both at same time"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1476122850.000006",
"text": "synthetic thyroid (already on it) and fixing leaky gut (may need to do new food sensitivity testing)",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "OlG",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "synthetic thyroid (already on it) and fixing leaky gut (may need to do new food sensitivity testing)"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1476122864.000007",
"text": "the synthroid may be too low a dose",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "+6S",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "the synthroid may be too low a dose"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1476122903.000008",
"text": "but we'll see",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "oZKHf",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "but we'll see"
}
]
}
]
}
]
}
]

View File

@ -0,0 +1,241 @@
[
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1477621049.000002",
"text": "<https:\/\/www.tweed.com\/collections\/available\/products\/ocean-view-leafs-by-snoop>",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"attachments": [
{
"service_name": "Tweed",
"title": "OCEAN VIEW (Leafs by Snoop)",
"title_link": "https:\/\/www.tweed.com\/collections\/available\/products\/ocean-view-leafs-by-snoop",
"text": "THC: 15.7\/ CBD: &lt;0.05 ↓Also of Note THC and CBD amounts listed for this strain have been tested by Tweed. This product has not been cold-pasteurized. To lear",
"fallback": "Tweed: OCEAN VIEW (Leafs by Snoop)",
"thumb_url": "http:\/\/cdn.shopify.com\/s\/files\/1\/0266\/0901\/products\/Untitled-4_grande.png?v=1477513626",
"thumb_width": 41,
"thumb_height": 40,
"fields": [
{
"title": "Price",
"value": "From $9.60 CAD",
"short": true
},
{
"title": "Brand",
"value": "Leafs by Snoop",
"short": true
}
],
"from_url": "https:\/\/www.tweed.com\/collections\/available\/products\/ocean-view-leafs-by-snoop",
"service_icon": "http:\/\/cdn.shopify.com\/s\/files\/1\/0266\/0901\/t\/16\/assets\/favicon.png?17894041713191168692",
"id": 1
}
],
"blocks": [
{
"type": "rich_text",
"block_id": "KpkG",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "link",
"url": "https:\/\/www.tweed.com\/collections\/available\/products\/ocean-view-leafs-by-snoop"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1477621066.000004",
"text": "nice",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "Il6oK",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "nice"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1477621108.000005",
"text": "<https:\/\/www.tweed.com\/collections\/available\/products\/palm-tree-cbd-leafs-by-snoop>",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"attachments": [
{
"service_name": "Tweed",
"title": "PALM TREE CBD (Leafs By Snoop)",
"title_link": "https:\/\/www.tweed.com\/collections\/available\/products\/palm-tree-cbd-leafs-by-snoop",
"text": "THC: 8.71\/ CBD: 9.76 ↓Also of Note THC and CBD amounts listed for this strain have been tested by Tweed. This product has not been cold-pasteurized. To learn mo",
"fallback": "Tweed: PALM TREE CBD (Leafs By Snoop)",
"thumb_url": "http:\/\/cdn.shopify.com\/s\/files\/1\/0266\/0901\/products\/Untitled-44_grande.png?v=1477513710",
"thumb_width": 41,
"thumb_height": 40,
"fields": [
{
"title": "Price",
"value": "From $7.20 CAD",
"short": true
},
{
"title": "Brand",
"value": "Leafs by Snoop",
"short": true
}
],
"from_url": "https:\/\/www.tweed.com\/collections\/available\/products\/palm-tree-cbd-leafs-by-snoop",
"service_icon": "http:\/\/cdn.shopify.com\/s\/files\/1\/0266\/0901\/t\/16\/assets\/favicon.png?17894041713191168692",
"id": 1
}
],
"blocks": [
{
"type": "rich_text",
"block_id": "ueWRO",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "link",
"url": "https:\/\/www.tweed.com\/collections\/available\/products\/palm-tree-cbd-leafs-by-snoop"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1477621115.000007",
"text": "that one is similar to Devon",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "FwcA",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "that one is similar to Devon"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1477621164.000008",
"text": "the breakdown is a bit strange though - its supposedly focusing and sleepy at the same time",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "thl",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "the breakdown is a bit strange though - its supposedly focusing and sleepy at the same time"
}
]
}
]
}
]
}
]

View File

@ -0,0 +1,84 @@
[
{
"text": "",
"files": [
{
"id": "FCT24D5B7",
"created": 1536844410,
"timestamp": 1536844410,
"mimetype": "application\/pdf",
"filetype": "pdf",
"pretty_type": "PDF",
"user": "U2HHNN1HQ",
"user_team": "T2HHNN1FU",
"editable": false,
"size": 8190867,
"mode": "hosted",
"is_external": false,
"external_type": "",
"is_public": true,
"public_url_shared": false,
"display_as_bot": false,
"username": "",
"name": "img20180913_09120499.pdf",
"title": "img20180913_09120499.pdf",
"is_modified_by_ai": false,
"url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-FCT24D5B7\/img20180913_09120499.pdf?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-FCT24D5B7\/download\/img20180913_09120499.pdf?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"media_display_type": "unknown",
"thumb_pdf": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-FCT24D5B7-45e94cde7b\/img20180913_09120499_thumb_pdf.png?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_pdf_w": 930,
"thumb_pdf_h": 1196,
"permalink": "https:\/\/levkine.slack.com\/files\/U2HHNN1HQ\/FCT24D5B7\/img20180913_09120499.pdf",
"permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-FCT24D5B7-e76617f641",
"is_starred": false,
"skipped_shares": true,
"has_rich_preview": false,
"file_access": "visible"
}
],
"upload": true,
"user": "U2HHNN1HQ",
"display_as_bot": false,
"type": "message",
"ts": "1536844412.000100"
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1536844629.000100",
"client_msg_id": "a0e588b0-0bf3-442a-bf02-edfbea2bec31",
"text": "Can you email so I can forward? ",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "Bv=9",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Can you email so I can forward?"
}
]
}
]
}
]
}
]

933
tests/fixtures/mini-export/users.json vendored Normal file
View File

@ -0,0 +1,933 @@
[
{
"id": "U2HHNN1HQ",
"team_id": "T2HHNN1FU",
"name": "jedi_jew",
"deleted": false,
"color": "9f69e7",
"real_name": "Ilia Dobkin",
"tz": "America/New_York",
"tz_label": "Eastern Daylight Time",
"tz_offset": -14400,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Ilia Dobkin",
"real_name_normalized": "Ilia Dobkin",
"display_name": "Ilia",
"display_name_normalized": "Ilia",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "gde41167d309",
"huddle_state": "default_unset",
"huddle_state_expiration_ts": 0,
"first_name": "Ilia",
"last_name": "Dobkin",
"image_24": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-24.png",
"image_32": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-32.png",
"image_48": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-48.png",
"image_72": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"image_192": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-192.png",
"image_512": "https://secure.gravatar.com/avatar/ede41167d309b977e5c67645d9a1a699.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": true,
"is_owner": true,
"is_primary_owner": true,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": false,
"is_app_user": false,
"updated": 1747338096,
"is_email_confirmed": true,
"who_can_share_contact_card": "EVERYONE"
},
{
"id": "U2J30FCLW",
"team_id": "T2HHNN1FU",
"name": "ira_feeds",
"deleted": false,
"color": "4bbe2e",
"real_name": "Irina",
"tz": "America/New_York",
"tz_label": "Eastern Daylight Time",
"tz_offset": -14400,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Irina",
"real_name_normalized": "Irina",
"display_name": "ira_feeds",
"display_name_normalized": "ira_feeds",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "gc49a37283df",
"first_name": "Irina",
"last_name": "",
"image_24": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-24.png",
"image_32": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-32.png",
"image_48": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-48.png",
"image_72": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"image_192": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-192.png",
"image_512": "https://secure.gravatar.com/avatar/bc49a37283dfa139af6916bb32030146.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": true,
"is_owner": false,
"is_primary_owner": false,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": false,
"is_app_user": false,
"updated": 1626449137,
"is_email_confirmed": true,
"who_can_share_contact_card": "EVERYONE"
},
{
"id": "U2KQS74Q0",
"team_id": "T2HHNN1FU",
"name": "humblebot",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Humblebot",
"real_name_normalized": "Humblebot",
"display_name": "humblebot",
"display_name_normalized": "humblebot",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "b11cb3deda79",
"api_app_id": "A0H11TLMD",
"image_original": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_original.png",
"is_custom_image": true,
"bot_id": "B2KR3BLG1",
"first_name": "Humblebot",
"image_24": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_24.png",
"image_32": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_32.png",
"image_48": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_48.png",
"image_72": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_72.png",
"image_192": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_192.png",
"image_512": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_512.png",
"image_1024": "https://avatars.slack-edge.com/2016-10-05/87859422434_b11cb3deda792d278311_512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": true,
"is_app_user": false,
"updated": 1742354811
},
{
"id": "U3WCAAHV4",
"team_id": "T2HHNN1FU",
"name": "deactivateduser196138",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Deactivated User",
"real_name_normalized": "Deactivated User",
"display_name": "deactivateduser",
"display_name_normalized": "deactivateduser",
"fields": null,
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "g849cc56ed76",
"huddle_state": "default_unset",
"first_name": "Deactivated",
"last_name": "User",
"image_24": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-24.png",
"image_32": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-32.png",
"image_48": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-48.png",
"image_72": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-72.png",
"image_192": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-192.png",
"image_512": "https://secure.gravatar.com/avatar/512b06e3a7177de41d7c382e05055887.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0024-512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": false,
"is_app_user": false,
"updated": 1748398757,
"is_forgotten": true
},
{
"id": "UPE67TA20",
"team_id": "T2HHNN1FU",
"name": "google_calendar",
"deleted": false,
"color": "df3dc0",
"real_name": "Google Calendar",
"tz": "America/Los_Angeles",
"tz_label": "Pacific Daylight Time",
"tz_offset": -25200,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Google Calendar",
"real_name_normalized": "Google Calendar",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "379b3e470f6f",
"api_app_id": "ADZ494LHY",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_original.png",
"is_custom_image": true,
"bot_id": "BPE67T9PW",
"image_24": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_24.png",
"image_32": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_32.png",
"image_48": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_48.png",
"image_72": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_72.png",
"image_192": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_192.png",
"image_512": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_512.png",
"image_1024": "https://avatars.slack-edge.com/2026-02-18/10537829174518_379b3e470f6fb8f55e0d_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": false,
"is_owner": false,
"is_primary_owner": false,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": true,
"is_app_user": false,
"updated": 1771487052,
"is_email_confirmed": false,
"who_can_share_contact_card": "EVERYONE"
},
{
"id": "U06GCU7DDHU",
"team_id": "T2HHNN1FU",
"name": "aiintern",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "AI Intern",
"real_name_normalized": "AI Intern",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "7eedd5bf639b",
"api_app_id": "A04KKT0TQ2W",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_original.jpg",
"is_custom_image": true,
"bot_id": "B06H8B5JHR6",
"first_name": "AI",
"last_name": "Intern",
"image_24": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_24.jpg",
"image_32": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_32.jpg",
"image_48": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_48.jpg",
"image_72": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_72.jpg",
"image_192": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_192.jpg",
"image_512": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_512.jpg",
"image_1024": "https://avatars.slack-edge.com/2024-01-31/6576243776113_7eedd5bf639b86048f0c_1024.jpg",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": true,
"is_app_user": false,
"updated": 1742354804
},
{
"id": "U08JDB604N6",
"team_id": "T2HHNN1FU",
"name": "mural-bot",
"deleted": false,
"color": "e85d72",
"real_name": "MURAL",
"tz": "America/Los_Angeles",
"tz_label": "Pacific Daylight Time",
"tz_offset": -25200,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "MURAL",
"real_name_normalized": "MURAL",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "e36bc8361c8f",
"api_app_id": "A4LV0TKR9",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_original.jpg",
"is_custom_image": true,
"bot_id": "B08JDB604LS",
"first_name": "MURAL",
"last_name": "",
"image_24": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_24.jpg",
"image_32": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_32.jpg",
"image_48": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_48.jpg",
"image_72": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_72.jpg",
"image_192": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_192.jpg",
"image_512": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_512.jpg",
"image_1024": "https://avatars.slack-edge.com/2025-03-18/8645220738464_e36bc8361c8f486870cf_1024.jpg",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": false,
"is_owner": false,
"is_primary_owner": false,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": true,
"is_app_user": false,
"updated": 1742354230,
"is_email_confirmed": false,
"who_can_share_contact_card": "EVERYONE"
},
{
"id": "U2KQP4VUZ",
"team_id": "T2HHNN1FU",
"name": "meekan",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Meekan Scheduling",
"real_name_normalized": "Meekan Scheduling",
"display_name": "meekan",
"display_name_normalized": "meekan",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "7d4b8d227280",
"api_app_id": "A0G51AT60",
"image_original": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_original.png",
"is_custom_image": true,
"bot_id": "B2KQCB85C",
"first_name": "Meekan",
"last_name": "Scheduling",
"image_24": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_24.png",
"image_32": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_32.png",
"image_48": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_48.png",
"image_72": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_72.png",
"image_192": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_192.png",
"image_512": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_512.png",
"image_1024": "https://avatars.slack-edge.com/2017-11-27/278874366151_7d4b8d227280e8130276_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": true,
"is_app_user": false,
"updated": 1742354835
},
{
"id": "U2KR6LA3B",
"team_id": "T2HHNN1FU",
"name": "howdy",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Howdy",
"real_name_normalized": "Howdy",
"display_name": "howdy",
"display_name_normalized": "howdy",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "26e5aa1363cf",
"api_app_id": "A09RDP4AW",
"image_original": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_original.png",
"is_custom_image": true,
"bot_id": "B2KQVTFUN",
"first_name": "Howdy",
"image_24": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_24.png",
"image_32": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_32.png",
"image_48": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_48.png",
"image_72": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_72.png",
"image_192": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_192.png",
"image_512": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_512.png",
"image_1024": "https://avatars.slack-edge.com/2016-10-05/87845548640_26e5aa1363cf295e9a90_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": true,
"is_app_user": false,
"updated": 1504348811
},
{
"id": "U35CW23UZ",
"team_id": "T2HHNN1FU",
"name": "leo_officevibe_bot",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Leo (Officevibe Bot)",
"real_name_normalized": "Leo (Officevibe Bot)",
"display_name": "leo_officevibe_bot",
"display_name_normalized": "leo_officevibe_bot",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "65c14a601f21",
"api_app_id": "A0GU27WR1",
"always_active": false,
"image_original": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_original.png",
"is_custom_image": true,
"bot_id": "B35BYVB7A",
"first_name": "Leo (Officevibe Bot)",
"image_24": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_24.png",
"image_32": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_32.png",
"image_48": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_48.png",
"image_72": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_72.png",
"image_192": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_192.png",
"image_512": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_512.png",
"image_1024": "https://avatars.slack-edge.com/2016-11-22/108800284919_65c14a601f2106b2af3b_512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": true,
"is_app_user": false,
"updated": 1504349194
},
{
"id": "U3660DH2R",
"team_id": "T2HHNN1FU",
"name": "jewbot",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "jewbot",
"real_name_normalized": "jewbot",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "7ab64cd294c2",
"api_app_id": "",
"always_active": false,
"image_original": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_original.png",
"is_custom_image": true,
"bot_id": "B365PU67N",
"image_24": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_24.png",
"image_32": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_32.png",
"image_48": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_48.png",
"image_72": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_72.png",
"image_192": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_192.png",
"image_512": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_512.png",
"image_1024": "https://avatars.slack-edge.com/2016-11-22/108792815526_7ab64cd294c2adc03cc4_512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": true,
"is_app_user": false,
"updated": 1742354829
},
{
"id": "UBMJ5M7CZ",
"team_id": "T2HHNN1FU",
"name": "googledrive",
"deleted": false,
"color": "5b89d5",
"real_name": "Google Drive",
"tz": "America/Los_Angeles",
"tz_label": "Pacific Daylight Time",
"tz_offset": -25200,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Google Drive",
"real_name_normalized": "Google Drive",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "a74495570a68",
"api_app_id": "A6NL8MJ6Q",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_original.png",
"is_custom_image": true,
"bot_id": "BBMT728UU",
"first_name": "Google",
"last_name": "Drive",
"image_24": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_24.png",
"image_32": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_32.png",
"image_48": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_48.png",
"image_72": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_72.png",
"image_192": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_192.png",
"image_512": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_512.png",
"image_1024": "https://avatars.slack-edge.com/2020-10-15/1438649512884_a74495570a688b6aa1b5_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": false,
"is_owner": false,
"is_primary_owner": false,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": true,
"is_app_user": false,
"updated": 1602813021,
"is_email_confirmed": false,
"who_can_share_contact_card": "EVERYONE"
},
{
"id": "ULBNL5U3X",
"team_id": "T2HHNN1FU",
"name": "deactivateduser536011",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Deactivated User",
"real_name_normalized": "Deactivated User",
"display_name": "deactivateduser",
"display_name_normalized": "deactivateduser",
"fields": null,
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "g849cc56ed76",
"huddle_state": "default_unset",
"first_name": "Deactivated",
"last_name": "User",
"image_24": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-24.png",
"image_32": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-32.png",
"image_48": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-48.png",
"image_72": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-72.png",
"image_192": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-192.png",
"image_512": "https://secure.gravatar.com/avatar/6964489e1811fcd5a8604d2ec7c72abf.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0007-512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": false,
"is_app_user": false,
"updated": 1748398744,
"is_forgotten": true
},
{
"id": "UP862QZ2M",
"team_id": "T2HHNN1FU",
"name": "deactivateduser867192",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Deactivated User",
"real_name_normalized": "Deactivated User",
"display_name": "deactivateduser",
"display_name_normalized": "deactivateduser",
"fields": null,
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "g849cc56ed76",
"huddle_state": "default_unset",
"first_name": "Deactivated",
"last_name": "User",
"image_24": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-24.png",
"image_32": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-32.png",
"image_48": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-48.png",
"image_72": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-72.png",
"image_192": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-192.png",
"image_512": "https://secure.gravatar.com/avatar/51ac16be6dc728a09d4f2b3b0833f0ec.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0020-512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": false,
"is_app_user": false,
"updated": 1748398741,
"is_forgotten": true
},
{
"id": "UP8BUNWAV",
"team_id": "T2HHNN1FU",
"name": "deactivateduser876224",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Deactivated User",
"real_name_normalized": "Deactivated User",
"display_name": "deactivateduser",
"display_name_normalized": "deactivateduser",
"fields": null,
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "g849cc56ed76",
"huddle_state": "default_unset",
"first_name": "Deactivated",
"last_name": "User",
"image_24": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-24.png",
"image_32": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-32.png",
"image_48": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-48.png",
"image_72": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-72.png",
"image_192": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-192.png",
"image_512": "https://secure.gravatar.com/avatar/35499da5ddf5214ad21f33c672280a42.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0013-512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": false,
"is_app_user": false,
"updated": 1748398749,
"is_forgotten": true
},
{
"id": "UQF26AT1R",
"team_id": "T2HHNN1FU",
"name": "deactivateduser911684",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Deactivated User",
"real_name_normalized": "Deactivated User",
"display_name": "deactivateduser",
"display_name_normalized": "deactivateduser",
"fields": null,
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "g849cc56ed76",
"huddle_state": "default_unset",
"first_name": "Deactivated",
"last_name": "User",
"image_24": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=24&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-24.png",
"image_32": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=32&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-32.png",
"image_48": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=48&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-48.png",
"image_72": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-72.png",
"image_192": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=192&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-192.png",
"image_512": "https://secure.gravatar.com/avatar/a2c76eb32226f873bb3ab77542778325.jpg?s=512&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0021-512.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": false,
"is_app_user": false,
"updated": 1748398765,
"is_forgotten": true
},
{
"id": "U04ARJ3A7LM",
"team_id": "T2HHNN1FU",
"name": "asana",
"deleted": false,
"color": "d58247",
"real_name": "Asana",
"tz": "America/Los_Angeles",
"tz_label": "Pacific Daylight Time",
"tz_offset": -25200,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Asana",
"real_name_normalized": "Asana",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "3b8995f549dc",
"api_app_id": "A01734836JY",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_original.png",
"is_custom_image": true,
"bot_id": "B04AUGF0ZUJ",
"first_name": "Asana",
"last_name": "",
"image_24": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_24.png",
"image_32": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_32.png",
"image_48": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_48.png",
"image_72": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_72.png",
"image_192": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_192.png",
"image_512": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_512.png",
"image_1024": "https://avatars.slack-edge.com/2024-09-14/7727304057861_3b8995f549dc5f9ca5f6_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": false,
"is_owner": false,
"is_primary_owner": false,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": true,
"is_app_user": false,
"updated": 1726350133,
"is_email_confirmed": false,
"who_can_share_contact_card": "EVERYONE"
},
{
"id": "U08HX1HNW31",
"team_id": "T2HHNN1FU",
"name": "otter",
"deleted": false,
"color": "827327",
"real_name": "Otter.ai",
"tz": "America/Los_Angeles",
"tz_label": "Pacific Daylight Time",
"tz_offset": -25200,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Otter.ai",
"real_name_normalized": "Otter.ai",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "c1628076c3b3",
"api_app_id": "A04ULDS8UFN",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_original.png",
"is_custom_image": true,
"bot_id": "B08HX1HNPST",
"first_name": "Otter.ai",
"last_name": "",
"image_24": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_24.png",
"image_32": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_32.png",
"image_48": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_48.png",
"image_72": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_72.png",
"image_192": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_192.png",
"image_512": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_512.png",
"image_1024": "https://avatars.slack-edge.com/2025-03-18/8633921706705_c1628076c3b3d6440777_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": false,
"is_owner": false,
"is_primary_owner": false,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": true,
"is_app_user": false,
"updated": 1742353383,
"is_email_confirmed": false,
"who_can_share_contact_card": "EVERYONE"
},
{
"id": "U08JDP2SQRZ",
"team_id": "T2HHNN1FU",
"name": "miro",
"deleted": true,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Miro",
"real_name_normalized": "Miro",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "c8beb7d87ed3",
"api_app_id": "A8VK125AS",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_original.png",
"is_custom_image": true,
"bot_id": "B08JDP2SDFD",
"first_name": "Miro",
"last_name": "",
"image_24": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_24.png",
"image_32": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_32.png",
"image_48": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_48.png",
"image_72": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_72.png",
"image_192": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_192.png",
"image_512": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_512.png",
"image_1024": "https://avatars.slack-edge.com/2025-03-18/8645198828528_c8beb7d87ed3cae76b84_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_bot": true,
"is_app_user": false,
"updated": 1772212905
},
{
"id": "U08SZSAHH5F",
"team_id": "T2HHNN1FU",
"name": "wf_bot_a08szs6b62v",
"deleted": false,
"color": "902d59",
"real_name": "Item status notification",
"tz": "America/Los_Angeles",
"tz_label": "Pacific Daylight Time",
"tz_offset": -25200,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Item status notification",
"real_name_normalized": "Item status notification",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "85aa14fc1250",
"api_app_id": "A08SZS6B62V",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_original.png",
"is_custom_image": true,
"bot_id": "B08SZSAHERF",
"first_name": "Item",
"last_name": "status notification",
"image_24": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_24.png",
"image_32": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_32.png",
"image_48": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_48.png",
"image_72": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_72.png",
"image_192": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_192.png",
"image_512": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_512.png",
"image_1024": "https://avatars.slack-edge.com/2025-05-15/8906772541186_85aa14fc1250307356f0_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": false,
"is_owner": false,
"is_primary_owner": false,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": true,
"is_app_user": false,
"updated": 1747337592,
"is_email_confirmed": false,
"who_can_share_contact_card": "EVERYONE",
"is_workflow_bot": true
},
{
"id": "U08SZSC6VH7",
"team_id": "T2HHNN1FU",
"name": "wf_bot_a08szs62zgr",
"deleted": false,
"color": "bd9336",
"real_name": "Welcome message",
"tz": "America/Los_Angeles",
"tz_label": "Pacific Daylight Time",
"tz_offset": -25200,
"profile": {
"title": "",
"phone": "",
"skype": "",
"real_name": "Welcome message",
"real_name_normalized": "Welcome message",
"display_name": "",
"display_name_normalized": "",
"fields": {},
"status_text": "",
"status_emoji": "",
"status_emoji_display_info": [],
"status_expiration": 0,
"status_clear_on_focus_end": false,
"avatar_hash": "ab5acb82d517",
"api_app_id": "A08SZS62ZGR",
"always_active": true,
"image_original": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_original.png",
"is_custom_image": true,
"bot_id": "B08SZSC2V7T",
"first_name": "Welcome",
"last_name": "message",
"image_24": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_24.png",
"image_32": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_32.png",
"image_48": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_48.png",
"image_72": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_72.png",
"image_192": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_192.png",
"image_512": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_512.png",
"image_1024": "https://avatars.slack-edge.com/2025-05-15/8927514171840_ab5acb82d5173fa35a95_1024.png",
"status_text_canonical": "",
"team": "T2HHNN1FU"
},
"is_admin": false,
"is_owner": false,
"is_primary_owner": false,
"is_restricted": false,
"is_ultra_restricted": false,
"is_bot": true,
"is_app_user": false,
"updated": 1747337594,
"is_email_confirmed": false,
"who_can_share_contact_card": "EVERYONE",
"is_workflow_bot": true
}
]

View File

@ -0,0 +1,159 @@
[
{
"subtype": "channel_join",
"user": "U2HHNN1HQ",
"text": "<@U2HHNN1HQ> has joined the channel",
"type": "message",
"ts": "1587214815.000200"
},
{
"subtype": "channel_join",
"user": "U2J30FCLW",
"text": "<@U2J30FCLW> has joined the channel",
"inviter": "U2HHNN1HQ",
"type": "message",
"ts": "1587214815.000400"
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1587214908.000600",
"client_msg_id": "7719b88e-a403-427f-8cd1-2665e5f6d342",
"text": "baby!",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "7Zca",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "baby!"
}
]
}
]
}
]
},
{
"text": "",
"files": [
{
"id": "F011NH76RUP",
"created": 1587214950,
"timestamp": 1587214950,
"mimetype": "image\/jpeg",
"filetype": "jpg",
"pretty_type": "JPEG",
"user": "U2HHNN1HQ",
"user_team": "T2HHNN1FU",
"editable": false,
"size": 391288,
"mode": "hosted",
"is_external": false,
"external_type": "",
"is_public": true,
"public_url_shared": false,
"display_as_bot": false,
"username": "",
"name": "IMG_20200418_090219.jpg",
"title": "IMG_20200418_090219.jpg",
"is_modified_by_ai": false,
"url_private": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F011NH76RUP\/img_20200418_090219.jpg?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"url_private_download": "https:\/\/files.slack.com\/files-pri\/T2HHNN1FU-F011NH76RUP\/download\/img_20200418_090219.jpg?token=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"media_display_type": "unknown",
"thumb_64": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_64.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_80": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_80.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_360": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_360.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_360_w": 360,
"thumb_360_h": 270,
"thumb_480": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_480.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_480_w": 480,
"thumb_480_h": 360,
"thumb_160": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_160.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_720": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_720.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_720_w": 720,
"thumb_720_h": 540,
"thumb_800": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_800.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_800_w": 800,
"thumb_800_h": 600,
"thumb_960": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_960.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_960_w": 960,
"thumb_960_h": 720,
"thumb_1024": "https:\/\/files.slack.com\/files-tmb\/T2HHNN1FU-F011NH76RUP-165eb608df\/img_20200418_090219_1024.jpg?t=xoxe-85600749538-11214969224067-11215699816469-13b604e355c6cb0f8b9d8a5c2dd39dd1",
"thumb_1024_w": 1024,
"thumb_1024_h": 768,
"original_w": 4032,
"original_h": 3024,
"thumb_tiny": "AwAkADB\/2iQHq1NN1J\/eNRc5IzjPWnBMrnt70APN1IB1bPuKb9sl\/vfpUZJBG75h6U824ZC8bZHv2oAX7bID1B\/Ck+3t6A1W2MzYA5p0qqE+VcEdTnNAEy\/M2B1NSIwCsAMt2BqOIAygE4q35KleRye9AFMBnJ4AHfFOWQ7QqgADnnvU5tyF4Vfriq8YO4gk8dPagCxGpCkkAs5zkCoZYgEYZ59KnaZVQAbmIqpMXZST8q+lAAn+tX61dclQCO9Uk\/1q\/Wrkv3FoAiaVwfvVCGJc5wfwpzdajH3jQAssrghQcD24qu0jH0\/KpJvvfhUNAH\/\/2Q==",
"permalink": "https:\/\/levkine.slack.com\/files\/U2HHNN1HQ\/F011NH76RUP\/img_20200418_090219.jpg",
"permalink_public": "https:\/\/slack-files.com\/T2HHNN1FU-F011NH76RUP-993ff9b75a",
"is_starred": false,
"skipped_shares": true,
"has_rich_preview": false,
"file_access": "visible"
}
],
"upload": true,
"user": "U2HHNN1HQ",
"display_as_bot": false,
"type": "message",
"ts": "1587214956.000700",
"client_msg_id": "e9473154-7c74-4e16-a1ea-aa5855d8648f"
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1587215471.001100",
"client_msg_id": "1837b1b9-15d4-41bb-bd82-c4be4d24ce03",
"text": "another bublik!",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"blocks": [
{
"type": "rich_text",
"block_id": "beU",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "another bublik!"
}
]
}
]
}
]
}
]

View File

@ -0,0 +1,277 @@
[
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587651542.000200",
"client_msg_id": "5e8e082d-4605-4875-a2d6-86607e98e112",
"text": "Baby girl names",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"reply_count": 45,
"reply_users_count": 2,
"latest_reply": "1728352618.235309",
"reply_users": [
"U2HHNN1HQ",
"U2J30FCLW"
],
"replies": [
{
"user": "U2HHNN1HQ",
"ts": "1587651713.000300"
},
{
"user": "U2J30FCLW",
"ts": "1587842605.000600"
},
{
"user": "U2HHNN1HQ",
"ts": "1587844690.001400"
},
{
"user": "U2HHNN1HQ",
"ts": "1587844880.001600"
},
{
"user": "U2HHNN1HQ",
"ts": "1587847411.003900"
},
{
"user": "U2HHNN1HQ",
"ts": "1587849379.004800"
},
{
"user": "U2J30FCLW",
"ts": "1587849408.005000"
},
{
"user": "U2HHNN1HQ",
"ts": "1587849458.005200"
},
{
"user": "U2J30FCLW",
"ts": "1587851865.005400"
},
{
"user": "U2J30FCLW",
"ts": "1590795033.000100"
},
{
"user": "U2HHNN1HQ",
"ts": "1595716889.000100"
},
{
"user": "U2HHNN1HQ",
"ts": "1595828802.000600"
},
{
"user": "U2HHNN1HQ",
"ts": "1644007596.214229"
},
{
"user": "U2J30FCLW",
"ts": "1644007715.586559"
},
{
"user": "U2HHNN1HQ",
"ts": "1645042618.388199"
},
{
"user": "U2HHNN1HQ",
"ts": "1645804390.278629"
},
{
"user": "U2HHNN1HQ",
"ts": "1647010205.674939"
},
{
"user": "U2HHNN1HQ",
"ts": "1655498003.694859"
},
{
"user": "U2J30FCLW",
"ts": "1655498021.086939"
},
{
"user": "U2J30FCLW",
"ts": "1655498026.842429"
},
{
"user": "U2J30FCLW",
"ts": "1655498036.462279"
},
{
"user": "U2HHNN1HQ",
"ts": "1655498116.144589"
},
{
"user": "U2HHNN1HQ",
"ts": "1656524709.035089"
},
{
"user": "U2J30FCLW",
"ts": "1656525039.656139"
},
{
"user": "U2HHNN1HQ",
"ts": "1656547897.691709"
},
{
"user": "U2HHNN1HQ",
"ts": "1656967670.835969"
},
{
"user": "U2HHNN1HQ",
"ts": "1665714316.436109"
},
{
"user": "U2HHNN1HQ",
"ts": "1665714329.861469"
},
{
"user": "U2HHNN1HQ",
"ts": "1665714395.843509"
},
{
"user": "U2HHNN1HQ",
"ts": "1665714406.505149"
},
{
"user": "U2HHNN1HQ",
"ts": "1666017144.309979"
},
{
"user": "U2J30FCLW",
"ts": "1666018571.116679"
},
{
"user": "U2HHNN1HQ",
"ts": "1666283311.665639"
},
{
"user": "U2HHNN1HQ",
"ts": "1666548905.238439"
},
{
"user": "U2HHNN1HQ",
"ts": "1666549084.108529"
},
{
"user": "U2HHNN1HQ",
"ts": "1666710283.949469"
},
{
"user": "U2J30FCLW",
"ts": "1667570827.120389"
},
{
"user": "U2HHNN1HQ",
"ts": "1668113332.283759"
},
{
"user": "U2HHNN1HQ",
"ts": "1668990306.602409"
},
{
"user": "U2HHNN1HQ",
"ts": "1669219368.659929"
},
{
"user": "U2HHNN1HQ",
"ts": "1677535858.373999"
},
{
"user": "U2HHNN1HQ",
"ts": "1679322325.329009"
},
{
"user": "U2J30FCLW",
"ts": "1680209837.675889"
},
{
"user": "U2J30FCLW",
"ts": "1680209876.614319"
},
{
"user": "U2HHNN1HQ",
"ts": "1728352618.235309"
}
],
"is_locked": false,
"subscribed": true,
"last_read": "1728352618.235309",
"blocks": [
{
"type": "rich_text",
"block_id": "jjC",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Baby girl names"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587651713.000300",
"edited": {
"user": "U2HHNN1HQ",
"ts": "1665714375.000000"
},
"client_msg_id": "c9bd3422-21c0-423d-830e-030c97e91b02",
"text": "Ada\nAlia\nAndi\nAria\/Arya \nDara\nElli\nErin\nEsther\nFadi[e]\nFaye\nFran\nJada\nJane\nJoni\nKira\nMara\nNina\nNora\nRaya\nShai\nYara\nZara\nZoey (Zoya)",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "LQG",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Ada\nAlia\nAndi\nAria\/Arya \nDara\nElli\nErin\nEsther\nFadi[e]\nFaye\nFran\nJada\nJane\nJoni\nKira\nMara\nNina\nNora\nRaya\nShai\nYara\nZara\nZoey (Zoya)"
}
]
}
]
}
]
}
]

View File

@ -0,0 +1,665 @@
[
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1587842605.000600",
"edited": {
"user": "U2J30FCLW",
"ts": "1587842643.000000"
},
"client_msg_id": "614c8f69-b8dc-483e-b559-e26c490e57b4",
"text": "Alia\nAndi\nEllie\nErin\nEsther\nFaye\nKira\nMara\nNora\nRaya\nRivka\nShai\nZara\nZoey (Zoya)",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "R4c",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Alia\nAndi\nEllie\nErin\nEsther\nFaye\nKira\nMara\nNora\nRaya\nRivka\nShai\nZara\nZoey (Zoya)"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587842804.001100",
"client_msg_id": "a48e6885-3191-4df9-ae39-cc6e31939fad",
"text": "Baby boy names",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587842804.001100",
"reply_count": 30,
"reply_users_count": 2,
"latest_reply": "1729275913.097429",
"reply_users": [
"U2HHNN1HQ",
"U2J30FCLW"
],
"replies": [
{
"user": "U2HHNN1HQ",
"ts": "1587844686.001200"
},
{
"user": "U2HHNN1HQ",
"ts": "1587847806.004200"
},
{
"user": "U2HHNN1HQ",
"ts": "1587847856.004400"
},
{
"user": "U2HHNN1HQ",
"ts": "1587848964.004600"
},
{
"user": "U2J30FCLW",
"ts": "1593312084.002600"
},
{
"user": "U2HHNN1HQ",
"ts": "1655497970.128139"
},
{
"user": "U2HHNN1HQ",
"ts": "1656550206.616869"
},
{
"user": "U2HHNN1HQ",
"ts": "1657383160.915509"
},
{
"user": "U2HHNN1HQ",
"ts": "1665713679.801149"
},
{
"user": "U2HHNN1HQ",
"ts": "1665713775.915449"
},
{
"user": "U2HHNN1HQ",
"ts": "1665713968.370649"
},
{
"user": "U2HHNN1HQ",
"ts": "1665714073.390289"
},
{
"user": "U2HHNN1HQ",
"ts": "1665772316.640129"
},
{
"user": "U2HHNN1HQ",
"ts": "1669042577.074349"
},
{
"user": "U2HHNN1HQ",
"ts": "1669134587.534359"
},
{
"user": "U2HHNN1HQ",
"ts": "1669817715.842749"
},
{
"user": "U2HHNN1HQ",
"ts": "1677534276.857389"
},
{
"user": "U2HHNN1HQ",
"ts": "1677534932.624229"
},
{
"user": "U2J30FCLW",
"ts": "1677537182.790029"
},
{
"user": "U2J30FCLW",
"ts": "1677537194.748409"
},
{
"user": "U2HHNN1HQ",
"ts": "1681397953.547769"
},
{
"user": "U2HHNN1HQ",
"ts": "1681573698.769379"
},
{
"user": "U2HHNN1HQ",
"ts": "1681573897.480289"
},
{
"user": "U2HHNN1HQ",
"ts": "1681673462.733159"
},
{
"user": "U2J30FCLW",
"ts": "1681673481.251839"
},
{
"user": "U2HHNN1HQ",
"ts": "1681673491.808809"
},
{
"user": "U2HHNN1HQ",
"ts": "1682375712.953539"
},
{
"user": "U2HHNN1HQ",
"ts": "1726097016.108129"
},
{
"user": "U2HHNN1HQ",
"ts": "1728352950.566759"
},
{
"user": "U2J30FCLW",
"ts": "1729275913.097429"
}
],
"is_locked": false,
"subscribed": true,
"last_read": "1729275913.097429",
"blocks": [
{
"type": "rich_text",
"block_id": "0Mn",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Baby boy names"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587844686.001200",
"edited": {
"user": "U2HHNN1HQ",
"ts": "1587847558.000000"
},
"client_msg_id": "36244a45-572c-4d55-887e-759be38af09a",
"text": "Noah\nAbir\nAxel\nAzra\nJonah\nJoni\nSeth\nOzzy\nZach\nUriah\nShai",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587842804.001100",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "i4KW",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Noah\nAbir\nAxel\nAzra\nJonah\nJoni\nSeth\nOzzy\nZach\nUriah\nShai"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587844690.001400",
"client_msg_id": "6eb582dc-ea1f-420e-9264-9a7a71baeeb6",
"text": "Adie\nJean",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "gAvpY",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Adie\nJean"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587844880.001600",
"client_msg_id": "112d0d54-1cd8-4812-a5d9-59f6af293603",
"text": "Sara",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "OHsu",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Sara"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587847411.003900",
"client_msg_id": "c10be03a-516a-4d31-9820-1263811fdfaf",
"text": "Alvi",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "SsyoV",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Alvi"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587847806.004200",
"client_msg_id": "886ee1b0-2ae7-4e3b-b59c-d155dc54808e",
"text": "Zain",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587842804.001100",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "dgP",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Zain"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587847856.004400",
"edited": {
"user": "U2HHNN1HQ",
"ts": "1669817790.000000"
},
"client_msg_id": "b6c4a1b6-2a19-4b65-be54-0eff9061b8c1",
"text": "Enoch (Nucky)\nEnok",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587842804.001100",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "9uj3M",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Enoch (Nucky)\nEnok"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587848964.004600",
"client_msg_id": "7cf7fc44-924f-4dad-ac6a-3e909d26b94c",
"text": "Noel",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587842804.001100",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "dpq",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Noel"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587849379.004800",
"client_msg_id": "3581817c-3d7a-4708-bc2e-0ece2f173539",
"text": "Lucy\nSadie\nNoa",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "qD4V",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Lucy\nSadie\nNoa"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1587849408.005000",
"client_msg_id": "44d5139e-033f-4a83-bc6b-ffc5bde8edae",
"text": "Not a fan of noa. But I do like Lucy and Sadie",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "EAVP",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Not a fan of noa. But I do like Lucy and Sadie"
}
]
}
]
}
]
},
{
"user": "U2HHNN1HQ",
"type": "message",
"ts": "1587849458.005200",
"client_msg_id": "2a778fe4-88aa-4b81-a3e6-9515ada836ce",
"text": "Alvi?",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gde41167d309",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/ede41167d309b977e5c67645d9a1a699.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0001-72.png",
"first_name": "Ilia",
"real_name": "Ilia Dobkin",
"display_name": "Ilia",
"team": "T2HHNN1FU",
"name": "jedi_jew",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "PdB",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Alvi?"
}
]
}
]
}
]
},
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1587851865.005400",
"client_msg_id": "9a9a0cf0-962f-443d-9458-4af3c299575e",
"text": "Not a fan",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "dpYcf",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Not a fan"
}
]
}
]
}
]
}
]

View File

@ -0,0 +1,42 @@
[
{
"user": "U2J30FCLW",
"type": "message",
"ts": "1590795033.000100",
"client_msg_id": "c1405527-49a1-49f6-9a14-5e7797adee00",
"text": "Ellie\nErin\nEsther\nMara\nShai\nSara\nZoey\nLuct\nSadie",
"team": "T2HHNN1FU",
"user_team": "T2HHNN1FU",
"source_team": "T2HHNN1FU",
"user_profile": {
"avatar_hash": "gc49a37283df",
"image_72": "https:\/\/secure.gravatar.com\/avatar\/bc49a37283dfa139af6916bb32030146.jpg?s=72&d=https%3A%2F%2Fa.slack-edge.com%2Fdf10d%2Fimg%2Favatars%2Fava_0017-72.png",
"first_name": "Irina",
"real_name": "Irina",
"display_name": "ira_feeds",
"team": "T2HHNN1FU",
"name": "ira_feeds",
"is_restricted": false,
"is_ultra_restricted": false
},
"thread_ts": "1587651542.000200",
"parent_user_id": "U2HHNN1HQ",
"blocks": [
{
"type": "rich_text",
"block_id": "dYo",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Ellie\nErin\nEsther\nMara\nShai\nSara\nZoey\nLuct\nSadie"
}
]
}
]
}
]
}
]

167
tests/fixtures/test-manifest.json vendored Normal file
View File

@ -0,0 +1,167 @@
{
"version": 1,
"export_path": "/Users/macilia/Documents/code/slack-sieve/tests/fixtures/mini-export",
"updated_at": "2026-06-03T16:36:33.190656+00:00",
"global_notes": "pytest was here",
"channels": {
"medical": {
"import": true,
"status": "partial",
"reviewed": true,
"import_mode": "pick",
"notes": "",
"mattermost_channel": "",
"mattermost_team": "",
"messages": {
"medical:test:1": {
"action": "import",
"checked": true,
"files": {},
"notes": "",
"merge_target": ""
}
}
},
"29wilson": {
"import": false,
"status": "skip",
"reviewed": true,
"import_mode": "all",
"notes": "Returned — not importing",
"mattermost_channel": "",
"mattermost_team": "",
"messages": {}
},
"zoey": {
"import": true,
"status": "partial",
"reviewed": true,
"import_mode": "pick",
"notes": "",
"mattermost_channel": "",
"mattermost_team": "",
"messages": {
"zoey:test:2": {
"action": "import",
"checked": true,
"files": {},
"notes": "",
"merge_target": ""
}
}
},
"153niagara": {
"import": false,
"status": "undecided",
"reviewed": false,
"import_mode": "all",
"notes": "",
"mattermost_channel": "",
"mattermost_team": "",
"messages": {
"153niagara:1476210563.000002": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1476210563.000003": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1476833882.000005": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1476833895.000006": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1476833908.000007": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1476834078.000008": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1476834088.000009": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1477341477.000002": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1477341924.000003": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1477342010.000004": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1477342047.000005": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1477342051.000006": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1477342059.000007": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1477342065.000008": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
},
"153niagara:1477342068.000009": {
"action": "inherit",
"merge_target": "",
"notes": "",
"files": {}
}
}
}
},
"users": {
"UTEST": {
"import": true,
"notes": "mapped",
"mattermost_username": "ilia"
},
"U2HHNN1HQ": {
"import": true,
"notes": "mapped",
"mattermost_username": "testuser"
}
}
}

View File

@ -0,0 +1,49 @@
"""Integration test for auto-cherry-pick main() on mini-export."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
@pytest.fixture
def cherry_pick_module():
import importlib.util
path = REPO / "scripts" / "auto-cherry-pick.py"
spec = importlib.util.spec_from_file_location("auto_cherry_pick_main", path)
mod = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(mod)
return mod
def test_main_dry_run_on_fixture(cherry_pick_module, fixture_export_path, tmp_path, monkeypatch):
manifest = tmp_path / "manifest.json"
monkeypatch.setattr(cherry_pick_module, "MANIFEST_PATH", manifest)
monkeypatch.setattr(cherry_pick_module, "DEFAULT_EXPORT", fixture_export_path)
monkeypatch.setattr(sys, "argv", ["auto-cherry-pick.py", "--dry-run"])
assert cherry_pick_module.main() == 0
def test_main_writes_pick_manifest(cherry_pick_module, fixture_export_path, tmp_path, monkeypatch):
manifest = tmp_path / "manifest.json"
monkeypatch.setattr(cherry_pick_module, "MANIFEST_PATH", manifest)
monkeypatch.setattr(cherry_pick_module, "DEFAULT_EXPORT", fixture_export_path)
monkeypatch.setattr(sys, "argv", ["auto-cherry-pick.py"])
assert cherry_pick_module.main() == 0
data = json.loads(manifest.read_text())
picks = sum(
1
for ch in data["channels"].values()
for m in ch.get("messages", {}).values()
if m.get("action") == "import"
)
assert picks > 0
assert "medical" in data["channels"]
assert data["channels"]["medical"]["import_mode"] == "pick"

82
tests/test_cherry_pick.py Normal file
View File

@ -0,0 +1,82 @@
"""Unit tests for scripts/auto-cherry-pick.py heuristics."""
from __future__ import annotations
def test_return_channels_skipped(cherry_pick, empty_manifest):
_, manifest = empty_manifest
manifest["channels"]["29wilson"] = {
"status": "partial",
"messages": {"x": {"action": "import"}},
}
cherry_pick.reset_all_picks(manifest)
entry = manifest["channels"]["29wilson"]
assert entry["status"] == "skip"
assert entry["messages"] == {}
def test_conversation_not_imported(cherry_pick):
assert cherry_pick.looks_conversational("should we go with option A?")
assert cherry_pick.looks_conversational("sounds good!")
assert not cherry_pick.text_is_factual("should we book for Tuesday?")
assert cherry_pick.text_is_factual("Call Dr. Smith at 416-555-1234")
def test_name_list_detected(cherry_pick):
text = "Baby girl names\n• Maya\n• Sarah\n• Leah\n• Naomi"
assert cherry_pick.is_name_list_message(text)
assert not cherry_pick.is_name_list_message("Zoey had fun at camp today")
def test_latest_draft_only_one_winner(cherry_pick, export):
msgs, files_map = cherry_pick.build_latest_draft_winners(export, "153niagara")
# Multiple draft versions of hot_plate → one message id wins
hot = [mid for mid in msgs if "153niagara" in mid]
assert len(hot) >= 1
for mid, fids in files_map.items():
assert mid in msgs
assert fids
def test_should_import_doc_not_chat(cherry_pick, export):
latest, _ = cherry_pick.build_latest_draft_winners(export, "153niagara")
picked_docs = 0
picked_chat = 0
for msg in export.iter_channel_messages("153niagara"):
if cherry_pick.should_import(msg, "153niagara", latest_draft_msgs=latest):
if msg.get("files"):
picked_docs += 1
elif (msg.get("text") or "").strip():
picked_chat += 1
assert picked_docs >= 1
# Data-only mode: far fewer bare-text picks than files
assert picked_chat <= picked_docs + 5
def test_wilson_not_in_auto_channels(cherry_pick):
assert "29wilson" not in cherry_pick.AUTO_CHANNELS
assert "520steeles" not in cherry_pick.AUTO_CHANNELS
assert cherry_pick.RETURN_CHANNELS >= {"29wilson", "520steeles"}
def test_dry_run_pick_count(cherry_pick, export, empty_manifest, tmp_path, monkeypatch):
path, manifest = empty_manifest
monkeypatch.setattr(cherry_pick, "MANIFEST_PATH", tmp_path / "manifest.json")
monkeypatch.setattr(cherry_pick, "DEFAULT_EXPORT", export.paths.root)
import sys
sys.argv = ["auto-cherry-pick.py", "--dry-run"]
# Run pick logic inline
cherry_pick.reset_all_picks(manifest)
total = 0
for ch_name in sorted(cherry_pick.AUTO_CHANNELS):
if ch_name in cherry_pick.RETURN_CHANNELS:
continue
if not export.channel_folder(ch_name):
continue
latest, _ = cherry_pick.build_latest_draft_winners(export, ch_name)
for msg in export.iter_channel_messages(ch_name):
if cherry_pick.should_import(msg, ch_name, latest_draft_msgs=latest):
total += 1
assert 0 < total < 200 # mini fixture stays small

View File

@ -0,0 +1,63 @@
"""Additional auto-cherry-pick heuristic coverage."""
from __future__ import annotations
def test_informative_image_casual_name(cherry_pick):
f = {"name": "MVIMG_20190105.jpg", "mimetype": "image/jpeg"}
assert not cherry_pick.informative_image(f, "nice photo")
assert cherry_pick.informative_image(
f, "Invoice total $1,200 due 416-555-0100"
)
def test_informative_filename(cherry_pick):
assert cherry_pick.informative_filename("lease_agreement.pdf")
assert not cherry_pick.informative_filename("IMG_0001.jpg")
def test_msg_selection_skips_old_draft(cherry_pick):
msg = {
"id": "ch:1",
"files": [
{"id": "D1", "name": "draft_v1.docx"},
{"id": "D2", "name": "photo.jpg", "mimetype": "image/jpeg"},
],
}
sel = cherry_pick.msg_selection(msg, latest_draft_files={"D1"})
assert sel["files"].get("D1") is True
assert "D2" not in sel["files"] or sel["files"].get("D2") is True # image w/ name
def test_draft_group_key_strips_draft_prefix(cherry_pick):
key = cherry_pick.draft_group_key("draft_-_hot_plate_v2.docx")
assert "draft" not in key.split()[0] or "hot" in key
assert "hot" in key.replace("_", " ")
def test_text_is_factual_requires_signals(cherry_pick):
assert not cherry_pick.text_is_factual("sounds good thanks")
assert cherry_pick.text_is_factual("Pay $1,500 to TD account 12345")
def test_is_doc_and_image(cherry_pick):
assert cherry_pick.is_doc({"name": "x.pdf"})
assert cherry_pick.is_image({"name": "x.jpg", "mimetype": "image/jpeg"})
def test_channel_entry_skip_shape(cherry_pick):
entry = cherry_pick.channel_entry_skip("note")
assert entry["status"] == "skip"
assert entry["messages"] == {}
def test_reset_only_writes(empty_manifest, cherry_pick, tmp_path, monkeypatch):
path, _manifest = empty_manifest
path.write_text(__import__("json").dumps(_manifest, indent=2) + "\n", encoding="utf-8")
monkeypatch.setattr(cherry_pick, "MANIFEST_PATH", path)
import sys
monkeypatch.setattr(sys, "argv", ["auto-cherry-pick.py", "--reset"])
assert cherry_pick.main() == 0
saved = __import__("json").loads(path.read_text())
assert saved["channels"] == {}

260
tests/test_coverage_gaps.py Normal file
View File

@ -0,0 +1,260 @@
"""Tests targeting previously uncovered branches."""
from __future__ import annotations
import io
import json
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
import pytest
def _status(base: str, path: str, method: str = "GET", body: bytes | None = None):
req = urllib.request.Request(
f"{base}{path}",
data=body,
headers={"Content-Type": "application/json"} if body else {},
method=method,
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.read()
# --- server.py ---
def test_file_preview_bad_docx_zip():
import server
out = server.file_preview_payload_bytes(b"PK\x03\x04garbage", "bad.docx")
assert out["ok"] is False
assert "DOCX" in out["error"] or "Could not" in out["error"]
def test_file_preview_quip_html():
import server
html = '<div class="quip-canvas-content"><p>Canvas</p></div>'
out = server.file_preview_payload_bytes(html.encode(), "doc.quip")
assert out["ok"] and "Canvas" in out["html"]
def test_static_file_not_found(api):
status, _ = _status(api["base"], "/static/no-such-asset.css")
assert status == 404
def test_uploads_missing_file(api):
status, _ = _status(api["base"], "/uploads/__uploads/missing/deadbeef.bin")
assert status in (400, 404)
def test_file_preview_by_file_id_not_found(api):
status, body = _status(api["base"], "/api/file-preview?file_id=UNKNOWN_FILE_XYZ")
assert status == 404
data = json.loads(body.decode())
assert data["ok"] is False
def test_file_preview_by_rel_on_disk(api, review_server):
rel_dir = review_server["httpd"].RequestHandlerClass.export.paths.root
target = rel_dir / "__uploads" / "T1"
target.mkdir(parents=True, exist_ok=True)
(target / "note.txt").write_text("fixture preview text", encoding="utf-8")
status, body = _status(api["base"], "/api/file-preview?rel=__uploads/T1/note.txt")
assert status == 200
data = json.loads(body.decode())
assert data["ok"]
assert "fixture preview" in data["html"]
def test_manifest_post_empty_body(api):
status, _ = _status(
api["base"],
"/api/manifest",
method="POST",
body=b"",
)
assert status == 400
def test_manifest_post_wrong_path(api):
status, _ = _status(api["base"], "/api/manifest/extra", method="POST", body=b"{}")
assert status == 404
def test_manifest_post_invalid_import_mode_defaults(api):
api["post"](
"/api/manifest",
{
"channels": {
"medical": {
"status": "partial",
"import_mode": "bogus",
"messages": {},
}
}
},
)
med = api["get"]("/api/manifest")["channels"]["medical"]
assert med["import_mode"] == "all"
def test_manifest_post_skips_non_dict_user(api):
users = api["get"]("/api/users")["users"]
uid = users[0]["id"]
api["post"](
"/api/manifest",
{"users": {"bad": "nope", uid: {"import": True, "notes": "", "mattermost_username": ""}}},
)
again = api["get"]("/api/users")["users"]
assert any(u["id"] == uid for u in again)
def test_safe_export_path_rejects_dotdot():
import server
from slack_export import SlackExport
from unittest.mock import Mock
root = Path(__file__).resolve().parents[1] / "tests/fixtures/mini-export"
handler = Mock(spec=server.ReviewHandler)
handler.export = SlackExport(root)
assert server.ReviewHandler._safe_export_path(handler, "../outside") is None
assert server.ReviewHandler._safe_export_path(handler, "/etc/passwd") is None
# --- slack_export.py ---
@pytest.fixture
def rich_export(tmp_path):
from slack_export import SlackExport
root = tmp_path / "rich"
root.mkdir()
(root / "channels.json").write_text("[]", encoding="utf-8")
(root / "users.json").write_text(
json.dumps([{"id": "U1", "name": "u", "profile": {"real_name": "U"}}]),
encoding="utf-8",
)
(root / "canvases.json").write_text(
json.dumps([{"id": "CV1", "name": "canvas.pdf", "mimetype": "application/pdf"}]),
encoding="utf-8",
)
ch = root / "ch1"
ch.mkdir()
(ch / "meta.json").write_text('{"not": "a list"}', encoding="utf-8")
(ch / "2016-01-01.json").write_text(
json.dumps(
[
{"subtype": "channel_leave", "ts": "1.0"},
{"subtype": "channel_purpose", "purpose": "Purpose here", "ts": "2.0"},
{"files": [{"id": "NO_NAME"}]},
]
),
encoding="utf-8",
)
return SlackExport(root)
def test_file_index_from_canvases_and_skips_bad_rows(rich_export):
idx = rich_export.ensure_file_index()
assert "CV1" in idx
assert rich_export.file_record("NO_NAME") is None
def test_fetch_no_download_url(rich_export):
rich_export._file_index = {
"X1": {
"id": "X1",
"name": "a.bin",
"local_relpath": "__uploads/X1/a.bin",
"local_exists": False,
"url_private_download": None,
}
}
with pytest.raises(FileNotFoundError, match="No download URL"):
rich_export.fetch_file_bytes("X1")
def test_fetch_http_error(rich_export, monkeypatch):
rich_export._file_index = {
"X2": {
"id": "X2",
"name": "b.bin",
"local_relpath": "__uploads/X2/b.bin",
"local_exists": False,
"url_private_download": "https://example.com/x?token=xoxe-abc",
}
}
def _fail(*args, **kwargs):
raise urllib.error.HTTPError("url", 401, "Unauthorized", None, None)
monkeypatch.setattr("urllib.request.urlopen", _fail)
with pytest.raises(FileNotFoundError, match="401"):
rich_export.fetch_file_bytes("X2")
def test_normalize_leave_and_purpose(rich_export):
msgs = rich_export.iter_channel_messages("ch1")
texts = [m["text"] for m in msgs]
assert any("left" in t for t in texts)
assert any("Purpose" in t for t in texts)
def test_read_non_list_json_returns_empty(rich_export):
assert rich_export.read_messages_file("ch1", "meta") == []
def test_list_dates_missing_channel(rich_export):
assert rich_export.list_channel_date_files("missing-channel") == []
def test_search_files_query_filters_out(rich_export):
ch = rich_export.paths.root / "hits"
ch.mkdir()
(ch / "2017-01-01.json").write_text(
json.dumps(
[
{
"type": "message",
"user": "U1",
"ts": "1.0",
"text": "alpha",
"files": [{"id": "F9", "name": "pic.png", "mimetype": "image/png"}],
}
]
),
encoding="utf-8",
)
ex = rich_export
out = ex.search_files_global("zzzznomatch", file_type="image", limit=10)
assert out["hits"] == []
def test_search_global_hits_limit(export):
out = export.search_global("a", limit=1)
assert len(out["messages"]) <= 1
def test_search_messages_stops_at_limit(export):
hits = export.search_messages("zoey", "a", limit=2)
assert len(hits) <= 2
def test_classify_word_doc(export):
assert export.classify_file({"name": "x.doc", "mimetype": "application/msword"}) == "doc"
def test_register_file_raw_skips_incomplete(export):
export._file_index = {}
export._register_file_raw({"name": "onlyname"})
export._register_file_raw({"id": "idonly"})
assert export.file_record("onlyname") is None
assert export.file_record("idonly") is None

113
tests/test_helpers.py Normal file
View File

@ -0,0 +1,113 @@
"""Tests for small helpers in slack_export and server."""
from __future__ import annotations
import io
import json
import zipfile
from pathlib import Path
import pytest
from slack_export import SlackExport, load_json, resolve_paths, slack_ts_to_iso, user_display
def test_slack_ts_to_iso():
assert slack_ts_to_iso("1476210563.000002") is not None
assert "2016" in slack_ts_to_iso("1476210563.000002")
assert slack_ts_to_iso(None) is None
assert slack_ts_to_iso("not-a-ts") is None
def test_user_display_fallbacks():
assert user_display(None) == "unknown"
assert user_display({"name": "bot", "is_bot": True}) == "bot"
assert user_display({"profile": {"display_name": "Ilia"}, "name": "jedi"}) == "Ilia"
def test_resolve_paths_custom_root(fixture_export_path):
paths = resolve_paths(fixture_export_path)
assert paths.root == fixture_export_path.resolve()
assert paths.channels_json.name == "channels.json"
def test_load_json_roundtrip(tmp_path):
p = tmp_path / "x.json"
p.write_text('{"a": 1}', encoding="utf-8")
assert load_json(p) == {"a": 1}
def test_server_manifest_helpers(tmp_path, fixture_export_path):
import server
path = tmp_path / "m.json"
default = server.default_manifest(str(fixture_export_path))
assert default["version"] == 1
loaded = server.load_manifest(path, str(fixture_export_path))
assert loaded["export_path"] == str(fixture_export_path)
path.write_text("{not json", encoding="utf-8")
with pytest.raises(json.JSONDecodeError):
server.load_manifest(path, str(fixture_export_path))
server.save_manifest(path, {"version": 1, "channels": {}})
assert path.is_file()
assert "updated_at" in load_json(path)
def test_file_preview_payload_bytes_text_and_html():
import server
txt = server.file_preview_payload_bytes(b"hello world", "notes.txt")
assert txt["ok"] and "hello" in txt["html"]
html = server.file_preview_payload_bytes(
b"<html><body><h1>Title</h1></body></html>", "page.html"
)
assert html["ok"] and html["kind"] == "html"
bad = server.file_preview_payload_bytes(b"\x00\x01\xff", "binary.dat")
assert not bad["ok"]
def test_file_preview_payload_bytes_docx(tmp_path):
import server
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(
"word/document.xml",
'<?xml version="1.0"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
"<w:p><w:r><w:t>Lease terms</w:t></w:r></w:p></w:document>",
)
raw = buf.getvalue()
out = server.file_preview_payload_bytes(raw, "lease.docx")
assert out["ok"]
assert "Lease" in out["html"]
path = tmp_path / "lease.docx"
path.write_bytes(raw)
assert "Lease" in server.file_preview_payload(path)["html"]
def test_docx_to_html_file(tmp_path):
import server
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(
"word/document.xml",
'<?xml version="1.0"?>'
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
"<w:p><w:r><w:t>Line one</w:t></w:r></w:p></w:document>",
)
path = tmp_path / "t.docx"
path.write_bytes(buf.getvalue())
assert "Line one" in server.docx_to_html(path)
def test_export_missing_root_raises():
with pytest.raises(FileNotFoundError):
SlackExport("/nonexistent/export/path")

View File

@ -0,0 +1,151 @@
"""Tests that manifest selections produce a valid filtered Slack export."""
from __future__ import annotations
import json
import shutil
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
def _load_prepare():
import importlib.util
path = REPO / "scripts" / "prepare-export.py"
spec = importlib.util.spec_from_file_location("prepare_export", path)
mod = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def prepare_mod():
return _load_prepare()
def test_channel_included_rules(prepare_mod):
assert prepare_mod.channel_included({"status": "import", "import_mode": "all"})
assert not prepare_mod.channel_included({"status": "skip"})
assert prepare_mod.channel_included(
{
"status": "partial",
"import_mode": "pick",
"messages": {"medical:1.0": {"action": "import", "checked": True}},
}
)
assert not prepare_mod.channel_included(
{"status": "partial", "import_mode": "pick", "messages": {}}
)
def test_message_and_files_filter(prepare_mod):
entry = {
"status": "partial",
"import_mode": "pick",
"messages": {
"ch:1000.1": {
"action": "import",
"files": {"F1": True, "F2": False},
}
},
}
assert prepare_mod.message_included(entry, "ch:1000.1")
assert not prepare_mod.message_included(entry, "ch:1000.2")
raw = {
"ts": "1000.1",
"text": "hi",
"files": [
{"id": "F1", "name": "a.pdf"},
{"id": "F2", "name": "b.pdf"},
],
}
out = prepare_mod.filter_files_on_message(entry, "ch:1000.1", raw)
assert len(out["files"]) == 1
assert out["files"][0]["id"] == "F1"
def test_prepare_export_writes_slack_shape(prepare_mod, fixture_export_path, tmp_path):
from slack_export import SlackExport
export = SlackExport(fixture_export_path)
msgs = export.iter_channel_messages("medical")
pick = msgs[0]
key = pick["id"]
manifest = {
"channels": {
"medical": {
"status": "partial",
"import_mode": "pick",
"import": True,
"messages": {key: {"action": "import", "checked": True, "files": {}}},
}
}
}
out = tmp_path / "prepared"
stats = prepare_mod.prepare_export(fixture_export_path, manifest, out, export_mod=export)
assert stats["medical"] == 1
assert (out / "channels.json").is_file()
assert (out / "users.json").is_file()
assert (out / "medical").is_dir()
day_files = list((out / "medical").glob("*.json"))
assert len(day_files) == 1
written = json.loads(day_files[0].read_text(encoding="utf-8"))
assert len(written) == 1
assert written[0]["ts"] == pick["ts"]
assert (out / "prepare-export-summary.json").is_file()
def test_prepare_whole_channel(prepare_mod, fixture_export_path, tmp_path):
from slack_export import SlackExport
export = SlackExport(fixture_export_path)
total = len(export.iter_channel_messages("medical"))
manifest = {
"channels": {
"medical": {
"status": "import",
"import_mode": "all",
"import": True,
"messages": {},
}
}
}
out = tmp_path / "prepared-all"
stats = prepare_mod.prepare_export(fixture_export_path, manifest, out, export_mod=export)
assert stats["medical"] == total
def test_prepare_skips_channel(prepare_mod, fixture_export_path, tmp_path):
from slack_export import SlackExport
export = SlackExport(fixture_export_path)
manifest = {
"channels": {
"medical": {"status": "skip", "import": False, "messages": {}},
"zoey": {
"status": "partial",
"import_mode": "pick",
"import": True,
"messages": {
export.iter_channel_messages("zoey")[0]["id"]: {
"action": "import",
"checked": True,
}
},
},
}
}
out = tmp_path / "prepared-skip"
stats = prepare_mod.prepare_export(fixture_export_path, manifest, out, export_mod=export)
assert "medical" not in stats
assert stats["zoey"] == 1
meta = json.loads((out / "channels.json").read_text(encoding="utf-8"))
names = {c["name"] for c in meta}
assert "medical" not in names
assert "zoey" in names

View File

@ -0,0 +1,50 @@
"""Tests for scripts/preset-skips.py."""
from __future__ import annotations
import importlib.util
import json
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[1]
def _load_preset_skips():
path = REPO / "scripts" / "preset-skips.py"
spec = importlib.util.spec_from_file_location("preset_skips", path)
mod = importlib.util.module_from_spec(spec)
assert spec.loader
spec.loader.exec_module(mod)
return mod
def test_load_preset_tiers():
mod = _load_preset_skips()
preset = mod.load_preset(REPO / "presets" / "levkin-skips.json")
assert "medical" in preset["skip_all"]
assert "zoey" in preset["review_priority"]
assert "29wilson" in preset["tier_b"]
def test_channel_entry_skip():
mod = _load_preset_skips()
entry = mod.channel_entry_skip("tier-a")
assert entry["status"] == "skip"
assert entry["reviewed"] is True
def test_main_writes_manifest(tmp_path, fixture_export_path, monkeypatch):
mod = _load_preset_skips()
manifest_path = tmp_path / "manifest.json"
monkeypatch.setattr(mod, "MANIFEST_PATH", manifest_path)
monkeypatch.setattr(mod, "DEFAULT_EXPORT", fixture_export_path)
import sys
sys.argv = ["preset-skips.py", str(fixture_export_path)]
assert mod.main() == 0
data = json.loads(manifest_path.read_text())
assert data["channels"]["medical"]["status"] == "skip"
assert "zoey" not in data["channels"] or data["channels"].get("zoey", {}).get("status") != "skip"

112
tests/test_server.py Normal file
View File

@ -0,0 +1,112 @@
"""HTTP API tests against the review server (mini-export)."""
from __future__ import annotations
import json
def test_health(api):
data = api["get"]("/api/health")
assert data["ok"] is True
assert "mini-export" in data["export_path"]
def test_summary(api):
data = api["get"]("/api/summary")
assert data["channel_count"] >= 3
assert data["total_messages"] > 0
def test_channels_list(api):
data = api["get"]("/api/channels")
names = {c["name"] for c in data["channels"]}
assert "medical" in names
med = next(c for c in data["channels"] if c["name"] == "medical")
assert med["status"] == "partial"
assert med["import_mode"] == "pick"
def test_channel_messages_all(api):
data = api["get"]("/api/channels/medical/messages/all")
assert data["channel"] == "medical"
assert data["total"] == len(data["messages"])
assert data["total"] > 0
def test_search_messages(api):
data = api["get"]("/api/search?q=doctor&limit=10")
assert "messages" in data
def test_search_files(api):
data = api["get"]("/api/search-files?type=pdf&q=&limit=10")
assert data["file_type"] == "pdf"
assert "hits" in data
def test_manifest_roundtrip(api):
got = api["get"]("/api/manifest")
assert "channels" in got
got["global_notes"] = "pytest was here"
api["post"]("/api/manifest", {"global_notes": "pytest was here", "channels": got["channels"], "users": {}})
again = api["get"]("/api/manifest")
assert again["global_notes"] == "pytest was here"
def test_manifest_merge_preserves_other_channel_picks(api):
api["post"](
"/api/manifest",
{
"channels": {
"medical": {
"import": True,
"status": "partial",
"import_mode": "pick",
"reviewed": True,
"messages": {
"medical:test:1": {
"action": "import",
"checked": True,
"files": {},
"notes": "",
"merge_target": "",
}
},
}
}
},
)
api["post"](
"/api/manifest",
{
"channels": {
"zoey": {
"import": True,
"status": "partial",
"import_mode": "pick",
"reviewed": True,
"messages": {
"zoey:test:2": {
"action": "import",
"checked": True,
"files": {},
"notes": "",
"merge_target": "",
}
},
}
}
},
)
merged = api["get"]("/api/manifest")
assert "medical:test:1" in merged["channels"]["medical"]["messages"]
assert "zoey:test:2" in merged["channels"]["zoey"]["messages"]
def test_static_index(api):
import urllib.request
with urllib.request.urlopen(f"{api['base']}/", timeout=5) as resp:
html = resp.read().decode()
assert "Slack import review" in html
assert "filter-status" in html

View File

@ -0,0 +1,162 @@
"""Additional HTTP API and server module coverage."""
from __future__ import annotations
import json
import urllib.error
import urllib.request
import pytest
def _get_raw(base: str, path: str) -> tuple[int, bytes]:
try:
with urllib.request.urlopen(f"{base}{path}", timeout=5) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.read()
def test_users_list(api):
data = api["get"]("/api/users")
assert isinstance(data["users"], list)
if data["users"]:
assert "id" in data["users"][0]
def test_channel_search_endpoint(api):
data = api["get"]("/api/channels/medical/search?q=doctor")
assert data["channel"] == "medical"
assert isinstance(data["hits"], list)
def test_channel_messages_paged(api):
data = api["get"]("/api/channels/zoey/messages?limit=3&offset=0")
assert data["channel"] == "zoey"
assert len(data["messages"]) <= 3
assert "dates" in data
def test_channels_filter_query(api):
data = api["get"]("/api/channels?q=med")
names = {c["name"] for c in data["channels"]}
assert "medical" in names
def test_file_preview_missing_rel(api):
status, body = _get_raw(api["base"], "/api/file-preview?rel=../outside.txt")
assert status == 404
data = json.loads(body.decode())
assert data["ok"] is False
def test_file_preview_traversal_blocked(api):
status, _ = _get_raw(api["base"], "/api/file-preview?rel=..%2F..%2Fetc%2Fpasswd")
assert status in (400, 404)
def test_file_download_bad_id(api):
status, _ = _get_raw(api["base"], "/api/file-download?file_id=NOPE")
assert status == 404
def test_file_download_missing_param(api):
status, _ = _get_raw(api["base"], "/api/file-download")
assert status == 400
def test_uploads_path_traversal(api):
status, _ = _get_raw(api["base"], "/uploads/..%2F..%2Fetc%2Fpasswd")
assert status == 400
def test_static_css(api):
status, body = _get_raw(api["base"], "/static/app.css")
assert status == 200
assert b"message" in body or len(body) > 0
def test_options_cors(api):
req = urllib.request.Request(
f"{api['base']}/api/manifest",
method="OPTIONS",
)
with urllib.request.urlopen(req, timeout=5) as resp:
assert resp.status == 204
assert resp.headers.get("Access-Control-Allow-Origin") == "*"
def test_manifest_invalid_json(api):
req = urllib.request.Request(
f"{api['base']}/api/manifest",
data=b"{not valid json",
headers={"Content-Type": "application/json"},
method="POST",
)
with pytest.raises(urllib.error.HTTPError) as exc:
urllib.request.urlopen(req, timeout=5)
assert exc.value.code == 400
def test_not_found_route(api):
status, _ = _get_raw(api["base"], "/api/does-not-exist")
assert status == 404
def test_manifest_post_users(api):
users_before = api["get"]("/api/users")["users"]
assert users_before
uid = users_before[0]["id"]
api["post"](
"/api/manifest",
{
"users": {
uid: {
"import": True,
"notes": "mapped",
"mattermost_username": "testuser",
}
}
},
)
users = api["get"]("/api/users")["users"]
match = next((u for u in users if u["id"] == uid), None)
assert match and match["import"] and match["mattermost_username"] == "testuser"
def test_manifest_post_invalid_channel_entry_skipped(api):
api["post"](
"/api/manifest",
{"channels": {"bad": "not-a-dict", "medical": {"status": "partial", "import_mode": "pick"}}},
)
med = api["get"]("/api/manifest")["channels"]["medical"]
assert med["status"] == "partial"
def test_file_download_local_upload(api, tmp_path, review_server):
"""Download endpoint serves bytes when upload is on disk."""
import sys
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parents[1]))
from slack_export import SlackExport
root = tmp_path / "dl_export"
root.mkdir()
(root / "channels.json").write_text("[]", encoding="utf-8")
(root / "users.json").write_text("[]", encoding="utf-8")
up = root / "__uploads" / "FID1"
up.mkdir(parents=True)
(up / "doc.pdf").write_bytes(b"%PDF-1.4")
ex = SlackExport(root)
ex.ensure_file_index()
ex._file_index["FID1"] = {
"id": "FID1",
"name": "doc.pdf",
"local_relpath": "__uploads/FID1/doc.pdf",
"local_exists": True,
"url_private_download": None,
}
review_server["httpd"].RequestHandlerClass.export = ex
status, body = _get_raw(api["base"], "/api/file-download?file_id=FID1")
assert status == 200
assert body.startswith(b"%PDF")

View File

@ -0,0 +1,58 @@
"""Unit tests for slack_export.py (mini-export fixture)."""
from __future__ import annotations
def test_fixture_has_expected_channels(export):
names = {c["name"] for c in export.build_summary()["channels"]}
assert names >= {"medical", "zoey", "153niagara"}
def test_normalize_message_shape(export):
msgs = export.iter_channel_messages("medical")
msg = msgs[0]
assert "id" in msg
assert "channel" in msg
assert msg["channel"] == "medical"
assert "user_name" in msg
assert "text" in msg
def test_classify_file_types(export):
assert export.classify_file({"name": "lease.pdf", "mimetype": "application/pdf"}) == "pdf"
assert export.classify_file({"name": "photo.jpg", "mimetype": "image/jpeg"}) == "image"
assert export.classify_file({"name": "data.xlsx", "mimetype": ""}) == "spreadsheet"
def test_search_channels(export):
hits = export.search_channels("med")
assert any(c["name"] == "medical" for c in hits)
assert not any(c["name"] == "zoey" for c in hits)
def test_search_global_text(export):
result = export.search_global("doctor", limit=20)
assert result["query"] == "doctor"
assert isinstance(result["messages"], list)
def test_search_files_global_images(export):
result = export.search_files_global("", file_type="image", limit=30)
assert result["file_type"] == "image"
for hit in result["hits"]:
kinds = set(hit.get("file_kinds") or [])
assert "image" in kinds
def test_channel_messages_all_sorted(export):
data = export.channel_messages_all("zoey")
assert data["channel"] == "zoey"
assert data["total"] == len(data["messages"])
times = [m.get("time") or "" for m in data["messages"]]
assert times == sorted(times)
def test_upload_path_safe(export):
rel = export.upload_relpath("F123", "my file.pdf")
assert rel == "__uploads/F123/my file.pdf"
assert ".." not in rel

View File

@ -0,0 +1,203 @@
"""Additional slack_export coverage — normalize, index, pagination, search."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from slack_export import SlackExport
@pytest.fixture
def export_with_synthetic(tmp_path, fixture_export_path):
"""Mini export plus synthetic message edge cases."""
ch = tmp_path / "synthetic"
ch.mkdir()
(tmp_path / "channels.json").write_text(
json.dumps([{"name": "synthetic", "id": "C1"}]),
encoding="utf-8",
)
(tmp_path / "users.json").write_text(
json.dumps(
[
{
"id": "U1",
"name": "ilia",
"profile": {"display_name": "Ilia", "real_name": "Ilia L"},
}
]
),
encoding="utf-8",
)
messages = [
{"type": "message", "user": "U1", "ts": "1000.1", "subtype": "channel_join"},
{
"type": "message",
"user": "U1",
"ts": "1000.2",
"subtype": "channel_topic",
"topic": "Rentals",
},
{
"type": "message",
"user": "U1",
"ts": "1000.3",
"subtype": "file_share",
"files": [{"id": "F1", "name": "invoice.pdf", "mimetype": "application/pdf"}],
},
{
"type": "message",
"user": "U1",
"ts": "1000.4",
"thread_ts": "1000.4",
"reply_count": 2,
"text": "Thread root",
},
{
"type": "message",
"user": "U1",
"ts": "1000.5",
"thread_ts": "1000.4",
"text": "Reply",
},
{"type": "message", "bot_id": "B1", "ts": "1000.6", "text": "bot says hi"},
{"type": "message", "user": "U1", "ts": "1000.7", "blocks": [{"type": "section"}]},
{"type": "message", "user": "U1", "ts": "1000.8", "attachments": [{"text": "card"}]},
]
(ch / "2016-10-10.json").write_text(json.dumps(messages), encoding="utf-8")
return SlackExport(tmp_path)
def test_classify_all_file_kinds(export):
assert export.classify_file({"name": "x.pptx"}) == "slides"
assert export.classify_file({"name": "x.mp4", "mimetype": "video/mp4"}) == "video"
assert export.classify_file({"name": "x.mp3"}) == "audio"
assert export.classify_file({"name": "x.zip"}) == "archive"
assert export.classify_file({"name": "readme.md"}) == "text"
assert export.classify_file({"name": "unknown.bin"}) == "other"
def test_normalize_message_subtypes(export_with_synthetic):
msgs = export_with_synthetic.iter_channel_messages("synthetic")
by_ts = {m["ts"]: m for m in msgs}
assert "joined" in by_ts["1000.1"]["text"]
assert "Rentals" in by_ts["1000.2"]["text"]
assert "invoice.pdf" in by_ts["1000.3"]["text"]
assert by_ts["1000.5"]["is_thread_reply"]
assert by_ts["1000.4"]["thread_reply_count"] >= 1
assert by_ts["1000.6"]["is_bot"]
assert by_ts["1000.7"]["text"] == "[rich message]"
assert by_ts["1000.8"]["text"] == "[attachment]"
def test_ensure_file_index(export_with_synthetic):
idx = export_with_synthetic.ensure_file_index()
assert "F1" in idx
assert idx["F1"]["name"] == "invoice.pdf"
def test_file_record_unknown(export):
assert export.file_record("NONEXISTENT_ID_XYZ") is None
def test_channel_messages_page_pagination(export):
page = export.channel_messages_page("zoey", limit=2, offset=0)
assert page["channel"] == "zoey"
assert len(page["messages"]) <= 2
assert page["total_in_date"] >= len(page["messages"])
if page["has_more"]:
page2 = export.channel_messages_page("zoey", limit=2, offset=2)
assert page2["offset"] == 2
def test_channel_messages_page_empty_channel(tmp_path):
empty = tmp_path / "empty_export"
empty.mkdir()
(empty / "channels.json").write_text("[]", encoding="utf-8")
(empty / "users.json").write_text("[]", encoding="utf-8")
(empty / "ghost").mkdir()
ex = SlackExport(empty)
out = ex.channel_messages_page("ghost")
assert out["messages"] == []
assert out["has_more"] is False
def test_search_messages_channel_scoped(export):
hits = export.search_messages("medical", "doctor", limit=5)
assert isinstance(hits, list)
assert export.search_messages("medical", "", limit=5) == []
def test_search_global_empty_query(export):
out = export.search_global(" ", limit=5)
assert out["channels"] == []
assert out["messages"] == []
def test_search_global_channel_name(export):
out = export.search_global("niagara", limit=10)
assert any(c["name"] == "153niagara" for c in out["channels"])
def test_search_files_with_query(export):
out = export.search_files_global("proof", file_type="all", limit=10)
assert "hits" in out
def test_channel_stats_missing(export):
stats = export.channel_stats("no-such-channel-xyz")
assert stats["exists_in_export"] is False
assert stats["message_count"] == 0
def test_read_messages_invalid_date(export):
assert export.read_messages_file("medical", "2099-99-99") == []
def test_attach_thread_reply_counts(export_with_synthetic):
msgs = export_with_synthetic.iter_channel_messages("synthetic")
assert any(m.get("thread_reply_count", 0) > 0 for m in msgs)
def test_fetch_file_bytes_local(tmp_path, export_with_synthetic):
rec = export_with_synthetic.file_record("F1")
assert rec
local = export_with_synthetic.paths.root / rec["local_relpath"]
local.parent.mkdir(parents=True, exist_ok=True)
local.write_bytes(b"%PDF-1.4 test")
data, meta = export_with_synthetic.fetch_file_bytes("F1")
assert data.startswith(b"%PDF")
assert meta["id"] == "F1"
def test_fetch_file_bytes_remote(export_with_synthetic, monkeypatch):
rec = export_with_synthetic.file_record("F1")
local = export_with_synthetic.paths.root / rec["local_relpath"]
if local.is_file():
local.unlink()
class _Resp:
def read(self):
return b"remote-bytes"
def __enter__(self):
return self
def __exit__(self, *args):
pass
monkeypatch.setattr("urllib.request.urlopen", lambda *a, **k: _Resp())
rec_entry = export_with_synthetic.ensure_file_index()["F1"]
rec_entry["url_private_download"] = "https://files.slack.com/x?token=xoxe-test"
data, meta = export_with_synthetic.fetch_file_bytes("F1")
assert data == b"remote-bytes"
assert local.is_file()
assert meta["local_exists"] is True
def test_fetch_file_bytes_unknown_id(export):
with pytest.raises(FileNotFoundError, match="Unknown file"):
export.fetch_file_bytes("NO_SUCH_FILE")