slkcli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Rohit Das
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # slk 💬 — Slack CLI for macOS, so your agents can read and send messages
2
+
3
+ `slk` is a Slack command-line tool for macOS that auto-extracts auth from the Slack desktop app. Read channels, send messages, search, manage drafts, track unreads, and view pins — no tokens, no OAuth, no config.
4
+
5
+ Built for AI agents and terminal workflows. Zero dependencies. Zero setup.
6
+
7
+ > **Not affiliated with Slack.** This is an independent Slack CLI built for personal productivity and agent automation. It uses session credentials from the Slack desktop app and works only on macOS. Use at your own discretion.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install -g slkcli
13
+ ```
14
+
15
+ One-shot (no install):
16
+
17
+ ```bash
18
+ npx slkcli auth
19
+ ```
20
+
21
+ **Requirements:** macOS, Slack desktop app (installed and logged in), Node.js 18+.
22
+
23
+ ## Quickstart
24
+
25
+ ```bash
26
+ # Verify your session works
27
+ slk auth
28
+
29
+ # List channels
30
+ slk channels
31
+
32
+ # Read the last 20 messages in a channel
33
+ slk read general
34
+ slk read C08A8AQ2AFP # by channel ID
35
+
36
+ # Send a message
37
+ slk send general "Hello from slk"
38
+
39
+ # Search across the workspace
40
+ slk search "deployment failed"
41
+
42
+ # Check what's unread
43
+ slk unread
44
+
45
+ # See starred items and VIP users
46
+ slk starred
47
+
48
+ # See pinned messages in a channel
49
+ slk pins general
50
+
51
+ # Read a thread
52
+ slk thread general 1234567890.123456
53
+
54
+ # React to a message
55
+ slk react general 1234567890.123456 thumbsup
56
+ ```
57
+
58
+ ## Commands
59
+
60
+ | Command | Alias | Description |
61
+ |---------|-------|-------------|
62
+ | `slk auth` | | Test authentication, show user/team info |
63
+ | `slk channels` | `ch` | List all channels with member counts |
64
+ | `slk users` | `u` | List workspace users with statuses |
65
+ | `slk read <channel> [count]` | `r` | Read recent messages (default: 20) |
66
+ | `slk send <channel> <message>` | `s` | Send a message to a channel |
67
+ | `slk search <query> [count]` | | Search messages across the workspace |
68
+ | `slk thread <channel> <ts> [count]` | `t` | Read thread replies (default: 50) |
69
+ | `slk react <channel> <ts> <emoji>` | | Add an emoji reaction to a message |
70
+ | `slk activity` | `a` | Show all channel activity with unread/mention counts |
71
+ | `slk unread` | `ur` | Show only channels with unreads (excludes muted) |
72
+ | `slk starred` | `star` | Show VIP users and starred items |
73
+ | `slk pins <channel>` | `pin` | Show pinned items in a channel |
74
+
75
+ ### Drafts
76
+
77
+ Drafts sync to Slack — they appear in the Slack editor UI.
78
+
79
+ | Command | Description |
80
+ |---------|-------------|
81
+ | `slk draft <channel> <message>` | Draft a channel message |
82
+ | `slk draft thread <channel> <ts> <message>` | Draft a thread reply |
83
+ | `slk draft user <user_id> <message>` | Draft a DM |
84
+ | `slk drafts` | List all active drafts |
85
+ | `slk draft drop <draft_id>` | Delete a draft |
86
+
87
+ ### Channel resolution
88
+
89
+ Channels can be specified by **name** or **ID** in any command:
90
+
91
+ ```bash
92
+ slk read general # by name
93
+ slk read ai-coding # by name
94
+ slk read C08A8AQ2AFP # by ID
95
+ ```
96
+
97
+ ## Authentication
98
+
99
+ `slk` uses the credentials already stored by the Slack desktop app. No OAuth flows, no manual token management.
100
+
101
+ ### Keychain access prompt
102
+
103
+ On first run, macOS will show a Keychain dialog asking whether to allow access to "Slack Safe Storage":
104
+
105
+ - **Allow** — grants one-time access. You'll be prompted again next time slk needs to decrypt the cookie.
106
+ - **Always Allow** — grants permanent access for this binary. No future prompts.
107
+ - **Deny** — blocks access. slk cannot authenticate.
108
+
109
+ > **Caution:** Choosing "Always Allow" means any process running as your user that invokes the `slk` binary (or the `security` command targeting "Slack Safe Storage") can read the encryption key without a prompt. This is convenient but reduces the security boundary — any code running in your terminal (scripts, agents, other CLI tools) could trigger credential extraction silently. On a personal machine this is a reasonable trade-off. On a shared or managed machine, prefer "Allow" so you get prompted each time and maintain visibility into access.
110
+
111
+ ### How it works
112
+
113
+ 1. **Cookie decryption** — Reads the encrypted `d` cookie from Slack's SQLite cookie store (`~/Library/Application Support/Slack/Cookies`). Decrypts it using the "Slack Safe Storage" key from the macOS Keychain via PBKDF2 + AES-128-CBC.
114
+
115
+ 2. **Token extraction** — Scans Slack's LevelDB storage (`~/Library/Application Support/Slack/Local Storage/leveldb/`) for `xoxc-` session tokens. Uses both direct regex scanning and a Python fallback for Snappy-compressed entries.
116
+
117
+ 3. **Validation** — Tests each candidate token against `auth.test` with the decrypted cookie. The first valid pair is used.
118
+
119
+ 4. **Auto-refresh** — On `invalid_auth`, credentials are re-extracted and the request is retried once automatically.
120
+
121
+ ### Token caching
122
+
123
+ Validated tokens are cached to avoid re-extracting on every invocation:
124
+
125
+ | | |
126
+ |---|---|
127
+ | **Cache file** | `~/.local/slk/token-cache.json` |
128
+ | **Format** | `{ "token": "xoxc-...", "ts": 1706000000000 }` |
129
+ | **Behavior** | Load cache → validate with Slack API → use if valid, otherwise re-extract from LevelDB |
130
+ | **In-memory** | Within a single process, credentials are cached in memory after first load |
131
+
132
+ ### Credential resolution order
133
+
134
+ ```
135
+ 1. In-memory cache (same process)
136
+ 2. Disk cache (~/.local/slk/token-cache.json) → validate → use if ok
137
+ 3. Fresh extraction from Slack desktop app → validate → cache → use
138
+ ```
139
+
140
+ ### What it reads from your system
141
+
142
+ | Data | Source | Purpose |
143
+ |------|--------|---------|
144
+ | Keychain password | `security find-generic-password -s "Slack Safe Storage"` | Derive AES key for cookie decryption |
145
+ | Encrypted cookie | `~/Library/Application Support/Slack/Cookies` (SQLite) | Decrypt the `d` session cookie (`xoxd-`) |
146
+ | Session token | `~/Library/Application Support/Slack/Local Storage/leveldb/` | Extract `xoxc-` token |
147
+
148
+ ## Agent usage patterns
149
+
150
+ `slk` is designed to be used by AI agents. Common patterns:
151
+
152
+ ```bash
153
+ # Check auth before doing anything
154
+ slk auth
155
+
156
+ # Get channel list, find the right one
157
+ slk channels
158
+
159
+ # Read recent context from a channel
160
+ slk read engineering 50
161
+
162
+ # Search for something specific
163
+ slk search "PR review needed"
164
+
165
+ # Check what needs attention
166
+ slk unread
167
+
168
+ # See pinned context in a channel
169
+ slk pins engineering
170
+
171
+ # Send a message
172
+ slk send engineering "Build passed on main"
173
+
174
+ # Read a thread for full context
175
+ slk thread engineering 1706000000.000000
176
+
177
+ # Draft a message for human review (appears in Slack UI)
178
+ slk draft engineering "Here's the summary of today's standup..."
179
+ ```
180
+
181
+ **Exit codes:** `0` on success, `1` on error. Errors are printed to stderr.
182
+
183
+ ## How it was installed
184
+
185
+ The `bin` field in `package.json` maps `slk` to `./bin/slk.js`:
186
+
187
+ ```json
188
+ { "bin": { "slk": "./bin/slk.js" } }
189
+ ```
190
+
191
+ Running `npm install -g` creates a symlink in your PATH:
192
+
193
+ ```
194
+ /opt/homebrew/bin/slk -> ../lib/node_modules/slkcli/bin/slk.js
195
+ ```
196
+
197
+ ## Development
198
+
199
+ ```bash
200
+ git clone https://github.com/therohitdas/slk.git
201
+ cd slk
202
+ node bin/slk.js auth # run directly
203
+ npm link # symlink globally for development
204
+ ```
205
+
206
+ ## Notes
207
+
208
+ - **macOS only** — uses Keychain and Electron storage paths specific to macOS.
209
+ - **Slack desktop app required** — must be installed and logged in. The app does not need to be running for cached tokens.
210
+ - **Zero dependencies** — uses only Node.js built-in modules (`crypto`, `fs`, `child_process`, `fetch`).
211
+ - **Session-based** — uses `xoxc-` tokens (user session), not bot tokens. This means you act as yourself.
212
+ - **Mute-aware** — `activity` and `unread` commands respect your mute settings.
package/bin/slk.js ADDED
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * slk — Slack CLI with auto-auth from macOS Slack desktop app.
5
+ */
6
+
7
+ import * as cmd from "../src/commands.js";
8
+ import * as drafts from "../src/drafts.js";
9
+
10
+ const args = process.argv.slice(2);
11
+ const command = args[0];
12
+
13
+ const supportsEmoji = !process.env.NO_EMOJI && !process.argv.includes("--no-emoji");
14
+ const e = (emoji, fallback = "") => supportsEmoji ? emoji + " " : fallback;
15
+
16
+ const HELP = `${e("💬")}slk — Slack CLI for macOS (auto-auth from Slack desktop app)
17
+
18
+ Commands:
19
+ slk auth Test auth, show user/team info
20
+ slk channels (ch) List channels with member counts
21
+ slk users (u) List workspace users with statuses
22
+ slk read <ch> [n] (r) Read last n messages (default: 20)
23
+ slk send <ch> <msg> (s) Send a message
24
+ slk search <query> [n] Search messages across workspace
25
+ slk thread <ch> <ts> [n] (t) Read thread replies (default: 50)
26
+ slk react <ch> <ts> <emoji> Add emoji reaction
27
+ slk activity (a) Channel activity with unread/mention counts
28
+ slk unread (ur) Channels with unreads (excludes muted)
29
+ slk starred (star) VIP users + starred items
30
+ slk pins <ch> (pin) Pinned items in a channel
31
+
32
+ Drafts (synced to Slack UI):
33
+ slk draft <ch> <msg> Draft a channel message
34
+ slk draft thread <ch> <ts> <msg> Draft a thread reply
35
+ slk draft user <user_id> <msg> Draft a DM
36
+ slk drafts List active drafts
37
+ slk draft drop <id> Delete a draft
38
+
39
+ Settings:
40
+ --no-emoji Disable emoji output (or set NO_EMOJI=1)
41
+
42
+ Channels: name ("general") or ID ("C08A8AQ2AFP"). Aliases shown in parens.
43
+
44
+ Examples:
45
+ slk read general 50 Last 50 messages from #general
46
+ slk send engineering "build passed" Send to #engineering
47
+ slk search "deploy failed" 10 Search with limit
48
+ slk thread general 1706000000.000000 Read a thread
49
+ slk react general 1706000000.000000 eyes React with :eyes:
50
+ slk draft general "PR summary..." Save draft in Slack UI
51
+ slk unread What needs attention?
52
+
53
+ Auth: reads credentials from the Slack desktop app automatically.
54
+ Cache: ~/.local/slk/token-cache.json (auto-validated, auto-refreshed).
55
+ Docs: https://github.com/therohitdas/slkcli`;
56
+
57
+ async function main() {
58
+ try {
59
+ switch (command) {
60
+ case "auth":
61
+ await cmd.auth();
62
+ break;
63
+
64
+ case "channels":
65
+ case "ch":
66
+ await cmd.channels();
67
+ break;
68
+
69
+ case "read":
70
+ case "r":
71
+ if (!args[1]) { console.error("Usage: slk read <channel> [count]"); process.exit(1); }
72
+ await cmd.read(args[1], parseInt(args[2]) || 20);
73
+ break;
74
+
75
+ case "send":
76
+ case "s":
77
+ if (!args[1] || !args[2]) { console.error("Usage: slk send <channel> <message>"); process.exit(1); }
78
+ await cmd.send(args[1], args.slice(2).join(" "));
79
+ break;
80
+
81
+ case "search":
82
+ if (!args[1]) { console.error("Usage: slk search <query> [count]"); process.exit(1); }
83
+ await cmd.search(args.slice(1).join(" "), parseInt(args[args.length - 1]) || 20);
84
+ break;
85
+
86
+ case "thread":
87
+ case "t":
88
+ if (!args[1] || !args[2]) { console.error("Usage: slk thread <channel> <ts>"); process.exit(1); }
89
+ await cmd.thread(args[1], args[2], parseInt(args[3]) || 50);
90
+ break;
91
+
92
+ case "users":
93
+ case "u":
94
+ await cmd.users();
95
+ break;
96
+
97
+ case "react":
98
+ if (!args[1] || !args[2] || !args[3]) {
99
+ console.error("Usage: slk react <channel> <ts> <emoji>");
100
+ process.exit(1);
101
+ }
102
+ await cmd.react(args[1], args[2], args[3]);
103
+ break;
104
+
105
+ case "activity":
106
+ case "a":
107
+ await cmd.activity(false);
108
+ break;
109
+
110
+ case "unread":
111
+ case "ur":
112
+ await cmd.activity(true);
113
+ break;
114
+
115
+ case "starred":
116
+ case "star":
117
+ await cmd.starred();
118
+ break;
119
+
120
+ case "pins":
121
+ case "pin":
122
+ if (!args[1]) { console.error("Usage: slk pins <channel>"); process.exit(1); }
123
+ await cmd.pins(args[1]);
124
+ break;
125
+
126
+ case "drafts":
127
+ await drafts.listDrafts();
128
+ break;
129
+
130
+ case "draft": {
131
+ const sub = args[1];
132
+ if (sub === "thread") {
133
+ if (!args[2] || !args[3] || !args[4]) {
134
+ console.error("Usage: slk draft thread <channel> <ts> <message>");
135
+ process.exit(1);
136
+ }
137
+ await drafts.draftThread(args[2], args[3], args.slice(4).join(" "));
138
+ } else if (sub === "user") {
139
+ if (!args[2] || !args[3]) {
140
+ console.error("Usage: slk draft user <user_id> <message>");
141
+ process.exit(1);
142
+ }
143
+ await drafts.draftUser(args[2], args.slice(3).join(" "));
144
+ } else if (sub === "drop") {
145
+ if (!args[2]) { console.error("Usage: slk draft drop <draft_id>"); process.exit(1); }
146
+ await drafts.dropDraft(args[2]);
147
+ } else {
148
+ // slk draft <channel> <message>
149
+ if (!sub || !args[2]) {
150
+ console.error("Usage: slk draft <channel> <message>");
151
+ process.exit(1);
152
+ }
153
+ await drafts.draftChannel(sub, args.slice(2).join(" "));
154
+ }
155
+ break;
156
+ }
157
+
158
+ case "help":
159
+ case "-h":
160
+ case "--help":
161
+ case undefined:
162
+ console.log(HELP);
163
+ break;
164
+
165
+ default:
166
+ console.error(`Unknown command: ${command}`);
167
+ console.log(HELP);
168
+ process.exit(1);
169
+ }
170
+ } catch (err) {
171
+ console.error(`Error: ${err.message}`);
172
+ process.exit(1);
173
+ }
174
+ }
175
+
176
+ main();
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "slkcli",
3
+ "version": "0.1.0",
4
+ "description": "Slack CLI for macOS, so your agents can read and send messages",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Rohit Das",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/therohitdas/slkcli.git"
11
+ },
12
+ "homepage": "https://github.com/therohitdas/slkcli",
13
+ "bugs": {
14
+ "url": "https://github.com/therohitdas/slkcli/issues"
15
+ },
16
+ "keywords": [
17
+ "slack",
18
+ "slack-cli",
19
+ "cli",
20
+ "macos",
21
+ "slack-api",
22
+ "slack-bot",
23
+ "agent",
24
+ "terminal",
25
+ "messaging"
26
+ ],
27
+ "bin": {
28
+ "slk": "bin/slk.js"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "src/",
33
+ "LICENSE",
34
+ "README.md"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "scripts": {
40
+ "dev": "node bin/slk.js"
41
+ },
42
+ "dependencies": {},
43
+ "devDependencies": {}
44
+ }
package/src/api.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Slack API wrapper — handles auth, retries on invalid_auth, and pagination.
3
+ */
4
+
5
+ import { getCredentials, refresh } from "./auth.js";
6
+
7
+ const BASE = "https://slack.com/api";
8
+
9
+ /**
10
+ * Make an authenticated Slack API call.
11
+ * Auto-refreshes credentials on invalid_auth (once).
12
+ */
13
+ export async function slackApi(method, params = {}, retried = false) {
14
+ const { token, cookie } = getCredentials();
15
+
16
+ const url = new URL(`${BASE}/${method}`);
17
+
18
+ // GET for read methods, POST for write methods
19
+ const writeMethods = [
20
+ "chat.postMessage",
21
+ "chat.update",
22
+ "chat.delete",
23
+ "reactions.add",
24
+ "reactions.remove",
25
+ "files.upload",
26
+ "drafts.create",
27
+ "drafts.delete",
28
+ "drafts.update",
29
+ "conversations.open",
30
+ "client.counts",
31
+ "users.prefs.get",
32
+ ];
33
+
34
+ const isWrite = writeMethods.some((m) => method.startsWith(m));
35
+ let res;
36
+
37
+ if (isWrite) {
38
+ res = await fetch(url, {
39
+ method: "POST",
40
+ headers: {
41
+ Authorization: `Bearer ${token}`,
42
+ Cookie: `d=${cookie}`,
43
+ "Content-Type": "application/json; charset=utf-8",
44
+ },
45
+ body: JSON.stringify(params),
46
+ });
47
+ } else {
48
+ for (const [k, v] of Object.entries(params)) {
49
+ if (v !== undefined && v !== null) url.searchParams.set(k, v);
50
+ }
51
+ res = await fetch(url, {
52
+ headers: {
53
+ Authorization: `Bearer ${token}`,
54
+ Cookie: `d=${cookie}`,
55
+ },
56
+ });
57
+ }
58
+
59
+ const data = await res.json();
60
+
61
+ if (!data.ok && data.error === "invalid_auth" && !retried) {
62
+ refresh();
63
+ return slackApi(method, params, true);
64
+ }
65
+
66
+ return data;
67
+ }
68
+
69
+ /**
70
+ * Paginate through a Slack API method using cursor-based pagination.
71
+ */
72
+ export async function slackPaginate(method, params = {}, key = "channels") {
73
+ const results = [];
74
+ let cursor;
75
+
76
+ do {
77
+ const data = await slackApi(method, { ...params, cursor, limit: params.limit || 200 });
78
+ if (!data.ok) return data; // return error as-is
79
+
80
+ if (data[key]) results.push(...data[key]);
81
+ cursor = data.response_metadata?.next_cursor;
82
+ } while (cursor);
83
+
84
+ return { ok: true, [key]: results };
85
+ }
package/src/auth.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Slack auth — extracts session credentials from the Slack desktop app on macOS.
3
+ *
4
+ * 1. Keychain → "Slack Safe Storage" password
5
+ * 2. Cookies SQLite → encrypted `d` cookie → AES-128-CBC decrypt
6
+ * 3. LevelDB files → `xoxc-` token (string scan)
7
+ */
8
+
9
+ import { execSync, spawnSync } from "child_process";
10
+ import { readFileSync, readdirSync, copyFileSync, unlinkSync, writeFileSync } from "fs";
11
+ import { join } from "path";
12
+ import { homedir, tmpdir } from "os";
13
+ import { pbkdf2Sync } from "crypto";
14
+
15
+ import { existsSync, mkdirSync } from "fs";
16
+
17
+ const SLACK_DIR = join(homedir(), "Library", "Application Support", "Slack");
18
+ const LEVELDB_DIR = join(SLACK_DIR, "Local Storage", "leveldb");
19
+ const COOKIES_DB = join(SLACK_DIR, "Cookies");
20
+ const CACHE_DIR = join(homedir(), ".local", "slk");
21
+ const TOKEN_CACHE = join(CACHE_DIR, "token-cache.json");
22
+
23
+ let cachedCreds = null;
24
+
25
+ function getKeychainKey() {
26
+ return Buffer.from(
27
+ execSync('security find-generic-password -s "Slack Safe Storage" -w', {
28
+ encoding: "utf-8",
29
+ }).trim()
30
+ );
31
+ }
32
+
33
+ function decryptCookie() {
34
+ const tmpDb = join(tmpdir(), `slk_cookies_${Date.now()}.db`);
35
+ copyFileSync(COOKIES_DB, tmpDb);
36
+
37
+ try {
38
+ const hex = execSync(
39
+ `sqlite3 "${tmpDb}" "SELECT hex(encrypted_value) FROM cookies WHERE name='d' AND host_key='.slack.com' LIMIT 1;"`,
40
+ { encoding: "utf-8" }
41
+ ).trim();
42
+
43
+ if (!hex) throw new Error("No 'd' cookie found in Slack cookie store");
44
+
45
+ const encrypted = Buffer.from(hex, "hex");
46
+
47
+ if (encrypted.subarray(0, 3).toString() !== "v10") {
48
+ throw new Error("Unknown cookie encryption format");
49
+ }
50
+
51
+ const data = encrypted.subarray(3);
52
+ const aesKey = pbkdf2Sync(getKeychainKey(), "saltysalt", 1003, 16, "sha1");
53
+ const iv = Buffer.alloc(16, " ");
54
+
55
+ // Decrypt via openssl using spawnSync for clean binary output
56
+ const tmpEnc = join(tmpdir(), `slk_enc_${Date.now()}.bin`);
57
+ writeFileSync(tmpEnc, data);
58
+
59
+ const result = spawnSync("openssl", [
60
+ "enc", "-aes-128-cbc", "-d", "-nopad",
61
+ "-K", aesKey.toString("hex"),
62
+ "-iv", iv.toString("hex"),
63
+ "-in", tmpEnc,
64
+ ]);
65
+ const decrypted = result.stdout;
66
+
67
+ unlinkSync(tmpEnc);
68
+
69
+ if (!decrypted || decrypted.length === 0) {
70
+ throw new Error("Cookie decryption failed");
71
+ }
72
+
73
+ // Remove PKCS7 padding
74
+ const padLen = decrypted[decrypted.length - 1];
75
+ const unpadded = padLen <= 16 ? decrypted.subarray(0, -padLen) : decrypted;
76
+ const text = unpadded.toString("utf-8");
77
+
78
+ const idx = text.indexOf("xoxd-");
79
+ if (idx < 0) throw new Error("No xoxd- found in decrypted cookie");
80
+ return text.substring(idx);
81
+ } finally {
82
+ try { unlinkSync(tmpDb); } catch {}
83
+ }
84
+ }
85
+
86
+ function extractToken() {
87
+ const files = readdirSync(LEVELDB_DIR).filter(
88
+ (f) => f.endsWith(".ldb") || f.endsWith(".log")
89
+ );
90
+
91
+ const tokens = new Set();
92
+
93
+ for (const file of files) {
94
+ try {
95
+ const raw = readFileSync(join(LEVELDB_DIR, file));
96
+ const content = raw.toString("latin1");
97
+
98
+ // Method 1: direct regex (works for uncompressed entries)
99
+ for (const m of content.matchAll(/xoxc-[a-zA-Z0-9_-]{20,}/g)) {
100
+ tokens.add(m[0]);
101
+ }
102
+
103
+ // Method 2: Snappy-compressed LevelDB blocks mangle tokens.
104
+ // Use Python to properly decompress and extract from the JSON structure.
105
+ // Skip here — handled in extractTokenPython() below.
106
+ } catch {}
107
+ }
108
+
109
+ // Method 2: Use Python to extract tokens from Snappy-compressed LevelDB
110
+ // Python's regex on binary-stripped data handles compression artifacts better
111
+ try {
112
+ const pyResult = spawnSync("python3", ["-c", `
113
+ import os, re
114
+ path = os.path.expanduser("~/Library/Application Support/Slack/Local Storage/leveldb")
115
+ for f in os.listdir(path):
116
+ if not (f.endswith(".ldb") or f.endswith(".log")): continue
117
+ data = open(os.path.join(path, f), "rb").read()
118
+ # Find all xoxc- positions and extract by reading the hex tail
119
+ pos = 0
120
+ while True:
121
+ idx = data.find(b"xoxc-", pos)
122
+ if idx < 0: break
123
+ pos = idx + 5
124
+ chunk = data[idx:idx+200]
125
+ # Find the 64-char hex tail
126
+ text = chunk.decode("latin1")
127
+ hm = re.search(r'[a-f0-9]{64}', text)
128
+ if not hm: continue
129
+ # Get all bytes from xoxc- to end of hex tail
130
+ end = text.index(hm.group()) + 64
131
+ raw = chunk[:end]
132
+ # Keep only printable token chars
133
+ clean = bytes(b for b in raw if chr(b) in '0123456789abcdef-xoc').decode()
134
+ # Validate structure
135
+ if re.match(r'^xoxc-\\d+-\\d+-\\d+-[a-f0-9]{64}$', clean):
136
+ print(clean)
137
+ `], { encoding: "utf-8", timeout: 5000 });
138
+ if (pyResult.stdout) {
139
+ for (const line of pyResult.stdout.trim().split("\n")) {
140
+ if (line.startsWith("xoxc-")) tokens.add(line);
141
+ }
142
+ }
143
+ } catch {}
144
+
145
+ if (tokens.size === 0) {
146
+ throw new Error("No xoxc- token found. Is Slack running?");
147
+ }
148
+
149
+ // Return all candidates sorted by length desc; caller will validate
150
+ return [...tokens]
151
+ .filter((t) => t.length > 50) // filter truncated tokens
152
+ .sort((a, b) => b.length - a.length);
153
+ }
154
+
155
+ function loadTokenCache() {
156
+ try {
157
+ if (existsSync(TOKEN_CACHE)) {
158
+ return JSON.parse(readFileSync(TOKEN_CACHE, "utf-8"));
159
+ }
160
+ } catch {}
161
+ return null;
162
+ }
163
+
164
+ function saveTokenCache(token) {
165
+ try {
166
+ mkdirSync(CACHE_DIR, { recursive: true });
167
+ writeFileSync(TOKEN_CACHE, JSON.stringify({ token, ts: Date.now() }));
168
+ } catch {}
169
+ }
170
+
171
+ function validateToken(token, cookie) {
172
+ try {
173
+ const result = spawnSync("curl", [
174
+ "-s", "https://slack.com/api/auth.test",
175
+ "-H", `Authorization: Bearer ${token}`,
176
+ "-b", `d=${cookie}`,
177
+ ], { encoding: "utf-8", timeout: 10000 });
178
+ const data = JSON.parse(result.stdout);
179
+ return data.ok;
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ export function getCredentials(forceRefresh = false) {
186
+ if (cachedCreds && !forceRefresh) return cachedCreds;
187
+
188
+ const cookie = decryptCookie();
189
+
190
+ // Try cached token first (fastest path)
191
+ if (!forceRefresh) {
192
+ const cache = loadTokenCache();
193
+ if (cache?.token && validateToken(cache.token, cookie)) {
194
+ cachedCreds = { token: cache.token, cookie };
195
+ return cachedCreds;
196
+ }
197
+ }
198
+
199
+ // Extract fresh tokens from LevelDB
200
+ const candidates = extractToken();
201
+
202
+ // Validate each candidate
203
+ for (const token of candidates) {
204
+ if (validateToken(token, cookie)) {
205
+ saveTokenCache(token);
206
+ cachedCreds = { token, cookie };
207
+ return cachedCreds;
208
+ }
209
+ }
210
+
211
+ // Fallback: return first candidate
212
+ cachedCreds = { token: candidates[0], cookie };
213
+ return cachedCreds;
214
+ }
215
+
216
+ export function refresh() {
217
+ cachedCreds = null;
218
+ return getCredentials(true);
219
+ }
@@ -0,0 +1,348 @@
1
+ /**
2
+ * CLI command implementations.
3
+ */
4
+
5
+ import { slackApi, slackPaginate } from "./api.js";
6
+ import { getCredentials } from "./auth.js";
7
+
8
+ // ── Helpers ──────────────────────────────────────────────
9
+
10
+ let userCache = null;
11
+
12
+ async function getUsers() {
13
+ if (userCache) return userCache;
14
+ const data = await slackPaginate("users.list", {}, "members");
15
+ if (!data.ok) return {};
16
+ userCache = {};
17
+ for (const u of data.members) {
18
+ userCache[u.id] = u.real_name || u.profile?.display_name || u.name;
19
+ }
20
+ return userCache;
21
+ }
22
+
23
+ function userName(users, id) {
24
+ return users[id] || id;
25
+ }
26
+
27
+ async function resolveChannel(nameOrId) {
28
+ if (nameOrId.startsWith("C") || nameOrId.startsWith("D") || nameOrId.startsWith("G")) {
29
+ return nameOrId; // Already an ID
30
+ }
31
+ const name = nameOrId.replace(/^#/, "");
32
+ const data = await slackPaginate("conversations.list", {
33
+ types: "public_channel,private_channel,mpim,im",
34
+ });
35
+ if (!data.ok) throw new Error(`Failed to list channels: ${data.error}`);
36
+ const ch = data.channels.find(
37
+ (c) => c.name === name || c.name_normalized === name
38
+ );
39
+ if (!ch) throw new Error(`Channel not found: ${nameOrId}`);
40
+ return ch.id;
41
+ }
42
+
43
+ function formatTs(ts) {
44
+ return new Date(parseFloat(ts) * 1000).toLocaleString();
45
+ }
46
+
47
+ // ── Commands ─────────────────────────────────────────────
48
+
49
+ export async function auth() {
50
+ const data = await slackApi("auth.test");
51
+ if (data.ok) {
52
+ console.log(`✅ Authenticated as ${data.user} @ ${data.team}`);
53
+ console.log(` Team ID: ${data.team_id}`);
54
+ console.log(` User ID: ${data.user_id}`);
55
+ console.log(` URL: ${data.url}`);
56
+ } else {
57
+ console.error(`❌ Auth failed: ${data.error}`);
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ export async function channels() {
63
+ const data = await slackPaginate("conversations.list", {
64
+ types: "public_channel,private_channel",
65
+ exclude_archived: true,
66
+ });
67
+ if (!data.ok) {
68
+ console.error(`Error: ${data.error}`);
69
+ process.exit(1);
70
+ }
71
+ for (const ch of data.channels) {
72
+ const prefix = ch.is_private ? "🔒" : "#";
73
+ const members = ch.num_members || 0;
74
+ console.log(`${prefix} ${ch.name} (${members} members, id: ${ch.id})`);
75
+ }
76
+ }
77
+
78
+ export async function read(channelRef, count = 20) {
79
+ const channel = await resolveChannel(channelRef);
80
+ const users = await getUsers();
81
+ const data = await slackApi("conversations.history", {
82
+ channel,
83
+ limit: count,
84
+ });
85
+ if (!data.ok) {
86
+ console.error(`Error: ${data.error}`);
87
+ process.exit(1);
88
+ }
89
+
90
+ const messages = data.messages.reverse();
91
+ for (const msg of messages) {
92
+ const who = userName(users, msg.user);
93
+ const time = formatTs(msg.ts);
94
+ const thread = msg.reply_count ? ` [${msg.reply_count} replies]` : "";
95
+ console.log(`[${time}] ${who}${thread}:`);
96
+ console.log(` ${msg.text}`);
97
+ if (msg.files?.length) {
98
+ for (const f of msg.files) {
99
+ console.log(` 📎 ${f.name} (${f.mimetype})`);
100
+ }
101
+ }
102
+ console.log();
103
+ }
104
+ }
105
+
106
+ export async function send(channelRef, text) {
107
+ const channel = await resolveChannel(channelRef);
108
+ const data = await slackApi("chat.postMessage", { channel, text });
109
+ if (data.ok) {
110
+ console.log(`✅ Sent to ${channelRef} (ts: ${data.ts})`);
111
+ } else {
112
+ console.error(`❌ Failed: ${data.error}`);
113
+ process.exit(1);
114
+ }
115
+ }
116
+
117
+ export async function search(query, count = 20) {
118
+ const data = await slackApi("search.messages", { query, count });
119
+ if (!data.ok) {
120
+ console.error(`Error: ${data.error}`);
121
+ process.exit(1);
122
+ }
123
+
124
+ const matches = data.messages?.matches || [];
125
+ console.log(`Found ${data.messages?.total || 0} results\n`);
126
+
127
+ const users = await getUsers();
128
+ for (const msg of matches) {
129
+ const who = userName(users, msg.user);
130
+ const time = formatTs(msg.ts);
131
+ const ch = msg.channel?.name || msg.channel?.id || "?";
132
+ console.log(`[${time}] #${ch} — ${who}:`);
133
+ console.log(` ${msg.text}`);
134
+ console.log();
135
+ }
136
+ }
137
+
138
+ export async function thread(channelRef, ts, count = 50) {
139
+ const channel = await resolveChannel(channelRef);
140
+ const users = await getUsers();
141
+ const data = await slackApi("conversations.replies", {
142
+ channel,
143
+ ts,
144
+ limit: count,
145
+ });
146
+ if (!data.ok) {
147
+ console.error(`Error: ${data.error}`);
148
+ process.exit(1);
149
+ }
150
+
151
+ for (const msg of data.messages) {
152
+ const who = userName(users, msg.user);
153
+ const time = formatTs(msg.ts);
154
+ console.log(`[${time}] ${who}:`);
155
+ console.log(` ${msg.text}`);
156
+ console.log();
157
+ }
158
+ }
159
+
160
+ export async function users() {
161
+ const data = await slackPaginate("users.list", {}, "members");
162
+ if (!data.ok) {
163
+ console.error(`Error: ${data.error}`);
164
+ process.exit(1);
165
+ }
166
+
167
+ for (const u of data.members) {
168
+ if (u.deleted || u.is_bot) continue;
169
+ const name = u.real_name || u.name;
170
+ const display = u.profile?.display_name || "";
171
+ const status = u.profile?.status_text ? ` — ${u.profile.status_text}` : "";
172
+ console.log(`${name}${display ? ` (@${display})` : ""} (${u.id})${status}`);
173
+ }
174
+ }
175
+
176
+ async function getMutedChannels() {
177
+ const prefs = await slackApi("users.prefs.get", {});
178
+ if (!prefs.ok) return new Set();
179
+
180
+ const allNotifs = prefs.prefs?.all_notifications_prefs;
181
+ if (!allNotifs) return new Set();
182
+
183
+ const parsed = typeof allNotifs === "string" ? JSON.parse(allNotifs) : allNotifs;
184
+ const muted = new Set();
185
+ for (const [chId, chPrefs] of Object.entries(parsed.channels || {})) {
186
+ if (chPrefs.muted) muted.add(chId);
187
+ }
188
+ return muted;
189
+ }
190
+
191
+ export async function activity(unreadOnly = false) {
192
+ const users = await getUsers();
193
+
194
+ // Get unread counts + muted channels in parallel
195
+ const [counts, mutedSet] = await Promise.all([
196
+ slackApi("client.counts", {}),
197
+ getMutedChannels(),
198
+ ]);
199
+
200
+ if (!counts.ok) {
201
+ console.error(`Error: ${counts.error}`);
202
+ process.exit(1);
203
+ }
204
+
205
+ // Build channel name map
206
+ const chData = await slackPaginate("conversations.list", {
207
+ types: "public_channel,private_channel,mpim,im",
208
+ exclude_archived: true,
209
+ });
210
+ const chMap = {};
211
+ if (chData.ok) {
212
+ for (const ch of chData.channels) {
213
+ chMap[ch.id] = ch.name || (ch.user ? `DM:${userName(users, ch.user)}` : ch.id);
214
+ }
215
+ }
216
+
217
+ // Merge all conversation types
218
+ const all = [
219
+ ...(counts.channels || []).map((c) => ({ ...c, type: "channel" })),
220
+ ...(counts.mpims || []).map((c) => ({ ...c, type: "group" })),
221
+ ...(counts.ims || []).map((c) => ({ ...c, type: "dm" })),
222
+ ];
223
+
224
+ // Threads summary
225
+ if (counts.threads?.has_unreads || counts.threads?.mention_count > 0) {
226
+ console.log(`🧵 Threads — ${counts.threads.mention_count} mentions, unreads: ${counts.threads.has_unreads}`);
227
+ console.log();
228
+ }
229
+
230
+ // Filter: unreads only, and exclude muted channels
231
+ let filtered = all;
232
+ if (unreadOnly) {
233
+ filtered = filtered.filter(
234
+ (c) => (c.has_unreads || c.mention_count > 0) && !mutedSet.has(c.id)
235
+ );
236
+ }
237
+
238
+ if (filtered.length === 0) {
239
+ console.log(unreadOnly ? "No unreads! 🎉" : "No activity.");
240
+ return;
241
+ }
242
+
243
+ for (const ch of filtered) {
244
+ const name = chMap[ch.id] || ch.id;
245
+ const isMuted = mutedSet.has(ch.id);
246
+ const prefix = ch.type === "dm" ? "💬" : ch.type === "group" ? "👥" : "#";
247
+ const mentions = ch.mention_count > 0 ? ` (${ch.mention_count} mentions)` : "";
248
+ const unread = ch.has_unreads ? " •" : "";
249
+ const muted = isMuted ? " 🔇" : "";
250
+ console.log(`${prefix} ${name}${unread}${mentions}${muted}`);
251
+ }
252
+ }
253
+
254
+ export async function starred() {
255
+ const users = await getUsers();
256
+
257
+ // Get VIP users from prefs
258
+ const prefs = await slackApi("users.prefs.get", {});
259
+ const vipIds = prefs.ok ? (prefs.prefs?.vip_users || "").split(",").filter(Boolean) : [];
260
+
261
+ if (vipIds.length > 0) {
262
+ console.log("👑 VIP Users:");
263
+ for (const uid of vipIds) {
264
+ console.log(` ${userName(users, uid)} (${uid})`);
265
+ }
266
+ console.log();
267
+ }
268
+
269
+ // Build channel name map
270
+ const chData = await slackPaginate("conversations.list", {
271
+ types: "public_channel,private_channel,mpim,im",
272
+ exclude_archived: true,
273
+ });
274
+ const chMap = {};
275
+ if (chData.ok) {
276
+ for (const ch of chData.channels) {
277
+ chMap[ch.id] = ch.name || (ch.user ? `DM:${userName(users, ch.user)}` : ch.id);
278
+ }
279
+ }
280
+
281
+ // Get starred items
282
+ const stars = await slackApi("stars.list", { count: 50 });
283
+ if (!stars.ok) {
284
+ console.error(`Error: ${stars.error}`);
285
+ process.exit(1);
286
+ }
287
+
288
+ if (stars.items?.length > 0) {
289
+ console.log("⭐ Starred:");
290
+ for (const item of stars.items) {
291
+ if (item.type === "message") {
292
+ const msg = item.message || {};
293
+ const ch = chMap[item.channel] || item.channel;
294
+ const who = userName(users, msg.user);
295
+ console.log(` #${ch} — ${who}: ${(msg.text || "").substring(0, 100)}`);
296
+ } else if (item.type === "channel") {
297
+ console.log(` #${chMap[item.channel] || item.channel}`);
298
+ } else if (item.type === "im") {
299
+ console.log(` 💬 ${chMap[item.channel] || item.channel}`);
300
+ } else if (item.type === "file") {
301
+ console.log(` 📎 ${item.file?.name || "?"}`);
302
+ }
303
+ }
304
+ } else {
305
+ console.log("⭐ No starred items.");
306
+ }
307
+ }
308
+
309
+ export async function pins(channelRef) {
310
+ const channel = await resolveChannel(channelRef);
311
+ const users = await getUsers();
312
+
313
+ const data = await slackApi("pins.list", { channel });
314
+ if (!data.ok) {
315
+ console.error(`Error: ${data.error}`);
316
+ process.exit(1);
317
+ }
318
+
319
+ if (!data.items?.length) {
320
+ console.log("No pinned items.");
321
+ return;
322
+ }
323
+
324
+ console.log(`📌 ${data.items.length} pinned items:\n`);
325
+ for (const item of data.items) {
326
+ const msg = item.message || {};
327
+ const who = userName(users, msg.user);
328
+ const time = formatTs(msg.ts);
329
+ console.log(`[${time}] ${who}:`);
330
+ console.log(` ${(msg.text || "").substring(0, 200)}`);
331
+ console.log();
332
+ }
333
+ }
334
+
335
+ export async function react(channelRef, ts, emoji) {
336
+ const channel = await resolveChannel(channelRef);
337
+ const data = await slackApi("reactions.add", {
338
+ channel,
339
+ timestamp: ts,
340
+ name: emoji.replace(/:/g, ""),
341
+ });
342
+ if (data.ok) {
343
+ console.log(`✅ Reacted with :${emoji.replace(/:/g, "")}:`);
344
+ } else {
345
+ console.error(`❌ Failed: ${data.error}`);
346
+ process.exit(1);
347
+ }
348
+ }
package/src/drafts.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Slack drafts — uses the undocumented drafts.* API to create real Slack drafts
3
+ * that appear in the Slack editor UI.
4
+ */
5
+
6
+ import { randomUUID } from "crypto";
7
+ import { slackApi } from "./api.js";
8
+
9
+ /**
10
+ * Create a draft for a channel.
11
+ */
12
+ export async function draftChannel(channel, message) {
13
+ const channelId = await resolveChannelId(channel);
14
+ const data = await createDraft(channelId, null, null, message);
15
+ if (data.ok) {
16
+ console.log(`📝 Draft saved → #${channel} (${data.draft.id})`);
17
+ console.log(` Check Slack — draft icon should appear.`);
18
+ } else {
19
+ console.error(`❌ Failed: ${data.error}`);
20
+ process.exit(1);
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Create a draft thread reply.
26
+ */
27
+ export async function draftThread(channel, threadTs, message) {
28
+ const channelId = await resolveChannelId(channel);
29
+ const data = await createDraft(channelId, threadTs, null, message);
30
+ if (data.ok) {
31
+ console.log(`📝 Draft saved → #${channel} thread ${threadTs} (${data.draft.id})`);
32
+ console.log(` Check Slack — draft icon should appear.`);
33
+ } else {
34
+ console.error(`❌ Failed: ${data.error}`);
35
+ process.exit(1);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Create a draft DM.
41
+ */
42
+ export async function draftUser(userId, message) {
43
+ // Open a DM conversation to get the channel ID
44
+ const conv = await slackApi("conversations.open", { users: userId });
45
+ if (!conv.ok) {
46
+ console.error(`❌ Can't open DM with ${userId}: ${conv.error}`);
47
+ process.exit(1);
48
+ }
49
+ const channelId = conv.channel.id;
50
+ const data = await createDraft(channelId, null, null, message);
51
+ if (data.ok) {
52
+ console.log(`📝 Draft saved → DM @${userId} (${data.draft.id})`);
53
+ console.log(` Check Slack — draft icon should appear.`);
54
+ } else {
55
+ console.error(`❌ Failed: ${data.error}`);
56
+ process.exit(1);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * List all drafts.
62
+ */
63
+ export async function listDrafts() {
64
+ const data = await slackApi("drafts.list");
65
+ if (!data.ok) {
66
+ console.error(`Error: ${data.error}`);
67
+ process.exit(1);
68
+ }
69
+
70
+ const drafts = (data.drafts || []).filter((d) => !d.is_deleted && !d.is_sent);
71
+ if (drafts.length === 0) {
72
+ console.log("No active drafts.");
73
+ return;
74
+ }
75
+
76
+ for (const d of drafts) {
77
+ const dest = d.destinations?.[0];
78
+ const target = dest?.channel_id || "?";
79
+ const thread = dest?.thread_ts ? ` (thread ${dest.thread_ts})` : "";
80
+ const text = extractText(d.blocks);
81
+ const age = timeSince(d.date_created * 1000);
82
+ console.log(`${d.id} → ${target}${thread} (${age})`);
83
+ console.log(` ${text.substring(0, 120)}${text.length > 120 ? "..." : ""}`);
84
+ console.log();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Delete a draft.
90
+ */
91
+ export async function dropDraft(draftId) {
92
+ // Need client_last_updated_ts to delete — fetch it first
93
+ const list = await slackApi("drafts.list", {});
94
+ if (!list.ok) {
95
+ console.error(`❌ Failed to list drafts: ${list.error}`);
96
+ process.exit(1);
97
+ }
98
+
99
+ const draft = (list.drafts || []).find((d) => d.id === draftId);
100
+ if (!draft) {
101
+ console.error(`❌ Draft ${draftId} not found.`);
102
+ process.exit(1);
103
+ }
104
+
105
+ const data = await slackApi("drafts.delete", {
106
+ draft_id: draftId,
107
+ client_last_updated_ts: draft.last_updated_ts,
108
+ });
109
+ if (data.ok) {
110
+ console.log(`🗑 Draft ${draftId} deleted.`);
111
+ } else {
112
+ console.error(`❌ Failed: ${data.error}`);
113
+ process.exit(1);
114
+ }
115
+ }
116
+
117
+ // ── Helpers ──────────────────────────────────────────────
118
+
119
+ async function resolveChannelId(nameOrId) {
120
+ if (/^[CDG]/.test(nameOrId)) return nameOrId;
121
+
122
+ const name = nameOrId.replace(/^#/, "");
123
+ const data = await slackApi("conversations.list", {
124
+ types: "public_channel,private_channel,mpim,im",
125
+ limit: 200,
126
+ });
127
+ if (!data.ok) throw new Error(`Failed to list channels: ${data.error}`);
128
+ const ch = data.channels.find(
129
+ (c) => c.name === name || c.name_normalized === name
130
+ );
131
+ if (!ch) throw new Error(`Channel not found: ${nameOrId}`);
132
+ return ch.id;
133
+ }
134
+
135
+ async function createDraft(channelId, threadTs, userIds, text) {
136
+ const destination = { channel_id: channelId };
137
+ if (threadTs) {
138
+ destination.thread_ts = threadTs;
139
+ destination.broadcast = false;
140
+ }
141
+ if (userIds) {
142
+ destination.user_ids = userIds;
143
+ }
144
+
145
+ return slackApi("drafts.create", {
146
+ client_msg_id: randomUUID(),
147
+ is_from_composer: false,
148
+ file_ids: [],
149
+ destinations: [destination],
150
+ blocks: [
151
+ {
152
+ type: "rich_text",
153
+ elements: [
154
+ {
155
+ type: "rich_text_section",
156
+ elements: [{ type: "text", text }],
157
+ },
158
+ ],
159
+ },
160
+ ],
161
+ });
162
+ }
163
+
164
+ function extractText(blocks) {
165
+ if (!blocks?.length) return "(empty)";
166
+ const parts = [];
167
+ for (const block of blocks) {
168
+ for (const el of block.elements || []) {
169
+ for (const item of el.elements || []) {
170
+ if (item.type === "text") parts.push(item.text);
171
+ if (item.type === "emoji") parts.push(`:${item.name}:`);
172
+ if (item.type === "link") parts.push(item.url);
173
+ }
174
+ }
175
+ }
176
+ return parts.join("") || "(empty)";
177
+ }
178
+
179
+ function timeSince(ms) {
180
+ const diff = Date.now() - ms;
181
+ const mins = Math.floor(diff / 60000);
182
+ if (mins < 1) return "just now";
183
+ if (mins < 60) return `${mins}m ago`;
184
+ const hrs = Math.floor(mins / 60);
185
+ if (hrs < 24) return `${hrs}h ago`;
186
+ return `${Math.floor(hrs / 24)}d ago`;
187
+ }