slack-term 1.41.0 → 1.41.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.41.0",
3
+ "version": "1.41.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
@@ -0,0 +1,68 @@
1
+ // Refuse to act as the USER when the caller clearly meant the BOT.
2
+ //
3
+ // SLACK_BOT_TOKEN is only ever used by `--as-bot`: its documented home is the
4
+ // global ~/.config/slack-cli/.env, where it sits for `slack ask` and friends,
5
+ // and it must not turn every post into a bot post. But a lane that EXPORTS it
6
+ // (or puts it in its project's .env) without SLACK_TOKEN is saying "I am the
7
+ // bot" — and without --as-bot every write still went out under the logged-in
8
+ // person's session, silently (reported 2026-10-26: a lane posted a pinlog as
9
+ // the user). In that case writes are refused until the caller picks a side.
10
+
11
+ /** Write commands, as `argv._` spells them (aliases included). Read-only
12
+ * commands are never refused — reading as the user impersonates no one. */
13
+ const WRITES = new Set([
14
+ "send", "ask", "poll", "vote", "edit", "delete", "react", "upload",
15
+ "schedule send", "schedule rm",
16
+ "pinlog new", "pinlog set", "pinlog note", "pinlog pin",
17
+ "todo set", "todo flag",
18
+ "lists create", "lists add", "lists set", "lists share",
19
+ "drafts new", "drafts save", "drafts edit", "drafts update", "drafts delete", "drafts rm",
20
+ "channel create", "channel new", "channel rename", "channel mv",
21
+ ]);
22
+
23
+ /** `argv._` holds what was TYPED, so top-level aliases must be folded first. */
24
+ const TOP_ALIAS: Record<string, string> = { channels: "channel", ch: "channel" };
25
+
26
+ export function isWriteCommand(path: (string | number)[]): boolean {
27
+ const [raw = "", b = ""] = path.map(String);
28
+ const a = TOP_ALIAS[raw] ?? raw;
29
+ return WRITES.has(a) || WRITES.has(`${a} ${b}`);
30
+ }
31
+
32
+ /** Read-only modes of a write command. `ask --ls` only reads the local
33
+ * sent-log and the questions' live state. (`ask --waitFor` and
34
+ * `poll --results` are NOT here: both can edit the posted message.) */
35
+ function isReadMode(path: (string | number)[], argv: Record<string, unknown>): boolean {
36
+ return String(path[0] ?? "") === "ask" && argv.ls === true;
37
+ }
38
+
39
+ export type BotIntent = {
40
+ /** Where SLACK_BOT_TOKEN (an xoxb-) came from: "shell", a dotenv path, or undefined when unset. */
41
+ botTokenSource: string | undefined;
42
+ /** dotenv paths that are the bot token's GLOBAL home — not a statement of intent. */
43
+ globalPaths: string[];
44
+ slackTokenSet: boolean;
45
+ /** The token this command would act with is itself the bot token (no profiles). */
46
+ activeTokenIsBot: boolean;
47
+ };
48
+
49
+ /** A refusal message, or null when the write may proceed. */
50
+ export function botIntentRefusal(
51
+ cmd: (string | number)[],
52
+ argv: Record<string, unknown>,
53
+ intent: BotIntent,
54
+ ): string | null {
55
+ const src = intent.botTokenSource;
56
+ if (!src || intent.globalPaths.includes(src)) return null;
57
+ if (intent.slackTokenSet || intent.activeTokenIsBot) return null;
58
+ if (!isWriteCommand(cmd) || isReadMode(cmd, argv) || argv["as-bot"] === true) return null;
59
+ const from = src === "shell" ? "exported in the environment" : `set in ${src}`;
60
+ const canBot = "as-bot" in argv;
61
+ return [
62
+ `Error: refusing to act as YOU — SLACK_BOT_TOKEN is ${from}, but \`${cmd.slice(0, 2).join(" ")}\` would use your user session, not the bot.`,
63
+ canBot
64
+ ? " As the app: add --as-bot"
65
+ : " This command has no --as-bot: it can only act as a user.",
66
+ " As yourself: set SLACK_TOKEN, or clear the bot token for this run (SLACK_BOT_TOKEN= slack ...)",
67
+ ].join("\n");
68
+ }
package/ts/cli.ts CHANGED
@@ -130,11 +130,21 @@ import {
130
130
  import { setCacheEnabled } from "./cache.ts";
131
131
  import { escapeArg, unescapeArg } from "./escapes.ts";
132
132
  import { attachmentLines, parseAttachments } from "./attachments.ts";
133
- import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
133
+ import { botIntentRefusal } from "./botIntent.ts";
134
+ import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult, type UnresolvedMention } from "./format.ts";
134
135
  import { quietHoursNotice } from "./quietHours.ts";
