slack-term 1.39.1 → 1.40.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slack-term",
3
- "version": "1.39.1",
3
+ "version": "1.40.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
@@ -0,0 +1,71 @@
1
+ // Message attachments — forwarded/shared messages and legacy bot attachments.
2
+ //
3
+ // A message forwarded with Slack's "Forward" (or shared via its link) arrives
4
+ // as an ordinary message whose own `text` is only the comment typed with it;
5
+ // the forwarded message itself lives in `attachments[]`, flagged `is_share`
6
+ // (or `is_msg_unfurl` for a pasted message link), carrying the original
7
+ // author, channel, ts, text and permalink (`from_url`). Older shares put the
8
+ // text in `message_blocks[].message` instead, with `fallback` as the summary.
9
+ // Plain link unfurls (`original_url` set) are previews of a web page, not
10
+ // content anyone wrote, so the text view leaves them out.
11
+
12
+ import type { Json } from "./slack.ts";
13
+
14
+ const rec = (v: Json | undefined): Record<string, Json> =>
15
+ v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, Json>) : {};
16
+ const str = (v: Json | undefined): string => (typeof v === "string" ? v : "");
17
+
18
+ export type Attachment = {
19
+ /** A forwarded/shared Slack message (as opposed to a bot or link attachment). */
20
+ shared: boolean;
21
+ /** A web-page link preview — omitted from the text view. */
22
+ unfurl: boolean;
23
+ author_id: string | null;
24
+ author_name: string | null;
25
+ channel_id: string | null;
26
+ ts: string | null;
27
+ title: string | null;
28
+ text: string;
29
+ permalink: string | null;
30
+ };
31
+
32
+ // Older shares carry the text only inside message_blocks[].message.text.
33
+ function messageBlocksText(a: Record<string, Json>): string {
34
+ const blocks = Array.isArray(a.message_blocks) ? a.message_blocks : [];
35
+ return blocks.map((b) => str(rec(rec(b).message).text)).filter(Boolean).join("\n");
36
+ }
37
+
38
+ export function parseAttachments(m: Record<string, Json>): Attachment[] {
39
+ const list = Array.isArray(m.attachments) ? m.attachments : [];
40
+ return list.map(rec).map((a) => {
41
+ const shared = a.is_share === true || a.is_msg_unfurl === true || Array.isArray(a.message_blocks);
42
+ const mb = rec(rec((Array.isArray(a.message_blocks) ? a.message_blocks : [])[0]));
43
+ return {
44
+ shared,
45
+ unfurl: !shared && !!str(a.original_url),
46
+ author_id: str(a.author_id) || str(rec(mb.message).user) || null,
47
+ author_name: str(a.author_name) || null,
48
+ channel_id: str(a.channel_id) || str(mb.channel) || null,
49
+ ts: str(a.ts) || str(mb.ts) || null,
50
+ title: str(a.title) || null,
51
+ text: str(a.text) || messageBlocksText(a) || str(a.pretext) || str(a.fallback),
52
+ permalink: str(a.from_url) || str(a.title_link) || str(a.original_url) || null,
53
+ };
54
+ });
55
+ }
56
+
57
+ /** The text-view lines for a message's attachments (each starts with "\n "). */
58
+ export function attachmentLines(atts: Attachment[], tsLabel: (ts: string) => string): string {
59
+ return atts
60
+ .filter((a) => !a.unfurl && (a.text || a.title))
61
+ .map((a) => {
62
+ const who = a.author_name ?? a.author_id;
63
+ const head = a.shared
64
+ ? `↪ shared message${who ? ` from @${who}` : ""}${a.channel_id ? ` in <#${a.channel_id}>` : ""}${a.ts ? ` · ${tsLabel(a.ts)}` : ""}`
65
+ : `▸ ${a.title ?? "attachment"}`;
66
+ const lines = a.text ? a.text.replace(/\n+$/, "").split("\n").map((l) => `\n │ ${l}`).join("") : "";
67
+ const link = a.permalink ? `\n ${a.permalink}` : "";
68
+ return `\n ${head}${lines}${link}`;
69
+ })
70
+ .join("");
71
+ }
package/ts/cli.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  authTestSession,
51
51
  conversationInfoSession,
