spinuptui 1.3.0-alpha.3 → 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.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +12 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +32 -0
  5. package/src/index.tsx +11 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +14 -1
  8. package/src/lib/beaconBoard.test.ts +69 -0
  9. package/src/lib/beaconBoard.ts +197 -0
  10. package/src/lib/beaconListen.test.ts +190 -0
  11. package/src/lib/beaconListen.ts +355 -0
  12. package/src/lib/beaconListenInstall.ts +251 -0
  13. package/src/lib/cliBeacon.ts +115 -3
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliScheduler.ts +30 -1
  16. package/src/lib/cliSentinel.ts +451 -0
  17. package/src/lib/heartbeat.ts +5 -0
  18. package/src/lib/schedule.ts +4 -1
  19. package/src/lib/scheduler.test.ts +189 -1
  20. package/src/lib/schedulerExtend.ts +105 -0
  21. package/src/lib/schedulerInstall.ts +36 -15
  22. package/src/lib/schedulerJobs.ts +4 -2
  23. package/src/lib/schedulerRun.ts +61 -15
  24. package/src/lib/schedulerTick.ts +35 -13
  25. package/src/lib/sentinelAttach.test.ts +45 -0
  26. package/src/lib/sentinelAttach.ts +98 -0
  27. package/src/lib/sentinelDigest.test.ts +82 -0
  28. package/src/lib/sentinelDigest.ts +261 -0
  29. package/src/lib/sentinelExpect.test.ts +82 -0
  30. package/src/lib/sentinelExpect.ts +197 -0
  31. package/src/lib/sentinelIncidents.test.ts +145 -0
  32. package/src/lib/sentinelIncidents.ts +194 -0
  33. package/src/lib/sentinelPrompt.ts +89 -0
  34. package/src/lib/sentinelReply.ts +36 -0
  35. package/src/lib/sentinelState.ts +246 -0
  36. package/src/lib/sentinelTick.test.ts +214 -0
  37. package/src/lib/sentinelTick.ts +265 -0
  38. package/src/lib/sentinelWorker.test.ts +170 -0
  39. package/src/lib/sentinelWorker.ts +291 -0
  40. package/src/modules/beacon.ts +9 -1
  41. package/src/modules/contract.ts +34 -0
  42. package/src/modules/index.ts +2 -7
  43. package/src/modules/scheduler.ts +147 -6
  44. package/src/modules/sentinel.ts +90 -0
  45. package/src/ui/App.tsx +8 -0
  46. package/src/ui/store.tsx +11 -0
  47. package/src/ui/views/BeaconSetup.tsx +11 -7
  48. package/src/ui/views/ListenerSetup.tsx +205 -0
  49. package/src/ui/views/Modules.tsx +70 -5
  50. package/src/ui/views/SentinelSetup.tsx +171 -0