135
136
  import { installSyncConsole } from "./stdout.ts";
136
137
  import { LockBusyError, PINLOG_MARKER, acquireLock, composeHead, formatJst, headUpdatedAt, isPinlogHead, parsePinlogId, pinlogId, stripPinlogFooter } from "./pinlog.ts";
137
138
 
139
+ // Which dotenv file supplied each variable (absent = the shell's own env).
140
+ const dotenvSource = new Map<string, string>();
141
+ // Every value a GLOBAL dotenv holds, even when the shell already had the key.
142
+ // A child of `slack agent` (or any process the CLI spawned) inherits the
143
+ // global SLACK_BOT_TOKEN as a plain env var; matching it back to its global
144
+ // file keeps that inherited copy from reading as an explicit export.
145
+ const globalDotenvValue = new Map<string, { path: string; val: string }[]>();
146
+ const GLOBAL_DOTENVS = [join(homedir(), ".slack-cli", ".env.local"), join(homedir(), ".config/slack-cli", ".env")];
147
+
138
148
  function loadDotenv(path: string): void {
139
149
  if (!existsSync(path)) return;
140
150
  const text = readFileSync(path, "utf8");
@@ -148,7 +158,13 @@ function loadDotenv(path: string): void {
148
158
  if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
149
159
  val = val.slice(1, -1);
150
160
  }
151
- if (!(key in process.env)) process.env[key] = val;
161
+ if (GLOBAL_DOTENVS.includes(path)) {
162
+ globalDotenvValue.set(key, [...(globalDotenvValue.get(key) ?? []), { path, val }]);
163
+ }
164
+ if (!(key in process.env)) {
165
+ process.env[key] = val;
166
+ dotenvSource.set(key, path);
167
+ }
152
168
  }
153
169
  }
154
170
 
