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/README.md +81 -0
- package/SKILL.md +22 -2
- package/dist/cli.js +223 -209
- package/package.json +2 -1
- package/ts/ask.ts +61 -4
- package/ts/cli.ts +336 -20
- package/ts/newlines.ts +158 -0
- package/ts/relay.ts +105 -0
- package/ts/slack.ts +52 -4
- package/ts/stream.ts +1048 -0
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> = { "&": "&", "<": "<", ">": ">" };
|
|
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}[.)]|>|>)$/;
|
|
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. `/`> ` 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> {
|