slkcli 0.1.1 → 0.1.3
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/README.md +10 -4
- package/bin/slk.js +6 -0
- package/package.json +1 -1
- package/src/api.js +1 -0
- package/src/auth.js +40 -7
- package/src/commands.js +66 -0
package/README.md
CHANGED
|
@@ -59,6 +59,9 @@ slk unread
|
|
|
59
59
|
# See starred items and VIP users
|
|
60
60
|
slk starred
|
|
61
61
|
|
|
62
|
+
# See saved for later items
|
|
63
|
+
slk saved
|
|
64
|
+
|
|
62
65
|
# See pinned messages in a channel
|
|
63
66
|
slk pins general
|
|
64
67
|
|
|
@@ -84,6 +87,7 @@ slk react general 1234567890.123456 thumbsup
|
|
|
84
87
|
| `slk activity` | `a` | Show all channel activity with unread/mention counts |
|
|
85
88
|
| `slk unread` | `ur` | Show only channels with unreads (excludes muted) |
|
|
86
89
|
| `slk starred` | `star` | Show VIP users and starred items |
|
|
90
|
+
| `slk saved [count]` | `sv` | Show saved for later items (active by default, `--all` includes completed) |
|
|
87
91
|
| `slk pins <channel>` | `pin` | Show pinned items in a channel |
|
|
88
92
|
|
|
89
93
|
### Flags
|
|
@@ -92,6 +96,7 @@ slk react general 1234567890.123456 thumbsup
|
|
|
92
96
|
|------|-------------|
|
|
93
97
|
| `--ts` | Show raw Slack timestamps (useful for getting ts to read threads) |
|
|
94
98
|
| `--no-emoji` | Disable emoji in output (or set `NO_EMOJI=1`) |
|
|
99
|
+
| `--all` | Include completed items in `slk saved` |
|
|
95
100
|
|
|
96
101
|
```bash
|
|
97
102
|
# Get timestamps to use with thread command
|
|
@@ -140,9 +145,9 @@ On first run, macOS will show a Keychain dialog asking whether to allow access t
|
|
|
140
145
|
|
|
141
146
|
### How it works
|
|
142
147
|
|
|
143
|
-
1. **Cookie decryption** — Reads the encrypted `d` cookie from Slack's SQLite cookie store (
|
|
148
|
+
1. **Cookie decryption** — Reads the encrypted `d` cookie from Slack's SQLite cookie store (`Cookies` file). Decrypts it using the "Slack Safe Storage" key from the macOS Keychain via PBKDF2 + AES-128-CBC. Supports both direct-download and Mac App Store keychain account names.
|
|
144
149
|
|
|
145
|
-
2. **Token extraction** — Scans Slack's LevelDB storage (
|
|
150
|
+
2. **Token extraction** — Scans Slack's LevelDB storage (`Local Storage/leveldb/`) for `xoxc-` session tokens. Uses both direct regex scanning and a Python fallback for Snappy-compressed entries. The Slack data directory is auto-detected (direct download or App Store sandbox).
|
|
146
151
|
|
|
147
152
|
3. **Validation** — Tests each candidate token against `auth.test` with the decrypted cookie. The first valid pair is used.
|
|
148
153
|
|
|
@@ -172,8 +177,8 @@ Validated tokens are cached to avoid re-extracting on every invocation:
|
|
|
172
177
|
| Data | Source | Purpose |
|
|
173
178
|
|------|--------|---------|
|
|
174
179
|
| Keychain password | `security find-generic-password -s "Slack Safe Storage"` | Derive AES key for cookie decryption |
|
|
175
|
-
| Encrypted cookie |
|
|
176
|
-
| Session token |
|
|
180
|
+
| Encrypted cookie | `<slack-data-dir>/Cookies` (SQLite) | Decrypt the `d` session cookie (`xoxd-`) |
|
|
181
|
+
| Session token | `<slack-data-dir>/Local Storage/leveldb/` | Extract `xoxc-` token |
|
|
177
182
|
|
|
178
183
|
## Agent usage patterns
|
|
179
184
|
|
|
@@ -236,6 +241,7 @@ npm link # symlink globally for development
|
|
|
236
241
|
## Notes
|
|
237
242
|
|
|
238
243
|
- **macOS only** — uses Keychain and Electron storage paths specific to macOS.
|
|
244
|
+
- **Both Slack variants supported** — works with the direct download (`~/Library/Application Support/Slack/`) and the Mac App Store version (`~/Library/Containers/com.tinyspeck.slackmacgap/.../Slack/`). The correct path is auto-detected at runtime.
|
|
239
245
|
- **Slack desktop app required** — must be installed and logged in. The app does not need to be running for cached tokens.
|
|
240
246
|
- **Zero dependencies** — uses only Node.js built-in modules (`crypto`, `fs`, `child_process`, `fetch`).
|
|
241
247
|
- **Session-based** — uses `xoxc-` tokens (user session), not bot tokens. This means you act as yourself.
|
package/bin/slk.js
CHANGED
|
@@ -28,6 +28,7 @@ Commands:
|
|
|
28
28
|
slk activity (a) Channel activity with unread/mention counts
|
|
29
29
|
slk unread (ur) Channels with unreads (excludes muted)
|
|
30
30
|
slk starred (star) VIP users + starred items
|
|
31
|
+
slk saved [n] (sv) Saved for later items (--all includes completed)
|
|
31
32
|
slk pins <ch> (pin) Pinned items in a channel
|
|
32
33
|
|
|
33
34
|
Drafts (synced to Slack UI):
|
|
@@ -119,6 +120,11 @@ async function main() {
|
|
|
119
120
|
await cmd.starred();
|
|
120
121
|
break;
|
|
121
122
|
|
|
123
|
+
case "saved":
|
|
124
|
+
case "sv":
|
|
125
|
+
await cmd.saved(parseInt(args[1]) || 20, args.includes("--all"));
|
|
126
|
+
break;
|
|
127
|
+
|
|
122
128
|
case "pins":
|
|
123
129
|
case "pin":
|
|
124
130
|
if (!args[1]) { console.error("Usage: slk pins <channel>"); process.exit(1); }
|
package/package.json
CHANGED
package/src/api.js
CHANGED
package/src/auth.js
CHANGED
|
@@ -14,7 +14,27 @@ import { pbkdf2Sync } from "crypto";
|
|
|
14
14
|
|
|
15
15
|
import { existsSync, mkdirSync } from "fs";
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const SLACK_DIR_DIRECT = join(homedir(), "Library", "Application Support", "Slack");
|
|
18
|
+
const SLACK_DIR_APPSTORE = join(
|
|
19
|
+
homedir(),
|
|
20
|
+
"Library", "Containers", "com.tinyspeck.slackmacgap",
|
|
21
|
+
"Data", "Library", "Application Support", "Slack"
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
function resolveSlackDir() {
|
|
25
|
+
if (existsSync(SLACK_DIR_DIRECT)) return SLACK_DIR_DIRECT;
|
|
26
|
+
if (existsSync(SLACK_DIR_APPSTORE)) return SLACK_DIR_APPSTORE;
|
|
27
|
+
console.error(
|
|
28
|
+
"Could not find Slack data directory.\n" +
|
|
29
|
+
"Checked:\n" +
|
|
30
|
+
` ${SLACK_DIR_DIRECT}\n` +
|
|
31
|
+
` ${SLACK_DIR_APPSTORE}\n` +
|
|
32
|
+
"Is Slack installed?"
|
|
33
|
+
);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SLACK_DIR = resolveSlackDir();
|
|
18
38
|
const LEVELDB_DIR = join(SLACK_DIR, "Local Storage", "leveldb");
|
|
19
39
|
const COOKIES_DB = join(SLACK_DIR, "Cookies");
|
|
20
40
|
const CACHE_DIR = join(homedir(), ".local", "slk");
|
|
@@ -23,11 +43,24 @@ const TOKEN_CACHE = join(CACHE_DIR, "token-cache.json");
|
|
|
23
43
|
let cachedCreds = null;
|
|
24
44
|
|
|
25
45
|
function getKeychainKey() {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
// Mac App Store Slack uses account "Slack App Store Key", direct download uses "Slack"
|
|
47
|
+
const accounts = SLACK_DIR === SLACK_DIR_APPSTORE
|
|
48
|
+
? ["Slack App Store Key", "Slack"]
|
|
49
|
+
: ["Slack", "Slack App Store Key"];
|
|
50
|
+
|
|
51
|
+
for (const account of accounts) {
|
|
52
|
+
try {
|
|
53
|
+
return Buffer.from(
|
|
54
|
+
execSync(
|
|
55
|
+
`security find-generic-password -s "Slack Safe Storage" -a "${account}" -w`,
|
|
56
|
+
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
57
|
+
).trim()
|
|
58
|
+
);
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.error("Could not find Slack Safe Storage key in Keychain.");
|
|
63
|
+
process.exit(1);
|
|
31
64
|
}
|
|
32
65
|
|
|
33
66
|
function decryptCookie() {
|
|
@@ -111,7 +144,7 @@ function extractToken() {
|
|
|
111
144
|
try {
|
|
112
145
|
const pyResult = spawnSync("python3", ["-c", `
|
|
113
146
|
import os, re
|
|
114
|
-
path =
|
|
147
|
+
path = ${JSON.stringify(LEVELDB_DIR)}
|
|
115
148
|
for f in os.listdir(path):
|
|
116
149
|
if not (f.endswith(".ldb") or f.endswith(".log")): continue
|
|
117
150
|
data = open(os.path.join(path, f), "rb").read()
|
package/src/commands.js
CHANGED
|
@@ -333,6 +333,72 @@ export async function pins(channelRef) {
|
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
+
export async function saved(count = 20, includeCompleted = false) {
|
|
337
|
+
const users = await getUsers();
|
|
338
|
+
|
|
339
|
+
// Build channel name map
|
|
340
|
+
const chData = await slackPaginate("conversations.list", {
|
|
341
|
+
types: "public_channel,private_channel,mpim,im",
|
|
342
|
+
exclude_archived: true,
|
|
343
|
+
});
|
|
344
|
+
const chMap = {};
|
|
345
|
+
if (chData.ok) {
|
|
346
|
+
for (const ch of chData.channels) {
|
|
347
|
+
chMap[ch.id] = ch.name || (ch.user ? `DM:${userName(users, ch.user)}` : ch.id);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const data = await slackApi("saved.list", { count });
|
|
352
|
+
if (!data.ok) {
|
|
353
|
+
console.error(`Error: ${data.error}`);
|
|
354
|
+
process.exit(1);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const items = data.saved_items || [];
|
|
358
|
+
const counts = data.counts || {};
|
|
359
|
+
console.log(`📑 Saved for Later — ${counts.uncompleted_count || 0} active, ${counts.completed_count || 0} completed\n`);
|
|
360
|
+
|
|
361
|
+
if (!items.length) {
|
|
362
|
+
console.log("No saved items.");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const item of items) {
|
|
367
|
+
if (!includeCompleted && item.state === "completed") continue;
|
|
368
|
+
|
|
369
|
+
const chName = chMap[item.item_id] || item.item_id;
|
|
370
|
+
const savedAt = formatTs(item.date_created);
|
|
371
|
+
const state = item.state === "completed" ? " ✅" : "";
|
|
372
|
+
|
|
373
|
+
// Fetch the actual message
|
|
374
|
+
try {
|
|
375
|
+
const msgData = await slackApi("conversations.history", {
|
|
376
|
+
channel: item.item_id,
|
|
377
|
+
latest: item.ts,
|
|
378
|
+
inclusive: true,
|
|
379
|
+
limit: 1,
|
|
380
|
+
});
|
|
381
|
+
if (msgData.ok && msgData.messages?.[0]) {
|
|
382
|
+
const msg = msgData.messages[0];
|
|
383
|
+
const who = userName(users, msg.user);
|
|
384
|
+
const msgTime = formatTs(msg.ts);
|
|
385
|
+
console.log(`[saved ${savedAt}]${state} #${chName} — ${who} (${msgTime}):`);
|
|
386
|
+
console.log(` ${(msg.text || "").substring(0, 300)}`);
|
|
387
|
+
if (msg.files?.length) {
|
|
388
|
+
for (const f of msg.files) {
|
|
389
|
+
console.log(` 📎 ${f.name} (${f.mimetype})`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
console.log(`[saved ${savedAt}]${state} #${chName} (ts: ${item.ts}) — could not fetch message`);
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
console.log(`[saved ${savedAt}]${state} #${chName} (ts: ${item.ts}) — access denied or channel not found`);
|
|
397
|
+
}
|
|
398
|
+
console.log();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
336
402
|
export async function react(channelRef, ts, emoji) {
|
|
337
403
|
const channel = await resolveChannel(channelRef);
|
|
338
404
|
const data = await slackApi("reactions.add", {
|