@@ -160,8 +176,7 @@ function ensureSlackCliDir(dir: string): void {
160
176
 
161
177
  function loadDotenvFiles(): void {
162
178
  // Global home config
163
- loadDotenv(join(homedir(), ".slack-cli", ".env.local"));
164
- loadDotenv(join(homedir(), ".config/slack-cli", ".env"));
179
+ for (const p of GLOBAL_DOTENVS) loadDotenv(p);
165
180
  // Local project overrides (highest priority — loaded last so they win)
166
181
  loadDotenv(join(process.cwd(), ".slack-cli", ".env.local"));
167
182
  loadDotenv(join(process.cwd(), ".env.local"));
@@ -2510,6 +2525,22 @@ const ASK_ANSWERABLE_SUBTYPES = new Set(["thread_broadcast"]);
2510
2525
  // ends it (`\b` is unreliable outside ASCII).
2511
2526
  const ASK_BROADCAST_RE = /(?<![A-Za-z0-9._@<-])@(here|channel|everyone)(?![A-Za-z0-9._-])/giu;
2512
2527
 
2528
+ /** `ask` cannot degrade an unresolved @tag to plain text the way `send` does:
2529
+ * the tags ARE who may answer, so a tag that named nobody would silently drop
2530
+ * an intended answerer (or, in a 1:1 DM, hand the decision to the counterpart
2531
+ * the caller meant to bypass). Refuse instead, before anything is posted. */
2532
+ function askRefuseUnresolved(unresolved: UnresolvedMention[]): void {
2533
+ if (!unresolved.length) return;
2534
+ const reasons = unresolved.map((u) => {
2535
+ const tag = stripTerminalControls(u.surface);
2536
+ if (u.reason === "unavailable") return `${tag}: could not fetch the user list or channel members (users:read missing or an API/connection error)`;
2537
+ if (u.reason === "ambiguous") return `${tag}: matches multiple people — use an explicit <@USERID> tag`;
2538
+ return `${tag}: matched no one`;
2539
+ });
2540
+ console.error(`Error: \`ask\` will not post with unresolved answerer tags.\n ${reasons.join("\n ")}`);
2541
+ process.exit(ASK_EXIT_ERROR);
2542
+ }
2543
+
2513
2544
  /** Rewrite `@here`/`@channel`/`@everyone` to the `<!here>` form Slack actually
2514
2545
  * broadcasts on, and report which were used. Run BEFORE user-mention encoding
2515
2546
  * so those tokens are never offered to the directory as if they were handles. */
@@ -3166,9 +3197,9 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3166
3197
  const body = bRep.text;
3167
3198
  const resolved = [...qRep.resolved, ...bRep.resolved];
3168
3199
  const unresolved = [...qRep.unresolved, ...bRep.unresolved];
3169
- // An unresolved tag is not just cosmetic here: it names nobody, so it grants
3170
- // nobody the right to answer. Say so before the rejection below.
3171
- for (const line of mentionWarnings(unresolved)) console.error(`⚠ ${stripTerminalControls(line)}`);
3200
+ // An unresolved tag names nobody, so it grants nobody the right to answer —
3201
+ // and posting anyway would silently drop someone the caller meant to ask.
3202
+ askRefuseUnresolved(unresolved);
3172
3203
 
3173
3204
  const broadcast = broadcastKinds.size > 0;
3174
3205
  // Tagging yourself grants nothing — your own reactions are the seeds.
@@ -3196,9 +3227,6 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3196
3227
  if (!audience.size) {
3197
3228
  console.error(
3198
3229
  `Error: \`ask\` will not post a question nobody is addressed to — no one is tagged.\n` +
3199
- (unresolved.length
3200
- ? ` (${unresolved.map((u) => stripTerminalControls(u.surface)).join(", ")} matched no one, so it names nobody.)\n`
3201
- : "") +
3202
3230
  ` Tag the person who should answer: slack ask '${args.target}' '@alice ${args.question}' ...\n` +
3203
3231
  ` Or open it to the whole channel: slack ask '${args.target}' '@here ${args.question}' ...\n` +
3204
3232
  ` (In a 1:1 DM the other person counts automatically.)`,
@@ -3490,7 +3518,9 @@ async function cmdAskEdit(token: string, args: {
3490
3518
  const old = t.parsed;
3491
3519
  const encode = async (raw: string): Promise<string> => {
3492
3520
  const enc = askEncodeBroadcasts(unescapeArg(raw));
3493
- return (await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie)).text;
3521
+ const rep = await encodeMentionsDetailed(args.mentionToken, enc.text, t.channelId, args.mentionCookie);
3522
+ askRefuseUnresolved(rep.unresolved);
3523
+ return rep.text;
3494
3524
  };
3495
3525
  const question = args.question !== undefined ? await encode(args.question) : old.question;
3496
3526
  const body = args.body !== undefined ? await encode(args.body) : old.body;
@@ -4965,7 +4995,22 @@ async function main(): Promise<void> {
4965
4995
  // `sent` reads only the local log — no token, no network.
4966
4996
  if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent") return;
4967
4997
  try {
4968
- resolveToken((argv as W).workspace);
4998
+ const active = resolveToken((argv as W).workspace);
4999
+ const bot = resolveBotToken();
5000
+ const refusal = botIntentRefusal(argv._ ?? [], argv as Record<string, unknown>, {
5001
+ botTokenSource: bot
5002
+ ? dotenvSource.get("SLACK_BOT_TOKEN")
5003
+ ?? globalDotenvValue.get("SLACK_BOT_TOKEN")?.find((g) => g.val === bot)?.path
5004
+ ?? "shell"
5005
+ : undefined,
5006
+ globalPaths: GLOBAL_DOTENVS,
5007
+ slackTokenSet: !!process.env.SLACK_TOKEN,
5008
+ activeTokenIsBot: !!bot && active === bot,
5009
+ });
5010
+ if (refusal) {
5011
+ console.error(refusal);
5012
+ process.exit(2);
5013
+ }
4969
5014
  } catch (e) {
4970
5015
  const msg = e instanceof Error ? e.message : String(e);
4971
5016
  if (msg.startsWith("No profiles configured")) {
@@ -5501,7 +5546,7 @@ async function main(): Promise<void> {
5501
5546
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
5502
5547
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
5503
5548
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
5504
- .option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient" }),
5549
+ .option("as-bot", { type: "boolean", default: false, describe: "Ask via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient. @tags and an @user target then resolve with the bot token too (needs users:read)." }),
5505
5550
  async (argv) => {
5506
5551
  // Verbs. A target is always #chan / @user / a permalink / a raw ID, so a
5507
5552
  // bare word here cannot be one — `slack ask void …` is unambiguous.
@@ -5550,7 +5595,9 @@ async function main(): Promise<void> {
5550
5595
  }
5551
5596
  const { token, cookie } = askTokenFor(!!argv["as-bot"]);
5552
5597
  const a: Parameters<typeof cmdAskEdit>[1] = {
5553
- link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: tok(argv as W),
5598
+ // Tags resolve as whoever posts the question — the bot under --as-bot,
5599
+ // so a dead or absent user token cannot break a bot edit.
5600
+ link: String(argv.edit), asBot: !!argv["as-bot"], mentionToken: token,
5554
5601
  allowUrlAdjacent: !!argv["allow-url-adjacent"],
5555
5602
  };
5556
5603
  if (newQuestion !== undefined) a.question = newQuestion;
@@ -5558,9 +5605,7 @@ async function main(): Promise<void> {
5558
5605
  if (argv.body !== undefined) a.body = String(argv.body);
5559
5606
  if (argv.lang) a.lang = String(argv.lang);
5560
5607
  if (argv.code) a.code = String(argv.code);
5561
- if (cookie) a.cookie = cookie;
5562
- const mc = ck(argv as W);
5563
- if (mc) a.mentionCookie = mc;
5608
+ if (cookie) { a.cookie = cookie; a.mentionCookie = cookie; }
5564
5609
  try {
5565
5610
  await cmdAskEdit(token, a);
5566
5611
  } catch (e: unknown) {
@@ -5727,11 +5772,6 @@ async function main(): Promise<void> {
5727
5772
  if (voidAfter) args.voidAfter = voidAfter;
5728
5773
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5729
5774
  if (argv["user-id"]) args.userId = argv["user-id"];
5730
- // Always resolve @tags with the user token (it has users:read), even
5731
- // when the question itself is posted by the bot.
5732
- args.mentionToken = tok(argv as W);
5733
- const mc = ck(argv as W);
5734
- if (mc) args.mentionCookie = mc;
5735
5775
 
5736
5776
  let askToken: string;
5737
5777
  if (argv["as-bot"]) {
@@ -5743,11 +5783,12 @@ async function main(): Promise<void> {
5743
5783
  );
5744
5784
  process.exit(ASK_EXIT_ERROR);
5745
5785
  }
5746
- // Resolve @user with the *user* token (has users:read), then let cmdAsk
5747
- // open the DM with the bot token — same split as `send --as-bot`.
5786
+ // Resolve the @user destination AND the answerer tags as the posting
5787
+ // bot: the bot's directory is the one the question is read against, and
5788
+ // a revoked user credential must not break an otherwise valid bot ask.
5748
5789
  if (!args.channelId && !args.userId && args.target.startsWith("@")) {
5749
5790
  try {
5750
- args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
5791
+ args.userId = await resolveUserId(botToken, args.target);
5751
5792
  } catch (e: unknown) {
5752
5793
  console.error(
5753
5794
  `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
@@ -5758,11 +5799,13 @@ async function main(): Promise<void> {
5758
5799
  }
5759
5800
  }
5760
5801
  askToken = botToken;
5802
+ args.mentionToken = botToken;
5761
5803
  args.asBot = true;
5762
5804
  } else {
5763
5805
  askToken = tok(argv as W);
5806
+ args.mentionToken = askToken;
5764
5807
  const sc = ck(argv as W);
5765
- if (sc) args.cookie = sc;
5808
+ if (sc) { args.cookie = sc; args.mentionCookie = sc; }
5766
5809
  }
5767
5810
  try {
5768
5811
  await cmdAsk(askToken, args);
@@ -5792,7 +5835,7 @@ async function main(): Promise<void> {
5792
5835
  .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
5793
5836
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
5794
5837
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
5795
- .option("as-bot", { type: "boolean", default: false, describe: "Post via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient — and so YOUR OWN vote counts, since the seeds are then the bot's" }),
5838
+ .option("as-bot", { type: "boolean", default: false, describe: "Post via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient — and so YOUR OWN vote counts, since the seeds are then the bot's. @tags and an @user target then resolve with the bot token too (needs users:read)." }),
5796
5839
  async (argv) => {
5797
5840
  const readLink = argv.close ? String(argv.close) : argv.results ? String(argv.results) : "";
5798
5841
  if (argv.results && argv.close) {
@@ -5862,18 +5905,14 @@ async function main(): Promise<void> {
5862
5905
  if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
5863
5906
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5864
5907
  if (argv["user-id"]) args.userId = argv["user-id"];
5865
- // Always resolve @tags with the user token (it has users:read), even
5866
- // when the poll itself is posted by the bot.
5867
- args.mentionToken = tok(argv as W);
5868
- const mc = ck(argv as W);
5869
- if (mc) args.mentionCookie = mc;
5870
5908
 
5871
5909
  let pollToken: string;
5872
5910
  if (argv["as-bot"]) {
5873
5911
  const botToken = botTokenOrExit();
5912
+ // Resolve the @user destination and @tags as the posting bot, same as `ask --as-bot`.
5874
5913
  if (!args.channelId && !args.userId && args.target.startsWith("@")) {
5875
5914
  try {
5876
- args.userId = await resolveUserId(tok(argv as W), args.target, ck(argv as W));
5915
+ args.userId = await resolveUserId(botToken, args.target);
5877
5916
  } catch (e: unknown) {
5878
5917
  console.error(
5879
5918
  `Error: could not resolve ${args.target} to a user for the bot DM.\n` +
@@ -5884,11 +5923,13 @@ async function main(): Promise<void> {
5884
5923
  }
5885
5924
  }
5886
5925
  pollToken = botToken;
5926
+ args.mentionToken = botToken;
5887
5927
  args.asBot = true;
5888
5928
  } else {
5889
5929
  pollToken = tok(argv as W);
5930
+ args.mentionToken = pollToken;
5890
5931
  const sc = ck(argv as W);
5891
- if (sc) args.cookie = sc;
5932
+ if (sc) { args.cookie = sc; args.mentionCookie = sc; }
5892
5933
  }
5893
5934
  try {
5894
5935
  await cmdPoll(pollToken, args);
package/ts/slack.ts CHANGED
@@ -852,8 +852,9 @@ function userNotFoundError(ref: string, found: UserSearch): Error {
852
852
 
853
853
  /**
854
854
  * Resolve `@name` (or a raw U…/W… id) to a user ID. Used when DMing as a bot:
855
- * the lookup runs on a *user* token (which has users:read), then the caller
856
- * opens the DM with the bot token — so the bot never needs users:read.
855
+ * the caller then opens the DM with the bot token. `send` runs the lookup on the
856
+ * *user* token; `ask`/`poll --as-bot` run it on the bot token (the bot scopes
857
+ * include users:read), so a dead user token cannot break a bot question.
857
858
  */
858
859
  export async function resolveUserId(token: string, ref: string, cookie?: string): Promise<string> {
859
860
  // `@U0123456789` — an id wearing the `@` that every other user target takes.