slack-term 1.32.2 → 1.32.4

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.32.2",
3
+ "version": "1.32.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/cli.ts CHANGED
@@ -8,6 +8,7 @@ import { join } from "node:path";
8
8
 
9
9
  import yargs, { type Options } from "yargs";
10
10
  import { hideBin } from "yargs/helpers";
11
+ import { guardUrlBoundaries } from "./urlGuard.ts";
11
12
  import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
12
13
  import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
13
14
  import { cmdAuthLogin, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp } from "./auth.ts";
@@ -1160,6 +1161,7 @@ function sumCounts(m: Map<string, number>): number {
1160
1161
 
1161
1162
  // --- edit ---
1162
1163
  interface EditArgs {
1164
+ allowUrlAdjacent?: boolean;
1163
1165
  target: string;
1164
1166
  newText: string;
1165
1167
  code?: string;
@@ -1168,7 +1170,62 @@ interface EditArgs {
1168
1170
  // Session cookie (xoxd) for `token` — required for an xoxc- desktop session
1169
1171
  // token to be accepted by the public Slack API at all.
1170
1172
  cookie?: string;
1173
+ asBot?: boolean;
1174
+ /** The USER token, kept for mention resolution when the edit itself runs as
1175
+ * the bot. A bot token has no users:read, and an unresolved @handle degrades
1176
+ * to plain text SILENTLY — which is the precise failure `edit` exists to
1177
+ * correct, so re-introducing it here would be circular. Same split as
1178
+ * `send --as-bot`. */
1179
+ mentionToken?: string;
1180
+ mentionCookie?: string;
1181
+ }
1182
+ /** The bot token, plus the bot↔user DM when the target names an `@handle`.
1183
+ *
1184
+ * Both halves are load-bearing. A bot token has no `users:read`, so resolving
1185
+ * `@bob` with it does not merely pick the wrong channel — it fails outright with
1186
+ * `missing_scope`, before the confirm gate is ever printed. And even where it
1187
+ * could resolve, a BOT-authored DM lives in the bot's conversation with that
1188
+ * person, not in the user's: looking the handle up as the user and opening the
1189
+ * DM as the bot is the only combination that names the message being corrected.
1190
+ * Same split `send --as-bot` makes.
1191
+ *
1192
+ * Found by the cross-vendor review of this change, which also named why the
1193
+ * first round of tests missed it: they all targeted a permalink, and
1194
+ * `resolveChannel` parses those locally without touching the API. */
1195
+ async function asBotTarget(
1196
+ target: string,
1197
+ userToken: string,
1198
+ userCookie: string | undefined,
1199
+ /** Already known from `--channel-id`. Passed in so the DM lookup is SKIPPED
1200
+ * rather than performed and discarded: `conversations.open` is a side effect,
1201
+ * and opening a DM the caller did not ask for is not undone by ignoring the
1202
+ * result. */
1203
+ knownChannelId?: string,
1204
+ ): Promise<{ botToken: string; channelId?: string }> {
1205
+ const botToken = resolveBotToken();
1206
+ if (!botToken) {
1207
+ console.error(
1208
+ "Error: --as-bot needs a bot token, but no xoxb- token was found.\n" +
1209
+ " Set SLACK_BOT_TOKEN=xoxb-... in ~/.config/slack-cli/.env (or your shell).",
1210
+ );
1211
+ process.exit(1);
1212
+ }
1213
+ if (knownChannelId) return { botToken };
1214
+ const { ref } = splitRefTs(target);
1215
+ if (!ref.startsWith("@")) return { botToken };
1216
+ try {
1217
+ const userId = await resolveUserId(userToken, ref, userCookie);
1218
+ return { botToken, channelId: await openDm(botToken, userId) };
1219
+ } catch (e: unknown) {
1220
+ console.error(
1221
+ `Error: could not resolve ${stripTerminalControls(ref)} to the bot's DM.\n` +
1222
+ ` ${e instanceof Error ? e.message : String(e)}\n` +
1223
+ ` Tip: pass the message permalink, or --channel-id <D…>, to skip the lookup.`,
1224
+ );
1225
+ process.exit(1);
1226
+ }
1171
1227
  }
1228
+
1172
1229
  async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1173
1230
  const getSelf = selfLookup(token, args.cookie);
1174
1231
  const { ref, ts } = splitRefTs(args.target);
@@ -1192,10 +1249,13 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1192
1249
  const originalText = typeof original.text === "string" ? original.text : "";
1193
1250
 
1194
1251
  // Convert @handle → <@USERID> before hashing/editing (unresolved stay as text).
1252
+ const mentionCookie = args.mentionCookie ?? args.cookie;
1195
1253
  const newText = args.mentions
1196
- ? await encodeMentions(token, args.newText, channelId, args.cookie ? { cookie: args.cookie } : {})
1254
+ ? await encodeMentions(args.mentionToken ?? token, args.newText, channelId, mentionCookie ? { cookie: mentionCookie } : {})
1197
1255
  : args.newText;
1198
1256
 
1257
+ guardUrlBoundaries(newText, args.allowUrlAdjacent);
1258
+
1199
1259
  // Identity is part of the hash for the same reason it is on `send`: a code
1200
1260
  // minted while previewing as one identity must not confirm the write as
1201
1261
  // another (Slack only lets you edit your own messages, so a silent profile
@@ -1205,7 +1265,7 @@ async function cmdEdit(token: string, args: EditArgs): Promise<void> {
1205
1265
  if (args.code !== code) {
1206
1266
  requireCode(args.code, code, [
1207
1267
  `--- Editing as -------------------------------`,
1208
- fromLine(self),
1268
+ fromLine(self, { asBot: args.asBot }),
1209
1269
  `--- Original message -------------------------`,
1210
1270
  ...originalText.split("\n").map((l) => ` ${l}`),
1211
1271
  `--- Replacing with ---------------------------`,
@@ -1224,6 +1284,7 @@ interface DeleteArgs {
1224
1284
  code?: string;
1225
1285
  channelId?: string;
1226
1286
  cookie?: string;
1287
+ asBot?: boolean;
1227
1288
  }
1228
1289
  async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
1229
1290
  const getSelf = selfLookup(token, args.cookie);
@@ -1255,7 +1316,7 @@ async function cmdDelete(token: string, args: DeleteArgs): Promise<void> {
1255
1316
  const dest = await destLabel(token, channelId, ref, args.cookie);
1256
1317
  requireCode(args.code, code, [
1257
1318
  `--- Deleting message -------------------------`,
1258
- fromLine(self),
1319
+ fromLine(self, { asBot: args.asBot }),
1259
1320
  ` → ${dest} at ${slackTsToIso(ts)}`,
1260
1321
  ...originalText.split("\n").map((l) => ` ${l}`),
1261
1322
  `---------------------------------------------`,
@@ -1301,6 +1362,7 @@ async function cmdReact(token: string, args: ReactArgs): Promise<void> {
1301
1362
 
1302
1363
  // --- send ---
1303
1364
  interface SendArgs {
1365
+ allowUrlAdjacent?: boolean;
1304
1366
  target: string;
1305
1367
  message: string;
1306
1368
  code?: string;
@@ -1386,6 +1448,7 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
1386
1448
  // show the real line breaks so "how many lines is this?" is answered by
1387
1449
  // looking rather than by guessing.
1388
1450
  const rawMessage = unescapeArg(args.message);
1451
+ guardUrlBoundaries(rawMessage, args.allowUrlAdjacent);
1389
1452
  let message = rawMessage;
1390
1453
  let mentionReport: MentionEncodeResult | null = null;
1391
1454
  if (args.mentions) {
@@ -1663,6 +1726,7 @@ const ASK_MAX_CONSECUTIVE_ERRORS = 8;
1663
1726
 
1664
1727
 
1665
1728
  interface AskArgs {
1729
+ allowUrlAdjacent?: boolean;
1666
1730
  target: string;
1667
1731
  question: string;
1668
1732
  choices: string[];
@@ -2162,6 +2226,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
2162
2226
  // INTO a thread is thread-scoped for the same reason.
2163
2227
  const threadOnly = !channelId.startsWith("D") || !!threadTs;
2164
2228
  const message = askBuildText(question, body, reactable, overflow, threadOnly);
2229
+ guardUrlBoundaries(message, args.allowUrlAdjacent);
2165
2230
 
2166
2231
  // Preview the destination's last message, exactly as `send` does — the gate's
2167
2232
  // job is to make you look at where this is going before it goes. Fail-soft:
@@ -3564,13 +3629,14 @@ async function main(): Promise<void> {
3564
3629
  .positional("target", { type: "string", demandOption: true, describe: "#chan, @user, #chan:thread_ts, or permalink (a message permalink replies in its thread)" })
3565
3630
  .positional("message", { type: "string", demandOption: true })
3566
3631
  .option("code", { type: "string", describe: "Safety hash to confirm send" })
3632
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
3567
3633
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
3568
3634
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
3569
3635
  .option("as-bot", { type: "boolean", default: false, describe: "Send via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient and can be two-way" })
3570
3636
  .option("broadcast", { type: "boolean", default: false, describe: "Also send to channel: broadcast a threaded reply back to the channel (Slack's \"Also send to #channel\" checkbox). Only effective with a thread target." })
3571
3637
  .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real <@USERID> mentions (on by default; resolves via users.list, then channel members for Slack Connect guests). Unresolved handles stay as plain text. The confirm preview shows the converted message before sending. Disable with --no-mentions for literal @text." }),
3572
3638
  async (argv) => {
3573
- const args: SendArgs = { target: argv.target!, message: argv.message! };
3639
+ const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] };
3574
3640
  if (argv.code) args.code = argv.code;
3575
3641
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
3576
3642
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -3642,6 +3708,7 @@ async function main(): Promise<void> {
3642
3708
  .positional("question", { type: "string", describe: "The question. Must @tag whoever may answer (@alice), or the whole channel (@here / @channel / @everyone) — only their answer counts. In a 1:1 DM the other party counts automatically." })
3643
3709
  .positional("choices", { type: "string", array: true, describe: "Up to 10 choices, seeded as 1️⃣..🔟 reactions. Beyond 10 they are listed but answerable only by text. With none, the question asks for a free-text reply." })
3644
3710
  .option("code", { type: "string", describe: "Safety hash to confirm the ask" })
3711
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
3645
3712
  .option("body", { type: "string", describe: "Extra context shown under the question" })
3646
3713
  .option("wait", { type: "boolean", default: false, describe: "Block until answered; print ONLY the answer on stdout. Exit 0 = answered, 2 = nobody replied, 3 = transport failure, 4 = somebody replied but picked none of the choices (stdout empty — do not act on it)." })
3647
3714
  .option("waitFor", { type: "string", describe: "Collect the answer to a question already posted: pass its permalink. Nothing is posted. Same stdout/exit contract as --wait; --timeout 0 checks once and exits 2 if still open." })
@@ -3702,6 +3769,7 @@ async function main(): Promise<void> {
3702
3769
  process.exit(ASK_EXIT_ERROR);
3703
3770
  }
3704
3771
  const args: AskArgs = {
3772
+ allowUrlAdjacent: argv["allow-url-adjacent"],
3705
3773
  target: argv.target!,
3706
3774
  question,
3707
3775
  choices: ((argv.choices as string[] | undefined) ?? []).map((s) => String(s).trim()).filter(Boolean),
@@ -4020,16 +4088,41 @@ async function main(): Promise<void> {
4020
4088
  .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
4021
4089
  .positional("newText", { type: "string", demandOption: true })
4022
4090
  .option("code", { type: "string", describe: "Safety hash to confirm edit" })
4091
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4023
4092
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4024
- .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." }),
4093
+ .option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
4094
+ .option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
4025
4095
  async (argv) => {
4026
- const args: EditArgs = { target: argv.target!, newText: argv.newText! };
4096
+ const args: EditArgs = { target: argv.target!, newText: argv.newText!, allowUrlAdjacent: argv["allow-url-adjacent"] };
4027
4097
  if (argv.code) args.code = argv.code;
4028
4098
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
4029
4099
  if (argv.mentions !== false) args.mentions = true;
4030
- const cookie = ck(argv as W);
4031
- if (cookie) args.cookie = cookie;
4032
- await cmdEdit(tok(argv as W), args);
4100
+ let editToken: string;
4101
+ if (argv["as-bot"]) {
4102
+ // --as-bot is not a switch on chat.update. It selects a DIFFERENT
4103
+ // resolved token, and until now `edit` never wired one — so every
4104
+ // message the bot posted was uncorrectable, and the workaround people
4105
+ // reached for was RESENDING, which this repo's own etiquette forbids.
4106
+ // Bot identity is what makes a DM notify at all, so "uncorrectable"
4107
+ // applied to exactly the messages that matter most.
4108
+ const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
4109
+ editToken = resolved.botToken;
4110
+ if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
4111
+ args.asBot = true;
4112
+ // Mentions still resolve on the USER token: the bot has no users:read,
4113
+ // and a silently unresolved @handle in a CORRECTION is the same defect
4114
+ // twice.
4115
+ args.mentionToken = tok(argv as W);
4116
+ const mc = ck(argv as W);
4117
+ if (mc) args.mentionCookie = mc;
4118
+ } else {
4119
+ editToken = tok(argv as W);
4120
+ // Not set under --as-bot: a bot token needs no session cookie, and
4121
+ // attaching one is how a working call starts failing.
4122
+ const cookie = ck(argv as W);
4123
+ if (cookie) args.cookie = cookie;
4124
+ }
4125
+ await cmdEdit(editToken, args);
4033
4126
  },
4034
4127
  )
4035
4128
  .command(
@@ -4038,14 +4131,28 @@ async function main(): Promise<void> {
4038
4131
  (y) => y
4039
4132
  .positional("target", { type: "string", demandOption: true, describe: "#chan:ts, @user:ts, or permalink" })
4040
4133
  .option("code", { type: "string", describe: "Safety hash to confirm delete" })
4041
- .option("channel-id", { type: "string", describe: "Raw channel ID" }),
4134
+ .option("channel-id", { type: "string", describe: "Raw channel ID" })
4135
+ .option("as-bot", { type: "boolean", default: false, describe: "Delete as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED for a message the bot posted: chat.delete refuses another token's message exactly as chat.update does." }),
4042
4136
  async (argv) => {
4043
4137
  const args: DeleteArgs = { target: argv.target! };
4044
4138
  if (argv.code) args.code = argv.code;
4045
4139
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
4046
- const cookie = ck(argv as W);
4047
- if (cookie) args.cookie = cookie;
4048
- await cmdDelete(tok(argv as W), args);
4140
+ let deleteToken: string;
4141
+ if (argv["as-bot"]) {
4142
+ // The SECOND call site of one defect, not a second defect: `delete`
4143
+ // had the identical unconditional `tok(argv)`, and chat.delete refuses
4144
+ // another token's message exactly as chat.update does. Fixing one and
4145
+ // leaving the other is how the next person finds it the hard way.
4146
+ const resolved = await asBotTarget(args.target, tok(argv as W), ck(argv as W), args.channelId);
4147
+ deleteToken = resolved.botToken;
4148
+ if (resolved.channelId && !args.channelId) args.channelId = resolved.channelId;
4149
+ args.asBot = true;
4150
+ } else {
4151
+ deleteToken = tok(argv as W);
4152
+ const cookie = ck(argv as W);
4153
+ if (cookie) args.cookie = cookie;
4154
+ }
4155
+ await cmdDelete(deleteToken, args);
4049
4156
  },
4050
4157
  )
4051
4158
  .command(
package/ts/urlGuard.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** Bare Unicode URL text has an ambiguous boundary for Slack's autolinker.
2
+ * Explicit Slack links delimit the URL, including intentional Unicode paths.
3
+ * ASCII whitespace (especially newlines) is a safe boundary; Unicode spaces
4
+ * should be made explicit too. Do not rewrite the message or its newlines. */
5
+ export function adjacentUrls(text: string): string[] {
6
+ const tokens = text.matchAll(/<https?:\/\/[^\s<>|]+(?:\|[^<>\r\n]*)?>|https?:\/\/[^\t\n\v\f\r <]+/gi);
7
+ return [...tokens]
8
+ .map(([token]) => token)
9
+ .filter((token) => !token.startsWith("<") && (/[^\x00-\x7f]/.test(token) || token.includes(">")));
10
+ }
11
+
12
+ export function guardUrlBoundaries(text: string, allow = false, warn: (message: string) => void = console.error): void {
13
+ const urls = adjacentUrls(text);
14
+ if (!urls.length) return;
15
+ const message = `Ambiguous URL boundary: ${urls.map((url) => JSON.stringify(url)).join(", ")}. ` +
16
+ "Put the URL on its own line, or wrap it as <url> / <url|label>. " +
17
+ "Use --allow-url-adjacent to warn only.";
18
+ if (!allow) throw new Error(message);
19
+ warn(`Warning: ${message}`);
20
+ }