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
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
|
@@ -15,6 +15,7 @@ import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAut
|
|
|
15
15
|
import { cmdTail, parseSince as parseDuration } from "./tail.ts";
|
|
16
16
|
import { runStream, webClient } from "./stream.ts";
|
|
17
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;
|
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);
|
package/worker/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# slack-term relay
|
|
2
|
+
|
|
3
|
+
A Cloudflare Worker that turns Slack's **Events API** into a resumable stream of
|
|
4
|
+
*doorbells* for `slack stream`. Slack posts each event to the Worker; the Worker
|
|
5
|
+
keeps only `{channel, ts, thread_ts}` and pushes it over a WebSocket (or, for
|
|
6
|
+
older clients, Server-Sent Events) to authenticated clients. Each client then
|
|
7
|
+
reads the message itself, with its own token.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Slack ──POST /slack/events──▶ Worker ──▶ Relay Durable Object (SQLite)
|
|
11
|
+
│ seq {channel, ts, thread_ts}
|
|
12
|
+
slack stream ◀──GET /ws (WebSocket, hibernating)┤
|
|
13
|
+
older clients ◀──GET /stream (SSE, Bearer)──────┘
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Cost: why WebSocket
|
|
17
|
+
|
|
18
|
+
A Durable Object is billed for **duration** while it is in memory, and an open
|
|
19
|
+
SSE response keeps it there for as long as a client is connected — all day,
|
|
20
|
+
for a stream that is idle most of the time. `/ws` instead uses the Durable
|
|
21
|
+
Object [WebSocket Hibernation API](https://developers.cloudflare.com/durable-objects/best-practices/websockets/):
|
|
22
|
+
|
|
23
|
+
- sockets are accepted with `ctx.acceptWebSocket()`, so between bells the object
|
|
24
|
+
is evicted and not billed, while the connections stay open;
|
|
25
|
+
- the client's keepalive (`"ping"` every 25 s) is answered `"pong"` by the
|
|
26
|
+
runtime (`setWebSocketAutoResponse`) **without waking the object**;
|
|
27
|
+
- all per-client state (resume position, acked position, replay/live, stall
|
|
28
|
+
clock) lives in the socket's attachment (`serializeAttachment`), so it
|
|
29
|
+
survives hibernation, and nothing waits on a timer.
|
|
30
|
+
|
|
31
|
+
The object wakes only for a Slack event, a client connecting, a client's ack
|
|
32
|
+
(sent within ~100 ms of the bells it acknowledges, while the object is still
|
|
33
|
+
awake from the event), or the hourly TTL alarm.
|
|
34
|
+
|
|
35
|
+
**SSE is kept for one release** so `slack stream` clients from before `/ws`
|
|
36
|
+
keep working against a redeployed relay. Each connected SSE client keeps the
|
|
37
|
+
object awake as before; `/health` shows `sse_clients` so you can see when none
|
|
38
|
+
are left, after which `/stream` can be removed. New clients try `/ws` first and
|
|
39
|
+
fall back to `/stream` for a connection only if the socket cannot be opened at
|
|
40
|
+
all (an older relay, a proxy that strips upgrades, a runtime without
|
|
41
|
+
`WebSocket`); `SLACK_RELAY_TRANSPORT=websocket|sse` pins one.
|
|
42
|
+
|
|
43
|
+
## Endpoints
|
|
44
|
+
|
|
45
|
+
| Path | Auth | What |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `POST /slack/events` | Slack signing secret (`X-Slack-Signature`, ±5 min) | Answers `url_verification`; stores one bell per `message` / `app_mention` event. Acks with a single SQLite insert, well inside Slack's 3 s. Retries collapse on `(channel, ts)`. |
|
|
48
|
+
| `GET /ws?after=<seq>&epoch=<e>` | Subprotocols `slack-term-relay.v1` **and** `bearer.<base64url($RELAY_TOKEN)>` (or `Authorization: Bearer`) | WebSocket (below). Rejected with 401 before the upgrade is accepted. |
|
|
49
|
+
| `GET /stream?after=<seq>` | `Authorization: Bearer $RELAY_TOKEN` | SSE (kept for older clients). First an `event: hello` frame `{seq, gap, retention_sec}`, then the backlog after `seq`, then live `event: bell` frames (`id:` = seq). A keepalive comment goes out every 25 s. `Last-Event-ID` works as well as `?after`. Pass `&epoch=` from the last hello: the epoch names the relay's storage, so a client resuming against a reset relay is told `gap: true` and gets everything kept, even once the new numbers have passed its old seq. If `after` is omitted, the stream starts from now. `gap: true` means some bells after `after` are gone (expired, or the relay was reset): everything still kept is replayed, and the client should also catch up by polling. |
|
|
50
|
+
| `GET /health` | Bearer | `{epoch, latest, oldest, clients, websocket_clients, sse_clients, retention_sec}` |
|
|
51
|
+
|
|
52
|
+
At most 16 clients (both kinds) at once; more get 503.
|
|
53
|
+
|
|
54
|
+
### The WebSocket protocol (`/ws`)
|
|
55
|
+
|
|
56
|
+
**Auth.** The token is never put in the URL (query strings end up in logs).
|
|
57
|
+
Node's built-in `WebSocket` cannot send an `Authorization` header, so the client
|
|
58
|
+
offers two subprotocols — `Sec-WebSocket-Protocol: slack-term-relay.v1,
|
|
59
|
+
bearer.<base64url(token)>` (base64url because subprotocols must be HTTP tokens;
|
|
60
|
+
the same scheme Kubernetes uses) — and the Worker compares the bearer one in
|
|
61
|
+
constant time before the request reaches the Durable Object. The server
|
|
62
|
+
selects only `slack-term-relay.v1`, never echoing the token.
|
|
63
|
+
|
|
64
|
+
**Messages** are JSON text, one per frame:
|
|
65
|
+
|
|
66
|
+
- server → client: `{"type":"hello","seq","gap","retention_sec","epoch"}` first
|
|
67
|
+
(same resume rules as SSE: `after`, `epoch`, `gap`); then
|
|
68
|
+
`{"type":"bell","seq":n,"bell":{channel,ts,thread_ts?,self?}}` — the backlog
|
|
69
|
+
after `seq`, then live bells; and `"pong"` (from the runtime).
|
|
70
|
+
- client → server: `"ping"` every 25 s; `{"type":"ack","seq":n}` for the highest
|
|
71
|
+
bell received — at once after 32 unacked bells, else within 100 ms.
|
|
72
|
+
|
|
73
|
+
**Flow control** replaces SSE's write backpressure, which a WebSocket send
|
|
74
|
+
does not give: the backlog is replayed 100 bells at a time, each batch only
|
|
75
|
+
after the previous one is acked, and the switch to live happens right after a
|
|
76
|
+
storage read comes back empty (synchronously, so no bell falls in between). A
|
|
77
|
+
client is closed — and resumes from storage with `?after` — when it has 64 live
|
|
78
|
+
bells unacked (`4008 overflow`), makes no ack progress for 30 s while owing one
|
|
79
|
+
(`4009 stalled`), or neither pings nor acks for 90 s (`4010 silent`). These
|
|
80
|
+
checks run whenever the object is awake (an event, a connect, the alarm), since
|
|
81
|
+
a hibernating object runs no timers.
|
|
82
|
+
|
|
83
|
+
## Privacy
|
|
84
|
+
|
|
85
|
+
- **No message content is stored or served:** no text, no sender, no attachments.
|
|
86
|
+
A bell holds a channel id and two timestamps — plus, on posts by **this app's own
|
|
87
|
+
bot** only, `self`: that bot's user id. A client streaming as the bot never prints
|
|
88
|
+
its own posts, so it skips the Web API read such a bell would cost (and, for
|
|
89
|
+
`--replies-to`, learns from it that the bot is in the thread). No other sender
|
|
90
|
+
is ever recorded.
|
|
91
|
+
- **Bells are deleted 1 hour after arrival** (`RETENTION_SEC`, enforced by a
|
|
92
|
+
Durable Object alarm). That window exists only so a client can resume.
|
|
93
|
+
Anything older is covered by the client's catch-up poll, except replies
|
|
94
|
+
under a thread parent older than the client's `--thread-window`: the poll
|
|
95
|
+
cannot find those, so a bell that expired before the client read it is lost.
|
|
96
|
+
- **Nothing logs a request body**, and Workers Logs are off (`observability`).
|
|
97
|
+
- **Token holders see activity metadata.** Anyone with `RELAY_TOKEN` can watch
|
|
98
|
+
*when* and *where* messages are posted — channel ids, timestamps and thread
|
|
99
|
+
structure, for every conversation the bot is in, **including DMs** with it —
|
|
100
|
+
even though they never see text or senders. Treat the token like read access
|
|
101
|
+
to that timing data: give it only to consumers you trust, and rotate it
|
|
102
|
+
(`wrangler secret put RELAY_TOKEN`) when one should lose access.
|
|
103
|
+
- **A forged bell costs a few Web API reads at most:** one
|
|
104
|
+
`conversations.replies` call, a few pages if needed, and up to 8 retries
|
|
105
|
+
if the message is not found. The client applies its own filter to what
|
|
106
|
+
Slack returns, so a forged bell never prints anything.
|
|
107
|
+
|
|
108
|
+
## Deploy
|
|
109
|
+
|
|
110
|
+
### Without cloning (recommended)
|
|
111
|
+
|
|
112
|
+
The relay ships in the npm package as `slack-term/worker`. Scaffold a Worker
|
|
113
|
+
folder with the CLI:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
slack relay init my-relay # or: npx slack-term relay init my-relay
|
|
117
|
+
cd my-relay && npm install
|
|
118
|
+
npx wrangler login # or CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID (below)
|
|
119
|
+
npx wrangler deploy
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`init` writes `package.json` (slack-term as `^` the CLI's version, wrangler
|
|
123
|
+
pinned), `wrangler.jsonc` (the same config as this folder's, with `--name` or
|
|
124
|
+
the folder's name), `src/index.ts`, `.gitignore` and a `README.md`. It makes no
|
|
125
|
+
network calls and refuses to overwrite existing files unless `--force`. It
|
|
126
|
+
prints a freshly generated `RELAY_TOKEN` — not saved anywhere — and the
|
|
127
|
+
remaining steps: the secrets, the Slack app settings and the client env, all
|
|
128
|
+
as described below (run them from the new folder, with `npx wrangler`).
|
|
129
|
+
|
|
130
|
+
Already have a Worker project? `src/index.ts` is just
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
export { default, Relay } from "slack-term/worker";
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
with a `RELAY` Durable Object binding to class `Relay` and a
|
|
137
|
+
`new_sqlite_classes: ["Relay"]` migration — copy them from
|
|
138
|
+
[`wrangler.jsonc`](wrangler.jsonc). `Env` is exported too, for typing.
|
|
139
|
+
`@cloudflare/workers-types` is an optional peer dependency of slack-term.
|
|
140
|
+
|
|
141
|
+
To update, bump `slack-term` in the folder's package.json and redeploy.
|
|
142
|
+
|
|
143
|
+
### From a clone of this repo (contributors)
|
|
144
|
+
|
|
145
|
+
Wrangler 4 needs **Node ≥ 22**.
|
|
146
|
+
|
|
147
|
+
Cloudflare credentials, in the environment:
|
|
148
|
+
|
|
149
|
+
- `CLOUDFLARE_API_TOKEN` — a token with the **Workers Scripts: Edit** permission
|
|
150
|
+
(the "Edit Cloudflare Workers" template).
|
|
151
|
+
- `CLOUDFLARE_ACCOUNT_ID` — from the dashboard sidebar.
|
|
152
|
+
|
|
153
|
+
```sh
|
|
154
|
+
cd worker
|
|
155
|
+
npx --yes wrangler@4.105.0 deploy
|
|
156
|
+
npx --yes wrangler@4.105.0 secret put SLACK_SIGNING_SECRET # Slack app → Basic Information → Signing Secret
|
|
157
|
+
npx --yes wrangler@4.105.0 secret put RELAY_TOKEN # long random string, shared with consumers (e.g. openssl rand -hex 32)
|
|
158
|
+
npx --yes wrangler@4.105.0 secret put SLACK_TEAM_ID # optional: accept one workspace only (T…)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Redeploying an existing relay keeps its storage (and epoch): no migration is
|
|
162
|
+
needed, the Durable Object class is unchanged. A deploy restarts the object,
|
|
163
|
+
which drops every connection; clients reconnect and resume from their last seq.
|
|
164
|
+
|
|
165
|
+
### Then, either way
|
|
166
|
+
|
|
167
|
+
Check it (expects `{"ok":true,…}`):
|
|
168
|
+
|
|
169
|
+
```sh
|
|
170
|
+
curl -H "Authorization: Bearer $RELAY_TOKEN" https://<worker>/health
|
|
171
|
+
# or, with SLACK_RELAY_URL / SLACK_RELAY_TOKEN set:
|
|
172
|
+
slack relay health
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Then, in the Slack app:
|
|
176
|
+
|
|
177
|
+
1. Go to **Event Subscriptions** and turn on **Enable Events**.
|
|
178
|
+
2. Set the **Request URL** to `https://<worker>/slack/events`. It should verify at once.
|
|
179
|
+
3. Under **Subscribe to bot events**, add:
|
|
180
|
+
- `message.channels`
|
|
181
|
+
- `message.groups`
|
|
182
|
+
- `message.im`
|
|
183
|
+
- `app_mention`
|
|
184
|
+
- `message.mpim`, only if the bot has `mpim:history`.
|
|
185
|
+
Each needs its bot scope (`channels:history`, `groups:history`, `im:history`,
|
|
186
|
+
`app_mentions:read`); Slack offers to add them.
|
|
187
|
+
4. Click **Save**, then reinstall the app if Slack asks.
|
|
188
|
+
|
|
189
|
+
The secrets must be in place first: Slack's URL verification is signed.
|
|
190
|
+
|
|
191
|
+
Consumers set `SLACK_RELAY_URL=https://<worker>` and `SLACK_RELAY_TOKEN=…`
|
|
192
|
+
(the same value as the `RELAY_TOKEN` secret). `slack stream` then uses the relay
|
|
193
|
+
automatically and says so on stderr: `relay connected (websocket)` — or
|
|
194
|
+
`(sse)` if it fell back. Optional: `SLACK_RELAY_TRANSPORT=websocket` or `sse`.
|
|
195
|
+
|
|
196
|
+
## Develop
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
cd worker && bun install && bun run typecheck
|
|
200
|
+
# or, from the repo root (what CI runs):
|
|
201
|
+
bun run typecheck:worker
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The signature and event parsing live in `src/slack.ts`, and the WebSocket
|
|
205
|
+
session logic (replay, acks, flow control, auth) in `src/hub.ts`; neither has
|
|
206
|
+
Cloudflare types, and the CLI suite tests both (`tests/relay.test.ts`,
|
|
207
|
+
`tests/relay-hub.test.ts`, `tests/relay-ws.test.ts`).
|
|
208
|
+
|
|
209
|
+
To run the Worker locally (Node ≥ 22; on an older Node,
|
|
210
|
+
`npx -y -p node@22 -- …` works):
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
cd worker
|
|
214
|
+
printf 'SLACK_SIGNING_SECRET=dev\nRELAY_TOKEN=dev\n' > .dev.vars # git-ignored
|
|
215
|
+
npx --yes wrangler@4.105.0 dev --local
|
|
216
|
+
```
|