slack-term 1.46.0 → 1.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/cli.js +172 -172
- package/package.json +1 -1
- package/ts/cli.ts +2 -2
- package/ts/relay.ts +171 -10
- package/ts/stream.ts +2 -2
package/package.json
CHANGED
package/ts/cli.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
|
14
14
|
import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
|
|
15
15
|
import { cmdTail, parseSince as parseDuration } from "./tail.ts";
|
|
16
16
|
import { runStream, webClient } from "./stream.ts";
|
|
17
|
-
import { relaySubscriber } from "./relay.ts";
|
|
17
|
+
import { relaySubscriber, relayTransport } from "./relay.ts";
|
|
18
18
|
import { agentCommands } from "./agent.ts";
|
|
19
19
|
|
|
20
20
|
import {
|
|
@@ -7753,7 +7753,7 @@ async function main(): Promise<void> {
|
|
|
7753
7753
|
const relayUrl = process.env.SLACK_RELAY_URL;
|
|
7754
7754
|
const relayToken = process.env.SLACK_RELAY_TOKEN;
|
|
7755
7755
|
const relay = argv.relay && !asUser && !argv.once && relayUrl && relayToken
|
|
7756
|
-
? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken), reconcileMs }
|
|
7756
|
+
? { url: relayUrl, subscribe: relaySubscriber(relayUrl, relayToken, undefined, { transport: relayTransport(process.env.SLACK_RELAY_TRANSPORT) }), reconcileMs }
|
|
7757
7757
|
: undefined;
|
|
7758
7758
|
if (argv.relay && asUser && relayUrl) console.error("slack stream: relay not used with --as-user (it only sees the bot's channels)");
|
|
7759
7759
|
const pollReason = !argv.relay ? "--no-relay"
|
package/ts/relay.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
// Client for the slack-term relay (worker/): a Cloudflare Worker that receives
|
|
2
2
|
// Slack's Events API and serves "doorbells" — {channel, ts, thread_ts}, never
|
|
3
|
-
// message text — as a resumable
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// message text — as a resumable stream. `slack stream` uses it to scan the one
|
|
4
|
+
// channel a bell names within a second of the post, instead of waiting for its
|
|
5
|
+
// next poll.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Transport: a WebSocket to /ws first. The relay accepts it as a hibernatable
|
|
8
|
+
// socket, so its Durable Object is not kept awake (and billed) while we sit
|
|
9
|
+
// connected, and our 25 s "ping" is answered without waking it. The bearer
|
|
10
|
+
// token never goes in a URL: it rides in the Sec-WebSocket-Protocol header as
|
|
11
|
+
// `bearer.<base64url(token)>` — Node's WebSocket cannot set other headers.
|
|
12
|
+
// If the socket cannot be opened at all (a relay from before /ws, a proxy
|
|
13
|
+
// that strips upgrades, a runtime without WebSocket), the same connection
|
|
14
|
+
// falls back to the old Server-Sent Events endpoint, /stream, over fetch.
|
|
9
15
|
|
|
10
16
|
/** A bell: where a message just appeared. */
|
|
11
17
|
export type Doorbell = {
|
|
@@ -18,7 +24,13 @@ export type Doorbell = {
|
|
|
18
24
|
* starts from; `gap` means bells after the requested seq are gone (expired,
|
|
19
25
|
* or the relay was reset), so the client must catch up by polling. `epoch`
|
|
20
26
|
* names the relay's storage: a new one means its numbering restarted. */
|
|
21
|
-
export type Hello = {
|
|
27
|
+
export type Hello = {
|
|
28
|
+
seq: number; gap: boolean; retention_sec: number; epoch?: string;
|
|
29
|
+
/** Client-side: which transport this connection uses. */
|
|
30
|
+
via?: Transport;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type Transport = "websocket" | "sse";
|
|
22
34
|
|
|
23
35
|
/** Where to resume: the last seq taken, in the numbering of `epoch`. */
|
|
24
36
|
export type Cursor = { seq: number; epoch?: string };
|
|
@@ -58,8 +70,158 @@ export function parseSse(buf: string, emit: (event: string, data: string, id: st
|
|
|
58
70
|
return buf;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
|
|
73
|
+
/** The subprotocol the relay's /ws speaks; it echoes this one back. */
|
|
74
|
+
export const WS_PROTOCOL = "slack-term-relay.v1";
|
|
75
|
+
|
|
76
|
+
/** The subprotocol that carries the bearer token: subprotocols must be HTTP
|
|
77
|
+
* tokens, so the token is base64url-encoded. */
|
|
78
|
+
export function bearerProtocol(token: string): string {
|
|
79
|
+
return `bearer.${Buffer.from(token, "utf8").toString("base64url")}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The slice of the WHATWG WebSocket this client uses (bun's and node's). */
|
|
83
|
+
export type WebSocketLike = {
|
|
84
|
+
readyState: number;
|
|
85
|
+
send(data: string): void;
|
|
86
|
+
close(code?: number, reason?: string): void;
|
|
87
|
+
onopen: ((ev: unknown) => void) | null;
|
|
88
|
+
onmessage: ((ev: { data: unknown }) => void) | null;
|
|
89
|
+
onerror: ((ev: unknown) => void) | null;
|
|
90
|
+
onclose: ((ev: { code: number; reason: string }) => void) | null;
|
|
91
|
+
};
|
|
92
|
+
export type WebSocketCtor = new (url: string, protocols: string[]) => WebSocketLike;
|
|
93
|
+
|
|
94
|
+
export type RelayOptions = {
|
|
95
|
+
/** "auto" (default): WebSocket, falling back to SSE when it cannot open. */
|
|
96
|
+
transport?: "auto" | Transport;
|
|
97
|
+
/** Defaults to the runtime's global WebSocket; null means none. */
|
|
98
|
+
WebSocket?: WebSocketCtor | null;
|
|
99
|
+
/** Keepalive ping interval; the relay answers without waking up. */
|
|
100
|
+
pingMs?: number;
|
|
101
|
+
/** How long a received bell may wait for its ack (acks are batched). */
|
|
102
|
+
ackDelayMs?: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** SLACK_RELAY_TRANSPORT: "websocket" or "sse" pins one; anything else is "auto". */
|
|
106
|
+
export function relayTransport(v: string | undefined): "auto" | Transport {
|
|
107
|
+
return v === "websocket" || v === "sse" ? v : "auto";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Received-but-unacked bells that trigger an ack at once. The relay cuts a
|
|
111
|
+
* client off at 64 unacked live bells, and replays its backlog one batch
|
|
112
|
+
* per ack. */
|
|
113
|
+
const ACK_EVERY = 32;
|
|
114
|
+
|
|
115
|
+
function resumeQuery(after: Cursor | undefined): string {
|
|
116
|
+
return after === undefined ? "" : `?after=${after.seq}${after.epoch ? `&epoch=${encodeURIComponent(after.epoch)}` : ""}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000, opts: RelayOptions = {}): Subscribe {
|
|
62
120
|
const base = baseUrl.replace(/\/+$/, "");
|
|
121
|
+
const transport = opts.transport ?? "auto";
|
|
122
|
+
const sse = sseSubscriber(base, token, idleMs);
|
|
123
|
+
return async (after, signal, on) => {
|
|
124
|
+
const WS = opts.WebSocket === undefined ? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket : opts.WebSocket;
|
|
125
|
+
if (transport !== "sse") {
|
|
126
|
+
if (!WS) {
|
|
127
|
+
if (transport === "websocket") throw new Error("relay: this runtime has no WebSocket");
|
|
128
|
+
} else {
|
|
129
|
+
const opened = await wsSubscribe(WS, base, token, idleMs, opts.pingMs ?? 25_000, opts.ackDelayMs ?? 100, after, signal, on);
|
|
130
|
+
if (opened || signal.aborted) return;
|
|
131
|
+
// Never opened: an old relay, or something in between refusing the
|
|
132
|
+
// upgrade. SSE says which — a 401 from it is a RelayAuthError.
|
|
133
|
+
if (transport === "websocket") throw new Error("relay: WebSocket connection failed");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return sse(after, signal, on);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One WebSocket connection. Resolves true once it has been open (closed by
|
|
141
|
+
* the server normally, or aborted), false if it never opened — the caller
|
|
142
|
+
* then falls back to SSE. Rejects on an abnormal close or silence. */
|
|
143
|
+
function wsSubscribe(
|
|
144
|
+
WS: WebSocketCtor, base: string, token: string, idleMs: number, pingMs: number, ackDelayMs: number,
|
|
145
|
+
after: Cursor | undefined, signal: AbortSignal, on: RelayHandlers,
|
|
146
|
+
): Promise<boolean> {
|
|
147
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
148
|
+
if (signal.aborted) return resolve(true);
|
|
149
|
+
let ws: WebSocketLike;
|
|
150
|
+
try {
|
|
151
|
+
ws = new WS(`${base.replace(/^http/i, "ws")}/ws${resumeQuery(after)}`, [WS_PROTOCOL, bearerProtocol(token)]);
|
|
152
|
+
} catch {
|
|
153
|
+
return resolve(false);
|
|
154
|
+
}
|
|
155
|
+
let opened = false;
|
|
156
|
+
let done = false;
|
|
157
|
+
let idle: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
let ackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
159
|
+
let ping: ReturnType<typeof setInterval> | undefined;
|
|
160
|
+
let last = 0; // highest seq received
|
|
161
|
+
let acked = 0; // highest seq acked
|
|
162
|
+
const finish = (err?: Error): void => {
|
|
163
|
+
if (done) return;
|
|
164
|
+
done = true;
|
|
165
|
+
clearTimeout(idle);
|
|
166
|
+
clearTimeout(ackTimer);
|
|
167
|
+
clearInterval(ping);
|
|
168
|
+
signal.removeEventListener("abort", onAbort);
|
|
169
|
+
ws.onopen = ws.onmessage = ws.onerror = ws.onclose = null;
|
|
170
|
+
try { ws.close(1000); } catch { /* already closing */ }
|
|
171
|
+
if (err) reject(err);
|
|
172
|
+
else resolve(opened || signal.aborted);
|
|
173
|
+
};
|
|
174
|
+
const onAbort = (): void => finish();
|
|
175
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
176
|
+
const arm = (): void => {
|
|
177
|
+
clearTimeout(idle);
|
|
178
|
+
// Silence before the open is a handshake that hangs: fall back.
|
|
179
|
+
idle = setTimeout(() => finish(opened ? new Error(`relay: no data for ${Math.round(idleMs / 1000)}s`) : undefined), idleMs);
|
|
180
|
+
};
|
|
181
|
+
const ack = (): void => {
|
|
182
|
+
clearTimeout(ackTimer);
|
|
183
|
+
ackTimer = undefined;
|
|
184
|
+
if (last <= acked) return;
|
|
185
|
+
acked = last;
|
|
186
|
+
try { ws.send(JSON.stringify({ type: "ack", seq: last })); } catch { /* the close event reports it */ }
|
|
187
|
+
};
|
|
188
|
+
arm();
|
|
189
|
+
ws.onopen = () => {
|
|
190
|
+
opened = true;
|
|
191
|
+
arm();
|
|
192
|
+
ping = setInterval(() => { try { ws.send("ping"); } catch { /* the close event reports it */ } }, pingMs);
|
|
193
|
+
};
|
|
194
|
+
ws.onmessage = (ev) => {
|
|
195
|
+
arm();
|
|
196
|
+
if (typeof ev.data !== "string" || ev.data === "pong") return;
|
|
197
|
+
let m: { type?: unknown; seq?: unknown; bell?: unknown };
|
|
198
|
+
try {
|
|
199
|
+
m = JSON.parse(ev.data) as typeof m;
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return finish(e as Error);
|
|
202
|
+
}
|
|
203
|
+
if (m.type === "hello") {
|
|
204
|
+
const { type: _, ...h } = m as Hello & { type: string };
|
|
205
|
+
last = acked = h.seq;
|
|
206
|
+
on.hello({ ...h, via: "websocket" });
|
|
207
|
+
} else if (m.type === "bell" && typeof m.seq === "number") {
|
|
208
|
+
on.bell(m.seq, m.bell as Doorbell);
|
|
209
|
+
if (done) return; // the handler hung up
|
|
210
|
+
last = Math.max(last, m.seq);
|
|
211
|
+
if (last - acked >= ACK_EVERY) ack();
|
|
212
|
+
else ackTimer ??= setTimeout(ack, ackDelayMs);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
ws.onerror = () => { /* a close event follows */ };
|
|
216
|
+
ws.onclose = (ev) => {
|
|
217
|
+
if (!opened || ev.code === 1000) return finish();
|
|
218
|
+
finish(new Error(`relay: WebSocket closed (${ev.code}${ev.reason ? ` ${ev.reason}` : ""})`));
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The Server-Sent Events transport (GET /stream). */
|
|
224
|
+
function sseSubscriber(base: string, token: string, idleMs: number): Subscribe {
|
|
63
225
|
return async (after, signal, on) => {
|
|
64
226
|
const ac = new AbortController();
|
|
65
227
|
const stop = (): void => ac.abort();
|
|
@@ -72,8 +234,7 @@ export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000)
|
|
|
72
234
|
};
|
|
73
235
|
try {
|
|
74
236
|
arm();
|
|
75
|
-
const
|
|
76
|
-
const res = await fetch(`${base}/stream${q}`, {
|
|
237
|
+
const res = await fetch(`${base}/stream${resumeQuery(after)}`, {
|
|
77
238
|
headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" },
|
|
78
239
|
signal: ac.signal,
|
|
79
240
|
});
|
|
@@ -89,7 +250,7 @@ export function relaySubscriber(baseUrl: string, token: string, idleMs = 70_000)
|
|
|
89
250
|
// CRLF is normalised on the whole buffer: a \r\n pair can straddle chunks.
|
|
90
251
|
buf = parseSse((buf + dec.decode(value, { stream: true })).replace(/\r\n/g, "\n"), (event, data, id) => {
|
|
91
252
|
const j = JSON.parse(data) as unknown;
|
|
92
|
-
if (event === "hello") on.hello(j as Hello);
|
|
253
|
+
if (event === "hello") on.hello({ ...j as Hello, via: "sse" });
|
|
93
254
|
else if (event === "bell" && id !== undefined) on.bell(Number(id), j as Doorbell);
|
|
94
255
|
});
|
|
95
256
|
}
|
package/ts/stream.ts
CHANGED
|
@@ -733,7 +733,7 @@ export async function runStream(client: StreamClient, opts: StreamOpts): Promise
|
|
|
733
733
|
if (bus.live === live) return;
|
|
734
734
|
bus.live = live;
|
|
735
735
|
_internals.err(live
|
|
736
|
-
? `slack stream: relay connected — polling every ${Math.round(Math.max(opts.intervalMs, relay!.reconcileMs) / 1000)}s as a safety net`
|
|
736
|
+
? `slack stream: relay connected${why ? ` (${why})` : ""} — polling every ${Math.round(Math.max(opts.intervalMs, relay!.reconcileMs) / 1000)}s as a safety net`
|
|
737
737
|
: `slack stream: relay unavailable${why ? ` (${why})` : ""} — polling every ${Math.round(opts.intervalMs / 1000)}s`);
|
|
738
738
|
ring();
|
|
739
739
|
};
|
|
@@ -777,7 +777,7 @@ export async function runStream(client: StreamClient, opts: StreamOpts): Promise
|
|
|
777
777
|
for (const p of [...bus.queue, ...bus.retry, ...(bus.inflight ? [bus.inflight] : [])]) p.seq = Math.min(p.seq, h.seq);
|
|
778
778
|
}
|
|
779
779
|
after = { seq: h.seq, ...(h.epoch ? { epoch: h.epoch } : {}) };
|
|
780
|
-
setLive(true);
|
|
780
|
+
setLive(true, h.via);
|
|
781
781
|
},
|
|
782
782
|
bell: (seq, bell) => {
|
|
783
783
|
if (paused) return;
|