spinuptui 1.1.1 → 1.2.0-alpha.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/CHANGELOG.md +21 -1
- package/README.md +8 -1
- package/package.json +1 -1
- package/src/config.ts +23 -0
- package/src/index.tsx +20 -0
- package/src/lib/appUpdate.test.ts +26 -0
- package/src/lib/appUpdate.ts +33 -7
- package/src/lib/beacon.test.ts +140 -0
- package/src/lib/beacon.ts +349 -0
- package/src/lib/beaconSetup.test.ts +65 -0
- package/src/lib/beaconSetup.ts +143 -0
- package/src/lib/cliBeacon.ts +290 -0
- package/src/lib/prompt.ts +47 -0
- package/src/lib/sshExecPolicy.ts +3 -1
- package/src/modules/beacon.ts +51 -0
- package/src/modules/contract.ts +61 -0
- package/src/modules/index.ts +34 -0
- package/src/ui/App.tsx +11 -1
- package/src/ui/Explain.tsx +15 -2
- package/src/ui/Header.tsx +2 -0
- package/src/ui/Help.tsx +1 -1
- package/src/ui/store.tsx +7 -1
- package/src/ui/views/BeaconSetup.tsx +268 -0
- package/src/ui/views/KumaSite.tsx +3 -3
- package/src/ui/views/Modules.tsx +196 -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.2.0-alpha.1] - 2026-10-17
|
|
15
|
+
|
|
16
|
+
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.1.2. The stable 1.2.0 will roll these notes up with the next modules.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Beacon: `spinuptui beacon send` pages you on Telegram.** One command any script, cron job or agent can use instead of its own `curl` to the Telegram API. Messages go out as plain text with link previews off; anything over Telegram's 4096-character limit is split into numbered parts instead of being rejected; sends are retried (honoring Telegram's rate-limit hints) and logged. Exit codes tell a caller what happened: `0` delivered, `1` not configured (nothing attempted), `2` delivery failed. No Telegram bot yet? `spinuptui beacon setup` walks you through making one with @BotFather, checks the token, and finds your chat by itself once you message the bot. No digging for a chat ID. It saves nothing until a test message has arrived. Scripts can use `spinuptui beacon add telegram <name> --env-file <path>` instead. `beacon test`, `beacon status` and `beacon remove` round it out. See [docs/beacon.md](docs/beacon.md).
|
|
20
|
+
- **A Modules tab (`6`).** Everything SpinupTUI can keep doing after you close it, with its status on this machine and set up from the same place: select Beacon and press `⏎` for the guided Telegram setup, or `t` for a test page. Modules still on the way (Scheduler, Sentinel, Samurai) are listed as coming soon.
|
|
21
|
+
- **`spinuptui modules`** lists SpinupTUI's modules (the features that keep working after the TUI is closed) and their status. Beacon is the first; the module contract is in [docs/2026-10-16_beacon-and-module-contract-spec.md](docs/2026-10-16_beacon-and-module-contract-spec.md).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **The update hint understands pre-releases.** Someone running a pre-release (say `1.2.0-alpha.1`) is now told when the matching stable release ships; before, the two counted as the same version.
|
|
25
|
+
|
|
26
|
+
## [1.1.2] - 2026-10-15
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **`spinuptui ssh-exec` no longer refuses a quoted `wp` command that ends in a flag.** `bash -c 'wp --info'` was denied because the closing quote landed on the flag and looked like an unfinished value.
|
|
30
|
+
- **Uptime Kuma connection errors now show the actual reason.** A long instance URL pushed the cause (TLS, DNS, proxy…) past the edge of the connect form, leaving just `Couldn't reach Uptime Kuma at https://…:`. The connect form's and the Alerts panel's error lines now wrap.
|
|
31
|
+
|
|
14
32
|
## [1.1.1] - 2026-10-01
|
|
15
33
|
|
|
16
34
|
### Changed
|
|
@@ -1479,7 +1497,9 @@ Initial tagged release.
|
|
|
1479
1497
|
### Notes
|
|
1480
1498
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1481
1499
|
|
|
1482
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.
|
|
1500
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0-alpha.1...HEAD
|
|
1501
|
+
[1.2.0-alpha.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0-alpha.1
|
|
1502
|
+
[1.1.2]: https://github.com/mwender/spinupwp-tui/compare/v1.1.1...v1.1.2
|
|
1483
1503
|
[1.1.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...v1.1.1
|
|
1484
1504
|
[1.1.0]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...v1.1.0
|
|
1485
1505
|
[1.0.0]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...v1.0.0
|
package/README.md
CHANGED
|
@@ -76,6 +76,11 @@
|
|
|
76
76
|
register/recalibrate/remove it — plus a cache/outage doctor and alert
|
|
77
77
|
wiring. → [docs/site-monitoring.md](docs/site-monitoring.md) ·
|
|
78
78
|
[docs/uptime-kuma.md](docs/uptime-kuma.md)
|
|
79
|
+
- **Modules** (`6`, pre-release: `bun add -g spinuptui@next --no-cache`) — the parts of SpinupTUI that keep working after you close it,
|
|
80
|
+
each set up from the same screen. First up, **Beacon**: `spinuptui beacon send`
|
|
81
|
+
pages you on Telegram from any cron job, watcher or agent, with retries, long
|
|
82
|
+
messages split instead of dropped, and a send log. A guided setup creates the
|
|
83
|
+
bot connection for you. → [docs/beacon.md](docs/beacon.md)
|
|
79
84
|
- **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
|
|
80
85
|
resolves a domain to a live SSH target with a live connectivity probe;
|
|
81
86
|
`spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
|
|
@@ -333,7 +338,7 @@ These can be set in `config.json` or via an environment variable:
|
|
|
333
338
|
|
|
334
339
|
| Key | Action |
|
|
335
340
|
| --- | --- |
|
|
336
|
-
| `1`…`
|
|
341
|
+
| `1`…`6` | Switch tabs: Dashboard · Servers · Stacks · Search · Events · Modules |
|
|
337
342
|
| `↑`/`↓` or `j`/`k` | Move selection |
|
|
338
343
|
| `Enter` / `→` | Drill in (server → its sites) |
|
|
339
344
|
| `←` / `Esc` | Go back / collapse |
|
|
@@ -369,6 +374,8 @@ These can be set in `config.json` or via an environment variable:
|
|
|
369
374
|
| `w` | Open the selected server/site in the SpinupWP web app |
|
|
370
375
|
| `/` | Jump to global search |
|
|
371
376
|
| `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
|
|
377
|
+
| `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup (Modules tab) |
|
|
378
|
+
| `t` | Send a test page through Beacon (Modules tab, once Beacon is set up) |
|
|
372
379
|
| `r` | Refresh data from the API |
|
|
373
380
|
| `i` | Explain the current screen (what each pane and key does) |
|
|
374
381
|
| `?` | Toggle the help overlay |
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -103,6 +103,27 @@ export interface AppConfig {
|
|
|
103
103
|
// running version with no notes shown — nothing meaningful to announce). A
|
|
104
104
|
// mismatch against the running version is what triggers the overlay.
|
|
105
105
|
lastSeenVersion: string | null
|
|
106
|
+
// Beacon's notification channels (see lib/beacon.ts and
|
|
107
|
+
// docs/2026-10-16_beacon-and-module-contract-spec.md). Machine-level like the
|
|
108
|
+
// Kuma login: one operator's pager serves every account. Holds bot tokens, so
|
|
109
|
+
// it relies on the file's 600 mode. The env channel (SPINUPTUI_BEACON_*) is
|
|
110
|
+
// resolved in lib/beacon.ts, not here, so this is only what's stored.
|
|
111
|
+
beacon: BeaconStored | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface BeaconTelegramChannel {
|
|
115
|
+
type: "telegram"
|
|
116
|
+
botToken: string
|
|
117
|
+
chatId: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A union so later transports (Slack, ntfy, a client-facing Basecamp channel)
|
|
121
|
+
// are additive.
|
|
122
|
+
export type BeaconChannel = BeaconTelegramChannel
|
|
123
|
+
|
|
124
|
+
export interface BeaconStored {
|
|
125
|
+
defaultChannel?: string
|
|
126
|
+
channels?: Record<string, BeaconChannel>
|
|
106
127
|
}
|
|
107
128
|
|
|
108
129
|
export interface ServerProviderRef {
|
|
@@ -202,6 +223,7 @@ export interface StoredConfig {
|
|
|
202
223
|
uptimeKuma?: UptimeKumaConn
|
|
203
224
|
kumaMonitors?: Record<string, KumaMonitorRef>
|
|
204
225
|
lastSeenVersion?: string
|
|
226
|
+
beacon?: BeaconStored
|
|
205
227
|
}
|
|
206
228
|
|
|
207
229
|
// ---- Accounts (profiles) -------------------------------------------------------
|
|
@@ -487,6 +509,7 @@ export function loadConfig(): AppConfig {
|
|
|
487
509
|
})(),
|
|
488
510
|
kumaMonitors: stored.kumaMonitors ?? {},
|
|
489
511
|
lastSeenVersion: stored.lastSeenVersion?.trim() || null,
|
|
512
|
+
beacon: stored.beacon ?? null,
|
|
490
513
|
}
|
|
491
514
|
}
|
|
492
515
|
|
package/src/index.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import { resolveIncidents } from "./lib/cliIncidents.ts"
|
|
|
23
23
|
import { resolveSiblings } from "./lib/cliSiblings.ts"
|
|
24
24
|
import { execSshCommand } from "./lib/sshExec.ts"
|
|
25
25
|
import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
|
|
26
|
+
import { runBeacon, runModules } from "./lib/cliBeacon.ts"
|
|
26
27
|
|
|
27
28
|
const args = process.argv.slice(2)
|
|
28
29
|
const positionals = args.filter((a) => !a.startsWith("-"))
|
|
@@ -89,6 +90,17 @@ Usage:
|
|
|
89
90
|
Import production's database into the linked local copy,
|
|
90
91
|
rewriting URLs. OVERWRITES your local database: needs
|
|
91
92
|
both --yes and "localSync": true in the config.
|
|
93
|
+
spinuptui beacon setup Guided Telegram setup: make a bot, find your chat,
|
|
94
|
+
send a test (interactive; also in the app's Modules tab, 6)
|
|
95
|
+
spinuptui beacon send [--to <channel>] [--silent] [<message> | -]
|
|
96
|
+
Page yourself through Beacon (Telegram). Reads the
|
|
97
|
+
message from stdin when it's \`-\` or absent. Exit 0 sent,
|
|
98
|
+
1 not configured / usage, 2 delivery failed.
|
|
99
|
+
spinuptui beacon add telegram <name> --env-file <path> [--default]
|
|
100
|
+
Add a channel from a file holding TELEGRAM_BOT_TOKEN and
|
|
101
|
+
TELEGRAM_CHAT_ID; sends a test message before saving.
|
|
102
|
+
spinuptui beacon test | status | remove <name>
|
|
103
|
+
spinuptui modules List SpinupTUI's modules and their status (--json)
|
|
92
104
|
spinuptui --version Print the version
|
|
93
105
|
spinuptui --help Show this help
|
|
94
106
|
|
|
@@ -272,6 +284,14 @@ if (command === "pull") {
|
|
|
272
284
|
process.exit(result.ok ? 0 : 1)
|
|
273
285
|
}
|
|
274
286
|
|
|
287
|
+
if (command === "beacon") {
|
|
288
|
+
process.exit(await runBeacon(args.slice(args.indexOf("beacon") + 1)))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (command === "modules") {
|
|
292
|
+
process.exit(await runModules(args.slice(1)))
|
|
293
|
+
}
|
|
294
|
+
|
|
275
295
|
// `spinuptui login` forces the onboarding wizard even when a token already exists,
|
|
276
296
|
// so the token can be (re)saved to the config file for global use.
|
|
277
297
|
const forceLogin = command === "login"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { expect, test } from "bun:test"
|
|
2
|
+
import { isNewer } from "./appUpdate.ts"
|
|
3
|
+
|
|
4
|
+
test("plain versions", () => {
|
|
5
|
+
expect(isNewer("1.2.0", "1.1.2")).toBe(true)
|
|
6
|
+
expect(isNewer("1.1.2", "1.2.0")).toBe(false)
|
|
7
|
+
expect(isNewer("1.10.0", "1.9.9")).toBe(true)
|
|
8
|
+
expect(isNewer("1.2.0", "1.2.0")).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("a release outranks its pre-releases", () => {
|
|
12
|
+
expect(isNewer("1.2.0", "1.2.0-alpha.1")).toBe(true)
|
|
13
|
+
expect(isNewer("1.2.0-alpha.1", "1.2.0")).toBe(false)
|
|
14
|
+
expect(isNewer("1.2.0-alpha.1", "1.1.2")).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("pre-release ordering", () => {
|
|
18
|
+
expect(isNewer("1.2.0-alpha.2", "1.2.0-alpha.1")).toBe(true)
|
|
19
|
+
expect(isNewer("1.2.0-alpha.10", "1.2.0-alpha.9")).toBe(true)
|
|
20
|
+
expect(isNewer("1.2.0-beta.1", "1.2.0-alpha.5")).toBe(true)
|
|
21
|
+
expect(isNewer("1.2.0-alpha.1", "1.2.0-alpha")).toBe(true)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("unparseable never nags", () => {
|
|
25
|
+
expect(isNewer("garbage", "1.0.0")).toBe(false)
|
|
26
|
+
})
|
package/src/lib/appUpdate.ts
CHANGED
|
@@ -35,21 +35,47 @@ function cachePath(): string {
|
|
|
35
35
|
return join(configDir(), "update-check.json")
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
// Parse "v1.2.3" / "1.2.3"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
// Parse "v1.2.3" / "1.2.3" / "1.2.3-alpha.1" → numbers + pre-release tag; null
|
|
39
|
+
// when it doesn't look like a version.
|
|
40
|
+
function parseVersion(s: string): { nums: [number, number, number]; pre: string | null } | null {
|
|
41
|
+
const m = s.trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
|
|
42
|
+
return m ? { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null } : null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// SemVer pre-release precedence: dot-separated identifiers compared left to
|
|
46
|
+
// right, numeric ones numerically and below alphanumeric ones; a shorter set of
|
|
47
|
+
// otherwise-equal identifiers sorts first.
|
|
48
|
+
function comparePre(a: string, b: string): number {
|
|
49
|
+
const x = a.split(".")
|
|
50
|
+
const y = b.split(".")
|
|
51
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
52
|
+
if (x[i] === undefined) return -1
|
|
53
|
+
if (y[i] === undefined) return 1
|
|
54
|
+
const nx = /^\d+$/.test(x[i]!)
|
|
55
|
+
const ny = /^\d+$/.test(y[i]!)
|
|
56
|
+
if (nx && ny) {
|
|
57
|
+
const d = Number(x[i]) - Number(y[i])
|
|
58
|
+
if (d) return d
|
|
59
|
+
} else if (nx !== ny) return nx ? -1 : 1
|
|
60
|
+
else if (x[i] !== y[i]) return x[i]! < y[i]! ? -1 : 1
|
|
61
|
+
}
|
|
62
|
+
return 0
|
|
42
63
|
}
|
|
43
64
|
|
|
44
65
|
// Strictly-newer comparison (latest > current). Unparseable → false (never nag).
|
|
45
|
-
|
|
66
|
+
// A release outranks its own pre-releases, so someone on 1.2.0-alpha.1 is told
|
|
67
|
+
// when 1.2.0 ships.
|
|
68
|
+
export function isNewer(latest: string, current: string): boolean {
|
|
46
69
|
const a = parseVersion(latest)
|
|
47
70
|
const b = parseVersion(current)
|
|
48
71
|
if (!a || !b) return false
|
|
49
72
|
for (let i = 0; i < 3; i++) {
|
|
50
|
-
if (a[i] !== b[i]) return a[i] > b[i]
|
|
73
|
+
if (a.nums[i] !== b.nums[i]) return a.nums[i]! > b.nums[i]!
|
|
51
74
|
}
|
|
52
|
-
return false
|
|
75
|
+
if (a.pre === b.pre) return false
|
|
76
|
+
if (a.pre === null) return true
|
|
77
|
+
if (b.pre === null) return false
|
|
78
|
+
return comparePre(a.pre, b.pre) > 0
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
function readCache(): CacheFile | null {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { beaconSend, MAX_PARTS, maskToken, parseEnvFile, resolveBeacon, splitMessage, TELEGRAM_LIMIT, type ResolvedBeacon } from "./beacon.ts"
|
|
3
|
+
|
|
4
|
+
const pager: ResolvedBeacon = {
|
|
5
|
+
channels: [{ name: "pager", channel: { type: "telegram", botToken: "123:SECRET", chatId: "-1001" }, env: false }],
|
|
6
|
+
defaultChannel: "pager",
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fakeFetch(responses: Array<{ status: number; body: unknown } | Error>) {
|
|
10
|
+
const calls: Array<{ url: string; params: URLSearchParams }> = []
|
|
11
|
+
const impl = async (url: string, init?: RequestInit) => {
|
|
12
|
+
calls.push({ url, params: init?.body as URLSearchParams })
|
|
13
|
+
const next = responses.shift() ?? { status: 200, body: { ok: true, result: {} } }
|
|
14
|
+
if (next instanceof Error) throw next
|
|
15
|
+
return new Response(JSON.stringify(next.body), { status: next.status })
|
|
16
|
+
}
|
|
17
|
+
return { impl, calls }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const noWait = async () => {}
|
|
21
|
+
|
|
22
|
+
describe("resolveBeacon", () => {
|
|
23
|
+
test("a lone stored channel is the default", () => {
|
|
24
|
+
const r = resolveBeacon({ channels: { pager: { type: "telegram", botToken: "1:a", chatId: "2" } } }, {})
|
|
25
|
+
expect(r.defaultChannel).toBe("pager")
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("env channel is added and becomes default unless one is chosen", () => {
|
|
29
|
+
const env = { SPINUPTUI_BEACON_TELEGRAM_TOKEN: "9:z", SPINUPTUI_BEACON_TELEGRAM_CHAT_ID: "5" }
|
|
30
|
+
const stored = { channels: { pager: { type: "telegram" as const, botToken: "1:a", chatId: "2" } } }
|
|
31
|
+
expect(resolveBeacon(stored, env).defaultChannel).toBe("env")
|
|
32
|
+
expect(resolveBeacon({ ...stored, defaultChannel: "pager" }, env).defaultChannel).toBe("pager")
|
|
33
|
+
expect(resolveBeacon(stored, env).channels.find((c) => c.name === "env")?.env).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("two stored channels and no choice means no default", () => {
|
|
37
|
+
const ch = { type: "telegram" as const, botToken: "1:a", chatId: "2" }
|
|
38
|
+
expect(resolveBeacon({ channels: { a: ch, b: ch } }, {}).defaultChannel).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe("splitMessage", () => {
|
|
43
|
+
test("short text is untouched", () => {
|
|
44
|
+
expect(splitMessage("hello")).toEqual(["hello"])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("long text splits on lines, numbered, each under the limit", () => {
|
|
48
|
+
const text = Array.from({ length: 300 }, (_, i) => `line ${i} ${"x".repeat(40)}`).join("\n")
|
|
49
|
+
const parts = splitMessage(text)
|
|
50
|
+
expect(parts.length).toBeGreaterThan(1)
|
|
51
|
+
parts.forEach((p, i) => {
|
|
52
|
+
expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT)
|
|
53
|
+
expect(p.startsWith(`(${i + 1}/${parts.length}) `)).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
// Nothing lost or reordered.
|
|
56
|
+
expect(parts.map((p) => p.replace(/^\(\d+\/\d+\) /, "")).join("\n")).toBe(text)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("a single huge line is hard-split", () => {
|
|
60
|
+
const parts = splitMessage("y".repeat(9000))
|
|
61
|
+
expect(parts.length).toBe(3)
|
|
62
|
+
parts.forEach((p) => expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("beyond the part cap the last part is truncated with a marker", () => {
|
|
66
|
+
const parts = splitMessage("z".repeat(4000 * 8))
|
|
67
|
+
expect(parts.length).toBe(MAX_PARTS)
|
|
68
|
+
expect(parts[MAX_PARTS - 1]).toContain("[truncated:")
|
|
69
|
+
parts.forEach((p) => expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT))
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe("parseEnvFile", () => {
|
|
74
|
+
test("handles export, quotes and comments", () => {
|
|
75
|
+
const env = parseEnvFile(`# creds\nexport TELEGRAM_BOT_TOKEN="1:abc"\nTELEGRAM_CHAT_ID='-100' \nOTHER=x # note\n`)
|
|
76
|
+
expect(env).toEqual({ TELEGRAM_BOT_TOKEN: "1:abc", TELEGRAM_CHAT_ID: "-100", OTHER: "x" })
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("maskToken keeps only the bot id", () => {
|
|
81
|
+
expect(maskToken("123456:AAE-secret")).toBe("123456:…")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe("beaconSend", () => {
|
|
85
|
+
test("sends plain text with previews off", async () => {
|
|
86
|
+
const f = fakeFetch([])
|
|
87
|
+
const r = await beaconSend(pager, "site down", { fetchImpl: f.impl, wait: noWait, log: false })
|
|
88
|
+
expect(r).toEqual({ ok: true, channel: "pager", parts: 1, chars: 9 })
|
|
89
|
+
expect(f.calls[0]!.params.get("disable_web_page_preview")).toBe("true")
|
|
90
|
+
expect(f.calls[0]!.params.has("parse_mode")).toBe(false)
|
|
91
|
+
expect(f.calls[0]!.params.has("disable_notification")).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("--silent sets disable_notification", async () => {
|
|
95
|
+
const f = fakeFetch([])
|
|
96
|
+
await beaconSend(pager, "digest", { silent: true, fetchImpl: f.impl, wait: noWait, log: false })
|
|
97
|
+
expect(f.calls[0]!.params.get("disable_notification")).toBe("true")
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test("retries a 429 and a network error, then succeeds", async () => {
|
|
101
|
+
const f = fakeFetch([{ status: 429, body: { ok: false, parameters: { retry_after: 1 } } }, new Error("socket hang up")])
|
|
102
|
+
const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
|
|
103
|
+
expect(r.ok).toBe(true)
|
|
104
|
+
expect(f.calls.length).toBe(3)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("a 400 is not retried and is a delivery failure", async () => {
|
|
108
|
+
const f = fakeFetch([{ status: 400, body: { ok: false, description: "Bad Request: chat not found" } }])
|
|
109
|
+
const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
|
|
110
|
+
expect(f.calls.length).toBe(1)
|
|
111
|
+
expect(r.ok).toBe(false)
|
|
112
|
+
if (!r.ok) {
|
|
113
|
+
expect(r.reason).toBe("delivery_failed")
|
|
114
|
+
expect(r.message).toContain("chat not found")
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("network errors never leak the token", async () => {
|
|
119
|
+
const f = fakeFetch([new Error("fail https://api.telegram.org/bot123:SECRET/sendMessage"), new Error("x"), new Error("x")])
|
|
120
|
+
const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
|
|
121
|
+
expect(r.ok).toBe(false)
|
|
122
|
+
if (!r.ok) expect(r.message).not.toContain("SECRET")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("not configured and empty messages attempt nothing", async () => {
|
|
126
|
+
const f = fakeFetch([])
|
|
127
|
+
const none = await beaconSend({ channels: [], defaultChannel: null }, "x", { fetchImpl: f.impl, log: false })
|
|
128
|
+
const empty = await beaconSend(pager, " ", { fetchImpl: f.impl, log: false })
|
|
129
|
+
expect(!none.ok && none.reason).toBe("not_configured")
|
|
130
|
+
expect(!empty.ok && empty.reason).toBe("empty_message")
|
|
131
|
+
expect(f.calls.length).toBe(0)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("a multi-part send stops at the first failed part", async () => {
|
|
135
|
+
const f = fakeFetch([{ status: 200, body: { ok: true } }, { status: 403, body: { ok: false, description: "Forbidden" } }])
|
|
136
|
+
const r = await beaconSend(pager, "q".repeat(9000), { fetchImpl: f.impl, wait: noWait, log: false })
|
|
137
|
+
expect(!r.ok && r.partsSent).toBe(1)
|
|
138
|
+
expect(f.calls.length).toBe(2)
|
|
139
|
+
})
|
|
140
|
+
})
|