slack-term 1.46.0 → 1.48.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 +33 -2
- package/SKILL.md +3 -2
- package/dist/cli.js +248 -184
- package/package.json +14 -1
- package/ts/cli.ts +47 -5
- package/ts/relay.ts +171 -10
- package/ts/relayInit.ts +209 -0
- package/ts/stream.ts +3 -3
- package/worker/README.md +216 -0
- package/worker/src/hub.ts +203 -0
- package/worker/src/index.ts +308 -0
- package/worker/src/slack.ts +127 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slack-term",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.48.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/slack-term.git"
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"bin": {
|
|
26
26
|
"slack": "./ts/cli.ts"
|
|
27
27
|
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./worker": "./worker/src/index.ts",
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
28
32
|
"description": "Slack CLI — read activity/news/mentions, search, send messages with a confirm-hash safety gate.",
|
|
29
33
|
"engines": {
|
|
30
34
|
"node": ">=18"
|
|
@@ -32,6 +36,7 @@
|
|
|
32
36
|
"files": [
|
|
33
37
|
"ts/",
|
|
34
38
|
"dist/",
|
|
39
|
+
"worker/src/",
|
|
35
40
|
"README.md",
|
|
36
41
|
"SKILL.md",
|
|
37
42
|
"LICENSE"
|
|
@@ -75,5 +80,13 @@
|
|
|
75
80
|
"type": "module",
|
|
76
81
|
"dependencies": {
|
|
77
82
|
"yargs": "^18.0.0"
|
|
83
|
+
},
|
|
84
|
+
"peerDependencies": {
|
|
85
|
+
"@cloudflare/workers-types": "^4"
|
|
86
|
+
},
|
|
87
|
+
"peerDependenciesMeta": {
|
|
88
|
+
"@cloudflare/workers-types": {
|
|
89
|
+
"optional": true
|
|
90
|
+
}
|
|
78
91
|
}
|
|
79
92
|
}
|
package/ts/cli.ts
CHANGED
|
@@ -14,7 +14,8 @@ 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
|
+
import { DEFAULT_DIR, initRelay, initReport, relayHealth } from "./relayInit.ts";
|
|
18
19
|
import { agentCommands } from "./agent.ts";
|
|
19
20
|
|
|
20
21
|
import {
|
|
@@ -5558,8 +5559,9 @@ async function main(): Promise<void> {
|
|
|
5558
5559
|
.option("workspace", { alias: "w", type: "string", describe: "Workspace name" })
|
|
5559
5560
|
.middleware(async (argv) => {
|
|
5560
5561
|
const cmd = String((argv._ ?? [])[0] ?? "");
|
|
5561
|
-
// `sent` reads only the local log — no token, no network.
|
|
5562
|
-
|
|
5562
|
+
// `sent` reads only the local log — no token, no network. `relay` writes
|
|
5563
|
+
// local files (init) or talks to the user's own relay (health): no Slack token.
|
|
5564
|
+
if (!cmd || cmd === "auth" || cmd === "login" || cmd === "agent" || cmd === "sent" || cmd === "relay") return;
|
|
5563
5565
|
try {
|
|
5564
5566
|
const active = resolveToken((argv as W).workspace);
|
|
5565
5567
|
const bot = resolveBotToken();
|
|
@@ -7638,6 +7640,46 @@ async function main(): Promise<void> {
|
|
|
7638
7640
|
)
|
|
7639
7641
|
.command("$0", false as unknown as string, () => {}, () => { y.showHelp(); process.exit(0); }),
|
|
7640
7642
|
)
|
|
7643
|
+
.command(
|
|
7644
|
+
"relay",
|
|
7645
|
+
"Set up / check the real-time relay for `slack stream` (a Cloudflare Worker)",
|
|
7646
|
+
(y) => y
|
|
7647
|
+
.command(
|
|
7648
|
+
"init [dir]",
|
|
7649
|
+
"Scaffold a deployable relay Worker folder (local files only; prints a fresh RELAY_TOKEN and the next steps)",
|
|
7650
|
+
(y2) => y2
|
|
7651
|
+
.positional("dir", { type: "string", default: DEFAULT_DIR, describe: "Folder to create" })
|
|
7652
|
+
.option("name", { type: "string", describe: "Worker name (default: the folder's name)" })
|
|
7653
|
+
.option("force", { type: "boolean", default: false, describe: "Overwrite files that already exist" })
|
|
7654
|
+
.option("slack-term", { type: "string", describe: "slack-term dependency spec (default: this CLI's version), e.g. latest or file:../slack-term" }),
|
|
7655
|
+
(argv) => {
|
|
7656
|
+
const r = initRelay(argv.dir, {
|
|
7657
|
+
force: argv.force,
|
|
7658
|
+
...(argv.name !== undefined ? { name: argv.name } : {}),
|
|
7659
|
+
...(argv["slack-term"] !== undefined ? { slackTerm: argv["slack-term"] } : {}),
|
|
7660
|
+
});
|
|
7661
|
+
if (!r.ok) {
|
|
7662
|
+
console.error(`slack relay init: ${r.error}`);
|
|
7663
|
+
for (const f of r.existing ?? []) console.error(` exists: ${f}`);
|
|
7664
|
+
process.exit(2);
|
|
7665
|
+
}
|
|
7666
|
+
console.log(initReport(r));
|
|
7667
|
+
},
|
|
7668
|
+
)
|
|
7669
|
+
.command(
|
|
7670
|
+
"health",
|
|
7671
|
+
"GET /health on the relay (SLACK_RELAY_URL / SLACK_RELAY_TOKEN); exit 0 = ok",
|
|
7672
|
+
(y2) => y2
|
|
7673
|
+
.option("url", { type: "string", describe: "Relay URL (default: $SLACK_RELAY_URL)" })
|
|
7674
|
+
.option("token", { type: "string", describe: "Relay token (default: $SLACK_RELAY_TOKEN)" }),
|
|
7675
|
+
async (argv) => {
|
|
7676
|
+
const r = await relayHealth(argv.url ?? process.env.SLACK_RELAY_URL, argv.token ?? process.env.SLACK_RELAY_TOKEN);
|
|
7677
|
+
(r.code === 0 ? console.log : console.error)(r.out);
|
|
7678
|
+
process.exit(r.code);
|
|
7679
|
+
},
|
|
7680
|
+
)
|
|
7681
|
+
.demandCommand(1, "Specify a relay subcommand (init, health)"),
|
|
7682
|
+
)
|
|
7641
7683
|
.command(
|
|
7642
7684
|
"stream",
|
|
7643
7685
|
"Print every new message matching --grep, across all channels the identity is in (threads included); resumable",
|
|
@@ -7655,7 +7697,7 @@ async function main(): Promise<void> {
|
|
|
7655
7697
|
.option("calls-per-min", { type: "number", default: 40, describe: "Polling: pace conversations.history/replies to this many calls per minute (0 = unpaced)" })
|
|
7656
7698
|
.option("state", { type: "string", describe: "Cursor file (default: one per identity+grep+channels under $XDG_STATE_HOME/slack-term/stream/)" })
|
|
7657
7699
|
.option("as-user", { type: "boolean", default: false, describe: "Stream as the user identity (sees the user's DMs) instead of the bot" })
|
|
7658
|
-
.option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (
|
|
7700
|
+
.option("relay", { type: "boolean", default: true, describe: "Use the doorbell relay (`slack relay init`) when SLACK_RELAY_URL and SLACK_RELAY_TOKEN are set; --no-relay polls only" })
|
|
7659
7701
|
.option("reconcile", { type: "string", default: "5m", describe: "While the relay is connected, poll this rarely as a safety net" }),
|
|
7660
7702
|
async (argv) => {
|
|
7661
7703
|
let re: RegExp;
|
|
@@ -7753,7 +7795,7 @@ async function main(): Promise<void> {
|
|
|
7753
7795
|
const relayUrl = process.env.SLACK_RELAY_URL;
|
|
7754
7796
|
const relayToken = process.env.SLACK_RELAY_TOKEN;
|
|
7755
7797
|
const relay = argv.relay && !asUser && !argv.once && relayUrl && relayToken
|
|
7756
|
-
? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken), reconcileMs }
|
|
7798
|
+
? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken, undefined, { transport: relayTransport(process.env.SLACK_RELAY_TRANSPORT) }), reconcileMs }
|
|
7757
7799
|
: undefined;
|
|
7758
7800
|
if (argv.relay && asUser && relayUrl) console.error("slack stream: relay not used with --as-user (it only sees the bot's channels)");
|
|
7759
7801
|
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/relayInit.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// `slack relay init` / `slack relay health`: set up and check the doorbell
|
|
2
|
+
// relay without cloning this repo.
|
|
3
|
+
//
|
|
4
|
+
// The relay's code ships in the npm package as `slack-term/worker`
|
|
5
|
+
// (worker/src/), so a deployable Worker is a two-line re-export plus a
|
|
6
|
+
// wrangler.jsonc. `init` writes that folder — local files only, no network,
|
|
7
|
+
// nothing that touches Slack — and prints a fresh RELAY_TOKEN with the next
|
|
8
|
+
// steps. The token is deliberately NOT written into the folder: it is a secret,
|
|
9
|
+
// and the folder is meant to be committed.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { basename, join, resolve } from "node:path";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import pkg from "../package.json" with { type: "json" };
|
|
15
|
+
|
|
16
|
+
/** The wrangler the repo deploys worker/ with (worker/package.json). */
|
|
17
|
+
export const WRANGLER_VERSION = "4.105.0";
|
|
18
|
+
/** worker/wrangler.jsonc's compatibility_date. */
|
|
19
|
+
export const COMPATIBILITY_DATE = "2026-07-01";
|
|
20
|
+
export const DEFAULT_DIR = "slack-term-relay";
|
|
21
|
+
export const RELAY_DOCS_URL = "https://github.com/snomiao/slack-term/blob/main/worker/README.md";
|
|
22
|
+
|
|
23
|
+
/** A Workers script name: lowercase letters, digits and dashes, 1–63 chars,
|
|
24
|
+
* no leading or trailing dash. */
|
|
25
|
+
export function validWorkerName(name: string): boolean {
|
|
26
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A directory name turned into a valid Worker name. */
|
|
30
|
+
export function workerNameFrom(dir: string): string {
|
|
31
|
+
const n = basename(resolve(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63).replace(/-+$/, "");
|
|
32
|
+
return n || DEFAULT_DIR;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ScaffoldOptions = {
|
|
36
|
+
/** Worker name; default: the directory's basename. */
|
|
37
|
+
name?: string;
|
|
38
|
+
/** The `slack-term` dependency spec; default: `^` this CLI's version (a source
|
|
39
|
+
* checkout carries a stale placeholder version; the range still resolves). */
|
|
40
|
+
slackTerm?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The scaffold's files, relative path → content. */
|
|
44
|
+
export function relayFiles(name: string, opts: { slackTerm?: string } = {}): Record<string, string> {
|
|
45
|
+
const packageJson = {
|
|
46
|
+
name,
|
|
47
|
+
private: true,
|
|
48
|
+
type: "module",
|
|
49
|
+
scripts: { deploy: "wrangler deploy", dev: "wrangler dev --local" },
|
|
50
|
+
dependencies: { "slack-term": opts.slackTerm ?? `^${pkg.version}` },
|
|
51
|
+
devDependencies: { wrangler: WRANGLER_VERSION },
|
|
52
|
+
engines: { node: ">=22" },
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
"package.json": `${JSON.stringify(packageJson, null, 2)}\n`,
|
|
56
|
+
"wrangler.jsonc": `// slack-term relay: Slack Events API -> doorbells for \`slack stream\`.
|
|
57
|
+
// Secrets (not in this file): wrangler secret put SLACK_SIGNING_SECRET / RELAY_TOKEN / SLACK_TEAM_ID (optional)
|
|
58
|
+
{
|
|
59
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
60
|
+
"name": ${JSON.stringify(name)},
|
|
61
|
+
"main": "src/index.ts",
|
|
62
|
+
"compatibility_date": "${COMPATIBILITY_DATE}",
|
|
63
|
+
"durable_objects": {
|
|
64
|
+
"bindings": [{ "name": "RELAY", "class_name": "Relay" }]
|
|
65
|
+
},
|
|
66
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Relay"] }],
|
|
67
|
+
// Request bodies are message events from possibly customer-facing channels:
|
|
68
|
+
// keep Workers Logs off so nothing about them is persisted by the platform.
|
|
69
|
+
"observability": { "enabled": false }
|
|
70
|
+
}
|
|
71
|
+
`,
|
|
72
|
+
"src/index.ts": `// The relay lives in the slack-term package. Relay (the Durable Object class)
|
|
73
|
+
// must be exported from this main module for the RELAY binding to find it.
|
|
74
|
+
export { default, Relay } from "slack-term/worker";
|
|
75
|
+
`,
|
|
76
|
+
".gitignore": "node_modules/\n.wrangler/\n.dev.vars*\n.env*\n",
|
|
77
|
+
"README.md": readme(name),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The steps after `init`, shared by the README and the printed output.
|
|
82
|
+
* `token` is only ever printed, never written to a file. */
|
|
83
|
+
export function nextSteps(name: string, token?: string): string {
|
|
84
|
+
const tok = token ?? "<RELAY_TOKEN>";
|
|
85
|
+
return `1. Node >= 22 (wrangler 4 needs it), then install:
|
|
86
|
+
npm install
|
|
87
|
+
2. Log in to Cloudflare:
|
|
88
|
+
npx wrangler login
|
|
89
|
+
or set CLOUDFLARE_API_TOKEN (Workers Scripts: Edit) and CLOUDFLARE_ACCOUNT_ID.
|
|
90
|
+
3. Deploy, then set the secrets:
|
|
91
|
+
npx wrangler deploy
|
|
92
|
+
npx wrangler secret put SLACK_SIGNING_SECRET # Slack app -> Basic Information -> Signing Secret
|
|
93
|
+
npx wrangler secret put RELAY_TOKEN # paste: ${tok}
|
|
94
|
+
npx wrangler secret put SLACK_TEAM_ID # optional: accept one workspace only (T...)
|
|
95
|
+
4. In the Slack app -> Event Subscriptions: Enable Events, Request URL
|
|
96
|
+
https://${name}.<your-subdomain>.workers.dev/slack/events
|
|
97
|
+
and subscribe to bot events: message.channels, message.groups, message.im,
|
|
98
|
+
app_mention (and message.mpim if the bot has mpim:history). Save; reinstall if asked.
|
|
99
|
+
5. Where \`slack stream\` runs:
|
|
100
|
+
export SLACK_RELAY_URL=https://${name}.<your-subdomain>.workers.dev
|
|
101
|
+
export SLACK_RELAY_TOKEN=${tok}
|
|
102
|
+
6. Check it:
|
|
103
|
+
slack relay health # or: curl -H "Authorization: Bearer $SLACK_RELAY_TOKEN" $SLACK_RELAY_URL/health
|
|
104
|
+
slack stream -e . # stderr says: relay connected (websocket)
|
|
105
|
+
`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readme(name: string): string {
|
|
109
|
+
return `# ${name}
|
|
110
|
+
|
|
111
|
+
A [slack-term](https://github.com/snomiao/slack-term) relay: a Cloudflare Worker
|
|
112
|
+
that turns Slack's Events API into a resumable doorbell stream for
|
|
113
|
+
\`slack stream\`. The code is the \`slack-term/worker\` export; this folder only
|
|
114
|
+
configures and deploys it. Created by \`slack relay init\`.
|
|
115
|
+
|
|
116
|
+
## Set up
|
|
117
|
+
|
|
118
|
+
${nextSteps(name)}
|
|
119
|
+
\`RELAY_TOKEN\` is any long random string (\`openssl rand -hex 32\`); \`slack relay init\`
|
|
120
|
+
printed one. Keep it out of this folder: anyone holding it can watch when and
|
|
121
|
+
where messages are posted.
|
|
122
|
+
|
|
123
|
+
## Update
|
|
124
|
+
|
|
125
|
+
Bump \`slack-term\` in package.json, \`npm install\`, \`npx wrangler deploy\`.
|
|
126
|
+
Storage survives redeploys; clients reconnect and resume.
|
|
127
|
+
|
|
128
|
+
Details (endpoints, privacy, protocol): ${RELAY_DOCS_URL}
|
|
129
|
+
`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type InitResult =
|
|
133
|
+
| { ok: true; dir: string; name: string; written: string[]; token: string }
|
|
134
|
+
| { ok: false; error: string; existing?: string[] };
|
|
135
|
+
|
|
136
|
+
/** Write the scaffold into `dir`. Refuses to overwrite any existing file
|
|
137
|
+
* unless `force`. Returns the generated RELAY_TOKEN for the caller to print. */
|
|
138
|
+
export function initRelay(
|
|
139
|
+
dir: string,
|
|
140
|
+
opts: ScaffoldOptions & { force?: boolean } = {},
|
|
141
|
+
rand: (n: number) => Buffer = randomBytes,
|
|
142
|
+
): InitResult {
|
|
143
|
+
const name = opts.name ?? workerNameFrom(dir);
|
|
144
|
+
if (!validWorkerName(name)) {
|
|
145
|
+
return { ok: false, error: `invalid worker name "${name}": use lowercase letters, digits and dashes (1-63 chars, no leading/trailing dash)` };
|
|
146
|
+
}
|
|
147
|
+
const files = relayFiles(name, opts.slackTerm !== undefined ? { slackTerm: opts.slackTerm } : {});
|
|
148
|
+
const root = resolve(dir);
|
|
149
|
+
const existing = Object.keys(files).filter((f) => existsSync(join(root, f)));
|
|
150
|
+
if (existing.length && !opts.force) {
|
|
151
|
+
return { ok: false, error: `refusing to overwrite existing files in ${root} (pass --force to replace them)`, existing };
|
|
152
|
+
}
|
|
153
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
154
|
+
const p = join(root, rel);
|
|
155
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
156
|
+
writeFileSync(p, content);
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, dir: root, name, written: Object.keys(files), token: rand(32).toString("hex") };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What `slack relay init` prints on success. */
|
|
162
|
+
export function initReport(r: Extract<InitResult, { ok: true }>, cwd: string = process.cwd()): string {
|
|
163
|
+
const rel = r.dir.startsWith(`${cwd}/`) ? r.dir.slice(cwd.length + 1) : r.dir;
|
|
164
|
+
return [
|
|
165
|
+
`Created ${r.dir} (worker "${r.name}"):`,
|
|
166
|
+
...r.written.map((f) => ` ${f}`),
|
|
167
|
+
"",
|
|
168
|
+
`RELAY_TOKEN (generated now, not saved anywhere — copy it):`,
|
|
169
|
+
` ${r.token}`,
|
|
170
|
+
"",
|
|
171
|
+
"Next steps:",
|
|
172
|
+
`0. cd ${rel}`,
|
|
173
|
+
nextSteps(r.name, r.token),
|
|
174
|
+
].join("\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type HealthResult = { code: 0 | 1 | 2; out: string };
|
|
178
|
+
|
|
179
|
+
/** GET /health on the relay (read-only). 0 = ok, 1 = unhealthy/unreachable,
|
|
180
|
+
* 2 = not configured. */
|
|
181
|
+
export async function relayHealth(
|
|
182
|
+
url: string | undefined,
|
|
183
|
+
token: string | undefined,
|
|
184
|
+
fetchImpl: typeof fetch = fetch,
|
|
185
|
+
timeoutMs = 10_000,
|
|
186
|
+
): Promise<HealthResult> {
|
|
187
|
+
if (!url || !token) {
|
|
188
|
+
return { code: 2, out: "slack relay health: set SLACK_RELAY_URL and SLACK_RELAY_TOKEN (or pass --url / --token)" };
|
|
189
|
+
}
|
|
190
|
+
const target = `${url.replace(/\/+$/, "")}/health`;
|
|
191
|
+
try {
|
|
192
|
+
const res = await fetchImpl(target, {
|
|
193
|
+
headers: { authorization: `Bearer ${token}` },
|
|
194
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
195
|
+
});
|
|
196
|
+
const body = await res.text();
|
|
197
|
+
if (res.status === 401) return { code: 1, out: `slack relay health: 401 unauthorized — SLACK_RELAY_TOKEN does not match the relay's RELAY_TOKEN secret` };
|
|
198
|
+
if (!res.ok) return { code: 1, out: `slack relay health: HTTP ${res.status} from ${target}${body ? `: ${body.slice(0, 200)}` : ""}` };
|
|
199
|
+
let json: { ok?: unknown };
|
|
200
|
+
try {
|
|
201
|
+
json = JSON.parse(body) as typeof json;
|
|
202
|
+
} catch {
|
|
203
|
+
return { code: 1, out: `slack relay health: not a slack-term relay (non-JSON reply from ${target})` };
|
|
204
|
+
}
|
|
205
|
+
return { code: json.ok === true ? 0 : 1, out: JSON.stringify(json, null, 2) };
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return { code: 1, out: `slack relay health: cannot reach ${target}: ${e instanceof Error ? e.message : String(e)}` };
|
|
208
|
+
}
|
|
209
|
+
}
|
package/ts/stream.ts
CHANGED
|
@@ -643,7 +643,7 @@ export async function runStream(client: StreamClient, opts: StreamOpts): Promise
|
|
|
643
643
|
`slack stream: WARNING: polling mode (${opts.pollReason ?? "no relay configured"}) — this is the fallback, ` +
|
|
644
644
|
`not the intended setup: matches arrive up to ${Math.round(opts.intervalMs / 1000)}s late (replies under older ` +
|
|
645
645
|
`parents up to ${Math.round(Math.max(opts.threadScanSec ?? 180, opts.intervalMs / 1000))}s) and every cycle spends ` +
|
|
646
|
-
`Slack API calls. For real time,
|
|
646
|
+
`Slack API calls. For real time, set up the relay (slack relay init) and set SLACK_RELAY_URL + SLACK_RELAY_TOKEN: ${RELAY_DOCS}`,
|
|
647
647
|
);
|
|
648
648
|
}
|
|
649
649
|
client = paced(client, opts.callsPerMin ?? 40, opts.signal);
|
|
@@ -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;
|