spinuptui 1.2.0-alpha.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -10
- package/README.md +5 -10
- package/package.json +1 -1
- package/src/api/client.ts +15 -0
- package/src/api/types.ts +3 -0
- package/src/config.ts +0 -23
- package/src/dev/mockClient.ts +30 -7
- package/src/index.tsx +17 -16
- package/src/lib/appUpdate.ts +7 -33
- package/src/lib/cliOffline.ts +418 -0
- package/src/lib/offline.test.ts +181 -0
- package/src/lib/offline.ts +422 -0
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/ui/App.tsx +12 -10
- package/src/ui/Details.tsx +8 -2
- package/src/ui/Explain.tsx +2 -15
- package/src/ui/Header.tsx +0 -2
- package/src/ui/Help.tsx +3 -2
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/components.tsx +2 -1
- package/src/ui/offlineRun.tsx +82 -0
- package/src/ui/store.tsx +76 -10
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +13 -5
- package/src/ui/views/Dashboard.tsx +3 -1
- package/src/ui/views/Search.tsx +8 -6
- package/src/ui/views/ServerActions.tsx +108 -11
- package/src/ui/views/SiteOffline.tsx +171 -0
- package/src/ui/views/Stacks.tsx +26 -9
- package/src/lib/appUpdate.test.ts +0 -26
- package/src/lib/beacon.test.ts +0 -140
- package/src/lib/beacon.ts +0 -349
- package/src/lib/beaconSetup.test.ts +0 -65
- package/src/lib/beaconSetup.ts +0 -143
- package/src/lib/cliBeacon.ts +0 -290
- package/src/lib/prompt.ts +0 -47
- package/src/modules/beacon.ts +0 -51
- package/src/modules/contract.ts +0 -61
- package/src/modules/index.ts +0 -34
- package/src/ui/views/BeaconSetup.tsx +0 -268
- package/src/ui/views/Modules.tsx +0 -196
package/src/lib/beacon.test.ts
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
2
|
-
import { beaconSend, MAX_PARTS, maskToken, parseEnvFile, resolveBeacon, splitMessage, TELEGRAM_LIMIT, type ResolvedBeacon } from "./beacon.ts"
|
|
3
|
-
|
|
4
|
-
const pager: ResolvedBeacon = {
|
|
5
|
-
channels: [{ name: "pager", channel: { type: "telegram", botToken: "123:SECRET", chatId: "-1001" }, env: false }],
|
|
6
|
-
defaultChannel: "pager",
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function fakeFetch(responses: Array<{ status: number; body: unknown } | Error>) {
|
|
10
|
-
const calls: Array<{ url: string; params: URLSearchParams }> = []
|
|
11
|
-
const impl = async (url: string, init?: RequestInit) => {
|
|
12
|
-
calls.push({ url, params: init?.body as URLSearchParams })
|
|
13
|
-
const next = responses.shift() ?? { status: 200, body: { ok: true, result: {} } }
|
|
14
|
-
if (next instanceof Error) throw next
|
|
15
|
-
return new Response(JSON.stringify(next.body), { status: next.status })
|
|
16
|
-
}
|
|
17
|
-
return { impl, calls }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const noWait = async () => {}
|
|
21
|
-
|
|
22
|
-
describe("resolveBeacon", () => {
|
|
23
|
-
test("a lone stored channel is the default", () => {
|
|
24
|
-
const r = resolveBeacon({ channels: { pager: { type: "telegram", botToken: "1:a", chatId: "2" } } }, {})
|
|
25
|
-
expect(r.defaultChannel).toBe("pager")
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
test("env channel is added and becomes default unless one is chosen", () => {
|
|
29
|
-
const env = { SPINUPTUI_BEACON_TELEGRAM_TOKEN: "9:z", SPINUPTUI_BEACON_TELEGRAM_CHAT_ID: "5" }
|
|
30
|
-
const stored = { channels: { pager: { type: "telegram" as const, botToken: "1:a", chatId: "2" } } }
|
|
31
|
-
expect(resolveBeacon(stored, env).defaultChannel).toBe("env")
|
|
32
|
-
expect(resolveBeacon({ ...stored, defaultChannel: "pager" }, env).defaultChannel).toBe("pager")
|
|
33
|
-
expect(resolveBeacon(stored, env).channels.find((c) => c.name === "env")?.env).toBe(true)
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
test("two stored channels and no choice means no default", () => {
|
|
37
|
-
const ch = { type: "telegram" as const, botToken: "1:a", chatId: "2" }
|
|
38
|
-
expect(resolveBeacon({ channels: { a: ch, b: ch } }, {}).defaultChannel).toBeNull()
|
|
39
|
-
})
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
describe("splitMessage", () => {
|
|
43
|
-
test("short text is untouched", () => {
|
|
44
|
-
expect(splitMessage("hello")).toEqual(["hello"])
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
test("long text splits on lines, numbered, each under the limit", () => {
|
|
48
|
-
const text = Array.from({ length: 300 }, (_, i) => `line ${i} ${"x".repeat(40)}`).join("\n")
|
|
49
|
-
const parts = splitMessage(text)
|
|
50
|
-
expect(parts.length).toBeGreaterThan(1)
|
|
51
|
-
parts.forEach((p, i) => {
|
|
52
|
-
expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT)
|
|
53
|
-
expect(p.startsWith(`(${i + 1}/${parts.length}) `)).toBe(true)
|
|
54
|
-
})
|
|
55
|
-
// Nothing lost or reordered.
|
|
56
|
-
expect(parts.map((p) => p.replace(/^\(\d+\/\d+\) /, "")).join("\n")).toBe(text)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
test("a single huge line is hard-split", () => {
|
|
60
|
-
const parts = splitMessage("y".repeat(9000))
|
|
61
|
-
expect(parts.length).toBe(3)
|
|
62
|
-
parts.forEach((p) => expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT))
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
test("beyond the part cap the last part is truncated with a marker", () => {
|
|
66
|
-
const parts = splitMessage("z".repeat(4000 * 8))
|
|
67
|
-
expect(parts.length).toBe(MAX_PARTS)
|
|
68
|
-
expect(parts[MAX_PARTS - 1]).toContain("[truncated:")
|
|
69
|
-
parts.forEach((p) => expect(p.length).toBeLessThanOrEqual(TELEGRAM_LIMIT))
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
describe("parseEnvFile", () => {
|
|
74
|
-
test("handles export, quotes and comments", () => {
|
|
75
|
-
const env = parseEnvFile(`# creds\nexport TELEGRAM_BOT_TOKEN="1:abc"\nTELEGRAM_CHAT_ID='-100' \nOTHER=x # note\n`)
|
|
76
|
-
expect(env).toEqual({ TELEGRAM_BOT_TOKEN: "1:abc", TELEGRAM_CHAT_ID: "-100", OTHER: "x" })
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
test("maskToken keeps only the bot id", () => {
|
|
81
|
-
expect(maskToken("123456:AAE-secret")).toBe("123456:…")
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
describe("beaconSend", () => {
|
|
85
|
-
test("sends plain text with previews off", async () => {
|
|
86
|
-
const f = fakeFetch([])
|
|
87
|
-
const r = await beaconSend(pager, "site down", { fetchImpl: f.impl, wait: noWait, log: false })
|
|
88
|
-
expect(r).toEqual({ ok: true, channel: "pager", parts: 1, chars: 9 })
|
|
89
|
-
expect(f.calls[0]!.params.get("disable_web_page_preview")).toBe("true")
|
|
90
|
-
expect(f.calls[0]!.params.has("parse_mode")).toBe(false)
|
|
91
|
-
expect(f.calls[0]!.params.has("disable_notification")).toBe(false)
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
test("--silent sets disable_notification", async () => {
|
|
95
|
-
const f = fakeFetch([])
|
|
96
|
-
await beaconSend(pager, "digest", { silent: true, fetchImpl: f.impl, wait: noWait, log: false })
|
|
97
|
-
expect(f.calls[0]!.params.get("disable_notification")).toBe("true")
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test("retries a 429 and a network error, then succeeds", async () => {
|
|
101
|
-
const f = fakeFetch([{ status: 429, body: { ok: false, parameters: { retry_after: 1 } } }, new Error("socket hang up")])
|
|
102
|
-
const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
|
|
103
|
-
expect(r.ok).toBe(true)
|
|
104
|
-
expect(f.calls.length).toBe(3)
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test("a 400 is not retried and is a delivery failure", async () => {
|
|
108
|
-
const f = fakeFetch([{ status: 400, body: { ok: false, description: "Bad Request: chat not found" } }])
|
|
109
|
-
const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
|
|
110
|
-
expect(f.calls.length).toBe(1)
|
|
111
|
-
expect(r.ok).toBe(false)
|
|
112
|
-
if (!r.ok) {
|
|
113
|
-
expect(r.reason).toBe("delivery_failed")
|
|
114
|
-
expect(r.message).toContain("chat not found")
|
|
115
|
-
}
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
test("network errors never leak the token", async () => {
|
|
119
|
-
const f = fakeFetch([new Error("fail https://api.telegram.org/bot123:SECRET/sendMessage"), new Error("x"), new Error("x")])
|
|
120
|
-
const r = await beaconSend(pager, "x", { fetchImpl: f.impl, wait: noWait, log: false })
|
|
121
|
-
expect(r.ok).toBe(false)
|
|
122
|
-
if (!r.ok) expect(r.message).not.toContain("SECRET")
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
test("not configured and empty messages attempt nothing", async () => {
|
|
126
|
-
const f = fakeFetch([])
|
|
127
|
-
const none = await beaconSend({ channels: [], defaultChannel: null }, "x", { fetchImpl: f.impl, log: false })
|
|
128
|
-
const empty = await beaconSend(pager, " ", { fetchImpl: f.impl, log: false })
|
|
129
|
-
expect(!none.ok && none.reason).toBe("not_configured")
|
|
130
|
-
expect(!empty.ok && empty.reason).toBe("empty_message")
|
|
131
|
-
expect(f.calls.length).toBe(0)
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
test("a multi-part send stops at the first failed part", async () => {
|
|
135
|
-
const f = fakeFetch([{ status: 200, body: { ok: true } }, { status: 403, body: { ok: false, description: "Forbidden" } }])
|
|
136
|
-
const r = await beaconSend(pager, "q".repeat(9000), { fetchImpl: f.impl, wait: noWait, log: false })
|
|
137
|
-
expect(!r.ok && r.partsSent).toBe(1)
|
|
138
|
-
expect(f.calls.length).toBe(2)
|
|
139
|
-
})
|
|
140
|
-
})
|
package/src/lib/beacon.ts
DELETED
|
@@ -1,349 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
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
|
-
})
|