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/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, unlinkSync } from "node:fs";
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 leveldbPath(): string {
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 join(home, ".config", "Slack", "Local Storage", "leveldb");
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 = leveldbPath();
104
- if (!existsSync(dbPath)) {
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 ${dbPath}\nIs Slack installed and opened at least once?`,
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 variants used across Chromium versions to decrypt a v10 cookie.
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()]).toString("utf8");
258
- return result.startsWith("xoxd-") ? result : undefined;
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; // how many Chrome profile dirs were scanned
327
+ totalProfiles: number;
322
328
  };
323
329
 
324
- export function discoverChromeCookies(): ChromeDiscoveryResult {
325
- if (process.platform !== "darwin") return { candidates: [], totalProfiles: 0 };
326
-
327
- const userDataDir = join(homedir(), "Library", "Application Support", "Google", "Chrome");
328
- if (!existsSync(userDataDir)) return { candidates: [], totalProfiles: 0 };
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
- let keychainPw: string;
337
+ function linuxChromePassword(): string | undefined {
331
338
  try {
332
- keychainPw = execSync(
333
- `security find-generic-password -a Chrome -s "Chrome Safe Storage" -w`,
334
- { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
335
- ).trimEnd();
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
- if (!keychainPw) return { candidates: [], totalProfiles: 0 };
352
+ const userDataDir = chromeUserDataDir();
353
+ if (!existsSync(userDataDir)) return { candidates: [], totalProfiles: 0 };
340
354
 
341
- const aesKey = pbkdf2Sync(keychainPw, "saltysalt", 1003, 16, "sha1");
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
- if (!existsSync(dbPath)) continue;
376
+ const dbPath = [join(userDataDir, profileDir, "Network", "Cookies"), join(userDataDir, profileDir, "Cookies")]
377
+ .find(existsSync);
378
+ if (!dbPath) continue;
349
379
 
350
- const tmp = join(tmpdir(), `slack-chrome-cookies-${Date.now()}-${profileDir}.db`);
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
- const row = db
356
- .prepare("SELECT encrypted_value FROM cookies WHERE name='d' AND host_key LIKE '%slack%'")
357
- .get() as { encrypted_value: Uint8Array } | null;
358
- db.close();
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.slice(0, 3).toString();
363
- if (prefix === "v10") {
364
- // Try both AES-128-CBC variants used by different Chromium versions:
365
- // 1. Standard (older Chrome): IV = 16 spaces, ciphertext = enc[3:]
366
- // 2. Embedded (newer Chrome/Electron): IV = enc[19:35], ciphertext = enc[35:]
367
- const cookie = decryptV10Cookie(enc, aesKey);
368
- if (!cookie) continue; // neither format produced a valid xoxd- value
369
- candidates.push({ profileDir, profileName: chromeProfileName(userDataDir, profileDir), cookie });
370
- } else if (prefix === "v20") {
371
- throw new Error(
372
- `Chrome cookie uses v20 (app-bound AES-256-GCM) which is not supported yet. ` +
373
- `Prefix found: ${enc.slice(0, 4).toString("hex")}`,
374
- );
375
- } else {
376
- throw new Error(`Unknown cookie encryption prefix: ${enc.slice(0, 4).toString("hex")}`);
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("app-bound") || e.message.includes("Unknown cookie"))) throw e;
380
- // Otherwise skip this profile
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
- try { unlinkSync(tmp); } catch { /* ignore */ }
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 firefoxProfilesDir(): string {
395
- const home = homedir();
396
- if (process.platform === "darwin") return join(home, "Library", "Application Support", "Firefox", "Profiles");
397
- if (process.platform === "linux") return join(home, ".mozilla", "firefox");
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 iniPath = join(dirname(profilesDir), "profiles.ini");
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
- let profileDirs: string[];
441
- try {
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
- copyFileSync(dbPath, tmp);
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
- // skip this profile
465
- } finally {
466
- try { unlinkSync(tmp); } catch { /* ignore */ }
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 chrome (macOS) or slack auth firefox\n` +
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 chrome (macOS) or slack auth firefox`,
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 post(token, "chat.postMessage", body, cookie)) as { ts?: string };
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 post(token, "chat.update", plain
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