slack-term 1.39.2 → 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.2",
3
+ "version": "1.40.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
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,
@@ -759,6 +762,8 @@ function blocksToText(blocks: Json[]): string {
759
762
  if (node && typeof node === "object") {
760
763
  const obj = node as Record<string, Json>;
761
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; }
762
767
  if (Array.isArray(obj.elements)) walk(obj.elements);
763
768
  else if (Array.isArray(obj.content)) walk(obj.content);
764
769
  }
@@ -841,6 +846,65 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
841
846
  }
842
847
  }
843
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
+
844
908
  // --- drafts get ---
845
909
  async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
846
910
  const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
@@ -904,6 +968,7 @@ const SCOPE_FOR_METHOD: Record<string, string> = {
904
968
  "slackLists.items.update": "lists:write",
905
969
  "slackLists.items.list": "lists:read",
906
970
  "slackLists.access.set": "lists:write",
971
+ "conversations.rename": "channels:manage (or groups:write for a private channel)",
907
972
  "conversations.replies": "channels:history (or groups:history)",
908
973
  "conversations.history": "channels:history (or groups:history)",
909
974
  };
@@ -3145,6 +3210,7 @@ interface PollArgs {
3145
3210
  cookie?: string;
3146
3211
  mentionToken?: string;
3147
3212
  mentionCookie?: string;
3213
+ allowUrlAdjacent?: boolean;
3148
3214
  }
3149
3215
 
3150
3216
  /** The `slack poll --results=…` line handed back after posting. Printed as a
@@ -3208,6 +3274,7 @@ async function cmdPoll(token: string, args: PollArgs): Promise<void> {
3208
3274
  const multi = !!args.multi;
3209
3275
  const until = args.until ?? "";
3210
3276
  const message = pollBuildText(question, body, choices, { multi, deadline: until });
3277
+ guardUrlBoundaries(message, args.allowUrlAdjacent);
3211
3278
 
3212
3279
  // Preview the destination's last message, exactly as `send`/`ask` do. Fail-soft.
3213
3280
  let lastText = "";
@@ -3492,8 +3559,10 @@ interface ScheduleSendArgs {
3492
3559
  /** How the caller overrode the target text, e.g. `--user-id U0123`. Set only
3493
3560
  * when a FLAG supplied it — never when the command resolved the id itself. */
3494
3561
  targetOverride?: string;
3562
+ allowUrlAdjacent?: boolean;
3495
3563
  }
3496
3564
  async function cmdScheduleSend(token: string, args: ScheduleSendArgs): Promise<void> {
3565
+ guardUrlBoundaries(args.message, args.allowUrlAdjacent);
3497
3566
  const getSelf = selfLookup(token, args.cookie);
3498
3567
  const { ref, threadTs } = parseTargetThread(args.target);
3499
3568
 
@@ -3639,8 +3708,10 @@ interface UploadArgs {
3639
3708
  channelId?: string;
3640
3709
  userId?: string;
3641
3710
  cookie?: string;
3711
+ allowUrlAdjacent?: boolean;
3642
3712
  }
3643
3713
  async function cmdUpload(token: string, args: UploadArgs): Promise<void> {
3714
+ if (args.comment !== undefined) guardUrlBoundaries(args.comment, args.allowUrlAdjacent);
3644
3715
  const getSelf = selfLookup(token, args.cookie);
3645
3716
  const { statSync, existsSync } = await import("node:fs");
3646
3717
  const { basename } = await import("node:path");
@@ -4262,6 +4333,89 @@ async function main(): Promise<void> {
4262
4333
  }
4263
4334
  },
4264
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
+ )
4265
4419
  .demandCommand(1, "")
4266
4420
  .showHelpOnFail(true),
4267
4421
  )
@@ -4457,6 +4611,15 @@ async function main(): Promise<void> {
4457
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." })
4458
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." }),
4459
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
+ }
4460
4623
  const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] };
4461
4624
  if (argv.code) args.code = argv.code;
4462
4625
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
@@ -4672,6 +4835,7 @@ async function main(): Promise<void> {
4672
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)" })
4673
4836
  .option("code", { type: "string", describe: "Safety hash to confirm the post" })
