slack-term 1.45.0 → 1.47.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.47.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
@@ -14,7 +14,7 @@ import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
14
14
  import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
15
15
  import { cmdTail, parseSince as parseDuration } from "./tail.ts";
16
16
  import { runStream, webClient } from "./stream.ts";
17
- import { relaySubscriber } from "./relay.ts";
17
+ import { relaySubscriber, relayTransport } from "./relay.ts";
18
18
  import { agentCommands } from "./agent.ts";
19
19
 
20
20
  import {
@@ -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",
@@ -7548,7 +7753,7 @@ async function main(): Promise<void> {
7548
7753
  const relayUrl = process.env.SLACK_RELAY_URL;
7549
7754
  const relayToken = process.env.SLACK_RELAY_TOKEN;
7550
7755
  const relay = argv.relay && !asUser && !argv.once && relayUrl && relayToken
7551
- ? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken), reconcileMs }
7756
+ ? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken, undefined, { transport: relayTransport(process.env.SLACK_RELAY_TRANSPORT) }), reconcileMs }
7552
7757
  : undefined;
7553
7758
  if (argv.relay && asUser && relayUrl) console.error("slack stream: relay not used with --as-user (it only sees the bot's channels)");
7554
7759
  const pollReason = !argv.relay ? "--no-relay"
package/ts/relay.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  // Client for the slack-term relay (worker/): a Cloudflare Worker that receives
2
2
  // Slack's Events API and serves "doorbells" — {channel, ts, thread_ts}, never
3
- // message text — as a resumable Server-Sent Events stream. `slack stream` uses
4
- // it to scan the one channel a bell names within a second of the post, instead
5
- // of waiting for its next poll.
3
+ // message text — as a resumable stream. `slack stream` uses it to scan the one
4
+ // channel a bell names within a second of the post, instead of waiting for its
5
+ // next poll.
6
6
  //
7
- // SSE over fetch (not WebSocket) so the bearer token travels in a header on
8
- // both bun and node, and never in a URL.
7
+ // Transport: a WebSocket to /ws first. The relay accepts it as a hibernatable
8
+ // socket, so its Durable Object is not kept awake (and billed) while we sit
9
+ // connected, and our 25 s "ping" is answered without waking it. The bearer
10
+ // token never goes in a URL: it rides in the Sec-WebSocket-Protocol header as
11
+ // `bearer.<base64url(token)>` — Node's WebSocket cannot set other headers.
12
+ // If the socket cannot be opened at all (a relay from before /ws, a proxy
13
+ // that strips upgrades, a runtime without WebSocket), the same connection
14
+ // falls back to the old Server-Sent Events endpoint, /stream, over fetch.
9
15
 
10
16
  /** A bell: where a message just appeared. */
