nebula-treasury 0.1.0
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/README.md +39 -0
- package/bin/nebula +11 -0
- package/package.json +65 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_unlock.ts +66 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1293 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +49 -0
- package/src/commands/gateway-run.ts +42 -0
- package/src/commands/gateway-start.ts +216 -0
- package/src/commands/gateway-status.ts +90 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/identity.ts +178 -0
- package/src/commands/init/cost.ts +40 -0
- package/src/commands/init/funding-gate.ts +64 -0
- package/src/commands/init/model-picker.ts +25 -0
- package/src/commands/init/operator-picker.ts +233 -0
- package/src/commands/init/telegram-step.ts +245 -0
- package/src/commands/init/wizard-state.ts +94 -0
- package/src/commands/init.ts +439 -0
- package/src/commands/logs.ts +37 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +65 -0
- package/src/commands/pairing-clear.ts +39 -0
- package/src/commands/pairing-list.ts +55 -0
- package/src/commands/pairing-revoke.ts +49 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/status.ts +44 -0
- package/src/commands/telegram-remove.ts +62 -0
- package/src/commands/telegram-setup.ts +64 -0
- package/src/commands/telegram-status.ts +87 -0
- package/src/commands/telegram.ts +44 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.ts +99 -0
- package/src/index.ts +153 -0
- package/src/ui/app.tsx +673 -0
- package/src/ui/approval-summary.ts +32 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.ts +181 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.ts +125 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.ts +218 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// GitHub Releases API helpers. Unauthenticated (60 req/hr per IP) which is
|
|
2
|
+
// plenty for the upgrade hot path: one resolveLatestRelease + zero-or-one
|
|
3
|
+
// checkTagExists per invocation. Pin `--ref vX.Y.Z` to skip the API entirely.
|
|
4
|
+
export interface GitHubRelease {
|
|
5
|
+
tagName: string
|
|
6
|
+
publishedAt: string
|
|
7
|
+
htmlUrl: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GitHubFetchOpts {
|
|
11
|
+
/** Override fetch (mainly for tests). Defaults to global `fetch`. */
|
|
12
|
+
fetchImpl?: typeof fetch
|
|
13
|
+
/** Per-call timeout. Defaults to 10s. */
|
|
14
|
+
timeoutMs?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse `https://github.com/owner/repo.git`, `https://github.com/owner/repo`,
|
|
19
|
+
* or `git@github.com:owner/repo.git` into `{owner, repo}`. Throws on shapes
|
|
20
|
+
* the regex doesn't recognize.
|
|
21
|
+
*/
|
|
22
|
+
export function parseGitHubRepoUrl(url: string): { owner: string; repo: string } {
|
|
23
|
+
const match = url.match(/github\.com[:/]([^/]+)\/([^/.]+)/)
|
|
24
|
+
if (!match || !match[1] || !match[2]) throw new Error(`cannot parse GitHub repo URL: ${url}`)
|
|
25
|
+
return { owner: match[1], repo: match[2] }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the most recent published GitHub release for a repo. Skips drafts
|
|
30
|
+
* and pre-releases (that's what GitHub's `/releases/latest` endpoint returns
|
|
31
|
+
* by default). Throws on 404 (no published release) or non-200.
|
|
32
|
+
*/
|
|
33
|
+
export async function resolveLatestRelease(
|
|
34
|
+
repoUrl: string,
|
|
35
|
+
opts: GitHubFetchOpts = {},
|
|
36
|
+
): Promise<GitHubRelease> {
|
|
37
|
+
const { owner, repo } = parseGitHubRepoUrl(repoUrl)
|
|
38
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
39
|
+
const timeoutMs = opts.timeoutMs ?? 10_000
|
|
40
|
+
const r = await fetchImpl(`https://api.github.com/repos/${owner}/${repo}/releases/latest`, {
|
|
41
|
+
headers: { Accept: 'application/vnd.github+json' },
|
|
42
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
43
|
+
})
|
|
44
|
+
if (r.status === 404) {
|
|
45
|
+
throw new Error(`no published releases found for ${owner}/${repo}`)
|
|
46
|
+
}
|
|
47
|
+
if (!r.ok) {
|
|
48
|
+
throw new Error(`GitHub API ${r.status} for ${owner}/${repo}/releases/latest`)
|
|
49
|
+
}
|
|
50
|
+
const data = (await r.json()) as { tag_name: string; published_at: string; html_url: string }
|
|
51
|
+
return { tagName: data.tag_name, publishedAt: data.published_at, htmlUrl: data.html_url }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Probe whether a tag exists on the remote. Returns `false` on 404 (the
|
|
56
|
+
* conventional "tag not found" signal), `true` on 200, throws on other
|
|
57
|
+
* errors so callers can surface "API down" vs "tag missing" distinctly.
|
|
58
|
+
*/
|
|
59
|
+
export async function checkTagExists(
|
|
60
|
+
repoUrl: string,
|
|
61
|
+
tag: string,
|
|
62
|
+
opts: GitHubFetchOpts = {},
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
const { owner, repo } = parseGitHubRepoUrl(repoUrl)
|
|
65
|
+
const fetchImpl = opts.fetchImpl ?? fetch
|
|
66
|
+
const timeoutMs = opts.timeoutMs ?? 10_000
|
|
67
|
+
const r = await fetchImpl(
|
|
68
|
+
`https://api.github.com/repos/${owner}/${repo}/git/refs/tags/${encodeURIComponent(tag)}`,
|
|
69
|
+
{
|
|
70
|
+
headers: { Accept: 'application/vnd.github+json' },
|
|
71
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
72
|
+
},
|
|
73
|
+
)
|
|
74
|
+
if (r.status === 404) return false
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
throw new Error(`GitHub API ${r.status} for ${owner}/${repo}/git/refs/tags/${tag}`)
|
|
77
|
+
}
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local accessor for the cached PROFILE scope key.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `getSessionKey(agentId, OPERATOR_BLOB_SCOPES.PROFILE)` with the
|
|
5
|
+
* hex-encoding the gateway handoff envelopes expect. Used by `nebula upgrade`
|
|
6
|
+
* (both `--reprovision` + in-place) to ship the cached key to the new sandbox
|
|
7
|
+
* daemon so it boots with `slots.profile` ready to anchor instead of
|
|
8
|
+
* `{ status: 'skipped', reason: 'no-profile-key' }`.
|
|
9
|
+
*
|
|
10
|
+
* Returns undefined when the operator session is absent / expired / missing
|
|
11
|
+
* the PROFILE scope (pre-v0.23.1 agents). Callers should surface a one-line
|
|
12
|
+
* note in that case so the operator knows to refresh the session before the
|
|
13
|
+
* next upgrade.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { OPERATOR_BLOB_SCOPES, getSessionKey } from 'nebula-ai-core'
|
|
17
|
+
|
|
18
|
+
export function loadProfileScopeKeyHex(agentId: string): `0x${string}` | undefined {
|
|
19
|
+
try {
|
|
20
|
+
const buf = getSessionKey(agentId, OPERATOR_BLOB_SCOPES.PROFILE)
|
|
21
|
+
return buf ? (`0x${buf.toString('hex')}` as `0x${string}`) : undefined
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type GitHubFetchOpts, resolveLatestRelease } from './github-releases'
|
|
2
|
+
|
|
3
|
+
/** Canonical nebula repo. Override via {@link ResolveNebulaRefOpts.repoUrl}. */
|
|
4
|
+
export const NEBULA_REPO_URL = 'https://github.com/rstfulzz/nebula.git'
|
|
5
|
+
|
|
6
|
+
/** Magic ref keyword that triggers GitHub `releases/latest` resolution. */
|
|
7
|
+
export const LATEST_KEYWORD = 'latest'
|
|
8
|
+
|
|
9
|
+
export interface ResolvedRef {
|
|
10
|
+
ref: string
|
|
11
|
+
/** True if `ref` looks like `vX.Y.Z`. Drives pre/post-flight version verification. */
|
|
12
|
+
isTag: boolean
|
|
13
|
+
/** True if user said `latest` and we resolved via API — caller skips pre-flight (already source of truth). */
|
|
14
|
+
resolvedFromLatest: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ResolveNebulaRefOpts extends GitHubFetchOpts {
|
|
18
|
+
repoUrl?: string
|
|
19
|
+
/** Test seam. Defaults to `process.env`. */
|
|
20
|
+
env?: Record<string, string | undefined>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TAG_RE = /^v\d+\.\d+\.\d+/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve user ref. Priority: rawRef → NEBULA_BOOTSTRAP_REF env → `latest`.
|
|
27
|
+
* Tag-shaped refs pass through. Branch / SHA refs return isTag=false (no
|
|
28
|
+
* version verification possible).
|
|
29
|
+
*/
|
|
30
|
+
export async function resolveNebulaRef(
|
|
31
|
+
rawRef: string | undefined,
|
|
32
|
+
opts: ResolveNebulaRefOpts = {},
|
|
33
|
+
): Promise<ResolvedRef> {
|
|
34
|
+
const env = opts.env ?? process.env
|
|
35
|
+
const arg = rawRef ?? env.NEBULA_BOOTSTRAP_REF ?? LATEST_KEYWORD
|
|
36
|
+
|
|
37
|
+
if (arg === LATEST_KEYWORD) {
|
|
38
|
+
const release = await resolveLatestRelease(opts.repoUrl ?? NEBULA_REPO_URL, opts)
|
|
39
|
+
return { ref: release.tagName, isTag: true, resolvedFromLatest: true }
|
|
40
|
+
}
|
|
41
|
+
if (TAG_RE.test(arg)) {
|
|
42
|
+
return { ref: arg, isTag: true, resolvedFromLatest: false }
|
|
43
|
+
}
|
|
44
|
+
return { ref: arg, isTag: false, resolvedFromLatest: false }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Pretty form of a ResolvedRef for prompts and outros. Adds `(resolved from latest)` suffix when applicable. */
|
|
48
|
+
export function formatResolvedRef(resolved: ResolvedRef): string {
|
|
49
|
+
return resolved.resolvedFromLatest ? `${resolved.ref} (resolved from latest)` : resolved.ref
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Expected `package.json` version for a ResolvedRef, or null when no strict expectation (branch / SHA). */
|
|
53
|
+
export function expectedVersionFromRef(resolved: ResolvedRef): string | null {
|
|
54
|
+
return resolved.isTag ? resolved.ref.replace(/^v/, '') : null
|
|
55
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `fn` with `console.log/info/warn/error/debug` swapped for no-ops so they
|
|
3
|
+
* cannot interleave with clack's in-place spinner re-render. Originals are
|
|
4
|
+
* restored even if `fn` throws.
|
|
5
|
+
*
|
|
6
|
+
* Why: Mantle Storage SDK and Mantle Compute broker SDK both `console.log` directly
|
|
7
|
+
* during their work (selected nodes, upload progress, broker tx hashes, etc).
|
|
8
|
+
* When a clack spinner is running, every leaked log line pushes the spinner
|
|
9
|
+
* down and the next animation frame draws a new spinner row, creating the
|
|
10
|
+
* "100x stacked spinner" visual we saw on the WC init test. Suppressing these
|
|
11
|
+
* during the spinner-active phases keeps the wizard output clean.
|
|
12
|
+
*
|
|
13
|
+
* Note: `chat.tsx` does its own process-lifetime console redirect to a chat
|
|
14
|
+
* log file. That cannot use this helper because its lifetime is the whole
|
|
15
|
+
* session, not a scoped wrap. Keep the two pathways separate.
|
|
16
|
+
*/
|
|
17
|
+
export async function withSilencedConsole<T>(fn: () => Promise<T>): Promise<T> {
|
|
18
|
+
const orig = {
|
|
19
|
+
log: console.log,
|
|
20
|
+
info: console.info,
|
|
21
|
+
warn: console.warn,
|
|
22
|
+
error: console.error,
|
|
23
|
+
debug: console.debug,
|
|
24
|
+
}
|
|
25
|
+
const noop = (() => {}) as (...args: unknown[]) => void
|
|
26
|
+
console.log = noop as typeof console.log
|
|
27
|
+
console.info = noop as typeof console.info
|
|
28
|
+
console.warn = noop as typeof console.warn
|
|
29
|
+
console.error = noop as typeof console.error
|
|
30
|
+
console.debug = noop as typeof console.debug
|
|
31
|
+
try {
|
|
32
|
+
return await fn()
|
|
33
|
+
} finally {
|
|
34
|
+
console.log = orig.log
|
|
35
|
+
console.info = orig.info
|
|
36
|
+
console.warn = orig.warn
|
|
37
|
+
console.error = orig.error
|
|
38
|
+
console.debug = orig.debug
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local persistence for telegram bot secrets, encrypted via the operator's
|
|
3
|
+
* sign-derived AEAD key (scope `OPERATOR_BLOB_SCOPES.TELEGRAM`).
|
|
4
|
+
*
|
|
5
|
+
* On-disk file: `~/.nebula/agents/<id>/telegram-secrets.encrypted`
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* version: 2,
|
|
9
|
+
* scope: 'nebula-telegram-v1',
|
|
10
|
+
* blob: <base64(iv|tag|ciphertext)>,
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Plaintext shape inside the blob:
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* botToken: string, // from @BotFather
|
|
17
|
+
* botUsername?: string, // cached at setup-time getMe
|
|
18
|
+
* botId?: number, // cached at setup-time getMe
|
|
19
|
+
* allowedUserIds: number[],
|
|
20
|
+
* }
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync } from 'node:fs'
|
|
23
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
import {
|
|
26
|
+
OPERATOR_BLOB_SCOPES,
|
|
27
|
+
type OperatorEncryptedBlob,
|
|
28
|
+
type OperatorSigner,
|
|
29
|
+
agentPaths,
|
|
30
|
+
decodeOperatorBlobBytes,
|
|
31
|
+
decryptOperatorBlob,
|
|
32
|
+
encodeOperatorBlobBytes,
|
|
33
|
+
encryptOperatorBlob,
|
|
34
|
+
} from 'nebula-ai-core'
|
|
35
|
+
import type { Address } from 'viem'
|
|
36
|
+
|
|
37
|
+
export interface TelegramSecretsPlaintext {
|
|
38
|
+
botToken: string
|
|
39
|
+
botUsername?: string
|
|
40
|
+
botId?: number
|
|
41
|
+
allowedUserIds: number[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Subset of `TelegramSecretsPlaintext` that the CLI ships into the harness
|
|
46
|
+
* provision envelope on init / upgrade / resume. The harness doesn't need
|
|
47
|
+
* the operator-side metadata (`botUsername`, `botId`); it only needs the
|
|
48
|
+
* token + allowlist + optional pairing list. Centralising this as a named
|
|
49
|
+
* type avoids drift between the four CLI handoff sites that previously
|
|
50
|
+
* inlined the literal shape (init wizard, upgrade, resume, the
|
|
51
|
+
* `HandoffAgentToGateway` + `ResumeArchivedSandbox` interfaces).
|
|
52
|
+
*/
|
|
53
|
+
export interface TelegramHandoffSecrets {
|
|
54
|
+
botToken: string
|
|
55
|
+
allowedUserIds: number[]
|
|
56
|
+
pairingApproved?: number[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function telegramSecretsPath(agentId: string): string {
|
|
60
|
+
return join(agentPaths.agent(agentId).dir, 'telegram-secrets.encrypted')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function telegramSecretsExist(agentId: string): boolean {
|
|
64
|
+
return existsSync(telegramSecretsPath(agentId))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function loadTelegramSecrets(opts: {
|
|
68
|
+
signer: OperatorSigner
|
|
69
|
+
agentAddress: Address
|
|
70
|
+
agentId: string
|
|
71
|
+
}): Promise<TelegramSecretsPlaintext | null> {
|
|
72
|
+
const path = telegramSecretsPath(opts.agentId)
|
|
73
|
+
if (!existsSync(path)) return null
|
|
74
|
+
const fileBytes = await readFile(path)
|
|
75
|
+
const blob: OperatorEncryptedBlob = decodeOperatorBlobBytes(new Uint8Array(fileBytes))
|
|
76
|
+
const ptBytes = await decryptOperatorBlob({
|
|
77
|
+
signer: opts.signer,
|
|
78
|
+
scope: OPERATOR_BLOB_SCOPES.TELEGRAM,
|
|
79
|
+
agentAddress: opts.agentAddress,
|
|
80
|
+
blob,
|
|
81
|
+
})
|
|
82
|
+
const parsed = JSON.parse(new TextDecoder().decode(ptBytes)) as TelegramSecretsPlaintext
|
|
83
|
+
if (typeof parsed.botToken !== 'string' || !Array.isArray(parsed.allowedUserIds)) {
|
|
84
|
+
throw new Error('telegram-secrets: malformed plaintext (missing botToken or allowedUserIds)')
|
|
85
|
+
}
|
|
86
|
+
return parsed
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load + project telegram secrets into the shape the gateway provision envelope
|
|
91
|
+
* expects. Used by every sandbox-handoff flow that ships TG (init/upgrade/
|
|
92
|
+
* resume/deploy/chat-sandbox auto-resume); centralises the try/decrypt/swallow
|
|
93
|
+
* pattern so future TG-secret schema changes touch one place.
|
|
94
|
+
*
|
|
95
|
+
* Errors are non-fatal: TG is opt-in. Failure fires `onNotice` (so the operator
|
|
96
|
+
* sees the reason in the spinner) and returns undefined.
|
|
97
|
+
*
|
|
98
|
+
* `chat.tsx` keeps its own loader because it needs the full plaintext (including
|
|
99
|
+
* `botUsername` for the unlock spinner UX).
|
|
100
|
+
*/
|
|
101
|
+
export async function loadTelegramHandoffSecrets(opts: {
|
|
102
|
+
signer: OperatorSigner
|
|
103
|
+
agentAddress: Address
|
|
104
|
+
agentId: string
|
|
105
|
+
onNotice?: (msg: string) => void
|
|
106
|
+
}): Promise<TelegramHandoffSecrets | undefined> {
|
|
107
|
+
const agentId = opts.agentId
|
|
108
|
+
try {
|
|
109
|
+
const tg = await loadTelegramSecrets({
|
|
110
|
+
signer: opts.signer,
|
|
111
|
+
agentAddress: opts.agentAddress,
|
|
112
|
+
agentId,
|
|
113
|
+
})
|
|
114
|
+
if (!tg) return undefined
|
|
115
|
+
return { botToken: tg.botToken, allowedUserIds: tg.allowedUserIds }
|
|
116
|
+
} catch (err) {
|
|
117
|
+
opts.onNotice?.(`telegram secrets read failed: ${(err as Error).message.slice(0, 120)}`)
|
|
118
|
+
return undefined
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function saveTelegramSecrets(opts: {
|
|
123
|
+
signer: OperatorSigner
|
|
124
|
+
agentAddress: Address
|
|
125
|
+
agentId: string
|
|
126
|
+
plaintext: TelegramSecretsPlaintext
|
|
127
|
+
/**
|
|
128
|
+
* v0.24.3: pre-derived TELEGRAM scope key (32 bytes). The init wizard
|
|
129
|
+
* derives this once and passes it both here AND into `.operator-session`,
|
|
130
|
+
* so encryptOperatorBlob skips the redundant sign it would otherwise make.
|
|
131
|
+
* Threads through to encryptOperatorBlob; see that helper for fallback.
|
|
132
|
+
*/
|
|
133
|
+
precomputedKey?: Buffer
|
|
134
|
+
}): Promise<void> {
|
|
135
|
+
const path = telegramSecretsPath(opts.agentId)
|
|
136
|
+
await mkdir(dirname(path), { recursive: true })
|
|
137
|
+
const ptBytes = new TextEncoder().encode(JSON.stringify(opts.plaintext))
|
|
138
|
+
const blob = await encryptOperatorBlob({
|
|
139
|
+
signer: opts.signer,
|
|
140
|
+
scope: OPERATOR_BLOB_SCOPES.TELEGRAM,
|
|
141
|
+
agentAddress: opts.agentAddress,
|
|
142
|
+
plaintext: ptBytes,
|
|
143
|
+
precomputedKey: opts.precomputedKey,
|
|
144
|
+
})
|
|
145
|
+
await writeFile(path, encodeOperatorBlobBytes(blob))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function removeTelegramSecrets(agentId: string): Promise<boolean> {
|
|
149
|
+
const path = telegramSecretsPath(agentId)
|
|
150
|
+
if (!existsSync(path)) return false
|
|
151
|
+
await rm(path, { force: true })
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const BOT_TOKEN_RE = /^\d{6,15}:[A-Za-z0-9_-]{30,}$/
|
|
156
|
+
|
|
157
|
+
export function looksLikeBotToken(s: string): boolean {
|
|
158
|
+
return BOT_TOKEN_RE.test(s.trim())
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface ValidatedBotInfo {
|
|
162
|
+
id: number
|
|
163
|
+
username: string
|
|
164
|
+
firstName: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Telegram Bot API getMe — cheap, free, no message side-effect. Used by
|
|
169
|
+
* `nebula telegram setup` to validate the token before persisting it AND by
|
|
170
|
+
* `nebula telegram status` to confirm the stored token still works.
|
|
171
|
+
*
|
|
172
|
+
* Throws on non-200 / `ok: false` with a clean error message; caller wraps
|
|
173
|
+
* the throw in a clack spinner.stop().
|
|
174
|
+
*/
|
|
175
|
+
export async function fetchBotInfo(
|
|
176
|
+
botToken: string,
|
|
177
|
+
opts?: { signal?: AbortSignal },
|
|
178
|
+
): Promise<ValidatedBotInfo> {
|
|
179
|
+
const url = `https://api.telegram.org/bot${encodeURIComponent(botToken)}/getMe`
|
|
180
|
+
const res = await fetch(url, { signal: opts?.signal })
|
|
181
|
+
if (!res.ok) {
|
|
182
|
+
throw new Error(`getMe HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
183
|
+
}
|
|
184
|
+
const body = (await res.json()) as {
|
|
185
|
+
ok: boolean
|
|
186
|
+
description?: string
|
|
187
|
+
result?: { id: number; username?: string; first_name?: string }
|
|
188
|
+
}
|
|
189
|
+
if (!body.ok || !body.result) {
|
|
190
|
+
throw new Error(`getMe rejected: ${body.description ?? 'unknown error'}`)
|
|
191
|
+
}
|
|
192
|
+
if (!body.result.username) throw new Error('bot has no username; create one in @BotFather')
|
|
193
|
+
return {
|
|
194
|
+
id: body.result.id,
|
|
195
|
+
username: body.result.username,
|
|
196
|
+
firstName: body.result.first_name ?? body.result.username,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseAllowedUserIds(
|
|
201
|
+
input: string,
|
|
202
|
+
): { ok: true; ids: number[] } | { ok: false; reason: string } {
|
|
203
|
+
const trimmed = input.trim()
|
|
204
|
+
if (trimmed.length === 0) return { ok: true, ids: [] }
|
|
205
|
+
const parts = trimmed
|
|
206
|
+
.split(/[,\s]+/)
|
|
207
|
+
.map(p => p.trim())
|
|
208
|
+
.filter(p => p.length > 0)
|
|
209
|
+
const ids: number[] = []
|
|
210
|
+
for (const p of parts) {
|
|
211
|
+
if (!/^\d+$/.test(p)) return { ok: false, reason: `not a numeric id: "${p}"` }
|
|
212
|
+
const n = Number(p)
|
|
213
|
+
if (!Number.isFinite(n) || n <= 0) return { ok: false, reason: `not a positive id: "${p}"` }
|
|
214
|
+
ids.push(n)
|
|
215
|
+
}
|
|
216
|
+
// Dedupe, preserve first-seen order.
|
|
217
|
+
return { ok: true, ids: [...new Set(ids)] }
|
|
218
|
+
}
|