52
52
  createChannel,
53
+ renameChannel,
53
54
  inviteToChannel,
54
55
  createDraft,
55
56
  deleteDraft,
@@ -95,7 +96,9 @@ import {
95
96
  userName,
96
97
  getPath,
97
98
  type Json,
99
+ savedList,
98
100
  } from "./slack.ts";
101
+ import { isDone, messagePermalink, parseSavedItems, sortTodos, TODO_FILTERS, todoStatus, type TodoFilter, type TodoItem } from "./slackTodos.ts";
99
102
  import {
100
103
  buildTodoQuery,
101
104
  loadTodoConfig,
@@ -114,8 +117,10 @@ import {
114
117
  } from "./todo.ts";
115
118
  import { setCacheEnabled } from "./cache.ts";
116
119
  import { escapeArg, unescapeArg } from "./escapes.ts";
120
+ import { attachmentLines, parseAttachments } from "./attachments.ts";
117
121
  import { dayLabel, encodeMentions, encodeMentionsDetailed, findUntaggedMentions, formatYmdHm, maskSecrets, mentionWarnings, resolveDateMarkup, resolveMentions, type MentionEncodeResult } from "./format.ts";
118
122
  import { quietHoursNotice } from "./quietHours.ts";
123
+ import { installSyncConsole } from "./stdout.ts";
119
124
  import { LockBusyError, PINLOG_MARKER, acquireLock, composeHead, formatJst, headUpdatedAt, isPinlogHead, parsePinlogId, pinlogId, stripPinlogFooter } from "./pinlog.ts";
120
125
 
121
126
  function loadDotenv(path: string): void {
@@ -273,7 +278,12 @@ async function formatMsgLine(
273
278
  // top-level history can see there is more to read without opening each ts.
274
279
  const replyCount = Number(m.reply_count ?? 0);
275
280
  const threadMark = replyCount > 0 ? ` [+${replyCount} ${replyCount === 1 ? "reply" : "replies"}]` : "";
276
- const tail = (reactions ? `\n ${reactions}` : "") + attachTail;
281
+ // Forwarded/shared messages live in attachments[], not in the message text.
282
+ const atts = await Promise.all(parseAttachments(m).map(async (a) => ({
283
+ ...a,
284
+ text: a.text ? resolveDateMarkup(await resolveMentions(token, a.text, cache, cookie)) : "",
285
+ })));
286
+ const tail = (reactions ? `\n ${reactions}` : "") + attachTail + attachmentLines(atts, slackTsToIso);
277
287
  return `${stamp} ${who}: ${body}${threadMark}${tail}`;
278
288
  }
279
289
 
@@ -302,6 +312,7 @@ function slimFiles(m: Record<string, Json>): Record<string, Json>[] {
302
312
  // users.list) so callers can resolve mentions without scraping history by hand.
303
313
  function slimMsg(m: Record<string, Json>): Record<string, Json> {
304
314
  const files = slimFiles(m);
315
+ const attachments = parseAttachments(m);
305
316
  return {
306
317
  ts: m.ts ?? null,
307
318
  user: m.user ?? null,
@@ -329,6 +340,10 @@ function slimMsg(m: Record<string, Json>): Record<string, Json> {
329
340
  // Present only when the message carries attachments, so scripts can reliably
330
341
  // detect files without the key adding noise to every plain-text line.
331
342
  ...(files.length > 0 ? { files } : {}),
343
+ // Forwarded/shared messages (and bot attachments): the original author,
344
+ // channel, ts, text and permalink. The message's own `text` is only the
345
+ // comment typed with the share.
346
+ ...(attachments.length > 0 ? { attachments } : {}),
332
347
  };
333
348
  }
334
349
 
@@ -747,6 +762,8 @@ function blocksToText(blocks: Json[]): string {
747
762
  if (node && typeof node === "object") {
748
763
  const obj = node as Record<string, Json>;
749
764
  if (typeof obj.text === "string") { parts.push(obj.text); return; }
765
+ // A bare `link` element carries only its url — which IS its visible text.
766
+ if (obj.type === "link" && typeof obj.url === "string") { parts.push(obj.url); return; }
750
767
  if (Array.isArray(obj.elements)) walk(obj.elements);
751
768
  else if (Array.isArray(obj.content)) walk(obj.content);
752
769
  }
@@ -829,6 +846,65 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
829
846
  }
830
847
  }
831
848
 
849
+ // --- todos (Slack's To-dos tab) ---
850
+ // Read-only. Pages through saved.list up to `limit` items, then shows each
851
+ // to-do with its due date, where it lives, and the message it points at.
852
+ async function cmdTodos(
853
+ token: string,
854
+ cookie: string | undefined,
855
+ opts: { filter: TodoFilter; limit: number; json: boolean },
856
+ ): Promise<void> {
857
+ const items: TodoItem[] = [];
858
+ let cursor: string | undefined;
859
+ do {
860
+ const resp = asRecord((await savedList(token, { filter: opts.filter, limit: Math.min(opts.limit, 100), cursor }, cookie)) as Json);
861
+ items.push(...parseSavedItems(resp as Json));
862
+ const next = asRecord(resp.response_metadata).next_cursor;
863
+ cursor = typeof next === "string" && next ? next : undefined;
864
+ } while (cursor && items.length < opts.limit);
865
+ const shown = sortTodos(items).slice(0, opts.limit);
866
+
867
+ if (shown.length === 0) {
868
+ // --json is JSONL on stdout: an empty list is no lines, the note goes to stderr.
869
+ (opts.json ? console.error : console.log)(`No ${opts.filter === "todo" ? "to-dos" : `${opts.filter} items`}.`);
870
+ return;
871
+ }
872
+
873
+ let teamUrl = "";
874
+ let myUserId = "";
875
+ try { ({ url: teamUrl, userId: myUserId } = await authScopes(token, cookie)); } catch { /* links degrade to channel:ts */ }
876
+ const resolveChLabel = await buildChLabelResolver(token, cookie, myUserId);
877
+ const now = Date.now() / 1000;
878
+
879
+ for (const t of shown) {
880
+ // The message a to-do points at. conversations.replies on its own ts
881
+ // returns it whether or not it has a thread; a thread reply comes back
882
+ // inside its thread, so match by ts. Fail-soft: an unreadable message
883
+ // still lists, with its link.
884
+ let msg: Record<string, Json> | undefined;
885
+ if (t.channel && t.ts) {
886
+ try {
887
+ const r = asRecord((await replies(token, t.channel, t.ts, 1, cookie)) as Json);
888
+ msg = asArray(r.messages).map(asRecord).find((m) => m.ts === t.ts);
889
+ } catch { /* not readable */ }
890
+ }
891
+ const link = t.channel && t.ts
892
+ ? (teamUrl ? messagePermalink(teamUrl, t.channel, t.ts) : `${t.channel}:${t.ts}`)
893
+ : "";
894
+ if (opts.json) {
895
+ console.log(JSON.stringify({ ...t, done: isDone(t), permalink: link, user: msg?.user ?? null, text: msg?.text ?? null }));
896
+ continue;
897
+ }
898
+ const where = t.channel ? await resolveChLabel(t.channel) : `(${t.itemType || "item"})`;
899
+ const author = typeof msg?.user === "string" ? `@${await userName(token, msg.user, cookie)}: ` : "";
900
+ const raw = typeof msg?.text === "string" ? msg.text.split("\n")[0] ?? "" : "(message not readable)";
901
+ const head = raw.length > 100 ? `${raw.slice(0, 100)}…` : raw;
902
+ console.log(`${todoStatus(t, now, formatYmdHm)} ${stripTerminalControls(where)}`);
903
+ console.log(` ${stripTerminalControls(author + head)}`);
904
+ if (link) console.log(` ${link}`);
905
+ }
906
+ }
907
+
832
908
  // --- drafts get ---
833
909
  async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
834
910
  const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
@@ -892,6 +968,7 @@ const SCOPE_FOR_METHOD: Record<string, string> = {
892
968
  "slackLists.items.update": "lists:write",
893
969
  "slackLists.items.list": "lists:read",
894
970
  "slackLists.access.set": "lists:write",
971
+ "conversations.rename": "channels:manage (or groups:write for a private channel)",
895
972
  "conversations.replies": "channels:history (or groups:history)",
896
973
  "conversations.history": "channels:history (or groups:history)",
897
974
  };
@@ -1975,6 +2052,31 @@ async function isSelfDmChannel(
1975
2052
  }
1976
2053
  }
1977
2054
 
2055
+ /** Everyone a thread reply notifies: the thread's starter and its repliers.
2056
+ * From the fetched messages, plus the parent's `reply_users` (Slack's own
2057
+ * participant list), which still names a replier past the fetched window. */
2058
+ function threadParticipants(msgs: Record<string, Json>[]): Set<string> {
2059
+ const out = new Set<string>();
2060
+ for (const m of msgs) {
2061
+ if (typeof m.user === "string") out.add(m.user);
2062
+ if (typeof m.parent_user_id === "string") out.add(m.parent_user_id);
2063
+ for (const u of asArray(m.reply_users)) if (typeof u === "string") out.add(u);
2064
+ }
2065
+ return out;
2066
+ }
2067
+
2068
+ /** The other member of a 1:1 DM, or undefined for anything else (or an
2069
+ * unreadable channel). Only asked for `D…` ids, so channel sends pay nothing. */
2070
+ async function dmCounterparty(token: string, channelId: string, cookie?: string): Promise<string | undefined> {
2071
+ if (!channelId.startsWith("D")) return undefined;
2072
+ try {
2073
+ const ch = asRecord(asRecord((await conversationInfo(token, channelId, cookie)) as Json).channel);
2074
+ return ch.is_im === true && typeof ch.user === "string" ? ch.user : undefined;
2075
+ } catch {
2076
+ return undefined;
2077
+ }
2078
+ }
2079
+
1978
2080
  async function cmdSend(token: string, args: SendArgs): Promise<void> {
1979
2081
  const { ref, threadTs } = parseTargetThread(args.target);
1980
2082
  const cookie = args.cookie;
@@ -2023,19 +2125,6 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2023
2125
  for (const line of mentionWarnings(mentionReport.unresolved)) console.error(`⚠ ${stripTerminalControls(line)}`);
2024
2126
  }
2025
2127
 
2026
- // Warn-only lint: names written as plain text (no leading @) that map to a
2027
- // known workspace member but aren't <@USERID>-tagged — they won't be notified.
2028
- // Never blocks; fail-soft (a missing users:read scope or API error must not
2029
- // stall a send).
2030
- try {
2031
- for (const u of await findUntaggedMentions(args.mentionToken ?? token, message, args.mentionCookie)) {
2032
- // u.surface / u.display carry Slack-controlled display text — strip control chars.
2033
- console.error(`⚠ possible untagged mention: ${stripTerminalControls(u.surface)} — @${stripTerminalControls(u.display)} won't be notified (did you mean <@${u.userId}>?)`);
2034
- }
2035
- } catch {
2036
- // best-effort; ignore
2037
- }
2038
-
2039
2128
  // Confirm-gate mention preview: exactly who resolves (→ notified) and who
2040
2129
  // stays literal. Inserted into the dry-run block so the sender eyeballs the
2041
2130
  // tagging before committing with --code.
@@ -2114,6 +2203,26 @@ async function cmdSend(token: string, args: SendArgs): Promise<void> {
2114
2203
  }
2115
2204
  }
2116
2205
 
2206
+ // Warn-only lint: names written as plain text (no leading @) that map to a
2207
+ // known workspace member but aren't <@USERID>-tagged — they won't be notified.
2208
+ // Skipped for people Slack notifies anyway: a thread reply reaches everyone
2209
+ // who started or replied in that thread, and a 1:1 DM reaches its other
2210
+ // member — warning there is noise that makes agents stop for nothing.
2211
+ // Never blocks; fail-soft (a missing users:read scope or API error must not
2212
+ // stall a send).
2213
+ try {
2214
+ const notified = threadTs
2215
+ ? threadParticipants(threadMsgs)
2216
+ : new Set([await dmCounterparty(token, channelId, cookie)].filter((u): u is string => !!u));
2217
+ for (const u of await findUntaggedMentions(args.mentionToken ?? token, message, args.mentionCookie)) {
2218
+ if (notified.has(u.userId)) continue;
2219
+ // u.surface / u.display carry Slack-controlled display text — strip control chars.
2220
+ console.error(`⚠ possible untagged mention: ${stripTerminalControls(u.surface)} — @${stripTerminalControls(u.display)} won't be notified (did you mean <@${u.userId}>?)`);
2221
+ }
2222
+ } catch {
2223
+ // best-effort; ignore
2224
+ }
2225
+
2117
2226
  // Unreplied guard: the destination's last message is our own and no one has
2118
2227
  // replied since (by definition, since it's still the most recent message) —
2119
2228
  // sending another new message risks spamming the same person twice. Warn
@@ -3101,6 +3210,7 @@ interface PollArgs {
3101
3210
  cookie?: string;
3102
3211
  mentionToken?: string;
3103
3212
  mentionCookie?: string;
3213
+ allowUrlAdjacent?: boolean;
3104
3214
  }
3105
3215
 
3106
3216
  /** The `slack poll --results=…` line handed back after posting. Printed as a
@@ -3164,6 +3274,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
3164
3274
  const multi = !!args.multi;
3165
3275
  const until = args.until ?? "";
3166
3276
  const message = pollBuildText(question, body, choices, { multi, deadline: until });
3277
+ guardUrlBoundaries(message, args.allowUrlAdjacent);
3167
3278
 
3168
3279
  // Preview the destination's last message, exactly as `send`/`ask` do. Fail-soft.
3169
3280
  let lastText = "";
@@ -3448,8 +3559,10 @@ interface ScheduleSendArgs {
3448
3559
  /** How the caller overrode the target text, e.g. `--user-id U0123`. Set only
3449
3560
  * when a FLAG supplied it — never when the command resolved the id itself. */
3450
3561
  targetOverride?: string;
3562
+ allowUrlAdjacent?: boolean;
3451
3563
  }
