slack-term 1.47.0 → 1.49.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 +86 -2
- package/SKILL.md +3 -2
- package/dist/cli.js +346 -242
- package/locales/en.json +32 -0
- package/locales/ja.json +31 -0
- package/package.json +15 -1
- package/ts/ask.ts +47 -46
- package/ts/botIntent.ts +1 -1
- package/ts/choiceHint.ts +30 -0
- package/ts/cli.ts +774 -126
- package/ts/inbox.ts +191 -0
- package/ts/locales.ts +113 -0
- package/ts/readback.ts +102 -0
- package/ts/redact.ts +225 -0
- package/ts/relayInit.ts +209 -0
- package/ts/scope.ts +472 -0
- package/ts/slack.ts +40 -2
- package/ts/stream.ts +4 -1
- package/ts/tick.ts +47 -0
- package/ts/vendor/gitleaks.LICENSE +21 -0
- package/ts/vendor/gitleaks.toml +3209 -0
- 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/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/scope.ts
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
// `.slack-term/` — per-directory state, found like `.git`: the nearest one
|
|
2
|
+
// walking up from the cwd is the scope, so each lane's tree (or repo) owns its
|
|
3
|
+
// own aliases and settings without a pid, an env var or a daemon. No
|
|
4
|
+
// `.slack-term/` anywhere up the tree falls back to ~/.config/slack-term/.
|
|
5
|
+
//
|
|
6
|
+
// .slack-term/
|
|
7
|
+
// README.md what this is (written on init)
|
|
8
|
+
// .gitignore "*" — nothing in here is ever committed
|
|
9
|
+
// settings.yml written by people: prefix, channel, as_bot, subscriptions
|
|
10
|
+
// settings.local.yml written by `slack alias`; overrides settings.yml
|
|
11
|
+
// inbox/ archived/ incoming messages, one file each; handled ones move to archived/
|
|
12
|
+
// outbox/ sent/ outgoing messages, one file each; sent ones move to sent/
|
|
13
|
+
//
|
|
14
|
+
// Every write is tmp + rename (a reader never sees half a file), and the
|
|
15
|
+
// read-modify-write of settings.local.yml holds a lock file, so two `slack
|
|
16
|
+
// alias set` at once cannot drop each other's entry.
|
|
17
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const SCOPE_DIR = ".slack-term";
|
|
22
|
+
export const SCOPE_SUBDIRS = ["inbox", "archived", "outbox", "sent"] as const;
|
|
23
|
+
|
|
24
|
+
export type Scope = { dir: string; global: boolean };
|
|
25
|
+
|
|
26
|
+
/** The global fallback: ~/.config/slack-term (SLACK_TERM_HOME overrides, for tests). */
|
|
27
|
+
export function globalScopeDir(): string {
|
|
28
|
+
return process.env.SLACK_TERM_HOME ?? join(homedir(), ".config", "slack-term");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The nearest `.slack-term/` at or above `cwd`, else the global one. */
|
|
32
|
+
export function findScope(cwd = process.cwd()): Scope {
|
|
33
|
+
let d = resolve(cwd);
|
|
34
|
+
for (;;) {
|
|
35
|
+
const c = join(d, SCOPE_DIR);
|
|
36
|
+
try {
|
|
37
|
+
if (statSync(c).isDirectory()) return { dir: c, global: false };
|
|
38
|
+
} catch { /* not here */ }
|
|
39
|
+
const up = dirname(d);
|
|
40
|
+
if (up === d) break;
|
|
41
|
+
d = up;
|
|
42
|
+
}
|
|
43
|
+
return { dir: globalScopeDir(), global: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const README = `# .slack-term/
|
|
47
|
+
|
|
48
|
+
State for [slack-term](https://github.com/snomiao/slack-term) in this directory.
|
|
49
|
+
\`slack\` uses the nearest \`.slack-term/\` walking up from where it runs (like
|
|
50
|
+
\`.git\`), so everything below this directory shares it. Without one it uses
|
|
51
|
+
~/.config/slack-term/.
|
|
52
|
+
|
|
53
|
+
Nothing here is committed: \`.gitignore\` holds \`*\`.
|
|
54
|
+
|
|
55
|
+
| Path | What |
|
|
56
|
+
|---|---|
|
|
57
|
+
| \`settings.yml\` | yours to edit: \`prefix\` (put in front of every \`slack reply\`, e.g. \`"[design]"\`), \`channel\` (default), \`as_bot\`, \`subscriptions\` |
|
|
58
|
+
| \`settings.local.yml\` | written by \`slack alias set/unset\`; same keys, wins over \`settings.yml\` |
|
|
59
|
+
| \`inbox/\` → \`archived/\` | one file per incoming message (\`slack stream --inbox\`): who, where, the text with secrets redacted, a files count, the permalink. Handled = moved to \`archived/\`, by \`slack done\` or by a sent outbox reply with \`in_reply_to\` |
|
|
60
|
+
| \`outbox/\` → \`sent/\` | one file per queued reply (\`slack outbox add\`, redacted); \`slack outbox flush\` sends them and moves each to \`sent/\` with its ts and permalink. A failure stays, with \`error\` and \`attempts\` |
|
|
61
|
+
|
|
62
|
+
\`restrict_to_subscriptions\`, \`auto_subscribe\` and \`nudge_after_min\` count only in
|
|
63
|
+
\`settings.yml\`. With \`restrict_to_subscriptions: true\`, only \`settings.yml\`'s
|
|
64
|
+
subscriptions count, \`slack alias set\` refuses (the owner uses \`--shared\`), and
|
|
65
|
+
\`reply\` / \`outbox flush\` refuse any other thread.
|
|
66
|
+
|
|
67
|
+
\`subscriptions\` maps an alias to a place:
|
|
68
|
+
|
|
69
|
+
subscriptions:
|
|
70
|
+
board:
|
|
71
|
+
channel: C00000001
|
|
72
|
+
thread_ts: "1700000000.000100"
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
slack alias ls # the aliases here
|
|
77
|
+
slack alias set <name> <permalink> # name a thread (a reply's permalink names its root)
|
|
78
|
+
slack alias unset <name>
|
|
79
|
+
slack read <name> # read the thread
|
|
80
|
+
slack reply <name> '<text>' # reply in it, with the prefix, behind the --code gate
|
|
81
|
+
slack reply --to <permalink> '<text>' # reply in the thread where that message is
|
|
82
|
+
slack reply <name> --to <permalink> … # refused when that message is in another thread
|
|
83
|
+
slack inbox ls | listen | nudge # what was routed here
|
|
84
|
+
slack done <file|ts> # handled without a reply
|
|
85
|
+
slack outbox add <name> '<text>' --in-reply-to <ts>
|
|
86
|
+
slack outbox flush # send the queue (one --code for the batch)
|
|
87
|
+
slack alias who <channel> <ts> # which dirs subscribe a thread
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
const SETTINGS = `# slack-term settings for this directory. settings.local.yml (written by
|
|
91
|
+
# \`slack alias\`) has the same keys and wins over this file.
|
|
92
|
+
#
|
|
93
|
+
# prefix: "[lane]" # put in front of every \`slack reply\`; never doubled
|
|
94
|
+
# channel: C00000001 # default channel
|
|
95
|
+
# as_bot: false # reply as the bot (SLACK_BOT_TOKEN)
|
|
96
|
+
# subscriptions: # alias -> place (\`slack alias set\` writes settings.local.yml)
|
|
97
|
+
# board:
|
|
98
|
+
# channel: C00000001
|
|
99
|
+
# thread_ts: "1700000000.000100"
|
|
100
|
+
#
|
|
101
|
+
# Only here, never in settings.local.yml:
|
|
102
|
+
# restrict_to_subscriptions: true # the subscriptions above are the whole allowlist,
|
|
103
|
+
# # and prefix / as_bot / channel come from here only
|
|
104
|
+
# owner: ~/ws/x/tree/shr # with restrict: --shared writes run only from under it
|
|
105
|
+
# auto_subscribe: [D00000001] # new top-level messages there are subscribed here
|
|
106
|
+
# nudge_after_min: 30 # \`slack inbox nudge\` reports what waits longer
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
export type Subscription = { channel: string; thread_ts?: string; added?: string; note?: string };
|
|
110
|
+
export type Settings = {
|
|
111
|
+
prefix?: string; channel?: string; as_bot?: boolean; subscriptions?: Record<string, Subscription>;
|
|
112
|
+
/** settings.yml only: `subscriptions` there are the whole allowlist —
|
|
113
|
+
* settings.local.yml can neither add one nor switch this off, and `reply`
|
|
114
|
+
* refuses a thread that is not in it. */
|
|
115
|
+
restrict_to_subscriptions?: boolean;
|
|
116
|
+
/** settings.yml only: channels whose NEW top-level messages are subscribed
|
|
117
|
+
* here automatically (as `t-<ts>`), so a fresh DM thread has an owner. */
|
|
118
|
+
auto_subscribe?: string[];
|
|
119
|
+
/** Minutes an inbox entry may wait before `slack inbox nudge` reports it. */
|
|
120
|
+
nudge_after_min?: number;
|
|
121
|
+
/** settings.yml only: the directory whose lane owns this allowlist (e.g. the
|
|
122
|
+
* coordinator's tree). In a restricted scope, `--shared` writes are refused
|
|
123
|
+
* unless they run from under it — every lane is the same uid, so the CLI is
|
|
124
|
+
* the only thing that can tell them apart. */
|
|
125
|
+
owner?: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Create (or complete) `<parent>/.slack-term/`. Idempotent: existing files are kept. */
|
|
129
|
+
export function initScope(parent: string, settings: Omit<Settings, "subscriptions"> = {}): { dir: string; created: string[] } {
|
|
130
|
+
const dir = join(resolve(parent), SCOPE_DIR);
|
|
131
|
+
const created: string[] = [];
|
|
132
|
+
mkdirSync(dir, { recursive: true });
|
|
133
|
+
for (const sub of SCOPE_SUBDIRS) {
|
|
134
|
+
const d = join(dir, sub);
|
|
135
|
+
if (!existsSync(d)) { mkdirSync(d, { recursive: true }); created.push(`${sub}/`); }
|
|
136
|
+
}
|
|
137
|
+
const put = (name: string, body: string): void => {
|
|
138
|
+
const p = join(dir, name);
|
|
139
|
+
if (existsSync(p)) return;
|
|
140
|
+
atomicWrite(p, body);
|
|
141
|
+
created.push(name);
|
|
142
|
+
};
|
|
143
|
+
put(".gitignore", "*\n");
|
|
144
|
+
put("README.md", README);
|
|
145
|
+
const given = Object.fromEntries(Object.entries(settings).filter(([, v]) => v !== undefined));
|
|
146
|
+
put("settings.yml", SETTINGS + (Object.keys(given).length ? "\n" + Bun.YAML.stringify(given, null, 2) + "\n" : ""));
|
|
147
|
+
if (dir !== globalScopeDir()) registerScope(dir);
|
|
148
|
+
return { dir, created };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function atomicWrite(path: string, body: string): void {
|
|
152
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
153
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
154
|
+
writeFileSync(tmp, body, { mode: 0o600 });
|
|
155
|
+
renameSync(tmp, path);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Hold `<path>.lock` (O_EXCL) around `fn`. A lock older than `staleMs` is a
|
|
159
|
+
* crashed writer's and is taken over. */
|
|
160
|
+
export function withFileLock<T>(path: string, fn: () => T, staleMs = 10_000, waitMs = 5_000): T {
|
|
161
|
+
const lock = `${path}.lock`;
|
|
162
|
+
mkdirSync(dirname(lock), { recursive: true });
|
|
163
|
+
const until = Date.now() + waitMs;
|
|
164
|
+
for (;;) {
|
|
165
|
+
try {
|
|
166
|
+
closeSync(openSync(lock, "wx"));
|
|
167
|
+
break;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
170
|
+
try {
|
|
171
|
+
if (Date.now() - statSync(lock).mtimeMs > staleMs) { unlinkSync(lock); continue; }
|
|
172
|
+
} catch { continue; }
|
|
173
|
+
if (Date.now() > until) throw new Error(`${lock} is held by another writer`);
|
|
174
|
+
Bun.sleepSync(25);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
return fn();
|
|
179
|
+
} finally {
|
|
180
|
+
try { unlinkSync(lock); } catch { /* already gone */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function asSubscription(v: unknown): Subscription | undefined {
|
|
185
|
+
if (!v || typeof v !== "object") return undefined;
|
|
186
|
+
const o = v as Record<string, unknown>;
|
|
187
|
+
if (typeof o.channel !== "string" || !o.channel) return undefined;
|
|
188
|
+
const s: Subscription = { channel: o.channel };
|
|
189
|
+
// A ts written unquoted parses as a number and loses its trailing zeros.
|
|
190
|
+
if (typeof o.thread_ts === "string" && /^\d{10}\.\d{6}$/.test(o.thread_ts)) s.thread_ts = o.thread_ts;
|
|
191
|
+
if (typeof o.added === "string") s.added = o.added;
|
|
192
|
+
if (typeof o.note === "string") s.note = o.note;
|
|
193
|
+
return s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One settings file, validated; unknown keys and malformed entries are dropped. */
|
|
197
|
+
export function readSettingsFile(path: string): Settings {
|
|
198
|
+
let raw: unknown;
|
|
199
|
+
try {
|
|
200
|
+
raw = Bun.YAML.parse(readFileSync(path, "utf8"));
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
203
|
+
throw new Error(`${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
204
|
+
}
|
|
205
|
+
if (!raw || typeof raw !== "object") return {};
|
|
206
|
+
const o = raw as Record<string, unknown>;
|
|
207
|
+
const s: Settings = {};
|
|
208
|
+
if (typeof o.prefix === "string" && o.prefix.trim()) s.prefix = o.prefix.trim();
|
|
209
|
+
// `prefix: [lane]` unquoted is a YAML list: take the one-element form as meant.
|
|
210
|
+
else if (Array.isArray(o.prefix) && o.prefix.length === 1 && typeof o.prefix[0] === "string") s.prefix = `[${o.prefix[0]}]`;
|
|
211
|
+
if (typeof o.channel === "string" && o.channel) s.channel = o.channel;
|
|
212
|
+
if (typeof o.as_bot === "boolean") s.as_bot = o.as_bot;
|
|
213
|
+
if (o.restrict_to_subscriptions === true) s.restrict_to_subscriptions = true;
|
|
214
|
+
if (Array.isArray(o.auto_subscribe)) s.auto_subscribe = o.auto_subscribe.filter((c): c is string => typeof c === "string");
|
|
215
|
+
if (typeof o.nudge_after_min === "number" && o.nudge_after_min > 0) s.nudge_after_min = o.nudge_after_min;
|
|
216
|
+
if (typeof o.owner === "string" && o.owner) s.owner = o.owner;
|
|
217
|
+
if (o.subscriptions && typeof o.subscriptions === "object") {
|
|
218
|
+
const subs: Record<string, Subscription> = {};
|
|
219
|
+
for (const [k, v] of Object.entries(o.subscriptions as Record<string, unknown>)) {
|
|
220
|
+
const sub = asSubscription(v);
|
|
221
|
+
if (sub && validAliasName(k)) subs[k] = sub;
|
|
222
|
+
}
|
|
223
|
+
s.subscriptions = subs;
|
|
224
|
+
}
|
|
225
|
+
return s;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** settings.yml with settings.local.yml over it (subscriptions merge by name). */
|
|
229
|
+
export function readSettings(scope: Scope): Settings {
|
|
230
|
+
const base = readSettingsFile(join(scope.dir, "settings.yml"));
|
|
231
|
+
const local = readSettingsFile(join(scope.dir, "settings.local.yml"));
|
|
232
|
+
// What only settings.yml may say.
|
|
233
|
+
delete local.restrict_to_subscriptions;
|
|
234
|
+
delete local.auto_subscribe;
|
|
235
|
+
delete local.owner;
|
|
236
|
+
if (base.restrict_to_subscriptions) {
|
|
237
|
+
// Who the lane posts as is the owner's call too: a local prefix `[shr]`
|
|
238
|
+
// would let any lane speak as another.
|
|
239
|
+
delete local.subscriptions;
|
|
240
|
+
delete local.prefix;
|
|
241
|
+
delete local.as_bot;
|
|
242
|
+
delete local.channel;
|
|
243
|
+
}
|
|
244
|
+
const subs = { ...base.subscriptions, ...local.subscriptions };
|
|
245
|
+
const out: Settings = { ...base, ...local };
|
|
246
|
+
if (Object.keys(subs).length) out.subscriptions = subs;
|
|
247
|
+
else delete out.subscriptions;
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- aliases ---
|
|
252
|
+
|
|
253
|
+
export type Alias = Subscription & { name: string; scope: Scope; file: "settings.yml" | "settings.local.yml" };
|
|
254
|
+
|
|
255
|
+
const ALIAS_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
256
|
+
|
|
257
|
+
/** Lowercase letters, digits, `.` `_` `-`; never a bare ts or a Slack id. */
|
|
258
|
+
export function validAliasName(name: string): boolean {
|
|
259
|
+
return ALIAS_RE.test(name) && !/^\d{10}\.\d{6}$/.test(name);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function listAliases(scope: Scope): Alias[] {
|
|
263
|
+
const baseFile = readSettingsFile(join(scope.dir, "settings.yml"));
|
|
264
|
+
const base = baseFile.subscriptions ?? {};
|
|
265
|
+
const local = baseFile.restrict_to_subscriptions ? {} : readSettingsFile(join(scope.dir, "settings.local.yml")).subscriptions ?? {};
|
|
266
|
+
const out: Alias[] = [];
|
|
267
|
+
for (const [name, s] of Object.entries(base)) if (!local[name]) out.push({ ...s, name, scope, file: "settings.yml" });
|
|
268
|
+
for (const [name, s] of Object.entries(local)) out.push({ ...s, name, scope, file: "settings.local.yml" });
|
|
269
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A bare alias name, in the nearest scope, then the global one. */
|
|
273
|
+
export function lookupAlias(name: string, cwd = process.cwd()): Alias | undefined {
|
|
274
|
+
if (!validAliasName(name)) return undefined;
|
|
275
|
+
const near = findScope(cwd);
|
|
276
|
+
const scopes = near.global ? [near] : [near, { dir: globalScopeDir(), global: true }];
|
|
277
|
+
for (const s of scopes) {
|
|
278
|
+
const a = listAliases(s).find((x) => x.name === name);
|
|
279
|
+
if (a) return a;
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const LOCAL_HEADER = "# Written by `slack alias set/unset`; wins over settings.yml. Safe to edit by hand.\n";
|
|
285
|
+
|
|
286
|
+
/** The comment block a settings file starts with: kept when the file is rewritten. */
|
|
287
|
+
function leadingComments(path: string): string {
|
|
288
|
+
try {
|
|
289
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
290
|
+
const head: string[] = [];
|
|
291
|
+
for (const l of lines) { if (l.startsWith("#") || (!l.trim() && head.length)) head.push(l); else break; }
|
|
292
|
+
return head.length ? head.join("\n").replace(/\n*$/, "\n") : "";
|
|
293
|
+
} catch {
|
|
294
|
+
return "";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Read-modify-write one settings file under its lock. `shared` = settings.yml
|
|
299
|
+
* (the allowlist owner's file: the relay or the coordinator, never the lane). */
|
|
300
|
+
function updateSettings(scope: Scope, shared: boolean, fn: (s: Record<string, unknown>) => void): void {
|
|
301
|
+
const path = join(scope.dir, shared ? "settings.yml" : "settings.local.yml");
|
|
302
|
+
withFileLock(path, () => {
|
|
303
|
+
const header = shared ? leadingComments(path) : LOCAL_HEADER;
|
|
304
|
+
let raw: Record<string, unknown> = {};
|
|
305
|
+
try {
|
|
306
|
+
const v = Bun.YAML.parse(readFileSync(path, "utf8"));
|
|
307
|
+
if (v && typeof v === "object" && !Array.isArray(v)) raw = v as Record<string, unknown>;
|
|
308
|
+
} catch (e) {
|
|
309
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw new Error(`${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
310
|
+
}
|
|
311
|
+
fn(raw);
|
|
312
|
+
atomicWrite(path, header + Bun.YAML.stringify(raw, null, 2) + "\n");
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Is this scope's allowlist locked to settings.yml? */
|
|
317
|
+
export function isRestricted(scope: Scope): boolean {
|
|
318
|
+
return readSettingsFile(join(scope.dir, "settings.yml")).restrict_to_subscriptions === true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** May the process at `cwd` write `scope`'s settings.yml? Always when the
|
|
322
|
+
* scope is not restricted; otherwise only from under its `owner` dir (and
|
|
323
|
+
* never when no owner is named). */
|
|
324
|
+
export function sharedWriteRefusal(scope: Scope, cwd = process.cwd()): string | undefined {
|
|
325
|
+
if (!isRestricted(scope)) return undefined;
|
|
326
|
+
const owner = readSettingsFile(join(scope.dir, "settings.yml")).owner;
|
|
327
|
+
if (!owner) return `${scope.dir} is restricted and names no \`owner\` in settings.yml: nobody may write it with --shared (edit the file)`;
|
|
328
|
+
const real = (p: string): string => { try { return realpathSync(resolve(p)); } catch { return resolve(p); } };
|
|
329
|
+
const o = real(owner.replace(/^~(?=\/|$)/, homedir()));
|
|
330
|
+
const c = real(cwd);
|
|
331
|
+
if (c === o || c.startsWith(o.endsWith("/") ? o : o + "/")) return undefined;
|
|
332
|
+
return `${scope.dir} is owned by ${owner}: --shared writes run only from under that directory`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export type SetAliasOpts = {
|
|
336
|
+
/** Write settings.yml instead of settings.local.yml. */
|
|
337
|
+
shared?: boolean;
|
|
338
|
+
/** The owner's own rule (auto_subscribe) writing: skips the owner/cwd check. */
|
|
339
|
+
asOwner?: boolean;
|
|
340
|
+
cwd?: string;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/** Add or replace an alias — in settings.local.yml, or with `shared` in
|
|
344
|
+
* settings.yml — and return the one it replaced. A restricted scope refuses
|
|
345
|
+
* the local write (its allowlist is not the lane's to extend), and a shared
|
|
346
|
+
* write from outside its owner dir. */
|
|
347
|
+
export function setAlias(scope: Scope, name: string, sub: Subscription, opts: SetAliasOpts | boolean = {}): Subscription | undefined {
|
|
348
|
+
const o: SetAliasOpts = typeof opts === "boolean" ? { shared: opts } : opts;
|
|
349
|
+
const shared = o.shared === true;
|
|
350
|
+
if (!validAliasName(name)) throw new Error(`invalid alias name "${name}" (a-z 0-9 . _ -, at most 64, not a bare ts)`);
|
|
351
|
+
if (!shared && isRestricted(scope)) throw new RestrictedError(scope);
|
|
352
|
+
if (shared && !o.asOwner) {
|
|
353
|
+
const why = sharedWriteRefusal(scope, o.cwd);
|
|
354
|
+
if (why) throw new RestrictedError(scope, why);
|
|
355
|
+
}
|
|
356
|
+
const before = listAliases(scope).find((a) => a.name === name);
|
|
357
|
+
updateSettings(scope, shared, (raw) => {
|
|
358
|
+
const subs = (raw.subscriptions && typeof raw.subscriptions === "object" ? raw.subscriptions : {}) as Record<string, unknown>;
|
|
359
|
+
subs[name] = { channel: sub.channel, ...(sub.thread_ts ? { thread_ts: sub.thread_ts } : {}), added: sub.added ?? new Date().toISOString(), ...(sub.note ? { note: sub.note } : {}) };
|
|
360
|
+
raw.subscriptions = subs;
|
|
361
|
+
});
|
|
362
|
+
return before;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Remove an alias from settings.local.yml. One defined in settings.yml is
|
|
366
|
+
* refused: that file belongs to whoever wrote it. */
|
|
367
|
+
export function unsetAlias(scope: Scope, name: string, shared = false, cwd?: string): "removed" | "absent" | "in-settings.yml" {
|
|
368
|
+
const a = listAliases(scope).find((x) => x.name === name);
|
|
369
|
+
if (!a) return "absent";
|
|
370
|
+
if (a.file === "settings.yml" && !shared) return "in-settings.yml";
|
|
371
|
+
if (a.file === "settings.yml") {
|
|
372
|
+
const why = sharedWriteRefusal(scope, cwd);
|
|
373
|
+
if (why) throw new RestrictedError(scope, why);
|
|
374
|
+
}
|
|
375
|
+
updateSettings(scope, a.file === "settings.yml", (raw) => {
|
|
376
|
+
const subs = raw.subscriptions as Record<string, unknown> | undefined;
|
|
377
|
+
if (subs) delete subs[name];
|
|
378
|
+
if (subs && !Object.keys(subs).length) delete raw.subscriptions;
|
|
379
|
+
});
|
|
380
|
+
return "removed";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export class RestrictedError extends Error {
|
|
384
|
+
constructor(scope: Scope, why?: string) {
|
|
385
|
+
super(why ?? `${scope.dir} is restricted (restrict_to_subscriptions in settings.yml): only its owner adds subscriptions, with \`slack alias set --shared\` run from the owner's directory`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// --- the registry: every .slack-term/ on this machine, for the relay fan-out ---
|
|
390
|
+
|
|
391
|
+
function registryPath(): string {
|
|
392
|
+
return join(globalScopeDir(), "scopes");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Remember `dir` (a .slack-term/ path) so a stream can deliver into it. */
|
|
396
|
+
export function registerScope(dir: string): void {
|
|
397
|
+
const p = registryPath();
|
|
398
|
+
withFileLock(p, () => {
|
|
399
|
+
const have = registeredDirs();
|
|
400
|
+
if (have.includes(dir)) return;
|
|
401
|
+
atomicWrite(p, [...have, dir].join("\n") + "\n");
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function registeredDirs(): string[] {
|
|
406
|
+
try {
|
|
407
|
+
return readFileSync(registryPath(), "utf8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
|
|
408
|
+
} catch {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Registered scopes that still exist, plus the global one. */
|
|
414
|
+
export function registeredScopes(): Scope[] {
|
|
415
|
+
const out: Scope[] = [];
|
|
416
|
+
for (const d of registeredDirs()) {
|
|
417
|
+
try { if (statSync(d).isDirectory()) out.push({ dir: d, global: false }); } catch { /* moved or removed */ }
|
|
418
|
+
}
|
|
419
|
+
const g = globalScopeDir();
|
|
420
|
+
// The global dir always exists once anything registered; it is a scope only
|
|
421
|
+
// when it has settings of its own.
|
|
422
|
+
const hasSettings = existsSync(join(g, "settings.yml")) || existsSync(join(g, "settings.local.yml"));
|
|
423
|
+
if (hasSettings && !out.some((s) => s.dir === g)) out.push({ dir: g, global: true });
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Which scopes subscribe a message: a thread subscription matches its thread
|
|
428
|
+
* (the message's thread_ts, or its own ts for a root); a channel-only one
|
|
429
|
+
* matches everything in the channel. */
|
|
430
|
+
export function whoSubscribes(channel: string, threadOrTs: string, scopes = registeredScopes()): Alias[] {
|
|
431
|
+
const out: Alias[] = [];
|
|
432
|
+
for (const s of scopes) {
|
|
433
|
+
for (const a of listAliases(s)) {
|
|
434
|
+
if (a.channel === channel && (a.thread_ts === undefined || a.thread_ts === threadOrTs)) out.push(a);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// --- reply text ---
|
|
441
|
+
|
|
442
|
+
/** Put `prefix` in front of `text`, never twice: a caller that already wrote
|
|
443
|
+
* `[lane] ` (or `[lane@machine] `) keeps one (owner request 11-02:
|
|
444
|
+
* "[shr] [shr]"). Only the exact lane is stripped — `[lanex]` or another
|
|
445
|
+
* lane's tag is the caller's text. Returns "" when nothing is left. */
|
|
446
|
+
export function applyPrefix(text: string, prefix: string | undefined): string {
|
|
447
|
+
const p = prefix?.trim();
|
|
448
|
+
if (!p) return text;
|
|
449
|
+
const m = /^\[([^\]@\s]+)\]$/.exec(p);
|
|
450
|
+
let body = text;
|
|
451
|
+
if (m) {
|
|
452
|
+
const lane = m[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
453
|
+
body = text.replace(new RegExp(`^\\s*\\[${lane}(?:@[a-z0-9-]{1,16})?\\][ \\t]*`), "");
|
|
454
|
+
} else if (text.trimStart().startsWith(p)) {
|
|
455
|
+
body = text.trimStart().slice(p.length).replace(/^[ \t]+/, "");
|
|
456
|
+
}
|
|
457
|
+
return body.trim() ? `${p} ${body}` : "";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Where a message sits: its channel and its thread root (its own ts at top level). */
|
|
461
|
+
export type MessagePlace = { channel: string; ts: string; root: string };
|
|
462
|
+
|
|
463
|
+
/** Why an explicit destination is not where the answered message lives (the
|
|
464
|
+
* 11-02 "replied in the wrong thread"), or undefined when it is. */
|
|
465
|
+
export function threadMismatch(dest: { channel: string; threadTs?: string }, answered: MessagePlace): string | undefined {
|
|
466
|
+
if (dest.channel === answered.channel && dest.threadTs === answered.root) return undefined;
|
|
467
|
+
const where = (c: string, t?: string): string => (t ? `${c} thread ${t}` : `${c} (top level)`);
|
|
468
|
+
return `the message you are answering (${answered.ts}) is in ${where(answered.channel, answered.root)}, ` +
|
|
469
|
+
`but the destination is ${where(dest.channel, dest.threadTs)}.\n` +
|
|
470
|
+
` Drop the destination to reply where the message is: slack reply --to <permalink> '…'\n` +
|
|
471
|
+
` Or pass --any-thread if the other thread is really meant.`;
|
|
472
|
+
}
|