initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
result
|
||||||
56
AGENTS.md
Normal file
56
AGENTS.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# AI Agent Guidelines
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Reads Slack messages from the local Chromium IndexedDB cache on disk. Slack's
|
||||||
|
desktop app (Mac App Store) persists its entire Redux state as a single
|
||||||
|
Blink-serialized blob. We decode it with `dfindexeddb` and extract messages,
|
||||||
|
channels, members, etc.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── slack_cli/ # Python package (canonical source)
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── __main__.py # CLI entry point
|
||||||
|
├── pyproject.toml # Python packaging metadata
|
||||||
|
├── flake.nix # Nix dev shell + package build
|
||||||
|
├── docs/
|
||||||
|
│ └── indexeddb-format.md # Full IndexedDB data format documentation
|
||||||
|
└── scripts/
|
||||||
|
└── analyze_structure.py # Generates schema data for docs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
- **`slack_cli/__main__.py`** — Canonical CLI source. Entry point is `main()`, exposed as the `slack-cli` console script.
|
||||||
|
- **`pyproject.toml`** — Python packaging metadata. Declares `slack-cli` console script entry point and `dfindexeddb` dependency.
|
||||||
|
- **`docs/indexeddb-format.md`** — Documents the on-disk format: LevelDB layer, IndexedDB databases, Blink value encoding, and the full Redux state schema with field-level detail.
|
||||||
|
- **`scripts/analyze_structure.py`** — Introspects the live IndexedDB and dumps database/object-store/record-type info plus Redux state key schemas. Re-run this when the data format changes and update the docs accordingly.
|
||||||
|
- **`flake.nix`** — Nix dev shell (python3.12, uv, snappy) + standalone package build. Packages pinned PyPI deps (python-snappy==0.6.1, zstd==1.5.5.1, dfindexeddb) inline.
|
||||||
|
|
||||||
|
## Dev Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop # Enter shell with python3.12, uv, snappy
|
||||||
|
./slack-cli.py # uv resolves deps automatically via inline metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
Or without nix, ensure `python3.12`, `uv`, and `libsnappy` are available.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix build # Build standalone CLI to ./result/bin/slack-cli
|
||||||
|
nix run # Build and run directly
|
||||||
|
nix run . -- --help # Pass args
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
All Python deps are declared inline in each script's `# /// script` metadata block. `uv` resolves and caches them automatically. The only dependency is:
|
||||||
|
|
||||||
|
- **`dfindexeddb`** — Forensic parser for Chromium IndexedDB/LevelDB and Blink V8 serialized values.
|
||||||
|
|
||||||
|
The nix flake provides **`snappy`** (the C library) because `python-snappy` needs it to compile its native extension.
|
||||||
601
docs/indexeddb-format.md
Normal file
601
docs/indexeddb-format.md
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
# Slack Desktop IndexedDB Data Format
|
||||||
|
|
||||||
|
This document describes the on-disk format and data structure of the Slack
|
||||||
|
desktop app's local IndexedDB cache. It covers the Mac App Store version
|
||||||
|
(`com.tinyspeck.slackmacgap`), though the Electron version uses the same
|
||||||
|
Chromium IndexedDB format at a different path.
|
||||||
|
|
||||||
|
> **Tooling**: All structures were analyzed using
|
||||||
|
> [`dfindexeddb`](https://pypi.org/project/dfindexeddb/) — a forensic Python
|
||||||
|
> library that parses Chromium IndexedDB / LevelDB files and Blink-serialized
|
||||||
|
> V8 values without native dependencies.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Filesystem Layout](#filesystem-layout)
|
||||||
|
- [LevelDB Layer](#leveldb-layer)
|
||||||
|
- [Files](#files)
|
||||||
|
- [Custom Comparator](#custom-comparator)
|
||||||
|
- [Record Types](#record-types)
|
||||||
|
- [IndexedDB Layer](#indexeddb-layer)
|
||||||
|
- [Databases](#databases)
|
||||||
|
- [Object Stores](#object-stores)
|
||||||
|
- [Blob Storage](#blob-storage)
|
||||||
|
- [Value Encoding](#value-encoding)
|
||||||
|
- [Blink IDB Value Wrapper](#blink-idb-value-wrapper)
|
||||||
|
- [V8 Serialization](#v8-serialization)
|
||||||
|
- [Sentinel Types](#sentinel-types)
|
||||||
|
- [JSArray Encoding](#jsarray-encoding)
|
||||||
|
- [Redux State Schema](#redux-state-schema)
|
||||||
|
- [Overview](#overview)
|
||||||
|
- [messages](#messages)
|
||||||
|
- [channels](#channels)
|
||||||
|
- [members](#members)
|
||||||
|
- [reactions](#reactions)
|
||||||
|
- [files](#files-1)
|
||||||
|
- [bots](#bots)
|
||||||
|
- [teams](#teams)
|
||||||
|
- [userGroups](#usergroups)
|
||||||
|
- [channelHistory](#channelhistory)
|
||||||
|
- [allThreads](#allthreads)
|
||||||
|
- [Other Stores](#other-stores)
|
||||||
|
- [Caveats & Limitations](#caveats--limitations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filesystem Layout
|
||||||
|
|
||||||
|
### Mac App Store Version
|
||||||
|
|
||||||
|
```
|
||||||
|
~/Library/Containers/com.tinyspeck.slackmacgap/
|
||||||
|
Data/Library/Application Support/Slack/IndexedDB/
|
||||||
|
https_app.slack.com_0.indexeddb.leveldb/ # LevelDB database
|
||||||
|
000042.log # Write-ahead log (active writes)
|
||||||
|
000044.ldb # SSTable (compacted data)
|
||||||
|
CURRENT # Points to active MANIFEST
|
||||||
|
LOCK # Process lock file
|
||||||
|
LOG # LevelDB operational log
|
||||||
|
LOG.old # Previous operational log
|
||||||
|
MANIFEST-000001 # Database manifest (file versions, levels)
|
||||||
|
https_app.slack.com_0.indexeddb.blob/ # External blob storage
|
||||||
|
2/ # database_id=2
|
||||||
|
1e/ # Sharded directory (blob_number >> 8)
|
||||||
|
1e80 # Blob file (blob_number in hex)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Electron Version (if installed)
|
||||||
|
|
||||||
|
```
|
||||||
|
~/Library/Application Support/Slack/IndexedDB/
|
||||||
|
https_app.slack.com_0.indexeddb.leveldb/
|
||||||
|
https_app.slack.com_0.indexeddb.blob/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other Platforms
|
||||||
|
|
||||||
|
| OS | Path |
|
||||||
|
| ------- | ------------------------------- |
|
||||||
|
| Linux | `~/.config/Slack/IndexedDB/...` |
|
||||||
|
| Windows | `%AppData%\Slack\IndexedDB\...` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LevelDB Layer
|
||||||
|
|
||||||
|
Chromium's IndexedDB is backed by LevelDB, a sorted key-value store.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
| File Pattern | Purpose |
|
||||||
|
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `*.log` | Write-ahead log. Contains recent, uncommitted writes as `WriteBatch` records. Each record has a 7-byte header: checksum (4), length (2), type (1). |
|
||||||
|
| `*.ldb` / `*.sst` | SSTables. Immutable, sorted, compressed (Snappy) data files produced by compaction. |
|
||||||
|
| `MANIFEST-*` | Tracks which files belong to which LSM-tree level, active file set. |
|
||||||
|
| `CURRENT` | Text file pointing to the active manifest (e.g., `MANIFEST-000001`). |
|
||||||
|
| `LOCK` | Advisory file lock. **Held by Slack while running.** |
|
||||||
|
| `LOG` | LevelDB's internal operational log (compaction events, etc.). |
|
||||||
|
|
||||||
|
### Custom Comparator
|
||||||
|
|
||||||
|
Chromium IndexedDB uses a custom LevelDB comparator called **`idb_cmp1`**
|
||||||
|
rather than the default `leveldb.BytewiseComparator`. This means:
|
||||||
|
|
||||||
|
- Standard LevelDB libraries (`plyvel`, `leveldb`) **cannot open** these
|
||||||
|
databases — they will fail with a comparator mismatch error.
|
||||||
|
- You must either use `dfindexeddb` (which parses the raw files without
|
||||||
|
opening the DB) or copy the data and parse at the binary level.
|
||||||
|
|
||||||
|
### Record Types
|
||||||
|
|
||||||
|
Each LevelDB key in the IndexedDB encodes a typed key prefix. The key types
|
||||||
|
observed in Slack's database:
|
||||||
|
|
||||||
|
| Record Type | Count | Description |
|
||||||
|
| ------------------------ | ------- | ----------------------------------------------- |
|
||||||
|
| `ScopesPrefixKey` | ~13,000 | Internal scope tracking records |
|
||||||
|
| `RecoveryBlobJournalKey` | ~2,300 | Blob lifecycle / garbage collection journal |
|
||||||
|
| `ObjectStoreDataKey` | ~1,600 | **Actual data records** (messages, state, etc.) |
|
||||||
|
| `ExistsEntryKey` | ~1,600 | Existence index (mirrors data keys) |
|
||||||
|
| `BlobEntryKey` | ~1,500 | Maps data keys to external blob references |
|
||||||
|
| `ObjectStoreMetaDataKey` | ~800 | Object store schema metadata |
|
||||||
|
| `DatabaseMetaDataKey` | ~770 | Database-level metadata (version, etc.) |
|
||||||
|
| `ActiveBlobJournalKey` | ~760 | Currently active blob journal |
|
||||||
|
| `DatabaseNameKey` | 3 | Maps database IDs to names |
|
||||||
|
| `ObjectStoreNamesKey` | 3 | Maps object store IDs to names |
|
||||||
|
| `SchemaVersionKey` | 1 | IndexedDB schema version |
|
||||||
|
| `MaxDatabaseIdKey` | 1 | Highest allocated database ID |
|
||||||
|
| `DataVersionKey` | 1 | Data format version |
|
||||||
|
|
||||||
|
The high counts for `ScopesPrefixKey`, `RecoveryBlobJournalKey`, and
|
||||||
|
`ActiveBlobJournalKey` reflect Slack's frequent Redux state persistence —
|
||||||
|
each save cycle creates a new blob and journals the old one for garbage
|
||||||
|
collection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## IndexedDB Layer
|
||||||
|
|
||||||
|
### Databases
|
||||||
|
|
||||||
|
Three IndexedDB databases are present:
|
||||||
|
|
||||||
|
| `database_id` | Name | Purpose |
|
||||||
|
| ------------- | ------------------ | ---------------------------------------------------------------------------- |
|
||||||
|
| 2 | `reduxPersistence` | Slack's full Redux application state |
|
||||||
|
| 3 | _(unnamed)_ | Encrypted syncer data (e.g., `syncer.User/{hash}`, `syncer.Document/{hash}`) |
|
||||||
|
| 10 | _(unnamed)_ | Sundry metadata (e.g., `minChannelUpdated` timestamp) |
|
||||||
|
|
||||||
|
> **Note**: `database_id=0` is used for global IndexedDB metadata records
|
||||||
|
> (database names, schema version, etc.) and is not an application database.
|
||||||
|
|
||||||
|
### Object Stores
|
||||||
|
|
||||||
|
| `database_id` | `object_store_id` | Store Name | Key Pattern | Storage |
|
||||||
|
| ------------- | ----------------- | -------------------- | ------------------------------------------ | ------------------------------- |
|
||||||
|
| 2 | 1 | `reduxPersenceStore` | `persist:slack-client-{TEAM_ID}-{USER_ID}` | External blob (~4-7 MB) |
|
||||||
|
| 3 | 1 | `{hex-hash}` | `syncer.{Type}/{ID}` | Inline, **encrypted** (AES-GCM) |
|
||||||
|
| 10 | 1 | `sundryStorage` | `0` | Inline |
|
||||||
|
|
||||||
|
The Redux state store (db=2) contains a single key per team+user combination.
|
||||||
|
The entire application state is serialized into one large blob.
|
||||||
|
|
||||||
|
### Blob Storage
|
||||||
|
|
||||||
|
When an IndexedDB value exceeds the inline size threshold, Chromium stores it
|
||||||
|
as an external blob file. The blob path is derived from the blob number:
|
||||||
|
|
||||||
|
```
|
||||||
|
{blob_dir}/{database_id}/{blob_number >> 8 :02x}/{blob_number :04x}
|
||||||
|
```
|
||||||
|
|
||||||
|
For example, blob number `7808` (hex `0x1e80`) in database `2`:
|
||||||
|
|
||||||
|
```
|
||||||
|
https_app.slack.com_0.indexeddb.blob/2/1e/1e80
|
||||||
|
```
|
||||||
|
|
||||||
|
Blobs are **versioned** — each Redux persist cycle allocates a new blob number
|
||||||
|
and the previous blob is journaled for deletion. Only the latest blob contains
|
||||||
|
the current state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Value Encoding
|
||||||
|
|
||||||
|
### Blink IDB Value Wrapper
|
||||||
|
|
||||||
|
Chromium wraps IndexedDB values in a Blink-specific envelope with MIME type
|
||||||
|
`application/vnd.blink-idb-value-wrapper`. The blob file begins with a
|
||||||
|
3-byte header:
|
||||||
|
|
||||||
|
| Offset | Value | Meaning |
|
||||||
|
| ------ | ------ | ------------------------------------- |
|
||||||
|
| 0 | `0xFF` | Blink serialization tag: VERSION |
|
||||||
|
| 1 | `0x11` | Pseudo-version: "requires processing" |
|
||||||
|
| 2 | `0x02` | Compression: Snappy |
|
||||||
|
|
||||||
|
After the header, the remaining bytes are **Snappy-compressed** V8 serialized
|
||||||
|
data.
|
||||||
|
|
||||||
|
### V8 Serialization
|
||||||
|
|
||||||
|
The decompressed data uses Chrome's V8 serialization format — the same binary
|
||||||
|
format used by `structuredClone()` and `postMessage()`. It encodes JavaScript
|
||||||
|
values including:
|
||||||
|
|
||||||
|
- Primitives: `string`, `number`, `boolean`, `null`, `undefined`
|
||||||
|
- Objects: `{}` → Python `dict`
|
||||||
|
- Arrays: `[]` → Python `JSArray` (see below)
|
||||||
|
- Typed arrays, `Date`, `RegExp`, `Map`, `Set`, `ArrayBuffer`, etc.
|
||||||
|
|
||||||
|
`dfindexeddb` deserializes this into Python-native types with a few special
|
||||||
|
sentinel objects.
|
||||||
|
|
||||||
|
### Sentinel Types
|
||||||
|
|
||||||
|
`dfindexeddb` represents JavaScript values that have no Python equivalent
|
||||||
|
using sentinel objects:
|
||||||
|
|
||||||
|
| JS Value | dfindexeddb Type | Python `repr` | Notes |
|
||||||
|
| ----------- | ---------------- | ------------- | ----------------------------------------------------------------------------------- |
|
||||||
|
| `undefined` | `Undefined` | `Undefined()` | Distinct from `null`. Common on optional message fields (e.g., `subtype`, `files`). |
|
||||||
|
| `null` | `Null` | `Null()` | Used where Slack explicitly sets `null`. |
|
||||||
|
| `NaN` | `NaN` | `NaN()` | Rare. |
|
||||||
|
|
||||||
|
**Important**: When checking fields, always handle these types. A message's
|
||||||
|
`subtype` field is `Undefined()` (not `None`, not missing) when no subtype
|
||||||
|
applies:
|
||||||
|
|
||||||
|
```python
|
||||||
|
subtype = msg.get("subtype", "")
|
||||||
|
if not isinstance(subtype, str):
|
||||||
|
subtype = "" # Was Undefined() or Null()
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSArray Encoding
|
||||||
|
|
||||||
|
JavaScript sparse arrays are encoded as `JSArray` objects with two attributes:
|
||||||
|
|
||||||
|
- **`values`**: A Python list of positional values. Sparse positions are
|
||||||
|
`Undefined()`.
|
||||||
|
- **`properties`**: A Python dict mapping string indices to the actual values.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# JS: ["alice", "bob", "carol"]
|
||||||
|
# Python:
|
||||||
|
JSArray(
|
||||||
|
values=[Undefined(), Undefined(), Undefined()],
|
||||||
|
properties={0: "alice", 1: "bob", 2: "carol"}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
To iterate a `JSArray` as a flat list:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def jsarray_to_list(arr):
|
||||||
|
if hasattr(arr, "properties"):
|
||||||
|
return [arr.properties.get(i) for i in range(len(arr.values))]
|
||||||
|
return arr # Already a plain list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redux State Schema
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The Redux state blob contains Slack's entire client-side application state.
|
||||||
|
It is a single large JavaScript object with ~140 top-level keys. The largest
|
||||||
|
stores (by serialized size):
|
||||||
|
|
||||||
|
| Key | Size | Entries | Description |
|
||||||
|
| ---------------- | ------- | ------------- | ----------------------------------------- |
|
||||||
|
| `messages` | ~44 MB | ~295 channels | Cached message history |
|
||||||
|
| `channels` | ~1.3 MB | ~583 | Channel metadata |
|
||||||
|
| `files` | ~1.2 MB | ~267 | File/upload metadata |
|
||||||
|
| `channelHistory` | ~800 KB | ~1,200 | Pagination / scroll state per channel |
|
||||||
|
| `members` | ~730 KB | ~351 | User profiles |
|
||||||
|
| `experiments` | ~310 KB | ~1,527 | Feature flag experiments |
|
||||||
|
| `reactions` | ~290 KB | ~955 | Emoji reactions on messages |
|
||||||
|
| `apps` | ~280 KB | ~29 | Installed Slack app metadata |
|
||||||
|
| `prefs` | ~170 KB | 4 | User preferences (huge, hundreds of keys) |
|
||||||
|
| `userPrefs` | ~156 KB | ~667 | Additional user preference data |
|
||||||
|
| `threadSub` | ~135 KB | ~1,120 | Thread subscription state |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### messages
|
||||||
|
|
||||||
|
**Path**: `state.messages[channel_id][timestamp]`
|
||||||
|
|
||||||
|
The primary message store. Keyed by channel ID, then by message timestamp.
|
||||||
|
|
||||||
|
```
|
||||||
|
messages: {
|
||||||
|
"C0XXXXXXXXX": { # Channel ID
|
||||||
|
"1776115292.356529": { ... }, # Message (ts is the key)
|
||||||
|
"1776117909.325989": { ... },
|
||||||
|
...
|
||||||
|
},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Message Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `ts` | `str` | Message timestamp (unique ID). Unix epoch with microseconds as decimal. |
|
||||||
|
| `type` | `str` | Always `"message"`. |
|
||||||
|
| `text` | `str` | Message text content. Contains Slack markup: `<@U123>` for mentions, `<url\|label>` for links. |
|
||||||
|
| `user` | `str` | User ID of sender (e.g., `"U0XXXXXXXXX"`). |
|
||||||
|
| `channel` | `str` | Channel ID (e.g., `"C0XXXXXXXXX"`). |
|
||||||
|
| `subtype` | `str` \| `Undefined` | Message subtype: `"channel_join"`, `"bot_message"`, etc. `Undefined()` for normal messages. |
|
||||||
|
| `thread_ts` | `str` \| `Undefined` | Parent thread timestamp. Same as `ts` for thread parent messages. `Undefined()` for non-threaded. |
|
||||||
|
| `reply_count` | `int` | Number of replies (0 for non-parent messages). |
|
||||||
|
| `reply_users` | `JSArray` \| `Undefined` | User IDs of thread participants. |
|
||||||
|
| `reply_users_count` | `int` \| `Undefined` | Count of unique repliers. |
|
||||||
|
| `latest_reply` | `str` \| `Undefined` | Timestamp of latest reply. |
|
||||||
|
| `_hidden_reply` | `bool` | `True` if this is a thread reply not shown in the channel. |
|
||||||
|
| `blocks` | `JSArray` \| `Undefined` | Slack Block Kit elements (rich text, sections, images, etc.). |
|
||||||
|
| `files` | `JSArray` \| `Undefined` | File IDs attached to this message. Values in `properties` are file ID strings (not full file objects). |
|
||||||
|
| `attachments` | `JSArray` \| `Undefined` | Legacy attachments (links, bot attachments). |
|
||||||
|
| `client_msg_id` | `str` | Client-generated UUID for the message. |
|
||||||
|
| `no_display` | `bool` | Whether to hide this message in UI. |
|
||||||
|
| `_rxn_key` | `str` | Key for looking up reactions: `"message-{ts}-{channel}"`. |
|
||||||
|
| `slackbot_feels` | `Null` | Slackbot sentiment (always `Null()` in practice). |
|
||||||
|
| `__meta__` | `dict` | Internal cache metadata: `{"lastUpdatedTs": "..."}`. |
|
||||||
|
| `parent_user_id` | `str` | User ID of the thread parent author (only on replies). |
|
||||||
|
| `upload` | `bool` | Present and `True` on file upload messages. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### channels
|
||||||
|
|
||||||
|
**Path**: `state.channels[channel_id]`
|
||||||
|
|
||||||
|
Channel metadata. Includes public channels, private channels, DMs, and MPDMs.
|
||||||
|
|
||||||
|
#### Channel Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------------- | ------- | --------------------------------------------------- |
|
||||||
|
| `id` | `str` | Channel ID (e.g., `"C0XXXXXXXXX"`). |
|
||||||
|
| `name` | `str` | Channel display name. |
|
||||||
|
| `name_normalized` | `str` | Lowercase normalized name. |
|
||||||
|
| `is_channel` | `bool` | Public channel. |
|
||||||
|
| `is_group` | `bool` | Private channel (legacy term). |
|
||||||
|
| `is_im` | `bool` | Direct message. |
|
||||||
|
| `is_mpim` | `bool` | Multi-party direct message. |
|
||||||
|
| `is_private` | `bool` | Private (group or MPIM). |
|
||||||
|
| `is_archived` | `bool` | Channel is archived. |
|
||||||
|
| `is_general` | `bool` | The `#general` channel. |
|
||||||
|
| `is_member` | `bool` | Current user is a member. |
|
||||||
|
| `created` | `float` | Unix timestamp of channel creation. |
|
||||||
|
| `creator` | `str` | User ID of channel creator. |
|
||||||
|
| `context_team_id` | `str` | Team ID this channel belongs to. |
|
||||||
|
| `topic` | `dict` | `{"value": "...", "creator": "...", "last_set": 0}` |
|
||||||
|
| `purpose` | `dict` | `{"value": "...", "creator": "...", "last_set": 0}` |
|
||||||
|
| `unread_cnt` | `int` | Unread message count. |
|
||||||
|
| `unread_highlight_cnt` | `int` | Unread mentions/highlights count. |
|
||||||
|
| `is_ext_shared` | `bool` | Slack Connect shared channel. |
|
||||||
|
| `is_org_shared` | `bool` | Shared across org workspaces. |
|
||||||
|
| `is_frozen` | `bool` | Channel is frozen (read-only). |
|
||||||
|
|
||||||
|
_Plus ~30 additional boolean flags and UI state fields._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### members
|
||||||
|
|
||||||
|
**Path**: `state.members[user_id]`
|
||||||
|
|
||||||
|
User profiles for all visible workspace members.
|
||||||
|
|
||||||
|
#### Member Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------------------- | ------- | -------------------------------------------------------------------------------------------------- |
|
||||||
|
| `id` | `str` | User ID (e.g., `"U0XXXXXXXXX"`). |
|
||||||
|
| `team_id` | `str` | Primary team ID. |
|
||||||
|
| `name` | `str` | Username (login name). |
|
||||||
|
| `real_name` | `str` | Full display name. |
|
||||||
|
| `deleted` | `bool` | Account deactivated. |
|
||||||
|
| `color` | `str` | Hex color assigned to user. |
|
||||||
|
| `tz` | `str` | Timezone identifier (e.g., `"America/New_York"`). |
|
||||||
|
| `tz_label` | `str` | Human-readable timezone name. |
|
||||||
|
| `tz_offset` | `int` | UTC offset in seconds. |
|
||||||
|
| `profile` | `dict` | Nested profile with `title`, `phone`, `email`, `image_*` URLs, `status_text`, `status_emoji`, etc. |
|
||||||
|
| `is_admin` | `bool` | Workspace admin. |
|
||||||
|
| `is_owner` | `bool` | Workspace owner. |
|
||||||
|
| `is_bot` | `bool` | Bot account. |
|
||||||
|
| `is_app_user` | `bool` | App-associated user. |
|
||||||
|
| `is_restricted` | `bool` | Guest (single-channel or multi-channel). |
|
||||||
|
| `is_ultra_restricted` | `bool` | Single-channel guest. |
|
||||||
|
| `updated` | `float` | Last profile update timestamp. |
|
||||||
|
| `is_self` | `bool` | `True` for the current logged-in user. |
|
||||||
|
|
||||||
|
_Plus `_name_lc`, `_display_name_lc`, etc. for search/sorting._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### reactions
|
||||||
|
|
||||||
|
**Path**: `state.reactions[reaction_key]`
|
||||||
|
|
||||||
|
Keyed by `"message-{ts}-{channel_id}"` (matching the `_rxn_key` field on
|
||||||
|
messages).
|
||||||
|
|
||||||
|
Each value is a `JSArray` of reaction objects:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"name": "eyes", # Emoji name
|
||||||
|
"baseName": "eyes", # Base name (without skin tone)
|
||||||
|
"count": 2, # Total reaction count
|
||||||
|
"users": JSArray( # User IDs who reacted
|
||||||
|
values=[Undefined(), Undefined()],
|
||||||
|
properties={0: "U0XXXXXXXXX", 1: "U0YYYYYYYYY"}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### files
|
||||||
|
|
||||||
|
**Path**: `state.files[file_id]`
|
||||||
|
|
||||||
|
File metadata for files visible in the current session.
|
||||||
|
|
||||||
|
#### File Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------- | ------- | ----------------------------------------------------------------------------- |
|
||||||
|
| `id` | `str` | File ID (e.g., `"F0XXXXXXXXX"`). |
|
||||||
|
| `name` | `str` | Original filename. |
|
||||||
|
| `title` | `str` | Display title. |
|
||||||
|
| `mimetype` | `str` | MIME type (e.g., `"image/png"`). |
|
||||||
|
| `filetype` | `str` | Short type (e.g., `"png"`, `"pdf"`). |
|
||||||
|
| `size` | `int` | File size in bytes. |
|
||||||
|
| `user` | `str` | Uploader's user ID. |
|
||||||
|
| `created` | `float` | Upload timestamp. |
|
||||||
|
| `url_private` | `str` | Authenticated download URL. |
|
||||||
|
| `permalink` | `str` | Permanent link to file in Slack. |
|
||||||
|
| `thumb_*` | `str` | Thumbnail URLs at various sizes (64, 80, 160, 360, 480, 720, 800, 960, 1024). |
|
||||||
|
| `original_w` | `int` | Original image width. |
|
||||||
|
| `original_h` | `int` | Original image height. |
|
||||||
|
| `is_public` | `bool` | Shared to a public channel. |
|
||||||
|
| `is_external` | `bool` | External file (Google Drive, etc.). |
|
||||||
|
|
||||||
|
> **Note**: File URLs require Slack authentication to access. The `files`
|
||||||
|
> store in messages contains only file IDs (strings), not full file objects.
|
||||||
|
> Cross-reference with `state.files[file_id]` for metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### bots
|
||||||
|
|
||||||
|
**Path**: `state.bots[bot_id]`
|
||||||
|
|
||||||
|
Bot user metadata.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| --------- | ------- | ---------------------------------------------- |
|
||||||
|
| `id` | `str` | Bot ID (e.g., `"B0XXXXXXXXX"`). |
|
||||||
|
| `name` | `str` | Bot display name (e.g., `"MyBot"`). |
|
||||||
|
| `app_id` | `str` | Associated Slack app ID. |
|
||||||
|
| `user_id` | `str` | User ID associated with this bot. |
|
||||||
|
| `icons` | `dict` | Icon URLs: `image_36`, `image_48`, `image_72`. |
|
||||||
|
| `deleted` | `bool` | Bot is deactivated. |
|
||||||
|
| `updated` | `float` | Last update timestamp. |
|
||||||
|
| `team_id` | `str` | Team ID. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### teams
|
||||||
|
|
||||||
|
**Path**: `state.teams[team_id]`
|
||||||
|
|
||||||
|
Workspace/org metadata.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| -------------- | ------- | ---------------------------------------------------- |
|
||||||
|
| `id` | `str` | Team ID (e.g., `"T0XXXXXXXXX"`). |
|
||||||
|
| `name` | `str` | Workspace name. |
|
||||||
|
| `domain` | `str` | Slack subdomain. |
|
||||||
|
| `url` | `str` | Full workspace URL. |
|
||||||
|
| `email_domain` | `str` | Email domain for sign-up. |
|
||||||
|
| `plan` | `str` | Plan type (`"std"`, `"plus"`, `"enterprise"`, etc.). |
|
||||||
|
| `icon` | `dict` | Workspace icon URLs at various sizes. |
|
||||||
|
| `date_created` | `float` | Workspace creation timestamp. |
|
||||||
|
| `prefs` | `dict` | Workspace-level preferences (large, many keys). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### userGroups
|
||||||
|
|
||||||
|
**Path**: `state.userGroups[group_id]`
|
||||||
|
|
||||||
|
User groups (e.g., `@engineering`, `@design`).
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------- | --------- | ----------------------------------------------- |
|
||||||
|
| `id` | `str` | Group ID (e.g., `"S0XXXXXXXXX"`). |
|
||||||
|
| `name` | `str` | Display name. |
|
||||||
|
| `handle` | `str` | Mention handle (e.g., `"design"`). |
|
||||||
|
| `description` | `str` | Group description. |
|
||||||
|
| `user_count` | `int` | Number of members. |
|
||||||
|
| `users` | `JSArray` | Member user IDs. |
|
||||||
|
| `prefs` | `dict` | Contains `channels` JSArray (default channels). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### channelHistory
|
||||||
|
|
||||||
|
**Path**: `state.channelHistory[channel_id]`
|
||||||
|
|
||||||
|
Pagination and fetch state for channel message history.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------- | ---------------- | ----------------------------------------------------------------------- |
|
||||||
|
| `reachedStart` | `bool` | Scrolled to the very first message. |
|
||||||
|
| `reachedEnd` | `bool` \| `Null` | Scrolled to the latest message. |
|
||||||
|
| `prevReachedEnd` | `bool` | Previously reached end (before new messages arrived). |
|
||||||
|
| `slices` | `JSArray` | Loaded message timestamp ranges. Each slice has a `timestamps` JSArray. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### allThreads
|
||||||
|
|
||||||
|
**Path**: `state.allThreads`
|
||||||
|
|
||||||
|
Thread view state (the "Threads" sidebar panel).
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ------------- | --------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `threads` | `JSArray` | Thread summaries. Each property has `threadKey` (`"{channel}-{ts}"`), `sortTs`, `hasUnreads`, `isPriority`. |
|
||||||
|
| `hasMore` | `bool` | More threads available to load. |
|
||||||
|
| `cursorTs` | `str` | Pagination cursor. |
|
||||||
|
| `maxTs` | `str` | Most recent thread timestamp. |
|
||||||
|
| `selectedTab` | `str` | Active tab: `"all"` or `"unreads"`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Other Stores
|
||||||
|
|
||||||
|
Stores not detailed above but present in the state:
|
||||||
|
|
||||||
|
| Key | Entries | Description |
|
||||||
|
| ------------------ | ------- | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `experiments` | ~1,500 | Feature flags and A/B test assignments |
|
||||||
|
| `prefs` | 4 | User preferences — `user`, `team`, `client`, `features`. The `user` entry alone has 400+ preference keys. |
|
||||||
|
| `threadSub` | ~1,100 | Thread subscription state per channel+thread |
|
||||||
|
| `searchResults` | 1 | Last search query, results, and filters |
|
||||||
|
| `membership` | ~18 | Channel membership maps: `{user_id: {isKnown, isMember}}` |
|
||||||
|
| `membershipCounts` | ~97 | Channel member counts |
|
||||||
|
| `channelCursors` | ~256 | Read cursor positions per channel |
|
||||||
|
| `mutedChannels` | ~14 | Muted channel list |
|
||||||
|
| `unreadCounts` | ~18 | Unread count state per channel |
|
||||||
|
| `flannelEmoji` | ~548 | Custom workspace emoji definitions |
|
||||||
|
| `slashCommand` | 2 | Slash command definitions |
|
||||||
|
| `channelSections` | ~18 | Sidebar section organization |
|
||||||
|
| `bootData` | 67 | Initial boot data (team info, feature gates) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caveats & Limitations
|
||||||
|
|
||||||
|
1. **Cache only** — Slack only caches **recently viewed** channels and
|
||||||
|
messages. The IndexedDB does not contain complete workspace history.
|
||||||
|
|
||||||
|
2. **Single blob** — The entire Redux state is one monolithic blob (~4-7 MB
|
||||||
|
compressed, ~90 MB JSON). There is no way to read individual channels
|
||||||
|
without decoding the whole thing.
|
||||||
|
|
||||||
|
3. **Lock file** — Slack holds the LevelDB `LOCK` file while running. To
|
||||||
|
read the data you must either:
|
||||||
|
- Copy the LevelDB + blob directories and remove the `LOCK` file from
|
||||||
|
the copy, or
|
||||||
|
- Parse the raw `.log` and `.ldb` files directly (which `dfindexeddb`
|
||||||
|
does).
|
||||||
|
|
||||||
|
4. **Blob rotation** — Slack persists state frequently. The blob file changes
|
||||||
|
every few seconds. Only the **latest** blob (highest modification time)
|
||||||
|
contains current data.
|
||||||
|
|
||||||
|
5. **Encrypted data** — Database 3 (object store name is a hex hash)
|
||||||
|
contains AES-GCM encrypted values (syncer data). The encryption key is
|
||||||
|
not stored in the IndexedDB and these records cannot be decrypted from
|
||||||
|
disk alone.
|
||||||
|
|
||||||
|
6. **File references** — Messages reference files by ID only (e.g.,
|
||||||
|
`"F0XXXXXXXXX"`), not by the full file object. Cross-reference with
|
||||||
|
`state.files[file_id]` for metadata and URLs.
|
||||||
|
|
||||||
|
7. **Slack markup** — Message `text` fields contain Slack's markup format:
|
||||||
|
- User mentions: `<@U0XXXXXXXXX>`
|
||||||
|
- Channel links: `<#C0XXXXXXXXX|general>`
|
||||||
|
- URLs: `<https://example.com|label>`
|
||||||
|
- Emoji: `:thumbsup:` (not Unicode)
|
||||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1776067740,
|
||||||
|
"narHash": "sha256-B35lpsqnSZwn1Lmz06BpwF7atPgFmUgw1l8KAV3zpVQ=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "7e495b747b51f95ae15e74377c5ce1fe69c1765f",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-25.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
129
flake.nix
Normal file
129
flake.nix
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Usage:
|
||||||
|
# - Shell: `nix develop`
|
||||||
|
# - Direnv (https://direnv.net/): `.envrc` content of `use flake`
|
||||||
|
# - Build: `nix build`
|
||||||
|
# - Run: `nix run`
|
||||||
|
|
||||||
|
{
|
||||||
|
description = "Slack Local Reader";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self
|
||||||
|
, nixpkgs
|
||||||
|
, flake-utils
|
||||||
|
,
|
||||||
|
}:
|
||||||
|
flake-utils.lib.eachDefaultSystem (
|
||||||
|
system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
python = pkgs.python312;
|
||||||
|
|
||||||
|
# ── Python Dependency Overrides ──────────────────────────────
|
||||||
|
#
|
||||||
|
# dfindexeddb pins python-snappy==0.6.1 and zstd==1.5.5.1.
|
||||||
|
# nixpkgs ships newer versions, so we build the exact pins
|
||||||
|
# from PyPI source tarballs.
|
||||||
|
|
||||||
|
pythonPackages = python.pkgs;
|
||||||
|
|
||||||
|
python-snappy = pythonPackages.buildPythonPackage rec {
|
||||||
|
pname = "python-snappy";
|
||||||
|
version = "0.6.1";
|
||||||
|
format = "setuptools";
|
||||||
|
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://files.pythonhosted.org/packages/98/7a/44a24bad98335b2c72e4cadcdecf79f50197d1bab9f22f863a274f104b96/python-snappy-0.6.1.tar.gz";
|
||||||
|
hash = "sha256-tqEHqwYgasxTWdTFYyvZsi1EhwKnmzFpsMYuD7gIuyo=";
|
||||||
|
};
|
||||||
|
|
||||||
|
buildInputs = [ pkgs.snappy ];
|
||||||
|
|
||||||
|
# Tests require snappy test fixtures not present in sdist
|
||||||
|
doCheck = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
zstd-python = pythonPackages.buildPythonPackage rec {
|
||||||
|
pname = "zstd";
|
||||||
|
version = "1.5.5.1";
|
||||||
|
format = "setuptools";
|
||||||
|
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://files.pythonhosted.org/packages/source/z/zstd/zstd-1.5.5.1.tar.gz";
|
||||||
|
hash = "sha256-HvmAq/Dh4HKwKNLXbvlbR2YyZRyWIlzzC2Gcbu9iVnI=";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Bundled C sources — no external zstd library needed
|
||||||
|
doCheck = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
dfindexeddb = pythonPackages.buildPythonPackage rec {
|
||||||
|
pname = "dfindexeddb";
|
||||||
|
version = "20260210";
|
||||||
|
format = "setuptools";
|
||||||
|
|
||||||
|
src = pkgs.fetchurl {
|
||||||
|
url = "https://files.pythonhosted.org/packages/source/d/dfindexeddb/dfindexeddb-20260210.tar.gz";
|
||||||
|
hash = "sha256-4ahEe4Lpoh0oqGR6kI7J1HEGfvKVEzu3qQ+3ykgFd/Y=";
|
||||||
|
};
|
||||||
|
|
||||||
|
propagatedBuildInputs = [
|
||||||
|
python-snappy
|
||||||
|
zstd-python
|
||||||
|
];
|
||||||
|
|
||||||
|
doCheck = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
# ── Slack CLI Package ────────────────────────────────────────
|
||||||
|
|
||||||
|
slack-cli = pythonPackages.buildPythonApplication {
|
||||||
|
pname = "slack-cli";
|
||||||
|
version = "0.1.0";
|
||||||
|
format = "pyproject";
|
||||||
|
|
||||||
|
src = let
|
||||||
|
fs = pkgs.lib.fileset;
|
||||||
|
in
|
||||||
|
fs.toSource {
|
||||||
|
root = ./.;
|
||||||
|
fileset = fs.unions [
|
||||||
|
./pyproject.toml
|
||||||
|
./slack_cli
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
build-system = [ pythonPackages.setuptools ];
|
||||||
|
|
||||||
|
dependencies = [ dfindexeddb ];
|
||||||
|
|
||||||
|
doCheck = false;
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Read Slack messages from local Chromium IndexedDB cache";
|
||||||
|
mainProgram = "slack-cli";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.default = slack-cli;
|
||||||
|
|
||||||
|
devShells.default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
python
|
||||||
|
uv
|
||||||
|
snappy
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export UV_PYTHON_PREFERENCE=only-system
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "slack-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
description = "Read Slack messages from local Chromium IndexedDB cache"
|
||||||
|
dependencies = ["dfindexeddb"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
slack-cli = "slack_cli.__main__:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["slack_cli*"]
|
||||||
196
scripts/analyze_structure.py
Executable file
196
scripts/analyze_structure.py
Executable file
@@ -0,0 +1,196 @@
|
|||||||
|
#!/usr/bin/env -S uv run --python=python3.12 --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.12"
|
||||||
|
# dependencies = ["dfindexeddb"]
|
||||||
|
# ///
|
||||||
|
"""Analyze Slack IndexedDB Structure
|
||||||
|
|
||||||
|
Dumps the full schema of the IndexedDB: databases, object stores,
|
||||||
|
record types, and the Redux state top-level keys with sizes/types.
|
||||||
|
Used for generating documentation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from dfindexeddb.indexeddb.chromium.blink import V8ScriptValueDecoder
|
||||||
|
from dfindexeddb.indexeddb.chromium.record import FolderReader
|
||||||
|
|
||||||
|
HOME = pathlib.Path.home()
|
||||||
|
IDB_BASE = HOME / (
|
||||||
|
"Library/Containers/com.tinyspeck.slackmacgap"
|
||||||
|
"/Data/Library/Application Support/Slack/IndexedDB"
|
||||||
|
)
|
||||||
|
LDB_DIR = IDB_BASE / "https_app.slack.com_0.indexeddb.leveldb"
|
||||||
|
BLOB_DIR = IDB_BASE / "https_app.slack.com_0.indexeddb.blob"
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_leveldb():
|
||||||
|
"""Analyze LevelDB Record Structure"""
|
||||||
|
print("=" * 70)
|
||||||
|
print("LEVELDB / INDEXEDDB STRUCTURE")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Filesystem Layout
|
||||||
|
print("\n## Filesystem Layout")
|
||||||
|
print(f"\nLevelDB dir: {LDB_DIR}")
|
||||||
|
for p in sorted(LDB_DIR.iterdir()):
|
||||||
|
size = p.stat().st_size
|
||||||
|
print(f" {p.name:30s} {size:>12,} bytes")
|
||||||
|
|
||||||
|
print(f"\nBlob dir: {BLOB_DIR}")
|
||||||
|
for p in sorted(BLOB_DIR.rglob("*")):
|
||||||
|
if p.is_file():
|
||||||
|
size = p.stat().st_size
|
||||||
|
rel = p.relative_to(BLOB_DIR)
|
||||||
|
print(f" {str(rel):30s} {size:>12,} bytes")
|
||||||
|
|
||||||
|
# Copy DB to Avoid Lock
|
||||||
|
tmp = pathlib.Path(tempfile.mkdtemp())
|
||||||
|
shutil.copytree(str(LDB_DIR), str(tmp / "db"))
|
||||||
|
(tmp / "db" / "LOCK").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Parse Records
|
||||||
|
reader = FolderReader(tmp / "db")
|
||||||
|
key_types = Counter()
|
||||||
|
db_meta = {} # db_id -> {name, obj_stores}
|
||||||
|
obj_store_names = {} # (db_id, os_id) -> name
|
||||||
|
|
||||||
|
for rec in reader.GetRecords(load_blobs=False):
|
||||||
|
kt = type(rec.key).__name__
|
||||||
|
key_types[kt] += 1
|
||||||
|
|
||||||
|
db_id = rec.database_id or 0
|
||||||
|
|
||||||
|
if kt == "DatabaseNameKey":
|
||||||
|
dn = getattr(rec.key, "database_name", None)
|
||||||
|
if dn:
|
||||||
|
db_meta.setdefault(db_id, {"name": None})["name"] = str(dn)
|
||||||
|
|
||||||
|
if kt == "ObjectStoreMetaDataKey":
|
||||||
|
md_type = getattr(rec.key, "metadata_type", None)
|
||||||
|
os_id = getattr(rec.key, "object_store_id", None)
|
||||||
|
if md_type == 0 and rec.value:
|
||||||
|
obj_store_names[(db_id, os_id)] = str(rec.value)
|
||||||
|
|
||||||
|
if kt == "ObjectStoreDataKey":
|
||||||
|
user_key = getattr(rec.key, "encoded_user_key", None)
|
||||||
|
val = rec.value
|
||||||
|
blob_size = getattr(val, "blob_size", None) if val else None
|
||||||
|
version = getattr(val, "version", None) if val else None
|
||||||
|
key_val = getattr(user_key, "value", None) if user_key else None
|
||||||
|
os_id = rec.object_store_id
|
||||||
|
|
||||||
|
info = db_meta.setdefault(db_id, {"name": None})
|
||||||
|
stores = info.setdefault("obj_stores", {})
|
||||||
|
store_info = stores.setdefault(os_id, {"keys": [], "sample_key": None})
|
||||||
|
if key_val and not store_info["sample_key"]:
|
||||||
|
store_info["sample_key"] = str(key_val)[:80]
|
||||||
|
store_info["blob_size"] = blob_size
|
||||||
|
store_info["version"] = version
|
||||||
|
|
||||||
|
shutil.rmtree(tmp)
|
||||||
|
|
||||||
|
# Print Databases
|
||||||
|
print("\n## Databases")
|
||||||
|
for db_id in sorted(db_meta.keys()):
|
||||||
|
info = db_meta[db_id]
|
||||||
|
name = info.get("name", "?")
|
||||||
|
print(f"\n database_id={db_id}: \"{name}\"")
|
||||||
|
for (did, osid), osname in sorted(obj_store_names.items()):
|
||||||
|
if did == db_id:
|
||||||
|
print(f" object_store_id={osid}: \"{osname}\"")
|
||||||
|
store_info = info.get("obj_stores", {}).get(osid, {})
|
||||||
|
if store_info.get("sample_key"):
|
||||||
|
print(f" sample_key: {store_info['sample_key']}")
|
||||||
|
if store_info.get("blob_size"):
|
||||||
|
print(f" blob_size: {store_info['blob_size']:,}")
|
||||||
|
|
||||||
|
# Print Record Types
|
||||||
|
print("\n## Record Type Counts")
|
||||||
|
for kt, count in key_types.most_common():
|
||||||
|
print(f" {count:6d} {kt}")
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_redux_state():
|
||||||
|
"""Analyze Redux State Blob Structure"""
|
||||||
|
print("\n")
|
||||||
|
print("=" * 70)
|
||||||
|
print("REDUX STATE BLOB STRUCTURE")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Find Blob
|
||||||
|
blobs = sorted(BLOB_DIR.rglob("*"), key=lambda p: p.stat().st_mtime if p.is_file() else 0)
|
||||||
|
blob_files = [b for b in blobs if b.is_file()]
|
||||||
|
if not blob_files:
|
||||||
|
print("No blob files found!")
|
||||||
|
return
|
||||||
|
|
||||||
|
blob_path = blob_files[-1]
|
||||||
|
size = blob_path.stat().st_size
|
||||||
|
print(f"\nBlob: {blob_path.relative_to(IDB_BASE)} ({size:,} bytes)")
|
||||||
|
|
||||||
|
state = V8ScriptValueDecoder.FromBytes(blob_path.read_bytes())
|
||||||
|
|
||||||
|
# Top-Level Keys
|
||||||
|
print("\n## Top-Level Keys (sorted by size)")
|
||||||
|
entries = []
|
||||||
|
for k in sorted(state.keys()):
|
||||||
|
v = state[k]
|
||||||
|
size = len(str(v))
|
||||||
|
t = type(v).__name__
|
||||||
|
child_count = len(v) if isinstance(v, dict) else None
|
||||||
|
entries.append((size, k, t, child_count))
|
||||||
|
|
||||||
|
entries.sort(reverse=True)
|
||||||
|
for size, k, t, child_count in entries:
|
||||||
|
cc = f" ({child_count} entries)" if child_count is not None else ""
|
||||||
|
print(f" {size:>12,} chars {k} ({t}){cc}")
|
||||||
|
|
||||||
|
# Detailed Structure of Key Stores
|
||||||
|
detail_keys = [
|
||||||
|
"messages", "channels", "members", "reactions",
|
||||||
|
"files", "bots", "teams", "userGroups",
|
||||||
|
"channelHistory", "allThreads", "searchResults",
|
||||||
|
"prefs", "userPrefs", "membership",
|
||||||
|
]
|
||||||
|
|
||||||
|
print("\n## Key Store Schemas")
|
||||||
|
for store_key in detail_keys:
|
||||||
|
store = state.get(store_key)
|
||||||
|
if store is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n### {store_key}")
|
||||||
|
print(f" type: {type(store).__name__}")
|
||||||
|
|
||||||
|
if isinstance(store, dict):
|
||||||
|
print(f" entry_count: {len(store)}")
|
||||||
|
|
||||||
|
# Find a representative entry
|
||||||
|
for entry_key, entry_val in store.items():
|
||||||
|
if isinstance(entry_val, dict) and len(entry_val) > 3:
|
||||||
|
print(f" sample_key: \"{entry_key}\"")
|
||||||
|
print(f" fields:")
|
||||||
|
for fk, fv in entry_val.items():
|
||||||
|
ft = type(fv).__name__
|
||||||
|
fval = repr(fv)[:80]
|
||||||
|
print(f" {fk}: {ft} = {fval}")
|
||||||
|
break
|
||||||
|
elif not isinstance(entry_val, dict):
|
||||||
|
# Nested dict of dicts (e.g., messages -> channel -> ts -> msg)
|
||||||
|
if isinstance(entry_val, dict):
|
||||||
|
for inner_key, inner_val in entry_val.items():
|
||||||
|
if isinstance(inner_val, dict):
|
||||||
|
print(f" structure: {store_key}[channel_id][timestamp] -> message")
|
||||||
|
break
|
||||||
|
break
|
||||||
|
elif hasattr(store, "properties"):
|
||||||
|
print(f" JSArray with {len(store.properties)} properties")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
analyze_leveldb()
|
||||||
|
analyze_redux_state()
|
||||||
0
slack_cli/__init__.py
Normal file
0
slack_cli/__init__.py
Normal file
480
slack_cli/__main__.py
Normal file
480
slack_cli/__main__.py
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
"""Read Slack Messages From Local IndexedDB
|
||||||
|
|
||||||
|
Parses the Chromium IndexedDB backing Slack's desktop app (Mac App Store
|
||||||
|
version). Finds the Redux state blob, decodes it with dfindexeddb's Blink
|
||||||
|
V8 deserializer, and prints cached messages.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./slack-cli.py # Recent messages (all channels)
|
||||||
|
./slack-cli.py -c general # Filter by channel name (glob pattern)
|
||||||
|
./slack-cli.py -c 'team-*' -c general # Multiple channel filters
|
||||||
|
./slack-cli.py -x 'alerts-*' -x 'bot-*' # Exclude channels
|
||||||
|
./slack-cli.py -n 50 # Show last 50 messages
|
||||||
|
./slack-cli.py -u # Show unread messages only
|
||||||
|
./slack-cli.py -u -c general # Unread messages in a specific channel
|
||||||
|
./slack-cli.py -s 2h # Messages from the last 2 hours
|
||||||
|
./slack-cli.py -s 2026-04-15 # Messages since a specific date
|
||||||
|
./slack-cli.py --channels # List channels with message counts
|
||||||
|
./slack-cli.py --dump # Dump full Redux state to file
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fnmatch
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dfindexeddb.indexeddb.chromium.blink import V8ScriptValueDecoder
|
||||||
|
|
||||||
|
# ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SLACK_IDB_BASE = Path.home() / (
|
||||||
|
"Library/Containers/com.tinyspeck.slackmacgap"
|
||||||
|
"/Data/Library/Application Support/Slack/IndexedDB"
|
||||||
|
)
|
||||||
|
BLOB_DIR = SLACK_IDB_BASE / "https_app.slack.com_0.indexeddb.blob"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def find_latest_blob() -> Path | None:
|
||||||
|
"""Find the Latest Blob File in the IndexedDB Blob Directory
|
||||||
|
|
||||||
|
Slack stores a single large blob containing the entire Redux state.
|
||||||
|
The blob number increments on every persist, so the latest file is
|
||||||
|
what we want.
|
||||||
|
"""
|
||||||
|
blob_files = [b for b in BLOB_DIR.rglob("*") if b.is_file()]
|
||||||
|
if not blob_files:
|
||||||
|
return None
|
||||||
|
blob_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
return blob_files[0]
|
||||||
|
|
||||||
|
|
||||||
|
def decode_blob(blob_path: Path) -> dict:
|
||||||
|
"""Decode a Blink IDB Value Wrapper Blob"""
|
||||||
|
raw = blob_path.read_bytes()
|
||||||
|
return V8ScriptValueDecoder.FromBytes(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def ts_to_datetime(ts: str) -> datetime:
|
||||||
|
"""Convert Slack Timestamp to Datetime"""
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(ts))
|
||||||
|
except (ValueError, TypeError, OSError):
|
||||||
|
return datetime.min
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_user(state: dict, user_id: str) -> str:
|
||||||
|
"""Resolve a Slack User ID to Display Name"""
|
||||||
|
if not isinstance(user_id, str):
|
||||||
|
return str(user_id)
|
||||||
|
members = state.get("members", {})
|
||||||
|
if not isinstance(members, dict):
|
||||||
|
return user_id
|
||||||
|
member = members.get(user_id)
|
||||||
|
if not isinstance(member, dict):
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
# Slack Redux State Stores Name Fields at Top Level
|
||||||
|
name = member.get("display_name") or member.get("real_name") or member.get("name")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Also Check Nested Profile
|
||||||
|
profile = member.get("profile", {})
|
||||||
|
if isinstance(profile, dict):
|
||||||
|
name = profile.get("display_name") or profile.get("real_name")
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
|
def parse_since(value: str) -> datetime:
|
||||||
|
"""Parse a --since Value Into a Datetime
|
||||||
|
|
||||||
|
Supports relative durations (e.g. 30m, 2h, 3d) and absolute
|
||||||
|
dates/datetimes (e.g. 2026-04-15, '2026-04-15 10:00').
|
||||||
|
"""
|
||||||
|
# Relative Duration: <number><unit>
|
||||||
|
m = re.fullmatch(r"(\d+)([mhd])", value.strip())
|
||||||
|
if m:
|
||||||
|
amount = int(m.group(1))
|
||||||
|
unit = m.group(2)
|
||||||
|
delta = {"m": timedelta(minutes=amount), "h": timedelta(
|
||||||
|
hours=amount), "d": timedelta(days=amount)}[unit]
|
||||||
|
return datetime.now() - delta
|
||||||
|
|
||||||
|
# Absolute Datetime
|
||||||
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value.strip(), fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"invalid --since value: {value!r} "
|
||||||
|
f"(expected e.g. 30m, 2h, 3d, 2026-04-15, '2026-04-15 10:00')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_channel_names(state: dict) -> dict[str, str]:
|
||||||
|
"""Build Channel ID -> Name Lookup"""
|
||||||
|
channels_store = state.get("channels", {})
|
||||||
|
names = {}
|
||||||
|
if isinstance(channels_store, dict):
|
||||||
|
for cid, cdata in channels_store.items():
|
||||||
|
if isinstance(cdata, dict):
|
||||||
|
names[cid] = cdata.get("name", cdata.get("name_normalized", cid))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_domain(state: dict) -> str | None:
|
||||||
|
"""Get the Primary Workspace Domain From Teams Store"""
|
||||||
|
teams = state.get("teams", {})
|
||||||
|
if not isinstance(teams, dict):
|
||||||
|
return None
|
||||||
|
for _tid, team in teams.items():
|
||||||
|
if isinstance(team, dict) and team.get("url"):
|
||||||
|
return team.get("domain")
|
||||||
|
# Fallback to First Team With a Domain
|
||||||
|
for _tid, team in teams.items():
|
||||||
|
if isinstance(team, dict) and team.get("domain"):
|
||||||
|
return team.get("domain")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def slack_url(domain: str, channel_id: str, ts: str, thread_ts: str | None = None) -> str:
|
||||||
|
"""Build a Slack Deep Link URL
|
||||||
|
|
||||||
|
Message timestamps become URL path segments by removing the dot:
|
||||||
|
1776219948.820859 -> p1776219948820859
|
||||||
|
Thread replies additionally include ?thread_ts=...&cid=...
|
||||||
|
"""
|
||||||
|
ts_url = "p" + ts.replace(".", "")
|
||||||
|
url = f"https://{domain}.slack.com/archives/{channel_id}/{ts_url}"
|
||||||
|
if thread_ts and thread_ts != ts:
|
||||||
|
url += f"?thread_ts={thread_ts}&cid={channel_id}"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Commands ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_read_cursors(state: dict) -> dict[str, float]:
|
||||||
|
"""Build Channel ID -> Read Cursor Timestamp Lookup
|
||||||
|
|
||||||
|
The channelCursors store maps channel IDs to the timestamp of the
|
||||||
|
last-read message. Messages with ts > cursor are unread.
|
||||||
|
"""
|
||||||
|
cursors = state.get("channelCursors", {})
|
||||||
|
result = {}
|
||||||
|
if isinstance(cursors, dict):
|
||||||
|
for cid, ts in cursors.items():
|
||||||
|
try:
|
||||||
|
result[cid] = float(ts)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def channel_matches(name: str, patterns: list[str]) -> bool:
|
||||||
|
"""Check if a Channel Name Matches Any of the Glob Patterns"""
|
||||||
|
name_lower = name.lower()
|
||||||
|
return any(fnmatch.fnmatch(name_lower, p.lower()) for p in patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_messages(
|
||||||
|
state: dict,
|
||||||
|
include_channels: list[str] | None,
|
||||||
|
exclude_channels: list[str] | None,
|
||||||
|
count: int,
|
||||||
|
unread_only: bool = False,
|
||||||
|
since: datetime | None = None,
|
||||||
|
):
|
||||||
|
"""Print Messages From Slack State"""
|
||||||
|
messages_store = state.get("messages", {})
|
||||||
|
channel_names = build_channel_names(state)
|
||||||
|
read_cursors = build_read_cursors(state) if unread_only else {}
|
||||||
|
domain = get_workspace_domain(state)
|
||||||
|
|
||||||
|
# Collect All Messages
|
||||||
|
all_msgs = []
|
||||||
|
for cid, channel_msgs in messages_store.items():
|
||||||
|
if not isinstance(channel_msgs, dict):
|
||||||
|
continue
|
||||||
|
ch_name = channel_names.get(cid, cid)
|
||||||
|
|
||||||
|
# Apply Channel Include / Exclude Filters
|
||||||
|
if include_channels and not channel_matches(ch_name, include_channels):
|
||||||
|
continue
|
||||||
|
if exclude_channels and channel_matches(ch_name, exclude_channels):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine Read Cursor for Unread Filtering
|
||||||
|
cursor = read_cursors.get(cid, 0.0) if unread_only else 0.0
|
||||||
|
|
||||||
|
# Convert --since Datetime to Unix Timestamp for Comparison
|
||||||
|
since_ts = since.timestamp() if since else 0.0
|
||||||
|
|
||||||
|
for ts, msg in channel_msgs.items():
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip Messages Before Cutoff (Unread Cursor or --since)
|
||||||
|
try:
|
||||||
|
ts_f = float(ts)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if unread_only and ts_f <= cursor:
|
||||||
|
continue
|
||||||
|
if since and ts_f < since_ts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = msg.get("text", "")
|
||||||
|
if not text or not isinstance(text, str):
|
||||||
|
continue
|
||||||
|
|
||||||
|
user_id = msg.get("user", msg.get("bot_id", "?"))
|
||||||
|
user_name = resolve_user(state, user_id)
|
||||||
|
subtype = msg.get("subtype", "")
|
||||||
|
|
||||||
|
# dfindexeddb Represents JS undefined as an Undefined object
|
||||||
|
if not isinstance(subtype, str):
|
||||||
|
subtype = ""
|
||||||
|
|
||||||
|
# Resolve Thread Timestamp
|
||||||
|
thread_ts = msg.get("thread_ts", "")
|
||||||
|
if not isinstance(thread_ts, str):
|
||||||
|
thread_ts = ""
|
||||||
|
|
||||||
|
all_msgs.append({
|
||||||
|
"channel": ch_name,
|
||||||
|
"channel_id": cid,
|
||||||
|
"ts": ts,
|
||||||
|
"thread_ts": thread_ts or None,
|
||||||
|
"dt": ts_to_datetime(ts),
|
||||||
|
"user": user_name,
|
||||||
|
"user_id": user_id,
|
||||||
|
"text": text,
|
||||||
|
"subtype": subtype,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by Timestamp (Most Recent Last)
|
||||||
|
all_msgs.sort(key=lambda m: m["dt"])
|
||||||
|
|
||||||
|
# Group Thread Replies Under Their Parents
|
||||||
|
threads: dict[str, list[dict]] = defaultdict(list)
|
||||||
|
top_level: list[dict] = []
|
||||||
|
for msg in all_msgs:
|
||||||
|
thread_ts = msg["thread_ts"]
|
||||||
|
if thread_ts and thread_ts != msg["ts"]:
|
||||||
|
threads[thread_ts].append(msg)
|
||||||
|
else:
|
||||||
|
top_level.append(msg)
|
||||||
|
|
||||||
|
# Build Display List — Each Top-Level Entry Followed by Its Replies
|
||||||
|
# item = (msg | None, depth) — None msg means orphan thread header
|
||||||
|
# depth 0 = root (top-level message or orphan thread header)
|
||||||
|
# depth 1 = thread reply
|
||||||
|
display: list[tuple[dict | None, int]] = []
|
||||||
|
seen_parents: set[str] = {m["ts"] for m in top_level}
|
||||||
|
|
||||||
|
for msg in top_level:
|
||||||
|
display.append((msg, 0))
|
||||||
|
for reply in threads.get(msg["ts"], []):
|
||||||
|
display.append((reply, 1))
|
||||||
|
|
||||||
|
# Collect Orphan Thread Groups — Replies Whose Parent Isn't Shown
|
||||||
|
orphan_groups: list[tuple[str, list[dict]]] = []
|
||||||
|
for thread_ts, replies in threads.items():
|
||||||
|
if thread_ts not in seen_parents:
|
||||||
|
orphan_groups.append((thread_ts, replies))
|
||||||
|
|
||||||
|
# Sort Orphan Groups by Earliest Reply Timestamp
|
||||||
|
orphan_groups.sort(key=lambda g: g[1][0]["dt"])
|
||||||
|
|
||||||
|
# Append Each Orphan Group With a Header Placeholder
|
||||||
|
for thread_ts, replies in orphan_groups:
|
||||||
|
# Use First Reply's Channel and ID for the Header
|
||||||
|
header = {
|
||||||
|
"channel": replies[0]["channel"],
|
||||||
|
"channel_id": replies[0]["channel_id"],
|
||||||
|
"thread_ts": thread_ts,
|
||||||
|
}
|
||||||
|
display.append((None, 0)) # Placeholder for header
|
||||||
|
for reply in replies:
|
||||||
|
display.append((reply, 1))
|
||||||
|
# Patch the Placeholder With Header Info
|
||||||
|
display[-len(replies) - 1] = (header, 0)
|
||||||
|
|
||||||
|
# Print Last N Messages (Count depth=0 Entries Only)
|
||||||
|
if len(display) > 0:
|
||||||
|
# Walk Backwards to Find the Cutoff That Includes `count` Roots
|
||||||
|
roots_seen = 0
|
||||||
|
start_idx = len(display)
|
||||||
|
for i in range(len(display) - 1, -1, -1):
|
||||||
|
if display[i][1] == 0:
|
||||||
|
roots_seen += 1
|
||||||
|
if roots_seen > count:
|
||||||
|
break
|
||||||
|
start_idx = i
|
||||||
|
|
||||||
|
visible = display[start_idx:]
|
||||||
|
else:
|
||||||
|
visible = []
|
||||||
|
|
||||||
|
ts_color = "\033[90m"
|
||||||
|
ch_color = "\033[36m"
|
||||||
|
user_color = "\033[33m"
|
||||||
|
reset = "\033[0m"
|
||||||
|
|
||||||
|
bar_str = f"\033[90m│\033[0m"
|
||||||
|
|
||||||
|
for idx, (msg, depth) in enumerate(visible):
|
||||||
|
# Peek Ahead to See if Next Item Is Still a Thread Reply
|
||||||
|
next_is_reply = (idx + 1 < len(visible) and visible[idx + 1][1] > 0)
|
||||||
|
|
||||||
|
# Orphan Thread Header
|
||||||
|
if msg is not None and "dt" not in msg:
|
||||||
|
header_line = f"\033[90m↳ thread in {ch_color}#{msg['channel']}{reset}"
|
||||||
|
if domain and msg.get("channel_id") and msg.get("thread_ts"):
|
||||||
|
link = slack_url(domain, msg["channel_id"], msg["thread_ts"])
|
||||||
|
header_line += f" \033[90m{link}{reset}"
|
||||||
|
print(header_line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
dt_str = msg["dt"].strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
subtype_tag = f" [{msg['subtype']}]" if msg["subtype"] else ""
|
||||||
|
bar = f"{bar_str} " if depth > 0 else ""
|
||||||
|
|
||||||
|
# Build Slack Link for Top-Level Messages
|
||||||
|
link_suffix = ""
|
||||||
|
if domain and depth == 0:
|
||||||
|
link = slack_url(domain, msg["channel_id"], msg["ts"])
|
||||||
|
link_suffix = f" {ts_color}{link}{reset}"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{bar}{ts_color}{dt_str}{reset} "
|
||||||
|
f"{ch_color}#{msg['channel']}{reset} "
|
||||||
|
f"{user_color}{msg['user']}{reset}{subtype_tag}{link_suffix}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indent Message Text (Prefix Every Line for Multi-Line Messages)
|
||||||
|
text_prefix = f"{bar_str} " if depth > 0 else " "
|
||||||
|
for line in msg["text"][:500].split("\n"):
|
||||||
|
print(f"{text_prefix}{line}")
|
||||||
|
|
||||||
|
# Connecting Bar Between Thread Messages
|
||||||
|
if next_is_reply:
|
||||||
|
print(bar_str)
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
|
||||||
|
root_count = sum(1 for _, d in visible if d == 0)
|
||||||
|
total_roots = sum(1 for _, d in display if d == 0)
|
||||||
|
label = "unread messages" if unread_only else "messages"
|
||||||
|
if since:
|
||||||
|
label += f" since {since.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
print(f"--- Showing {root_count} of {total_roots} {label} ---")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_channels(state: dict):
|
||||||
|
"""List Channels With Message Counts"""
|
||||||
|
messages_store = state.get("messages", {})
|
||||||
|
channel_names = build_channel_names(state)
|
||||||
|
|
||||||
|
counts = {}
|
||||||
|
for cid, channel_msgs in messages_store.items():
|
||||||
|
if not isinstance(channel_msgs, dict):
|
||||||
|
continue
|
||||||
|
ch_name = channel_names.get(cid, cid)
|
||||||
|
msg_count = sum(
|
||||||
|
1 for v in channel_msgs.values()
|
||||||
|
if isinstance(v, dict) and v.get("text")
|
||||||
|
)
|
||||||
|
if msg_count > 0:
|
||||||
|
counts[ch_name] = msg_count
|
||||||
|
|
||||||
|
for name, c in sorted(counts.items(), key=lambda x: -x[1]):
|
||||||
|
print(f" {c:5d} #{name}")
|
||||||
|
|
||||||
|
print(f"\n--- {len(counts)} channels with cached messages ---")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_dump(state: dict, output: str):
|
||||||
|
"""Dump Full Redux State to File"""
|
||||||
|
with open(output, "w") as f:
|
||||||
|
json.dump(state, f, indent=2, default=str, ensure_ascii=False)
|
||||||
|
size_mb = os.path.getsize(output) / 1024 / 1024
|
||||||
|
print(f"Dumped {size_mb:.1f}MB to {output}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Read Slack messages from local IndexedDB"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-c", "--channel", action="append", dest="channels",
|
||||||
|
help="Include channels matching glob pattern (repeatable, e.g. -c 'team-*' -c general)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-x", "--exclude", action="append", dest="exclude_channels",
|
||||||
|
help="Exclude channels matching glob pattern (repeatable, e.g. -x 'alerts-*' -x 'bot-*')"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-n", "--count", type=int, default=30,
|
||||||
|
help="Number of messages to show (default: 30)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-u", "--unread", action="store_true",
|
||||||
|
help="Show only unread messages (based on read cursor position)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-s", "--since", type=parse_since,
|
||||||
|
help="Show messages since time (e.g. 30m, 2h, 3d, 2026-04-15, '2026-04-15 10:00')"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--channels", action="store_true", dest="list_channels",
|
||||||
|
help="List channels with message counts"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dump", nargs="?", const="slack_state.json",
|
||||||
|
help="Dump full state to JSON file"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Find and Decode the Blob
|
||||||
|
blob_path = find_latest_blob()
|
||||||
|
if not blob_path:
|
||||||
|
print(
|
||||||
|
"No blob files found. Is Slack installed and has it been opened?",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
size_mb = blob_path.stat().st_size / 1024 / 1024
|
||||||
|
print(f"Reading blob: {blob_path} ({size_mb:.1f}MB)", file=sys.stderr)
|
||||||
|
state = decode_blob(blob_path)
|
||||||
|
|
||||||
|
# Dispatch Command
|
||||||
|
if args.dump:
|
||||||
|
cmd_dump(state, args.dump)
|
||||||
|
elif args.list_channels:
|
||||||
|
cmd_channels(state)
|
||||||
|
else:
|
||||||
|
cmd_messages(state, args.channels, args.exclude_channels,
|
||||||
|
args.count, args.unread, args.since)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user