slack-term 1.44.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/ts/cli.ts CHANGED
@@ -20,6 +20,8 @@ import { agentCommands } from "./agent.ts";
20
20
  import {
21
21
  ASK_KEYCAPS,
22
22
  ASK_MARKER,
23
+ ASK_OTHER_MARKER,
24
+ ASK_OTHER_MARKERS,
23
25
  ASK_EXPLAIN_MARKER,
24
26
  ASK_RESOLVED_MARKER,
25
27
  ASK_MAX_REACTION_CHOICES,
@@ -27,6 +29,7 @@ import {
27
29
  askIsExplainNote,
28
30
  askBuildResolvedText,
29
31
  askParseMessage,
32
+ askSeedMarker,
30
33
  askExplainReject,
31
34
  askFlatten,
32
35
  askMatchChoice,
@@ -114,7 +117,10 @@ import {
114
117
  userName,
115
118
  getPath,
116
119
  type Json,
120
+ savedAdd,
121
+ savedDelete,
117
122
  savedList,
123
+ savedUpdate,
118
124
  } from "./slack.ts";
119
125
  import { isDone, messagePermalink, parseSavedItems, sortTodos, TODO_FILTERS, todoStatus, type TodoFilter, type TodoItem } from "./slackTodos.ts";
120
126
  import {
@@ -880,8 +886,13 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
880
886
  }
881
887
 
882
888
  // --- todos (Slack's To-dos tab) ---
883
- // Read-only. Pages through saved.list up to `limit` items, then shows each
884
- // 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
+
885
896
  async function cmdTodos(
886
897
  token: string,
887
898
  cookie: string | undefined,
@@ -889,12 +900,14 @@ async function cmdTodos(
889
900
  ): Promise<void> {
890
901
  const items: TodoItem[] = [];
891
902
  let cursor: string | undefined;
903
+ let pages = 0;
892
904
  do {
893
- 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);
894
906
  items.push(...parseSavedItems(resp as Json));
895
907
  const next = asRecord(resp.response_metadata).next_cursor;
896
908
  cursor = typeof next === "string" && next ? next : undefined;
897
- } 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.`);
898
911
  const shown = sortTodos(items).slice(0, opts.limit);
899
912
 
900
913
  if (shown.length === 0) {
@@ -938,6 +951,139 @@ async function cmdTodos(
938
951
  }
939
952
  }
940
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
+
941
1087
  // --- drafts get ---
942
1088
  async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
943
1089
  const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
@@ -2612,6 +2758,9 @@ interface AskWaitCtx {
2612
2758
  * nothing". */
2613
2759
  overflow: string[];
2614
2760
  threadOnly: boolean;
2761
+ /** The marker reaction this question was seeded with (➕, or ❓ on a
2762
+ * free-text question or one posted before ➕) — what the ✅ swap takes off. */
2763
+ seedMarker: string;
2615
2764
  /** The language the question's copy is in — the ✅ stamp and the
2616
2765
  * invalid-ballot line are written in the same one. */
2617
2766
  lang: AskLang;
@@ -2869,17 +3018,21 @@ function askPoller(token: string, ctx: AskWaitCtx, readOnly = false): {
2869
3018
  }
2870
3019
  }
2871
3020
 
2872
- /** Who has pressed ❓ ("other"). Not an answer — ❓ carries no text — so it
2873
- * never resolves anything; the reply that should follow it does. Reported
2874
- * once per person so the operator knows a reply is on its way rather than
2875
- * reading the silence as "nobody looked". */
3021
+ /** Who has pressed ➕ ("other") — or ❓, the other pill on questions posted
3022
+ * before ➕, which is still pressed out of habit. Not an answer — neither
3023
+ * carries text — so it never resolves anything; the reply that should
3024
+ * follow it does. Reported once per person so the operator knows a reply is
3025
+ * on its way rather than reading the silence as "nobody looked". */
2876
3026
  const otherReported = new Set<string>();
2877
3027
  function noteOther(msg: Record<string, Json>): void {
2878
- const r = asArray(msg.reactions).map(asRecord).find((x) => x.name === ASK_MARKER);
2879
- for (const u of r ? asArray(r.users).filter(isAnswerer) : []) {
2880
- if (otherReported.has(u)) continue;
2881
- otherReported.add(u);
2882
- if (!readOnly) console.error(` (<@${u}> が ❓ その他 を押しました。返信を待ちます)`);
3028
+ for (const r of asArray(msg.reactions).map(asRecord)) {
3029
+ if (typeof r.name !== "string" || !ASK_OTHER_MARKERS.includes(r.name)) continue;
3030
+ const glyph = r.name === ASK_OTHER_MARKER ? "➕" : "❓";
3031
+ for (const u of asArray(r.users).filter(isAnswerer)) {
3032
+ if (otherReported.has(u)) continue;
3033
+ otherReported.add(u);
3034
+ if (!readOnly) console.error(` (<@${u}> が ${glyph} その他 を押しました。返信を待ちます)`);
3035
+ }
2883
3036
  }
2884
3037
  }
2885
3038
 
@@ -3158,12 +3311,12 @@ async function askWaitForAnswer(token: string, ctx: AskWaitCtx): Promise<never>
3158
3311
  const keep = state.ownText ? askResolvedKeep(state.ownText, found.choice) : undefined;
3159
3312
  await editMessage(token, channelId, ts, askBuildResolvedText(question, found, who, ctx.lang, keep), cookie, true);
3160
3313
  // Swap the marker for the resolved one so search reflects reality:
3161
- // `has::question:` should list what still needs answering, not everything
3162
- // ever asked. Removal last — a crash between the two leaves the question
3314
+ // `has::question: OR has::heavy_plus_sign:` should list what still needs
3315
+ // answering, not everything ever asked. Removal last — a crash between the two leaves the question
3163
3316
  // findable under BOTH, which is recoverable; the reverse loses it.
3164
3317
  try {
3165
3318
  await reactionAdd(token, channelId, ts, ASK_RESOLVED_MARKER, cookie);
3166
- await reactionRemove(token, channelId, ts, ASK_MARKER, cookie);
3319
+ await reactionRemove(token, channelId, ts, ctx.seedMarker, cookie);
3167
3320
  } catch {
3168
3321
  // Cosmetic: the ✅ in the body is what actually marks it resolved.
3169
3322
  }
@@ -3513,10 +3666,12 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3513
3666
  const ts = await slackSend(token, channelId, message, threadTs, false, cookie, true, attr.metadata);
3514
3667
 
3515
3668
  // The marker goes on LAST, after 1,2,3, because it doubles as the "other"
3516
- // choice the body lists under the numbered ones (people were already pressing
3517
- // ❓ for "none of these fit"). It is a reaction so
3518
- // `has::question:` lists every question the way `has::pushpin:` lists every
3519
- // todo. Body text cannot do that job: Slack's index splits on punctuation, so
3669
+ // choice the body lists under the numbered ones: ➕ (ASK_OTHER_MARKER). A
3670
+ // free-text question has no other choice and seeds ❓. It is a reaction so
3671
+ // `has::question: OR has::heavy_plus_sign:` lists every open question the
3672
+ // way `has::pushpin:` lists every todo — though ➕ is a common emoji, so that
3673
+ // search can also match unrelated messages (`slack ask --ls` reads the local
3674
+ // sent-log and is unaffected). Body text cannot do that job: Slack's index splits on punctuation, so
3520
3675
  // a `:question:` written in the text is indexed as the word "question" and
3521
3676
  // matches unrelated messages (measured). Only a real reaction is searchable.
3522
3677
  //
@@ -3527,14 +3682,15 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3527
3682
  // enough: measured on two real questions from the same build, one came back
3528
3683
  // `two|one|question|three`. See ts/reactionSeed.ts for the evidence and for
3529
3684
  // SLACK_REACTION_SEED_GAP_MS.
3685
+ const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
3530
3686
  await seedReactionsInOrder(
3531
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
3687
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), seedMarker, ...(explain ? [ASK_EXPLAIN_MARKER] : [])],
3532
3688
  (name) => reactionAdd(token, channelId, ts, name, cookie),
3533
3689
  (name, e) => {
3534
3690
  const why = e instanceof Error ? e.message : String(e);
3535
3691
  // The marker is cosmetic + searchability; a keycap only costs the
3536
3692
  // answerer a trip to the emoji picker. Neither abandons a posted question.
3537
- if (name === ASK_MARKER) console.error(` (marker ${name} を付けられませんでした: ${why})`);
3693
+ if (name === seedMarker) console.error(` (marker ${name} を付けられませんでした: ${why})`);
3538
3694
  else console.error(` (リアクション ${name} を付けられませんでした: ${why})`);
3539
3695
  },
3540
3696
  );
@@ -3575,6 +3731,7 @@ async function cmdAsk(token: string, args: AskArgs): Promise<void> {
3575
3731
  reactable,
3576
3732
  overflow,
3577
3733
  threadOnly,
3734
+ seedMarker,
3578
3735
  lang,
3579
3736
  audience,
3580
3737
  broadcast,
@@ -3667,7 +3824,7 @@ async function askLoadOwnOpen(token: string, link: string, self: Self | null, as
3667
3824
  if (parsed.kind === "void") return "void";
3668
3825
  if (parsed.kind === "resolved") { console.error(`✗ ${shown}: 回答済みの質問です(回答: ${stripTerminalControls(parsed.answer.split("\n")[0] ?? "")})— 変更できません`); return null; }
3669
3826
  if (parsed.kind !== "open") { console.error(`✗ ${shown}: \`slack ask\` の質問として読み取れません — ${askExplainReject(text)}`); return null; }
3670
- const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, msg };
3827
+ const out: AskVoidTarget & { msg: Record<string, Json>; threadTs?: string } = { link, channelId: got.channelId, ts: got.ts, parsed, seedMarker: askSeedMarker(text) ?? ASK_MARKER, msg };
3671
3828
  if (got.threadTs) out.threadTs = got.threadTs;
