slack-term 1.48.0 → 1.49.1
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 +56 -0
- package/dist/cli.js +289 -249
- package/locales/en.json +32 -0
- package/locales/ja.json +31 -0
- package/package.json +2 -1
- package/ts/ask.ts +47 -46
- package/ts/botIntent.ts +1 -1
- package/ts/choiceHint.ts +30 -0
- package/ts/cli.ts +739 -123
- 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/scope.ts +472 -0
- package/ts/slack.ts +40 -2
- package/ts/stream.ts +3 -0
- package/ts/tick.ts +47 -0
- package/ts/vendor/gitleaks.LICENSE +21 -0
- package/ts/vendor/gitleaks.toml +3209 -0
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
|
+
}
|
package/ts/slack.ts
CHANGED
|
@@ -41,7 +41,41 @@ function base(): string {
|
|
|
41
41
|
return (process.env.SLACK_API_BASE ?? "https://slack.com/api").replace(/\/$/, "");
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** How long one call may spend waiting out 429s before it throws
|
|
45
|
+
* RateLimitError (seconds, summed over its retries). A 429 means Slack
|
|
46
|
+
* rejected the request unprocessed, so retrying it — even a write — cannot
|
|
47
|
+
* duplicate anything. One-shot commands wait (`pinlog`, `read`, `send`…);
|
|
48
|
+
* `slack stream` sets 0 because it schedules around Retry-After itself.
|
|
49
|
+
* SLACK_RATELIMIT_WAIT_SEC overrides the default. */
|
|
50
|
+
let rateLimitWaitSec = ((): number => {
|
|
51
|
+
const v = Number(process.env.SLACK_RATELIMIT_WAIT_SEC);
|
|
52
|
+
return Number.isFinite(v) && v >= 0 ? v : 120;
|
|
53
|
+
})();
|
|
54
|
+
export function setRateLimitWait(sec: number): void {
|
|
55
|
+
rateLimitWaitSec = sec;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Run `once`; while it throws RateLimitError and the budget allows, wait out
|
|
59
|
+
* its Retry-After (one stderr line each, method and seconds only) and retry. */
|
|
60
|
+
export async function withRateLimitWait<T>(once: () => Promise<T>, sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))): Promise<T> {
|
|
61
|
+
let waited = 0;
|
|
62
|
+
for (;;) {
|
|
63
|
+
try {
|
|
64
|
+
return await once();
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (!(e instanceof RateLimitError) || waited + e.retryAfter > rateLimitWaitSec) throw e;
|
|
67
|
+
waited += e.retryAfter;
|
|
68
|
+
console.error(`slack: ${e.message} — waiting`);
|
|
69
|
+
await sleep(e.retryAfter * 1000);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function call(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
|
|
75
|
+
return withRateLimitWait(() => callOnce(token, method, init, cookie));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function callOnce(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
|
|
45
79
|
cookie = token.startsWith("xoxc-") ? (cookie ?? resolveCookie()) : undefined;
|
|
46
80
|
const extraHeaders: Record<string, string> = {};
|
|
47
81
|
if (cookie) extraHeaders["Cookie"] = `d=${cookie}`;
|
|
@@ -95,7 +129,11 @@ async function call(token: string, method: string, init: RequestInit, cookie?: s
|
|
|
95
129
|
// Internal Slack API caller — does NOT reject xoxc tokens.
|
|
96
130
|
// Used for hidden endpoints (drafts, etc.) only accessible via session tokens.
|
|
97
131
|
// cookie: raw xoxd value (without "d=" prefix); injected as Cookie header when present.
|
|
98
|
-
|
|
132
|
+
function callSession(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
|
|
133
|
+
return withRateLimitWait(() => callSessionOnce(token, method, init, cookie));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function callSessionOnce(token: string, method: string, init: RequestInit, cookie?: string): Promise<Json> {
|
|
99
137
|
const extraHeaders: Record<string, string> = {};
|
|
100
138
|
if (cookie) extraHeaders["Cookie"] = `d=${cookie}`;
|
|
101
139
|
const res = await fetch(`${base()}/${method}`, {
|
package/ts/stream.ts
CHANGED
|
@@ -136,6 +136,8 @@ export type StreamOpts = {
|
|
|
136
136
|
selfUsers: Set<string>;
|
|
137
137
|
selfBots: Set<string>;
|
|
138
138
|
json: boolean;
|
|
139
|
+
/** Also called with every emitted match and its attachment count (`slack stream --inbox`). */
|
|
140
|
+
onMatch?: (rec: StreamMatch, files: number) => void;
|
|
139
141
|
once: boolean;
|
|
140
142
|
/** Seconds back to (re)start every channel from, overriding the saved cursor. */
|
|
141
143
|
sinceSec?: number;
|
|
@@ -371,6 +373,7 @@ async function consider(
|
|
|
371
373
|
match,
|
|
372
374
|
};
|
|
373
375
|
ctx.opts.json ? _internals.out(JSON.stringify(rec)) : _internals.out(humanLine(rec));
|
|
376
|
+
ctx.opts.onMatch?.(rec, Array.isArray(m.files) ? m.files.length : 0);
|
|
374
377
|
ctx.matches++;
|
|
375
378
|
return true;
|
|
376
379
|
}
|
package/ts/tick.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// `slack tick`: turn a "⬜ <n>." item of a posted checklist into "✅ <n>.",
|
|
2
|
+
// changing nothing else (10-30: when an announced ⬜ criterion is done,
|
|
3
|
+
// edit the original to ✅ rather than only posting a new "done" reply).
|
|
4
|
+
// Slack may store the box as the shortcode, so both spellings are handled and
|
|
5
|
+
// the tick keeps the spelling it found.
|
|
6
|
+
|
|
7
|
+
const BOX = /(⬜️?|:white_large_square:)(\s*)(\d+)\./g;
|
|
8
|
+
const DONE = /(✅|:white_check_mark:)\s*(\d+)\./g;
|
|
9
|
+
|
|
10
|
+
export type TickResult =
|
|
11
|
+
| { kind: "ticked"; text: string; n: number }
|
|
12
|
+
| { kind: "already"; n: number }
|
|
13
|
+
| { kind: "none" }
|
|
14
|
+
| { kind: "ambiguous"; open: number[] }
|
|
15
|
+
| { kind: "missing"; n: number };
|
|
16
|
+
|
|
17
|
+
/** Tick item `n`, or — without `n` — the one open item there is. */
|
|
18
|
+
export function tickText(text: string, n?: number): TickResult {
|
|
19
|
+
const open = [...text.matchAll(BOX)].map((m) => Number(m[3]));
|
|
20
|
+
const done = [...text.matchAll(DONE)].map((m) => Number(m[2]));
|
|
21
|
+
if (n === undefined) {
|
|
22
|
+
if (open.length === 0) return done.length ? { kind: "already", n: done[0]! } : { kind: "none" };
|
|
23
|
+
if (open.length > 1) return { kind: "ambiguous", open };
|
|
24
|
+
n = open[0]!;
|
|
25
|
+
}
|
|
26
|
+
if (!open.includes(n)) return done.includes(n) ? { kind: "already", n } : { kind: "missing", n };
|
|
27
|
+
let hit = false;
|
|
28
|
+
const out = text.replace(BOX, (all, box: string, sp: string, num: string) => {
|
|
29
|
+
if (hit || Number(num) !== n) return all;
|
|
30
|
+
hit = true;
|
|
31
|
+
return `${box.startsWith(":") ? ":white_check_mark:" : "✅"}${sp}${num}.`;
|
|
32
|
+
});
|
|
33
|
+
return { kind: "ticked", text: out, n };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An edit keeps the lane tag the message was posted with (`[lane] ` or
|
|
37
|
+
* `[lane@host] `), so a rewrite cannot drop or change who said it. Only when
|
|
38
|
+
* `lanePrefix` (the scope's prefix) is the tag the original starts with. */
|
|
39
|
+
export function keepLanePrefix(original: string, next: string, lanePrefix: string | undefined): string {
|
|
40
|
+
const m = /^\[([^\]@\s]+)\]$/.exec(lanePrefix?.trim() ?? "");
|
|
41
|
+
if (!m) return next;
|
|
42
|
+
const lane = m[1]!.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
43
|
+
const tag = new RegExp(`^\\[${lane}(?:@[a-z0-9-]{1,16})?\\][ \\t]*`);
|
|
44
|
+
const had = tag.exec(original);
|
|
45
|
+
if (!had) return next;
|
|
46
|
+
return had[0] + next.replace(tag, "");
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 Zachary Rice
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|