slack-term 1.14.0 → 1.16.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/README.md +22 -3
- package/SKILL.md +15 -4
- package/dist/cli.js +82 -64
- package/package.json +6 -3
- package/ts/auth.ts +268 -13
- package/ts/botdoctor.ts +96 -0
- package/ts/cli.ts +400 -68
- package/ts/profiles.ts +165 -36
- package/ts/rtm.ts +18 -1
- package/ts/slack-app.ts +207 -3
- package/ts/slack.ts +112 -0
- package/ts/tail.ts +111 -12
package/ts/profiles.ts
CHANGED
|
@@ -5,12 +5,20 @@
|
|
|
5
5
|
// Local (cwd): .slack-cli/workspace — set with: slack workspace use <name>
|
|
6
6
|
// Global (home): ~/.slack-cli/workspace — set with: slack workspace use -g <name>
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
// 1.
|
|
10
|
-
// 2.
|
|
11
|
-
// 3.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// Token resolution order (no --workspace flag):
|
|
9
|
+
// 1. process.env SLACK_TOKEN or SLACK_BOT_TOKEN
|
|
10
|
+
// 2. process.env SLACK_MCP_XOXP_TOKEN (legacy, yields to profiles if any exist)
|
|
11
|
+
// 3. Dir walk from cwd→$HOME: .slack-term/.env.local, then .env.local (modern env-file names only)
|
|
12
|
+
// Note: cli.ts loadDotenvFiles() has already loaded cwd/.env.local into process.env (#1 catches
|
|
13
|
+
// that case), so the walk's extra value is finding .slack-term/ variants and parent-dir files.
|
|
14
|
+
// 4. ~/.slack-term/.env.local (global slack-term config)
|
|
15
|
+
// 5. profiles.json via SLACK_WORKSPACE / lockfiles
|
|
16
|
+
// 6. throw with auth help
|
|
17
|
+
//
|
|
18
|
+
// --workspace flag skips #2-4 and goes straight to profiles.json.
|
|
19
|
+
//
|
|
20
|
+
// Note: lockfiles and profiles.json use .slack-cli/ (older name); new per-dir token storage
|
|
21
|
+
// uses .slack-term/ (current project name). Both coexist intentionally.
|
|
14
22
|
|
|
15
23
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
16
24
|
import { homedir } from "node:os";
|
|
@@ -71,6 +79,80 @@ function writeLockfile(path: string, name: string): void {
|
|
|
71
79
|
writeFileSync(path, name + "\n");
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
function parseEnvVars(content: string): Record<string, string> {
|
|
83
|
+
const result: Record<string, string> = {};
|
|
84
|
+
for (const rawLine of content.split("\n")) {
|
|
85
|
+
const line = rawLine.trim();
|
|
86
|
+
if (!line || line.startsWith("#")) continue;
|
|
87
|
+
const eq = line.indexOf("=");
|
|
88
|
+
if (eq === -1) continue;
|
|
89
|
+
const key = line.slice(0, eq).trim();
|
|
90
|
+
let val = line.slice(eq + 1).trim();
|
|
91
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
92
|
+
val = val.slice(1, -1);
|
|
93
|
+
}
|
|
94
|
+
result[key] = val;
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readEnvFile(filePath: string): Record<string, string> {
|
|
100
|
+
if (!existsSync(filePath)) return {};
|
|
101
|
+
try {
|
|
102
|
+
return parseEnvVars(readFileSync(filePath, "utf8"));
|
|
103
|
+
} catch {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Walk from startDir up to $HOME checking .slack-term/.env.local then .env.local.
|
|
109
|
+
* Returns first value found for any of the given keys. */
|
|
110
|
+
function walkDirEnv(startDir: string, keys: string[]): string | undefined {
|
|
111
|
+
const homeDir = home();
|
|
112
|
+
let dir = startDir;
|
|
113
|
+
|
|
114
|
+
while (true) {
|
|
115
|
+
for (const subdir of [join(dir, ".slack-term", ".env.local"), join(dir, ".env.local")]) {
|
|
116
|
+
const vars = readEnvFile(subdir);
|
|
117
|
+
for (const key of keys) {
|
|
118
|
+
if (vars[key]) return vars[key];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (dir === homeDir) break;
|
|
122
|
+
const parent = dirname(dir);
|
|
123
|
+
if (parent === dir) break; // filesystem root
|
|
124
|
+
dir = parent;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Global slack-term config — separate from the walk, checked after $HOME
|
|
128
|
+
const globalVars = readEnvFile(join(homeDir, ".slack-term", ".env.local"));
|
|
129
|
+
for (const key of keys) {
|
|
130
|
+
if (globalVars[key]) return globalVars[key];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Write or update KEY=VALUE entries in an env file (creates file and parent dirs as needed). */
|
|
137
|
+
export function saveToEnvFile(filePath: string, updates: Record<string, string>): void {
|
|
138
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
139
|
+
const content = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
|
|
140
|
+
const lines = content ? content.split("\n") : [];
|
|
141
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
142
|
+
const idx = lines.findIndex((l) => {
|
|
143
|
+
const t = l.trim();
|
|
144
|
+
return t.startsWith(key + "=") || t.startsWith(key + " =");
|
|
145
|
+
});
|
|
146
|
+
const newLine = `${key}=${value}`;
|
|
147
|
+
if (idx !== -1) {
|
|
148
|
+
lines[idx] = newLine;
|
|
149
|
+
} else {
|
|
150
|
+
lines.push(newLine);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
writeFileSync(filePath, lines.join("\n").trimEnd() + "\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
74
156
|
export function listProfiles(): { name: string; profile: Profile; current: boolean }[] {
|
|
75
157
|
const store = load();
|
|
76
158
|
const local = readLockfile(localLockfilePath());
|
|
@@ -117,34 +199,52 @@ export function resolveToken(workspaceFlag?: string): string {
|
|
|
117
199
|
const store = load();
|
|
118
200
|
const profiles = store.profiles;
|
|
119
201
|
const names = Object.keys(profiles);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!
|
|
125
|
-
(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
202
|
+
|
|
203
|
+
// --workspace flag: skip env-file walk, go straight to profiles.json
|
|
204
|
+
if (workspaceFlag) {
|
|
205
|
+
const profile = profiles[workspaceFlag];
|
|
206
|
+
if (!profile) {
|
|
207
|
+
throw new Error(`Workspace "${workspaceFlag}" not found. Available: ${names.join(", ") || "(none)"}`);
|
|
208
|
+
}
|
|
209
|
+
return profile.token;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// SLACK_TOKEN always wins — intended as an explicit per-project override.
|
|
213
|
+
if (process.env.SLACK_TOKEN) return process.env.SLACK_TOKEN;
|
|
214
|
+
|
|
215
|
+
// SLACK_BOT_TOKEN and SLACK_MCP_XOXP_TOKEN: only used when no profiles exist.
|
|
216
|
+
// Bot tokens in shell env or .env files must not shadow workspace profiles.
|
|
217
|
+
const legacyEnvToken = process.env.SLACK_BOT_TOKEN ?? process.env.SLACK_MCP_XOXP_TOKEN;
|
|
218
|
+
if (legacyEnvToken && names.length === 0) return legacyEnvToken;
|
|
219
|
+
if (legacyEnvToken && names.length > 0) {
|
|
220
|
+
// Warn only when the token differs from what's in .env.local (same value = not a real conflict)
|
|
221
|
+
const envFileToken = walkDirEnv(process.cwd(), ["SLACK_BOT_TOKEN", "SLACK_MCP_XOXP_TOKEN"]);
|
|
222
|
+
if (legacyEnvToken !== envFileToken) {
|
|
223
|
+
if (!(globalThis as Record<string, unknown>).__slackEnvWarnShown) {
|
|
224
|
+
(globalThis as Record<string, unknown>).__slackEnvWarnShown = true;
|
|
225
|
+
console.error(
|
|
226
|
+
"Warning: SLACK_MCP_XOXP_TOKEN / SLACK_BOT_TOKEN is set but workspace profiles exist — using profiles.\n" +
|
|
227
|
+
" Migrate to SLACK_TOKEN=... or remove from your shell config.",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
130
230
|
}
|
|
131
|
-
} else if (envToken) {
|
|
132
|
-
return envToken;
|
|
133
231
|
}
|
|
134
232
|
|
|
135
|
-
//
|
|
136
|
-
|
|
233
|
+
// Dir walk: only SLACK_TOKEN from env files can override profiles.
|
|
234
|
+
// SLACK_BOT_TOKEN in .env files yields to profiles (same as shell env).
|
|
235
|
+
const walked = walkDirEnv(process.cwd(), ["SLACK_TOKEN"]);
|
|
236
|
+
if (walked) return walked;
|
|
237
|
+
|
|
238
|
+
// profiles.json via SLACK_WORKSPACE env var or lockfiles
|
|
239
|
+
const selected = process.env.SLACK_WORKSPACE;
|
|
137
240
|
if (selected) {
|
|
138
241
|
const profile = profiles[selected];
|
|
139
242
|
if (!profile) {
|
|
140
|
-
throw new Error(
|
|
141
|
-
`Workspace "${selected}" not found. Available: ${names.join(", ") || "(none)"}`,
|
|
142
|
-
);
|
|
243
|
+
throw new Error(`Workspace "${selected}" not found. Available: ${names.join(", ") || "(none)"}`);
|
|
143
244
|
}
|
|
144
245
|
return profile.token;
|
|
145
246
|
}
|
|
146
247
|
|
|
147
|
-
// Local lockfile (cwd)
|
|
148
248
|
const localName = readLockfile(localLockfilePath());
|
|
149
249
|
if (localName) {
|
|
150
250
|
const profile = profiles[localName];
|
|
@@ -152,7 +252,6 @@ export function resolveToken(workspaceFlag?: string): string {
|
|
|
152
252
|
return profile.token;
|
|
153
253
|
}
|
|
154
254
|
|
|
155
|
-
// Global lockfile (~/.slack-cli/workspace)
|
|
156
255
|
const globalName = readLockfile(globalLockfilePath());
|
|
157
256
|
if (globalName) {
|
|
158
257
|
const profile = profiles[globalName];
|
|
@@ -160,29 +259,59 @@ export function resolveToken(workspaceFlag?: string): string {
|
|
|
160
259
|
return profile.token;
|
|
161
260
|
}
|
|
162
261
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
262
|
+
if (names.length > 0) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Workspace not selected (${names.join(", ")} available).\n` +
|
|
265
|
+
` Select locally: slack workspace use <name> (writes .slack-cli/workspace)\n` +
|
|
266
|
+
` Select globally: slack workspace use -g <name> (writes ~/.slack-cli/workspace)`,
|
|
267
|
+
);
|
|
166
268
|
}
|
|
167
269
|
|
|
168
|
-
// Profiles exist but none selected — never silently pick one
|
|
169
270
|
throw new Error(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
271
|
+
"No Slack token found.\n" +
|
|
272
|
+
" Run one of:\n" +
|
|
273
|
+
" slack auth token — paste an existing xoxp-/xoxb- token\n" +
|
|
274
|
+
" slack auth chrome — import from Chrome browser (macOS)\n" +
|
|
275
|
+
" slack auth app — guided Slack app creation\n" +
|
|
276
|
+
" Or set SLACK_TOKEN=xoxp-... in .slack-term/.env.local",
|
|
173
277
|
);
|
|
174
278
|
}
|
|
175
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Resolve a bot user token (xoxb-) for sending as the app rather than as the
|
|
282
|
+
* user. Reads SLACK_BOT_TOKEN from the environment — cli.ts loadDotenvFiles()
|
|
283
|
+
* has already sourced ~/.config/slack-cli/.env, so a bot token configured there
|
|
284
|
+
* is available here. Returns undefined when no xoxb- token is set.
|
|
285
|
+
*/
|
|
286
|
+
export function resolveBotToken(): string | undefined {
|
|
287
|
+
const t = process.env.SLACK_BOT_TOKEN;
|
|
288
|
+
return t && t.startsWith("xoxb-") ? t : undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
176
291
|
/** Resolve the xoxd session cookie for the active workspace (best-effort). */
|
|
177
292
|
export function resolveCookie(workspaceFlag?: string): string | undefined {
|
|
178
293
|
const store = load();
|
|
179
294
|
const profiles = store.profiles;
|
|
180
|
-
const envToken = process.env.SLACK_MCP_XOXP_TOKEN;
|
|
181
295
|
|
|
182
|
-
//
|
|
183
|
-
if (
|
|
296
|
+
// --workspace flag: skip env-file walk, go straight to profiles.json
|
|
297
|
+
if (workspaceFlag) return profiles[workspaceFlag]?.cookie;
|
|
298
|
+
|
|
299
|
+
// process.env: SLACK_COOKIE (official extension), legacy SLACK_MCP_XOXD_COOKIE
|
|
300
|
+
if (process.env.SLACK_COOKIE) return process.env.SLACK_COOKIE;
|
|
301
|
+
if (process.env.SLACK_MCP_XOXD_COOKIE) return process.env.SLACK_MCP_XOXD_COOKIE;
|
|
302
|
+
|
|
303
|
+
// Legacy env-only mode (SLACK_MCP_XOXP_TOKEN set, no profiles)
|
|
304
|
+
const legacyToken = process.env.SLACK_MCP_XOXP_TOKEN;
|
|
305
|
+
if (legacyToken && Object.keys(profiles).length === 0) {
|
|
306
|
+
return process.env.SLACK_MCP_XOXD_COOKIE;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Dir walk for SLACK_COOKIE
|
|
310
|
+
const walked = walkDirEnv(process.cwd(), ["SLACK_COOKIE"]);
|
|
311
|
+
if (walked) return walked;
|
|
184
312
|
|
|
185
|
-
|
|
313
|
+
// profiles.json via SLACK_WORKSPACE env var or lockfiles
|
|
314
|
+
const selected = process.env.SLACK_WORKSPACE;
|
|
186
315
|
if (selected) return profiles[selected]?.cookie;
|
|
187
316
|
|
|
188
317
|
const localName = readLockfile(localLockfilePath());
|
package/ts/rtm.ts
CHANGED
|
@@ -3,10 +3,19 @@ import { resolveDateMarkup, resolveMentions } from "./format.ts";
|
|
|
3
3
|
|
|
4
4
|
export type RtmPollOpts = {
|
|
5
5
|
thread?: string;
|
|
6
|
+
watchThread?: string;
|
|
6
7
|
me?: boolean;
|
|
7
8
|
myUserId?: string;
|
|
8
9
|
};
|
|
9
10
|
|
|
11
|
+
// A message is a thread reply (not a root/broadcast) when it carries a
|
|
12
|
+
// thread_ts that differs from its own ts.
|
|
13
|
+
function isThreadReply(m: Record<string, Json>): boolean {
|
|
14
|
+
const ts = typeof m.ts === "string" ? m.ts : "";
|
|
15
|
+
const tts = typeof m.thread_ts === "string" ? m.thread_ts : "";
|
|
16
|
+
return tts !== "" && tts !== ts;
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
// Minimal WebSocket interface — avoids requiring DOM lib in tsconfig
|
|
11
20
|
interface WsLike {
|
|
12
21
|
addEventListener(type: string, handler: (event: unknown) => void): void;
|
|
@@ -53,7 +62,8 @@ async function formatRtmLine(
|
|
|
53
62
|
const resolved = resolveDateMarkup(await resolveMentions(token, raw, cache));
|
|
54
63
|
const lines = resolved.split("\n");
|
|
55
64
|
const body = lines[0] + (lines.length > 1 ? "\n" + lines.slice(1).map((l) => ` ${l}`).join("\n") : "");
|
|
56
|
-
|
|
65
|
+
const mark = isThreadReply(m) ? "↳ " : "";
|
|
66
|
+
return `${stamp} ${mark}@${handle}: ${body}`;
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
async function connectAndStream(
|
|
@@ -117,6 +127,13 @@ async function connectAndStream(
|
|
|
117
127
|
if (parentTs !== opts.thread && ts !== opts.thread) return;
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
// --watch-thread: keep all top-level posts plus the watched thread's
|
|
131
|
+
// replies; drop replies addressed to other threads.
|
|
132
|
+
if (opts.watchThread && isThreadReply(data)) {
|
|
133
|
+
const tts = typeof data.thread_ts === "string" ? data.thread_ts : "";
|
|
134
|
+
if (tts !== opts.watchThread) return;
|
|
135
|
+
}
|
|
136
|
+
|
|
120
137
|
if (opts.me && opts.myUserId) {
|
|
121
138
|
const text = typeof data.text === "string" ? data.text : "";
|
|
122
139
|
if (!text.includes(`<@${opts.myUserId}>`)) return;
|
package/ts/slack-app.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Reads raw .ldb/.log files with regex — works even while Slack is running (no exclusive lock).
|
|
3
3
|
// Also extracts the xoxd session cookie from the Slack Cookies SQLite database (macOS only).
|
|
4
4
|
|
|
5
|
-
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
5
|
+
import { readdirSync, readFileSync, existsSync, copyFileSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
8
|
import { pbkdf2Sync, createDecipheriv } from "node:crypto";
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
10
10
|
|
|
@@ -241,3 +241,207 @@ export async function extractSessions(): Promise<SlackAppSession[]> {
|
|
|
241
241
|
}
|
|
242
242
|
return result;
|
|
243
243
|
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Try both AES-128-CBC variants used across Chromium versions to decrypt a v10 cookie.
|
|
247
|
+
* Returns the decrypted string if it starts with "xoxd-", otherwise undefined.
|
|
248
|
+
*
|
|
249
|
+
* Variant A (older Chrome): IV = 16 spaces, ciphertext = enc[3:]
|
|
250
|
+
* Variant B (newer Chrome 127+): IV = enc[19:35], ciphertext = enc[35:]
|
|
251
|
+
*/
|
|
252
|
+
function decryptV10Cookie(enc: Buffer, aesKey: Buffer): string | undefined {
|
|
253
|
+
const tryDecrypt = (iv: Buffer, ciphertext: Buffer): string | undefined => {
|
|
254
|
+
try {
|
|
255
|
+
const decipher = createDecipheriv("aes-128-cbc", aesKey, iv);
|
|
256
|
+
decipher.setAutoPadding(true);
|
|
257
|
+
const result = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
258
|
+
return result.startsWith("xoxd-") ? result : undefined;
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
return tryDecrypt(Buffer.alloc(16, 32), enc.slice(3))
|
|
265
|
+
?? (enc.length >= 35 ? tryDecrypt(enc.slice(19, 35), enc.slice(35)) : undefined);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export type ChromeCookieCandidate = {
|
|
269
|
+
profileDir: string; // "Default", "Profile 1", etc.
|
|
270
|
+
profileName: string; // display name from Chrome Preferences, e.g. email address
|
|
271
|
+
cookie: string; // decrypted xoxd-...
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/** Read the display name for a Chrome profile from its Preferences JSON. */
|
|
275
|
+
function chromeProfileName(userDataDir: string, profileDir: string): string {
|
|
276
|
+
try {
|
|
277
|
+
const prefs = JSON.parse(
|
|
278
|
+
readFileSync(join(userDataDir, profileDir, "Preferences"), "utf8"),
|
|
279
|
+
) as Record<string, unknown>;
|
|
280
|
+
const profile = prefs.profile as Record<string, unknown> | undefined;
|
|
281
|
+
const name = (profile?.name as string | undefined) ?? "";
|
|
282
|
+
const email = (profile?.user_name as string | undefined) ?? "";
|
|
283
|
+
return email ? `${name} (${email})` : name || profileDir;
|
|
284
|
+
} catch {
|
|
285
|
+
return profileDir;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Discover all Chrome browser profiles that have a Slack xoxd cookie (macOS only).
|
|
291
|
+
*
|
|
292
|
+
* Requires the Chrome Safe Storage key from the system keychain. When called from an
|
|
293
|
+
* interactive terminal, macOS will show a dialog asking for the login password.
|
|
294
|
+
* Throws on v20 (app-bound encryption). Returns [] if keychain is inaccessible.
|
|
295
|
+
*/
|
|
296
|
+
export type ChromeDiscoveryResult = {
|
|
297
|
+
candidates: ChromeCookieCandidate[];
|
|
298
|
+
totalProfiles: number; // how many Chrome profile dirs were scanned
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export function discoverChromeCookies(): ChromeDiscoveryResult {
|
|
302
|
+
if (process.platform !== "darwin") return { candidates: [], totalProfiles: 0 };
|
|
303
|
+
|
|
304
|
+
const userDataDir = join(homedir(), "Library", "Application Support", "Google", "Chrome");
|
|
305
|
+
if (!existsSync(userDataDir)) return { candidates: [], totalProfiles: 0 };
|
|
306
|
+
|
|
307
|
+
let keychainPw: string;
|
|
308
|
+
try {
|
|
309
|
+
keychainPw = execSync(
|
|
310
|
+
`security find-generic-password -a Chrome -s "Chrome Safe Storage" -w`,
|
|
311
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
312
|
+
).trimEnd();
|
|
313
|
+
} catch {
|
|
314
|
+
return { candidates: [], totalProfiles: 0 };
|
|
315
|
+
}
|
|
316
|
+
if (!keychainPw) return { candidates: [], totalProfiles: 0 };
|
|
317
|
+
|
|
318
|
+
const aesKey = pbkdf2Sync(keychainPw, "saltysalt", 1003, 16, "sha1");
|
|
319
|
+
|
|
320
|
+
const profileDirs = ["Default", ...readdirSync(userDataDir).filter((d) => d.startsWith("Profile "))];
|
|
321
|
+
const candidates: ChromeCookieCandidate[] = [];
|
|
322
|
+
|
|
323
|
+
for (const profileDir of profileDirs) {
|
|
324
|
+
const dbPath = join(userDataDir, profileDir, "Cookies");
|
|
325
|
+
if (!existsSync(dbPath)) continue;
|
|
326
|
+
|
|
327
|
+
const tmp = join(tmpdir(), `slack-chrome-cookies-${Date.now()}-${profileDir}.db`);
|
|
328
|
+
try {
|
|
329
|
+
copyFileSync(dbPath, tmp);
|
|
330
|
+
const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
|
|
331
|
+
const db = new Database(tmp, { readonly: true });
|
|
332
|
+
const row = db
|
|
333
|
+
.prepare("SELECT encrypted_value FROM cookies WHERE name='d' AND host_key LIKE '%slack%'")
|
|
334
|
+
.get() as { encrypted_value: Uint8Array } | null;
|
|
335
|
+
db.close();
|
|
336
|
+
if (!row) continue;
|
|
337
|
+
|
|
338
|
+
const enc = Buffer.from(row.encrypted_value);
|
|
339
|
+
const prefix = enc.slice(0, 3).toString();
|
|
340
|
+
if (prefix === "v10") {
|
|
341
|
+
// Try both AES-128-CBC variants used by different Chromium versions:
|
|
342
|
+
// 1. Standard (older Chrome): IV = 16 spaces, ciphertext = enc[3:]
|
|
343
|
+
// 2. Embedded (newer Chrome/Electron): IV = enc[19:35], ciphertext = enc[35:]
|
|
344
|
+
const cookie = decryptV10Cookie(enc, aesKey);
|
|
345
|
+
if (!cookie) continue; // neither format produced a valid xoxd- value
|
|
346
|
+
candidates.push({ profileDir, profileName: chromeProfileName(userDataDir, profileDir), cookie });
|
|
347
|
+
} else if (prefix === "v20") {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`Chrome cookie uses v20 (app-bound AES-256-GCM) which is not supported yet. ` +
|
|
350
|
+
`Prefix found: ${enc.slice(0, 4).toString("hex")}`,
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
throw new Error(`Unknown cookie encryption prefix: ${enc.slice(0, 4).toString("hex")}`);
|
|
354
|
+
}
|
|
355
|
+
} catch (e: unknown) {
|
|
356
|
+
if (e instanceof Error && (e.message.includes("app-bound") || e.message.includes("Unknown cookie"))) throw e;
|
|
357
|
+
// Otherwise skip this profile
|
|
358
|
+
} finally {
|
|
359
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { candidates, totalProfiles: profileDirs.length };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export type FirefoxCookieCandidate = {
|
|
366
|
+
profileDir: string; // e.g. "abc123.default-release"
|
|
367
|
+
profileName: string; // display name from profiles.ini, or the dir name
|
|
368
|
+
cookie: string; // plaintext xoxd-... (Firefox stores cookies unencrypted)
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
function firefoxProfilesDir(): string {
|
|
372
|
+
const home = homedir();
|
|
373
|
+
if (process.platform === "darwin") return join(home, "Library", "Application Support", "Firefox", "Profiles");
|
|
374
|
+
if (process.platform === "linux") return join(home, ".mozilla", "firefox");
|
|
375
|
+
if (process.platform === "win32") {
|
|
376
|
+
return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Mozilla", "Firefox", "Profiles");
|
|
377
|
+
}
|
|
378
|
+
return "";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Read Firefox profiles.ini to map profile dir names to display names. */
|
|
382
|
+
function firefoxProfileNames(profilesDir: string): Record<string, string> {
|
|
383
|
+
const iniPath = join(dirname(profilesDir), "profiles.ini");
|
|
384
|
+
const map: Record<string, string> = {};
|
|
385
|
+
if (!existsSync(iniPath)) return map;
|
|
386
|
+
try {
|
|
387
|
+
const text = readFileSync(iniPath, "utf8");
|
|
388
|
+
let currentName = "";
|
|
389
|
+
let currentPath = "";
|
|
390
|
+
for (const line of text.split("\n")) {
|
|
391
|
+
const t = line.trim();
|
|
392
|
+
if (t.startsWith("[")) { currentName = ""; currentPath = ""; continue; }
|
|
393
|
+
const eq = t.indexOf("=");
|
|
394
|
+
if (eq === -1) continue;
|
|
395
|
+
const key = t.slice(0, eq).trim().toLowerCase();
|
|
396
|
+
const val = t.slice(eq + 1).trim();
|
|
397
|
+
if (key === "name") currentName = val;
|
|
398
|
+
if (key === "path") currentPath = val.split(/[\\/]/).pop() ?? val;
|
|
399
|
+
if (currentName && currentPath) { map[currentPath] = currentName; currentName = ""; currentPath = ""; }
|
|
400
|
+
}
|
|
401
|
+
} catch { /* ignore */ }
|
|
402
|
+
return map;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Discover all Firefox profiles that have a Slack xoxd cookie.
|
|
407
|
+
* Firefox stores cookies in plaintext — no keychain or decryption needed.
|
|
408
|
+
* Returns [] if Firefox is not installed or has no Slack session.
|
|
409
|
+
*/
|
|
410
|
+
export function discoverFirefoxCookies(): FirefoxCookieCandidate[] {
|
|
411
|
+
const profilesDir = firefoxProfilesDir();
|
|
412
|
+
if (!profilesDir || !existsSync(profilesDir)) return [];
|
|
413
|
+
|
|
414
|
+
const nameMap = firefoxProfileNames(profilesDir);
|
|
415
|
+
const candidates: FirefoxCookieCandidate[] = [];
|
|
416
|
+
|
|
417
|
+
let profileDirs: string[];
|
|
418
|
+
try {
|
|
419
|
+
profileDirs = readdirSync(profilesDir);
|
|
420
|
+
} catch {
|
|
421
|
+
return [];
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
for (const profileDir of profileDirs) {
|
|
425
|
+
const dbPath = join(profilesDir, profileDir, "cookies.sqlite");
|
|
426
|
+
if (!existsSync(dbPath)) continue;
|
|
427
|
+
|
|
428
|
+
const tmp = join(tmpdir(), `slack-firefox-cookies-${Date.now()}-${profileDir}.db`);
|
|
429
|
+
try {
|
|
430
|
+
copyFileSync(dbPath, tmp);
|
|
431
|
+
const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
|
|
432
|
+
const db = new Database(tmp, { readonly: true });
|
|
433
|
+
const row = db
|
|
434
|
+
.prepare("SELECT value FROM moz_cookies WHERE name='d' AND host LIKE '%slack%' LIMIT 1")
|
|
435
|
+
.get() as { value: string } | null;
|
|
436
|
+
db.close();
|
|
437
|
+
if (!row?.value || !row.value.startsWith("xoxd-")) continue;
|
|
438
|
+
const profileName = nameMap[profileDir] ?? profileDir;
|
|
439
|
+
candidates.push({ profileDir, profileName, cookie: row.value });
|
|
440
|
+
} catch {
|
|
441
|
+
// skip this profile
|
|
442
|
+
} finally {
|
|
443
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return candidates;
|
|
447
|
+
}
|
package/ts/slack.ts
CHANGED
|
@@ -128,6 +128,43 @@ export async function authTest(token: string): Promise<{ team: string; teamId: s
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// auth.test plus the token's granted scopes. Slack returns the granted bot/user
|
|
132
|
+
// scopes in the `X-OAuth-Scopes` response header on every Web API call, which is
|
|
133
|
+
// the only way to learn what a token can do without probing each endpoint.
|
|
134
|
+
export async function authScopes(token: string): Promise<{
|
|
135
|
+
userId: string; botId: string; team: string; url: string; scopes: string[];
|
|
136
|
+
}> {
|
|
137
|
+
const res = await fetch(`${base()}/auth.test`, {
|
|
138
|
+
method: "GET",
|
|
139
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
140
|
+
});
|
|
141
|
+
if (res.status === 429) {
|
|
142
|
+
const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
|
|
143
|
+
throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
|
|
144
|
+
}
|
|
145
|
+
const scopesHeader = res.headers.get("x-oauth-scopes") ?? "";
|
|
146
|
+
const body = (await res.json()) as {
|
|
147
|
+
ok?: boolean; error?: string; user_id?: string; bot_id?: string; team?: string; url?: string;
|
|
148
|
+
};
|
|
149
|
+
if (body.ok !== true) throw new Error(`Slack error on auth.test: ${body.error ?? "unknown"}`);
|
|
150
|
+
return {
|
|
151
|
+
userId: body.user_id ?? "",
|
|
152
|
+
botId: body.bot_id ?? "",
|
|
153
|
+
team: body.team ?? "",
|
|
154
|
+
url: body.url ?? "",
|
|
155
|
+
scopes: scopesHeader.split(",").map((s) => s.trim()).filter(Boolean),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Resolve a bot's app_id (and bot user id) from its bot_id — needed to build the
|
|
160
|
+
// app-settings deep link in messaging diagnostics.
|
|
161
|
+
export async function botsInfo(token: string, botId: string): Promise<{ appId: string; userId: string }> {
|
|
162
|
+
const resp = (await get(token, "bots.info", { bot: botId })) as {
|
|
163
|
+
bot?: { app_id?: string; user_id?: string };
|
|
164
|
+
};
|
|
165
|
+
return { appId: resp.bot?.app_id ?? "", userId: resp.bot?.user_id ?? "" };
|
|
166
|
+
}
|
|
167
|
+
|
|
131
168
|
export async function history(token: string, channel: string, limit = 20, oldest?: string, cursor?: string): Promise<Json> {
|
|
132
169
|
const params: Record<string, string> = { channel, limit: String(limit) };
|
|
133
170
|
if (oldest !== undefined) params.oldest = oldest;
|
|
@@ -189,6 +226,7 @@ export async function send(
|
|
|
189
226
|
channel: string,
|
|
190
227
|
text: string,
|
|
191
228
|
threadTs?: string,
|
|
229
|
+
replyBroadcast?: boolean,
|
|
192
230
|
): Promise<string> {
|
|
193
231
|
const body: Record<string, Json> = {
|
|
194
232
|
channel,
|
|
@@ -196,6 +234,9 @@ export async function send(
|
|
|
196
234
|
blocks: [{ type: "markdown", text }],
|
|
197
235
|
};
|
|
198
236
|
if (threadTs !== undefined) body.thread_ts = threadTs;
|
|
237
|
+
// "Also send to channel": broadcast a threaded reply back to the channel.
|
|
238
|
+
// Only meaningful alongside thread_ts; Slack ignores it on top-level sends.
|
|
239
|
+
if (replyBroadcast && threadTs !== undefined) body.reply_broadcast = true;
|
|
199
240
|
const resp = (await post(token, "chat.postMessage", body)) as { ts?: string };
|
|
200
241
|
return resp.ts ?? "";
|
|
201
242
|
}
|
|
@@ -238,6 +279,18 @@ export async function deleteScheduledMessage(
|
|
|
238
279
|
});
|
|
239
280
|
}
|
|
240
281
|
|
|
282
|
+
export async function getPermalink(
|
|
283
|
+
token: string,
|
|
284
|
+
channel: string,
|
|
285
|
+
messageTs: string,
|
|
286
|
+
): Promise<string> {
|
|
287
|
+
const resp = (await get(token, "chat.getPermalink", {
|
|
288
|
+
channel,
|
|
289
|
+
message_ts: messageTs,
|
|
290
|
+
})) as { permalink?: string };
|
|
291
|
+
return resp.permalink ?? "";
|
|
292
|
+
}
|
|
293
|
+
|
|
241
294
|
export async function editMessage(
|
|
242
295
|
token: string,
|
|
243
296
|
channel: string,
|
|
@@ -253,6 +306,14 @@ export async function editMessage(
|
|
|
253
306
|
return resp.ts ?? ts;
|
|
254
307
|
}
|
|
255
308
|
|
|
309
|
+
export async function deleteMessage(
|
|
310
|
+
token: string,
|
|
311
|
+
channel: string,
|
|
312
|
+
ts: string,
|
|
313
|
+
): Promise<void> {
|
|
314
|
+
await post(token, "chat.delete", { channel, ts });
|
|
315
|
+
}
|
|
316
|
+
|
|
256
317
|
export async function listConversations(token: string): Promise<Json> {
|
|
257
318
|
const allChannels: Json[] = [];
|
|
258
319
|
let cursor = "";
|
|
@@ -285,6 +346,42 @@ function normName(s: string): string {
|
|
|
285
346
|
return s.toLowerCase().replace(/[-_\s]/g, "");
|
|
286
347
|
}
|
|
287
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Resolve `@name` (or a raw U…/W… id) to a user ID. Used when DMing as a bot:
|
|
351
|
+
* the lookup runs on a *user* token (which has users:read), then the caller
|
|
352
|
+
* opens the DM with the bot token — so the bot never needs users:read.
|
|
353
|
+
*/
|
|
354
|
+
export async function resolveUserId(token: string, ref: string, cookie?: string): Promise<string> {
|
|
355
|
+
if (!ref.startsWith("@")) {
|
|
356
|
+
if (/^[UW][A-Za-z0-9]{6,}$/.test(ref)) return ref;
|
|
357
|
+
throw new Error(`Expected @user or a user ID, got: ${ref}`);
|
|
358
|
+
}
|
|
359
|
+
const nameNorm = normName(ref.slice(1));
|
|
360
|
+
const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
|
|
361
|
+
if (nameNorm === "you" || nameNorm === "me" || normName(selfInfo.user ?? "") === nameNorm) {
|
|
362
|
+
if (!selfInfo.user_id) throw new Error("auth.test did not return user_id");
|
|
363
|
+
return selfInfo.user_id;
|
|
364
|
+
}
|
|
365
|
+
let cursor = "";
|
|
366
|
+
while (true) {
|
|
367
|
+
const params: Record<string, string> = { limit: "200" };
|
|
368
|
+
if (cursor) params.cursor = cursor;
|
|
369
|
+
const resp = (await get(token, "users.list", params, cookie)) as {
|
|
370
|
+
members?: Array<{ id?: string; name?: string; real_name?: string; profile?: { display_name?: string } }>;
|
|
371
|
+
response_metadata?: { next_cursor?: string };
|
|
372
|
+
};
|
|
373
|
+
for (const u of resp.members ?? []) {
|
|
374
|
+
const n = normName(u.name ?? "");
|
|
375
|
+
const rn = normName(u.real_name ?? "");
|
|
376
|
+
const dn = normName(u.profile?.display_name ?? "");
|
|
377
|
+
if (n === nameNorm || rn === nameNorm || (dn && dn === nameNorm)) return u.id ?? "";
|
|
378
|
+
}
|
|
379
|
+
cursor = resp.response_metadata?.next_cursor ?? "";
|
|
380
|
+
if (!cursor) break;
|
|
381
|
+
}
|
|
382
|
+
throw new Error(`User not found: ${ref}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
288
385
|
/** Parse a Slack permalink, returning channel ID, optional message ts, and optional thread_ts.
|
|
289
386
|
* Supports both forms:
|
|
290
387
|
* https://app.slack.com/client/T.../C...[/p1700000000000100]
|
|
@@ -326,6 +423,21 @@ export async function resolveChannel(token: string, ref: string, cookie?: string
|
|
|
326
423
|
const nameNorm = normName(rawName);
|
|
327
424
|
|
|
328
425
|
if (isIm) {
|
|
426
|
+
// Use auth.test to check if @name refers to self (avoids users:read scope requirement).
|
|
427
|
+
// auth.test is always available; users.list requires users:read which xoxc- tokens lack.
|
|
428
|
+
const selfInfo = (await get(token, "auth.test", {}, cookie)) as { user_id?: string; user?: string };
|
|
429
|
+
const isSelf = nameNorm === "you" || nameNorm === "me" || normName(selfInfo.user ?? "") === nameNorm;
|
|
430
|
+
if (isSelf) {
|
|
431
|
+
const userId = selfInfo.user_id;
|
|
432
|
+
if (!userId) throw new Error("auth.test did not return user_id");
|
|
433
|
+
const dmResp = (await get(token, "conversations.list", { types: "im", limit: "200" }, cookie)) as {
|
|
434
|
+
channels?: Array<{ id?: string; user?: string }>;
|
|
435
|
+
};
|
|
436
|
+
const dm = (dmResp.channels ?? []).find((ch) => ch.user === userId);
|
|
437
|
+
if (dm?.id) return String(dm.id);
|
|
438
|
+
return openDm(token, userId);
|
|
439
|
+
}
|
|
440
|
+
|
|
329
441
|
// Find user ID first via users.list (batch), then locate existing DM.
|
|
330
442
|
let userId = "";
|
|
331
443
|
let userCursor = "";
|