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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slack-term",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/slack-term.git"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"vitest": "^4.1.4"
|
|
23
23
|
},
|
|
24
24
|
"bin": {
|
|
25
|
-
"slack": "./
|
|
25
|
+
"slack": "./ts/cli.ts"
|
|
26
26
|
},
|
|
27
27
|
"description": "Slack CLI — read activity/news/mentions, search, send messages with a confirm-hash safety gate.",
|
|
28
28
|
"engines": {
|
|
@@ -49,7 +49,10 @@
|
|
|
49
49
|
"@semantic-release/github": "^12"
|
|
50
50
|
},
|
|
51
51
|
"publishConfig": {
|
|
52
|
-
"access": "public"
|
|
52
|
+
"access": "public",
|
|
53
|
+
"bin": {
|
|
54
|
+
"slack": "./dist/cli.js"
|
|
55
|
+
}
|
|
53
56
|
},
|
|
54
57
|
"scripts": {
|
|
55
58
|
"dev": "bun run ts/cli.ts",
|
package/ts/auth.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Interactive auth setup: slack auth login / slack login
|
|
2
2
|
import { createInterface, type Interface } from "node:readline/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { addProfile, listProfiles, setCookie, saveToEnvFile } from "./profiles.ts";
|
|
4
6
|
import { authTest } from "./slack.ts";
|
|
5
|
-
import { extractSessions } from "./slack-app.ts";
|
|
7
|
+
import { extractSessions, discoverChromeCookies, discoverFirefoxCookies } from "./slack-app.ts";
|
|
6
8
|
|
|
7
9
|
const USER_SCOPES = [
|
|
8
10
|
"search:read",
|
|
@@ -50,7 +52,8 @@ async function ask(rl: Interface, q: string): Promise<string> {
|
|
|
50
52
|
return (await rl.question(q)).trim();
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
55
|
+
|
|
56
|
+
async function saveToken(rl: Interface | null, token: string, nameOverride?: string, cookie?: string): Promise<string> {
|
|
54
57
|
console.error("Verifying token...");
|
|
55
58
|
const info = await authTest(token);
|
|
56
59
|
const defaultName = slugify(info.team);
|
|
@@ -63,28 +66,67 @@ async function saveToken(rl: Interface | null, token: string, nameOverride?: str
|
|
|
63
66
|
} else {
|
|
64
67
|
name = defaultName;
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
|
|
70
|
+
// Non-interactive: always save to profiles.json (predictable, backward-compatible for scripting).
|
|
71
|
+
// Interactive: ask where to save.
|
|
72
|
+
let filePath: string | null = null; // null = profiles.json
|
|
73
|
+
if (rl) {
|
|
74
|
+
console.log("");
|
|
75
|
+
console.log("Where to save the token?");
|
|
76
|
+
console.log(" 1) ./.env.local (current directory) [default]");
|
|
77
|
+
console.log(" 2) ./.slack-term/.env.local (current directory, slack-specific)");
|
|
78
|
+
console.log(" 3) ~/.slack-term/.env.local (global, available everywhere)");
|
|
79
|
+
console.log(" 4) profiles.json (multi-workspace: slack auth use)");
|
|
69
80
|
console.log("");
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
81
|
+
const choice = await ask(rl, "Choice [1/2/3/4, Enter=1]: ");
|
|
82
|
+
if (choice === "2") filePath = join(process.cwd(), ".slack-term", ".env.local");
|
|
83
|
+
else if (choice === "3") filePath = join(homedir(), ".slack-term", ".env.local");
|
|
84
|
+
else if (choice === "4") filePath = null;
|
|
85
|
+
else filePath = join(process.cwd(), ".env.local"); // 1 or Enter
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (filePath === null) {
|
|
89
|
+
addProfile(name, { token, ...info, ...(cookie ? { cookie } : {}) });
|
|
90
|
+
console.log(`Saved workspace "${name}" to profiles.json: ${info.team} (${info.user})`);
|
|
91
|
+
if (process.env.SLACK_MCP_XOXP_TOKEN) {
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log("Warning: SLACK_MCP_XOXP_TOKEN is set — it conflicts with profiles.");
|
|
94
|
+
console.log(" Unset it: unset SLACK_MCP_XOXP_TOKEN (and remove from ~/.zshrc / ~/.bashrc)");
|
|
95
|
+
} else {
|
|
96
|
+
console.log(` Run: slack auth use -g ${name}`);
|
|
97
|
+
}
|
|
74
98
|
} else {
|
|
75
|
-
|
|
99
|
+
const updates: Record<string, string> = { SLACK_TOKEN: token };
|
|
100
|
+
if (cookie) updates.SLACK_COOKIE = cookie;
|
|
101
|
+
saveToEnvFile(filePath, updates);
|
|
102
|
+
console.log(`Saved token to ${filePath}`);
|
|
103
|
+
console.log(` SLACK_TOKEN will be picked up automatically in this directory tree.`);
|
|
76
104
|
}
|
|
77
105
|
return name;
|
|
78
106
|
}
|
|
79
107
|
|
|
80
108
|
/** Shared logic for importing sessions from the Slack desktop app. */
|
|
81
|
-
export async function importFromDesktop(): Promise<void> {
|
|
109
|
+
export async function importFromDesktop(rl?: Interface): Promise<void> {
|
|
82
110
|
console.error("Scanning Slack desktop app...");
|
|
83
111
|
const sessions = await extractSessions();
|
|
84
112
|
if (sessions.length === 0) {
|
|
85
113
|
console.error("No sessions found. Make sure Slack is installed and you have signed in at least once.");
|
|
86
114
|
process.exit(1);
|
|
87
115
|
}
|
|
116
|
+
|
|
117
|
+
// Single workspace + interactive: offer save-destination choice
|
|
118
|
+
if (sessions.length === 1 && rl) {
|
|
119
|
+
const s = sessions[0]!;
|
|
120
|
+
const teamLabel = s.teamName ?? s.teamId;
|
|
121
|
+
console.log(`Found workspace: ${teamLabel}${s.cookie ? " (+ xoxd cookie)" : ""}`);
|
|
122
|
+
await saveToken(rl, s.token, slugify(teamLabel), s.cookie);
|
|
123
|
+
console.log("");
|
|
124
|
+
console.log("Note: desktop app tokens (xoxc-) are internal Slack tokens.");
|
|
125
|
+
console.log("If API calls fail, replace with an xoxp- token: slack auth login");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Multiple workspaces or non-interactive: save all to profiles.json
|
|
88
130
|
for (const s of sessions) {
|
|
89
131
|
const teamLabel = s.teamName ?? s.teamId;
|
|
90
132
|
const name = slugify(teamLabel);
|
|
@@ -172,6 +214,219 @@ async function loginNewApp(rl: Interface, mode: "user" | "bot"): Promise<void> {
|
|
|
172
214
|
await saveToken(rl, token);
|
|
173
215
|
}
|
|
174
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Attach the xoxd session cookie from Chrome browser to an existing workspace profile.
|
|
219
|
+
*
|
|
220
|
+
* When run interactively, macOS will show a system dialog asking for the login password
|
|
221
|
+
* to grant access to the "Chrome Safe Storage" keychain item — click Allow.
|
|
222
|
+
*/
|
|
223
|
+
export async function cmdAuthChrome(opts: { workspace?: string } = {}): Promise<void> {
|
|
224
|
+
if (process.platform !== "darwin") {
|
|
225
|
+
console.error("Chrome cookie extraction is only supported on macOS.");
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const profiles = listProfiles();
|
|
230
|
+
if (profiles.length === 0) {
|
|
231
|
+
console.error("No workspaces configured. Run: slack auth login");
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let profileName: string;
|
|
236
|
+
if (opts.workspace) {
|
|
237
|
+
const found = profiles.find((p) => p.name === opts.workspace);
|
|
238
|
+
if (!found) {
|
|
239
|
+
console.error(`Workspace "${opts.workspace}" not found. Available: ${profiles.map((p) => p.name).join(", ")}`);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
profileName = opts.workspace;
|
|
243
|
+
} else if (profiles.length === 1) {
|
|
244
|
+
profileName = profiles[0]!.name;
|
|
245
|
+
} else {
|
|
246
|
+
// Multiple profiles — ask the user to pick
|
|
247
|
+
const current = profiles.find((p) => p.current);
|
|
248
|
+
if (current) {
|
|
249
|
+
profileName = current.name;
|
|
250
|
+
console.log(`Using active workspace: ${profileName}`);
|
|
251
|
+
} else {
|
|
252
|
+
console.log("Multiple workspaces found. Choose one:");
|
|
253
|
+
profiles.forEach((p, i) => console.log(` ${i + 1}) ${p.name} (${p.profile.team})`));
|
|
254
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
255
|
+
const choice = (await rl.question("Choice: ")).trim();
|
|
256
|
+
rl.close();
|
|
257
|
+
const idx = parseInt(choice, 10) - 1;
|
|
258
|
+
if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
|
|
259
|
+
console.error("Invalid choice.");
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
profileName = profiles[idx]!.name;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
console.log("Scanning Chrome profiles for Slack session...");
|
|
267
|
+
console.log("macOS may show a dialog asking for your login password — click Allow.");
|
|
268
|
+
|
|
269
|
+
let candidates: import("./slack-app.ts").ChromeCookieCandidate[];
|
|
270
|
+
let totalProfiles: number;
|
|
271
|
+
try {
|
|
272
|
+
({ candidates, totalProfiles } = discoverChromeCookies());
|
|
273
|
+
} catch (e: unknown) {
|
|
274
|
+
console.error(`Failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (candidates.length === 0) {
|
|
279
|
+
console.error(`No Slack session found in Chrome (scanned ${totalProfiles} profile${totalProfiles !== 1 ? "s" : ""}). Possible reasons:`);
|
|
280
|
+
console.error(" - You denied the keychain dialog (try running again and click Allow)");
|
|
281
|
+
console.error(" - Chrome is not installed or has no Slack session");
|
|
282
|
+
console.error(" - You're not logged in to Slack in Chrome");
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let cookie: string;
|
|
287
|
+
// Always prompt when multiple Chrome profiles exist, so users can confirm the right one.
|
|
288
|
+
if (candidates.length === 1 && totalProfiles <= 1) {
|
|
289
|
+
cookie = candidates[0]!.cookie;
|
|
290
|
+
console.log(`Found session in Chrome profile: ${candidates[0]!.profileName}`);
|
|
291
|
+
} else {
|
|
292
|
+
const label = candidates.length === 1
|
|
293
|
+
? `Found 1 Slack session across ${totalProfiles} Chrome profiles:`
|
|
294
|
+
: `Found ${candidates.length} Slack sessions across ${totalProfiles} Chrome profiles:`;
|
|
295
|
+
console.log(label);
|
|
296
|
+
candidates.forEach((c, i) => console.log(` ${i + 1}) ${c.profileName} [${c.profileDir}]`));
|
|
297
|
+
console.log("");
|
|
298
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
299
|
+
const defaultChoice = candidates.length === 1 ? " [Enter=1]" : "";
|
|
300
|
+
const choice = (await rl.question(`Choice [1-${candidates.length}]${defaultChoice}: `)).trim();
|
|
301
|
+
rl.close();
|
|
302
|
+
const idx = choice === "" && candidates.length === 1 ? 0 : parseInt(choice, 10) - 1;
|
|
303
|
+
if (isNaN(idx) || idx < 0 || idx >= candidates.length) {
|
|
304
|
+
console.error("Invalid choice.");
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
cookie = candidates[idx]!.cookie;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
setCookie(profileName, cookie);
|
|
311
|
+
console.log(`Saved xoxd cookie to workspace "${profileName}".`);
|
|
312
|
+
console.log(`RTM WebSocket mode is now available: slack tail @you`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Attach the xoxd session cookie from Firefox browser to an existing workspace profile.
|
|
317
|
+
* Firefox stores cookies in plaintext — no keychain access needed.
|
|
318
|
+
*/
|
|
319
|
+
export async function cmdAuthFirefox(opts: { workspace?: string } = {}): Promise<void> {
|
|
320
|
+
const profiles = listProfiles();
|
|
321
|
+
if (profiles.length === 0) {
|
|
322
|
+
console.error("No workspaces configured. Run: slack auth token");
|
|
323
|
+
process.exit(1);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let profileName: string;
|
|
327
|
+
if (opts.workspace) {
|
|
328
|
+
const found = profiles.find((p) => p.name === opts.workspace);
|
|
329
|
+
if (!found) {
|
|
330
|
+
console.error(`Workspace "${opts.workspace}" not found. Available: ${profiles.map((p) => p.name).join(", ")}`);
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
profileName = opts.workspace;
|
|
334
|
+
} else if (profiles.length === 1) {
|
|
335
|
+
profileName = profiles[0]!.name;
|
|
336
|
+
} else {
|
|
337
|
+
const current = profiles.find((p) => p.current);
|
|
338
|
+
if (current) {
|
|
339
|
+
profileName = current.name;
|
|
340
|
+
console.log(`Using active workspace: ${profileName}`);
|
|
341
|
+
} else {
|
|
342
|
+
console.log("Multiple workspaces found. Choose one:");
|
|
343
|
+
profiles.forEach((p, i) => console.log(` ${i + 1}) ${p.name} (${p.profile.team})`));
|
|
344
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
345
|
+
const choice = (await rl.question("Choice: ")).trim();
|
|
346
|
+
rl.close();
|
|
347
|
+
const idx = parseInt(choice, 10) - 1;
|
|
348
|
+
if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
|
|
349
|
+
console.error("Invalid choice.");
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
profileName = profiles[idx]!.name;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
console.log("Scanning Firefox profiles for Slack session...");
|
|
357
|
+
const candidates = discoverFirefoxCookies();
|
|
358
|
+
|
|
359
|
+
if (candidates.length === 0) {
|
|
360
|
+
console.error("No Slack session found in Firefox. Possible reasons:");
|
|
361
|
+
console.error(" - Firefox is not installed");
|
|
362
|
+
console.error(" - You are not logged in to Slack in Firefox");
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let cookie: string;
|
|
367
|
+
if (candidates.length === 1) {
|
|
368
|
+
cookie = candidates[0]!.cookie;
|
|
369
|
+
console.log(`Found session in Firefox profile: ${candidates[0]!.profileName}`);
|
|
370
|
+
} else {
|
|
371
|
+
console.log("Multiple Firefox profiles have a Slack session. Choose one:");
|
|
372
|
+
candidates.forEach((c, i) => console.log(` ${i + 1}) ${c.profileName} [${c.profileDir}]`));
|
|
373
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
374
|
+
const choice = (await rl.question("Choice: ")).trim();
|
|
375
|
+
rl.close();
|
|
376
|
+
const idx = parseInt(choice, 10) - 1;
|
|
377
|
+
if (isNaN(idx) || idx < 0 || idx >= candidates.length) {
|
|
378
|
+
console.error("Invalid choice.");
|
|
379
|
+
process.exit(1);
|
|
380
|
+
}
|
|
381
|
+
cookie = candidates[idx]!.cookie;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
setCookie(profileName, cookie);
|
|
385
|
+
console.log(`Saved xoxd cookie to workspace "${profileName}".`);
|
|
386
|
+
console.log(`RTM WebSocket mode is now available: slack tail @you`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Paste an existing xoxp-/xoxb- token (non-interactive or TTY). */
|
|
390
|
+
export async function cmdAuthToken(opts: { token?: string; name?: string } = {}): Promise<void> {
|
|
391
|
+
if (opts.token) {
|
|
392
|
+
await saveToken(null, opts.token, opts.name);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (!process.stdin.isTTY) {
|
|
396
|
+
const chunks: Buffer[] = [];
|
|
397
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
398
|
+
const token = Buffer.concat(chunks).toString("utf8").trim();
|
|
399
|
+
if (!token) { console.error("No token provided on stdin."); process.exit(1); }
|
|
400
|
+
await saveToken(null, token, opts.name);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
404
|
+
try {
|
|
405
|
+
await loginExisting(rl);
|
|
406
|
+
} finally {
|
|
407
|
+
rl.close();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Guided Slack app creation wizard (user or bot token). */
|
|
412
|
+
export async function cmdAuthApp(opts: { bot?: boolean } = {}): Promise<void> {
|
|
413
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
414
|
+
try {
|
|
415
|
+
if (opts.bot !== undefined) {
|
|
416
|
+
await loginNewApp(rl, opts.bot ? "bot" : "user");
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
console.log("Which token type?");
|
|
420
|
+
console.log(" 1) User token (xoxp-) -full access including search [default]");
|
|
421
|
+
console.log(" 2) Bot token (xoxb-) -search and news unavailable");
|
|
422
|
+
console.log("");
|
|
423
|
+
const choice = await ask(rl, "Choice [1/2, Enter=1]: ");
|
|
424
|
+
await loginNewApp(rl, choice === "2" ? "bot" : "user");
|
|
425
|
+
} finally {
|
|
426
|
+
rl.close();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
175
430
|
export async function cmdAuthLogin(opts: { token?: string; name?: string } = {}): Promise<void> {
|
|
176
431
|
// Show existing profiles if any
|
|
177
432
|
const existing = listProfiles();
|
|
@@ -225,7 +480,7 @@ export async function cmdAuthLogin(opts: { token?: string; name?: string } = {})
|
|
|
225
480
|
console.log("");
|
|
226
481
|
|
|
227
482
|
if (choice === "1") {
|
|
228
|
-
await importFromDesktop();
|
|
483
|
+
await importFromDesktop(rl);
|
|
229
484
|
} else if (choice === "2") {
|
|
230
485
|
await loginExisting(rl);
|
|
231
486
|
} else if (choice === "3") {
|
package/ts/botdoctor.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Bot messaging diagnostics.
|
|
2
|
+
//
|
|
3
|
+
// A bot can *send* a DM with chat:write + im:write, but for an escalation DM to
|
|
4
|
+
// be useful the user must also be able to *reply* and the bot must be able to
|
|
5
|
+
// *read* that reply (im:history / im:read), and the app's Messages Tab must
|
|
6
|
+
// allow user messages. Slack exposes granted scopes via the X-OAuth-Scopes
|
|
7
|
+
// header (see slack.ts authScopes); the Messages-Tab toggle is not readable via
|
|
8
|
+
// the API, so we surface it as a manual step whenever messaging looks incomplete.
|
|
9
|
+
|
|
10
|
+
import { authScopes, botsInfo } from "./slack.ts";
|
|
11
|
+
|
|
12
|
+
// Scopes required to DM a user as the bot.
|
|
13
|
+
export const DM_SEND_SCOPES = ["chat:write", "im:write"];
|
|
14
|
+
// Scopes required to read the user's reply to that DM.
|
|
15
|
+
export const DM_REPLY_SCOPES = ["im:history", "im:read"];
|
|
16
|
+
|
|
17
|
+
export type BotMessagingDiagnosis = {
|
|
18
|
+
ok: boolean; // can both send and receive replies
|
|
19
|
+
canSend: boolean;
|
|
20
|
+
canReceiveReplies: boolean;
|
|
21
|
+
botUserId: string;
|
|
22
|
+
appId: string;
|
|
23
|
+
team: string;
|
|
24
|
+
grantedScopes: string[];
|
|
25
|
+
missingSendScopes: string[];
|
|
26
|
+
missingReplyScopes: string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export async function diagnoseBotMessaging(token: string): Promise<BotMessagingDiagnosis> {
|
|
30
|
+
const { userId, botId, team, scopes } = await authScopes(token);
|
|
31
|
+
let appId = "";
|
|
32
|
+
if (botId) {
|
|
33
|
+
// app_id is best-effort: bots.info needs no extra scope, but never let a
|
|
34
|
+
// lookup failure block the diagnosis itself.
|
|
35
|
+
try {
|
|
36
|
+
appId = (await botsInfo(token, botId)).appId;
|
|
37
|
+
} catch {
|
|
38
|
+
appId = "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const has = (s: string): boolean => scopes.includes(s);
|
|
42
|
+
const missingSendScopes = DM_SEND_SCOPES.filter((s) => !has(s));
|
|
43
|
+
const missingReplyScopes = DM_REPLY_SCOPES.filter((s) => !has(s));
|
|
44
|
+
return {
|
|
45
|
+
canSend: missingSendScopes.length === 0,
|
|
46
|
+
canReceiveReplies: missingReplyScopes.length === 0,
|
|
47
|
+
ok: missingSendScopes.length === 0 && missingReplyScopes.length === 0,
|
|
48
|
+
botUserId: userId,
|
|
49
|
+
appId,
|
|
50
|
+
team,
|
|
51
|
+
grantedScopes: scopes,
|
|
52
|
+
missingSendScopes,
|
|
53
|
+
missingReplyScopes,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function detailLines(d: BotMessagingDiagnosis): string[] {
|
|
58
|
+
return [
|
|
59
|
+
``,
|
|
60
|
+
` Bot user: ${d.botUserId || "(unknown)"}`,
|
|
61
|
+
` App ID: ${d.appId || "(unknown — bots.info unavailable)"}`,
|
|
62
|
+
` Granted scopes: ${d.grantedScopes.join(", ") || "(none reported)"}`,
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Human-facing guidance. `compact` drops the trailing identity/scope dump used
|
|
67
|
+
// by `slack doctor`. Note `ok` only means scopes are complete — the Messages Tab
|
|
68
|
+
// toggle ("Allow users to send messages") is NOT readable via the API, so we
|
|
69
|
+
// always surface it as a manual check rather than over-claiming readiness.
|
|
70
|
+
export function formatDiagnosis(d: BotMessagingDiagnosis, compact = false): string[] {
|
|
71
|
+
const appUrl = d.appId ? `https://api.slack.com/apps/${d.appId}` : "https://api.slack.com/apps";
|
|
72
|
+
|
|
73
|
+
if (d.ok) {
|
|
74
|
+
const lines = [
|
|
75
|
+
`✓ Bot DM scopes are complete (send + read replies).`,
|
|
76
|
+
` Two-way replies also require the Messages Tab, which is not API-detectable.`,
|
|
77
|
+
` If recipients see "Sending messages to this app is turned off", enable it:`,
|
|
78
|
+
` ${appUrl} → App Home → "Messages Tab" ON + "Allow users to send messages".`,
|
|
79
|
+
];
|
|
80
|
+
return compact ? lines : [...lines, ...detailLines(d)];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const lines: string[] = [];
|
|
84
|
+
if (!d.canSend) {
|
|
85
|
+
lines.push(`⚠ This bot CANNOT send DMs — missing scope(s): ${d.missingSendScopes.join(", ")}.`);
|
|
86
|
+
} else {
|
|
87
|
+
lines.push(`⚠ This bot can send DMs, but the user's replies will NOT be readable.`);
|
|
88
|
+
}
|
|
89
|
+
lines.push(` Make escalation DMs two-way:`);
|
|
90
|
+
lines.push(` 1. ${appUrl} → App Home → enable "Messages Tab" and check`);
|
|
91
|
+
lines.push(` "Allow users to send Slash commands and messages".`);
|
|
92
|
+
lines.push(` 2. OAuth & Permissions → add Bot Token Scopes: ${[...d.missingSendScopes, ...d.missingReplyScopes].join(", ")}`);
|
|
93
|
+
lines.push(` 3. Reinstall the app to apply the new scopes.`);
|
|
94
|
+
|
|
95
|
+
return compact ? lines : [...lines, ...detailLines(d)];
|
|
96
|
+
}
|