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
|
@@ -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
|
+
}
|