3672
3829
  return out;
3673
3830
  }
@@ -3695,7 +3852,7 @@ async function askHasResponses(token: string, t: { channelId: string; ts: string
3695
3852
  }
3696
3853
 
3697
3854
  /** `slack ask --edit=<link> ["new question" [choices…]] [--body …]` — change an
3698
- * open question THROUGH the ask builder, so pills, ❓, the instructions and the
3855
+ * open question THROUGH the ask builder, so pills, ➕, the instructions and the
3699
3856
  * body stay in the layout collect reads back (a free-hand `slack edit` is what
3700
3857
  * this replaces; `edit` now refuses ask messages without --force).
3701
3858
  *
@@ -3778,17 +3935,32 @@ async function cmdAskEdit(token: string, args: {
3778
3935
  }
3779
3936
 
3780
3937
  await editMessage(token, t.channelId, t.ts, next, args.cookie, true);
3938
+ // The body was rebuilt, so its other line now says ➕ (or there is none, on
3939
+ // a free-text question) — the marker seed follows it.
3940
+ const seedMarker = reactable.length ? ASK_OTHER_MARKER : ASK_MARKER;
3941
+ const oldSeed = askSeedMarker(oldText) ?? ASK_MARKER;
3781
3942
  if (choicesChanged) {
3782
- // Re-seed in order: 1..n then ❓ last, as a fresh question has them.
3783
- const tail = old.explain ? [ASK_MARKER, ASK_EXPLAIN_MARKER] : [ASK_MARKER];
3784
- for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail]) {
3943
+ // Re-seed in order: 1..n then ➕ last, as a fresh question has them. The
3944
+ // old marker comes off as whatever it was seeded as (❓ on an old pill).
3945
+ const tail = (m: string) => old.explain ? [m, ASK_EXPLAIN_MARKER] : [m];
3946
+ for (const name of [...old.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(oldSeed)]) {
3785
3947
  try { await reactionRemove(token, t.channelId, t.ts, name, args.cookie); } catch { /* already gone */ }
