slack-term 1.34.0 → 1.36.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 +35 -3
- package/SKILL.md +27 -9
- package/dist/cli.js +184 -138
- package/package.json +1 -1
- package/ts/agent.ts +148 -0
- package/ts/ask.ts +14 -3
- package/ts/auth.ts +185 -16
- package/ts/cli.ts +216 -38
- package/ts/profiles.ts +11 -5
- package/ts/sentlog.ts +356 -0
- package/ts/slack-app.ts +180 -102
- package/ts/slack.ts +56 -5
package/ts/slack-app.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
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, copyFileSync,
|
|
5
|
+
import { readdirSync, readFileSync, existsSync, copyFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
6
6
|
import { homedir, tmpdir } from "node:os";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
|
-
import { pbkdf2Sync, createDecipheriv } from "node:crypto";
|
|
9
|
-
import { execSync } from "node:child_process";
|
|
8
|
+
import { pbkdf2Sync, createDecipheriv, createHash, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
10
10
|
|
|
11
11
|
export type SlackAppSession = {
|
|
12
12
|
token: string; // xoxc-...
|
|
@@ -16,23 +16,27 @@ export type SlackAppSession = {
|
|
|
16
16
|
url?: string;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
function
|
|
20
|
-
const home = homedir();
|
|
19
|
+
function leveldbPaths(): string[] {
|
|
20
|
+
const home = process.env.HOME ?? homedir();
|
|
21
21
|
if (process.platform === "darwin") {
|
|
22
|
-
return join(home, "Library", "Application Support", "Slack", "Local Storage", "leveldb");
|
|
22
|
+
return [join(home, "Library", "Application Support", "Slack", "Local Storage", "leveldb")];
|
|
23
23
|
}
|
|
24
24
|
if (process.platform === "linux") {
|
|
25
|
-
return
|
|
25
|
+
return [
|
|
26
|
+
join(home, ".config", "Slack", "Local Storage", "leveldb"),
|
|
27
|
+
join(home, "snap", "slack", "current", ".config", "Slack", "Local Storage", "leveldb"),
|
|
28
|
+
join(home, ".var", "app", "com.slack.Slack", "config", "Slack", "Local Storage", "leveldb"),
|
|
29
|
+
];
|
|
26
30
|
}
|
|
27
31
|
if (process.platform === "win32") {
|
|
28
32
|
const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
|
|
29
|
-
return join(appData, "Slack", "Local Storage", "leveldb");
|
|
33
|
+
return [join(appData, "Slack", "Local Storage", "leveldb")];
|
|
30
34
|
}
|
|
31
35
|
throw new Error(`Unsupported platform: ${process.platform}`);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
function cookiesDbPath(): string {
|
|
35
|
-
const home = homedir();
|
|
39
|
+
const home = process.env.HOME ?? homedir();
|
|
36
40
|
if (process.platform === "darwin") {
|
|
37
41
|
return join(home, "Library", "Application Support", "Slack", "Cookies");
|
|
38
42
|
}
|
|
@@ -99,11 +103,11 @@ export function extractXoxd(): string | undefined {
|
|
|
99
103
|
// Scan for "token":"xoxc-..." to get complete, clean tokens + workspace URL.
|
|
100
104
|
// 2. .ldb files (sorted tables): values are length-prefixed with binary framing
|
|
101
105
|
// bytes that can split the token mid-segment. Use gap-bridging as fallback.
|
|
102
|
-
export async function extractSessions(): Promise<SlackAppSession[]> {
|
|
103
|
-
const dbPath =
|
|
104
|
-
if (!
|
|
106
|
+
export async function extractSessions(leveldbOverride?: string): Promise<SlackAppSession[]> {
|
|
107
|
+
const dbPath = leveldbOverride ?? leveldbPaths().find(existsSync);
|
|
108
|
+
if (!dbPath) {
|
|
105
109
|
throw new Error(
|
|
106
|
-
`Slack desktop app LevelDB not found at:\n ${
|
|
110
|
+
`Slack desktop app LevelDB not found at:\n ${leveldbPaths().join("\n ")}\nIs Slack installed and opened at least once?`,
|
|
107
111
|
);
|
|
108
112
|
}
|
|
109
113
|
|
|
@@ -234,7 +238,7 @@ export async function extractSessions(): Promise<SlackAppSession[]> {
|
|
|
234
238
|
}
|
|
235
239
|
|
|
236
240
|
// Attach xoxd cookie to all sessions (shared — one Slack desktop app, one cookie jar)
|
|
237
|
-
const xoxd = extractXoxd();
|
|
241
|
+
const xoxd = leveldbOverride ? undefined : extractXoxd();
|
|
238
242
|
const result = [...sessions.values()];
|
|
239
243
|
if (xoxd) {
|
|
240
244
|
for (const s of result) s.cookie = xoxd;
|
|
@@ -243,26 +247,34 @@ export async function extractSessions(): Promise<SlackAppSession[]> {
|
|
|
243
247
|
}
|
|
244
248
|
|
|
245
249
|
/**
|
|
246
|
-
* Try both AES-128-CBC
|
|
250
|
+
* Try both AES-128-CBC layouts for a versioned Chromium cookie.
|
|
247
251
|
* Returns the decrypted string if it starts with "xoxd-", otherwise undefined.
|
|
248
252
|
*
|
|
249
253
|
* Variant A (older Chrome): IV = 16 spaces, ciphertext = enc[3:]
|
|
250
254
|
* Variant B (newer Chrome 127+): IV = enc[19:35], ciphertext = enc[35:]
|
|
251
255
|
*/
|
|
252
|
-
function decryptV10Cookie(enc: Buffer, aesKey: Buffer): string | undefined {
|
|
256
|
+
function decryptV10Cookie(enc: Buffer, aesKey: Buffer, hostKey: string, allowEmbedded = false): string | undefined {
|
|
253
257
|
const tryDecrypt = (iv: Buffer, ciphertext: Buffer): string | undefined => {
|
|
254
258
|
try {
|
|
255
259
|
const decipher = createDecipheriv("aes-128-cbc", aesKey, iv);
|
|
256
260
|
decipher.setAutoPadding(true);
|
|
257
|
-
const result = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
258
|
-
|
|
261
|
+
const result = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
262
|
+
if (result.subarray(0, 5).toString() === "xoxd-") return result.toString("utf8");
|
|
263
|
+
if (result.length >= 37) {
|
|
264
|
+
const hostHash = createHash("sha256").update(hostKey).digest();
|
|
265
|
+
if (timingSafeEqual(result.subarray(0, 32), hostHash)) {
|
|
266
|
+
const value = result.subarray(32).toString("utf8");
|
|
267
|
+
if (value.startsWith("xoxd-")) return value;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
259
271
|
} catch {
|
|
260
272
|
return undefined;
|
|
261
273
|
}
|
|
262
274
|
};
|
|
263
275
|
|
|
264
276
|
return tryDecrypt(Buffer.alloc(16, 32), enc.slice(3))
|
|
265
|
-
?? (enc.length >= 35 ? tryDecrypt(enc.slice(19, 35), enc.slice(35)) : undefined);
|
|
277
|
+
?? (allowEmbedded && enc.length >= 35 ? tryDecrypt(enc.slice(19, 35), enc.slice(35)) : undefined);
|
|
266
278
|
}
|
|
267
279
|
|
|
268
280
|
export type ChromeCookieCandidate = {
|
|
@@ -309,101 +321,168 @@ function chromeProfileName(userDataDir: string, profileDir: string): string {
|
|
|
309
321
|
}
|
|
310
322
|
}
|
|
311
323
|
|
|
312
|
-
/**
|
|
313
|
-
* Discover all Chrome browser profiles that have a Slack xoxd cookie (macOS only).
|
|
314
|
-
*
|
|
315
|
-
* Requires the Chrome Safe Storage key from the system keychain. When called from an
|
|
316
|
-
* interactive terminal, macOS will show a dialog asking for the login password.
|
|
317
|
-
* Throws on v20 (app-bound encryption). Returns [] if keychain is inaccessible.
|
|
318
|
-
*/
|
|
324
|
+
/** Discover Slack cookies in Chrome profiles on macOS and Linux. */
|
|
319
325
|
export type ChromeDiscoveryResult = {
|
|
320
326
|
candidates: ChromeCookieCandidate[];
|
|
321
|
-
totalProfiles: number;
|
|
327
|
+
totalProfiles: number;
|
|
322
328
|
};
|
|
323
329
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
330
|
+
function chromeUserDataDir(): string {
|
|
331
|
+
const home = process.env.HOME ?? homedir();
|
|
332
|
+
if (process.platform === "darwin") return join(home, "Library", "Application Support", "Google", "Chrome");
|
|
333
|
+
if (process.platform === "linux") return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "google-chrome");
|
|
334
|
+
return "";
|
|
335
|
+
}
|
|
329
336
|
|
|
330
|
-
|
|
337
|
+
function linuxChromePassword(): string | undefined {
|
|
331
338
|
try {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
339
|
+
const password = execFileSync("secret-tool", ["lookup", "application", "chrome"], {
|
|
340
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
341
|
+
}).trimEnd();
|
|
342
|
+
return password || undefined;
|
|
336
343
|
} catch {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function discoverChromeCookies(): ChromeDiscoveryResult {
|
|
349
|
+
if (process.platform !== "darwin" && process.platform !== "linux") {
|
|
337
350
|
return { candidates: [], totalProfiles: 0 };
|
|
338
351
|
}
|
|
339
|
-
|
|
352
|
+
const userDataDir = chromeUserDataDir();
|
|
353
|
+
if (!existsSync(userDataDir)) return { candidates: [], totalProfiles: 0 };
|
|
340
354
|
|
|
341
|
-
|
|
355
|
+
let macKey: Buffer | undefined;
|
|
356
|
+
if (process.platform === "darwin") {
|
|
357
|
+
let keychainPw: string;
|
|
358
|
+
try {
|
|
359
|
+
keychainPw = execSync(
|
|
360
|
+
`security find-generic-password -a Chrome -s "Chrome Safe Storage" -w`,
|
|
361
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
|
|
362
|
+
).trimEnd();
|
|
363
|
+
} catch {
|
|
364
|
+
return { candidates: [], totalProfiles: 0 };
|
|
365
|
+
}
|
|
366
|
+
if (!keychainPw) return { candidates: [], totalProfiles: 0 };
|
|
367
|
+
macKey = pbkdf2Sync(keychainPw, "saltysalt", 1003, 16, "sha1");
|
|
368
|
+
}
|
|
342
369
|
|
|
343
370
|
const profileDirs = ["Default", ...readdirSync(userDataDir).filter((d) => d.startsWith("Profile "))];
|
|
344
371
|
const candidates: ChromeCookieCandidate[] = [];
|
|
372
|
+
let linuxV11Key: Buffer | undefined;
|
|
373
|
+
let missingLinuxKey = false;
|
|
345
374
|
|
|
346
375
|
for (const profileDir of profileDirs) {
|
|
347
|
-
const dbPath = join(userDataDir, profileDir, "Cookies")
|
|
348
|
-
|
|
376
|
+
const dbPath = [join(userDataDir, profileDir, "Network", "Cookies"), join(userDataDir, profileDir, "Cookies")]
|
|
377
|
+
.find(existsSync);
|
|
378
|
+
if (!dbPath) continue;
|
|
349
379
|
|
|
350
|
-
const
|
|
380
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "slack-chrome-cookies-"));
|
|
381
|
+
const tmp = join(tmpDir, "Cookies");
|
|
351
382
|
try {
|
|
352
383
|
copyFileSync(dbPath, tmp);
|
|
384
|
+
if (existsSync(`${dbPath}-wal`)) copyFileSync(`${dbPath}-wal`, `${tmp}-wal`);
|
|
353
385
|
const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
|
|
354
386
|
const db = new Database(tmp, { readonly: true });
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
.
|
|
358
|
-
|
|
387
|
+
let row: { encrypted_value: Uint8Array; value: string; host_key: string } | null;
|
|
388
|
+
try {
|
|
389
|
+
row = db.prepare(
|
|
390
|
+
"SELECT encrypted_value, value, host_key FROM cookies WHERE name='d' AND (host_key='slack.com' OR host_key LIKE '%.slack.com') LIMIT 1",
|
|
391
|
+
).get() as typeof row;
|
|
392
|
+
} finally {
|
|
393
|
+
db.close();
|
|
394
|
+
}
|
|
359
395
|
if (!row) continue;
|
|
360
|
-
|
|
361
396
|
const enc = Buffer.from(row.encrypted_value);
|
|
362
|
-
const prefix = enc.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (!
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
397
|
+
const prefix = enc.subarray(0, 3).toString();
|
|
398
|
+
let cookie: string | undefined;
|
|
399
|
+
if (prefix === "v20") {
|
|
400
|
+
throw new Error("Chrome cookie uses unsupported v20 app-bound encryption.");
|
|
401
|
+
}
|
|
402
|
+
if (process.platform === "linux") {
|
|
403
|
+
if (!enc.length && row.value?.startsWith("xoxd-")) cookie = row.value;
|
|
404
|
+
else if (prefix === "v10") {
|
|
405
|
+
cookie = decryptV10Cookie(enc, pbkdf2Sync("peanuts", "saltysalt", 1, 16, "sha1"), row.host_key);
|
|
406
|
+
} else if (prefix === "v11") {
|
|
407
|
+
if (!linuxV11Key) {
|
|
408
|
+
const password = linuxChromePassword();
|
|
409
|
+
if (password) linuxV11Key = pbkdf2Sync(password, "saltysalt", 1, 16, "sha1");
|
|
410
|
+
}
|
|
411
|
+
if (linuxV11Key) cookie = decryptV10Cookie(enc, linuxV11Key, row.host_key);
|
|
412
|
+
else missingLinuxKey = true;
|
|
413
|
+
}
|
|
414
|
+
} else if (prefix === "v10" && macKey) {
|
|
415
|
+
cookie = decryptV10Cookie(enc, macKey, row.host_key, true);
|
|
416
|
+
} else if (prefix !== "v10") {
|
|
417
|
+
throw new Error("Unknown cookie encryption prefix.");
|
|
377
418
|
}
|
|
419
|
+
if (cookie) candidates.push({ profileDir, profileName: chromeProfileName(userDataDir, profileDir), cookie });
|
|
378
420
|
} catch (e: unknown) {
|
|
379
|
-
if (e instanceof Error && (e.message.includes("
|
|
380
|
-
//
|
|
421
|
+
if (e instanceof Error && (e.message.includes("unsupported v20") || e.message.includes("Unknown cookie"))) throw e;
|
|
422
|
+
// Skip unreadable profiles without exposing cookie or key material.
|
|
381
423
|
} finally {
|
|
382
|
-
|
|
424
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
383
425
|
}
|
|
384
426
|
}
|
|
427
|
+
if (missingLinuxKey && candidates.length === 0) {
|
|
428
|
+
throw new Error("Chrome v11 cookie key is unavailable. Install secret-tool and unlock the GNOME keyring, or use slack auth firefox.");
|
|
429
|
+
}
|
|
385
430
|
return { candidates, totalProfiles: profileDirs.length };
|
|
386
431
|
}
|
|
387
432
|
|
|
433
|
+
/** Read xoxc tokens from Chrome's Slack local storage and pair cookies from the same profile. */
|
|
434
|
+
export async function extractChromeSessions(): Promise<SlackAppSession[]> {
|
|
435
|
+
if (process.platform !== "linux") throw new Error("Chrome session import is currently supported on Linux only.");
|
|
436
|
+
const userDataDir = chromeUserDataDir();
|
|
437
|
+
if (!existsSync(userDataDir)) throw new Error("Chrome profile directory was not found.");
|
|
438
|
+
const cookies = new Map(discoverChromeCookies().candidates.map((c) => [c.profileDir, c.cookie]));
|
|
439
|
+
const sessions = new Map<string, SlackAppSession>();
|
|
440
|
+
const profileDirs = ["Default", ...readdirSync(userDataDir).filter((d) => d.startsWith("Profile "))];
|
|
441
|
+
for (const profileDir of profileDirs) {
|
|
442
|
+
const leveldb = join(userDataDir, profileDir, "Local Storage", "leveldb");
|
|
443
|
+
if (!existsSync(leveldb)) continue;
|
|
444
|
+
let found: SlackAppSession[];
|
|
445
|
+
try {
|
|
446
|
+
found = await extractSessions(leveldb);
|
|
447
|
+
} catch {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
for (const session of found) {
|
|
451
|
+
if (sessions.has(session.teamId)) {
|
|
452
|
+
throw new Error("More than one Chrome profile has a token for the same workspace. Select one browser profile first.");
|
|
453
|
+
}
|
|
454
|
+
const cookie = cookies.get(profileDir);
|
|
455
|
+
if (cookie) session.cookie = cookie;
|
|
456
|
+
sessions.set(session.teamId, session);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return [...sessions.values()];
|
|
460
|
+
}
|
|
461
|
+
|
|
388
462
|
export type FirefoxCookieCandidate = {
|
|
389
463
|
profileDir: string; // e.g. "abc123.default-release"
|
|
390
464
|
profileName: string; // display name from profiles.ini, or the dir name
|
|
391
465
|
cookie: string; // plaintext xoxd-... (Firefox stores cookies unencrypted)
|
|
392
466
|
};
|
|
393
467
|
|
|
394
|
-
function
|
|
395
|
-
const home = homedir();
|
|
396
|
-
if (process.platform === "darwin") return join(home, "Library", "Application Support", "Firefox", "Profiles");
|
|
397
|
-
if (process.platform === "linux") return
|
|
468
|
+
function firefoxProfilesDirs(): string[] {
|
|
469
|
+
const home = process.env.HOME ?? homedir();
|
|
470
|
+
if (process.platform === "darwin") return [join(home, "Library", "Application Support", "Firefox", "Profiles")];
|
|
471
|
+
if (process.platform === "linux") return [
|
|
472
|
+
join(home, ".mozilla", "firefox"),
|
|
473
|
+
join(home, "snap", "firefox", "common", ".mozilla", "firefox"),
|
|
474
|
+
join(home, ".var", "app", "org.mozilla.firefox", ".mozilla", "firefox"),
|
|
475
|
+
];
|
|
398
476
|
if (process.platform === "win32") {
|
|
399
|
-
return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Mozilla", "Firefox", "Profiles");
|
|
477
|
+
return [join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Mozilla", "Firefox", "Profiles")];
|
|
400
478
|
}
|
|
401
|
-
return
|
|
479
|
+
return [];
|
|
402
480
|
}
|
|
403
481
|
|
|
404
482
|
/** Read Firefox profiles.ini to map profile dir names to display names. */
|
|
405
483
|
function firefoxProfileNames(profilesDir: string): Record<string, string> {
|
|
406
|
-
const
|
|
484
|
+
const localIni = join(profilesDir, "profiles.ini");
|
|
485
|
+
const iniPath = existsSync(localIni) ? localIni : join(dirname(profilesDir), "profiles.ini");
|
|
407
486
|
const map: Record<string, string> = {};
|
|
408
487
|
if (!existsSync(iniPath)) return map;
|
|
409
488
|
try {
|
|
@@ -431,39 +510,38 @@ function firefoxProfileNames(profilesDir: string): Record<string, string> {
|
|
|
431
510
|
* Returns [] if Firefox is not installed or has no Slack session.
|
|
432
511
|
*/
|
|
433
512
|
export function discoverFirefoxCookies(): FirefoxCookieCandidate[] {
|
|
434
|
-
const profilesDir = firefoxProfilesDir();
|
|
435
|
-
if (!profilesDir || !existsSync(profilesDir)) return [];
|
|
436
|
-
|
|
437
|
-
const nameMap = firefoxProfileNames(profilesDir);
|
|
438
513
|
const candidates: FirefoxCookieCandidate[] = [];
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
profileDirs = readdirSync(profilesDir);
|
|
443
|
-
} catch {
|
|
444
|
-
return [];
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
for (const profileDir of profileDirs) {
|
|
448
|
-
const dbPath = join(profilesDir, profileDir, "cookies.sqlite");
|
|
449
|
-
if (!existsSync(dbPath)) continue;
|
|
450
|
-
|
|
451
|
-
const tmp = join(tmpdir(), `slack-firefox-cookies-${Date.now()}-${profileDir}.db`);
|
|
514
|
+
for (const profilesDir of firefoxProfilesDirs().filter(existsSync)) {
|
|
515
|
+
const nameMap = firefoxProfileNames(profilesDir);
|
|
516
|
+
let profileDirs: string[];
|
|
452
517
|
try {
|
|
453
|
-
|
|
454
|
-
const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
|
|
455
|
-
const db = new Database(tmp, { readonly: true });
|
|
456
|
-
const row = db
|
|
457
|
-
.prepare("SELECT value FROM moz_cookies WHERE name='d' AND host LIKE '%slack%' LIMIT 1")
|
|
458
|
-
.get() as { value: string } | null;
|
|
459
|
-
db.close();
|
|
460
|
-
if (!row?.value || !row.value.startsWith("xoxd-")) continue;
|
|
461
|
-
const profileName = nameMap[profileDir] ?? profileDir;
|
|
462
|
-
candidates.push({ profileDir, profileName, cookie: row.value });
|
|
518
|
+
profileDirs = readdirSync(profilesDir);
|
|
463
519
|
} catch {
|
|
464
|
-
|
|
465
|
-
}
|
|
466
|
-
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
for (const profileDir of profileDirs) {
|
|
524
|
+
const dbPath = join(profilesDir, profileDir, "cookies.sqlite");
|
|
525
|
+
if (!existsSync(dbPath)) continue;
|
|
526
|
+
|
|
527
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "slack-firefox-cookies-"));
|
|
528
|
+
const tmp = join(tmpDir, "cookies.sqlite");
|
|
529
|
+
try {
|
|
530
|
+
copyFileSync(dbPath, tmp);
|
|
531
|
+
const { default: Database } = require("bun:sqlite") as typeof import("bun:sqlite");
|
|
532
|
+
const db = new Database(tmp, { readonly: true });
|
|
533
|
+
const row = db
|
|
534
|
+
.prepare("SELECT value FROM moz_cookies WHERE name='d' AND (host='slack.com' OR host LIKE '%.slack.com') LIMIT 1")
|
|
535
|
+
.get() as { value: string } | null;
|
|
536
|
+
db.close();
|
|
537
|
+
if (!row?.value || !row.value.startsWith("xoxd-")) continue;
|
|
538
|
+
const profileName = nameMap[profileDir] ?? profileDir;
|
|
539
|
+
candidates.push({ profileDir, profileName, cookie: row.value });
|
|
540
|
+
} catch {
|
|
541
|
+
// skip this profile
|
|
542
|
+
} finally {
|
|
543
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
544
|
+
}
|
|
467
545
|
}
|
|
468
546
|
}
|
|
469
547
|
return candidates;
|
package/ts/slack.ts
CHANGED
|
@@ -47,7 +47,7 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
|
|
|
47
47
|
if (err === "invalid_auth" && token.startsWith("xoxc-") && !cookie) {
|
|
48
48
|
throw new Error(
|
|
49
49
|
`Desktop app token (xoxc-) needs its session cookie to be accepted by the public Slack API.\n` +
|
|
50
|
-
`Attach it: slack auth
|
|
50
|
+
`Attach it: slack auth firefox (Linux/macOS/Windows) or slack auth chrome (macOS/Linux)\n` +
|
|
51
51
|
`Or replace the token with an xoxp- user token:\n` +
|
|
52
52
|
` slack auth login`,
|
|
53
53
|
);
|
|
@@ -55,6 +55,9 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
|
|
|
55
55
|
// Bot tokens (xoxb-) can't act as a user: they lack user scopes (missing_scope) and can't
|
|
56
56
|
// open a DM with themselves (cannot_dm_bot, e.g. `tail @you`). Point at a user-token workspace.
|
|
57
57
|
if ((err === "missing_scope" || err === "cannot_dm_bot") && token.startsWith("xoxb-")) {
|
|
58
|
+
if (method.startsWith("agents.sessions.")) {
|
|
59
|
+
throw new Error(`Slack error on ${method}: ${err}. Agent sessions need a granular bot token with chat:write and app membership in the channel.`);
|
|
60
|
+
}
|
|
58
61
|
const why = err === "cannot_dm_bot"
|
|
59
62
|
? `you're authenticated as a bot, so "@you" is the bot itself and it can't DM itself`
|
|
60
63
|
: `this bot token lacks the user scope this action needs`;
|
|
@@ -101,7 +104,7 @@ async function callSession(token: string, method: string, init: RequestInit, coo
|
|
|
101
104
|
if ((err === "invalid_auth" || err === "not_authed") && !cookie) {
|
|
102
105
|
throw new Error(
|
|
103
106
|
`drafts.list also requires the xoxd session cookie.\n` +
|
|
104
|
-
`Attach it with: slack auth
|
|
107
|
+
`Attach it with: slack auth firefox (Linux/macOS/Windows) or slack auth chrome (macOS/Linux)`,
|
|
105
108
|
);
|
|
106
109
|
}
|
|
107
110
|
throw new Error(`Slack error on ${method}: ${err}`);
|
|
@@ -132,6 +135,28 @@ function post(token: string, method: string, body: Record<string, Json>, cookie?
|
|
|
132
135
|
}, cookie);
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
// Bound session writes so a stalled heartbeat cannot block final cleanup forever.
|
|
139
|
+
async function agentPost(token: string, method: string, body: Record<string, Json>): Promise<Json> {
|
|
140
|
+
if (!token.startsWith("xoxb-")) throw new Error("Agent sessions require a bot token (xoxb); user tokens are refused.");
|
|
141
|
+
return call(token, method, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
body: JSON.stringify(body),
|
|
145
|
+
signal: AbortSignal.timeout(10_000),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type AgentStatus = "processing" | "active" | "suspended" | "closed";
|
|
150
|
+
export type AgentThread = { channel_id: string; thread_ts: string };
|
|
151
|
+
|
|
152
|
+
export function setAgentStatus(token: string, thread: AgentThread, status: AgentStatus): Promise<Json> {
|
|
153
|
+
return agentPost(token, "agents.sessions.setStatus", { ...thread, status });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function renameAgentSession(token: string, thread: AgentThread, title: string): Promise<Json> {
|
|
157
|
+
return agentPost(token, "agents.sessions.rename", { ...thread, title });
|
|
158
|
+
}
|
|
159
|
+
|
|
135
160
|
export async function authTest(
|
|
136
161
|
token: string,
|
|
137
162
|
cookie?: string,
|
|
@@ -318,6 +343,10 @@ export async function send(
|
|
|
318
343
|
// `:one:` — so a message whose body has to be read back verbatim (`ask`,
|
|
319
344
|
// `poll`) must not carry them. Verified against a real workspace 2026-08-20.
|
|
320
345
|
plain?: boolean,
|
|
346
|
+
/** Message metadata (`event_type`/`event_payload`) — invisible to readers.
|
|
347
|
+
* Dropped and retried once if Slack rejects it: attribution is bookkeeping,
|
|
348
|
+
* and must never be the reason a message was not sent. */
|
|
349
|
+
metadata?: MessageMetadata,
|
|
321
350
|
): Promise<string> {
|
|
322
351
|
const body: Record<string, Json> = plain
|
|
323
352
|
? { channel, text }
|
|
@@ -326,10 +355,31 @@ export async function send(
|
|
|
326
355
|
// "Also send to channel": broadcast a threaded reply back to the channel.
|
|
327
356
|
// Only meaningful alongside thread_ts; Slack ignores it on top-level sends.
|
|
328
357
|
if (replyBroadcast && threadTs !== undefined) body.reply_broadcast = true;
|
|
329
|
-
const resp = (await
|
|
358
|
+
const resp = (await postWithMetadata(token, "chat.postMessage", body, metadata, cookie)) as { ts?: string };
|
|
330
359
|
return resp.ts ?? "";
|
|
331
360
|
}
|
|
332
361
|
|
|
362
|
+
export type MessageMetadata = { event_type: string; event_payload: Record<string, string | number> };
|
|
363
|
+
|
|
364
|
+
/** Slack error codes that mean "the metadata itself was refused". Only these
|
|
365
|
+
* trigger the retry without it — any other failure is the send's own and must
|
|
366
|
+
* surface, not be retried into a duplicate post. */
|
|
367
|
+
function isMetadataError(e: unknown): boolean {
|
|
368
|
+
return e instanceof Error && /: (invalid_metadata\w*|metadata_\w+)$/.test(e.message);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function postWithMetadata(
|
|
372
|
+
token: string, method: string, body: Record<string, Json>, metadata: MessageMetadata | undefined, cookie?: string,
|
|
373
|
+
): Promise<Json> {
|
|
374
|
+
if (!metadata) return post(token, method, body, cookie);
|
|
375
|
+
try {
|
|
376
|
+
return await post(token, method, { ...body, metadata: metadata as unknown as Json }, cookie);
|
|
377
|
+
} catch (e: unknown) {
|
|
378
|
+
if (!isMetadataError(e)) throw e;
|
|
379
|
+
return post(token, method, body, cookie);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
333
383
|
export async function scheduleMessage(
|
|
334
384
|
token: string,
|
|
335
385
|
channel: string,
|
|
@@ -392,11 +442,12 @@ export async function editMessage(
|
|
|
392
442
|
cookie?: string,
|
|
393
443
|
/** As in `send`: no blocks, so the stored text survives verbatim. */
|
|
394
444
|
plain?: boolean,
|
|
445
|
+
metadata?: MessageMetadata,
|
|
395
446
|
): Promise<string> {
|
|
396
|
-
const resp = (await
|
|
447
|
+
const resp = (await postWithMetadata(token, "chat.update", plain
|
|
397
448
|
? { channel, ts, text }
|
|
398
449
|
: { channel, ts, text, blocks: [{ type: "markdown", text }] },
|
|
399
|
-
cookie)) as { ts?: string };
|
|
450
|
+
metadata, cookie)) as { ts?: string };
|
|
400
451
|
return resp.ts ?? ts;
|
|
401
452
|
}
|
|
402
453
|
|