slack-term 1.47.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 +30 -2
- package/SKILL.md +3 -2
- package/dist/cli.js +230 -166
- package/package.json +14 -1
- package/ts/cli.ts +45 -3
- package/ts/relayInit.ts +209 -0
- package/ts/stream.ts +1 -1
- 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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The relay's WebSocket side, as pure logic: no Cloudflare types, so the CLI's
|
|
2
|
+
// test suite exercises it under bun with fake sockets (tests/relay-hub.test.ts).
|
|
3
|
+
// index.ts adapts a hibernatable WebSocket to `Sock` and SQLite to `Store`.
|
|
4
|
+
//
|
|
5
|
+
// Why WebSocket: an open SSE response keeps the Durable Object in memory — and
|
|
6
|
+
// billed for duration — for as long as any client is connected. A socket
|
|
7
|
+
// accepted with ctx.acceptWebSocket() lets the object hibernate between bells,
|
|
8
|
+
// and the client's keepalive ("ping" → "pong") is answered by the runtime
|
|
9
|
+
// without waking it. Everything this module remembers about a client
|
|
10
|
+
// therefore lives in the socket's attachment, which survives hibernation;
|
|
11
|
+
// nothing lives in memory, and nothing waits on a timer.
|
|
12
|
+
//
|
|
13
|
+
// The guarantees are those of the SSE path:
|
|
14
|
+
// - hello first: {seq, gap, retention_sec, epoch}, from the same resume rules;
|
|
15
|
+
// - then the backlog after seq, read from storage in REPLAY_BATCH steps, and
|
|
16
|
+
// only then live bells — the switch happens right after a read that came
|
|
17
|
+
// back empty, synchronously, so a bell rung meanwhile is never lost: it is
|
|
18
|
+
// either in storage for the next read or delivered live;
|
|
19
|
+
// - a client that stops taking bells is cut off, not buffered for without
|
|
20
|
+
// bound. SSE learned that from write backpressure; a WebSocket send gives
|
|
21
|
+
// none, so the client acks ({"type":"ack","seq":n}) what it has received.
|
|
22
|
+
// A replay step waits for the ack of the previous one (one batch in flight
|
|
23
|
+
// at most); live bells are cut off past MAX_PENDING unacked; a client that
|
|
24
|
+
// leaves a send unacked for STALL_MS, or stops pinging for DEAD_MS, is
|
|
25
|
+
// closed. Closed clients reconnect with ?after and get the rest from storage.
|
|
26
|
+
|
|
27
|
+
import { type Doorbell as Bell, safeEqual } from "./slack.ts";
|
|
28
|
+
|
|
29
|
+
export type { Bell };
|
|
30
|
+
export type Row = { seq: number; channel: string; ts: string; thread_ts: string | null; self: string | null };
|
|
31
|
+
export type Hello = { seq: number; gap: boolean; retention_sec: number; epoch: string };
|
|
32
|
+
|
|
33
|
+
/** Per-socket state, kept with serializeAttachment (2 KiB max; this is ~100 B). */
|
|
34
|
+
export type Attachment = {
|
|
35
|
+
/** Highest seq sent to this client (its resume point, at the start). */
|
|
36
|
+
sent: number;
|
|
37
|
+
/** Highest seq the client has acked. */
|
|
38
|
+
acked: number;
|
|
39
|
+
/** Still being fed its backlog from storage; live bells reach it that way. */
|
|
40
|
+
replaying: boolean;
|
|
41
|
+
/** Since when (ms) the client owes an ack without having made progress;
|
|
42
|
+
* 0 while nothing is unacked. */
|
|
43
|
+
waitSince: number;
|
|
44
|
+
/** Last message from the client (ms): connect, or an ack. */
|
|
45
|
+
lastSeen: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface Sock {
|
|
49
|
+
send(data: string): void;
|
|
50
|
+
close(code: number, reason: string): void;
|
|
51
|
+
attachment(): Attachment | null;
|
|
52
|
+
setAttachment(a: Attachment): void;
|
|
53
|
+
/** Last runtime-answered "ping" (ms since epoch), null if none yet. */
|
|
54
|
+
lastPing(): number | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface Store {
|
|
58
|
+
rowsAfter(seq: number, limit: number): Row[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The subprotocol a client must offer; the server echoes it back. */
|
|
62
|
+
export const PROTOCOL = "slack-term-relay.v1";
|
|
63
|
+
/** The other offered subprotocol carries the bearer: `bearer.<base64url(token)>`.
|
|
64
|
+
* A header, not the URL: query strings end up in logs. */
|
|
65
|
+
export const BEARER_PREFIX = "bearer.";
|
|
66
|
+
/** Backlog rows sent per replay step. */
|
|
67
|
+
export const REPLAY_BATCH = 100;
|
|
68
|
+
/** Live bells a client may have unacked before it is cut off. */
|
|
69
|
+
export const MAX_PENDING = 64;
|
|
70
|
+
/** An unacked send older than this frees the client's slot. */
|
|
71
|
+
export const STALL_MS = 30_000;
|
|
72
|
+
/** Clients ping every 25 s; one silent for this long is gone. */
|
|
73
|
+
export const DEAD_MS = 90_000;
|
|
74
|
+
/** Close codes (4000–4999: application-defined). */
|
|
75
|
+
export const CLOSE_OVERFLOW = 4008;
|
|
76
|
+
export const CLOSE_STALLED = 4009;
|
|
77
|
+
export const CLOSE_GONE = 4010;
|
|
78
|
+
|
|
79
|
+
export function base64url(s: string): string {
|
|
80
|
+
let bin = "";
|
|
81
|
+
for (const b of new TextEncoder().encode(s)) bin += String.fromCharCode(b);
|
|
82
|
+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Does a Sec-WebSocket-Protocol header offer our protocol and this token?
|
|
86
|
+
* Every offered entry is compared, so timing does not reveal which matched. */
|
|
87
|
+
export function protocolAuthorized(header: string | null, token: string): boolean {
|
|
88
|
+
if (!header || !token) return false;
|
|
89
|
+
const want = BEARER_PREFIX + base64url(token);
|
|
90
|
+
let ok = false;
|
|
91
|
+
let ours = false;
|
|
92
|
+
for (const p of header.split(",")) {
|
|
93
|
+
const v = p.trim();
|
|
94
|
+
if (v === PROTOCOL) ours = true;
|
|
95
|
+
if (safeEqual(v, want)) ok = true;
|
|
96
|
+
}
|
|
97
|
+
return ok && ours;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Where a (re)connecting client starts, from ?after (or Last-Event-ID) and
|
|
101
|
+
* ?epoch. Absent `after` means "from now". A seq we can no longer serve —
|
|
102
|
+
* expired, or from a relay whose storage was reset (a different epoch, or a
|
|
103
|
+
* number past our high-water mark) — is a gap: everything kept is replayed
|
|
104
|
+
* and the client must also catch up by polling. */
|
|
105
|
+
export function resumePoint(
|
|
106
|
+
rawAfter: string | null, clientEpoch: string | null, epoch: string, oldest: number | null, latest: number,
|
|
107
|
+
): { from: number; gap: boolean } {
|
|
108
|
+
const after = rawAfter !== null && /^\d+$/.test(rawAfter) ? Number(rawAfter) : null;
|
|
109
|
+
if (after === null) return { from: latest, gap: false };
|
|
110
|
+
const reset = after > latest || (clientEpoch !== null && clientEpoch !== epoch);
|
|
111
|
+
const gap = reset || (oldest !== null ? after < oldest - 1 : after < latest);
|
|
112
|
+
return { from: reset ? 0 : after, gap };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function bellOf(r: Row): Bell {
|
|
116
|
+
return {
|
|
117
|
+
channel: r.channel, ts: r.ts,
|
|
118
|
+
...(r.thread_ts ? { thread_ts: r.thread_ts } : {}),
|
|
119
|
+
...(r.self ? { self: r.self } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const bellMsg = (seq: number, b: Bell): string => JSON.stringify({ type: "bell", seq, bell: b });
|
|
124
|
+
|
|
125
|
+
function trySend(s: Sock, data: string): boolean {
|
|
126
|
+
try {
|
|
127
|
+
s.send(data);
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
s.close(1011, "send failed");
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One replay step: the next batch after `a.sent`, or — when that read comes
|
|
136
|
+
* back empty — the switch to live. Synchronous from read to switch. */
|
|
137
|
+
function step(s: Sock, a: Attachment, store: Store, now: number, prefix?: string): void {
|
|
138
|
+
if (prefix !== undefined && !trySend(s, prefix)) return;
|
|
139
|
+
const rows = store.rowsAfter(a.sent, REPLAY_BATCH);
|
|
140
|
+
for (const r of rows) {
|
|
141
|
+
if (!trySend(s, bellMsg(r.seq, bellOf(r)))) return;
|
|
142
|
+
a.sent = r.seq;
|
|
143
|
+
}
|
|
144
|
+
if (!rows.length) a.replaying = false;
|
|
145
|
+
else if (!a.waitSince) a.waitSince = now;
|
|
146
|
+
s.setAttachment(a);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A newly accepted socket: hello, then the first replay step. */
|
|
150
|
+
export function open(s: Sock, from: number, hello: Hello, store: Store, now: number): void {
|
|
151
|
+
const a: Attachment = { sent: from, acked: from, replaying: true, waitSince: 0, lastSeen: now };
|
|
152
|
+
step(s, a, store, now, JSON.stringify({ type: "hello", ...hello }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A message from the client. Only acks mean anything; the rest is ignored. */
|
|
156
|
+
export function message(s: Sock, data: string | ArrayBuffer, store: Store, now: number): void {
|
|
157
|
+
const a = s.attachment();
|
|
158
|
+
if (!a) return s.close(1011, "no session");
|
|
159
|
+
a.lastSeen = now;
|
|
160
|
+
let seq: unknown;
|
|
161
|
+
if (typeof data === "string" && data.length <= 200) {
|
|
162
|
+
try {
|
|
163
|
+
const m = JSON.parse(data) as { type?: unknown; seq?: unknown };
|
|
164
|
+
if (m && m.type === "ack") seq = m.seq;
|
|
165
|
+
} catch { /* not JSON: ignored */ }
|
|
166
|
+
}
|
|
167
|
+
if (typeof seq === "number" && Number.isSafeInteger(seq)) {
|
|
168
|
+
const acked = Math.min(seq, a.sent);
|
|
169
|
+
// Progress restarts the stall clock: "stalled" means no ack progress for
|
|
170
|
+
// STALL_MS while something is unacked, not a long busy stretch.
|
|
171
|
+
if (acked > a.acked) a.waitSince = acked >= a.sent ? 0 : now;
|
|
172
|
+
a.acked = Math.max(a.acked, acked);
|
|
173
|
+
}
|
|
174
|
+
if (a.replaying && a.acked >= a.sent) return step(s, a, store, now);
|
|
175
|
+
s.setAttachment(a);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Close clients that stalled or went silent; return the rest. */
|
|
179
|
+
export function sweep<S extends Sock>(socks: Iterable<S>, now: number): S[] {
|
|
180
|
+
const live: S[] = [];
|
|
181
|
+
for (const s of socks) {
|
|
182
|
+
const a = s.attachment();
|
|
183
|
+
if (!a) { s.close(1011, "no session"); continue; }
|
|
184
|
+
if (a.waitSince && now - a.waitSince > STALL_MS) { s.close(CLOSE_STALLED, "stalled"); continue; }
|
|
185
|
+
if (now - Math.max(a.lastSeen, s.lastPing() ?? 0) > DEAD_MS) { s.close(CLOSE_GONE, "silent"); continue; }
|
|
186
|
+
live.push(s);
|
|
187
|
+
}
|
|
188
|
+
return live;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** A new bell, already stored as `seq`: push it to every live client. */
|
|
192
|
+
export function deliver(socks: Iterable<Sock>, seq: number, bell: Bell, now: number): void {
|
|
193
|
+
const msg = bellMsg(seq, bell);
|
|
194
|
+
for (const s of sweep(socks, now)) {
|
|
195
|
+
const a = s.attachment()!;
|
|
196
|
+
if (a.replaying || seq <= a.sent) continue; // storage gets it to a replaying client
|
|
197
|
+
if (a.sent - a.acked >= MAX_PENDING) { s.close(CLOSE_OVERFLOW, "overflow"); continue; }
|
|
198
|
+
if (!trySend(s, msg)) continue;
|
|
199
|
+
if (!a.waitSince) a.waitSince = now;
|
|
200
|
+
a.sent = seq;
|
|
201
|
+
s.setAttachment(a);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// slack-term relay — a Cloudflare Worker that turns Slack's Events API into a
|
|
2
|
+
// resumable doorbell stream for `slack stream`.
|
|
3
|
+
//
|
|
4
|
+
// Slack ──POST /slack/events──▶ Worker ──▶ Relay (Durable Object, SQLite)
|
|
5
|
+
// │ {seq, channel, ts, thread_ts}
|
|
6
|
+
// slack stream ◀──GET /ws (WebSocket, hibernating)┤
|
|
7
|
+
// older clients ◀──GET /stream (SSE, bearer)──────┘
|
|
8
|
+
//
|
|
9
|
+
// Clients connect over a hibernatable WebSocket (hub.ts): between bells the
|
|
10
|
+
// object is evicted from memory and not billed, and keepalive pings are
|
|
11
|
+
// answered by the runtime without waking it. /stream (SSE) stays for clients
|
|
12
|
+
// from before the switch — every connected SSE client keeps the object awake.
|
|
13
|
+
//
|
|
14
|
+
// Privacy: the relay stores and serves ids and timestamps only — never message
|
|
15
|
+
// text, never sender ids (beyond marking the app's own bot posts, `self`). A client that hears a bell reads the message itself
|
|
16
|
+
// through the Web API with its own token, so a forged or replayed bell costs at
|
|
17
|
+
// most one extra scan. Bells are deleted RETENTION_SEC after they arrive; that
|
|
18
|
+
// window only exists so a reconnecting client can resume from its last seq.
|
|
19
|
+
// Nothing here logs a request body.
|
|
20
|
+
|
|
21
|
+
import { DurableObject } from "cloudflare:workers";
|
|
22
|
+
import { type Doorbell, doorbellOf, MAX_BODY_BYTES, plausiblySigned, readLimited, safeEqual, verifySlack } from "./slack.ts";
|
|
23
|
+
import * as hub from "./hub.ts";
|
|
24
|
+
|
|
25
|
+
export interface Env {
|
|
26
|
+
RELAY: DurableObjectNamespace<Relay>;
|
|
27
|
+
/** Slack app → Basic Information → Signing Secret. */
|
|
28
|
+
SLACK_SIGNING_SECRET: string;
|
|
29
|
+
/** Bearer token every stream consumer presents. */
|
|
30
|
+
RELAY_TOKEN: string;
|
|
31
|
+
/** Optional: drop events from any other workspace. */
|
|
32
|
+
SLACK_TEAM_ID?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How long a bell is kept for resume. Longer gaps are covered by the client's
|
|
36
|
+
* catch-up poll, which runs on every (re)connect anyway. */
|
|
37
|
+
const RETENTION_SEC = 3600;
|
|
38
|
+
const KEEPALIVE_MS = 25_000;
|
|
39
|
+
const MAX_CLIENTS = 16;
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
async fetch(req: Request, env: Env): Promise<Response> {
|
|
43
|
+
const url = new URL(req.url);
|
|
44
|
+
const relay = env.RELAY.get(env.RELAY.idFromName("relay"));
|
|
45
|
+
|
|
46
|
+
if (url.pathname === "/slack/events" && req.method === "POST") {
|
|
47
|
+
const timestamp = req.headers.get("x-slack-request-timestamp");
|
|
48
|
+
const signature = req.headers.get("x-slack-signature");
|
|
49
|
+
// Reject before reading anything we would have to buffer.
|
|
50
|
+
if (!plausiblySigned(timestamp, signature, Date.now() / 1000)) return new Response("bad signature", { status: 401 });
|
|
51
|
+
if (Number(req.headers.get("content-length") ?? 0) > MAX_BODY_BYTES) return new Response("too large", { status: 413 });
|
|
52
|
+
const body = await readLimited(req.body);
|
|
53
|
+
if (body === null) return new Response("too large", { status: 413 });
|
|
54
|
+
if (!await verifySlack(env.SLACK_SIGNING_SECRET, timestamp, signature, body, Date.now() / 1000)) {
|
|
55
|
+
return new Response("bad signature", { status: 401 });
|
|
56
|
+
}
|
|
57
|
+
let payload: { type?: unknown; challenge?: unknown };
|
|
58
|
+
try {
|
|
59
|
+
payload = JSON.parse(body) as typeof payload;
|
|
60
|
+
} catch {
|
|
61
|
+
return new Response("bad json", { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
if (payload.type === "url_verification" && typeof payload.challenge === "string") {
|
|
64
|
+
return new Response(payload.challenge, { headers: { "content-type": "text/plain" } });
|
|
65
|
+
}
|
|
66
|
+
const bell = doorbellOf(payload, env.SLACK_TEAM_ID || undefined);
|
|
67
|
+
// A single SQLite insert: well inside Slack's 3 s ack budget. Retries of
|
|
68
|
+
// the same event (x-slack-retry-num) collapse on (channel, ts).
|
|
69
|
+
if (bell) await relay.ring(bell);
|
|
70
|
+
return new Response(null, { status: 200 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (url.pathname === "/ws") {
|
|
74
|
+
// Checked here, before the object is touched or the socket accepted.
|
|
75
|
+
// The bearer rides in a subprotocol (Node's WebSocket cannot set
|
|
76
|
+
// headers); an Authorization header works too.
|
|
77
|
+
const bearer = !!env.RELAY_TOKEN && safeEqual(req.headers.get("authorization") ?? "", `Bearer ${env.RELAY_TOKEN}`);
|
|
78
|
+
if (!bearer && !hub.protocolAuthorized(req.headers.get("sec-websocket-protocol"), env.RELAY_TOKEN)) {
|
|
79
|
+
return new Response("unauthorized", { status: 401 });
|
|
80
|
+
}
|
|
81
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
82
|
+
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return new Response("upgrade required", { status: 426 });
|
|
83
|
+
return relay.fetch(req);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (url.pathname === "/stream" || url.pathname === "/health") {
|
|
87
|
+
if (!env.RELAY_TOKEN || !safeEqual(req.headers.get("authorization") ?? "", `Bearer ${env.RELAY_TOKEN}`)) {
|
|
88
|
+
return new Response("unauthorized", { status: 401 });
|
|
89
|
+
}
|
|
90
|
+
if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
|
|
91
|
+
return relay.fetch(req);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return new Response("not found", { status: 404 });
|
|
95
|
+
},
|
|
96
|
+
} satisfies ExportedHandler<Env>;
|
|
97
|
+
|
|
98
|
+
/** An SSE client (GET /stream, kept for clients from before /ws).
|
|
99
|
+
* `pending`: writes not yet taken by the consumer. A client that stops
|
|
100
|
+
* reading is cut off past MAX_PENDING instead of buffering without bound;
|
|
101
|
+
* it reconnects with ?after and gets the rest from storage. */
|
|
102
|
+
type Client = {
|
|
103
|
+
writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
104
|
+
pending: number;
|
|
105
|
+
/** Still being fed its backlog from storage; live bells reach it that way. */
|
|
106
|
+
replaying: boolean;
|
|
107
|
+
};
|
|
108
|
+
const MAX_PENDING = 64;
|
|
109
|
+
/** Backlog rows read and written per step of a replay. */
|
|
110
|
+
const REPLAY_BATCH = hub.REPLAY_BATCH;
|
|
111
|
+
/** A replay write the reader has not taken by then frees its slot. */
|
|
112
|
+
const REPLAY_WRITE_MS = 30_000;
|
|
113
|
+
const enc = new TextEncoder();
|
|
114
|
+
|
|
115
|
+
export class Relay extends DurableObject<Env> {
|
|
116
|
+
private clients = new Set<Client>();
|
|
117
|
+
/** Names this storage. A client that saved a seq under another epoch is
|
|
118
|
+
* reading a reset relay, whose numbers mean something else now. */
|
|
119
|
+
private readonly epoch: string;
|
|
120
|
+
private keepalive: ReturnType<typeof setInterval> | undefined;
|
|
121
|
+
/** An alarm is known to be set: ring() skips the getAlarm() storage read.
|
|
122
|
+
* Starts false on every (re)start of the object, so it re-checks once. */
|
|
123
|
+
private alarmArmed = false;
|
|
124
|
+
|
|
125
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
126
|
+
super(ctx, env);
|
|
127
|
+
// Answered by the runtime, without waking a hibernating object.
|
|
128
|
+
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
|
|
129
|
+
ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS bells (
|
|
130
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
131
|
+
channel TEXT NOT NULL, ts TEXT NOT NULL, thread_ts TEXT,
|
|
132
|
+
at INTEGER NOT NULL,
|
|
133
|
+
UNIQUE (channel, ts)
|
|
134
|
+
)`);
|
|
135
|
+
// Added later: a relay deployed before it gets the column on its next start.
|
|
136
|
+
const cols = ctx.storage.sql.exec<{ name: string }>("PRAGMA table_info(bells)").toArray().map((c) => c.name);
|
|
137
|
+
if (!cols.includes("self")) ctx.storage.sql.exec("ALTER TABLE bells ADD COLUMN self TEXT");
|
|
138
|
+
ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)");
|
|
139
|
+
ctx.storage.sql.exec("INSERT OR IGNORE INTO meta (k, v) VALUES ('epoch', ?)", crypto.randomUUID());
|
|
140
|
+
this.epoch = ctx.storage.sql.exec<{ v: string }>("SELECT v FROM meta WHERE k = 'epoch'").one().v;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Store a bell (once per channel+ts) and push it to every live client. */
|
|
144
|
+
async ring(bell: Doorbell): Promise<void> {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const rows = this.ctx.storage.sql.exec<{ seq: number }>(
|
|
147
|
+
"INSERT OR IGNORE INTO bells (channel, ts, thread_ts, self, at) VALUES (?, ?, ?, ?, ?) RETURNING seq",
|
|
148
|
+
bell.channel, bell.ts, bell.thread_ts ?? null, bell.self ?? null, now,
|
|
149
|
+
).toArray();
|
|
150
|
+
const seq = rows[0]?.seq;
|
|
151
|
+
if (seq === undefined) return; // a retry of a bell we already have
|
|
152
|
+
if (!this.alarmArmed) {
|
|
153
|
+
if ((await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(now + RETENTION_SEC * 1000);
|
|
154
|
+
this.alarmArmed = true;
|
|
155
|
+
}
|
|
156
|
+
this.broadcast(frame(seq, bell));
|
|
157
|
+
hub.deliver(this.sockets(), seq, bell, now);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** TTL sweep: drop expired bells, re-arm while any remain. */
|
|
161
|
+
override async alarm(): Promise<void> {
|
|
162
|
+
const cutoff = Date.now() - RETENTION_SEC * 1000;
|
|
163
|
+
this.ctx.storage.sql.exec("DELETE FROM bells WHERE at <= ?", cutoff);
|
|
164
|
+
const next = this.ctx.storage.sql.exec<{ at: number | null }>("SELECT MIN(at) AS at FROM bells").one().at;
|
|
165
|
+
if (next !== null) await this.ctx.storage.setAlarm(next + RETENTION_SEC * 1000);
|
|
166
|
+
this.alarmArmed = next !== null;
|
|
167
|
+
hub.sweep(this.sockets(), Date.now());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
override webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): void {
|
|
171
|
+
hub.message(this.sock(ws), data, this.store, Date.now());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Socket state lives on the socket: nothing to clean up but the socket.
|
|
175
|
+
override webSocketClose(ws: WebSocket, code: number): void {
|
|
176
|
+
try { ws.close(code === 1005 || code === 1006 ? 1000 : code, "bye"); } catch { /* already closed */ }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
override webSocketError(ws: WebSocket): void {
|
|
180
|
+
try { ws.close(1011, "error"); } catch { /* already closed */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private readonly store: hub.Store = {
|
|
184
|
+
rowsAfter: (seq, limit) => this.ctx.storage.sql.exec<hub.Row>(
|
|
185
|
+
"SELECT seq, channel, ts, thread_ts, self FROM bells WHERE seq > ? ORDER BY seq LIMIT ?", seq, limit,
|
|
186
|
+
).toArray(),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
private sock(ws: WebSocket): hub.Sock {
|
|
190
|
+
return {
|
|
191
|
+
send: (d) => ws.send(d),
|
|
192
|
+
close: (code, reason) => { try { ws.close(code, reason); } catch { /* already closed */ } },
|
|
193
|
+
attachment: () => ws.deserializeAttachment() as hub.Attachment | null,
|
|
194
|
+
setAttachment: (a) => ws.serializeAttachment(a),
|
|
195
|
+
lastPing: () => this.ctx.getWebSocketAutoResponseTimestamp(ws)?.getTime() ?? null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private sockets(): hub.Sock[] {
|
|
200
|
+
return this.ctx.getWebSockets().filter((ws) => ws.readyState === WebSocket.OPEN).map((ws) => this.sock(ws));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
override async fetch(req: Request): Promise<Response> {
|
|
204
|
+
const url = new URL(req.url);
|
|
205
|
+
const { oldest, latest } = this.bounds();
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
const sockets = hub.sweep(this.sockets(), now);
|
|
208
|
+
if (url.pathname === "/health") {
|
|
209
|
+
return Response.json({
|
|
210
|
+
ok: true, epoch: this.epoch, latest, oldest, clients: this.clients.size + sockets.length,
|
|
211
|
+
websocket_clients: sockets.length, sse_clients: this.clients.size, retention_sec: RETENTION_SEC,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (this.clients.size + sockets.length >= MAX_CLIENTS) return new Response("too many clients", { status: 503 });
|
|
216
|
+
// Resume point: ?after=<seq> or the SSE Last-Event-ID header (hub.resumePoint).
|
|
217
|
+
const { from, gap } = hub.resumePoint(
|
|
218
|
+
url.searchParams.get("after") ?? req.headers.get("last-event-id"), url.searchParams.get("epoch"), this.epoch, oldest, latest,
|
|
219
|
+
);
|
|
220
|
+
const hello = { seq: from, gap, retention_sec: RETENTION_SEC, epoch: this.epoch };
|
|
221
|
+
|
|
222
|
+
if (url.pathname === "/ws") {
|
|
223
|
+
const [client, server] = Object.values(new WebSocketPair()) as [WebSocket, WebSocket];
|
|
224
|
+
this.ctx.acceptWebSocket(server);
|
|
225
|
+
hub.open(this.sock(server), from, hello, this.store, now);
|
|
226
|
+
const offered = (req.headers.get("sec-websocket-protocol") ?? "").split(",").some((p) => p.trim() === hub.PROTOCOL);
|
|
227
|
+
return new Response(null, { status: 101, webSocket: client, headers: offered ? { "sec-websocket-protocol": hub.PROTOCOL } : {} });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>();
|
|
231
|
+
const client: Client = { writer: writable.getWriter(), pending: 0, replaying: true };
|
|
232
|
+
this.clients.add(client);
|
|
233
|
+
this.keepalive ??= setInterval(() => this.broadcast(": ka\n\n"), KEEPALIVE_MS);
|
|
234
|
+
void this.replay(client, from, `event: hello\ndata: ${JSON.stringify(hello)}\n\n`);
|
|
235
|
+
|
|
236
|
+
return new Response(readable, {
|
|
237
|
+
headers: { "content-type": "text/event-stream", "cache-control": "no-store", "x-accel-buffering": "no" },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private bounds(): { oldest: number | null; latest: number } {
|
|
242
|
+
const b = this.ctx.storage.sql.exec<{ oldest: number | null }>("SELECT MIN(seq) AS oldest FROM bells").one();
|
|
243
|
+
// sqlite_sequence remembers the high-water mark even after every row expired.
|
|
244
|
+
const hw = this.ctx.storage.sql.exec<{ seq: number }>("SELECT seq FROM sqlite_sequence WHERE name = 'bells'").toArray();
|
|
245
|
+
return { oldest: b.oldest, latest: hw[0]?.seq ?? 0 };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Feed a client its backlog in batches, each write awaited — a reader
|
|
249
|
+
* that stalls stalls only its own replay, holding one batch at most. A
|
|
250
|
+
* bell rung meanwhile is already stored, so the next batch picks it up;
|
|
251
|
+
* the switch to live happens right after a read that came back empty,
|
|
252
|
+
* with no await in between, so nothing falls between the two. */
|
|
253
|
+
private async replay(c: Client, from: number, hello: string): Promise<void> {
|
|
254
|
+
let chunk = hello;
|
|
255
|
+
let cursor = from;
|
|
256
|
+
for (;;) {
|
|
257
|
+
const rows = this.store.rowsAfter(cursor, REPLAY_BATCH);
|
|
258
|
+
for (const r of rows) {
|
|
259
|
+
chunk += frame(r.seq, hub.bellOf(r));
|
|
260
|
+
cursor = r.seq;
|
|
261
|
+
}
|
|
262
|
+
if (!rows.length) c.replaying = false;
|
|
263
|
+
if (chunk) {
|
|
264
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
265
|
+
try {
|
|
266
|
+
await Promise.race([
|
|
267
|
+
c.writer.write(enc.encode(chunk)),
|
|
268
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("stalled")), REPLAY_WRITE_MS); }),
|
|
269
|
+
]);
|
|
270
|
+
} catch {
|
|
271
|
+
return this.drop(c);
|
|
272
|
+
} finally {
|
|
273
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!c.replaying || !this.clients.has(c)) return;
|
|
277
|
+
chunk = "";
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private broadcast(chunk: string): void {
|
|
282
|
+
for (const c of this.clients) if (!c.replaying) void this.send(c, chunk);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async send(c: Client, chunk: string): Promise<void> {
|
|
286
|
+
if (c.pending >= MAX_PENDING) return this.drop(c);
|
|
287
|
+
c.pending++;
|
|
288
|
+
try {
|
|
289
|
+
await c.writer.write(enc.encode(chunk));
|
|
290
|
+
c.pending--;
|
|
291
|
+
} catch {
|
|
292
|
+
this.drop(c);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private drop(c: Client): void {
|
|
297
|
+
if (!this.clients.delete(c)) return;
|
|
298
|
+
c.writer.abort().catch(() => {});
|
|
299
|
+
if (this.clients.size === 0 && this.keepalive !== undefined) {
|
|
300
|
+
clearInterval(this.keepalive);
|
|
301
|
+
this.keepalive = undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function frame(seq: number, bell: Doorbell): string {
|
|
307
|
+
return `id: ${seq}\nevent: bell\ndata: ${JSON.stringify(bell)}\n\n`;
|
|
308
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Pure Slack-side logic for the relay Worker: request signing and turning an
|
|
2
|
+
// Events API payload into a doorbell. No Cloudflare types here, so the CLI's
|
|
3
|
+
// test suite can exercise it under bun.
|
|
4
|
+
|
|
5
|
+
/** What the relay keeps and serves: WHERE something happened, never what was
|
|
6
|
+
* said. A client that hears a doorbell reads the message itself, through the
|
|
7
|
+
* Slack Web API, with its own token — so the relay holds no message content. */
|
|
8
|
+
export type Doorbell = {
|
|
9
|
+
channel: string; ts: string; thread_ts?: string;
|
|
10
|
+
/** Set only on a post by the receiving app's own bot: that bot's user id.
|
|
11
|
+
* A consumer streaming as this bot never prints its own posts, so it can
|
|
12
|
+
* skip the Web API read the bell would cost. Nothing about any other
|
|
13
|
+
* sender is kept. */
|
|
14
|
+
self?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Slack rejects replays older than five minutes; so do we. */
|
|
18
|
+
export const MAX_SKEW_SEC = 300;
|
|
19
|
+
|
|
20
|
+
/** Slack event payloads are a few KB; anything far larger is not Slack. */
|
|
21
|
+
export const MAX_BODY_BYTES = 1 << 20;
|
|
22
|
+
|
|
23
|
+
/** Cheap pre-check before the body is read at all: both signing headers
|
|
24
|
+
* present, signature well-formed, timestamp fresh. */
|
|
25
|
+
export function plausiblySigned(timestamp: string | null, signature: string | null, nowSec: number): boolean {
|
|
26
|
+
if (!timestamp || !signature || !/^v0=[0-9a-f]{64}$/.test(signature)) return false;
|
|
27
|
+
const ts = Number(timestamp);
|
|
28
|
+
return Number.isFinite(ts) && Math.abs(nowSec - ts) <= MAX_SKEW_SEC;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read a body as text, giving up (null) once it passes `max` bytes —
|
|
32
|
+
* so an oversized request is never buffered whole. */
|
|
33
|
+
export async function readLimited(body: ReadableStream<Uint8Array> | null, max = MAX_BODY_BYTES): Promise<string | null> {
|
|
34
|
+
if (!body) return "";
|
|
35
|
+
const reader = body.getReader();
|
|
36
|
+
const chunks: Uint8Array[] = [];
|
|
37
|
+
let size = 0;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const { done, value } = await reader.read();
|
|
40
|
+
if (done) break;
|
|
41
|
+
size += value.byteLength;
|
|
42
|
+
if (size > max) {
|
|
43
|
+
await reader.cancel().catch(() => {});
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
chunks.push(value);
|
|
47
|
+
}
|
|
48
|
+
const all = new Uint8Array(size);
|
|
49
|
+
let off = 0;
|
|
50
|
+
for (const c of chunks) { all.set(c, off); off += c.byteLength; }
|
|
51
|
+
return new TextDecoder().decode(all);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const enc = new TextEncoder();
|
|
55
|
+
|
|
56
|
+
function hex(buf: ArrayBuffer): string {
|
|
57
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Constant-time string compare (both sides are ours to length-check). */
|
|
61
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
62
|
+
if (a.length !== b.length) return false;
|
|
63
|
+
let d = 0;
|
|
64
|
+
for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
65
|
+
return d === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Verify X-Slack-Signature: v0=HMAC-SHA256(secret, "v0:{timestamp}:{raw body}").
|
|
69
|
+
* https://api.slack.com/authentication/verifying-requests-from-slack */
|
|
70
|
+
export async function verifySlack(
|
|
71
|
+
secret: string, timestamp: string | null, signature: string | null, body: string, nowSec: number,
|
|
72
|
+
): Promise<boolean> {
|
|
73
|
+
if (!secret || !timestamp || !signature) return false;
|
|
74
|
+
const ts = Number(timestamp);
|
|
75
|
+
if (!Number.isFinite(ts) || Math.abs(nowSec - ts) > MAX_SKEW_SEC) return false;
|
|
76
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
77
|
+
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`v0:${timestamp}:${body}`));
|
|
78
|
+
return safeEqual(`v0=${hex(mac)}`, signature);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Housekeeping that is never a person talking. message_changed is dropped too:
|
|
82
|
+
// `slack stream` does not re-emit edits, so ringing for one would only cost a scan.
|
|
83
|
+
const SKIP_SUBTYPES = new Set([
|
|
84
|
+
"message_changed", "message_deleted", "channel_join", "channel_leave", "group_join", "group_leave",
|
|
85
|
+
"channel_topic", "channel_purpose", "channel_name", "pinned_item", "unpinned_item",
|
|
86
|
+
// A hidden update to a thread parent: its outer ts names the update, not a
|
|
87
|
+
// message — the reply itself arrives as its own event.
|
|
88
|
+
"message_replied",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const TS = /^\d{6,}\.\d{1,9}$/;
|
|
92
|
+
const CHANNEL = /^[CDG][A-Z0-9]{2,}$/;
|
|
93
|
+
const USER = /^[UW][A-Z0-9]{2,}$/;
|
|
94
|
+
|
|
95
|
+
/** The receiving app's bot user id when this event is that bot's own post:
|
|
96
|
+
* the sender is the authorized bot user, or the post carries this app's id. */
|
|
97
|
+
function selfPost(p: { api_app_id?: unknown; authorizations?: unknown }, e: Record<string, unknown>): string | undefined {
|
|
98
|
+
const auth = Array.isArray(p.authorizations) ? p.authorizations[0] as Record<string, unknown> | undefined : undefined;
|
|
99
|
+
const botUser = auth && auth.is_bot === true && typeof auth.user_id === "string" && USER.test(auth.user_id) ? auth.user_id : undefined;
|
|
100
|
+
if (!botUser) return undefined;
|
|
101
|
+
if (e.user === botUser) return botUser;
|
|
102
|
+
const appId = typeof e.app_id === "string" ? e.app_id
|
|
103
|
+
: e.bot_profile && typeof e.bot_profile === "object" ? (e.bot_profile as Record<string, unknown>).app_id : undefined;
|
|
104
|
+
return typeof p.api_app_id === "string" && appId === p.api_app_id && e.user === undefined ? botUser : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The doorbell for one event_callback, or null when there is nothing to ring
|
|
108
|
+
* for. Only ids and timestamps survive this function. */
|
|
109
|
+
export function doorbellOf(payload: unknown, teamId?: string): Doorbell | null {
|
|
110
|
+
if (!payload || typeof payload !== "object") return null;
|
|
111
|
+
const p = payload as { type?: unknown; team_id?: unknown; api_app_id?: unknown; authorizations?: unknown; event?: Record<string, unknown> };
|
|
112
|
+
if (p.type !== "event_callback" || !p.event || typeof p.event !== "object") return null;
|
|
113
|
+
if (teamId && p.team_id !== teamId) return null;
|
|
114
|
+
const e = p.event;
|
|
115
|
+
if (e.type !== "message" && e.type !== "app_mention") return null;
|
|
116
|
+
if (typeof e.subtype === "string" && SKIP_SUBTYPES.has(e.subtype)) return null;
|
|
117
|
+
if (e.hidden === true) return null;
|
|
118
|
+
const { channel, ts, thread_ts } = e;
|
|
119
|
+
if (typeof channel !== "string" || !CHANNEL.test(channel)) return null;
|
|
120
|
+
if (typeof ts !== "string" || !TS.test(ts)) return null;
|
|
121
|
+
const self = selfPost(p, e);
|
|
122
|
+
return {
|
|
123
|
+
channel, ts,
|
|
124
|
+
...(typeof thread_ts === "string" && TS.test(thread_ts) && thread_ts !== ts ? { thread_ts } : {}),
|
|
125
|
+
...(self ? { self } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|