3786
3948
  }
3787
3949
  await seedReactionsInOrder(
3788
- [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail],
3950
+ [...reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ...tail(seedMarker)],
3789
3951
  (name) => reactionAdd(token, t.channelId, t.ts, name, args.cookie),
3790
3952
  (name, e) => console.error(` (リアクション ${name} を付けられませんでした: ${e instanceof Error ? e.message : String(e)})`),
3791
3953
  );
3954
+ } else if (oldSeed !== seedMarker) {
3955
+ // An old ❓ pill edited without touching its choices: the body now lists
3956
+ // ➕, so swap the seed to match. Add before remove, as the ✅ swap does —
3957
+ // a failure in between leaves both, which still collects.
3958
+ try {
3959
+ await reactionAdd(token, t.channelId, t.ts, seedMarker, args.cookie);
3960
+ await reactionRemove(token, t.channelId, t.ts, oldSeed, args.cookie);
3961
+ } catch (e: unknown) {
3962
+ console.error(` (marker を ${seedMarker} に替えられませんでした: ${e instanceof Error ? e.message : String(e)})`);
3963
+ }
3792
3964
  }
3793
3965
  console.error(`✓ 編集しました: ${stripTerminalControls(args.link)}`);
3794
3966
  }
@@ -4031,9 +4203,12 @@ interface AskVoidTarget {
4031
4203
  channelId: string;
4032
4204
  ts: string;
4033
4205
  parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>;
4206
+ /** The marker reaction it was seeded with (➕, or ❓ on an old pill / free
4207
+ * text) — what the void takes off. */
4208
+ seedMarker: string;
4034
4209
  }
