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.
- package/CHANGELOG.md +32 -1
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/config.ts +32 -0
- package/src/index.tsx +11 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +14 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +190 -0
- package/src/lib/beaconListen.ts +355 -0
- package/src/lib/beaconListenInstall.ts +251 -0
- package/src/lib/cliBeacon.ts +115 -3
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliScheduler.ts +30 -1
- 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 +189 -1
- package/src/lib/schedulerExtend.ts +105 -0
- package/src/lib/schedulerInstall.ts +36 -15
- 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 +9 -1
- package/src/modules/contract.ts +34 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +147 -6
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +8 -0
- package/src/ui/store.tsx +11 -0
- package/src/ui/views/BeaconSetup.tsx +11 -7
- package/src/ui/views/ListenerSetup.tsx +205 -0
- package/src/ui/views/Modules.tsx +70 -5
- 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 & 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
|
+
}
|