3452
3564
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
3565
+ guardUrlBoundaries(args.message, args.allowUrlAdjacent);
3453
3566
  const getSelf = selfLookup(token, args.cookie);
3454
3567
  const { ref, threadTs } = parseTargetThread(args.target);
3455
3568
 
@@ -3595,8 +3708,10 @@ interface UploadArgs {
3595
3708
  channelId?: string;
3596
3709
  userId?: string;
3597
3710
  cookie?: string;
3711
+ allowUrlAdjacent?: boolean;
3598
3712
  }
3599
3713
  async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
3714
+ if (args.comment !== undefined) guardUrlBoundaries(args.comment, args.allowUrlAdjacent);
3600
3715
  const getSelf = selfLookup(token, args.cookie);
3601
3716
  const { statSync, existsSync } = await import("node:fs");
3602
3717
  const { basename } = await import("node:path");
@@ -3992,6 +4107,8 @@ async function cmdDownload(token: string, cookie: string | undefined, ref: strin
3992
4107
  // --- dispatch ---
3993
4108
 
3994
4109
  async function main(): Promise<void> {
4110
+ // Before any output: Bun drops console writes that meet a full pipe (see stdout.ts).
4111
+ installSyncConsole();
3995
4112
  loadDotenvFiles();
3996
4113
 
3997
4114
  type W = { workspace?: string };
@@ -4216,6 +4333,89 @@ async function main(): Promise<void> {
4216
4333
  }
4217
4334
  },
4218
4335
  )