@@ -0,0 +1,190 @@
1
+ import { beforeEach, describe, expect, test } from "bun:test"
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { handleUpdate, listen, readListenerState, routesFrom, sendReply, storeButton, takeButton, type TgUpdate } from "./beaconListen.ts"
6
+ import { listenerHealth } from "./beaconListenInstall.ts"
7
+ import type { BeaconReply } from "../modules/contract.ts"
8
+
9
+ const TOKEN = "123456789:AAEabcdefghijklmnopqrstuvwxyz0123456"
10
+ const OP = 4242
11
+ const channel = { type: "telegram" as const, botToken: TOKEN, chatId: String(OP) }
12
+ const NOW = new Date("2026-10-29T12:00:00Z")
13
+ const sec = (d: Date) => Math.floor(d.getTime() / 1000)
14
+
15
+ beforeEach(() => {
16
+ process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "listen-"))
17
+ })
18
+
19
+ // Records every Telegram call; getUpdates answers from `rounds`, then aborts.
20
+ // An empty round waits a moment, like a real long-poll, so a loop that never
21
+ // stops can't starve the test's timers.
22
+ function fakeTelegram(rounds: TgUpdate[][] = [], onEmpty?: () => void) {
23
+ const calls: { method: string; params: URLSearchParams }[] = []
24
+ const impl = async (url: string, init?: RequestInit) => {
25
+ const method = url.split("/").pop()!
26
+ calls.push({ method, params: init?.body as URLSearchParams })
27
+ if (method === "getUpdates") {
28
+ const next = rounds.shift()
29
+ if (!next) {
30
+ onEmpty?.()
31
+ await Bun.sleep(5)
32
+ }
33
+ return new Response(JSON.stringify({ ok: true, result: next ?? [] }))
34
+ }
35
+ return new Response(JSON.stringify({ ok: true, result: { message_id: 1 } }))
36
+ }
37
+ return { impl, calls, sent: () => calls.filter((c) => c.method === "sendMessage").map((c) => c.params.get("text")!) }
38
+ }
39
+
40
+ const message = (id: number, text: string, from = OP, chat = OP, date = NOW): TgUpdate => ({
41
+ update_id: id,
42
+ message: { message_id: id, date: sec(date), chat: { id: chat, type: chat > 0 ? "private" : "group" }, from: { id: from }, text },
43
+ })
44
+
45
+ function routes(runs: string[] = []) {
46
+ return routesFrom([
47
+ {
48
+ commands: [{ command: "jobs", describe: "list jobs", run: async (): Promise<BeaconReply> => ({ text: "the jobs", buttons: [[{ text: "Extend", action: "t.extend", payload: { id: "a" } }]] }) }],
49
+ actions: [{ action: "t.extend", run: async (p) => (runs.push(p.id!), { text: `extended ${p.id}` }) }],
50
+ },
51
+ ])
52
+ }
53
+
54
+ describe("handleUpdate", () => {
55
+ test("answers the operator's command, with buttons as one-use tokens", async () => {
56
+ const tg = fakeTelegram()
57
+ const h = await handleUpdate(message(1, "/jobs@station_bot"), { channel, routes: routes(), fetchImpl: tg.impl, now: () => NOW })
58
+ expect(h).toEqual({ kind: "command", command: "jobs", replied: null })
59
+ expect(tg.sent()).toEqual(["the jobs"])
60
+ const markup = JSON.parse(tg.calls[0]!.params.get("reply_markup")!)
61
+ const data: string = markup.inline_keyboard[0][0].callback_data
62
+ expect(data).toMatch(/^b:[\w-]{8}$/)
63
+ expect(takeButton(data.slice(2))).toMatchObject({ action: "t.extend", payload: { id: "a" } })
64
+ expect(takeButton(data.slice(2))).toBeNull()
65
+ })
66
+
67
+ test("ignores strangers, groups and stale commands without replying", async () => {
68
+ const tg = fakeTelegram()
69
+ const ctx = { channel, routes: routes(), fetchImpl: tg.impl, now: () => NOW }
70
+ expect((await handleUpdate(message(1, "/jobs", 999, 999), ctx)).kind).toBe("ignored")
71
+ expect((await handleUpdate(message(2, "/jobs", OP, -100), ctx)).kind).toBe("ignored")
72
+ // The operator, but in someone else's chat with the bot? Not possible in a
73
+ // private chat, but a forged pairing is still refused.
74
+ expect((await handleUpdate(message(3, "/jobs", 999, OP), ctx)).kind).toBe("ignored")
75
+ expect((await handleUpdate(message(4, "/jobs", OP, OP, new Date(NOW.getTime() - 11 * 60_000)), ctx)).kind).toBe("ignored")
76
+ expect(tg.calls).toEqual([])
77
+ })
78
+
79
+ test("unknown commands and plain text get the help list", async () => {
80
+ const tg = fakeTelegram()
81
+ const ctx = { channel, routes: routes(), fetchImpl: tg.impl, now: () => NOW }
82
+ await handleUpdate(message(1, "/nope"), ctx)
83
+ await handleUpdate(message(2, "hello"), ctx)
84
+ expect(tg.sent()).toEqual(["This station answers:\n/jobs — list jobs", "This station answers:\n/jobs — list jobs"])
85
+ })
86
+
87
+ test("a tap runs its action once; a second tap is told the button is used", async () => {
88
+ const runs: string[] = []
89
+ const tg = fakeTelegram()
90
+ const ctx = { channel, routes: routes(runs), fetchImpl: tg.impl, now: () => NOW }
91
+ const token = storeButton("t.extend", { id: "nightly" })
92
+ const tap = (id: number, from = OP): TgUpdate => ({
93
+ update_id: id,
94
+ callback_query: { id: `q${id}`, from: { id: from }, message: { message_id: 9, date: sec(NOW), chat: { id: OP, type: "private" } }, data: `b:${token}` },
95
+ })
96
+ expect((await handleUpdate(tap(1, 999), ctx)).kind).toBe("ignored")
97
+ expect(await handleUpdate(tap(2), ctx)).toEqual({ kind: "tap", action: "t.extend", replied: null })
98
+ expect(await handleUpdate(tap(3), ctx)).toEqual({ kind: "tap", action: null, replied: null })
99
+ expect(runs).toEqual(["nightly"])
100
+ expect(tg.sent()).toEqual(["extended nightly"])
101
+ const answers = tg.calls.filter((c) => c.method === "answerCallbackQuery")
102
+ expect(answers.map((a) => a.params.get("text"))).toEqual([null, "This button has already been used or has expired."])
103
+ })
104
+
105
+ test("an action that throws is reported, not fatal", async () => {
106
+ const tg = fakeTelegram()
107
+ const r = routesFrom([{ commands: [{ command: "boom", describe: "x", run: async () => Promise.reject(new Error("disk full")) }] }])
108
+ await handleUpdate(message(1, "/boom"), { channel, routes: r, fetchImpl: tg.impl, now: () => NOW })
109
+ expect(tg.sent()).toEqual(["/boom failed: disk full"])
110
+ })
111
+ })
112
+
113
+ describe("sendReply", () => {
114
+ test("sends HTML, and falls back to plain text if Telegram refuses it", async () => {
115
+ const calls: URLSearchParams[] = []
116
+ const impl = async (_url: string, init?: RequestInit) => {
117
+ const p = init?.body as URLSearchParams
118
+ calls.push(p)
119
+ if (p.get("parse_mode")) return new Response(JSON.stringify({ ok: false, description: "can't parse entities" }), { status: 400 })
120
+ return new Response(JSON.stringify({ ok: true, result: {} }))
121
+ }
122
+ expect(await sendReply({ channel, routes: routes(), fetchImpl: impl }, { text: "<b>a &amp; b</b>", html: true })).toBeNull()
123
+ expect(calls[0]!.get("parse_mode")).toBe("HTML")
124
+ expect(calls[1]!.get("parse_mode")).toBeNull()
125
+ expect(calls[1]!.get("text")).toBe("a & b")
126
+ })
127
+ })
128
+
129
+ describe("listen", () => {
130
+ test("saves the offset before acting, so a restart doesn't repeat an update", async () => {
131
+ const ctrl = new AbortController()
132
+ const tg = fakeTelegram([[message(41, "/jobs", OP, OP, new Date())]], () => ctrl.abort())
133
+ const r = await listen({ channel, channelName: "pager", routes: routes(), fetchImpl: tg.impl, signal: ctrl.signal, sleep: async () => {} })
134
+ expect(r).toEqual({ ok: true, reason: "stopped" })
135
+ expect(readListenerState()).toMatchObject({ channel: "pager", offset: 42 })
136
+ expect(tg.calls.find((c) => c.method === "getUpdates" && c.params.get("offset") === "42")).toBeTruthy()
137
+
138
+ // A restart resumes from the saved offset.
139
+ const ctrl2 = new AbortController()
140
+ const tg2 = fakeTelegram([], () => ctrl2.abort())
141
+ await listen({ channel, channelName: "pager", routes: routes(), fetchImpl: tg2.impl, signal: ctrl2.signal, sleep: async () => {} })
142
+ expect(tg2.calls[0]!.params.get("offset")).toBe("42")
143
+ })
144
+
145
+ test("exits after an upgrade so launchd restarts the new code", async () => {
146
+ let v = "1.3.0-alpha.4"
147
+ const tg = fakeTelegram([[]], () => (v = "1.3.0-alpha.5"))
148
+ const r = await listen({ channel, channelName: "pager", routes: routes(), fetchImpl: tg.impl, version: () => v, sleep: async () => {} })
149
+ expect(r).toEqual({ ok: true, reason: "upgraded" })
150
+ })
151
+
152
+ test("refuses a group channel, and a second listener", async () => {
153
+ expect(await listen({ channel: { ...channel, chatId: "-1001" }, channelName: "ops", routes: routes() })).toMatchObject({ ok: false, reason: "bad_channel" })
154
+ const ctrl = new AbortController()
155
+ const tg = fakeTelegram([], () => {})
156
+ const first = listen({ channel, channelName: "pager", routes: routes(), fetchImpl: tg.impl, signal: ctrl.signal, sleep: async () => {} })
157
+ await Bun.sleep(10)
158
+ expect(await listen({ channel, channelName: "pager", routes: routes() })).toMatchObject({ ok: false, reason: "locked" })
159
+ ctrl.abort()
160
+ await first
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
+ })
@@ -0,0 +1,355 @@
1
+ // Beacon's listener: the return half of the operator channel
2
+ // (docs/2026-10-15_ideas.md, "Two-way Beacon").
3
+ //
4
+ // One resident process long-polls Telegram for messages and button taps to the
5
+ // pager bot. Beacon owns the conversation, modules own what it means:
6
+ //
7
+ // - Identity: only the operator counts. The channel must be a private chat, and
8
+ // an update is acted on only when both its chat and its sender are that chat's
9
+ // id. Anything else is logged and ignored, without a reply, so a stranger who
10
+ // finds the bot learns nothing.
11
+ // - Handled once: the next offset is saved before an update is acted on, so a
12
+ // crash mid-action never repeats it (the operator can tap again; a repeat
13
+ // could extend a job twice). Buttons carry a one-use token, not their payload.
14
+ // - Routing: a /command goes to the module that declared it, a tap to the
15
+ // module that declared its action. Beacon has no module-specific logic.
16
+ //
17
+ // It long-polls 50 seconds at a time (about one request a minute when idle) and
18
+ // exits after an upgrade so its LaunchAgent restarts it on the new code.
19
+
20
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
21
+ import { randomBytes } from "node:crypto"
22
+ import { dirname, join } from "node:path"
23
+ import type { BeaconChannel } from "../config.ts"
24
+ import { moduleStateDir, type BeaconActionDecl, type BeaconButton, type BeaconCommandDecl, type BeaconReply, type ModuleDefinition } from "../modules/contract.ts"
25
+ import { htmlToPlain, splitMessage, telegramCall, type Fetch } from "./beacon.ts"
26
+ import { tryLock, type HeldLock } from "./flock.ts"
27
+
28
+ export const POLL_SECONDS = 50
29
+ // A /command older than this when it arrives (sent while the listener was down)
30
+ // is dropped rather than answered: the operator has likely moved on.
31
+ export const STALE_COMMAND_MS = 10 * 60_000
32
+ const BUTTON_TTL_MS = 30 * 24 * 60 * 60_000
33
+ const LOG_MAX_BYTES = 1_000_000
34
+
35
+ export const listenerDir = () => moduleStateDir("beacon")
36
+ export const listenerStatePath = () => join(listenerDir(), "listener.json")
37
+ export const listenerLockPath = () => join(listenerDir(), "listener.lock")
38
+ export const listenerLogPath = () => join(listenerDir(), "listener.log")
39
+ const buttonsPath = () => join(listenerDir(), "buttons.json")
40
+ const buttonsLockPath = () => join(listenerDir(), "buttons.lock")
41
+
42
+ export interface ListenerState {
43
+ channel?: string
44
+ offset?: number
45
+ pid?: number
46
+ version?: string
47
+ startedAt?: string
48
+ // The last time a poll came back from Telegram, ok or not: what the alive
49
+ // check reads.
50
+ lastPollAt?: string
51
+ lastError?: string
52
+ // When polls started failing; cleared by the next good poll.
53
+ failingSince?: string
54
+ lastHandledAt?: string
55
+ }
56
+
57
+ function readJson<T>(path: string, fallback: T): T {
58
+ try {
59
+ return JSON.parse(readFileSync(path, "utf8")) as T
60
+ } catch {
61
+ return fallback
62
+ }
63
+ }
64
+
65
+ function writeJson(path: string, value: unknown): void {
66
+ mkdirSync(dirname(path), { recursive: true })
67
+ writeFileSync(`${path}.tmp`, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
68
+ renameSync(`${path}.tmp`, path)
69
+ }
70
+
71
+ export function readListenerState(): ListenerState {
72
+ return readJson<ListenerState>(listenerStatePath(), {})
73
+ }
74
+
75
+ export function logLine(line: string): void {
76
+ try {
77
+ mkdirSync(listenerDir(), { recursive: true })
78
+ const path = listenerLogPath()
79
+ if (existsSync(path) && statSync(path).size > LOG_MAX_BYTES) renameSync(path, `${path}.1`)
80
+ appendFileSync(path, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 })
81
+ } catch {
82
+ // The log is for the operator's curiosity; never let it stop the listener.
83
+ }
84
+ }
85
+
86
+ // ---- routing ------------------------------------------------------------------
87
+
88
+ export interface Routes {
89
+ commands: Map<string, BeaconCommandDecl>
90
+ actions: Map<string, BeaconActionDecl>
91
+ }
92
+
93
+ export function routesFrom(modules: Pick<ModuleDefinition, "commands" | "actions">[]): Routes {
94
+ const commands = new Map<string, BeaconCommandDecl>()
95
+ const actions = new Map<string, BeaconActionDecl>()
96
+ for (const m of modules) {
97
+ for (const c of m.commands ?? []) commands.set(c.command, c)
98
+ for (const a of m.actions ?? []) actions.set(a.action, a)
99
+ }
100
+ return { commands, actions }
101
+ }
102
+
103
+ export function helpReply(routes: Routes): BeaconReply {
104
+ const lines = [...routes.commands.values()].map((c) => `/${c.command} — ${c.describe}`)
105
+ return { text: lines.length ? `This station answers:\n${lines.join("\n")}` : "This station has no commands yet." }
106
+ }
107
+
108
+ // ---- buttons --------------------------------------------------------------------
109
+
110
+ interface StoredButton {
111
+ action: string
112
+ payload: Record<string, string>
113
+ createdAt: string
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
+
132
+ export function storeButton(action: string, payload: Record<string, string>, now = new Date()): string {
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)}` }))) })
145
+ }
146
+
147
+ // The button for `token`, removed so it can't be used twice.
148
+ export function takeButton(token: string): StoredButton | null {
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
+ })
157
+ }
158
+
159
+ // ---- Telegram -----------------------------------------------------------------
160
+
161
+ interface TgUser {
162
+ id: number
163
+ }
164
+ interface TgChat {
165
+ id: number
166
+ type: string
167
+ }
168
+ interface TgMessage {
169
+ message_id: number
170
+ date: number // unix seconds
171
+ chat: TgChat
172
+ from?: TgUser
173
+ text?: string
174
+ }
175
+ export interface TgUpdate {
176
+ update_id: number
177
+ message?: TgMessage
178
+ callback_query?: { id: string; from: TgUser; message?: TgMessage; data?: string }
179
+ }
180
+
181
+ export interface ListenContext {
182
+ channel: BeaconChannel
183
+ routes: Routes
184
+ fetchImpl?: Fetch
185
+ now?: () => Date
186
+ }
187
+
188
+ // Link previews off. Buttons go on the last part when a reply is long enough to
189
+ // be split. If Telegram refuses a part's HTML, it goes again as plain text: a
190
+ // reply that looks plainer beats no reply.
191
+ export async function sendReply(ctx: ListenContext, reply: BeaconReply): Promise<string | null> {
192
+ const parts = splitMessage(reply.text)
193
+ for (let i = 0; i < parts.length; i++) {
194
+ const params: Record<string, string> = { chat_id: ctx.channel.chatId, text: parts[i]!, disable_web_page_preview: "true" }
195
+ if (i === parts.length - 1 && reply.buttons?.length) {
196
+ params.reply_markup = buttonMarkup(reply.buttons, ctx.now?.() ?? new Date())
197
+ }
198
+ if (reply.html) params.parse_mode = "HTML"
199
+ let r = await telegramCall(ctx.channel.botToken, "sendMessage", params, ctx.fetchImpl)
200
+ if (!r.ok && reply.html && r.status === 400) {
201
+ const { parse_mode: _html, ...plain } = params
202
+ r = await telegramCall(ctx.channel.botToken, "sendMessage", { ...plain, text: htmlToPlain(parts[i]!) }, ctx.fetchImpl)
203
+ }
204
+ if (!r.ok) return r.error ?? "send failed"
205
+ }
206
+ return null
207
+ }
208
+
209
+ async function run(what: string, fn: () => Promise<BeaconReply>): Promise<BeaconReply> {
210
+ try {
211
+ return await fn()
212
+ } catch (err) {
213
+ logLine(`${what} threw: ${(err as Error).message}`)
214
+ return { text: `${what} failed: ${(err as Error).message}` }
215
+ }
216
+ }
217
+
218
+ export type Handled =
219
+ | { kind: "ignored"; why: string }
220
+ | { kind: "command"; command: string; replied: string | null }
221
+ | { kind: "tap"; action: string | null; replied: string | null }
222
+
223
+ // Act on one update. The caller has already saved the offset past it.
224
+ export async function handleUpdate(u: TgUpdate, ctx: ListenContext): Promise<Handled> {
225
+ const operator = ctx.channel.chatId
226
+ const now = ctx.now?.() ?? new Date()
227
+
228
+ if (u.callback_query) {
229
+ const q = u.callback_query
230
+ if (String(q.from.id) !== operator || String(q.message?.chat.id) !== operator) return { kind: "ignored", why: `tap from ${q.from.id}` }
231
+ const token = q.data?.startsWith("b:") ? q.data.slice(2) : ""
232
+ const b = token ? takeButton(token) : null
233
+ const action = b ? ctx.routes.actions.get(b.action) : undefined
234
+ // Answer the tap first: Telegram shows a spinner on the button until we do.
235
+ await telegramCall(ctx.channel.botToken, "answerCallbackQuery", { callback_query_id: q.id, ...(b ? {} : { text: "This button has already been used or has expired." }) }, ctx.fetchImpl)
236
+ if (!b) return { kind: "tap", action: null, replied: null }
237
+ if (!action) {
238
+ const replied = await sendReply(ctx, { text: `Nothing on this station handles "${b.action}" any more.` })
239
+ return { kind: "tap", action: b.action, replied }
240
+ }
241
+ const reply = await run(b.action, () => action.run(b.payload))
242
+ return { kind: "tap", action: b.action, replied: await sendReply(ctx, reply) }
243
+ }
244
+
245
+ const m = u.message
246
+ if (!m) return { kind: "ignored", why: "not a message or a tap" }
247
+ if (m.chat.type !== "private" || String(m.chat.id) !== operator || String(m.from?.id) !== operator) {
248
+ return { kind: "ignored", why: `message from ${m.from?.id ?? "?"} in chat ${m.chat.id}` }
249
+ }
250
+ const text = (m.text ?? "").trim()
251
+ if (!text.startsWith("/")) {
252
+ return { kind: "command", command: "", replied: await sendReply(ctx, helpReply(ctx.routes)) }
253
+ }
254
+ // "/jobs", "/jobs@my_bot", "/Jobs extra words"
255
+ const command = text.slice(1).split(/[\s@]/)[0]!.toLowerCase()
256
+ if (now.getTime() - m.date * 1000 > STALE_COMMAND_MS) return { kind: "ignored", why: `/${command} sent ${new Date(m.date * 1000).toISOString()}, too old to answer` }
257
+ const decl = ctx.routes.commands.get(command)
258
+ const reply = decl ? await run(`/${command}`, () => decl.run()) : helpReply(ctx.routes)
259
+ return { kind: "command", command, replied: await sendReply(ctx, reply) }
260
+ }
261
+
262
+ // ---- the loop --------------------------------------------------------------------
263
+
264
+ export function runningVersion(): string {
265
+ try {
266
+ return (JSON.parse(readFileSync(join(import.meta.dir, "../../package.json"), "utf8")) as { version: string }).version
267
+ } catch {
268
+ return "?"
269
+ }
270
+ }
271
+
272
+ export type ListenExit = { ok: true; reason: "upgraded" | "stopped" } | { ok: false; reason: "locked" | "bad_channel"; message: string }
273
+
274
+ export interface ListenOptions extends ListenContext {
275
+ channelName: string
276
+ signal?: AbortSignal
277
+ // Testing hooks.
278
+ sleep?: (ms: number) => Promise<unknown>
279
+ version?: () => string
280
+ }
281
+
282
+ export function channelProblem(channel: BeaconChannel): string | null {
283
+ // Telegram's private chats have positive ids; groups and channels negative.
284
+ if (!/^\d+$/.test(channel.chatId)) return "The listener answers only a private chat with the bot, so it can tell the operator apart; this channel pages a group or channel."
285
+ return null
286
+ }
287
+
288
+ export async function listen(opts: ListenOptions): Promise<ListenExit> {
289
+ const problem = channelProblem(opts.channel)
290
+ if (problem) return { ok: false, reason: "bad_channel", message: problem }
291
+ const lock = tryLock(listenerLockPath())
292
+ if (!lock) return { ok: false, reason: "locked", message: "A Beacon listener is already running on this machine." }
293
+ const sleep = opts.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)))
294
+ const version = opts.version ?? runningVersion
295
+ const started = version()
296
+ const saved = readListenerState()
297
+ // A different bot or chat means the saved offset belongs to someone else.
298
+ let state: ListenerState = {
299
+ ...(saved.channel === opts.channelName ? { offset: saved.offset } : {}),
300
+ channel: opts.channelName,
301
+ pid: process.pid,
302
+ version: started,
303
+ startedAt: new Date().toISOString(),
304
+ }
305
+ const save = (patch: Partial<ListenerState>) => {
306
+ state = { ...state, ...patch }
307
+ writeJson(listenerStatePath(), state)
308
+ }
309
+ save({})
310
+ logLine(`listening on ${opts.channelName} (pid ${process.pid}, ${started})`)
311
+ let backoff = 5_000
312
+ try {
313
+ while (!opts.signal?.aborted) {
314
+ if (version() !== started) {
315
+ logLine(`SpinupTUI changed from ${started} to ${version()}; exiting so launchd restarts the new version`)
316
+ return { ok: true, reason: "upgraded" }
317
+ }
318
+ const r = await telegramCall(
319
+ opts.channel.botToken,
320
+ "getUpdates",
321
+ {
322
+ ...(state.offset !== undefined ? { offset: String(state.offset) } : {}),
323
+ timeout: String(POLL_SECONDS),
324
+ allowed_updates: JSON.stringify(["message", "callback_query"]),
325
+ },
326
+ opts.fetchImpl,
327
+ { timeoutMs: (POLL_SECONDS + 15) * 1000, signal: opts.signal },
328
+ )
329
+ if (opts.signal?.aborted) break
330
+ if (!r.ok) {
331
+ save({ lastPollAt: new Date().toISOString(), lastError: r.error, failingSince: state.failingSince ?? new Date().toISOString() })
332
+ // 409: something else is reading the bot's updates (Beacon setup's chat
333
+ // discovery, or a second copy elsewhere). Give it room.
334
+ const wait = r.status === 409 ? 30_000 : r.status === 401 || r.status === 404 ? 5 * 60_000 : backoff
335
+ logLine(`poll failed (${r.status || "network"}): ${r.error}; retrying in ${Math.round(wait / 1000)} s`)
336
+ backoff = Math.min(backoff * 2, 60_000)
337
+ await sleep(wait)
338
+ continue
339
+ }
340
+ backoff = 5_000
341
+ save({ lastPollAt: new Date().toISOString(), lastError: undefined, failingSince: undefined })
342
+ for (const u of (r.result as TgUpdate[] | undefined) ?? []) {
343
+ save({ offset: u.update_id + 1 })
344
+ const h = await handleUpdate(u, opts)
345
+ save({ lastHandledAt: new Date().toISOString() })
346
+ if (h.kind === "ignored") logLine(`ignored update ${u.update_id}: ${h.why}`)
347
+ else if (h.kind === "command") logLine(`/${h.command || "(text)"} answered${h.replied ? ` but the reply failed: ${h.replied}` : ""}`)
348
+ else logLine(`tap ${h.action ?? "(used or expired button)"}${h.replied ? `, reply failed: ${h.replied}` : ""}`)
349
+ }
350
+ }
351
+ return { ok: true, reason: "stopped" }
352
+ } finally {
353
+ lock.release()
354
+ }
355
+ }