spinuptui 1.3.0-alpha.4 → 1.3.0-alpha.5
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/CHANGELOG.md +20 -1
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/index.tsx +8 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +4 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +30 -1
- package/src/lib/beaconListen.ts +40 -17
- package/src/lib/beaconListenInstall.ts +13 -4
- package/src/lib/cliBeacon.ts +25 -1
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +88 -1
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +3 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +31 -8
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +4 -0
- package/src/ui/store.tsx +5 -0
- package/src/ui/views/Modules.tsx +56 -3
- package/src/ui/views/SentinelSetup.tsx +171 -0
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,24 @@ version; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.3.0-alpha.5] - 2026-10-30
|
|
15
|
+
|
|
16
|
+
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew are on 1.2.1.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Sentinel: know why, not just that it's down.** The third module checks Uptime Kuma every 5 minutes, groups what went down by server, pages you, and writes it up: the evidence on its own, or a full diagnosis if you bring an agent CLI. The agent reads servers only through `ssh-exec` and can't post anything; Sentinel saves its report and does the posting. A repeat within 6 hours skips the diagnosis and offers **Diagnose anyway**. A lapsed agent login delays work instead of losing it. Set it up from the Modules tab, or `spinuptui sentinel setup`. See [docs/sentinel.md](docs/sentinel.md).
|
|
20
|
+
- **A morning digest that only speaks up about what's new.** Sentinel's 08:00 message covers station health, the last 24 hours, server updates and your expectation checks: a crawler that stopped arriving, a cache that stopped hitting, a box that latches under load. A flag is New on the day it trips, then Ongoing with its age, and the digest is silent unless something needs you.
|
|
21
|
+
- **`spinuptui sentinel attach`** adopts an existing Kuma's monitors on a fresh install, without changing anything in Kuma.
|
|
22
|
+
- **Jobs can say they're blocked.** A job that ran but couldn't do its work now reports `blocked` instead of `ok`: an agent with `STATUS: blocked`, a script by exiting 75. Failed and blocked pages, and `/jobs`, get a **Run again** button. It re-runs the job exactly as written and never adds permissions.
|
|
23
|
+
- **Team boards.** `spinuptui beacon add basecamp <name> --project <id>` connects a Basecamp message board for your own team's record. It can't be paged, and it's where Sentinel posts reports.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- **`spinuptui incidents` output** gains `monitorId` on each event and a `failedMonitors` list, so a caller can tell "no events" from "couldn't read that monitor".
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **A long job no longer makes Kuma report a healthy station as dead.** The heartbeat was only sent when a tick finished, so a job past the 15-minute window looked like an outage. A tick that finds another still running now pings too.
|
|
30
|
+
- **Beacon's listener no longer reads as down right after it starts.** Its first poll takes up to 50 seconds, so `beacon listener status` showed ✗ after an install, and a Scheduler tick in that window could have paged about a listener that was fine.
|
|
31
|
+
|
|
14
32
|
## [1.3.0-alpha.4] - 2026-10-30
|
|
15
33
|
|
|
16
34
|
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew are on 1.2.1.
|
|
@@ -1545,7 +1563,8 @@ Initial tagged release.
|
|
|
1545
1563
|
### Notes
|
|
1546
1564
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1547
1565
|
|
|
1548
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.
|
|
1566
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.5...HEAD
|
|
1567
|
+
[1.3.0-alpha.5]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.4...v1.3.0-alpha.5
|
|
1549
1568
|
[1.3.0-alpha.4]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.3...v1.3.0-alpha.4
|
|
1550
1569
|
[1.3.0-alpha.3]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.2...v1.3.0-alpha.3
|
|
1551
1570
|
[1.2.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.2.1
|
package/README.md
CHANGED
|
@@ -91,6 +91,11 @@
|
|
|
91
91
|
any machine. One LaunchAgent ticks every 5 minutes and is the clock the other
|
|
92
92
|
modules run on, and a station heartbeat (`h`) pings your Uptime Kuma each
|
|
93
93
|
tick, so a station that stops is noticed from elsewhere. → [docs/scheduler.md](docs/scheduler.md)
|
|
94
|
+
Then **Sentinel**: watches the fleet, works out what actually broke, and
|
|
95
|
+
writes it up. A page tells you why, not just that something's down, and a
|
|
96
|
+
morning digest covers what monitoring can't see, like a crawler that stopped
|
|
97
|
+
arriving or a cache that stopped hitting. Bring your own agent for diagnosis,
|
|
98
|
+
or use it without one. → [docs/sentinel.md](docs/sentinel.md)
|
|
94
99
|
- **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
|
|
95
100
|
resolves a domain to a live SSH target with a live connectivity probe;
|
|
96
101
|
`spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
|
|
@@ -385,8 +390,9 @@ These can be set in `config.json` or via an environment variable:
|
|
|
385
390
|
| `w` | Open the selected server/site in the SpinupWP web app |
|
|
386
391
|
| `/` | Jump to global search |
|
|
387
392
|
| `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
|
|
388
|
-
| `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup, Scheduler its install plan (Modules tab) |
|
|
389
|
-
| `t` | Send a test page through Beacon
|
|
393
|
+
| `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup, Scheduler its install plan, Sentinel its setup (Modules tab) |
|
|
394
|
+
| `t` | Send a test page through Beacon, or preview Sentinel's digest (Modules tab) |
|
|
395
|
+
| `p` | Pause Sentinel for 2 hours, or resume it (Modules tab, Sentinel) |
|
|
390
396
|
| `l` | Beacon's listener: install it so the pager answers `/jobs`, see whether it's running, or remove it (Modules tab, Beacon) |
|
|
391
397
|
| `f` | Your SpinupTUI folder: where jobs and reports live, and sharing it across machines (Modules tab, Scheduler) |
|
|
392
398
|
| `h` | Station heartbeat: set up the Uptime Kuma push monitor, see the last ping, test or remove it (Modules tab, Scheduler) |
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -112,6 +112,7 @@ export interface AppConfig {
|
|
|
112
112
|
// Scheduler's sources and installed dispatcher (lib/scheduler*.ts,
|
|
113
113
|
// docs/2026-10-20_scheduler-spec.md). Machine-level: a station runs one clock.
|
|
114
114
|
scheduler: SchedulerStored | null
|
|
115
|
+
sentinel: SentinelStored | null
|
|
115
116
|
// Where modules put the user's work (Scheduler's jobs and reports…); null =
|
|
116
117
|
// the default, ~/SpinupTUI. SPINUPTUI_HOME overrides. See modules/contract.ts.
|
|
117
118
|
homeDir: string | null
|
|
@@ -127,14 +128,40 @@ export interface BeaconTelegramChannel {
|
|
|
127
128
|
// are additive.
|
|
128
129
|
export type BeaconChannel = BeaconTelegramChannel
|
|
129
130
|
|
|
131
|
+
// A Basecamp project's message board, for the team's own record. Not a channel:
|
|
132
|
+
// it's never paged, and only a named post reaches it. `audience` exists so a
|
|
133
|
+
// client-facing board can't be reached by accident: only "team" is accepted
|
|
134
|
+
// until the client channel's gate is built.
|
|
135
|
+
export interface BeaconBoard {
|
|
136
|
+
type: "basecamp"
|
|
137
|
+
projectId: string
|
|
138
|
+
mention?: string // the person @-mentioned on a new post
|
|
139
|
+
audience: "team"
|
|
140
|
+
}
|
|
141
|
+
|
|
130
142
|
export interface BeaconStored {
|
|
131
143
|
defaultChannel?: string
|
|
132
144
|
channels?: Record<string, BeaconChannel>
|
|
145
|
+
boards?: Record<string, BeaconBoard>
|
|
133
146
|
// Written by `beacon listener install --apply`, cleared by uninstall: the
|
|
134
147
|
// resident process that answers the operator (lib/beaconListen.ts).
|
|
135
148
|
listener?: { label: string; unitPath: string; bin: string; channel: string; installedAt: string }
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
// Sentinel's settings (docs/2026-10-30_sentinel-spec.md). Machine-level, and
|
|
152
|
+
// written only by setup: everything it does while running lives in its state
|
|
153
|
+
// directory.
|
|
154
|
+
export interface SentinelStored {
|
|
155
|
+
enabled?: boolean
|
|
156
|
+
mode?: "live" | "shadow"
|
|
157
|
+
channel?: string | null // Beacon channel for pages; default Beacon's default
|
|
158
|
+
reportTo?: string | null // a Beacon board's name
|
|
159
|
+
workspace?: string
|
|
160
|
+
recurrenceHours?: number
|
|
161
|
+
maxDiagnosesPerDay?: number
|
|
162
|
+
diagnosis?: { agent?: "claude" | "codex" | null; model?: string | null; timeoutMin?: number; allow?: string | null }
|
|
163
|
+
}
|
|
164
|
+
|
|
138
165
|
export interface SchedulerStored {
|
|
139
166
|
// Named job sources beyond the built-in `local` one: directories holding
|
|
140
167
|
// jobs/ and runs/, pulled and pushed when they are git repositories.
|
|
@@ -249,6 +276,7 @@ export interface StoredConfig {
|
|
|
249
276
|
lastSeenVersion?: string
|
|
250
277
|
beacon?: BeaconStored
|
|
251
278
|
scheduler?: SchedulerStored
|
|
279
|
+
sentinel?: SentinelStored
|
|
252
280
|
homeDir?: string
|
|
253
281
|
}
|
|
254
282
|
|
|
@@ -537,6 +565,7 @@ export function loadConfig(): AppConfig {
|
|
|
537
565
|
lastSeenVersion: stored.lastSeenVersion?.trim() || null,
|
|
538
566
|
beacon: stored.beacon ?? null,
|
|
539
567
|
scheduler: stored.scheduler ?? null,
|
|
568
|
+
sentinel: stored.sentinel ?? null,
|
|
540
569
|
homeDir: stored.homeDir?.trim() || null,
|
|
541
570
|
}
|
|
542
571
|
}
|
package/src/index.tsx
CHANGED
|
@@ -115,6 +115,9 @@ Usage:
|
|
|
115
115
|
Queue a follow-up for this station to run later and report
|
|
116
116
|
on through Beacon (\`spinuptui scheduler\` for the rest)
|
|
117
117
|
spinuptui scheduler list | status | install --plan|--apply
|
|
118
|
+
spinuptui sentinel setup | attach | status | list | pause | expect
|
|
119
|
+
Watch the fleet, work out what broke, write it up
|
|
120
|
+
(runs as Scheduler jobs; see docs/sentinel.md)
|
|
118
121
|
spinuptui home Where the SpinupTUI folder (your jobs and reports) is
|
|
119
122
|
spinuptui home sync --plan|--apply --github <name> | --remote <url> | --clone <url>
|
|
120
123
|
Share that folder across machines through a private git
|
|
@@ -328,6 +331,11 @@ if (command === "scheduler") {
|
|
|
328
331
|
process.exit(await runScheduler(args.slice(args.indexOf("scheduler") + 1)))
|
|
329
332
|
}
|
|
330
333
|
|
|
334
|
+
if (command === "sentinel") {
|
|
335
|
+
const { runSentinel } = await import("./lib/cliSentinel.ts")
|
|
336
|
+
process.exit(await runSentinel(args.slice(args.indexOf("sentinel") + 1)))
|
|
337
|
+
}
|
|
338
|
+
|
|
331
339
|
// `spinuptui login` forces the onboarding wizard even when a token already exists,
|
|
332
340
|
// so the token can be (re)saved to the config file for global use.
|
|
333
341
|
const { startDashboard } = await import("./ui/main.tsx")
|
package/src/lib/beacon.test.ts
CHANGED
|
@@ -138,3 +138,17 @@ describe("beaconSend", () => {
|
|
|
138
138
|
expect(f.calls.length).toBe(2)
|
|
139
139
|
})
|
|
140
140
|
})
|
|
141
|
+
|
|
142
|
+
describe("buttons on a page", () => {
|
|
143
|
+
test("go on the last part only", async () => {
|
|
144
|
+
const bodies: URLSearchParams[] = []
|
|
145
|
+
const beacon: ResolvedBeacon = { channels: [{ name: "pager", channel: { type: "telegram", botToken: "1:a", chatId: "2" }, env: false }], defaultChannel: "pager" }
|
|
146
|
+
const fetchImpl = (async (_u: string, init?: RequestInit) => (bodies.push(init!.body as URLSearchParams), Response.json({ ok: true, result: {} }))) as never
|
|
147
|
+
const long = Array.from({ length: 300 }, (_, i) => `line ${i} ${"x".repeat(40)}`).join("\n")
|
|
148
|
+
const r = await beaconSend(beacon, long, { replyMarkup: '{"inline_keyboard":[]}', fetchImpl, log: false })
|
|
149
|
+
expect(r.ok).toBe(true)
|
|
150
|
+
expect(bodies.length).toBeGreaterThan(1)
|
|
151
|
+
expect(bodies.slice(0, -1).some((b) => b.has("reply_markup"))).toBe(false)
|
|
152
|
+
expect(bodies[bodies.length - 1]!.get("reply_markup")).toBe('{"inline_keyboard":[]}')
|
|
153
|
+
})
|
|
154
|
+
})
|
package/src/lib/beacon.ts
CHANGED
|
@@ -227,6 +227,8 @@ export type SendResult =
|
|
|
227
227
|
export interface SendOptions {
|
|
228
228
|
to?: string | null
|
|
229
229
|
silent?: boolean
|
|
230
|
+
// Telegram's reply_markup for the last part. Build it with pageMarkup().
|
|
231
|
+
replyMarkup?: string
|
|
230
232
|
fetchImpl?: Fetch
|
|
231
233
|
wait?: (ms: number) => Promise<unknown>
|
|
232
234
|
log?: boolean
|
|
@@ -271,7 +273,7 @@ export async function beaconSend(beacon: ResolvedBeacon, text: string, opts: Sen
|
|
|
271
273
|
const wait = opts.wait ?? sleep
|
|
272
274
|
let sent = 0
|
|
273
275
|
let failure: string | undefined
|
|
274
|
-
for (const part of parts) {
|
|
276
|
+
for (const [i, part] of parts.entries()) {
|
|
275
277
|
const r = await telegramWithRetry(
|
|
276
278
|
channel.botToken,
|
|
277
279
|
"sendMessage",
|
|
@@ -280,6 +282,7 @@ export async function beaconSend(beacon: ResolvedBeacon, text: string, opts: Sen
|
|
|
280
282
|
text: part,
|
|
281
283
|
disable_web_page_preview: "true",
|
|
282
284
|
...(opts.silent ? { disable_notification: "true" } : {}),
|
|
285
|
+
...(opts.replyMarkup && i === parts.length - 1 ? { reply_markup: opts.replyMarkup } : {}),
|
|
283
286
|
},
|
|
284
287
|
fetchImpl,
|
|
285
288
|
wait,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { BeaconBoard } from "../config.ts"
|
|
3
|
+
import { markdownToBasecampHtml, postToBoard, splitTitle, type BasecampRun } from "./beaconBoard.ts"
|
|
4
|
+
|
|
5
|
+
const board: BeaconBoard = { type: "basecamp", projectId: "123", mention: "Pat Doe", audience: "team" }
|
|
6
|
+
|
|
7
|
+
function fakeCli(post: { code: number; out: string }): { run: BasecampRun; calls: string[][] } {
|
|
8
|
+
const calls: string[][] = []
|
|
9
|
+
const run: BasecampRun = async (args) => {
|
|
10
|
+
calls.push(args)
|
|
11
|
+
if (args[0] === "api" && args[1] === "get") return { code: 0, out: JSON.stringify({ ok: true, data: { dock: [{ id: 9, name: "message_board" }] } }) }
|
|
12
|
+
if (args[0] === "people") return { code: 0, out: JSON.stringify({ ok: true, data: [{ name: "Pat Doe", attachable_sgid: "sg" }] }) }
|
|
13
|
+
return post
|
|
14
|
+
}
|
|
15
|
+
return { run, calls }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("markdownToBasecampHtml", () => {
|
|
19
|
+
test("renders what a report uses", () => {
|
|
20
|
+
const html = markdownToBasecampHtml("## Root cause\n\nThe **cron** ran `wp` twice.\n\n- [x] done\n- [ ] todo\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```\nx < y\n```\n")
|
|
21
|
+
expect(html).toContain("<h2>Root cause</h2>")
|
|
22
|
+
expect(html).toContain("<p>The <strong>cron</strong> ran <code>wp</code> twice.</p>")
|
|
23
|
+
expect(html).toContain("<li>☑ done</li>")
|
|
24
|
+
expect(html).toContain("<li>☐ todo</li>")
|
|
25
|
+
expect(html).toContain("<th>A</th>")
|
|
26
|
+
expect(html).toContain("<td>2</td>")
|
|
27
|
+
expect(html).toContain("<pre><code>x < y</code></pre>")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("escapes anything a log line could carry, and joins soft-wrapped text", () => {
|
|
31
|
+
expect(markdownToBasecampHtml("a <script>alert(1)</script>\nb")).toBe("<p>a <script>alert(1)</script> b</p>")
|
|
32
|
+
expect(markdownToBasecampHtml("<img src=x onerror=y>")).toBe("<p><img src=x onerror=y></p>")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("keeps real links and flattens local ones", () => {
|
|
36
|
+
expect(markdownToBasecampHtml("[a](https://example.com) [b](2026-10-30-x.md)")).toBe('<p><a href="https://example.com">a</a> b</p>')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe("postToBoard", () => {
|
|
41
|
+
const report = "# 10/30/2026 (13:34) - example.com - health\n\n## Symptom\n\nDown."
|
|
42
|
+
|
|
43
|
+
test("uses the H1 as the title and returns the message", async () => {
|
|
44
|
+
const { run, calls } = fakeCli({ code: 0, out: JSON.stringify({ ok: true, data: { id: 42, app_url: "https://bc/42" } }) })
|
|
45
|
+
expect(await postToBoard(board, report, run)).toEqual({ ok: true, id: "42", url: "https://bc/42" })
|
|
46
|
+
const post = calls.find((c) => c[1] === "post")!
|
|
47
|
+
expect(post[2]).toBe("buckets/123/message_boards/9/messages.json")
|
|
48
|
+
const payload = JSON.parse(post[4]!) as { subject: string; content: string }
|
|
49
|
+
expect(payload.subject).toBe("10/30/2026 (13:34) - example.com - health")
|
|
50
|
+
expect(payload.content).toContain('sgid="sg"')
|
|
51
|
+
expect(payload.content).not.toContain("<h1")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("refuses a report with no title, and anything but a team board", async () => {
|
|
55
|
+
const { run } = fakeCli({ code: 0, out: "{}" })
|
|
56
|
+
expect(await postToBoard(board, "no heading", run)).toMatchObject({ ok: false, reason: "bad_report" })
|
|
57
|
+
expect(await postToBoard({ ...board, audience: "client" as never }, report, run)).toMatchObject({ ok: false })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("reports a refused post", async () => {
|
|
61
|
+
const { run } = fakeCli({ code: 1, out: "nope" })
|
|
62
|
+
expect(await postToBoard(board, report, run)).toMatchObject({ ok: false, reason: "api" })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("splitTitle", () => {
|
|
66
|
+
expect(splitTitle("# T\nbody")).toEqual({ title: "T", body: "body" })
|
|
67
|
+
expect(splitTitle("body").title).toBe("")
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Beacon's boards: a Basecamp project's message board, for the team's own record
|
|
2
|
+
// (docs/2026-10-30_sentinel-spec.md, "Basecamp"). A board isn't a pager channel.
|
|
3
|
+
// It carries reports for the team that owns the fleet, and nothing here can reach
|
|
4
|
+
// a client: `audience` only accepts "team" until that gate is built.
|
|
5
|
+
//
|
|
6
|
+
// The HTML is made here rather than by the CLI's converter, whose output depends
|
|
7
|
+
// on the installed version: one version posted a whole report as literal ## and
|
|
8
|
+
// **. The post goes through `basecamp api`, so its login stays the CLI's business.
|
|
9
|
+
// Basecamp has no idempotency key, so a caller must record a post before and
|
|
10
|
+
// after making it (see the worker) rather than retry blindly.
|
|
11
|
+
|
|
12
|
+
import type { BeaconBoard } from "../config.ts"
|
|
13
|
+
import { jobEnv } from "./schedulerRun.ts"
|
|
14
|
+
import { spawn } from "./spawn.ts"
|
|
15
|
+
|
|
16
|
+
// ---- Markdown → Basecamp HTML ------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
19
|
+
const escAttr = (s: string) => esc(s).replace(/"/g, """)
|
|
20
|
+
|
|
21
|
+
const HEADING = /^(#{1,6})\s+(.*)$/
|
|
22
|
+
const LIST_ITEM = /^\s*(?:([-*+])|(\d+)\.)\s+(.*)$/
|
|
23
|
+
const TASK_MARK = /^\[([ xX])\]\s+/
|
|
24
|
+
const TABLE_SEP = /^\s*\|[\s:|-]+\|\s*$/
|
|
25
|
+
|
|
26
|
+
function inline(text: string): string {
|
|
27
|
+
const spans: string[] = []
|
|
28
|
+
let out = text.replace(/`([^`]+)`/g, (_, code: string) => {
|
|
29
|
+
spans.push(`<code>${esc(code)}</code>`)
|
|
30
|
+
return `\x00${spans.length - 1}\x00`
|
|
31
|
+
})
|
|
32
|
+
out = esc(out)
|
|
33
|
+
// A relative link points at a local file that means nothing to a board reader.
|
|
34
|
+
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, target: string) =>
|
|
35
|
+
/^(https?:\/\/|mailto:)/.test(target) ? `<a href="${escAttr(target)}">${label}</a>` : label,
|
|
36
|
+
)
|
|
37
|
+
out = out.replace(/\*\*(.+?)\*\*/gs, "<strong>$1</strong>")
|
|
38
|
+
out = out.replace(/(?<![\w*])\*(?!\s)([^*]+?)(?<!\s)\*(?![\w*])/g, "<em>$1</em>")
|
|
39
|
+
// eslint-disable-next-line no-control-regex
|
|
40
|
+
return out.replace(/\x00(\d+)\x00/g, (_, n: string) => spans[Number(n)]!)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function splitRow(line: string): string[] {
|
|
44
|
+
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Headings, paragraphs, lists with task items, GFM tables, fenced code,
|
|
48
|
+
// blockquotes, rules, bold, italic, code and links: what a report uses.
|
|
49
|
+
export function markdownToBasecampHtml(md: string): string {
|
|
50
|
+
const lines = md.replace(/<!--[\s\S]*?-->/g, "").split("\n")
|
|
51
|
+
const out: string[] = []
|
|
52
|
+
let i = 0
|
|
53
|
+
while (i < lines.length) {
|
|
54
|
+
const line = lines[i]!
|
|
55
|
+
if (!line.trim()) {
|
|
56
|
+
i++
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (line.trimStart().startsWith("```")) {
|
|
60
|
+
const code: string[] = []
|
|
61
|
+
i++
|
|
62
|
+
while (i < lines.length && !lines[i]!.trimStart().startsWith("```")) code.push(lines[i++]!)
|
|
63
|
+
i++
|
|
64
|
+
out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
if (/^\s*-{3,}\s*$/.test(line)) {
|
|
68
|
+
out.push("<hr>")
|
|
69
|
+
i++
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const heading = HEADING.exec(line)
|
|
73
|
+
if (heading) {
|
|
74
|
+
const level = Math.min(Math.max(heading[1]!.length, 2), 4) // never an h1 in a post
|
|
75
|
+
out.push(`<h${level}>${inline(heading[2]!.trim())}</h${level}>`)
|
|
76
|
+
i++
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (line.trim().startsWith("|") && i + 1 < lines.length && TABLE_SEP.test(lines[i + 1]!)) {
|
|
80
|
+
const rows = [splitRow(line)]
|
|
81
|
+
i += 2
|
|
82
|
+
while (i < lines.length && lines[i]!.trim().startsWith("|")) rows.push(splitRow(lines[i++]!))
|
|
83
|
+
const body = rows.map((cells, n) => `<tr>${cells.map((c) => `<${n ? "td" : "th"}>${inline(c)}</${n ? "td" : "th"}>`).join("")}</tr>`)
|
|
84
|
+
out.push(`<table>\n${body.join("\n")}\n</table>`)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (line.trimStart().startsWith("> ")) {
|
|
88
|
+
const quote: string[] = []
|
|
89
|
+
while (i < lines.length && lines[i]!.trimStart().startsWith(">")) quote.push(lines[i++]!.trimStart().slice(1).trim())
|
|
90
|
+
out.push(`<blockquote><p>${inline(quote.join(" "))}</p></blockquote>`)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const first = LIST_ITEM.exec(line)
|
|
94
|
+
if (first) {
|
|
95
|
+
const ordered = first[2] !== undefined
|
|
96
|
+
const items: string[] = []
|
|
97
|
+
while (i < lines.length && lines[i]!.trim()) {
|
|
98
|
+
const m = LIST_ITEM.exec(lines[i]!)
|
|
99
|
+
if (m && (m[2] !== undefined) === ordered) {
|
|
100
|
+
const text = m[3]!
|
|
101
|
+
const task = TASK_MARK.exec(text)
|
|
102
|
+
items.push(task ? `${task[1]!.toLowerCase() === "x" ? "☑" : "☐"} ${text.slice(task[0].length)}` : text)
|
|
103
|
+
} else {
|
|
104
|
+
items[items.length - 1] += ` ${lines[i]!.trim()}`
|
|
105
|
+
}
|
|
106
|
+
i++
|
|
107
|
+
}
|
|
108
|
+
const tag = ordered ? "ol" : "ul"
|
|
109
|
+
out.push(`<${tag}>\n${items.map((t) => `<li>${inline(t)}</li>`).join("\n")}\n</${tag}>`)
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const para: string[] = []
|
|
113
|
+
while (i < lines.length && lines[i]!.trim()) {
|
|
114
|
+
const next = lines[i]!
|
|
115
|
+
if (para.length && (HEADING.test(next) || LIST_ITEM.test(next) || /^\s*(```|> |\|)/.test(next))) break
|
|
116
|
+
para.push(next.trim())
|
|
117
|
+
i++
|
|
118
|
+
}
|
|
119
|
+
out.push(`<p>${inline(para.join(" "))}</p>`)
|
|
120
|
+
}
|
|
121
|
+
return out.join("\n")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A report's first line, its H1, is the post's title.
|
|
125
|
+
export function splitTitle(md: string): { title: string; body: string } {
|
|
126
|
+
const m = /^\s*#\s+(.*?)\s*(\n|$)/.exec(md)
|
|
127
|
+
return m ? { title: m[1]!, body: md.slice(m[0].length) } : { title: "", body: md }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- the post ----------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
export type BasecampRun = (args: string[]) => Promise<{ code: number; out: string }>
|
|
133
|
+
|
|
134
|
+
const realRun: BasecampRun = async (args) => {
|
|
135
|
+
const p = spawn(["basecamp", ...args], { stdin: "ignore", stdout: "pipe", stderr: "pipe", env: jobEnv() })
|
|
136
|
+
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
|
|
137
|
+
const code = await p.exited
|
|
138
|
+
return { code, out: code === 0 ? out : `${out}${err}`.trim() }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type BoardPost = { ok: true; id: string; url: string } | { ok: false; reason: "cli_missing" | "auth" | "bad_report" | "api"; message: string }
|
|
142
|
+
|
|
143
|
+
interface CliEnvelope<T> {
|
|
144
|
+
ok?: boolean
|
|
145
|
+
data?: T
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function cliJson<T>(run: BasecampRun, args: string[]): Promise<T | null> {
|
|
149
|
+
const r = await run([...args, "--json"])
|
|
150
|
+
if (r.code !== 0) return null
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(r.out) as CliEnvelope<T>
|
|
153
|
+
return parsed.ok === false ? null : (parsed.data ?? null)
|
|
154
|
+
} catch {
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Is the CLI logged in? Only meaningful from the real login session (the
|
|
160
|
+
// dispatcher): a sandboxed shell can't read the Keychain, so a "no" there proves
|
|
161
|
+
// nothing. A digest runs under the dispatcher, which is why it can ask.
|
|
162
|
+
export async function boardLogin(run: BasecampRun = realRun): Promise<{ ok: boolean; detail: string }> {
|
|
163
|
+
try {
|
|
164
|
+
const me = await cliJson<{ identity?: { first_name?: string; last_name?: string } }>(run, ["me"])
|
|
165
|
+
if (!me) return { ok: false, detail: "not logged in: run `basecamp auth login` in a real terminal" }
|
|
166
|
+
return { ok: true, detail: [me.identity?.first_name, me.identity?.last_name].filter(Boolean).join(" ") || "logged in" }
|
|
167
|
+
} catch {
|
|
168
|
+
return { ok: false, detail: "the basecamp CLI isn't on the PATH" }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function postToBoard(board: BeaconBoard, report: string, run: BasecampRun = realRun): Promise<BoardPost> {
|
|
173
|
+
if (board.audience !== "team") return { ok: false, reason: "api", message: "Only team boards are supported." }
|
|
174
|
+
const { title, body } = splitTitle(report)
|
|
175
|
+
if (!title) return { ok: false, reason: "bad_report", message: "The report has no H1 to use as the post's title." }
|
|
176
|
+
try {
|
|
177
|
+
const project = await cliJson<{ dock?: { id: number; name: string }[] }>(run, ["api", "get", `buckets/${board.projectId}.json`])
|
|
178
|
+
const boardId = project?.dock?.find((d) => d.name === "message_board")?.id
|
|
179
|
+
if (!boardId) return { ok: false, reason: "auth", message: `Couldn't read project ${board.projectId}'s message board. Is \`basecamp\` logged in?` }
|
|
180
|
+
let content = markdownToBasecampHtml(body)
|
|
181
|
+
if (board.mention) {
|
|
182
|
+
// A mention's sgid is signed and expires, so it's resolved every time.
|
|
183
|
+
const people = await cliJson<{ name: string; attachable_sgid: string }[]>(run, ["people", "pingable"])
|
|
184
|
+
const sgid = people?.find((p) => p.name === board.mention)?.attachable_sgid
|
|
185
|
+
if (sgid) content = `<p><bc-attachment sgid="${escAttr(sgid)}" content-type="application/vnd.basecamp.mention"></bc-attachment> — new incident diagnosed and documented.</p>\n${content}`
|
|
186
|
+
}
|
|
187
|
+
const r = await run(["api", "post", `buckets/${board.projectId}/message_boards/${boardId}/messages.json`, "-d", JSON.stringify({ subject: title, status: "active", content }), "--json"])
|
|
188
|
+
if (r.code === 0) {
|
|
189
|
+
const d = (JSON.parse(r.out) as CliEnvelope<{ id: number; app_url: string }>).data
|
|
190
|
+
if (d?.id) return { ok: true, id: String(d.id), url: d.app_url }
|
|
191
|
+
}
|
|
192
|
+
return { ok: false, reason: "api", message: `Basecamp refused the post: ${r.out.slice(0, 300)}` }
|
|
193
|
+
} catch (err) {
|
|
194
|
+
const e = err as NodeJS.ErrnoException
|
|
195
|
+
return e.code === "ENOENT" ? { ok: false, reason: "cli_missing", message: "The basecamp CLI isn't on the PATH." } : { ok: false, reason: "api", message: e.message }
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { mkdtempSync } from "node:fs"
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { handleUpdate, listen, readListenerState, routesFrom, sendReply, storeButton, takeButton, type TgUpdate } from "./beaconListen.ts"
|
|
6
|
+
import { listenerHealth } from "./beaconListenInstall.ts"
|
|
6
7
|
import type { BeaconReply } from "../modules/contract.ts"
|
|
7
8
|
|
|
8
9
|
const TOKEN = "123456789:AAEabcdefghijklmnopqrstuvwxyz0123456"
|
|
@@ -159,3 +160,31 @@ describe("listen", () => {
|
|
|
159
160
|
await first
|
|
160
161
|
})
|
|
161
162
|
})
|
|
163
|
+
|
|
164
|
+
describe("listenerHealth", () => {
|
|
165
|
+
const install = (state: Record<string, string>) => {
|
|
166
|
+
const dir = join(process.env.XDG_CONFIG_HOME!, "spinupwp-tui")
|
|
167
|
+
mkdirSync(join(dir, "modules", "beacon"), { recursive: true })
|
|
168
|
+
const unitPath = join(dir, "unit.plist")
|
|
169
|
+
writeFileSync(unitPath, "")
|
|
170
|
+
writeFileSync(join(dir, "config.json"), JSON.stringify({ beacon: { listener: { label: "t", unitPath, bin: "x", channel: "pager", installedAt: "2026-10-30T12:00:00Z" } } }))
|
|
171
|
+
writeFileSync(join(dir, "modules", "beacon", "listener.json"), JSON.stringify(state))
|
|
172
|
+
}
|
|
173
|
+
const now = Date.parse("2026-10-30T12:00:00Z")
|
|
174
|
+
|
|
175
|
+
test("a listener whose first poll is still out is alive, until that takes too long", () => {
|
|
176
|
+
install({ startedAt: "2026-10-30T11:59:40Z" })
|
|
177
|
+
expect(listenerHealth(now)?.ok).toBe(true)
|
|
178
|
+
install({ startedAt: "2026-10-30T11:50:00Z" })
|
|
179
|
+
expect(listenerHealth(now)?.ok).toBe(false)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("stale polls and polls that keep failing are down", () => {
|
|
183
|
+
install({ startedAt: "2026-10-29T00:00:00Z", lastPollAt: "2026-10-30T11:59:30Z" })
|
|
184
|
+
expect(listenerHealth(now)?.ok).toBe(true)
|
|
185
|
+
install({ startedAt: "2026-10-29T00:00:00Z", lastPollAt: "2026-10-30T11:50:00Z" })
|
|
186
|
+
expect(listenerHealth(now)?.ok).toBe(false)
|
|
187
|
+
install({ startedAt: "2026-10-29T00:00:00Z", lastPollAt: "2026-10-30T11:59:30Z", failingSince: "2026-10-30T11:45:00Z", lastError: "Conflict" })
|
|
188
|
+
expect(listenerHealth(now)?.detail).toContain("Conflict")
|
|
189
|
+
})
|
|
190
|
+
})
|
package/src/lib/beaconListen.ts
CHANGED
|
@@ -21,9 +21,9 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSy
|
|
|
21
21
|
import { randomBytes } from "node:crypto"
|
|
22
22
|
import { dirname, join } from "node:path"
|
|
23
23
|
import type { BeaconChannel } from "../config.ts"
|
|
24
|
-
import { moduleStateDir, type BeaconActionDecl, type BeaconCommandDecl, type BeaconReply, type ModuleDefinition } from "../modules/contract.ts"
|
|
24
|
+
import { moduleStateDir, type BeaconActionDecl, type BeaconButton, type BeaconCommandDecl, type BeaconReply, type ModuleDefinition } from "../modules/contract.ts"
|
|
25
25
|
import { htmlToPlain, splitMessage, telegramCall, type Fetch } from "./beacon.ts"
|
|
26
|
-
import { tryLock } from "./flock.ts"
|
|
26
|
+
import { tryLock, type HeldLock } from "./flock.ts"
|
|
27
27
|
|
|
28
28
|
export const POLL_SECONDS = 50
|
|
29
29
|
// A /command older than this when it arrives (sent while the listener was down)
|
|
@@ -37,6 +37,7 @@ export const listenerStatePath = () => join(listenerDir(), "listener.json")
|
|
|
37
37
|
export const listenerLockPath = () => join(listenerDir(), "listener.lock")
|
|
38
38
|
export const listenerLogPath = () => join(listenerDir(), "listener.log")
|
|
39
39
|
const buttonsPath = () => join(listenerDir(), "buttons.json")
|
|
40
|
+
const buttonsLockPath = () => join(listenerDir(), "buttons.lock")
|
|
40
41
|
|
|
41
42
|
export interface ListenerState {
|
|
42
43
|
channel?: string
|
|
@@ -112,23 +113,47 @@ interface StoredButton {
|
|
|
112
113
|
createdAt: string
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
// The tick and Sentinel's worker store buttons while the listener takes them, so
|
|
117
|
+
// each read-modify-write holds this. It's held for a millisecond, and a button
|
|
118
|
+
// is worth more than the lock: past the wait, go ahead without it.
|
|
119
|
+
function withButtons<T>(fn: () => T): T {
|
|
120
|
+
let lock: HeldLock | null = null
|
|
121
|
+
for (let i = 0; i < 100 && !lock; i++) {
|
|
122
|
+
lock = tryLock(buttonsLockPath())
|
|
123
|
+
if (!lock) Bun.sleepSync(10)
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
return fn()
|
|
127
|
+
} finally {
|
|
128
|
+
lock?.release()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
115
132
|
export function storeButton(action: string, payload: Record<string, string>, now = new Date()): string {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
133
|
+
return withButtons(() => {
|
|
134
|
+
const all = readJson<Record<string, StoredButton>>(buttonsPath(), {})
|
|
135
|
+
for (const [k, b] of Object.entries(all)) if (now.getTime() - Date.parse(b.createdAt) > BUTTON_TTL_MS) delete all[k]
|
|
136
|
+
const token = randomBytes(6).toString("base64url")
|
|
137
|
+
all[token] = { action, payload, createdAt: now.toISOString() }
|
|
138
|
+
writeJson(buttonsPath(), all)
|
|
139
|
+
return token
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buttonMarkup(buttons: BeaconButton[][], now = new Date()): string {
|
|
144
|
+
return JSON.stringify({ inline_keyboard: buttons.map((row) => row.map((b) => ({ text: b.text, callback_data: `b:${storeButton(b.action, b.payload, now)}` }))) })
|
|
122
145
|
}
|
|
123
146
|
|
|
124
147
|
// The button for `token`, removed so it can't be used twice.
|
|
125
148
|
export function takeButton(token: string): StoredButton | null {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
149
|
+
return withButtons(() => {
|
|
150
|
+
const all = readJson<Record<string, StoredButton>>(buttonsPath(), {})
|
|
151
|
+
const b = all[token]
|
|
152
|
+
if (!b) return null
|
|
153
|
+
delete all[token]
|
|
154
|
+
writeJson(buttonsPath(), all)
|
|
155
|
+
return b
|
|
156
|
+
})
|
|
132
157
|
}
|
|
133
158
|
|
|
134
159
|
// ---- Telegram -----------------------------------------------------------------
|
|
@@ -168,9 +193,7 @@ export async function sendReply(ctx: ListenContext, reply: BeaconReply): Promise
|
|
|
168
193
|
for (let i = 0; i < parts.length; i++) {
|
|
169
194
|
const params: Record<string, string> = { chat_id: ctx.channel.chatId, text: parts[i]!, disable_web_page_preview: "true" }
|
|
170
195
|
if (i === parts.length - 1 && reply.buttons?.length) {
|
|
171
|
-
|
|
172
|
-
const keyboard = reply.buttons.map((row) => row.map((b) => ({ text: b.text, callback_data: `b:${storeButton(b.action, b.payload, now)}` })))
|
|
173
|
-
params.reply_markup = JSON.stringify({ inline_keyboard: keyboard })
|
|
196
|
+
params.reply_markup = buttonMarkup(reply.buttons, ctx.now?.() ?? new Date())
|
|
174
197
|
}
|
|
175
198
|
if (reply.html) params.parse_mode = "HTML"
|
|
176
199
|
let r = await telegramCall(ctx.channel.botToken, "sendMessage", params, ctx.fetchImpl)
|
|
@@ -9,8 +9,9 @@ import { homedir } from "node:os"
|
|
|
9
9
|
import { dirname, join } from "node:path"
|
|
10
10
|
import { loadConfig, saveConfig } from "../config.ts"
|
|
11
11
|
import { beaconFromConfig, beaconSend, pickChannel } from "./beacon.ts"
|
|
12
|
-
import { channelProblem, listenerDir, listenerLogPath, readListenerState } from "./beaconListen.ts"
|
|
12
|
+
import { buttonMarkup, channelProblem, listenerDir, listenerLogPath, readListenerState } from "./beaconListen.ts"
|
|
13
13
|
import { helpIncludes, launchctl, resolveRunner } from "./schedulerInstall.ts"
|
|
14
|
+
import type { BeaconButton } from "../modules/contract.ts"
|
|
14
15
|
|
|
15
16
|
export const DEFAULT_LISTENER_LABEL = "com.spinuptui.beacon-listener"
|
|
16
17
|
// Three missed polls, with room for a restart.
|
|
@@ -208,6 +209,12 @@ export function listenerInstalled(): boolean {
|
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
// Is the installed listener answering? null when none is installed.
|
|
212
|
+
// Inline buttons for a page. Only the listener can receive a tap, so with none
|
|
213
|
+
// installed a page goes out without them.
|
|
214
|
+
export function pageMarkup(buttons: BeaconButton[][]): string | undefined {
|
|
215
|
+
return listenerInstalled() ? buttonMarkup(buttons) : undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
211
218
|
export function listenerHealth(now = Date.now()): { ok: boolean; detail: string } | null {
|
|
212
219
|
if (!listenerInstalled()) return null
|
|
213
220
|
try {
|
|
@@ -217,14 +224,16 @@ export function listenerHealth(now = Date.now()): { ok: boolean; detail: string
|
|
|
217
224
|
// Not paused.
|
|
218
225
|
}
|
|
219
226
|
const st = readListenerState()
|
|
220
|
-
|
|
221
|
-
|
|
227
|
+
// A listener that just started has a first poll still out (up to 50 seconds),
|
|
228
|
+
// so its start counts as a sign of life too.
|
|
229
|
+
const last = Math.max(...[st.lastPollAt, st.startedAt].map((t) => (t ? Date.parse(t) : 0)))
|
|
230
|
+
if (now - last > LISTENER_STALE_MS) {
|
|
222
231
|
return { ok: false, detail: `hasn't polled Telegram since ${st.lastPollAt ?? "it was installed"}; see ${listenerLogPath()}` }
|
|
223
232
|
}
|
|
224
233
|
if (st.failingSince && now - Date.parse(st.failingSince) > LISTENER_FAILING_MS) {
|
|
225
234
|
return { ok: false, detail: `polls failing since ${st.failingSince}: ${st.lastError ?? "unknown error"}` }
|
|
226
235
|
}
|
|
227
|
-
return { ok: true, detail: `listening on ${st.channel ?? "?"} (pid ${st.pid ?? "?"}, ${st.version ?? "?"}), last poll ${st.lastPollAt}` }
|
|
236
|
+
return { ok: true, detail: `listening on ${st.channel ?? "?"} (pid ${st.pid ?? "?"}, ${st.version ?? "?"}), ${st.lastPollAt ? `last poll ${st.lastPollAt}` : `started ${st.startedAt}`}` }
|
|
228
237
|
}
|
|
229
238
|
|
|
230
239
|
// Telegram hands a bot's updates to one reader at a time, so Beacon setup's chat
|