slack-term 1.42.0 → 1.43.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 +76 -0
- package/SKILL.md +9 -0
- package/dist/cli.js +219 -205
- package/package.json +2 -1
- package/ts/cli.ts +151 -1
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slack-term",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/slack-term.git"
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"build": "bun build ts/cli.ts --target=node --outfile dist/cli.js --minify",
|
|
61
61
|
"typecheck": "tsgo --noEmit",
|
|
62
62
|
"typecheck:tsc": "tsc --noEmit",
|
|
63
|
+
"typecheck:worker": "bun install --cwd worker --frozen-lockfile && tsgo --noEmit -p worker",
|
|
63
64
|
"test": "bun test tests/ --timeout 90000 --parallel=2",
|
|
64
65
|
"test:watch": "bun test tests/ --timeout 90000 --isolate --watch",
|
|
65
66
|
"test:parity": "bash tests/parity.sh",
|
package/ts/cli.ts
CHANGED
|
@@ -12,7 +12,9 @@ import { guardUrlBoundaries } from "./urlGuard.ts";
|
|
|
12
12
|
import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
|
|
13
13
|
import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
14
14
|
import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
|
|
15
|
-
import { cmdTail } from "./tail.ts";
|
|
15
|
+
import { cmdTail, parseSince as parseDuration } from "./tail.ts";
|
|
16
|
+
import { runStream, webClient } from "./stream.ts";
|
|
17
|
+
import { relaySubscriber } from "./relay.ts";
|
|
16
18
|
import { agentCommands } from "./agent.ts";
|
|
17
19
|
|
|
18
20
|
import {
|
|
@@ -58,6 +60,7 @@ import { attributionEnabled, attributionMetadata, captureAttribution, markAskCol
|
|
|
58
60
|
import {
|
|
59
61
|
authTest,
|
|
60
62
|
authScopes,
|
|
63
|
+
botsInfo,
|
|
61
64
|
authTestSession,
|
|
62
65
|
conversationInfoSession,
|
|
63
66
|
createChannel,
|
|
@@ -7226,6 +7229,153 @@ async function main(): Promise<void> {
|
|
|
7226
7229
|
)
|
|
7227
7230
|
.command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
|
|
7228
7231
|
)
|
|
7232
|
+
.command(
|
|
7233
|
+
"stream",
|
|
7234
|
+
"Print every new message matching --grep, across all channels the identity is in (threads included); resumable",
|
|
7235
|
+
(y) => y
|
|
7236
|
+
.option("grep", { alias: "e", type: "string", demandOption: true, describe: "Regex matched against each message's text (raw, e.g. <@U00000001>). Non-matching messages are never printed or logged" })
|
|
7237
|
+
.option("ignore-case", { alias: "i", type: "boolean", default: false, describe: "Case-insensitive --grep" })
|
|
7238
|
+
.option("channel", { type: "array", string: true, describe: "Only these channels (#name, id or permalink); repeatable" })
|
|
7239
|
+
.option("replies-to", { type: "array", string: true, describe: "Also print replies in threads this id started or replied in (user U…/bot B…; `self` = the streaming identity), --grep or not; repeatable" })
|
|
7240
|
+
.option("json", { type: "boolean", default: false, describe: "One JSON object per match: type, channel{id,name}, ts, thread_ts, user{id,name}, text, permalink, match (grep | replies-to)" })
|
|
7241
|
+
.option("once", { type: "boolean", default: false, describe: "Scan once from the saved cursor (or --since) and exit: 0 = matches printed, 2 = none, 3 = failure" })
|
|
7242
|
+
.option("since", { type: "string", describe: "Replay from this long ago (e.g. 30m, 2h), ignoring the saved cursor" })
|
|
7243
|
+
.option("interval", { type: "string", default: "45s", describe: "Poll interval (e.g. 30s, 2m)" })
|
|
7244
|
+
.option("thread-window", { type: "string", default: "3d", describe: "Watch replies in threads whose parent is at most this old" })
|
|
7245
|
+
.option("thread-scan", { type: "string", default: "3m", describe: "Polling: re-read each channel's whole --thread-window this often (cycles in between read only what is new)" })
|
|
7246
|
+
.option("calls-per-min", { type: "number", default: 40, describe: "Polling: pace conversations.history/replies to this many calls per minute (0 = unpaced)" })
|
|
7247
|
+
.option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
|
|
7248
|
+
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
7249
|
+
.option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (worker/) when SLACK_RELAY_URL and SLACK_RELAY_TOKEN are set; --no-relay polls only" })
|
|
7250
|
+
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" }),
|
|
7251
|
+
async (argv) => {
|
|
7252
|
+
let re: RegExp;
|
|
7253
|
+
let reconcileMs: number;
|
|
7254
|
+
let intervalMs: number;
|
|
7255
|
+
let windowSec: number;
|
|
7256
|
+
let threadScanSec: number;
|
|
7257
|
+
let sinceSec: number | undefined;
|
|
7258
|
+
try {
|
|
7259
|
+
re = new RegExp(argv.grep, argv["ignore-case"] ? "i" : "");
|
|
7260
|
+
intervalMs = parseDuration(argv.interval) * 1000;
|
|
7261
|
+
windowSec = parseDuration(argv["thread-window"]);
|
|
7262
|
+
threadScanSec = parseDuration(argv["thread-scan"]);
|
|
7263
|
+
sinceSec = argv.since !== undefined ? parseDuration(argv.since) : undefined;
|
|
7264
|
+
reconcileMs = parseDuration(argv.reconcile) * 1000;
|
|
7265
|
+
} catch (e) {
|
|
7266
|
+
console.error(`slack stream: ${e instanceof Error ? e.message : String(e)}`);
|
|
7267
|
+
process.exit(1);
|
|
7268
|
+
}
|
|
7269
|
+
// Privacy-safe default: the bot sees only the channels it was invited
|
|
7270
|
+
// to, never the user's DMs. The user identity is an explicit opt-in.
|
|
7271
|
+
const bot = resolveBotToken();
|
|
7272
|
+
const asUser = argv["as-user"] === true || !bot;
|
|
7273
|
+
const token = asUser ? tok(argv as W) : bot!;
|
|
7274
|
+
const cookie = asUser ? ck(argv as W) : undefined;
|
|
7275
|
+
if (!bot && argv["as-user"] !== true) {
|
|
7276
|
+
console.error("slack stream: no bot token (SLACK_BOT_TOKEN) — streaming as the USER identity, which includes its DMs.");
|
|
7277
|
+
}
|
|
7278
|
+
let me: Awaited<ReturnType<typeof authScopes>>;
|
|
7279
|
+
try {
|
|
7280
|
+
me = await authScopes(token, cookie);
|
|
7281
|
+
} catch (e) {
|
|
7282
|
+
console.error(`slack stream: fatal: ${e instanceof Error ? e.message : String(e)}`);
|
|
7283
|
+
process.exit(3);
|
|
7284
|
+
}
|
|
7285
|
+
// Self-echo exclusion is by sender id: the identity's own user id, its
|
|
7286
|
+
// bot id, and — when streaming as the user — the bot's posts too.
|
|
7287
|
+
const selfUsers = new Set([me.userId].filter(Boolean));
|
|
7288
|
+
const selfBots = new Set([me.botId].filter(Boolean));
|
|
7289
|
+
if (asUser && bot) {
|
|
7290
|
+
try {
|
|
7291
|
+
const b = await authScopes(bot);
|
|
7292
|
+
if (b.userId) selfUsers.add(b.userId);
|
|
7293
|
+
if (b.botId) selfBots.add(b.botId);
|
|
7294
|
+
} catch { /* a dead bot token posts nothing to exclude */ }
|
|
7295
|
+
}
|
|
7296
|
+
// Slack names a bot in a thread either way: `parent_user_id` holds its
|
|
7297
|
+
// bot USER id (auth.test user_id), `reply_users` sometimes its B… bot
|
|
7298
|
+
// id — so `self` stands for both.
|
|
7299
|
+
let repliesTo: Set<string> | undefined;
|
|
7300
|
+
if (argv["replies-to"]?.length) {
|
|
7301
|
+
repliesTo = new Set();
|
|
7302
|
+
for (const r of argv["replies-to"].map(String)) {
|
|
7303
|
+
if (r === "self") {
|
|
7304
|
+
for (const id of [me.userId, me.botId]) if (id) repliesTo.add(id);
|
|
7305
|
+
continue;
|
|
7306
|
+
}
|
|
7307
|
+
if (!/^[UWB][A-Z0-9]{2,}$/.test(r)) {
|
|
7308
|
+
console.error(`slack stream: --replies-to: "${r}" is not a user or bot id (U…/W…/B…) or "self"`);
|
|
7309
|
+
process.exit(1);
|
|
7310
|
+
}
|
|
7311
|
+
repliesTo.add(r);
|
|
7312
|
+
// A bot can be listed under its user id too: add that alias.
|
|
7313
|
+
if (r.startsWith("B")) {
|
|
7314
|
+
try {
|
|
7315
|
+
const { userId } = await botsInfo(token, r, cookie);
|
|
7316
|
+
if (userId) repliesTo.add(userId);
|
|
7317
|
+
} catch (e) {
|
|
7318
|
+
console.error(`slack stream: --replies-to ${r}: cannot resolve its bot user id (${e instanceof Error ? e.message : String(e)}); threads where it shows as a user id are missed`);
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
}
|
|
7322
|
+
}
|
|
7323
|
+
let channels: string[] | undefined;
|
|
7324
|
+
if (argv.channel?.length) {
|
|
7325
|
+
try {
|
|
7326
|
+
channels = [];
|
|
7327
|
+
for (const c of argv.channel) channels.push(await resolveChannel(token, String(c), cookie));
|
|
7328
|
+
} catch (e) {
|
|
7329
|
+
console.error(`slack stream: --channel: ${e instanceof Error ? e.message : String(e)}`);
|
|
7330
|
+
process.exit(1);
|
|
7331
|
+
}
|
|
7332
|
+
}
|
|
7333
|
+
const key = createHash("sha256")
|
|
7334
|
+
.update(JSON.stringify([me.url, me.userId, re.source, re.flags, [...(channels ?? [])].sort(),
|
|
7335
|
+
// As given, not as resolved: a failed bots.info lookup must not
|
|
7336
|
+
// move the stream to a fresh state file.
|
|
7337
|
+
...(argv["replies-to"]?.length ? [[...argv["replies-to"].map(String)].sort()] : [])]))
|
|
7338
|
+
.digest("hex").slice(0, 16);
|
|
7339
|
+
const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
7340
|
+
const statePath = argv.state ?? join(stateHome, "slack-term", "stream", `${key}.json`);
|
|
7341
|
+
console.error(`slack stream: as ${asUser ? "user" : "bot"} @${me.user} (${me.userId}); state ${statePath}`);
|
|
7342
|
+
// The relay rings for what the Slack app's bot can see, so it only
|
|
7343
|
+
// stands in for polling when streaming as that bot.
|
|
7344
|
+
const relayUrl = process.env.SLACK_RELAY_URL;
|
|
7345
|
+
const relayToken = process.env.SLACK_RELAY_TOKEN;
|
|
7346
|
+
const relay = argv.relay && !asUser && !argv.once && relayUrl && relayToken
|
|
7347
|
+
? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken), reconcileMs }
|
|
7348
|
+
: undefined;
|
|
7349
|
+
if (argv.relay && asUser && relayUrl) console.error("slack stream: relay not used with --as-user (it only sees the bot's channels)");
|
|
7350
|
+
const pollReason = !argv.relay ? "--no-relay"
|
|
7351
|
+
: asUser ? (relayUrl ? "--as-user: the relay only rings for the bot's channels" : "streaming as the user; the relay serves the bot identity")
|
|
7352
|
+
: "SLACK_RELAY_URL / SLACK_RELAY_TOKEN not set";
|
|
7353
|
+
const ac = new AbortController();
|
|
7354
|
+
process.on("SIGINT", () => ac.abort());
|
|
7355
|
+
process.on("SIGTERM", () => ac.abort());
|
|
7356
|
+
const code = await runStream(webClient(token, cookie), {
|
|
7357
|
+
grep: re,
|
|
7358
|
+
...(repliesTo ? { repliesTo } : {}),
|
|
7359
|
+
...(channels ? { channels } : {}),
|
|
7360
|
+
selfUsers,
|
|
7361
|
+
selfBots,
|
|
7362
|
+
json: argv.json,
|
|
7363
|
+
once: argv.once,
|
|
7364
|
+
...(sinceSec !== undefined ? { sinceSec } : {}),
|
|
7365
|
+
intervalMs,
|
|
7366
|
+
threadWindowSec: windowSec,
|
|
7367
|
+
threadScanSec,
|
|
7368
|
+
callsPerMin: argv["calls-per-min"],
|
|
7369
|
+
...(relay ? {} : { pollReason }),
|
|
7370
|
+
statePath,
|
|
7371
|
+
teamUrl: me.url,
|
|
7372
|
+
identity: me.userId,
|
|
7373
|
+
signal: ac.signal,
|
|
7374
|
+
...(relay ? { relay } : {}),
|
|
7375
|
+
});
|
|
7376
|
+
process.exit(code);
|
|
7377
|
+
},
|
|
7378
|
+
)
|
|
7229
7379
|
.command(
|
|
7230
7380
|
"tail [target]",
|
|
7231
7381
|
"Stream new messages in real time (like tail -f)",
|
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> {
|