4336
+ .command(
4337
+ ["rename <channel> <new-name>", "mv <channel> <new-name>"],
4338
+ "Rename a channel (confirm-hash safety gate)",
4339
+ (y2) => y2
4340
+ .positional("channel", { type: "string", demandOption: true, describe: "#name, name, or channel ID (C…/G…)" })
4341
+ .positional("new-name", { type: "string", demandOption: true, describe: "New channel name (Slack lowercases it; no spaces)" })
4342
+ .option("dry-run", { type: "boolean", default: false, describe: "Resolve the channel and print old -> new without renaming" })
4343
+ .option("code", { type: "string", describe: "Safety hash to confirm rename" }),
4344
+ async (argv) => {
4345
+ const token = tok(argv as W);
4346
+ const cookie = ck(argv as W);
4347
+ const ref = argv.channel!;
4348
+ if (ref.startsWith("@")) {
4349
+ console.error(`Error: ${ref} is a DM — only channels can be renamed.`);
4350
+ process.exit(1);
4351
+ }
4352
+ // A bare word is a channel name unless it is shaped like an ID.
4353
+ const channelRef = ref.startsWith("#") || /^[CG][A-Z0-9]{8,}$/.test(ref) ? ref : `#${ref}`;
4354
+ const newName = argv.newName!.replace(/^#/, "").toLowerCase();
4355
+ let channelId: string;
4356
+ let oldName: string;
4357
+ try {
4358
+ channelId = await resolveChannel(token, channelRef, cookie);
4359
+ } catch (e: unknown) {
4360
+ console.error(`Error: could not resolve channel ${ref}: ${e instanceof Error ? e.message : String(e)}`);
4361
+ process.exit(1);
4362
+ }
4363
+ try {
4364
+ const ch = asRecord(asRecord((await conversationInfo(token, channelId, cookie)) as Json).channel);
4365
+ if (ch.is_im === true || ch.is_mpim === true) {
4366
+ console.error(`Error: ${channelId} is a DM — only channels can be renamed.`);
4367
+ process.exit(1);
4368
+ }
4369
+ oldName = typeof ch.name === "string" ? ch.name : channelRef.replace(/^#/, "");
4370
+ } catch (e: unknown) {
4371
+ // conversations.info is only for the preview label — a token without
4372
+ // channels:read can still rename. A channel that doesn't exist can't.
4373
+ const raw = e instanceof Error ? e.message : String(e);
4374
+ if (/channel_not_found/.test(raw)) {
4375
+ console.error(`Error: could not resolve channel ${ref}: channel not found (or not visible to this token).`);
4376
+ process.exit(1);
4377
+ }
4378
+ oldName = channelRef.replace(/^#/, "");
4379
+ }
4380
+ if (argv.dryRun === true) {
4381
+ console.log(`#${oldName} -> #${newName} (${channelId})`);
4382
+ console.log(`(dry run — conversations.rename not called)`);
4383
+ return;
4384
+ }
4385
+ // The renamer is the actor of record in the channel's history, so the
4386
+ // gate names them and the code is bound to them.
4387
+ const self = await selfIdentity(token, cookie);
4388
+ const code = safetyCode("rename", channelId, newName, self?.userId ?? "");
4389
+ if (argv.code !== code) requireCode(argv.code, code, [
4390
+ `--- Renaming channel -------------------------`,
4391
+ fromLine(self, { pad: 9 }),
4392
+ ` channel: ${channelId}`,
4393
+ ` from: #${oldName}`,
4394
+ ` to: #${newName}`,
4395
+ `--------------------------------────────────`,
4396
+ ]);
4397
+ let renamed: { id: string; name: string };
4398
+ try {
4399
+ renamed = await renameChannel(token, channelId, newName, cookie);
4400
+ } catch (e: unknown) {
4401
+ const raw = e instanceof Error ? e.message : String(e);
4402
+ const errCode = raw.match(/Slack error on conversations\.rename: (\w+)/)?.[1];
4403
+ const hint: Record<string, string> = {
4404
+ name_taken: `Error: a channel named #${newName} already exists.`,
4405
+ invalid_name: `Error: "${newName}" is not a valid channel name (lowercase letters, numbers, hyphens, underscores; max 80 chars).`,
4406
+ invalid_name_specials: `Error: "${newName}" contains characters Slack doesn't allow in channel names.`,
4407
+ invalid_name_maxlength: `Error: "${newName}" is too long (max 80 chars).`,
4408
+ invalid_name_required: `Error: the new channel name can't be empty.`,
4409
+ not_authorized: `Error: this token isn't allowed to rename #${oldName} (workspace settings may restrict renaming to admins or the channel creator).`,
4410
+ not_in_channel: `Error: this token isn't a member of #${oldName} — join it first.`,
4411
+ is_archived: `Error: #${oldName} is archived — unarchive it before renaming.`,
4412
+ };
4413
+ console.error((errCode && hint[errCode]) ?? friendlySlackError(e));
4414
+ process.exit(1);
4415
+ }
4416
+ console.log(`✓ Renamed #${oldName} -> #${renamed.name} (${renamed.id})`);
4417
+ },
4418
+ )
4219
4419
  .demandCommand(1, "")
4220
4420
  .showHelpOnFail(true),
4221
4421
  )
@@ -4411,6 +4611,15 @@ async function main(): Promise<void> {
4411
4611
  .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." })
4412
4612
  .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." }),
4413
4613
  async (argv) => {
4614
+ // An empty body is never a meaningful send. `ask` already refuses one
4615
+ // ("Error: question is empty"); `send` did not, so an empty draft
4616
+ // staged a confirm code and posting it produced a blank message.
4617
+ // Verification cannot catch this: an empty publish reads back as an
4618
+ // exact match, so refusing here is the only mechanism.
4619
+ if (!String(argv.message ?? "").trim()) {
4620
+ console.error("Error: message is empty");
4621
+ process.exit(1);
4622
+ }
4414
4623
  const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] };
4415
4624
  if (argv.code) args.code = argv.code;
4416
4625
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
@@ -4626,6 +4835,7 @@ async function main(): Promise<void> {
4626
4835
  .option("no-notify", { type: "boolean", default: false, describe: "With --results: do not write the invalid-ballot notice back into the message (keeps it strictly read-only)" })
4627
4836
  .option("code", { type: "string", describe: "Safety hash to confirm the post" })
4628
4837
  .option("body", { type: "string", describe: "Extra context shown under the question" })
4838
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4629
4839
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4630
4840
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
4631
4841
  .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" }),
@@ -4695,6 +4905,7 @@ async function main(): Promise<void> {
4695
4905
  if (argv.until) args.until = String(argv.until);
4696
4906
  if (argv.code) args.code = argv.code;
4697
4907
  if (argv.body) args.body = argv.body;
4908
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
4698
4909
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
4699
4910
  if (argv["user-id"]) args.userId = argv["user-id"];
4700
4911
  // Always resolve @tags with the user token (it has users:read), even
@@ -4811,12 +5022,23 @@ async function main(): Promise<void> {
4811
5022
  .positional("message", { type: "string", demandOption: true })
4812
5023
  .option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
4813
5024
  .option("code", { type: "string", describe: "Safety hash to confirm" })
5025
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
4814
5026
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4815
5027
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
4816
5028
  .option("as-bot", { type: "boolean", default: false, describe: "Schedule via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient. Without it a scheduled DM to yourself is posted by you, and Slack never notifies you of your own message — it fires into silence." }),
4817
5029
  async (argv) => {
5030
+ // An empty body is never a meaningful send. `ask` already refuses one
5031
+ // ("Error: question is empty"); `send` did not, so an empty draft
5032
+ // staged a confirm code and posting it produced a blank message.
5033
+ // Verification cannot catch this: an empty publish reads back as an
5034
+ // exact match, so refusing here is the only mechanism.
5035
+ if (!String(argv.message ?? "").trim()) {
5036
+ console.error("Error: message is empty");
5037
+ process.exit(1);
5038
+ }
4818
5039
  const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
4819
5040
  if (argv.code) args.code = argv.code;
5041
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
4820
5042
  // Recorded here, not in the command, because only a flag counts as an
4821
5043
  // override — under --as-bot the command sets args.userId itself from
4822
5044
  // the target, and claiming THAT was an override would be a new lie.
@@ -5306,6 +5528,7 @@ async function main(): Promise<void> {
5306
5528
  .positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
5307
5529
  .option("title", { type: "string", describe: "Title (single file only)" })
5308
5530
  .option("comment", { type: "string", describe: "Initial comment (first file)" })
5531
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
5309
5532
  .option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
5310
5533
  .option("channel-id", { type: "string" })
5311
5534
  .option("user-id", { type: "string" }),
@@ -5313,6 +5536,7 @@ async function main(): Promise<void> {
5313
5536
  const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
5314
5537
  if (argv.title) args.title = argv.title;
5315
5538
  if (argv.comment) args.comment = argv.comment;
5539
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
5316
5540
  if (argv.code) args.code = argv.code;
5317
5541
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5318
5542
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -5478,6 +5702,25 @@ async function main(): Promise<void> {
5478
5702
  await cmdDump(tok(argv as W), argv.days, argv.limit, argv.filter);
5479
5703
  },
5480
5704
  )
5705
+ .command(
5706
+ "todos",
5707
+ "Slack's To-dos tab — your personal to-dos (read-only; requires xoxc desktop token). Not `slack todo` (reaction tasks)",
5708
+ (y) => y.command(
5709
+ ["$0", "ls", "list"],
5710
+ "List your To-dos, soonest due first",
5711
+ (y2) => y2
5712
+ .option("filter", { type: "string", choices: [...TODO_FILTERS], default: "todo", describe: "todo = open to-dos; saved = everything saved; completed; archived" })
5713
+ .option("limit", { alias: "n", type: "number", default: 50, describe: "At most this many items" })
5714
+ .option("json", { type: "boolean", default: false, describe: "One JSON object per item (raw fields + permalink + message text)" }),
5715
+ async (argv) => {
5716
+ if (!Number.isInteger(argv.limit) || argv.limit < 1) {
5717
+ console.error(`Error: --limit must be a whole number of at least 1 (got ${argv.limit}).`);
5718
+ process.exit(2);
5719
+ }
5720
+ await cmdTodos(tok(argv as W), ck(argv as W), { filter: argv.filter as TodoFilter, limit: argv.limit, json: argv.json });
5721
+ },
5722
+ ),
5723
+ )
5481
5724
  .command(
5482
5725
  "drafts",
5483
5726
  "Manage message drafts (requires xoxc desktop token)",
@@ -5495,11 +5738,15 @@ async function main(): Promise<void> {
5495
5738
  "Create a draft",
5496
5739
  (y2) => y2
5497
5740
  .positional("channel", { type: "string", demandOption: true })
5498
- .positional("text", { type: "string", array: true, default: [] }),
5741
+ .positional("text", { type: "string", array: true, default: [] })
5742
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing a bare URL that runs into non-ASCII text (it would link only up to that point)" }),
5499
5743
  async (argv) => {
5500
5744
  const token = tok(argv as W); const cookie = ck(argv as W);
5501
5745
  const text = (argv.text as string[]).join(" ");
5502
5746
  if (!text) { console.error("Usage: slack drafts new <#channel|@user> <text>"); process.exit(2); }
5747
+ // Drafts emit explicit link elements, so a URL glued BEFORE is fine
5748
+ // here; one that runs into text AFTER would be cut at a guess.
5749
+ guardUrlBoundaries(text, argv["allow-url-adjacent"], undefined, { sides: ["after"], markdown: false });
5503
5750
  const channelId = await resolveChannel(token, argv.channel!, cookie);
5504
5751
  const resp = (await createDraft(token, channelId, text, cookie)) as Record<string, Json>;
5505
5752
  console.log(`✓ Draft created (id: ${asRecord(resp.draft).id ?? "?"})`);
@@ -5519,11 +5766,13 @@ async function main(): Promise<void> {
5519
5766
  (y2) => y2
5520
5767
  .positional("id", { type: "string", demandOption: true })
5521
5768
  .positional("text", { type: "string", array: true, default: [] })
5522
- .option("code", { type: "string" }),
5769
+ .option("code", { type: "string" })
5770
+ .option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing a bare URL that runs into non-ASCII text (it would link only up to that point)" }),
5523
5771
  async (argv) => {
5524
5772
  const token = tok(argv as W); const cookie = ck(argv as W);
5525
5773
  const text = (argv.text as string[]).join(" ");
5526
5774
  if (!text) { console.error("Usage: slack drafts edit <id> <new-text>"); process.exit(2); }
5775
+ guardUrlBoundaries(text, argv["allow-url-adjacent"], undefined, { sides: ["after"], markdown: false });
5527
5776
  const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
5528
5777
  const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
5529
5778
  if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }