slack-term 1.45.0 → 1.46.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.45.0",
3
+ "version": "1.46.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/slack-term.git"
package/ts/botIntent.ts CHANGED
@@ -15,6 +15,7 @@ const WRITES = new Set([
15
15
  "schedule send", "schedule rm",
16
16
  "pinlog new", "pinlog set", "pinlog note", "pinlog pin",
17
17
  "todo set", "todo flag",
18
+ "todos add", "todos done", "todos complete", "todos edit", "todos rm", "todos remove", "todos delete",
18
19
  "lists create", "lists add", "lists set", "lists share",
19
20
  "drafts new", "drafts save", "drafts edit", "drafts update", "drafts delete", "drafts rm",
20
21
  "channel create", "channel new", "channel rename", "channel mv",
package/ts/cli.ts CHANGED
@@ -117,7 +117,10 @@ import {
117
117
  userName,
118
118
  getPath,
119
119
  type Json,
120
+ savedAdd,
121
+ savedDelete,
120
122
  savedList,
123
+ savedUpdate,
121
124
  } from "./slack.ts";
122
125
  import { isDone, messagePermalink, parseSavedItems, sortTodos, TODO_FILTERS, todoStatus, type TodoFilter, type TodoItem } from "./slackTodos.ts";
123
126
  import {
@@ -883,8 +886,13 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
883
886
  }
884
887
 
885
888
  // --- todos (Slack's To-dos tab) ---
886
- // Read-only. Pages through saved.list up to `limit` items, then shows each
887
- // to-do with its due date, where it lives, and the message it points at.
889
+ // Read-only. Reads the whole view, sorts it by due date, then shows the first
890
+ // `limit` with where each lives and the message it points at. saved.list does
891
+ // not return items in due order, so cutting before sorting could hide an
892
+ // overdue to-do behind a later one; the read is capped at TODOS_MAX_PAGES.
893
+ const TODOS_PAGE = 50;
894
+ const TODOS_MAX_PAGES = 20;
895
+
888
896
  async function cmdTodos(
889
897
  token: string,
890
898
  cookie: string | undefined,
@@ -892,12 +900,14 @@ async function cmdTodos(
892
900
  ): Promise<void> {
893
901
  const items: TodoItem[] = [];
894
902
  let cursor: string | undefined;
903
+ let pages = 0;
895
904
  do {
896
- const resp = asRecord((await savedList(token, { filter: opts.filter, limit: Math.min(opts.limit, 100), cursor }, cookie)) as Json);
905
+ const resp = asRecord((await savedList(token, { filter: opts.filter, limit: TODOS_PAGE, cursor }, cookie)) as Json);
897
906
  items.push(...parseSavedItems(resp as Json));
898
907
  const next = asRecord(resp.response_metadata).next_cursor;
899
908
  cursor = typeof next === "string" && next ? next : undefined;
900
- } while (cursor && items.length < opts.limit);
909
+ } while (cursor && ++pages < TODOS_MAX_PAGES);
910
+ if (cursor) console.error(`Note: read the first ${items.length} items only (${TODOS_MAX_PAGES} pages); the order covers those.`);
901
911
  const shown = sortTodos(items).slice(0, opts.limit);
902
912
 
903
913
  if (shown.length === 0) {
@@ -941,6 +951,139 @@ async function cmdTodos(
941
951
  }
942
952
  }
943
953
 
954
+ // --- todos add / done / edit / rm — gated writes to your To-dos ---
955
+ // Every To-do points at a message, so the target is a message (permalink or
956
+ // #chan:<ts>). Same two-step as send: the first run previews and prints a code,
957
+ // the rerun with --code=<code> writes. Your own To-dos only — the session token
958
+ // can touch no one else's.
959
+ type TodoOp =
960
+ | { kind: "add"; due?: number; saved?: boolean }
961
+ | { kind: "done"; undo: boolean }
962
+ | { kind: "edit"; due?: number; todo?: boolean }
963
+ | { kind: "rm" };
964
+
965
+ // The saved item for one message (saved.delete answers ok even when there is
966
+ // nothing to delete, so writes check first). Views are searched in the order
967
+ // the op most likely needs, stopping at the first match, and the whole search
968
+ // is capped at TODOS_MAX_PAGES pages so a long saved history can't turn one
969
+ // command into a burst of saved.list calls. "unknown" = cap hit, not found.
970
+ function findOrder(op: TodoOp): TodoFilter[] {
971
+ if (op.kind === "done") return op.undo ? ["completed", "todo", "saved", "archived"] : ["todo", "saved", "completed", "archived"];
972
+ return ["todo", "saved", "completed", "archived"];
973
+ }
974
+
975
+ async function findSavedItem(
976
+ token: string,
977
+ cookie: string | undefined,
978
+ channel: string,
979
+ ts: string,
980
+ order: TodoFilter[],
981
+ ): Promise<TodoItem | "unknown" | undefined> {
982
+ let pages = 0;
983
+ for (const filter of order) {
984
+ let cursor: string | undefined;
985
+ do {
986
+ if (pages++ >= TODOS_MAX_PAGES) return "unknown";
987
+ const resp = asRecord((await savedList(token, { filter, limit: TODOS_PAGE, cursor }, cookie)) as Json);
988
+ const hit = parseSavedItems(resp as Json).find((t) => t.channel === channel && t.ts === ts);
989
+ if (hit) return hit;
990
+ const next = asRecord(resp.response_metadata).next_cursor;
991
+ cursor = typeof next === "string" && next ? next : undefined;
992
+ } while (cursor);
993
+ }
994
+ return undefined;
995
+ }
996
+
997
+ function todoOpLines(op: TodoOp, fmtDue: (sec: number) => string): string[] {
998
+ const due = (d: number | undefined) => (d === undefined ? [] : [` Due: ${d ? fmtDue(d) : "(clear the due date)"}`]);
999
+ switch (op.kind) {
1000
+ case "add": return [` Action: add as ${op.saved ? "a saved item (no checkbox)" : "a to-do"}`, ...due(op.due)];
1001
+ case "done": return [` Action: mark ${op.undo ? "NOT done (reopen)" : "done"}`];
1002
+ case "edit": return [
1003
+ " Action: edit",
1004
+ ...(op.todo === undefined ? [] : [` Kind: ${op.todo ? "to-do" : "saved item (no checkbox)"}`]),
1005
+ ...due(op.due),
1006
+ ];
1007
+ case "rm": return [" Action: REMOVE from your To-dos (the message itself is untouched)"];
1008
+ }
1009
+ }
1010
+
1011
+ async function cmdTodosWrite(token: string, cookie: string | undefined, target: string, op: TodoOp, code?: string): Promise<void> {
1012
+ const { ref, ts } = splitRefTs(target);
1013
+ if (!ts) {
1014
+ console.error("Error: a To-do points at a message — give its permalink or #chan:<ts> (see `slack todos ls`).");
1015
+ process.exit(2);
1016
+ }
1017
+ if (op.kind === "edit" && op.due === undefined && op.todo === undefined) {
1018
+ console.error("Error: nothing to edit — pass --due <time|none>, --todo or --saved.");
1019
+ process.exit(2);
1020
+ }
1021
+ const channel = await resolveChannel(token, ref, cookie);
1022
+ const found = await findSavedItem(token, cookie, channel, ts, findOrder(op));
1023
+ if (found === "unknown" && op.kind !== "add") {
1024
+ console.error(`Error: not found in the first ${TODOS_MAX_PAGES * TODOS_PAGE} saved items — stopped there to stay under Slack's rate limits. Nothing was changed.`);
1025
+ process.exit(1);
1026
+ }
1027
+ const existing = found === "unknown" ? undefined : found;
1028
+ if (op.kind === "add" && existing) {
1029
+ console.error(`Error: that message is already in your To-dos (${isDone(existing) ? "done" : existing.todoState || "saved"}) — use \`slack todos edit\` or \`done\`.`);
1030
+ process.exit(1);
1031
+ }
1032
+ if (op.kind !== "add" && !existing) {
1033
+ console.error("Error: that message is not in your To-dos — nothing to change. `slack todos ls --filter saved` lists everything saved.");
1034
+ process.exit(1);
1035
+ }
1036
+
1037
+ let teamUrl = "";
1038
+ let me = "";
1039
+ try { ({ url: teamUrl, userId: me } = await authScopes(token, cookie)); } catch { /* preview degrades */ }
1040
+ const label = await (await buildChLabelResolver(token, cookie, me))(channel);
1041
+ let msg: Record<string, Json> | undefined;
1042
+ try {
1043
+ const r = asRecord((await replies(token, channel, ts, 1, cookie)) as Json);
1044
+ msg = asArray(r.messages).map(asRecord).find((m) => m.ts === ts);
1045
+ } catch { /* not readable */ }
1046
+ const author = typeof msg?.user === "string" ? `@${await userName(token, msg.user, cookie)}: ` : "";
1047
+ const raw = typeof msg?.text === "string" ? msg.text.split("\n")[0] ?? "" : "(message not readable)";
1048
+ const head = maskSecrets(raw.length > 100 ? `${raw.slice(0, 100)}…` : raw);
1049
+ const now = Date.now() / 1000;
1050
+
1051
+ const expected = safetyCode("todos", JSON.stringify(op), channel, ts, me);
1052
+ if (code !== expected) {
1053
+ requireCode(code, expected, [
1054
+ "--- To-do ------------------------------------",
1055
+ ` For: ${me ? `@${await userName(token, me, cookie)} (your own To-dos)` : "(your own To-dos)"}`,
1056
+ ` Where: ${stripTerminalControls(label)}`,
1057
+ ` Msg: ${stripTerminalControls(author + head)}`,
1058
+ ...(teamUrl ? [` Link: ${messagePermalink(teamUrl, channel, ts)}`] : []),
1059
+ ...(existing ? [` Now: ${todoStatus(existing, now, formatYmdHm)}`] : []),
1060
+ ...(found === "unknown" ? [` Note: not checked beyond the first ${TODOS_MAX_PAGES * TODOS_PAGE} saved items — it may already be saved`] : []),
1061
+ ...todoOpLines(op, fmtPostAt),
1062
+ "----------------------------------------------",
1063
+ ]);
1064
+ }
1065
+
1066
+ const at = { channel, ts };
1067
+ switch (op.kind) {
1068
+ case "add":
1069
+ await savedAdd(token, at, { todo: !op.saved, ...(op.due ? { due: op.due } : {}) }, cookie);
1070
+ console.log(`✓ Added to your To-dos${op.due ? ` (due ${formatYmdHm(op.due)})` : ""}`);
1071
+ break;
1072
+ case "done":
1073
+ await savedUpdate(token, at, { mark: op.undo ? "uncompleted" : "completed" }, cookie);
1074
+ console.log(op.undo ? "✓ Reopened" : "✓ Marked done");
1075
+ break;
1076
+ case "edit":
1077
+ await savedUpdate(token, at, { ...(op.todo === undefined ? {} : { todo: op.todo }), ...(op.due === undefined ? {} : { due: op.due }) }, cookie);
1078
+ console.log("✓ Updated");
1079
+ break;
1080
+ case "rm":
1081
+ await savedDelete(token, at, cookie);
1082
+ console.log("✓ Removed from your To-dos");
1083
+ break;
1084
+ }
1085
+ }
1086
+
944
1087
  // --- drafts get ---
945
1088
  async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
946
1089
  const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
@@ -4752,6 +4895,16 @@ async function botDmForTarget(
4752
4895
  return openDm(botToken, userId);
4753
4896
  }
4754
4897
 
4898
+ /** A `--due` value as epoch seconds; exits 2 on an unparseable one. */
4899
+ function todoDue(v: string): number {
4900
+ try {
4901
+ return parsePostAt(v);
4902
+ } catch {
4903
+ console.error(`Error: cannot parse --due "${v}" — use an ISO datetime (2026-11-01T18:00) or a Unix ts.`);
4904
+ process.exit(2);
4905
+ }
4906
+ }
4907
+
4755
4908
  function parsePostAt(at: string): number {
4756
4909
  if (/^\d{10,}$/.test(at)) return parseInt(at, 10);
4757
4910
  const d = new Date(at.replace(" ", "T"));
@@ -7147,7 +7300,7 @@ async function main(): Promise<void> {
7147
7300
  )
7148
7301
  .command(
7149
7302
  "todos",
7150
- "Slack's To-dos tab — your personal to-dos (read-only; requires xoxc desktop token). Not `slack todo` (reaction tasks)",
7303
+ "Slack's To-dos tab — your personal to-dos (requires xoxc desktop token). Not `slack todo` (reaction tasks)",
7151
7304
  (y) => y.command(
7152
7305
  ["$0", "ls", "list"],
7153
7306
  "List your To-dos, soonest due first",
@@ -7162,7 +7315,59 @@ async function main(): Promise<void> {
7162
7315
  }
7163
7316
  await cmdTodos(tok(argv as W), ck(argv as W), { filter: argv.filter as TodoFilter, limit: argv.limit, json: argv.json });
7164
7317
  },
7165
- ),
7318
+ )
7319
+ .command(
7320
+ "add <target>",
7321
+ "Add a message to your To-dos (two-step: preview, then --code)",
7322
+ (y2) => y2
7323
+ .positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
7324
+ .option("due", { type: "string", describe: "Due time (ISO datetime or Unix ts)" })
7325
+ .option("saved", { type: "boolean", default: false, describe: "Save it without a checkbox instead of as a to-do" })
7326
+ .option("code", { type: "string", describe: "Confirmation code from the preview" }),
7327
+ async (argv) => {
7328
+ const due = argv.due === undefined ? undefined : todoDue(argv.due);
7329
+ await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "add", ...(due ? { due } : {}), saved: argv.saved }, argv.code);
7330
+ },
7331
+ )
7332
+ .command(
7333
+ ["done <target>", "complete <target>"],
7334
+ "Check a to-do off (--undo reopens it)",
7335
+ (y2) => y2
7336
+ .positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
7337
+ .option("undo", { type: "boolean", default: false, describe: "Mark it not done again" })
7338
+ .option("code", { type: "string", describe: "Confirmation code from the preview" }),
7339
+ async (argv) => {
7340
+ await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "done", undo: argv.undo }, argv.code);
7341
+ },
7342
+ )
7343
+ .command(
7344
+ "edit <target>",
7345
+ "Change a to-do's due date, or switch it between to-do and saved item",
7346
+ (y2) => y2
7347
+ .positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
7348
+ .option("due", { type: "string", describe: "New due time (ISO datetime or Unix ts), or `none` to clear it" })
7349
+ .option("todo", { type: "boolean", describe: "Make it a to-do (with a checkbox)" })
7350
+ .option("saved", { type: "boolean", describe: "Make it a plain saved item (no checkbox)" })
7351
+ .conflicts("todo", "saved")
7352
+ .option("code", { type: "string", describe: "Confirmation code from the preview" }),
7353
+ async (argv) => {
7354
+ const due = argv.due === undefined ? undefined : argv.due === "none" ? 0 : todoDue(argv.due);
7355
+ const todo = argv.todo ? true : argv.saved ? false : undefined;
7356
+ await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, {
7357
+ kind: "edit", ...(due === undefined ? {} : { due }), ...(todo === undefined ? {} : { todo }),
7358
+ }, argv.code);
7359
+ },
7360
+ )
7361
+ .command(
7362
+ ["rm <target>", "remove <target>", "delete <target>"],
7363
+ "Remove a message from your To-dos (the message stays)",
7364
+ (y2) => y2
7365
+ .positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
7366
+ .option("code", { type: "string", describe: "Confirmation code from the preview" }),
7367
+ async (argv) => {
7368
+ await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "rm" }, argv.code);
7369
+ },
7370
+ ),
7166
7371
  )
