slack-term 1.51.2 → 1.53.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 +10 -0
- package/dist/cli.js +241 -239
- package/package.json +1 -1
- package/ts/askPush.ts +83 -0
- package/ts/botIntent.ts +5 -4
- package/ts/cli.ts +172 -15
- package/ts/sentlog.ts +16 -2
- package/ts/urlGuard.ts +43 -6
package/package.json
CHANGED
package/ts/askPush.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `slack ask --pending --push`: hand an answered question to the agent that
|
|
2
|
+
// asked it, instead of waiting for that agent to poll.
|
|
3
|
+
//
|
|
4
|
+
// Why: a pill pressed while the asker had moved on was never read back
|
|
5
|
+
// (2026-09-27: 13 of 71 asks). `--pending` lists them, but only to whoever
|
|
6
|
+
// runs it; a wrapper outside slack-term pushed them over `ay`, and that now
|
|
7
|
+
// lives here instead. The sent-log already holds the asking
|
|
8
|
+
// agent's pid, so slack-term can tell it directly.
|
|
9
|
+
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
|
10
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { dirname } from "node:path";
|
|
12
|
+
import { pidAlive } from "./sentlog.ts";
|
|
13
|
+
|
|
14
|
+
/** ay rejects messages over 1024 chars. */
|
|
15
|
+
export const PUSH_MAX = 1000;
|
|
16
|
+
|
|
17
|
+
function clip(s: string, n: number): string {
|
|
18
|
+
const one = s.split(/\s+/).filter(Boolean).join(" ");
|
|
19
|
+
if (n <= 1) return "";
|
|
20
|
+
return one.length <= n ? one : `${one.slice(0, n - 1)}…`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The one-line message the asker receives: link, answer, how, question. */
|
|
24
|
+
export function askPushMessage(p: { link: string; answer: string; how: string; question: string; freeText: boolean; collected?: boolean }): string {
|
|
25
|
+
const head = `[ask-answer] ${p.link} →`;
|
|
26
|
+
const ans = clip(p.answer, 300);
|
|
27
|
+
let tail = p.how ? ` (${p.how})` : "";
|
|
28
|
+
if (p.freeText) tail += " — a free-text reply, not a choice: the question stays open";
|
|
29
|
+
else if (p.collected) tail += " — collected ✅";
|
|
30
|
+
const q = clip(p.question, PUSH_MAX - head.length - ans.length - tail.length - 8);
|
|
31
|
+
return `${head} ${ans}${tail}. Q: ${q}`.slice(0, PUSH_MAX);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Run = (cmd: string, args: string[]) => Pick<SpawnSyncReturns<string>, "status" | "stdout" | "stderr" | "error">;
|
|
35
|
+
const runSync: Run = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8", timeout: 60_000 });
|
|
36
|
+
|
|
37
|
+
/** Parent pid, or 0. */
|
|
38
|
+
export function parentPid(pid: number): number {
|
|
39
|
+
try {
|
|
40
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
41
|
+
return Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]) || 0;
|
|
42
|
+
} catch {
|
|
43
|
+
const r = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", timeout: 2000 });
|
|
44
|
+
return Number((r.stdout ?? "").trim()) || 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Send `msg` to the agent at `pid` with `ay send --force`. The sent-log keeps
|
|
49
|
+
* the agent's own pid, while ay may know the wrapper above it, so up to three
|
|
50
|
+
* parents are tried too. Returns the pid that took it, or undefined. QUEUED
|
|
51
|
+
* and NOT SUBMITTED count as delivered: both reach the agent, and treating
|
|
52
|
+
* them as failures re-sent the same answer on every pass. */
|
|
53
|
+
export function notifyAgent(pid: number, msg: string, run: Run = runSync, parent = parentPid): number | undefined {
|
|
54
|
+
for (let i = 0; i < 4 && pid > 1; i++, pid = parent(pid)) {
|
|
55
|
+
const r = run(process.env.SLACK_TERM_AY || "ay", ["send", String(pid), "--force", msg]);
|
|
56
|
+
if (r.error) return undefined; // no ay on this machine
|
|
57
|
+
const out = `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
|
58
|
+
if (/no agent matched/i.test(out)) continue;
|
|
59
|
+
if (r.status === 0 || /QUEUED|sent to pid|NOT SUBMITTED/.test(out)) return pid;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One push pass at a time per machine: a watcher and a one-off run must not
|
|
65
|
+
* both push the same answer. Returns a release function, or undefined when a
|
|
66
|
+
* live pass holds the lock (a dead holder's lock is taken over). */
|
|
67
|
+
export function acquirePushLock(path: string): (() => void) | undefined {
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const fd = openSync(path, "wx");
|
|
72
|
+
writeFileSync(fd, String(process.pid));
|
|
73
|
+
closeSync(fd);
|
|
74
|
+
return () => { try { unlinkSync(path); } catch { /* gone */ } };
|
|
75
|
+
} catch {
|
|
76
|
+
let holder = 0;
|
|
77
|
+
try { holder = Number(readFileSync(path, "utf8").trim()); } catch { /* vanished */ }
|
|
78
|
+
if (holder && holder !== process.pid && pidAlive(holder)) return undefined;
|
|
79
|
+
try { unlinkSync(path); } catch { /* raced */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
package/ts/botIntent.ts
CHANGED
|
@@ -30,11 +30,12 @@ export function isWriteCommand(path: (string | number)[]): boolean {
|
|
|
30
30
|
return WRITES.has(a) || WRITES.has(`${a} ${b}`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** Read-only modes of a write command. `ask --ls`
|
|
34
|
-
* sent-log and the questions' live state. (`ask --waitFor
|
|
35
|
-
* `poll --results` are NOT here:
|
|
33
|
+
/** Read-only modes of a write command. `ask --ls` and `ask --pending` only
|
|
34
|
+
* read the local sent-log and the questions' live state. (`ask --waitFor`,
|
|
35
|
+
* `ask --pending --push` and `poll --results` are NOT here: each can edit the
|
|
36
|
+
* posted message.) */
|
|
36
37
|
export function isReadMode(path: (string | number)[], argv: Record<string, unknown>): boolean {
|
|
37
|
-
return String(path[0] ?? "") === "ask" && argv.ls === true;
|
|
38
|
+
return String(path[0] ?? "") === "ask" && (argv.ls === true || (argv.pending === true && argv.push !== true));
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export type BotIntent = {
|
package/ts/cli.ts
CHANGED
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { existsSync, readFileSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
6
7
|
import { homedir } from "node:os";
|
|
7
8
|
import { dirname, join, resolve } from "node:path";
|
|
8
9
|
|
|
9
10
|
import yargs, { type Options } from "yargs";
|
|
10
11
|
import { hideBin } from "yargs/helpers";
|
|
11
|
-
import { guardUrlBoundaries } from "./urlGuard.ts";
|
|
12
|
+
import { fixUrlBoundaries, guardUrlBoundaries } from "./urlGuard.ts";
|
|
12
13
|
import { listProfiles, removeProfile, resolveBotToken, resolveCookie, resolveToken, useProfile, type Profile } from "./profiles.ts";
|
|
13
14
|
import { diagnoseBotMessaging, formatDiagnosis } from "./botdoctor.ts";
|
|
14
15
|
import { cmdAuthLogin, cmdAuthLoginChrome, cmdAuthChrome, cmdAuthFirefox, cmdAuthToken, cmdAuthApp, cmdAuthSave, cmdAuthTokens } from "./auth.ts";
|
|
15
16
|
import { cmdTail, parseSince as parseDuration } from "./tail.ts";
|
|
16
|
-
import { runStream, webClient, type StreamMatch } from "./stream.ts";
|
|
17
|
+
import { phiDelay, runStream, webClient, type StreamMatch } from "./stream.ts";
|
|
17
18
|
import { relaySubscriber, relayTransport } from "./relay.ts";
|
|
19
|
+
import { acquirePushLock, askPushMessage, notifyAgent } from "./askPush.ts";
|
|
18
20
|
import { DEFAULT_DIR, initRelay, initReport, relayHealth } from "./relayInit.ts";
|
|
19
21
|
import { agentCommands } from "./agent.ts";
|
|
20
22
|
|
|
@@ -70,7 +72,7 @@ import {
|
|
|
70
72
|
} from "./poll.ts";
|
|
71
73
|
import { seedReactionsInOrder } from "./reactionSeed.ts";
|
|
72
74
|
import { buildListSchema, cellValue, completedColumn, findColumn, parseColumnSpec, parseListSchema, parseSets, type ListColumn } from "./lists.ts";
|
|
73
|
-
import { attributionEnabled, attributionMetadata, captureAttribution, markAskCollected, markAskDelivered, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, senderAlive, uncollectedAsks, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
|
|
75
|
+
import { attributionEnabled, attributionMetadata, captureAttribution, markAskCollected, markAskDelivered, markAskPushed, parseSince, querySent, recordSent, SENT_LOG_UNAVAILABLE, sentDbPath, senderAlive, uncollectedAsks, type RecordSent, type SentKind, type SentRow } from "./sentlog.ts";
|
|
74
76
|
import {
|
|
75
77
|
authTest,
|
|
76
78
|
authScopes,
|
|
@@ -4247,6 +4249,10 @@ interface AskPendingItem {
|
|
|
4247
4249
|
alive: boolean;
|
|
4248
4250
|
/** The ready-to-run `--waitFor` line, cursor included. */
|
|
4249
4251
|
collect: string;
|
|
4252
|
+
/** Where a delivery of this free-text reply ends (`--after` for the next look). */
|
|
4253
|
+
cursor?: string;
|
|
4254
|
+
/** Set by `--push`: the pid that took the answer, and whether it was collected. */
|
|
4255
|
+
pushed?: { pid: number; collected: boolean };
|
|
4250
4256
|
}
|
|
4251
4257
|
|
|
4252
4258
|
/** `slack ask --pending [--since 7d] [--json]` — questions THIS machine asked
|
|
@@ -4265,16 +4271,24 @@ interface AskPendingItem {
|
|
|
4265
4271
|
* a person) runs the collect line. */
|
|
4266
4272
|
async function cmdAskPending(
|
|
4267
4273
|
tokens: { user?: string; bot?: string; cookie?: string },
|
|
4268
|
-
opts: {
|
|
4274
|
+
opts: {
|
|
4275
|
+
sinceMs: number; json: boolean; channel?: string; push?: boolean; selfArgs?: string[];
|
|
4276
|
+
/** `--watch`: only these questions (`channel:ts`), and print only what was pushed. */
|
|
4277
|
+
only?: Set<string>; watch?: boolean;
|
|
4278
|
+
},
|
|
4269
4279
|
): Promise<void> {
|
|
4280
|
+
// Never blocks: one look per question and no waiting out 429s. A throttled
|
|
4281
|
+
// pass says how many it skipped; the next pass picks them up.
|
|
4282
|
+
setRateLimitWait(0);
|
|
4270
4283
|
const all = uncollectedAsks(opts.sinceMs);
|
|
4271
4284
|
if (!all) {
|
|
4272
4285
|
console.error(`Error: ${SENT_LOG_UNAVAILABLE}\n (${sentDbPath()})`);
|
|
4273
4286
|
process.exit(ASK_EXIT_ERROR);
|
|
4274
4287
|
}
|
|
4275
|
-
const rows =
|
|
4288
|
+
const rows = all.filter((r) =>
|
|
4289
|
+
(!opts.channel || r.channel === opts.channel || r.target === opts.channel) && (!opts.only || opts.only.has(`${r.channel}:${r.ts}`)));
|
|
4276
4290
|
const pending: AskPendingItem[] = [];
|
|
4277
|
-
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0 };
|
|
4291
|
+
const count = { unanswered: 0, ambiguous: 0, resolved: 0, void: 0, explain: 0, throttled: 0 };
|
|
4278
4292
|
/** Open questions someone pressed ℹ️ on — reported only: the thread note is
|
|
4279
4293
|
* the waiting session's to post (`--pending` writes nothing). */
|
|
4280
4294
|
const explainAsked: string[] = [];
|
|
@@ -4287,7 +4301,7 @@ async function cmdAskPending(
|
|
|
4287
4301
|
: tokens.bot ? (await selfIdentity(tokens.bot))?.team ?? "" : "";
|
|
4288
4302
|
const otherTeams = new Map<string, number>();
|
|
4289
4303
|
|
|
4290
|
-
for (const row of rows) {
|
|
4304
|
+
for (const [i, row] of rows.entries()) {
|
|
4291
4305
|
if (row.team && currentTeam && row.team !== currentTeam) {
|
|
4292
4306
|
otherTeams.set(row.team, (otherTeams.get(row.team) ?? 0) + 1);
|
|
4293
4307
|
continue;
|
|
@@ -4330,19 +4344,30 @@ async function cmdAskPending(
|
|
|
4330
4344
|
if (found) {
|
|
4331
4345
|
pending.push({ ...base, answer: found.answer, how: found.how, freeText: false });
|
|
4332
4346
|
} else if (unchosen && !unchosen.ambiguous && unchosen.text !== row.delivered_answer) {
|
|
4333
|
-
pending.push({ ...base, answer: unchosen.text, how: "reply (not a choice)", freeText: true });
|
|
4347
|
+
pending.push({ ...base, answer: unchosen.text, how: "reply (not a choice)", freeText: true, cursor: askNextCursor(row.ts, after, unchosen.ts) });
|
|
4334
4348
|
} else if (unchosen?.ambiguous) {
|
|
4335
4349
|
count.ambiguous++;
|
|
4336
4350
|
} else {
|
|
4337
4351
|
count.unanswered++;
|
|
4338
4352
|
}
|
|
4339
4353
|
} catch (e: unknown) {
|
|
4354
|
+
if (e instanceof RateLimitError) {
|
|
4355
|
+
// Probing on would only collect more 429s for the same method.
|
|
4356
|
+
count.throttled = rows.length - i;
|
|
4357
|
+
problems.push(`rate-limited by Slack (${e.message}): ${count.throttled} question(s) not checked this pass`);
|
|
4358
|
+
break;
|
|
4359
|
+
}
|
|
4340
4360
|
problems.push(`${link}: ${e instanceof Error ? e.message : String(e)}`);
|
|
4341
4361
|
}
|
|
4342
4362
|
}
|
|
4343
4363
|
|
|
4364
|
+
if (opts.push) askPushAll(pending, opts.selfArgs ?? [], problems);
|
|
4365
|
+
|
|
4366
|
+
// A watcher's log carries what it did, not the whole list on every pass.
|
|
4367
|
+
const shown = opts.watch ? pending.filter((it) => it.pushed) : pending;
|
|
4368
|
+
if (opts.watch && !shown.length && !problems.length) return;
|
|
4344
4369
|
if (opts.json) {
|
|
4345
|
-
for (const it of
|
|
4370
|
+
for (const it of shown) {
|
|
4346
4371
|
const r = it.row;
|
|
4347
4372
|
console.log(JSON.stringify({
|
|
4348
4373
|
id: r.id, link: it.link, channel: r.channel, ts: r.ts, asked_at: r.sent_at,
|
|
@@ -4350,12 +4375,13 @@ async function cmdAskPending(
|
|
|
4350
4375
|
sender: { session_id: r.session_id ?? null, cli: r.cli ?? null, agent_pid: r.agent_pid ?? null, pid: r.pid, alive: it.alive, cwd: r.cwd, git_branch: r.git_branch ?? null, host: r.host ?? null },
|
|
4351
4376
|
delivered_at: r.delivered_at ?? null,
|
|
4352
4377
|
collect: it.collect,
|
|
4378
|
+
...(opts.push ? { pushed: it.pushed ?? null } : {}),
|
|
4353
4379
|
}));
|
|
4354
4380
|
}
|
|
4355
4381
|
} else {
|
|
4356
4382
|
// Grouped by the session that asked — the unit that has to be told.
|
|
4357
4383
|
const groups = new Map<string, AskPendingItem[]>();
|
|
4358
|
-
for (const it of
|
|
4384
|
+
for (const it of shown) {
|
|
4359
4385
|
const key = it.row.session_id ? `session ${it.row.session_id}` : `pid ${it.row.agent_pid ?? it.row.pid}`;
|
|
4360
4386
|
groups.set(key, [...(groups.get(key) ?? []), it]);
|
|
4361
4387
|
}
|
|
@@ -4370,6 +4396,7 @@ async function cmdAskPending(
|
|
|
4370
4396
|
console.log(` Q: ${stripTerminalControls(askFlatten(it.question))}`);
|
|
4371
4397
|
console.log(` A: ${stripTerminalControls(askFlatten(it.answer))} — ${stripTerminalControls(it.how)}${it.freeText ? " — not a decision" : ""}`);
|
|
4372
4398
|
console.log(` collect: ${stripTerminalControls(it.collect)}`);
|
|
4399
|
+
if (it.pushed) console.log(` pushed to pid ${it.pushed.pid}${it.pushed.collected ? ", collected ✅" : ""}`);
|
|
4373
4400
|
}
|
|
4374
4401
|
}
|
|
4375
4402
|
}
|
|
@@ -4384,10 +4411,113 @@ async function cmdAskPending(
|
|
|
4384
4411
|
(count.resolved ? `, ${count.resolved} ✅ already collected` : "") +
|
|
4385
4412
|
(count.void ? `, ${count.void} voided` : "") +
|
|
4386
4413
|
(count.explain ? `, ${count.explain} with an ℹ️ explain request` : "") +
|
|
4414
|
+
(count.throttled ? `, ${count.throttled} not checked (rate-limited)` : "") +
|
|
4415
|
+
(opts.push ? `, ${pending.filter((it) => it.pushed).length} pushed to the asker` : "") +
|
|
4387
4416
|
` — checked ${rows.length} uncollected question(s) (log: ${sentDbPath()})`,
|
|
4388
4417
|
);
|
|
4389
4418
|
}
|
|
4390
4419
|
|
|
4420
|
+
/** `ask --pending --push --watch`: a push pass every `intervalMs`, and — when
|
|
4421
|
+
* SLACK_RELAY_URL / SLACK_RELAY_TOKEN are set — one for a single question as
|
|
4422
|
+
* soon as the relay rings for it (a reaction on it, or a reply in its
|
|
4423
|
+
* thread), so a pressed pill reaches the asker in seconds. The relay only
|
|
4424
|
+
* sees the relay app's channels; the interval covers the rest. */
|
|
4425
|
+
async function askPushWatch(run: (only?: Set<string>) => Promise<void>, sinceMs: () => number, intervalMs: number): Promise<never> {
|
|
4426
|
+
let busy = false;
|
|
4427
|
+
const queued = new Set<string>();
|
|
4428
|
+
let full = true;
|
|
4429
|
+
const kick = async (): Promise<void> => {
|
|
4430
|
+
if (busy) return;
|
|
4431
|
+
busy = true;
|
|
4432
|
+
try {
|
|
4433
|
+
while (full || queued.size) {
|
|
4434
|
+
const only = full ? undefined : new Set(queued);
|
|
4435
|
+
full = false;
|
|
4436
|
+
queued.clear();
|
|
4437
|
+
try {
|
|
4438
|
+
await run(only);
|
|
4439
|
+
} catch (e: unknown) {
|
|
4440
|
+
console.error(`slack ask --watch: pass failed: ${friendlySlackError(e)}`);
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
} finally {
|
|
4444
|
+
busy = false;
|
|
4445
|
+
}
|
|
4446
|
+
};
|
|
4447
|
+
const relayUrl = process.env.SLACK_RELAY_URL;
|
|
4448
|
+
const relayToken = process.env.SLACK_RELAY_TOKEN;
|
|
4449
|
+
if (relayUrl && relayToken) {
|
|
4450
|
+
const subscribe = relaySubscriber(relayUrl, relayToken, undefined, { transport: relayTransport(process.env.SLACK_RELAY_TRANSPORT) });
|
|
4451
|
+
void (async () => {
|
|
4452
|
+
for (let attempt = 0; ; attempt++) {
|
|
4453
|
+
try {
|
|
4454
|
+
await subscribe(undefined, new AbortController().signal, {
|
|
4455
|
+
hello: () => { attempt = 0; },
|
|
4456
|
+
bell: (_seq, b) => {
|
|
4457
|
+
const key = `${b.channel}:${b.thread_ts ?? b.ts}`;
|
|
4458
|
+
const open = uncollectedAsks(sinceMs()) ?? [];
|
|
4459
|
+
if (!open.some((r) => `${r.channel}:${r.ts}` === key)) return;
|
|
4460
|
+
queued.add(key);
|
|
4461
|
+
// A few seconds' grace: a pill press often comes with a reply.
|
|
4462
|
+
setTimeout(() => { void kick(); }, 3000);
|
|
4463
|
+
},
|
|
4464
|
+
});
|
|
4465
|
+
} catch (e: unknown) {
|
|
4466
|
+
console.error(`slack ask --watch: relay: ${e instanceof Error ? e.message : String(e)} — reconnecting`);
|
|
4467
|
+
}
|
|
4468
|
+
await new Promise((r) => setTimeout(r, phiDelay(attempt, 2000, 300_000)));
|
|
4469
|
+
}
|
|
4470
|
+
})();
|
|
4471
|
+
} else {
|
|
4472
|
+
console.error("slack ask --watch: no SLACK_RELAY_URL / SLACK_RELAY_TOKEN — checking every interval only");
|
|
4473
|
+
}
|
|
4474
|
+
for (;;) {
|
|
4475
|
+
full = true;
|
|
4476
|
+
await kick();
|
|
4477
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
4478
|
+
}
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
/** `--push`: tell each alive asker its answer over ay, then collect a decision
|
|
4482
|
+
* (✅ in Slack, collected in the log) with the asker's own `--waitFor` line —
|
|
4483
|
+
* the same code path, so the stamp and the bookkeeping cannot drift. A
|
|
4484
|
+
* free-text reply is marked delivered instead: it is not a decision, so the
|
|
4485
|
+
* question stays open, but that reply is not pushed again. An asker that is
|
|
4486
|
+
* gone gets nothing: the answer stays on the list for a person. */
|
|
4487
|
+
function askPushAll(items: AskPendingItem[], selfArgs: string[], problems: string[]): void {
|
|
4488
|
+
const release = acquirePushLock(join(process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"), "slack-term", "ask-push.lock"));
|
|
4489
|
+
if (!release) {
|
|
4490
|
+
problems.push("another `ask --pending --push` pass is running: nothing pushed by this one");
|
|
4491
|
+
return;
|
|
4492
|
+
}
|
|
4493
|
+
try {
|
|
4494
|
+
for (const it of items) {
|
|
4495
|
+
const r = it.row;
|
|
4496
|
+
if (!it.alive || r.pushed_answer === it.answer) continue;
|
|
4497
|
+
const msg = askPushMessage({ link: it.link, answer: it.answer, how: it.how, question: it.question, freeText: it.freeText, collected: !it.freeText });
|
|
4498
|
+
const pid = notifyAgent(r.agent_pid ?? r.pid, msg);
|
|
4499
|
+
if (pid === undefined) {
|
|
4500
|
+
problems.push(`${it.link}: the asker (pid ${r.agent_pid ?? r.pid}) did not take the push over ay — tried again next pass`);
|
|
4501
|
+
continue;
|
|
4502
|
+
}
|
|
4503
|
+
markAskPushed(r.channel, r.ts, it.answer);
|
|
4504
|
+
let collected = false;
|
|
4505
|
+
if (it.freeText) {
|
|
4506
|
+
markAskDelivered(r.channel, r.ts, it.answer, it.cursor ?? "");
|
|
4507
|
+
} else {
|
|
4508
|
+
const after = r.delivered_cursor ? [`--after=${r.delivered_cursor}`] : [];
|
|
4509
|
+
const c = spawnSync(process.execPath, [...process.argv.slice(1, 2), ...selfArgs, "ask", `--waitFor=${it.link}`, "--timeout", "0", ...(r.as_bot ? ["--as-bot"] : []), ...after],
|
|
4510
|
+
{ encoding: "utf8", timeout: 120_000, stdio: ["ignore", "pipe", "pipe"] });
|
|
4511
|
+
collected = c.status === 0;
|
|
4512
|
+
if (!collected) problems.push(`${it.link}: pushed, but collecting it exited ${c.status ?? "on a signal"} — collect by hand: ${it.collect}`);
|
|
4513
|
+
}
|
|
4514
|
+
it.pushed = { pid, collected };
|
|
4515
|
+
}
|
|
4516
|
+
} finally {
|
|
4517
|
+
release();
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4391
4521
|
/** A question about to be voided: where it is, and what it parsed as. */
|
|
4392
4522
|
interface AskVoidTarget {
|
|
4393
4523
|
link: string;
|
|
@@ -5695,7 +5825,7 @@ async function main(): Promise<void> {
|
|
|
5695
5825
|
* prefix and thread check first). */
|
|
5696
5826
|
type SendCli = {
|
|
5697
5827
|
workspace?: string | undefined; target?: string | undefined; message?: string | undefined; code?: string | undefined;
|
|
5698
|
-
"allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
|
|
5828
|
+
"allow-url-adjacent"?: boolean | undefined; "fix-urls"?: boolean | undefined; "channel-id"?: string | undefined; "user-id"?: string | undefined;
|
|
5699
5829
|
"as-bot"?: boolean | undefined; broadcast?: boolean | undefined; mentions?: boolean | undefined;
|
|
5700
5830
|
json?: boolean | undefined;
|
|
5701
5831
|
};
|
|
@@ -5704,7 +5834,7 @@ async function main(): Promise<void> {
|
|
|
5704
5834
|
* its subscriptions. */
|
|
5705
5835
|
type EditCli = {
|
|
5706
5836
|
workspace?: string | undefined; target?: string | undefined; newText?: string | undefined; code?: string | undefined;
|
|
5707
|
-
force?: boolean | undefined; "allow-url-adjacent"?: boolean | undefined; "channel-id"?: string | undefined;
|
|
5837
|
+
force?: boolean | undefined; "allow-url-adjacent"?: boolean | undefined; "fix-urls"?: boolean | undefined; "channel-id"?: string | undefined;
|
|
5708
5838
|
mentions?: boolean | undefined; "as-bot"?: boolean | undefined; tick?: { n?: number } | undefined;
|
|
5709
5839
|
};
|
|
5710
5840
|
const runEdit = async (argv: EditCli): Promise<void> => {
|
|
@@ -5722,6 +5852,7 @@ async function main(): Promise<void> {
|
|
|
5722
5852
|
process.exit(1);
|
|
5723
5853
|
}
|
|
5724
5854
|
}
|
|
5855
|
+
if (argv["fix-urls"] && argv.newText) argv = { ...argv, newText: fixUrlBoundaries(argv.newText) };
|
|
5725
5856
|
const args: EditArgs = { target: argv.target!, newText: argv.newText ?? "", allowUrlAdjacent: argv["allow-url-adjacent"] === true };
|
|
5726
5857
|
if (argv.tick) args.tick = argv.tick;
|
|
5727
5858
|
if (settings.prefix) args.lanePrefix = settings.prefix;
|
|
@@ -5768,6 +5899,7 @@ async function main(): Promise<void> {
|
|
|
5768
5899
|
console.error("Error: message is empty");
|
|
5769
5900
|
process.exit(1);
|
|
5770
5901
|
}
|
|
5902
|
+
if (argv["fix-urls"]) argv = { ...argv, message: fixUrlBoundaries(argv.message!) };
|
|
5771
5903
|
const args: SendArgs = { target: argv.target!, message: argv.message!, allowUrlAdjacent: argv["allow-url-adjacent"] === true };
|
|
5772
5904
|
if (argv.json) args.json = true;
|
|
5773
5905
|
if (argv.code) args.code = argv.code;
|
|
@@ -6416,6 +6548,7 @@ async function main(): Promise<void> {
|
|
|
6416
6548
|
.positional("message", { type: "string", demandOption: true })
|
|
6417
6549
|
.option("code", { type: "string", describe: "Safety hash to confirm send" })
|
|
6418
6550
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
6551
|
+
.option("fix-urls", { type: "boolean", default: false, describe: "Wrap ambiguous bare URLs as <url> (cut at the first non-ASCII character / trailing punctuation) before sending" })
|
|
6419
6552
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
6420
6553
|
.option("user-id", { type: "string", describe: "Raw user ID (opens DM)" })
|
|
6421
6554
|
.option("as-bot", { type: "boolean", default: false, describe: "Send via the bot token (xoxb / SLACK_BOT_TOKEN) so a DM notifies the recipient and can be two-way" })
|
|
@@ -6594,6 +6727,7 @@ async function main(): Promise<void> {
|
|
|
6594
6727
|
.option("code", { type: "string", describe: "Safety hash to confirm send" })
|
|
6595
6728
|
.option("as-bot", { type: "boolean", describe: "Send via the bot token (default: settings' as_bot)" })
|
|
6596
6729
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
6730
|
+
.option("fix-urls", { type: "boolean", default: false, describe: "Wrap ambiguous bare URLs as <url> (cut at the first non-ASCII character / trailing punctuation) before sending" })
|
|
6597
6731
|
.option("broadcast", { type: "boolean", default: false, describe: "Also send to channel" })
|
|
6598
6732
|
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens to real mentions (--no-mentions for literal text)" }),
|
|
6599
6733
|
async (argv) => {
|
|
@@ -6628,7 +6762,7 @@ async function main(): Promise<void> {
|
|
|
6628
6762
|
if (!text.trim()) { console.error("Error: message is empty (only the prefix)"); process.exit(1); }
|
|
6629
6763
|
await runSend({
|
|
6630
6764
|
workspace: (argv as W).workspace, target: `${channel}:${threadTs}`, message: text, code: argv.code,
|
|
6631
|
-
"allow-url-adjacent": argv["allow-url-adjacent"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
|
|
6765
|
+
"allow-url-adjacent": argv["allow-url-adjacent"], "fix-urls": argv["fix-urls"], "as-bot": argv["as-bot"] ?? settings.as_bot ?? false,
|
|
6632
6766
|
broadcast: argv.broadcast, mentions: argv.mentions, json: argv.json,
|
|
6633
6767
|
});
|
|
6634
6768
|
},
|
|
@@ -6842,7 +6976,10 @@ async function main(): Promise<void> {
|
|
|
6842
6976
|
.option("reask", { type: "boolean", default: false, describe: "With ONE --void: post the question again (same channel/thread, same text and choices unless you give new ones as positionals / --body) and void the old one as replaced by it — one gate for both. The way to change the options of a question someone already answered." })
|
|
6843
6977
|
.option("edit", { type: "string", describe: "Change an open question you posted: --edit=<permalink> [\"new question\" [choices…]] [--body …]. Rebuilt with the ask layout so collect still reads it. Options can change only while nobody has answered (then: --void=<link> --reask). Same --code gate." })
|
|
6844
6978
|
.option("ls", { type: "boolean", default: false, describe: "List the questions you posted (local sent-log) with their LIVE state: --state open|answered|void|all (default open), --stale 24h, --channel, --json. With --stale it prints the --void command that would retire them." })
|
|
6845
|
-
.option("pending", { type: "boolean", default: false, describe: "List questions THIS machine asked (local sent-log) that somebody answered but nobody collected, grouped by the asking session, with whether that agent is still alive and the --waitFor line that collects each. Read-only: --since 7d, --channel, --json." })
|
|
6979
|
+
.option("pending", { type: "boolean", default: false, describe: "List questions THIS machine asked (local sent-log) that somebody answered but nobody collected, grouped by the asking session, with whether that agent is still alive and the --waitFor line that collects each. Read-only unless --push: --since 7d, --channel, --json. Never waits out a rate limit: a throttled pass says how many it skipped." })
|
|
6980
|
+
.option("push", { type: "boolean", default: false, describe: "With --pending: send each answer to the agent that asked (ay send to the pid in the sent-log, or up to 3 of its parents), then collect a decision (✅, off this list); a free-text reply is marked delivered, the question stays open. Askers that are gone get nothing. Each answer is pushed once." })
|
|
6981
|
+
.option("watch", { type: "boolean", default: false, describe: "With --pending --push: keep running — a pass every --interval, and one for a question as soon as the relay rings for it (SLACK_RELAY_URL / SLACK_RELAY_TOKEN). Prints only what it pushed." })
|
|
6982
|
+
.option("interval", { type: "number", default: 10, describe: "With --watch: minutes between full passes" })
|
|
6846
6983
|
.option("since", { type: "string", default: "7d", describe: "With --pending: how far back to look (30m, 2h, 7d, or an ISO date)" })
|
|
6847
6984
|
.option("stale", { type: "string", describe: "With --ls: only open questions older than this (24h, 3d)" })
|
|
6848
6985
|
.option("state", { type: "string", default: "open", choices: ["open", "answered", "void", "edited", "gone", "unknown", "all"], describe: "With --ls: which state to list" })
|
|
@@ -6886,8 +7023,27 @@ async function main(): Promise<void> {
|
|
|
6886
7023
|
console.error(`Error: --since needs 30m / 2h / 7d / 1w or an ISO date (got ${stripTerminalControls(String(argv.since))})`);
|
|
6887
7024
|
process.exit(ASK_EXIT_ERROR);
|
|
6888
7025
|
}
|
|
6889
|
-
|
|
7026
|
+
if (argv.watch && !argv.push) {
|
|
7027
|
+
console.error("Error: --watch keeps pushing answers — use it with --push.");
|
|
7028
|
+
process.exit(ASK_EXIT_ERROR);
|
|
7029
|
+
}
|
|
7030
|
+
const p: Parameters<typeof cmdAskPending>[1] = { sinceMs, json: !!argv.json, push: !!argv.push };
|
|
6890
7031
|
if (argv.channel) p.channel = String(argv.channel);
|
|
7032
|
+
// The collect run must read with the same workspace this one does.
|
|
7033
|
+
if (argv.workspace) p.selfArgs = ["-w", String(argv.workspace)];
|
|
7034
|
+
if (argv.watch) {
|
|
7035
|
+
const min = Number(argv.interval);
|
|
7036
|
+
if (!(min > 0)) {
|
|
7037
|
+
console.error("Error: --interval needs a number of minutes above 0.");
|
|
7038
|
+
process.exit(ASK_EXIT_ERROR);
|
|
7039
|
+
}
|
|
7040
|
+
const since = String(argv.since);
|
|
7041
|
+
await askPushWatch(
|
|
7042
|
+
(only) => cmdAskPending(tokens, { ...p, sinceMs: parseSince(since) ?? sinceMs, watch: true, ...(only ? { only } : {}) }),
|
|
7043
|
+
() => parseSince(since) ?? sinceMs,
|
|
7044
|
+
min * 60_000,
|
|
7045
|
+
);
|
|
7046
|
+
}
|
|
6891
7047
|
try {
|
|
6892
7048
|
await cmdAskPending(tokens, p);
|
|
6893
7049
|
} catch (e: unknown) {
|
|
@@ -7480,6 +7636,7 @@ async function main(): Promise<void> {
|
|
|
7480
7636
|
.option("code", { type: "string", describe: "Safety hash to confirm edit" })
|
|
7481
7637
|
.option("force", { type: "boolean", default: false, describe: "Apply an edit that BREAKS a `slack ask` / `slack poll` message (new text no longer reads as one, or an answered/void question made open again). Refused by default because collect reads that body back; edits that keep it readable (e.g. writing the ✅ form) need no flag. Prefer `slack ask --edit=` / `--void=`." })
|
|
7482
7638
|
.option("allow-url-adjacent", { type: "boolean", default: false, describe: "Warn instead of refusing ambiguous bare URL boundaries" })
|
|
7639
|
+
.option("fix-urls", { type: "boolean", default: false, describe: "Wrap ambiguous bare URLs as <url> (cut at the first non-ASCII character / trailing punctuation) before sending" })
|
|
7483
7640
|
.option("channel-id", { type: "string", describe: "Raw channel ID" })
|
|
7484
7641
|
.option("mentions", { type: "boolean", default: true, describe: "Convert @handle tokens in the new text to real <@USERID> mentions (on by default). Unresolved handles stay as plain text. Disable with --no-mentions for literal @text." })
|
|
7485
7642
|
.option("as-bot", { type: "boolean", default: false, describe: "Edit as the bot (xoxb / SLACK_BOT_TOKEN). REQUIRED to correct a message the bot posted: Slack only lets a token edit its OWN messages, so a bot-authored message is uneditable by the user token and chat.update returns cant_update_message. Address it by permalink or --channel-id; an @user target resolves against the BOT's DM list, which is where a bot-authored DM actually lives." }),
|
package/ts/sentlog.ts
CHANGED
|
@@ -70,6 +70,10 @@ export interface SentRow {
|
|
|
70
70
|
delivered_at?: number | null;
|
|
71
71
|
delivered_answer?: string | null;
|
|
72
72
|
delivered_cursor?: string | null;
|
|
73
|
+
/** `ask --pending --push` handed this answer to the asking agent (over ay):
|
|
74
|
+
* the same answer is not pushed twice. */
|
|
75
|
+
pushed_at?: number | null;
|
|
76
|
+
pushed_answer?: string | null;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export function attributionEnabled(): boolean {
|
|
@@ -239,7 +243,9 @@ CREATE TABLE IF NOT EXISTS sent (
|
|
|
239
243
|
answer_exit INTEGER,
|
|
240
244
|
delivered_at INTEGER,
|
|
241
245
|
delivered_answer TEXT,
|
|
242
|
-
delivered_cursor TEXT
|
|
246
|
+
delivered_cursor TEXT,
|
|
247
|
+
pushed_at INTEGER,
|
|
248
|
+
pushed_answer TEXT
|
|
243
249
|
);
|
|
244
250
|
CREATE INDEX IF NOT EXISTS sent_msg ON sent(channel, ts);
|
|
245
251
|
CREATE INDEX IF NOT EXISTS sent_at ON sent(sent_at);
|
|
@@ -262,7 +268,7 @@ export function openSentLog(): Db | undefined {
|
|
|
262
268
|
db.exec(SCHEMA);
|
|
263
269
|
// Columns added after the table first shipped. ALTER fails harmlessly when
|
|
264
270
|
// the column is already there.
|
|
265
|
-
for (const col of ["delivered_answer TEXT", "delivered_cursor TEXT"]) {
|
|
271
|
+
for (const col of ["delivered_answer TEXT", "delivered_cursor TEXT", "pushed_at INTEGER", "pushed_answer TEXT"]) {
|
|
266
272
|
try { db.exec(`ALTER TABLE sent ADD COLUMN ${col}`); } catch { /* exists */ }
|
|
267
273
|
}
|
|
268
274
|
return db;
|
|
@@ -408,6 +414,14 @@ export function markAskDelivered(channel: string, ts: string, answer: string, cu
|
|
|
408
414
|
);
|
|
409
415
|
}
|
|
410
416
|
|
|
417
|
+
/** `ask --pending --push` told the asking agent this answer. */
|
|
418
|
+
export function markAskPushed(channel: string, ts: string, answer: string): void {
|
|
419
|
+
updateLog(
|
|
420
|
+
`UPDATE sent SET pushed_at = ?, pushed_answer = ? WHERE kind = 'ask' AND channel = ? AND ts = ?`,
|
|
421
|
+
Date.now(), answer, channel, ts,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
411
425
|
/** Logged asks nobody has collected yet, oldest first. `undefined` when the
|
|
412
426
|
* log cannot be opened at all (no SQLite in this runtime). */
|
|
413
427
|
export function uncollectedAsks(sinceMs: number): SentRow[] | undefined {
|
package/ts/urlGuard.ts
CHANGED
|
@@ -16,7 +16,15 @@ const URL_TOKEN = /<https?:\/\/[^\s<>|]+(?:\|[^<>\r\n]*)?>|https?:\/\/[^\t\n\v\f
|
|
|
16
16
|
/** What may sit right before a bare URL and still let it autolink. */
|
|
17
17
|
const SAFE_BEFORE = /[\t\n\v\f\r (*_~]/;
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** `trailing`: the URL ends in ASCII punctuation (`)` without a matching `(`, or `.,:;!?`) that Slack may or may
|
|
20
|
+
* not take into the link: `(see https://x/a)` would link `https://x/a)`. Only warned, never refused. */
|
|
21
|
+
export type UrlBoundaryIssue = { url: string; side: "before" | "after" | "trailing"; char: string };
|
|
22
|
+
const TRAILING = /[.,:;!?]$/;
|
|
23
|
+
function trailingChar(url: string): string | undefined {
|
|
24
|
+
if (TRAILING.test(url)) return url.slice(-1);
|
|
25
|
+
if (url.endsWith(")") && count(url, "(") < count(url, ")")) return ")";
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
/** Code spans and fences are shown verbatim and never autolinked, so a URL in
|
|
22
30
|
* one is not a boundary problem; neither is a `[label](url)` link, which is
|
|
@@ -97,6 +105,11 @@ export function urlBoundaryIssues(text: string, { markdown = true }: { markdown?
|
|
|
97
105
|
}
|
|
98
106
|
const after = url.match(/[^\x00-\x7f]|>/);
|
|
99
107
|
if (after) issues.push({ url, side: "after", char: after[0] });
|
|
108
|
+
else {
|
|
109
|
+
// `(https://x/a)`: the `)` closes the paren before the URL, as GFM and Slack read it
|
|
110
|
+
const t = trailingChar(url);
|
|
111
|
+
if (t && !(t === ")" && before === "(")) issues.push({ url, side: "trailing", char: t });
|
|
112
|
+
}
|
|
100
113
|
}
|
|
101
114
|
return issues;
|
|
102
115
|
}
|
|
@@ -107,22 +120,25 @@ export function adjacentUrls(text: string): string[] {
|
|
|
107
120
|
|
|
108
121
|
export function guardUrlBoundaries(
|
|
109
122
|
text: string, allow = false, warn: (message: string) => void = console.error,
|
|
110
|
-
{ sides = ["before", "after"], markdown = true }: {
|
|
123
|
+
{ sides = ["before", "after", "trailing"], markdown = true }: {
|
|
111
124
|
sides?: ReadonlyArray<UrlBoundaryIssue["side"]>; markdown?: boolean;
|
|
112
125
|
} = {},
|
|
113
126
|
): void {
|
|
114
|
-
const
|
|
127
|
+
const all = urlBoundaryIssues(text, { markdown });
|
|
128
|
+
const trailing = all.filter((i) => i.side === "trailing" && sides.includes("trailing"));
|
|
129
|
+
if (trailing.length) warn(`⚠ URL ends in punctuation Slack may take into the link: ${trailing.map((i) => `${JSON.stringify(i.url)} → ${JSON.stringify(fixUrlBoundaries(`x ${i.url}`).slice(2))}`).join(", ")}. If the ${JSON.stringify(trailing[0]!.char)} is not part of the URL, send the suggested form (or --fix-urls).`);
|
|
130
|
+
const issues = all.filter((i) => i.side !== "trailing" && sides.includes(i.side));
|
|
115
131
|
if (!issues.length) return;
|
|
116
132
|
const described = issues.map((i) => i.side === "before"
|
|
117
133
|
? `${JSON.stringify(i.url)} (glued to ${JSON.stringify(i.char)} before it — Slack will NOT make it a link)`
|
|
118
|
-
: `${JSON.stringify(i.url)} (runs into ${JSON.stringify(i.char)} after it)`);
|
|
134
|
+
: `${JSON.stringify(i.url)} (runs into ${JSON.stringify(i.char)} after it; non-ASCII in a URL is OK only if intended, otherwise send ${JSON.stringify(fixUrlBoundaries(`x ${i.url}`).slice(2))} or put an ASCII space after the URL)`);
|
|
119
135
|
const fixes = issues.some((i) => i.side === "before")
|
|
120
136
|
? "Put an ASCII space (or a newline) before the URL, " : "";
|
|
121
137
|
const message = `Ambiguous URL boundary: ${described.join(", ")}. ` +
|
|
122
138
|
`${fixes}Put the URL on its own line, or wrap it as <url> / <url|label>. ` +
|
|
123
|
-
"Use --allow-url-adjacent to warn only.";
|
|
139
|
+
"Use --fix-urls to wrap them, or --allow-url-adjacent to warn only.";
|
|
124
140
|
if (!allow) throw new Error(message);
|
|
125
|
-
warn(
|
|
141
|
+
warn(`⚠ Warning: ${message}`);
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
type RichTextElement =
|
|
@@ -167,6 +183,27 @@ export function linkifyRichText(text: string): RichTextElement[] {
|
|
|
167
183
|
return out;
|
|
168
184
|
}
|
|
169
185
|
|
|
186
|
+
/** Optional auto-fix (`--fix-urls`): wrap every ambiguous bare URL as `<url>`, cut where Slack's link would end
|
|
187
|
+
* (at the first non-ASCII character, before trailing punctuation). Text, code spans and explicit links are kept. */
|
|
188
|
+
export function fixUrlBoundaries(text: string): string {
|
|
189
|
+
const masked = maskCode(text);
|
|
190
|
+
let out = "", pos = 0;
|
|
191
|
+
for (const m of masked.matchAll(URL_TOKEN)) {
|
|
192
|
+
const at = m.index ?? 0;
|
|
193
|
+
if (m[0].startsWith("<")) continue;
|
|
194
|
+
let url = text.slice(at, at + m[0].length);
|
|
195
|
+
const cut = url.search(/[^\x00-\x7f]|>/);
|
|
196
|
+
if (cut >= 0) url = url.slice(0, cut);
|
|
197
|
+
while (trailingChar(url)) url = url.slice(0, -1);
|
|
198
|
+
const before = at > 0 ? masked[at - 1]! : "";
|
|
199
|
+
const ambiguous = url.length < m[0].length || (before && !SAFE_BEFORE.test(before));
|
|
200
|
+
if (!ambiguous || !/^https?:\/\/./i.test(url)) continue;
|
|
201
|
+
out += text.slice(pos, at) + `<${url}>`;
|
|
202
|
+
pos = at + url.length;
|
|
203
|
+
}
|
|
204
|
+
return out + text.slice(pos);
|
|
205
|
+
}
|
|
206
|
+
|
|
170
207
|
function count(s: string, ch: string): number {
|
|
171
208
|
return s.split(ch).length - 1;
|
|
172
209
|
}
|