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.
@@ -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
+ }