slack-term 1.42.0 → 1.44.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/newlines.ts ADDED
@@ -0,0 +1,158 @@
1
+ // Line breaks for messages Slack stored flattened.
2
+ //
3
+ // WHY: `send`/`edit`/`schedule` attach a `markdown` block so Slack renders
4
+ // markdown. Whenever a message has blocks, Slack stores its top-level `text`
5
+ // with every newline turned into a space. The rendered message looks right,
6
+ // but every reader of `.text` sees one long line: `read --format jsonl`, ask's
7
+ // parser, and any script on conversations.history.
8
+ // Measured 2026-10-07:
9
+ // - 126 of 127 multi-line sends were stored that way;
10
+ // - the stored text is the source with `\n` → ` ` plus Slack's escaping;
11
+ // - a live send with our own rich_text blocks was flattened the same way, so
12
+ // no blocks keep `.text`, only a plain post does (`ask`, `poll`).
13
+ //
14
+ // The blocks still carry the breaks. Readers use them to put the breaks back.
15
+
16
+ import type { Json } from "./slack.ts";
17
+
18
+ const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
19
+
20
+ /** What must appear, in order, in the message's stored `text`: one character,
21
+ * or (an emoji) any one of several spellings. */
22
+ type Anchor = string | string[];
23
+
24
+ /** The glyph forms of an emoji element (`unicode: "2705-fe0f"`), with and
25
+ * without the variation selector — the stored text may hold either. */
26
+ function emojiGlyphs(unicode: Json | undefined): string[] {
27
+ if (typeof unicode !== "string" || !/^[0-9a-f]+(-[0-9a-f]+)*$/i.test(unicode)) return [];
28
+ const cps = unicode.split("-").map((h) => parseInt(h, 16));
29
+ const full = String.fromCodePoint(...cps);
30
+ const bare = String.fromCodePoint(...cps.filter((c) => c !== 0xfe0f));
31
+ return full === bare ? [full] : [full, bare];
32
+ }
33
+
34
+ /** The anchors of `blocks`, with `null` wherever the blocks put a line break. */
35
+ function anchors(blocks: Json[]): Array<Anchor | null> {
36
+ const out: Array<Anchor | null> = [];
37
+ const chars = (s: string) => { for (const ch of s) { if (ch === "\n") out.push(null); else if (!/\s/u.test(ch)) out.push(...(ESC[ch] ?? ch)); } };
38
+ const inline = (e: Record<string, Json>) => {
39
+ switch (e.type) {
40
+ case "text": chars(String(e.text ?? "")); break;
41
+ case "link": case "message_mention": chars(String(e.url ?? "")); break;
42
+ case "user": chars(String(e.user_id ?? "")); break;
43
+ case "channel": chars(String(e.channel_id ?? "")); break;
44
+ case "usergroup": chars(String(e.usergroup_id ?? "")); break;
45
+ case "broadcast": chars(String(e.range ?? "")); break;
46
+ // `:name:` in most stored texts, but a literal glyph in some.
47
+ case "emoji": out.push([`:${String(e.name ?? "")}:`, ...emojiGlyphs(e.unicode)].filter((x) => x !== "::")); break;
48
+ default: break;
49
+ }
50
+ };
51
+ const container = (b: Record<string, Json>) => {
52
+ const els = Array.isArray(b.elements) ? (b.elements as Record<string, Json>[]) : [];
53
+ if (b.type === "rich_text_list" || b.type === "rich_text") {
54
+ els.forEach((x, k) => { if (k > 0) out.push(null); container(x); });
55
+ } else {
56
+ els.forEach(inline);
57
+ }
58
+ };
59
+ blocks.forEach((b, k) => {
60
+ if (k > 0) out.push(null);
61
+ const r = b as Record<string, Json>;
62
+ if (r.type === "rich_text") container(r);
63
+ });
64
+ return out;
65
+ }
66
+
67
+ /** Earliest position at or after `from` where `a` (any of its spellings)
68
+ * occurs in `chars`, and its length in code points; [-1, 0] if nowhere. */
69
+ function find(chars: string[], a: Anchor, from: number): [number, number] {
70
+ if (typeof a === "string") {
71
+ let q = from;
72
+ while (q < chars.length && chars[q] !== a) q++;
73
+ return q < chars.length ? [q, 1] : [-1, 0];
74
+ }
75
+ let best: [number, number] = [-1, 0];
76
+ for (const alt of a) {
77
+ const cps = [...alt];
78
+ for (let q = from; q + cps.length <= chars.length && (best[0] < 0 || q < best[0]); q++) {
79
+ if (cps.every((c, k) => chars[q + k] === c)) { best = [q, cps.length]; break; }
80
+ }
81
+ }
82
+ return best;
83
+ }
84
+
85
+ /** `text` with its line breaks put back, for a message Slack stored flattened
86
+ * (it had blocks — see the top of this file). Slack replaced
87
+ * every `\n` of the source with one space; the `rich_text` blocks still carry
88
+ * the breaks. Walk the blocks' visible characters through `text` and, at each
89
+ * place the blocks break a line, turn the spaces there back into newlines.
90
+ *
91
+ * Only ever turns spaces into newlines, so the result is the stored text when
92
+ * breaks are ignored — what a reader saw before, never anything new. Returns
93
+ * `text` unchanged unless it is single-line and the blocks are multi-line,
94
+ * or when the two cannot be aligned. */
95
+ export function restoreNewlines(text: string, blocks: Json | undefined): string {
96
+ if (text.includes("\n") || !Array.isArray(blocks) || !blocks.some((b) => (b as Record<string, Json>)?.type === "rich_text")) return text;
97
+ const seq = anchors(blocks);
98
+ if (!seq.includes(null)) return text;
99
+ const chars = [...text];
100
+ // Map each anchor to its position in `text`; a code block at either end
101
+ // also has a break between its fence and its first/last character.
102
+ const elems = (blocks[0] as Record<string, Json>).elements;
103
+ const lastBlock = blocks[blocks.length - 1] as Record<string, Json>;
104
+ const lastElems = lastBlock.elements;
105
+ const startsPre = Array.isArray(elems) && (elems[0] as Record<string, Json>)?.type === "rich_text_preformatted";
106
+ const endsPre = Array.isArray(lastElems) && (lastElems[lastElems.length - 1] as Record<string, Json>)?.type === "rich_text_preformatted";
107
+ // `last` = -1 before the first anchor, so a leading break covers the gap
108
+ // from the start of the text.
109
+ let p = 0, last = -1, pendingBreak = startsPre;
110
+ const breaks: Array<[number, number]> = [];
111
+ for (const a of seq) {
112
+ if (a === null) { pendingBreak = true; continue; }
113
+ const [q, len] = find(chars, a, p);
114
+ if (q < 0) return text;
115
+ if (pendingBreak) { breaks.push([last + 1, q]); pendingBreak = false; }
116
+ last = q + len - 1; p = q + len;
117
+ }
118
+ if ((pendingBreak || endsPre) && last >= 0) breaks.push([last + 1, chars.length]);
119
+ const MARKER = /^(?:[-*+•]|\d{1,9}[.)]|&gt;|>)$/;
120
+ for (const [gapFrom, to] of breaks) {
121
+ // The gap between two visible characters holds the old newline(s) as
122
+ // spaces plus markup: the rest of a `<url|label>` (skip past its `>`),
123
+ // emphasis, `- `/`1. `/`&gt; ` markers, code fences. A space after a
124
+ // marker that itself starts a line is the marker's own, not a break.
125
+ let k = gapFrom;
126
+ // Inside a `<url|label>` token (its `<` opened before the gap and is not
127
+ // yet closed) the rest of the token up to its `>` is not a line break.
128
+ if (chars.lastIndexOf("<", gapFrom - 1) > chars.lastIndexOf(">", gapFrom - 1)) {
129
+ const close = chars.indexOf(">", gapFrom);
130
+ if (close !== -1 && close < to) k = close + 1;
131
+ }
132
+ let word = "", lineStart = gapFrom === 0;
133
+ while (k < to) {
134
+ if (chars[k] === " ") {
135
+ let e = k; while (e < to && chars[e] === " ") e++;
136
+ if (lineStart && MARKER.test(word)) {
137
+ lineStart = false;
138
+ } else {
139
+ // One space per old newline — but three or more are a newline
140
+ // followed by indentation (`\n - nested`), not a run of blank lines.
141
+ const n = e - k <= 2 ? e - k : 1;
142
+ for (let x = k; x < k + n; x++) chars[x] = "\n";
143
+ lineStart = true;
144
+ }
145
+ word = ""; k = e;
146
+ } else { word += chars[k]; k++; }
147
+ }
148
+ }
149
+ return chars.join("");
150
+ }
151
+
152
+ /** `restoreNewlines` applied in place to a conversations.history /
153
+ * conversations.replies message, so every reader of `.text` sees the breaks. */
154
+ export function repairMessageText(m: Json): void {
155
+ if (!m || typeof m !== "object" || Array.isArray(m)) return;
156
+ const r = m as Record<string, Json>;
157
+ if (typeof r.text === "string") r.text = restoreNewlines(r.text, r.blocks);
158
+ }
package/ts/relay.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Client for the slack-term relay (worker/): a Cloudflare Worker that receives
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.
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.
9
+
10
+ /** A bell: where a message just appeared. */
11
+ export type Doorbell = {
12
+ channel: string; ts: string; thread_ts?: string;
13
+ /** The relay app's bot user id, on that bot's own posts only. */
14
+ self?: string;
15
+ };
16
+
17
+ /** First frame of every connection. `seq` is the resume point the relay
18
+ * starts from; `gap` means bells after the requested seq are gone (expired,
19
+ * or the relay was reset), so the client must catch up by polling. `epoch`
20
+ * 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 };
22
+
23
+ /** Where to resume: the last seq taken, in the numbering of `epoch`. */
24
+ export type Cursor = { seq: number; epoch?: string };
25
+
26
+ export type RelayHandlers = {
27
+ hello(h: Hello): void;
28
+ bell(seq: number, b: Doorbell): void;
29
+ };
30
+
31
+ /** Subscribe from `after` (undefined = from now). Resolves when the server
32
+ * closes the stream; rejects on HTTP errors, network errors and silence
33
+ * longer than `idleMs` (the relay sends a keepalive every 25 s). */
34
+ export type Subscribe = (after: Cursor | undefined, signal: AbortSignal, on: RelayHandlers) => Promise<void>;
35
+
36
+ export class RelayAuthError extends Error {}
37
+
38
+ /** Parse SSE frames out of a growing buffer; returns the unparsed tail. */
39
+ export function parseSse(buf: string, emit: (event: string, data: string, id: string | undefined) => void): string {
40
+ let i: number;
41
+ while ((i = buf.indexOf("\n\n")) >= 0) {
42
+ const frame = buf.slice(0, i);
43
+ buf = buf.slice(i + 2);
44
+ let event = "message";
45
+ let id: string | undefined;
46
+ const data: string[] = [];
47
+ for (const line of frame.split("\n")) {
48
+ if (!line || line.startsWith(":")) continue; // keepalive / comment
49
+ const c = line.indexOf(":");
50
+ const field = c < 0 ? line : line.slice(0, c);
51
+ const value = c < 0 ? "" : line.slice(c + 1).replace(/^ /, "");
52
+ if (field === "event") event = value;
53
+ else if (field === "data") data.push(value);
54
+ else if (field === "id") id = value;
55
+ }
56
+ if (data.length) emit(event, data.join("\n"), id);
57
+ }
58
+ return buf;
59
+ }
60
+
61
+ export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000): Subscribe {
62
+ const base = baseUrl.replace(/\/+$/, "");
63
+ return async (after, signal, on) => {
64
+ const ac = new AbortController();
65
+ const stop = (): void => ac.abort();
66
+ signal.addEventListener("abort", stop, { once: true });
67
+ let idle: ReturnType<typeof setTimeout> | undefined;
68
+ let silent = false;
69
+ const arm = (): void => {
70
+ clearTimeout(idle);
71
+ idle = setTimeout(() => { silent = true; ac.abort(); }, idleMs);
72
+ };
73
+ try {
74
+ arm();
75
+ const q = after === undefined ? "" : `?after=${after.seq}${after.epoch ? `&epoch=${encodeURIComponent(after.epoch)}` : ""}`;
76
+ const res = await fetch(`${base}/stream${q}`, {
77
+ headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" },
78
+ signal: ac.signal,
79
+ });
80
+ if (res.status === 401) throw new RelayAuthError("relay rejected the token (401)");
81
+ if (!res.ok || !res.body) throw new Error(`relay: HTTP ${res.status}`);
82
+ const reader = res.body.getReader();
83
+ const dec = new TextDecoder();
84
+ let buf = "";
85
+ for (;;) {
86
+ const { done, value } = await reader.read();
87
+ if (done) return;
88
+ arm();
89
+ // CRLF is normalised on the whole buffer: a \r\n pair can straddle chunks.
90
+ buf = parseSse((buf + dec.decode(value, { stream: true })).replace(/\r\n/g, "\n"), (event, data, id) => {
91
+ const j = JSON.parse(data) as unknown;
92
+ if (event === "hello") on.hello(j as Hello);
93
+ else if (event === "bell" && id !== undefined) on.bell(Number(id), j as Doorbell);
94
+ });
95
+ }
96
+ } catch (e) {
97
+ if (signal.aborted) return;
98
+ if (silent) throw new Error(`relay: no data for ${Math.round(idleMs / 1000)}s`);
99
+ throw e;
100
+ } finally {
101
+ clearTimeout(idle);
102
+ signal.removeEventListener("abort", stop);
103
+ }
104
+ };
105
+ }
package/ts/slack.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Slack Web API client (user token, Authorization: Bearer)
2
2
  import { resolveCookie } from "./profiles.ts";