4674
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" })
4675
4839
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4676
4840
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
4677
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" }),
@@ -4741,6 +4905,7 @@ async function main(): Promise<void> {
4741
4905
  if (argv.until) args.until = String(argv.until);
4742
4906
  if (argv.code) args.code = argv.code;
4743
4907
  if (argv.body) args.body = argv.body;
4908
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
4744
4909
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
4745
4910
  if (argv["user-id"]) args.userId = argv["user-id"];
4746
4911
  // Always resolve @tags with the user token (it has users:read), even
@@ -4857,12 +5022,23 @@ async function main(): Promise<void> {
4857
5022
  .positional("message", { type: "string", demandOption: true })
4858
5023
  .option("at", { type: "string", demandOption: true, describe: "Delivery time (ISO datetime or Unix ts)" })
4859
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" })
4860
5026
  .option("channel-id", { type: "string", describe: "Raw channel ID" })
4861
5027
  .option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
4862
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." }),
4863
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
+ }
4864
5039
  const args: ScheduleSendArgs = { target: argv.target!, message: argv.message!, at: argv.at! };
4865
5040
  if (argv.code) args.code = argv.code;
5041
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
4866
5042
  // Recorded here, not in the command, because only a flag counts as an
4867
5043
  // override — under --as-bot the command sets args.userId itself from
4868
5044
  // the target, and claiming THAT was an override would be a new lie.
@@ -5352,6 +5528,7 @@ async function main(): Promise<void> {
5352
5528
  .positional("file", { type: "string", array: true, demandOption: true, describe: "Path(s) to file(s)" })
5353
5529
  .option("title", { type: "string", describe: "Title (single file only)" })
5354
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" })
5355
5532
  .option("code", { type: "string", describe: "4-hex safety code to confirm upload" })
5356
5533
  .option("channel-id", { type: "string" })
5357
5534
  .option("user-id", { type: "string" }),
@@ -5359,6 +5536,7 @@ async function main(): Promise<void> {
5359
5536
  const args: UploadArgs = { target: argv.target!, filePaths: argv.file as string[] };
5360
5537
  if (argv.title) args.title = argv.title;
5361
5538
  if (argv.comment) args.comment = argv.comment;
5539
+ if (argv["allow-url-adjacent"]) args.allowUrlAdjacent = true;
5362
5540
  if (argv.code) args.code = argv.code;
5363
5541
  if (argv["channel-id"]) args.channelId = argv["channel-id"];
5364
5542
  if (argv["user-id"]) args.userId = argv["user-id"];
@@ -5524,6 +5702,25 @@ async function main(): Promise<void> {
5524
5702
  await cmdDump(tok(argv as W), argv.days, argv.limit, argv.filter);
5525
5703
  },
5526
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
+ )
5527
5724
  .command(
5528
5725
  "drafts",
5529
5726
  "Manage message drafts (requires xoxc desktop token)",
@@ -5541,11 +5738,15 @@ async function main(): Promise<void> {
5541
5738
  "Create a draft",
5542
5739
  (y2) => y2
5543
5740
  .positional("channel", { type: "string", demandOption: true })
5544
- .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)" }),
5545
5743
  async (argv) => {
5546
5744
  const token = tok(argv as W); const cookie = ck(argv as W);
5547
5745
  const text = (argv.text as string[]).join(" ");
5548
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 });
5549
5750
  const channelId = await resolveChannel(token, argv.channel!, cookie);
5550
5751
  const resp = (await createDraft(token, channelId, text, cookie)) as Record<string, Json>;
5551
5752
  console.log(`✓ Draft created (id: ${asRecord(resp.draft).id ?? "?"})`);
@@ -5565,11 +5766,13 @@ async function main(): Promise<void> {
5565
5766
  (y2) => y2
5566
5767
  .positional("id", { type: "string", demandOption: true })
5567
5768
  .positional("text", { type: "string", array: true, default: [] })
5568
- .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)" }),
5569
5771
  async (argv) => {
5570
5772
  const token = tok(argv as W); const cookie = ck(argv as W);
5571
5773
  const text = (argv.text as string[]).join(" ");
5572
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 });
5573
5776
  const listResp = (await listDrafts(token, cookie)) as Record<string, Json>;
5574
5777
  const d = asArray(listResp.drafts).map(asRecord).find((x) => String(x.id) === argv.id);
5575
5778
  if (!d) { console.error(`Draft not found: ${argv.id}`); process.exit(1); }
package/ts/slack.ts CHANGED
@@ -1,12 +1,30 @@
1
1
  // Slack Web API client (user token, Authorization: Bearer)
2
2
  import { resolveCookie } from "./profiles.ts";
3
+ import { linkifyRichText } from "./urlGuard.ts";
3
4
 
4
5
  export class RateLimitError extends Error {
5
6
  retryAfter: number;
6
- constructor(retryAfter: number) {
7
- super(`Slack rate limited — retry after ${retryAfter}s`);
7
+ /**
8
+ * The Web API method that was throttled ("conversations.history", ...).
9
+ *
10
+ * Slack's limits are PER METHOD, so "retry after 30s" alone cannot say whether
11
+ * a caller's own polling caused the 429 or something else sharing the token
12
+ * did. The method name is already in scope at every throw site below; not
13
+ * passing it was the only reason the question was unanswerable.
14
+ *
15
+ * Optional because callers (and tests) constructed this with one argument
16
+ * before, and a required field would break them for no diagnostic gain.
17
+ */
18
+ method?: string;
19
+ constructor(retryAfter: number, method?: string) {
20
+ super(
21
+ method
22
+ ? `Slack rate limited on ${method} — retry after ${retryAfter}s`
23
+ : `Slack rate limited — retry after ${retryAfter}s`,
24
+ );
8
25
  this.name = "RateLimitError";
9
26
  this.retryAfter = retryAfter;
27
+ if (method) this.method = method;
10
28
  }
11
29
  }
12
30
 
@@ -36,12 +54,12 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
36
54
  });