11
17
  export type Doorbell = {
@@ -18,7 +24,13 @@ export type Doorbell = {
18
24
  * starts from; `gap` means bells after the requested seq are gone (expired,
19
25
  * or the relay was reset), so the client must catch up by polling. `epoch`
20
26
  * names the relay's storage: a new one means its numbering restarted. */
21
- export type Hello = { seq: number; gap: boolean; retention_sec: number; epoch?: string };
27
+ export type Hello = {
28
+ seq: number; gap: boolean; retention_sec: number; epoch?: string;
29
+ /** Client-side: which transport this connection uses. */
30
+ via?: Transport;
31
+ };
32
+
33
+ export type Transport = "websocket" | "sse";
22
34
 
23
35
  /** Where to resume: the last seq taken, in the numbering of `epoch`. */
24
36
  export type Cursor = { seq: number; epoch?: string };
@@ -58,8 +70,158 @@ export function parseSse(buf: string, emit: (event: string, data: string, id: st
58
70
  return buf;
59
71
  }
60
72
 
61
- export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000): Subscribe {
73
+ /** The subprotocol the relay's /ws speaks; it echoes this one back. */
74
+ export const WS_PROTOCOL = "slack-term-relay.v1";
75
+
76
+ /** The subprotocol that carries the bearer token: subprotocols must be HTTP
77
+ * tokens, so the token is base64url-encoded. */
78
+ export function bearerProtocol(token: string): string {
79
+ return `bearer.${Buffer.from(token, "utf8").toString("base64url")}`;
80
+ }
81
+
82
+ /** The slice of the WHATWG WebSocket this client uses (bun's and node's). */
83
+ export type WebSocketLike = {
84
+ readyState: number;
85
+ send(data: string): void;
86
+ close(code?: number, reason?: string): void;
87
+ onopen: ((ev: unknown) => void) | null;
88
+ onmessage: ((ev: { data: unknown }) => void) | null;
89
+ onerror: ((ev: unknown) => void) | null;
90
+ onclose: ((ev: { code: number; reason: string }) => void) | null;
91
+ };
92
+ export type WebSocketCtor = new (url: string, protocols: string[]) => WebSocketLike;
93
+
94
+ export type RelayOptions = {
95
+ /** "auto" (default): WebSocket, falling back to SSE when it cannot open. */
96
+ transport?: "auto" | Transport;
97
+ /** Defaults to the runtime's global WebSocket; null means none. */
98
+ WebSocket?: WebSocketCtor | null;
99
+ /** Keepalive ping interval; the relay answers without waking up. */
100
+ pingMs?: number;
101
+ /** How long a received bell may wait for its ack (acks are batched). */
102
+ ackDelayMs?: number;
103
+ };
104
+
105
+ /** SLACK_RELAY_TRANSPORT: "websocket" or "sse" pins one; anything else is "auto". */
106
+ export function relayTransport(v: string | undefined): "auto" | Transport {
107
+ return v === "websocket" || v === "sse" ? v : "auto";
108
+ }
109
+
110
+ /** Received-but-unacked bells that trigger an ack at once. The relay cuts a
111
+ * client off at 64 unacked live bells, and replays its backlog one batch
112
+ * per ack. */
113
+ const ACK_EVERY = 32;
114
+
115
+ function resumeQuery(after: Cursor | undefined): string {
116
+ return after === undefined ? "" : `?after=${after.seq}${after.epoch ? `&epoch=${encodeURIComponent(after.epoch)}` : ""}`;
117
+ }
118
+
119
+ export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000, opts: RelayOptions = {}): Subscribe {
62
120
  const base = baseUrl.replace(/\/+$/, "");
121
+ const transport = opts.transport ?? "auto";
122
+ const sse = sseSubscriber(base, token, idleMs);
123
+ return async (after, signal, on) => {
124
+ const WS = opts.WebSocket === undefined ? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket : opts.WebSocket;
125
+ if (transport !== "sse") {
126
+ if (!WS) {
127
+ if (transport === "websocket") throw new Error("relay: this runtime has no WebSocket");
128
+ } else {
129
+ const opened = await wsSubscribe(WS, base, token, idleMs, opts.pingMs ?? 25_000, opts.ackDelayMs ?? 100, after, signal, on);
130
+ if (opened || signal.aborted) return;
131
+ // Never opened: an old relay, or something in between refusing the
132
+ // upgrade. SSE says which — a 401 from it is a RelayAuthError.
133
+ if (transport === "websocket") throw new Error("relay: WebSocket connection failed");
134
+ }
135
+ }
136
+ return sse(after, signal, on);
137
+ };
138
+ }
139
+
140
+ /** One WebSocket connection. Resolves true once it has been open (closed by
141
+ * the server normally, or aborted), false if it never opened — the caller
142
+ * then falls back to SSE. Rejects on an abnormal close or silence. */
143
+ function wsSubscribe(
144
+ WS: WebSocketCtor, base: string, token: string, idleMs: number, pingMs: number, ackDelayMs: number,
145
+ after: Cursor | undefined, signal: AbortSignal, on: RelayHandlers,
146
+ ): Promise<boolean> {
147
+ return new Promise<boolean>((resolve, reject) => {
148
+ if (signal.aborted) return resolve(true);
149
+ let ws: WebSocketLike;
150
+ try {
151
+ ws = new WS(`${base.replace(/^http/i, "ws")}/ws${resumeQuery(after)}`, [WS_PROTOCOL, bearerProtocol(token)]);
152
+ } catch {
153
+ return resolve(false);
154
+ }
155
+ let opened = false;
156
+ let done = false;
157
+ let idle: ReturnType<typeof setTimeout> | undefined;
158
+ let ackTimer: ReturnType<typeof setTimeout> | undefined;
159
+ let ping: ReturnType<typeof setInterval> | undefined;
160
+ let last = 0; // highest seq received
161
+ let acked = 0; // highest seq acked
162
+ const finish = (err?: Error): void => {
163
+ if (done) return;
164
+ done = true;
165
+ clearTimeout(idle);
166
+ clearTimeout(ackTimer);
167
+ clearInterval(ping);
168
+ signal.removeEventListener("abort", onAbort);
169
+ ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
170
+ try { ws.close(1000); } catch { /* already closing */ }
171
+ if (err) reject(err);
172
+ else resolve(opened || signal.aborted);
173
+ };
174
+ const onAbort = (): void => finish();
175
+ signal.addEventListener("abort", onAbort, { once: true });
176
+ const arm = (): void => {
177
+ clearTimeout(idle);
178
+ // Silence before the open is a handshake that hangs: fall back.
179
+ idle = setTimeout(() => finish(opened ? new Error(`relay: no data for ${Math.round(idleMs / 1000)}s`) : undefined), idleMs);
180
+ };
181
+ const ack = (): void => {
182
+ clearTimeout(ackTimer);
183
+ ackTimer = undefined;
184
+ if (last <= acked) return;
185
+ acked = last;
186
+ try { ws.send(JSON.stringify({ type: "ack", seq: last })); } catch { /* the close event reports it */ }
187
+ };
188
+ arm();
189
+ ws.onopen = () => {
190
+ opened = true;
191
+ arm();
192
+ ping = setInterval(() => { try { ws.send("ping"); } catch { /* the close event reports it */ } }, pingMs);
193
+ };
194
+ ws.onmessage = (ev) => {
195
+ arm();
196
+ if (typeof ev.data !== "string" || ev.data === "pong") return;
197
+ let m: { type?: unknown; seq?: unknown; bell?: unknown };
198
+ try {
199
+ m = JSON.parse(ev.data) as typeof m;
200
+ } catch (e) {
201
+ return finish(e as Error);
202
+ }
203
+ if (m.type === "hello") {
204
+ const { type: _, ...h } = m as Hello & { type: string };
205
+ last = acked = h.seq;
206
+ on.hello({ ...h, via: "websocket" });
207
+ } else if (m.type === "bell" && typeof m.seq === "number") {
208
+ on.bell(m.seq, m.bell as Doorbell);
209
+ if (done) return; // the handler hung up
210
+ last = Math.max(last, m.seq);
211
+ if (last - acked >= ACK_EVERY) ack();
212
+ else ackTimer ??= setTimeout(ack, ackDelayMs);
213
+ }
214
+ };
215
+ ws.onerror = () => { /* a close event follows */ };
216
+ ws.onclose = (ev) => {
217
+ if (!opened || ev.code === 1000) return finish();
218
+ finish(new Error(`relay: WebSocket closed (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})`));
219
+ };
220
+ });
221
+ }
222
+
223
+ /** The Server-Sent Events transport (GET /stream). */
224
+ function sseSubscriber(base: string, token: string, idleMs: number): Subscribe {
63
225
  return async (after, signal, on) => {
64
226
  const ac = new AbortController();
65
227
  const stop = (): void => ac.abort();
@@ -72,8 +234,7 @@ export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000)
72
234
  };
73
235
  try {
74
236
  arm();
75
- const q = after === undefined ? "" : `?after=${after.seq}${after.epoch ? `&epoch=${encodeURIComponent(after.epoch)}` : ""}`;
76
- const res = await fetch(`${base}/stream${q}`, {
237
+ const res = await fetch(`${base}/stream${resumeQuery(after)}`, {
77
238
  headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" },
78
239
  signal: ac.signal,
79
240
  });
@@ -89,7 +250,7 @@ export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000)
89
250
  // CRLF is normalised on the whole buffer: a \r\n pair can straddle chunks.
90
251
  buf = parseSse((buf + dec.decode(value, { stream: true })).replace(/\r\n/g, "\n"), (event, data, id) => {
91
252
  const j = JSON.parse(data) as unknown;
92
- if (event === "hello") on.hello(j as Hello);
253
+ if (event === "hello") on.hello({ ...j as Hello, via: "sse" });
93
254
  else if (event === "bell" && id !== undefined) on.bell(Number(id), j as Doorbell);
94
255
  });
95
256
  }
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). */
package/ts/stream.ts CHANGED
@@ -733,7 +733,7 @@ export async function runStream(client: StreamClient, opts: StreamOpts): Promise
733
733
  if (bus.live === live) return;
734
734
  bus.live = live;
735
735
  _internals.err(live
736
- ? `slack stream: relay connected — polling every ${Math.round(Math.max(opts.intervalMs, relay!.reconcileMs) / 1000)}s as a safety net`
736
+ ? `slack stream: relay connected${why ? ` (${why})` : ""} — polling every ${Math.round(Math.max(opts.intervalMs, relay!.reconcileMs) / 1000)}s as a safety net`
737
737
  : `slack stream: relay unavailable${why ? ` (${why})` : ""} — polling every ${Math.round(opts.intervalMs / 1000)}s`);
738
738
  ring();
739
739
  };
@@ -777,7 +777,7 @@ export async function runStream(client: StreamClient, opts: StreamOpts): Promise
777
777
  for (const p of [...bus.queue, ...bus.retry, ...(bus.inflight ? [bus.inflight] : [])]) p.seq = Math.min(p.seq, h.seq);
778
778
  }
779
779
  after = { seq: h.seq, ...(h.epoch ? { epoch: h.epoch } : {}) };
780
- setLive(true);
780
+ setLive(true, h.via);
781
781
  },
782
782
  bell: (seq, bell) => {
783
783
  if (paused) return;