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/README.md +3 -0
- package/SKILL.md +14 -3
- package/dist/cli.js +203 -202
- package/package.json +1 -1
- package/ts/botIntent.ts +1 -0
- package/ts/cli.ts +213 -8
- package/ts/relay.ts +171 -10
- package/ts/slack.ts +32 -0
- package/ts/slackTodos.ts +4 -3
- package/ts/stream.ts +2 -2
package/package.json
CHANGED
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.
|
|
887
|
-
//
|
|
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:
|
|
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 &&
|
|
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 (
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
//
|
|
8
|
-
//
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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) —
|
|
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
|
-
|
|
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;
|