37
55
  if (res.status === 429) {
38
56
  const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
39
- throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
57
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, method);
40
58
  }
41
59
  const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
42
60
  if (body.ok !== true) {
43
61
  const err = body.error ?? "unknown";
44
- if (err === "ratelimited") throw new RateLimitError(60);
62
+ if (err === "ratelimited") throw new RateLimitError(60, method);
45
63
  // A desktop session token (xoxc-) is only accepted by the public Web API when
46
64
  // paired with its xoxd session cookie — without one, point at the xoxp fallback.
47
65
  if (err === "invalid_auth" && token.startsWith("xoxc-") && !cookie) {
@@ -89,21 +107,21 @@ async function callSession(token: string, method: string, init: RequestInit, coo
89
107
  });
90
108
  if (res.status === 429) {
91
109
  const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
92
- throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
110
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, method);
93
111
  }
94
112
  const body = (await res.json()) as { ok?: boolean; error?: string } & Record<string, Json>;
95
113
  if (body.ok !== true) {
96
114
  const err = body.error ?? "unknown";
97
- if (err === "ratelimited") throw new RateLimitError(60);
115
+ if (err === "ratelimited") throw new RateLimitError(60, method);
98
116
  if ((err === "invalid_auth" || err === "not_authed") && !token.startsWith("xoxc-")) {
99
117
  throw new Error(
100
- `The draft API requires a desktop app session token (xoxc-).\n` +
118
+ `${method} requires a desktop app session token (xoxc-).\n` +
101
119
  `Import from Slack desktop: slack auth login`,
102
120
  );
103
121
  }
104
122
  if ((err === "invalid_auth" || err === "not_authed") && !cookie) {
105
123
  throw new Error(
106
- `drafts.list also requires the xoxd session cookie.\n` +
124
+ `${method} also requires the xoxd session cookie.\n` +
107
125
  `Attach it with: slack auth firefox (Linux/macOS/Windows) or slack auth chrome (macOS/Linux)`,
108
126
  );
109
127
  }
@@ -185,7 +203,7 @@ export async function authScopes(token: string, cookie?: string): Promise<{
185
203
  const res = await fetch(`${base()}/auth.test`, { method: "GET", headers });
186
204
  if (res.status === 429) {
187
205
  const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
188
- throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter);
206
+ throw new RateLimitError(isNaN(retryAfter) ? 60 : retryAfter, "auth.test");
189
207
  }
190
208
  const scopesHeader = res.headers.get("x-oauth-scopes") ?? "";
191
209
  const body = (await res.json()) as {
@@ -594,6 +612,20 @@ export async function createChannel(
594
612
  return { id: resp.channel?.id ?? "", name: resp.channel?.name ?? name };
595
613
  }
596
614
 
615
+ // Rename a channel via conversations.rename. Needs channels:manage (public) or
616
+ // groups:write (private) — and, for a user token, rights to rename it.
617
+ export async function renameChannel(
618
+ token: string,
619
+ channel: string,
620
+ name: string,
621
+ cookie?: string,
622
+ ): Promise<{ id: string; name: string }> {
623
+ const resp = (await post(token, "conversations.rename", { channel, name }, cookie)) as {
624
+ channel?: { id?: string; name?: string };
625
+ };
626
+ return { id: resp.channel?.id ?? channel, name: resp.channel?.name ?? name };
627
+ }
628
+
597
629
  // Invite users to a channel via conversations.invite (up to ~1000 ids).
598
630
  export async function inviteToChannel(
599
631
  token: string,
@@ -1025,6 +1057,19 @@ export async function listConversationMembers(token: string, channel: string, co
1025
1057
  return all;
1026
1058
  }
1027
1059
 
1060
+ // To-dos tab (internal — requires xoxc session token + xoxd cookie). The
1061
+ // desktop client's own read of Slack's personal To-dos; see slackTodos.ts.
1062
+ export async function savedList(
1063
+ token: string,
1064
+ opts: { filter?: string; limit?: number; cursor?: string | undefined } = {},
1065
+ cookie?: string,
1066
+ ): Promise<Json> {
1067
+ const params: Record<string, string> = { limit: String(opts.limit ?? 50) };
1068
+ if (opts.filter) params.filter = opts.filter;
1069
+ if (opts.cursor) params.cursor = opts.cursor;
1070
+ return postSession(token, "saved.list", params, cookie);
1071
+ }
1072
+
1028
1073
  // Draft API (internal — requires xoxc session token + xoxd cookie)
1029
1074
  export async function listDrafts(token: string, cookie?: string): Promise<Json> {
1030
1075
  return postSession(token, "drafts.list", {}, cookie);
@@ -1039,7 +1084,7 @@ export async function createDraft(
1039
1084
  const { randomUUID } = await import("node:crypto");
1040
1085
  const blocks = JSON.stringify([{
1041
1086
  type: "rich_text",
1042
- elements: [{ type: "rich_text_section", elements: [{ type: "text", text }] }],
1087
+ elements: [{ type: "rich_text_section", elements: linkifyRichText(text) }],
1043
1088
  }]);
1044
1089
  return postSession(token, "drafts.create", {
1045
1090
  client_msg_id: randomUUID(),
@@ -1059,7 +1104,7 @@ export async function updateDraft(
1059
1104
  ): Promise<Json> {
1060
1105
  const blocks = JSON.stringify([{
1061
1106
  type: "rich_text",
1062
- elements: [{ type: "rich_text_section", elements: [{ type: "text", text }] }],
1107
+ elements: [{ type: "rich_text_section", elements: linkifyRichText(text) }],
1063
1108
  }]);
1064
1109
  // client_last_updated_ts must be the current time (server uses it as new stored ts)
1065
1110
  const nowTs = (Date.now() / 1000).toFixed(6);
@@ -0,0 +1,85 @@
1
+ // Slack's To-dos tab (app.slack.com/client/<team>/to-dos) — read-only helpers.
2
+ //
3
+ // Not to be confused with `slack todo` (todo.ts), which tracks tasks as
4
+ // reactions on messages. To-dos are Slack's own PERSONAL list: it replaced
5
+ // "Later" in Oct 2026, and there is no public API for it. The desktop client
6
+ // reads it through the internal `saved.list` (xoxc session token + d cookie),
7
+ // which is what `slack todos` calls — so it is per-user, and bot tokens cannot
8
+ // see it at all.
9
+ //
10
+ // A saved item points at a message: `item_id` is the channel id, `ts` the
11
+ // message ts. `todo_state` is "to_do" for a to-do and "saved" for a plain
12
+ // saved item; `state` is "in_progress" until it is checked off. Dates are
13
+ // epoch seconds, 0 when unset. The shape is undocumented, so every field is
14
+ // read defensively.
15
+
16
+ import type { Json } from "./slack.ts";
17
+
18
+ /** `--filter` values `saved.list` accepts (verified against the live API). */
19
+ export const TODO_FILTERS = ["todo", "saved", "completed", "archived"] as const;
20
+ export type TodoFilter = (typeof TODO_FILTERS)[number];
21
+
22
+ export type TodoItem = {
23
+ channel: string;
24
+ ts: string;
25
+ /** Epoch seconds; 0 = no due date. */
26
+ due: number;
27
+ /** Epoch seconds; 0 = not completed. */
28
+ completed: number;
29
+ /** "to_do" | "saved" (as Slack reports it). */
30
+ todoState: string;
31
+ state: string;
32
+ archived: boolean;
33
+ itemType: string;
34
+ };
35
+
36
+ const str = (v: Json | undefined): string => (typeof v === "string" ? v : "");
37
+ const num = (v: Json | undefined): number => {
38
+ const n = typeof v === "number" || typeof v === "string" ? Number(v) : 0;
39
+ return Number.isFinite(n) ? n : 0;
40
+ };
41
+
42
+ /** The items of one `saved.list` page, normalized. Non-message items are kept
43
+ * (their `itemType` says what they are) but carry no channel/ts to read. */
44
+ export function parseSavedItems(resp: Json): TodoItem[] {
45
+ const r = (resp && typeof resp === "object" && !Array.isArray(resp) ? resp : {}) as Record<string, Json>;
46
+ const items = Array.isArray(r.saved_items) ? r.saved_items : [];
47
+ return items
48
+ .filter((x): x is Record<string, Json> => !!x && typeof x === "object" && !Array.isArray(x))
49
+ .map((x) => ({
50
+ channel: str(x.item_id),
51
+ ts: str(x.ts),
52
+ due: num(x.date_due),
53
+ completed: num(x.date_completed),
54
+ todoState: str(x.todo_state),
55
+ state: str(x.state),
56
+ archived: x.is_archived === true,
57
+ itemType: str(x.item_type),
58
+ }));
59
+ }
60
+
61
+ /** Soonest due first; items without a due date last, newest message first among them. */
62
+ export function sortTodos(items: TodoItem[]): TodoItem[] {
63
+ return [...items].sort((a, b) => {
64
+ if (a.due && b.due) return a.due - b.due;
65
+ if (a.due !== b.due) return a.due ? -1 : 1;
66
+ return Number(b.ts) - Number(a.ts);
67
+ });
68
+ }
69
+
70
+ export function isDone(t: TodoItem): boolean {
71
+ return t.completed > 0 || t.state === "completed";
72
+ }
73
+
74
+ /** The status column: a checkbox, plus the due date (and OVERDUE when past it). */
75
+ export function todoStatus(t: TodoItem, nowSec: number, fmt: (epochSec: number) => string): string {
76
+ const box = isDone(t) ? "☑" : t.todoState === "to_do" ? "☐" : "·";
77
+ if (!t.due) return `${box} no due date`;
78
+ const overdue = !isDone(t) && t.due < nowSec ? " OVERDUE" : "";
79
+ return `${box} due ${fmt(t.due)}${overdue}`;
80
+ }
81
+
82
+ /** A message permalink built from the workspace URL — no API call per item. */
83
+ export function messagePermalink(teamUrl: string, channel: string, ts: string): string {
84
+ return `${teamUrl.replace(/\/+$/, "")}/archives/${channel}/p${ts.replace(".", "")}`;
85
+ }