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/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
- // Resolution order:
9
- // 1. --workspace=<name> flag or SLACK_WORKSPACE env var
10
- // 2. Local lockfile (.slack-cli/workspace in cwd)
11
- // 3. Global lockfile (~/.slack-cli/workspace)
12
- // 4. No profiles fall back to SLACK_MCP_XOXP_TOKEN
13
- // 5. Otherwise throw (no silent defaults)
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
- const envToken = process.env.SLACK_MCP_XOXP_TOKEN;
121
-
122
- // Conflict: env token and profiles are mutually exclusive — prefer profiles
123
- if (envToken && names.length > 0) {
124
- if (!(globalThis as Record<string, unknown>).__slackEnvWarnShown) {
125
- (globalThis as Record<string, unknown>).__slackEnvWarnShown = true;
126
- console.error(
127
- "Warning: SLACK_MCP_XOXP_TOKEN is set but workspace profiles exist - using profiles.\n" +
128
- " Run: unset SLACK_MCP_XOXP_TOKEN (and remove from ~/.zshrc or ~/.bashrc)",
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
- // Explicit per-command selection
136
- const selected = workspaceFlag ?? process.env.SLACK_WORKSPACE;
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
- // No profiles and no env token
164
- if (names.length === 0) {
165
- throw new Error("No profiles configured. Run: slack auth login");
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
- `Workspace not selected (${names.join(", ")} available).\n` +
171
- ` Select locally: slack workspace use <name> (writes .slack-cli/workspace)\n` +
172
- ` Select globally: slack workspace use -g <name> (writes ~/.slack-cli/workspace)`,
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
- // Env-only mode has no stored cookie
183
- if (envToken) return process.env.SLACK_MCP_XOXD_COOKIE;
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
- const selected = workspaceFlag ?? process.env.SLACK_WORKSPACE;
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
- return `${stamp} @${handle}: ${body}`;
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 = "";