3
3
  import { linkifyRichText } from "./urlGuard.ts";
4
+ import { repairMessageText } from "./newlines.ts";
4
5
 
5
6
  export class RateLimitError extends Error {
6
7
  retryAfter: number;
@@ -248,7 +249,21 @@ export async function history(
248
249
  if (oldest !== undefined) params.oldest = oldest;
249
250
  if (cursor !== undefined) params.cursor = cursor;
250
251
  if (inclusive) params.inclusive = "true";
251
- return get(token, "conversations.history", params, cookie);
252
+ return repairMessages(await get(token, "conversations.history", params, cookie));
253
+ }
254
+
255
+ /** Give every message in a history/replies/search response its line breaks
256
+ * back when Slack stored its text flattened (see newlines.ts). Done here, at
257
+ * the fetch, so `read`, `--format jsonl`, `ask`'s parser and every other
258
+ * reader of `.text` agree on one text. */
259
+ function repairMessages(resp: Json): Json {
260
+ const r = resp as { messages?: Json };
261
+ const msgs = Array.isArray(r.messages) ? r.messages
262
+ : r.messages && typeof r.messages === "object" && Array.isArray((r.messages as { matches?: Json }).matches)
263
+ ? (r.messages as { matches: Json[] }).matches
264
+ : [];
265
+ for (const m of msgs) repairMessageText(m);
266
+ return resp;
252
267
  }
253
268
 
254
269
  export async function replies(
@@ -258,7 +273,40 @@ export async function replies(
258
273
  limit = 50,
259
274
  cookie?: string,
260
275
  ): Promise<Json> {
261
- return get(token, "conversations.replies", { channel, ts, limit: String(limit) }, cookie);
276
+ return repairMessages(await get(token, "conversations.replies", { channel, ts, limit: String(limit) }, cookie));
277
+ }
278
+
279
+ // One page of a thread's replies newer than `oldest` (exclusive). Slack returns
280
+ // the parent first on every page regardless of `oldest`; callers drop it.
281
+ export async function repliesPage(
282
+ token: string,
283
+ channel: string,
284
+ ts: string,
285
+ opts: { oldest?: string; cursor?: string; limit?: number } = {},
286
+ cookie?: string,
287
+ ): Promise<Json> {
288
+ const params: Record<string, string> = { channel, ts, limit: String(opts.limit ?? 200) };
289
+ if (opts.oldest !== undefined) params.oldest = opts.oldest;
290
+ if (opts.cursor !== undefined) params.cursor = opts.cursor;
291
+ return repairMessages(await get(token, "conversations.replies", params, cookie));
292
+ }
293
+
294
+ // Conversations the token's OWN identity is a member of (users.conversations),
295
+ // unlike conversations.list, which also returns public channels it is not in.
296
+ export async function userConversations(token: string, types: string, cookie?: string): Promise<Json[]> {
297
+ const out: Json[] = [];
298
+ let cursor = "";
299
+ do {
300
+ const params: Record<string, string> = { limit: "200", types, exclude_archived: "true" };
301
+ if (cursor) params.cursor = cursor;
302
+ const resp = (await get(token, "users.conversations", params, cookie)) as {
303
+ channels?: Json[];
304
+ response_metadata?: { next_cursor?: string };
305
+ };
306
+ out.push(...(resp.channels ?? []));
307
+ cursor = resp.response_metadata?.next_cursor ?? "";
308
+ } while (cursor);
309
+ return out;
262
310
  }
263
311
 
264
312
  // Metadata for a single uploaded file (files.info). Carries url_private_download,
@@ -368,13 +416,13 @@ export async function searchPage(
368
416
  page: number,
369
417
  cookie?: string,
370
418
  ): Promise<Json> {
371
- return get(token, "search.messages", {
419
+ return repairMessages(await get(token, "search.messages", {
372
420
  query,
373
421
  sort: "timestamp",
374
422
  sort_dir: "desc",
375
423
  count: String(Math.min(Math.max(count, 1), 100)),
376
424
  page: String(Math.max(page, 1)),
377
- }, cookie);
425
+ }, cookie));
378
426
  }
379
427
 
380
428
  export async function search(token: string, query: string, cookie?: string): Promise<Json> {