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 +21 -0
- package/README.md +212 -0
- package/bin/slk.js +176 -0
- package/package.json +44 -0
- package/src/api.js +85 -0
- package/src/auth.js +219 -0
- package/src/commands.js +348 -0
- package/src/drafts.js +187 -0
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
|
+
}
|
package/src/commands.js
ADDED
|
@@ -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
|
+
}
|