spinuptui 1.2.0 → 1.3.0-alpha.2
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 +31 -1
- package/README.md +13 -1
- package/package.json +1 -1
- package/src/config.ts +45 -0
- package/src/index.tsx +37 -38
- 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/cliScheduler.ts +534 -0
- package/src/lib/flock.ts +51 -0
- package/src/lib/prompt.ts +47 -0
- package/src/lib/schedule.ts +156 -0
- package/src/lib/scheduler.test.ts +353 -0
- package/src/lib/schedulerGit.ts +119 -0
- package/src/lib/schedulerInstall.ts +325 -0
- package/src/lib/schedulerJobs.ts +288 -0
- package/src/lib/schedulerRun.ts +246 -0
- package/src/lib/schedulerTick.ts +307 -0
- package/src/modules/beacon.ts +52 -0
- package/src/modules/contract.ts +82 -0
- package/src/modules/index.ts +29 -0
- package/src/modules/scheduler.ts +146 -0
- package/src/ui/App.tsx +15 -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/main.tsx +42 -0
- package/src/ui/store.tsx +13 -1
- package/src/ui/views/BeaconSetup.tsx +268 -0
- package/src/ui/views/Modules.tsx +232 -0
- package/src/ui/views/SchedulerInstall.tsx +172 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Beacon: one notification layer for SpinupTUI and its modules
|
|
2
|
+
// (docs/2026-10-16_beacon-and-module-contract-spec.md). This round is the
|
|
3
|
+
// operator channel only — a Telegram pager.
|
|
4
|
+
//
|
|
5
|
+
// Messages go out as plain text (never parse_mode: an underscore in a domain
|
|
6
|
+
// must not break a page), with link previews off. Anything over Telegram's
|
|
7
|
+
// 4096-character limit is split on line boundaries instead of being dropped.
|
|
8
|
+
// Every send is appended to the module's send.log, which is how a caller's
|
|
9
|
+
// cutover is verified. Nothing here writes config.json.
|
|
10
|
+
|
|
11
|
+
import { appendFile, mkdir, rename, stat } from "node:fs/promises"
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
13
|
+
import { hostname } from "node:os"
|
|
14
|
+
import { join } from "node:path"
|
|
15
|
+
import type { AppConfig, BeaconChannel, BeaconStored } from "../config.ts"
|
|
16
|
+
import { moduleStateDir } from "../modules/contract.ts"
|
|
17
|
+
|
|
18
|
+
export const TELEGRAM_LIMIT = 4096
|
|
19
|
+
// Room left under the limit for the "(1/3) " prefix.
|
|
20
|
+
const PART_BUDGET = 4000
|
|
21
|
+
export const MAX_PARTS = 5
|
|
22
|
+
const ATTEMPTS = 3
|
|
23
|
+
const TIMEOUT_MS = 15_000
|
|
24
|
+
const LOG_MAX_BYTES = 1_000_000
|
|
25
|
+
const TELEGRAM_API = "https://api.telegram.org"
|
|
26
|
+
|
|
27
|
+
export interface ResolvedChannel {
|
|
28
|
+
name: string
|
|
29
|
+
channel: BeaconChannel
|
|
30
|
+
env: boolean // from SPINUPTUI_BEACON_* — read-only
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResolvedBeacon {
|
|
34
|
+
channels: ResolvedChannel[]
|
|
35
|
+
defaultChannel: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Stored channels plus the read-only env channel. The env channel is the default
|
|
39
|
+
// only when no stored channel has been made the default; a lone channel is the
|
|
40
|
+
// default automatically.
|
|
41
|
+
export function resolveBeacon(stored: BeaconStored | null, env: Record<string, string | undefined> = process.env): ResolvedBeacon {
|
|
42
|
+
const channels: ResolvedChannel[] = Object.entries(stored?.channels ?? {}).map(([name, channel]) => ({ name, channel, env: false }))
|
|
43
|
+
const token = env.SPINUPTUI_BEACON_TELEGRAM_TOKEN?.trim()
|
|
44
|
+
const chatId = env.SPINUPTUI_BEACON_TELEGRAM_CHAT_ID?.trim()
|
|
45
|
+
if (token && chatId) channels.push({ name: "env", channel: { type: "telegram", botToken: token, chatId }, env: true })
|
|
46
|
+
const want = stored?.defaultChannel
|
|
47
|
+
let defaultChannel: string | null = null
|
|
48
|
+
if (want && channels.some((c) => c.name === want)) defaultChannel = want
|
|
49
|
+
else if (channels.some((c) => c.name === "env")) defaultChannel = "env"
|
|
50
|
+
else if (channels.length === 1) defaultChannel = channels[0]!.name
|
|
51
|
+
return { channels, defaultChannel }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function beaconFromConfig(cfg: AppConfig): ResolvedBeacon {
|
|
55
|
+
return resolveBeacon(cfg.beacon)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// "123456789:AAE…" → "123456789:…" — the bot id identifies it, the rest is secret.
|
|
59
|
+
export function maskToken(token: string): string {
|
|
60
|
+
const i = token.indexOf(":")
|
|
61
|
+
return i > 0 ? `${token.slice(0, i)}:…` : "…"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function maskChatId(chatId: string): string {
|
|
65
|
+
return chatId.length <= 4 ? "…" : `…${chatId.slice(-4)}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Split text into parts that fit Telegram's limit, on line boundaries where
|
|
69
|
+
// possible, numbering them when there's more than one. Beyond MAX_PARTS the last
|
|
70
|
+
// part is cut short with a marker saying how much was left out.
|
|
71
|
+
export function splitMessage(text: string, budget = PART_BUDGET, maxParts = MAX_PARTS): string[] {
|
|
72
|
+
if (text.length <= TELEGRAM_LIMIT) return [text]
|
|
73
|
+
const chunks: string[] = []
|
|
74
|
+
let current = ""
|
|
75
|
+
const push = () => {
|
|
76
|
+
if (current) chunks.push(current)
|
|
77
|
+
current = ""
|
|
78
|
+
}
|
|
79
|
+
for (const line of text.split("\n")) {
|
|
80
|
+
let rest = line
|
|
81
|
+
// A single line longer than the budget is hard-split.
|
|
82
|
+
while (rest.length > budget) {
|
|
83
|
+
push()
|
|
84
|
+
chunks.push(rest.slice(0, budget))
|
|
85
|
+
rest = rest.slice(budget)
|
|
86
|
+
}
|
|
87
|
+
const next = current ? `${current}\n${rest}` : rest
|
|
88
|
+
if (next.length > budget) {
|
|
89
|
+
push()
|
|
90
|
+
current = rest
|
|
91
|
+
} else {
|
|
92
|
+
current = next
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
push()
|
|
96
|
+
let parts = chunks
|
|
97
|
+
if (parts.length > maxParts) {
|
|
98
|
+
const dropped = parts.slice(maxParts).reduce((n, p) => n + p.length + 1, 0)
|
|
99
|
+
const marker = `\n… [truncated: ${dropped} more characters]`
|
|
100
|
+
parts = parts.slice(0, maxParts)
|
|
101
|
+
const last = parts[maxParts - 1]!
|
|
102
|
+
parts[maxParts - 1] = last.slice(0, budget - marker.length) + marker
|
|
103
|
+
}
|
|
104
|
+
return parts.map((p, i) => `(${i + 1}/${parts.length}) ${p}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Parse a KEY=VALUE env file the way the existing shell senders write them:
|
|
108
|
+
// `export ` prefixes, quotes and # comments allowed.
|
|
109
|
+
export function parseEnvFile(text: string): Record<string, string> {
|
|
110
|
+
const out: Record<string, string> = {}
|
|
111
|
+
for (const raw of text.split("\n")) {
|
|
112
|
+
const line = raw.trim()
|
|
113
|
+
if (!line || line.startsWith("#")) continue
|
|
114
|
+
const m = line.replace(/^export\s+/, "").match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
|
|
115
|
+
if (!m) continue
|
|
116
|
+
let v = m[2]!.trim()
|
|
117
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
|
|
118
|
+
else v = v.replace(/\s+#.*$/, "")
|
|
119
|
+
out[m[1]!] = v
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- Telegram transport -------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export interface TelegramCallResult {
|
|
127
|
+
ok: boolean
|
|
128
|
+
status: number // HTTP status, 0 for a network error/timeout
|
|
129
|
+
error?: string
|
|
130
|
+
retryAfter?: number
|
|
131
|
+
result?: unknown
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type Fetch = (input: string, init?: RequestInit) => Promise<Response>
|
|
135
|
+
|
|
136
|
+
export async function telegramCall(
|
|
137
|
+
token: string,
|
|
138
|
+
method: string,
|
|
139
|
+
params: Record<string, string>,
|
|
140
|
+
fetchImpl: Fetch = fetch,
|
|
141
|
+
opts: { timeoutMs?: number; signal?: AbortSignal } = {},
|
|
142
|
+
): Promise<TelegramCallResult> {
|
|
143
|
+
try {
|
|
144
|
+
const timeout = AbortSignal.timeout(opts.timeoutMs ?? TIMEOUT_MS)
|
|
145
|
+
const res = await fetchImpl(`${TELEGRAM_API}/bot${token}/${method}`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: new URLSearchParams(params),
|
|
148
|
+
signal: opts.signal ? AbortSignal.any([timeout, opts.signal]) : timeout,
|
|
149
|
+
})
|
|
150
|
+
const body = (await res.json().catch(() => null)) as { ok?: boolean; description?: string; parameters?: { retry_after?: number }; result?: unknown } | null
|
|
151
|
+
if (res.ok && body?.ok) return { ok: true, status: res.status, result: body.result }
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
status: res.status,
|
|
155
|
+
error: body?.description || `HTTP ${res.status}`,
|
|
156
|
+
retryAfter: body?.parameters?.retry_after,
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// Never echo the URL: it carries the token.
|
|
160
|
+
const e = err as Error
|
|
161
|
+
if (opts.signal?.aborted) return { ok: false, status: 0, error: "cancelled" }
|
|
162
|
+
return { ok: false, status: 0, error: e.name === "TimeoutError" ? "timed out" : e.message.replaceAll(token, "…") }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
167
|
+
|
|
168
|
+
// Three attempts: honor retry_after on 429, back off on 5xx/network errors, and
|
|
169
|
+
// give up at once on any other 4xx (a bad token or chat id won't fix itself).
|
|
170
|
+
async function telegramWithRetry(
|
|
171
|
+
token: string,
|
|
172
|
+
method: string,
|
|
173
|
+
params: Record<string, string>,
|
|
174
|
+
fetchImpl: Fetch,
|
|
175
|
+
wait: (ms: number) => Promise<unknown>,
|
|
176
|
+
): Promise<TelegramCallResult> {
|
|
177
|
+
let last: TelegramCallResult = { ok: false, status: 0, error: "not attempted" }
|
|
178
|
+
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
|
179
|
+
last = await telegramCall(token, method, params, fetchImpl)
|
|
180
|
+
if (last.ok) return last
|
|
181
|
+
const retryable = last.status === 0 || last.status === 429 || last.status >= 500
|
|
182
|
+
if (!retryable || attempt === ATTEMPTS) break
|
|
183
|
+
await wait(last.status === 429 ? Math.min(last.retryAfter ?? 5, 30) * 1000 : 2000 * attempt)
|
|
184
|
+
}
|
|
185
|
+
return last
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface TelegramIdentity {
|
|
189
|
+
ok: boolean
|
|
190
|
+
username?: string
|
|
191
|
+
error?: string
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function telegramGetMe(token: string, fetchImpl: Fetch = fetch): Promise<TelegramIdentity> {
|
|
195
|
+
const r = await telegramCall(token, "getMe", {}, fetchImpl)
|
|
196
|
+
if (!r.ok) return { ok: false, error: r.error }
|
|
197
|
+
return { ok: true, username: (r.result as { username?: string } | undefined)?.username }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- send ---------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
export type SendResult =
|
|
203
|
+
| { ok: true; channel: string; parts: number; chars: number }
|
|
204
|
+
| {
|
|
205
|
+
ok: false
|
|
206
|
+
// not_configured / unknown_channel / empty_message: nothing was attempted
|
|
207
|
+
// (exit 1). delivery_failed: Beacon tried and Telegram refused or was
|
|
208
|
+
// unreachable (exit 2) — callers must not retry through another path.
|
|
209
|
+
reason: "not_configured" | "unknown_channel" | "empty_message" | "delivery_failed"
|
|
210
|
+
message: string
|
|
211
|
+
remedy?: string
|
|
212
|
+
channel?: string
|
|
213
|
+
partsSent?: number
|
|
214
|
+
parts?: number
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface SendOptions {
|
|
218
|
+
to?: string | null
|
|
219
|
+
silent?: boolean
|
|
220
|
+
fetchImpl?: Fetch
|
|
221
|
+
wait?: (ms: number) => Promise<unknown>
|
|
222
|
+
log?: boolean
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function pickChannel(beacon: ResolvedBeacon, to: string | null | undefined): ResolvedChannel | Extract<SendResult, { ok: false }> {
|
|
226
|
+
if (beacon.channels.length === 0) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
reason: "not_configured",
|
|
230
|
+
message: "Beacon has no channels configured.",
|
|
231
|
+
remedy: "Add one with `spinuptui beacon add telegram <name> --env-file <path>`.",
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const name = to || beacon.defaultChannel
|
|
235
|
+
if (!name) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
reason: "unknown_channel",
|
|
239
|
+
message: `No default channel; pick one with --to (${beacon.channels.map((c) => c.name).join(", ")}).`,
|
|
240
|
+
remedy: "Or make one the default with `spinuptui beacon add … --default`.",
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const found = beacon.channels.find((c) => c.name === name)
|
|
244
|
+
if (!found) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
reason: "unknown_channel",
|
|
248
|
+
message: `No Beacon channel named "${name}" (have: ${beacon.channels.map((c) => c.name).join(", ")}).`,
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return found
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function beaconSend(beacon: ResolvedBeacon, text: string, opts: SendOptions = {}): Promise<SendResult> {
|
|
255
|
+
if (!text.trim()) return { ok: false, reason: "empty_message", message: "Nothing to send: the message is empty." }
|
|
256
|
+
const picked = pickChannel(beacon, opts.to)
|
|
257
|
+
if ("ok" in picked) return picked
|
|
258
|
+
const { name, channel } = picked
|
|
259
|
+
const parts = splitMessage(text)
|
|
260
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
261
|
+
const wait = opts.wait ?? sleep
|
|
262
|
+
let sent = 0
|
|
263
|
+
let failure: string | undefined
|
|
264
|
+
for (const part of parts) {
|
|
265
|
+
const r = await telegramWithRetry(
|
|
266
|
+
channel.botToken,
|
|
267
|
+
"sendMessage",
|
|
268
|
+
{
|
|
269
|
+
chat_id: channel.chatId,
|
|
270
|
+
text: part,
|
|
271
|
+
disable_web_page_preview: "true",
|
|
272
|
+
...(opts.silent ? { disable_notification: "true" } : {}),
|
|
273
|
+
},
|
|
274
|
+
fetchImpl,
|
|
275
|
+
wait,
|
|
276
|
+
)
|
|
277
|
+
if (!r.ok) {
|
|
278
|
+
failure = r.error
|
|
279
|
+
break
|
|
280
|
+
}
|
|
281
|
+
sent++
|
|
282
|
+
}
|
|
283
|
+
const result: SendResult =
|
|
284
|
+
failure === undefined
|
|
285
|
+
? { ok: true, channel: name, parts: parts.length, chars: text.length }
|
|
286
|
+
: {
|
|
287
|
+
ok: false,
|
|
288
|
+
reason: "delivery_failed",
|
|
289
|
+
message: `Telegram refused or was unreachable: ${failure}`,
|
|
290
|
+
channel: name,
|
|
291
|
+
partsSent: sent,
|
|
292
|
+
parts: parts.length,
|
|
293
|
+
}
|
|
294
|
+
if (opts.log !== false) await appendSendLog(name, text, result)
|
|
295
|
+
return result
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- send log -----------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
export function sendLogPath(): string {
|
|
301
|
+
return join(moduleStateDir("beacon"), "send.log")
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface SendLogEntry {
|
|
305
|
+
at: string
|
|
306
|
+
channel: string
|
|
307
|
+
ok: boolean
|
|
308
|
+
parts: number
|
|
309
|
+
chars: number
|
|
310
|
+
error?: string
|
|
311
|
+
preview: string
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function appendSendLog(channel: string, text: string, result: SendResult): Promise<void> {
|
|
315
|
+
const entry: SendLogEntry = {
|
|
316
|
+
at: new Date().toISOString(),
|
|
317
|
+
channel,
|
|
318
|
+
ok: result.ok,
|
|
319
|
+
parts: result.ok ? result.parts : (result.parts ?? 0),
|
|
320
|
+
chars: text.length,
|
|
321
|
+
...(result.ok ? {} : { error: result.message }),
|
|
322
|
+
preview: text.replace(/\s+/g, " ").trim().slice(0, 80),
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
const dir = moduleStateDir("beacon")
|
|
326
|
+
await mkdir(dir, { recursive: true })
|
|
327
|
+
const path = sendLogPath()
|
|
328
|
+
const size = await stat(path).then((s) => s.size).catch(() => 0)
|
|
329
|
+
if (size > LOG_MAX_BYTES) await rename(path, `${path}.1`)
|
|
330
|
+
await appendFile(path, JSON.stringify(entry) + "\n", { mode: 0o600 })
|
|
331
|
+
} catch {
|
|
332
|
+
// Logging must never turn a delivered page into a failure.
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function lastSendLogEntry(): SendLogEntry | null {
|
|
337
|
+
try {
|
|
338
|
+
const path = sendLogPath()
|
|
339
|
+
if (!existsSync(path)) return null
|
|
340
|
+
const lines = readFileSync(path, "utf8").trimEnd().split("\n")
|
|
341
|
+
return JSON.parse(lines[lines.length - 1]!) as SendLogEntry
|
|
342
|
+
} catch {
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function testMessage(): string {
|
|
348
|
+
return `Beacon test from ${hostname()} at ${new Date().toLocaleString()}. If you can read this, SpinupTUI can page you here.`
|
|
349
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { discoverChat, looksLikeBotToken } from "./beaconSetup.ts"
|
|
3
|
+
|
|
4
|
+
const TOKEN = "123456789:AAEabcdefghijklmnopqrstuvwxyz0123456"
|
|
5
|
+
|
|
6
|
+
function fakeUpdates(rounds: Array<{ status?: number; body: unknown }>) {
|
|
7
|
+
const calls: URLSearchParams[] = []
|
|
8
|
+
const impl = async (_url: string, init?: RequestInit) => {
|
|
9
|
+
calls.push(init?.body as URLSearchParams)
|
|
10
|
+
const next = rounds.shift() ?? { body: { ok: true, result: [] } }
|
|
11
|
+
return new Response(JSON.stringify(next.body), { status: next.status ?? 200 })
|
|
12
|
+
}
|
|
13
|
+
return { impl, calls }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const chat = (id: number, extra: Record<string, unknown> = {}) => ({ id, type: "private", first_name: "Jane", ...extra })
|
|
17
|
+
|
|
18
|
+
describe("discoverChat", () => {
|
|
19
|
+
test("skips messages already waiting and returns the next chat", async () => {
|
|
20
|
+
const f = fakeUpdates([
|
|
21
|
+
{ body: { ok: true, result: [{ update_id: 7, message: { chat: chat(111) } }] } }, // stale
|
|
22
|
+
{ body: { ok: true, result: [] } }, // a quiet long-poll round
|
|
23
|
+
{ body: { ok: true, result: [{ update_id: 8, message: { chat: chat(222, { username: "jane" }) } }] } },
|
|
24
|
+
])
|
|
25
|
+
const r = await discoverChat(TOKEN, { fetchImpl: f.impl, timeoutMs: 60_000 })
|
|
26
|
+
expect(r).toEqual({ ok: true, chat: { chatId: "222", kind: "private", label: "Jane (@jane)" } })
|
|
27
|
+
// The first listening round starts after the stale update.
|
|
28
|
+
expect(f.calls[1]!.get("offset")).toBe("8")
|
|
29
|
+
// The found update is acknowledged.
|
|
30
|
+
expect(f.calls[3]!.get("offset")).toBe("9")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("a group chat is labelled by its title", async () => {
|
|
34
|
+
const f = fakeUpdates([
|
|
35
|
+
{ body: { ok: true, result: [] } },
|
|
36
|
+
{ body: { ok: true, result: [{ update_id: 1, my_chat_member: { chat: { id: -100, type: "supergroup", title: "Ops alerts" } } }] } },
|
|
37
|
+
])
|
|
38
|
+
const r = await discoverChat(TOKEN, { fetchImpl: f.impl, timeoutMs: 60_000 })
|
|
39
|
+
expect(r.ok && r.chat).toEqual({ chatId: "-100", kind: "supergroup", label: "Ops alerts" })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("a webhook conflict is explained", async () => {
|
|
43
|
+
const f = fakeUpdates([{ status: 409, body: { ok: false, description: "Conflict: can't use getUpdates method while webhook is active" } }])
|
|
44
|
+
const r = await discoverChat(TOKEN, { fetchImpl: f.impl })
|
|
45
|
+
expect(!r.ok && r.reason).toBe("conflict")
|
|
46
|
+
expect(!r.ok && r.message).toContain("webhook")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("an aborted signal cancels", async () => {
|
|
50
|
+
const ctrl = new AbortController()
|
|
51
|
+
ctrl.abort()
|
|
52
|
+
const impl = async (_u: string, init?: RequestInit) => {
|
|
53
|
+
init?.signal?.throwIfAborted()
|
|
54
|
+
return new Response("{}")
|
|
55
|
+
}
|
|
56
|
+
const r = await discoverChat(TOKEN, { fetchImpl: impl, signal: ctrl.signal })
|
|
57
|
+
expect(!r.ok && r.reason).toBe("cancelled")
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("looksLikeBotToken", () => {
|
|
62
|
+
expect(looksLikeBotToken(TOKEN)).toBe(true)
|
|
63
|
+
expect(looksLikeBotToken("not-a-token")).toBe(false)
|
|
64
|
+
expect(looksLikeBotToken("123:short")).toBe(false)
|
|
65
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Guided Beacon setup, shared by `spinuptui beacon setup` and the Modules
|
|
2
|
+
// screen: check a bot token, find the chat to page by waiting for the user to
|
|
3
|
+
// message the bot, send a test, save.
|
|
4
|
+
//
|
|
5
|
+
// Creating the bot itself can't be automated — Telegram only issues bots through
|
|
6
|
+
// a chat with @BotFather — so that step is instructions. Finding the chat id is
|
|
7
|
+
// the part people get stuck on, and it can be: once the user messages the bot,
|
|
8
|
+
// Telegram's getUpdates hands us the chat. Stale updates (anything already
|
|
9
|
+
// waiting when setup starts) are skipped so an old conversation can't be picked
|
|
10
|
+
// by mistake. Reading updates acknowledges them on Telegram's side; Beacon never
|
|
11
|
+
// uses updates for anything else, so nothing is lost.
|
|
12
|
+
|
|
13
|
+
import { loadConfig, saveConfig, type BeaconStored, type BeaconTelegramChannel } from "../config.ts"
|
|
14
|
+
import { beaconSend, telegramCall, telegramGetMe, type Fetch, type TelegramIdentity } from "./beacon.ts"
|
|
15
|
+
|
|
16
|
+
export const BOTFATHER_URL = "https://t.me/BotFather"
|
|
17
|
+
export const BOTFATHER_STEPS = [
|
|
18
|
+
"Open @BotFather in Telegram (t.me/BotFather) and send /newbot.",
|
|
19
|
+
"Pick a display name, then a username ending in “bot”.",
|
|
20
|
+
"BotFather replies with a token like 123456789:AAE… — copy it.",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
// A loose shape check so a mis-paste fails fast with a clear message instead of
|
|
24
|
+
// a round trip to Telegram.
|
|
25
|
+
export function looksLikeBotToken(token: string): boolean {
|
|
26
|
+
return /^\d{5,}:[A-Za-z0-9_-]{30,}$/.test(token.trim())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function verifyBotToken(token: string, fetchImpl?: Fetch): Promise<TelegramIdentity> {
|
|
30
|
+
if (!looksLikeBotToken(token)) return { ok: false, error: "That doesn't look like a bot token (expected digits, a colon, then a long code)." }
|
|
31
|
+
return telegramGetMe(token.trim(), fetchImpl)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function botLink(username: string): string {
|
|
35
|
+
return `https://t.me/${username}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FoundChat {
|
|
39
|
+
chatId: string
|
|
40
|
+
kind: "private" | "group" | "supergroup" | "channel"
|
|
41
|
+
label: string // "Jane Doe (@jane)" / "Ops alerts"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type DiscoverResult = { ok: true; chat: FoundChat } | { ok: false; reason: "timeout" | "cancelled" | "conflict" | "error"; message: string }
|
|
45
|
+
|
|
46
|
+
interface TgChat {
|
|
47
|
+
id: number
|
|
48
|
+
type: FoundChat["kind"]
|
|
49
|
+
title?: string
|
|
50
|
+
first_name?: string
|
|
51
|
+
last_name?: string
|
|
52
|
+
username?: string
|
|
53
|
+
}
|
|
54
|
+
interface TgUpdate {
|
|
55
|
+
update_id: number
|
|
56
|
+
message?: { chat: TgChat }
|
|
57
|
+
channel_post?: { chat: TgChat }
|
|
58
|
+
my_chat_member?: { chat: TgChat }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function describeChat(c: TgChat): FoundChat {
|
|
62
|
+
const name = c.title || [c.first_name, c.last_name].filter(Boolean).join(" ") || String(c.id)
|
|
63
|
+
return { chatId: String(c.id), kind: c.type, label: c.username ? `${name} (@${c.username})` : name }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function conflictMessage(error: string | undefined): string {
|
|
67
|
+
return /webhook/i.test(error ?? "")
|
|
68
|
+
? "This bot has a webhook set, so Telegram won't hand its messages to Beacon. Use a bot made just for alerts, or remove the webhook."
|
|
69
|
+
: "Something else is reading this bot's messages right now. Stop it, or use a bot made just for alerts."
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Wait for the next message to the bot (or the bot being added to a group or
|
|
73
|
+
// channel) and return that chat. Long-polls in 25-second rounds until
|
|
74
|
+
// `timeoutMs` or the signal aborts.
|
|
75
|
+
export async function discoverChat(
|
|
76
|
+
token: string,
|
|
77
|
+
opts: { timeoutMs?: number; signal?: AbortSignal; fetchImpl?: Fetch } = {},
|
|
78
|
+
): Promise<DiscoverResult> {
|
|
79
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 5 * 60_000)
|
|
80
|
+
const call = (params: Record<string, string>, timeoutMs: number) =>
|
|
81
|
+
telegramCall(token, "getUpdates", params, opts.fetchImpl, { timeoutMs, signal: opts.signal })
|
|
82
|
+
|
|
83
|
+
// Skip whatever is already queued: only a message sent from now on counts.
|
|
84
|
+
const first = await call({ timeout: "0" }, 15_000)
|
|
85
|
+
if (!first.ok) {
|
|
86
|
+
if (first.error === "cancelled") return { ok: false, reason: "cancelled", message: "Cancelled." }
|
|
87
|
+
if (first.status === 409) return { ok: false, reason: "conflict", message: conflictMessage(first.error) }
|
|
88
|
+
return { ok: false, reason: "error", message: `Couldn't read the bot's messages: ${first.error}` }
|
|
89
|
+
}
|
|
90
|
+
const stale = (first.result as TgUpdate[] | undefined) ?? []
|
|
91
|
+
let offset = stale.length ? Math.max(...stale.map((u) => u.update_id)) + 1 : 0
|
|
92
|
+
|
|
93
|
+
while (Date.now() < deadline) {
|
|
94
|
+
const wait = Math.max(1, Math.min(25, Math.floor((deadline - Date.now()) / 1000)))
|
|
95
|
+
const r = await call({ offset: String(offset), timeout: String(wait), allowed_updates: JSON.stringify(["message", "channel_post", "my_chat_member"]) }, (wait + 10) * 1000)
|
|
96
|
+
if (!r.ok) {
|
|
97
|
+
if (r.error === "cancelled") return { ok: false, reason: "cancelled", message: "Cancelled." }
|
|
98
|
+
if (r.status === 409) return { ok: false, reason: "conflict", message: conflictMessage(r.error) }
|
|
99
|
+
// A dropped long-poll is routine; pause and keep listening.
|
|
100
|
+
await new Promise((res) => setTimeout(res, 2000))
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
for (const u of (r.result as TgUpdate[] | undefined) ?? []) {
|
|
104
|
+
offset = Math.max(offset, u.update_id + 1)
|
|
105
|
+
const chat = u.message?.chat ?? u.channel_post?.chat ?? u.my_chat_member?.chat
|
|
106
|
+
if (chat) {
|
|
107
|
+
// Acknowledge it so a re-run doesn't see the same message as stale-but-new.
|
|
108
|
+
await call({ offset: String(offset), timeout: "0" }, 15_000)
|
|
109
|
+
return { ok: true, chat: describeChat(chat) }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: false, reason: "timeout", message: "No message reached the bot in time." }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function channelExists(name: string): boolean {
|
|
117
|
+
return Boolean(loadConfig().beacon?.channels?.[name])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The name a guided setup configures: the stored default if there is one (so
|
|
121
|
+
// re-running setup repairs the pager rather than adding a second one), else "pager".
|
|
122
|
+
export function setupChannelName(): string {
|
|
123
|
+
const stored = loadConfig().beacon
|
|
124
|
+
const def = stored?.defaultChannel
|
|
125
|
+
return def && stored?.channels?.[def] ? def : "pager"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function saveBeaconChannel(name: string, channel: BeaconTelegramChannel, makeDefault: boolean): Promise<{ isDefault: boolean }> {
|
|
129
|
+
const stored: BeaconStored = loadConfig().beacon ?? {}
|
|
130
|
+
const channels = { ...(stored.channels ?? {}), [name]: channel }
|
|
131
|
+
const isDefault = makeDefault || !stored.defaultChannel || stored.defaultChannel === name
|
|
132
|
+
await saveConfig({ beacon: { ...stored, channels, ...(isDefault ? { defaultChannel: name } : {}) } })
|
|
133
|
+
return { isDefault }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Send the "connected" message on a channel that isn't saved yet. Credentials
|
|
137
|
+
// that can't deliver are never stored, so callers save only after this succeeds.
|
|
138
|
+
export async function sendConnectedMessage(name: string, channel: BeaconTelegramChannel, fetchImpl?: Fetch) {
|
|
139
|
+
return beaconSend({ channels: [{ name, channel, env: false }], defaultChannel: name }, `Beacon connected: channel "${name}" will page this chat.`, {
|
|
140
|
+
log: false,
|
|
141
|
+
fetchImpl,
|
|
142
|
+
})
|
|
143
|
+
}
|