4035
4210
 
4036
- /** Void one question: the 🚫 text (body kept), our seeds and ❓ off, 🚫 on.
4211
+ /** Void one question: the 🚫 text (body kept), our seeds and marker (➕, or ❓) off, 🚫 on.
4037
4212
  * The text is what marks it void; the reactions are cleanup + searchability,
4038
4213
  * so a failure there is reported and does not undo the void. */
4039
4214
  async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, supersededBy: string | undefined, cookie?: string): Promise<boolean> {
@@ -4045,7 +4220,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
4045
4220
  console.error(`✗ ${shown}: 書き換えに失敗しました: ${e instanceof Error ? e.message : String(e)}`);
4046
4221
  return false;
4047
4222
  }
4048
- for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), ASK_MARKER, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
4223
+ for (const name of [...t.parsed.reactable.map((_, i) => ASK_KEYCAPS[i]!.name), t.seedMarker, ...(t.parsed.explain ? [ASK_EXPLAIN_MARKER] : [])]) {
4049
4224
  try { await reactionRemove(token, t.channelId, t.ts, name, cookie); } catch { /* not ours / already gone */ }
4050
4225
  }
4051
4226
  try {
@@ -4068,7 +4243,7 @@ async function askApplyVoid(token: string, t: AskVoidTarget, reason: string, sup
4068
4243
  *
4069
4244
  * What voiding does, per question: the text becomes the 🚫 form (head, void
4070
4245
  * stamp with the reason, optional pointer to the replacement, the body); our
4071
- * seeded pills and the ❓ marker come off; a 🚫 reaction goes on. Pills someone
4246
+ * seeded pills and the marker (➕, or ❓) come off; a 🚫 reaction goes on. Pills someone
4072
4247
  * already pressed stay — Slack lets only their owner remove them — and are
4073
4248
  * never collected, because a void is never polled again.
4074
4249
  *
@@ -4101,7 +4276,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4101
4276
  process.exit(ASK_EXIT_ERROR);
4102
4277
  }
4103
4278
  }
4104
- type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; pressed: string[]; unreadable?: boolean };
4279
+ type Target = { link: string; channelId: string; ts: string; text: string; parsed: Extract<ReturnType<typeof askParseMessage>, { kind: "open" }>; seedMarker: string; pressed: string[]; unreadable?: boolean };
4105
4280
  const targets: Target[] = [];
4106
4281
  let refused = 0;
4107
4282
  for (const link of args.links) {
@@ -4144,11 +4319,16 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4144
4319
  };
4145
4320
  unreadable = true;
4146
4321
  }
4322
+ // Our marker: what the body names, or — for an unreadable body — ➕ if we
4323
+ // seeded one, else ❓ (an old pill, or free text).
4324
+ const seedMarker = unreadable
4325
+ ? (asArray(msg.reactions).map(asRecord).some((r) => r.name === ASK_OTHER_MARKER && asArray(r.users).includes(self?.userId ?? "\u0000")) ? ASK_OTHER_MARKER : ASK_MARKER)
4326
+ : askSeedMarker(text) ?? ASK_MARKER;
4147
4327
  const pressed = asArray(msg.reactions).map(asRecord)
4148
4328
  .filter((r) => typeof r.name === "string" && ASK_KEYCAPS.some((k) => k.name === r.name))
4149
4329
  .filter((r) => asArray(r.users).some((u) => u !== self?.userId))
4150
4330
  .map((r) => `:${String(r.name)}:`);
4151
- targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, pressed, ...(unreadable ? { unreadable } : {}) });
4331
+ targets.push({ link, channelId: got.channelId, ts: got.ts, text, parsed, seedMarker, pressed, ...(unreadable ? { unreadable } : {}) });
4152
4332
  }