7167
7372
  .command(
7168
7373
  "drafts",
package/ts/slack.ts CHANGED
@@ -1119,6 +1119,38 @@ export async function savedList(
1119
1119
  return postSession(token, "saved.list", params, cookie);
1120
1120
  }
1121
1121
 
1122
+ // To-dos writes (internal, session token). Verified live 2026-10-26: add with
1123
+ // todo_state=to_do (+ date_due) creates a to-do; update mark=completed checks it
1124
+ // off (todo_state becomes "completed"); delete removes it. A To-do always
1125
+ // points at a message: item_type=message, item_id=channel, ts=message ts.
1126
+ export type SavedRef = { channel: string; ts: string };
1127
+ const savedIds = (r: SavedRef) => ({ item_type: "message", item_id: r.channel, ts: r.ts });
1128
+
1129
+ export async function savedAdd(token: string, ref: SavedRef, opts: { todo?: boolean; due?: number } = {}, cookie?: string): Promise<Json> {
1130
+ const params: Record<string, string> = { ...savedIds(ref), todo_state: opts.todo === false ? "saved" : "to_do" };
1131
+ if (opts.due) params.date_due = String(opts.due);
1132
+ return postSession(token, "saved.add", params, cookie);
1133
+ }
1134
+
1135
+ /** `mark`: completed | uncompleted | archived | unarchived. `due`: epoch seconds, 0 clears it. */
1136
+ export async function savedUpdate(
1137
+ token: string,
1138
+ ref: SavedRef,
1139
+ opts: { mark?: "completed" | "uncompleted" | "archived" | "unarchived"; todo?: boolean; due?: number },
1140
+ cookie?: string,
1141
+ ): Promise<Json> {
1142
+ const params: Record<string, string> = savedIds(ref);
1143
+ if (opts.mark) params.mark = opts.mark;
1144
+ if (opts.todo !== undefined) params.todo_state = opts.todo ? "to_do" : "saved";
1145
+ if (opts.due !== undefined) params.date_due = String(opts.due);
1146
+ return postSession(token, "saved.update", params, cookie);
1147
+ }
1148
+
1149
+ /** Slack answers ok even when nothing was saved — callers check first. */
1150
+ export async function savedDelete(token: string, ref: SavedRef, cookie?: string): Promise<Json> {
1151
+ return postSession(token, "saved.delete", savedIds(ref), cookie);
1152
+ }
1153
+
1122
1154
  // Draft API (internal — requires xoxc session token + xoxd cookie)
1123
1155
  export async function listDrafts(token: string, cookie?: string): Promise<Json> {
1124
1156
  return postSession(token, "drafts.list", {}, cookie);
package/ts/slackTodos.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Slack's To-dos tab (app.slack.com/client/<team>/to-dos) — read-only helpers.
1
+ // Slack's To-dos tab (app.slack.com/client/<team>/to-dos) — parsing helpers.
2
2
  //
3
3
  // Not to be confused with `slack todo` (todo.ts), which tracks tasks as
4
4
  // reactions on messages. To-dos are Slack's own PERSONAL list: it replaced
@@ -26,7 +26,7 @@ export type TodoItem = {
26
26
  due: number;
27
27
  /** Epoch seconds; 0 = not completed. */
28
28
  completed: number;
29
- /** "to_do" | "saved" (as Slack reports it). */
29
+ /** "to_do" | "saved" | "completed" (as Slack reports it). */
30
30
  todoState: string;
31
31
  state: string;
32
32
  archived: boolean;
@@ -68,7 +68,8 @@ export function sortTodos(items: TodoItem[]): TodoItem[] {
68
68
  }
69
69
 
70
70
  export function isDone(t: TodoItem): boolean {
71
- return t.completed > 0 || t.state === "completed";
71
+ // Checking one off sets state AND todo_state to "completed" (seen live).
72
+ return t.completed > 0 || t.state === "completed" || t.todoState === "completed";
72
73
  }
73
74
 
74
75
  /** The status column: a checkbox, plus the due date (and OVERDUE when past it). */