4153
4333
  if (!targets.length) {
4154
4334
  console.error(refused ? "無効化できる質問がありません。" : "何もすることがありません。");
@@ -4166,7 +4346,7 @@ async function cmdAskVoid(token: string, args: { links: string[]; reason: string
4166
4346
  ...targets.map((t) => ` • ${stripTerminalControls(t.link)}\n ${stripTerminalControls(t.parsed.question.split("\n")[0] ?? "").slice(0, 100)}` +
4167
4347
  (t.pressed.length ? `\n (already pressed: ${t.pressed.join(" ")} — left in place, never collected)` : "") +
4168
4348
  (t.unreadable ? `\n ⚠ its layout no longer reads as an ask (edited by hand?) — voided from a best-effort read: question and the rest of the text kept as they are` : "")),
4169
- ` Each becomes 🚫 (body kept, options and instructions removed); our pills and ❓ come off; a 🚫 reaction is added.`,
4349
+ ` Each becomes 🚫 (body kept, options and instructions removed); our pills and marker (➕, or ❓) come off; a 🚫 reaction is added.`,
4170
4350
  ...(refused ? [` (${refused} link(s) above were refused and will be skipped)`] : []),
4171
4351
  `----------------------------------------------`,
4172
4352
  ]);
@@ -4350,6 +4530,7 @@ function askWaitCtxFrom(
4350
4530
  reactable: parsed.reactable,
4351
4531
  overflow: parsed.overflow,
4352
4532
  threadOnly: parsed.threadOnly,
4533
+ seedMarker: askSeedMarker(q.text) ?? ASK_MARKER,
4353
4534
  lang: parsed.lang,
4354
4535
  audience: q.audience,
4355
4536
  broadcast: q.broadcast,
@@ -4714,6 +4895,16 @@ async function botDmForTarget(
4714
4895
  return openDm(botToken, userId);
4715
4896
  }
4716
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
+
4717
4908
  function parsePostAt(at: string): number {
4718
4909
  if (/^\d{10,}$/.test(at)) return parseInt(at, 10);
4719
4910
  const d = new Date(at.replace(" ", "T"));
@@ -5919,7 +6110,7 @@ async function main(): Promise<void> {
5919
6110
  .option("limit", { type: "number", default: 50, describe: "With --ls: how many recent asks to check" })
5920
6111
  .option("reason", { type: "string", describe: "With --void: why it is void (written into the message)" })
5921
6112
  .option("superseded-by", { type: "string", describe: "With --void: permalink of the question that replaces it" })
5922
- .option("explain", { type: "boolean", describe: "Also offer an ℹ️ 'explain' pill (seeded after ❓): pressing it asks YOU to explain the question in its thread and is never an answer — the question stays open. --wait/--waitFor report it on stderr (and as `explain` in --json), and post one thread note per question. Default: $SLACK_TERM_ASK_EXPLAIN=1 turns it on; --no-explain turns it off." })
6113
+ .option("explain", { type: "boolean", describe: "Also offer an ℹ️ 'explain' pill (seeded after ➕): pressing it asks YOU to explain the question in its thread and is never an answer — the question stays open. --wait/--waitFor report it on stderr (and as `explain` in --json), and post one thread note per question. Default: $SLACK_TERM_ASK_EXPLAIN=1 turns it on; --no-explain turns it off." })
5923
6114
  .option("explain-exit", { type: "boolean", default: false, describe: "With --wait / --waitFor: exit 7 (stdout empty; --json status \"explain\") when someone presses ℹ️ — once per question, on the run that posts the thread note. The question stays open: explain, then collect again." })
5924
6115
  .option("json", { type: "boolean", default: false, describe: "With --pending: one JSON object per answered question (sender, answer, collect command). With --wait / --waitFor: print ONE JSON object on stdout — {status, answer, how, who, notes:[{ts,user,text,permalink}], cursor, permalink} — instead of the bare answer. `notes` are the audience's replies that are not the answer (thread replies; plain replies too in a 1:1 DM). Exit codes unchanged." })
5925
6116
  .option("timeout", { type: "number", default: 3600, describe: "Overall limit for --wait / --waitFor, in seconds (0 with --waitFor = check once)" })
@@ -7109,7 +7300,7 @@ async function main(): Promise<void> {
7109
7300
  )
7110
7301
  .command(
7111
7302
  "todos",
7112
- "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)",
7113
7304
  (y) => y.command(
7114
7305
  ["$0", "ls", "list"],
7115
7306
  "List your To-dos, soonest due first",
@@ -7124,7 +7315,59 @@ async function main(): Promise<void> {
7124
7315
  }
7125
7316
  await cmdTodos(tok(argv as W), ck(argv as W), { filter: argv.filter as TodoFilter, limit: argv.limit, json: argv.json });
7126
7317
  },
7127
- ),
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
+ ),
7128
7371
  )
7129
7372
  .command(
7130
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). */