diff --git a/server/src/index.ts b/server/src/index.ts index 49e76dcf..d34545e5 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -17,6 +17,7 @@ import { normalizeBitcoinCoreVersion, TRANSLATOR_MONITORING_PORT, JDC_MONITORING_PORT, + MAX_FALLBACK_POOLS, } from '@sv2-ui/shared'; import { BITCOIN_ERROR_MESSAGES } from './messages.js'; import { @@ -33,7 +34,19 @@ import { } from './docker.js'; import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/diagnostics.js'; import { ActivePoolTracker } from './active-pool.js'; -import { getPoolConfigError, MAX_FALLBACK_POOLS } from './pool-validation.js'; +import { getPoolConfigError } from './pool-validation.js'; +import { + collectPaginatedMonitoringItems, + getTelegramWorkerCount, + TelegramApiError, + TelegramConfigError, + TelegramService, +} from './telegram.js'; +import type { + TelegramActivitySnapshot, + TelegramMiningChannel, + TelegramSettingsUpdate, +} from './telegram.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -42,13 +55,17 @@ const PORT = process.env.PORT || 3001; // Config storage const CONFIG_DIR = process.env.CONFIG_DIR || path.join(__dirname, '../../data/config'); const STATE_FILE = path.join(CONFIG_DIR, 'state.json'); +const TELEGRAM_SETTINGS_FILE = path.join(CONFIG_DIR, 'telegram.json'); const AUTO_START_RETRY_INTERVAL_MS = 30_000; +const TELEGRAM_POLL_INTERVAL_MS = 5_000; type StackBusyReason = 'auto-start' | 'manual'; let stackBusyReason: StackBusyReason | null = null; const activePoolTracker = new ActivePoolTracker(readContainerLogs); +const telegramService = new TelegramService(TELEGRAM_SETTINGS_FILE); +let telegramMonitorTimer: ReturnType | null = null; type SavedState = { configured: boolean; @@ -215,6 +232,39 @@ function stackBusyResponse() { }; } +async function getCurrentStatus(): Promise { + const state = await loadState(); + const containers = await getStackStatus(state.mode); + const running = isStackRunning(state.mode, containers); + const isSovereignSolo = state.data?.miningMode === 'solo' && state.data?.mode === 'jd'; + const pools = state.data && !isSovereignSolo ? configuredPools(state.data) : []; + + if (!running) { + activePoolTracker.reset(); + } + + const activePool = running && state.mode && pools.length > 0 + ? await activePoolTracker.getActivePool( + state.mode === 'jd' ? 'jdc' : 'translator', + pools + ) + : null; + + return { + configured: state.configured, + running, + autoStarting: stackBusyReason === 'auto-start', + shouldBeRunning: state.shouldBeRunning, + miningMode: state.miningMode, + mode: state.mode, + poolName: isSovereignSolo + ? 'Sovereign Solo Mining' + : (activePool?.name ?? null), + activePoolIndex: activePool?.index ?? null, + containers, + }; +} + /** * GET /api/health - Health check */ @@ -231,38 +281,7 @@ app.get('/api/health', async (_req, res) => { */ app.get('/api/status', async (_req, res) => { try { - const state = await loadState(); - const containers = await getStackStatus(state.mode); - const running = isStackRunning(state.mode, containers); - const isSovereignSolo = state.data?.miningMode === 'solo' && state.data?.mode === 'jd'; - const pools = state.data && !isSovereignSolo ? configuredPools(state.data) : []; - - if (!running) { - activePoolTracker.reset(); - } - - const activePool = running && state.mode && pools.length > 0 - ? await activePoolTracker.getActivePool( - state.mode === 'jd' ? 'jdc' : 'translator', - pools - ) - : null; - - const response: StatusResponse = { - configured: state.configured, - running, - autoStarting: stackBusyReason === 'auto-start', - shouldBeRunning: state.shouldBeRunning, - miningMode: state.miningMode, - mode: state.mode, - poolName: isSovereignSolo - ? 'Sovereign Solo Mining' - : (activePool?.name ?? null), - activePoolIndex: activePool?.index ?? null, - containers, - }; - - res.json(response); + res.json(await getCurrentStatus()); } catch (error) { console.error('Status error:', error); res.status(500).json({ error: 'Failed to get status' }); @@ -293,6 +312,87 @@ app.get('/api/env', (_req, res) => { res.json({ HOST_OS: process.env.HOST_OS || null, STRATUM_HOST: process.env.STRATUM_HOST || null }); }); +function sendTelegramError(res: express.Response, error: unknown): void { + if (error instanceof TelegramConfigError) { + res.status(400).json({ success: false, error: error.message }); + return; + } + + if (error instanceof TelegramApiError) { + res.status(error.statusCode).json({ success: false, error: error.message }); + return; + } + + console.error( + 'Telegram settings error:', + error instanceof Error ? error.message : 'Unknown error' + ); + res.status(500).json({ success: false, error: 'Telegram settings could not be updated' }); +} + +/** + * Telegram notification proof of concept. + * + * The bot token and chat ID are stored only in CONFIG_DIR/telegram.json and + * are never included in an API response. + */ +app.get('/api/telegram', async (_req, res) => { + try { + res.json(await telegramService.getSettings()); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/connect', async (req, res) => { + try { + if (!isJsonObject(req.body) || typeof req.body.botToken !== 'string') { + throw new TelegramConfigError('A Telegram bot token is required'); + } + + res.json(await telegramService.connectBot(req.body.botToken)); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/pair', async (_req, res) => { + try { + res.json(await telegramService.pairChat()); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.patch('/api/telegram', async (req, res) => { + try { + if (!isJsonObject(req.body)) { + throw new TelegramConfigError('Telegram settings must be a JSON object'); + } + + res.json(await telegramService.updateSettings(req.body as TelegramSettingsUpdate)); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.post('/api/telegram/test', async (_req, res) => { + try { + await telegramService.sendTestMessage(); + res.json({ success: true }); + } catch (error) { + sendTelegramError(res, error); + } +}); + +app.delete('/api/telegram', async (_req, res) => { + try { + res.json(await telegramService.disconnect()); + } catch (error) { + sendTelegramError(res, error); + } +}); + /** * POST /api/validate/bitcoin-socket - Check if a Bitcoin Core IPC socket is listening */ @@ -696,6 +796,191 @@ function getContainerUrl(containerName: string, port: number): string { : `http://localhost:${port}`; } +type MonitoringGlobal = { + server?: { total_hashrate: number } | null; + sv1_clients?: { total_clients: number; total_hashrate: number } | null; + sv2_clients?: { + total_clients: number; + total_channels: number; + total_hashrate: number; + } | null; +}; + +type MonitoringServerChannel = { + channel_id: number; + user_identity: string; + best_diff: number; + blocks_found: number; + shares_submitted: number; + shares_acknowledged: number; + shares_rejected: number; +}; + +type MonitoringChannelsPage = { + total_extended: number; + total_standard: number; + extended_channels: T[]; + standard_channels: T[]; +}; + +type MonitoringClient = { + client_id: number; +}; + +type MonitoringItemsPage = { + total: number; + items: T[]; +}; + +type TaggedMonitoringChannel = { + kind: 'extended' | 'standard'; + channel: T; +}; + +type MonitoringMiningChannel = { + channel_id: number; + user_identity: string; + best_diff: number; + blocks_found: number; +}; + +async function fetchMonitoringJson(url: string): Promise { + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(5000), + }); + return response.ok ? await response.json() as T : null; + } catch { + return null; + } +} + +function combineMonitoringChannelPage( + page: MonitoringChannelsPage, +): { + items: TaggedMonitoringChannel[]; + total: number; +} { + return { + items: [ + ...page.extended_channels.map((channel) => ({ + kind: 'extended' as const, + channel, + })), + ...page.standard_channels.map((channel) => ({ + kind: 'standard' as const, + channel, + })), + ], + total: Math.max(page.total_extended, page.total_standard), + }; +} + +async function fetchAllMonitoringChannels( + endpoint: string, +): Promise[] | null> { + return collectPaginatedMonitoringItems(async (offset, limit) => { + const page = await fetchMonitoringJson>( + `${endpoint}?offset=${offset}&limit=${limit}` + ); + return page ? combineMonitoringChannelPage(page) : null; + }); +} + +async function fetchAllMonitoringItems(endpoint: string): Promise { + return collectPaginatedMonitoringItems(async (offset, limit) => { + const page = await fetchMonitoringJson>( + `${endpoint}?offset=${offset}&limit=${limit}` + ); + return page ? { items: page.items, total: page.total } : null; + }); +} + +async function getTelegramActivitySnapshot(): Promise { + const status = await getCurrentStatus(); + + if (!status.running || !status.mode) { + return { + running: false, + poolName: status.poolName, + activePoolIndex: status.activePoolIndex, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: null, + channels: null, + }; + } + + const isJdMode = status.mode === 'jd'; + const containerName = isJdMode ? 'sv2-jdc' : 'sv2-translator'; + const port = isJdMode ? JDC_MONITORING_PORT : TRANSLATOR_MONITORING_PORT; + const baseUrl = `${getContainerUrl(containerName, port)}/api/v1`; + const [global, serverChannels, monitoringClients] = await Promise.all([ + fetchMonitoringJson(`${baseUrl}/global`), + fetchAllMonitoringChannels(`${baseUrl}/server/channels`), + isJdMode + ? fetchAllMonitoringItems(`${baseUrl}/clients`) + : Promise.resolve(null), + ]); + + const clients = isJdMode ? global?.sv2_clients : global?.sv1_clients; + let miningChannels: TelegramMiningChannel[] | null = null; + + if (isJdMode && monitoringClients) { + const downstreamResponses = await Promise.all( + monitoringClients.map(async (client) => ({ + clientId: client.client_id, + channels: await fetchAllMonitoringChannels( + `${baseUrl}/clients/${client.client_id}/channels` + ), + })) + ); + + if (downstreamResponses.every((response) => response.channels !== null)) { + miningChannels = downstreamResponses.flatMap(({ clientId, channels }) => { + if (!channels) return []; + return channels.map(({ kind, channel }) => ({ + key: `jdc:${clientId}:${kind}:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })); + }); + } + } else if (!isJdMode && serverChannels) { + miningChannels = serverChannels.map(({ kind, channel }) => ({ + key: `translator:server:${kind}:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })); + } + + return { + running: true, + poolName: status.poolName, + activePoolIndex: status.activePoolIndex, + hashrate: clients?.total_hashrate ?? global?.server?.total_hashrate ?? null, + workers: getTelegramWorkerCount( + isJdMode, + global?.sv1_clients, + global?.sv2_clients, + ), + sharesSubmitted: serverChannels + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_submitted, 0) + : null, + sharesAccepted: serverChannels + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_acknowledged, 0) + : null, + sharesRejected: serverChannels + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_rejected, 0) + : null, + channels: miningChannels, + }; +} + /** * Proxy requests to Translator monitoring API * This avoids CORS issues when the frontend is served from a different port @@ -777,6 +1062,17 @@ async function reconcileShouldBeRunning(): Promise { } } +async function pollTelegramNotifications(): Promise { + try { + await telegramService.poll(getTelegramActivitySnapshot); + } catch (error) { + console.warn( + 'Telegram notification check failed:', + error instanceof Error ? error.message : 'Unknown error' + ); + } +} + app.listen(PORT, () => { const dockerConnection = getDockerConnectionInfo(); @@ -801,6 +1097,13 @@ app.listen(PORT, () => { setInterval(() => { void reconcileShouldBeRunning(); }, AUTO_START_RETRY_INTERVAL_MS); + + // Telegram notifications run in the local backend, so they keep working + // while the browser UI is closed. + void pollTelegramNotifications(); + telegramMonitorTimer = setInterval(() => { + void pollTelegramNotifications(); + }, TELEGRAM_POLL_INTERVAL_MS); }); // Graceful shutdown: stop mining containers when sv2-ui exits @@ -810,6 +1113,11 @@ async function shutdown(signal: string) { if (isShuttingDown) return; isShuttingDown = true; + if (telegramMonitorTimer) { + clearInterval(telegramMonitorTimer); + telegramMonitorTimer = null; + } + console.log(`\n${signal} received. Stopping mining containers...`); try { await stopStack(); diff --git a/server/src/pool-validation.ts b/server/src/pool-validation.ts index accf3a7a..9e671460 100644 --- a/server/src/pool-validation.ts +++ b/server/src/pool-validation.ts @@ -5,7 +5,6 @@ const MAX_POOL_NAME_LENGTH = 128; const MAX_POOL_ADDRESS_LENGTH = 255; const MAX_AUTHORITY_KEY_LENGTH = 128; const MAX_IDENTITY_LENGTH = 512; -export const MAX_FALLBACK_POOLS = 16; // These characters can escape or terminate a generated TOML basic string. // eslint-disable-next-line no-control-regex diff --git a/server/src/telegram.test.ts b/server/src/telegram.test.ts new file mode 100644 index 00000000..4cf58d2b --- /dev/null +++ b/server/src/telegram.test.ts @@ -0,0 +1,441 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; + +import { + collectPaginatedMonitoringItems, + formatTelegramStatus, + getTelegramWorkerCount, + getStatusChangeMessage, + TelegramConfigError, + TelegramService, +} from './telegram.js'; +import type { TelegramActivitySnapshot } from './telegram.js'; + +const BOT_TOKEN = '123456:test-token'; +const BOT = { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', +}; + +type FetchCall = { + method: string; + url: string; + body: Record; +}; + +function createTelegramFetch(initialResults: Record = {}) { + const calls: FetchCall[] = []; + const results = new Map( + Object.entries(initialResults).map(([method, values]) => [method, [...values]]) + ); + + const fetchImplementation = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = url.split('/').at(-1) ?? ''; + const body = init?.body + ? JSON.parse(String(init.body)) as Record + : {}; + calls.push({ method, url, body }); + + const queued = results.get(method); + const result = queued?.length + ? queued.shift() + : method === 'getUpdates' + ? [] + : { message_id: calls.length }; + if (result instanceof Error) throw result; + + return new Response(JSON.stringify({ + ok: true, + result, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + return { + calls, + fetchImplementation, + enqueue(method: string, ...values: unknown[]) { + const queued = results.get(method) ?? []; + queued.push(...values); + results.set(method, queued); + }, + callsFor(method: string) { + return calls.filter((call) => call.method === method); + }, + }; +} + +async function createSettingsFile(t: TestContext): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sv2-telegram-test-')); + t.after(async () => { + await fs.rm(directory, { recursive: true, force: true }); + }); + return path.join(directory, 'telegram.json'); +} + +function snapshot( + update: Partial = {} +): TelegramActivitySnapshot { + return { + running: true, + poolName: 'Primary pool', + activePoolIndex: 0, + hashrate: 125_000_000_000_000, + workers: 3, + sharesSubmitted: 42, + sharesAccepted: 40, + sharesRejected: 2, + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 0, + bestDifficulty: 1250, + }], + ...update, + }; +} + +test('collects every monitoring page using the reported total', async () => { + const offsets: number[] = []; + const items = await collectPaginatedMonitoringItems(async (offset, limit) => { + offsets.push(offset); + const total = 205; + const pageLength = Math.min(limit, total - offset); + return { + total, + items: Array.from({ length: pageLength }, (_, index) => offset + index), + }; + }); + + assert.deepEqual(offsets, [0, 100, 200]); + assert.equal(items?.length, 205); + assert.equal(items?.at(-1), 204); +}); + +test('returns no partial monitoring snapshot when a later page fails', async () => { + const items = await collectPaginatedMonitoringItems(async (offset) => ( + offset === 0 + ? { total: 101, items: Array.from({ length: 100 }, (_, index) => index) } + : null + )); + + assert.equal(items, null); +}); + +test('counts JD channels as workers instead of downstream connections', () => { + assert.equal( + getTelegramWorkerCount( + true, + { total_clients: 4 }, + { total_channels: 17 }, + ), + 17, + ); + assert.equal( + getTelegramWorkerCount( + false, + { total_clients: 4 }, + { total_channels: 17 }, + ), + 4, + ); +}); + +async function pairService( + t: TestContext, + telegram = createTelegramFetch({ getMe: [BOT] }) +) { + const settingsFile = await createSettingsFile(t); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + const connected = await service.connectBot(BOT_TOKEN); + const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + telegram.enqueue('getUpdates', [{ + update_id: 77, + message: { + message_id: 4, + text: `/start ${pairingCode}`, + chat: { + id: 987, + type: 'private', + first_name: 'Miner', + username: 'miner_one', + }, + }, + }]); + await service.pairChat(); + return { service, settingsFile, telegram }; +} + +test('formats a compact mining summary with block and difficulty data', () => { + assert.equal( + formatTelegramStatus(snapshot()), + [ + '⛏ SV2 mining status', + 'Status: Running', + 'Pool: Primary pool', + 'Hashrate: 125 TH/s', + 'Workers: 3', + 'Shares: 42 submitted Β· 40 accepted Β· 2 rejected', + 'Blocks found: 0', + 'Best difficulty: 1,250', + ].join('\n') + ); +}); + +test('reports only meaningful status changes', () => { + const stopped = snapshot({ + running: false, + poolName: null, + activePoolIndex: null, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: null, + channels: null, + }); + + assert.match(getStatusChangeMessage(stopped, snapshot()) ?? '', /mining started/); + assert.match( + getStatusChangeMessage(snapshot(), snapshot({ poolName: 'Fallback pool' })) ?? '', + /active pool changed/ + ); + assert.equal(getStatusChangeMessage(snapshot(), snapshot()), null); +}); + +test('pairs a private chat with the three critical alerts enabled by default', async (t) => { + const { service, settingsFile, telegram } = await pairService(t); + const paired = await service.getSettings(); + + assert.equal(paired.paired, true); + assert.equal(paired.enabled, true); + assert.equal(paired.recipient, '@miner_one'); + assert.equal(paired.pairingUrl, null); + assert.equal(paired.notifyOnBlockFound, true); + assert.equal(paired.notifyOnBestDifficulty, true); + assert.equal(paired.notifyOnPoolChange, true); + assert.equal(paired.notifyOnStatusChange, false); + assert.equal(paired.notifyOnWorkerChange, false); + assert.equal(paired.notifyOnRejectedShares, false); + assert.equal(paired.summaryIntervalMinutes, 0); + assert.equal(JSON.stringify(paired).includes(BOT_TOKEN), false); + assert.match( + String(telegram.callsFor('sendMessage').at(-1)?.body.text), + /Block found, new best difficulty, and pool failover/ + ); + + const savedMode = (await fs.stat(settingsFile)).mode & 0o777; + assert.equal(savedMode, 0o600); +}); + +test('does not pair a chat until the matching Start command arrives', async (t) => { + const settingsFile = await createSettingsFile(t); + const telegram = createTelegramFetch({ + getMe: [BOT], + getUpdates: [[]], + }); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + await service.connectBot(BOT_TOKEN); + + await assert.rejects( + service.pairChat(), + (error: unknown) => + error instanceof TelegramConfigError && + error.message.includes('press Start') + ); +}); + +test('establishes a baseline and announces only a new block', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ channels: null })); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 1, + bestDifficulty: 99_000, + }], + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^πŸŽ‰ Block found!/); + assert.match(alert, /Worker: miner-one/); + assert.doesNotMatch(alert, /New best difficulty/); + + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 1, + bestDifficulty: 99_000, + }], + })); + assert.equal(telegram.callsFor('sendMessage').length, 2); +}); + +test('announces a new best difficulty on an existing channel', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + await service.poll(async () => snapshot({ + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 0, + bestDifficulty: 2500, + }], + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^πŸ† New best difficulty!/); + assert.match(alert, /Difficulty: 2,500/); +}); + +test('detects failover across a temporary unknown pool state', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot()); + await service.poll(async () => snapshot({ + poolName: null, + activePoolIndex: null, + })); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ + poolName: 'Fallback pool', + activePoolIndex: 1, + })); + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^πŸ” Pool failover/); + assert.match(alert, /From: Primary pool/); + assert.match(alert, /To: Fallback pool/); +}); + +test('detects failover between custom pools with the same name', async (t) => { + const { service, telegram } = await pairService(t); + + await service.poll(async () => snapshot({ poolName: 'Custom Pool' })); + await service.poll(async () => snapshot({ + poolName: 'Custom Pool', + activePoolIndex: 1, + })); + + const alert = String(telegram.callsFor('sendMessage').at(-1)?.body.text); + assert.match(alert, /^πŸ” Pool failover/); + assert.match(alert, /From: Custom Pool \(Primary\)/); + assert.match(alert, /To: Custom Pool \(Fallback 1\)/); +}); + +test('configures alerts with the bot settings keyboard', async (t) => { + const { service, telegram } = await pairService(t); + + telegram.enqueue('getUpdates', [{ + update_id: 78, + message: { + message_id: 8, + text: '/settings', + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }]); + await service.poll(async () => snapshot()); + + const settingsMessage = telegram.callsFor('sendMessage').at(-1); + assert.match(String(settingsMessage?.body.text), /SV2 Telegram alerts/); + assert.ok(settingsMessage?.body.reply_markup); + + telegram.enqueue('getUpdates', [ + { + update_id: 79, + callback_query: { + id: 'callback-1', + data: 'sv2:toggle:block', + message: { + message_id: 9, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }, + }, + { + update_id: 80, + callback_query: { + id: 'callback-2', + data: 'sv2:toggle:best', + message: { + message_id: 9, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }, + }, + ]); + await service.poll(async () => snapshot()); + + const settings = await service.getSettings(); + assert.equal(settings.notifyOnBlockFound, false); + assert.equal(settings.notifyOnBestDifficulty, false); + assert.equal(telegram.callsFor('answerCallbackQuery').length, 2); + assert.equal(telegram.callsFor('editMessageText').length, 2); + assert.equal( + (telegram.callsFor('getUpdates').at(-1)?.body.offset), + 79 + ); +}); + +test('keeps bot commands active when notifications are disabled', async (t) => { + const { service, telegram } = await pairService(t); + await service.updateSettings({ enabled: false }); + telegram.enqueue('getUpdates', [{ + update_id: 78, + message: { + message_id: 10, + text: '/status', + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }]); + + await service.poll(async () => snapshot()); + assert.match( + String(telegram.callsFor('sendMessage').at(-1)?.body.text), + /SV2 mining status/ + ); +}); + +test('migrates the original proof-of-concept settings', async (t) => { + const settingsFile = await createSettingsFile(t); + await fs.writeFile(settingsFile, JSON.stringify({ + version: 1, + botToken: BOT_TOKEN, + botUsername: 'sv2_alerts_bot', + botName: 'SV2 alerts', + pairingCode: null, + chatId: 987, + recipient: '@miner_one', + enabled: true, + notifyOnStatusChange: true, + summaryIntervalMinutes: 60, + })); + + const service = new TelegramService( + settingsFile, + createTelegramFetch().fetchImplementation + ); + const settings = await service.getSettings(); + assert.equal(settings.notifyOnBlockFound, true); + assert.equal(settings.notifyOnBestDifficulty, true); + assert.equal(settings.notifyOnPoolChange, true); + assert.equal(settings.notifyOnStatusChange, true); + assert.equal(settings.summaryIntervalMinutes, 60); +}); diff --git a/server/src/telegram.ts b/server/src/telegram.ts new file mode 100644 index 00000000..52627415 --- /dev/null +++ b/server/src/telegram.ts @@ -0,0 +1,1083 @@ +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const MIN_SUMMARY_INTERVAL_MINUTES = 15; +const MAX_SUMMARY_INTERVAL_MINUTES = 24 * 60; +const SUMMARY_INTERVAL_OPTIONS = [0, 15, 60, 6 * 60] as const; + +type FetchImplementation = typeof fetch; + +type TelegramBot = { + id: number; + is_bot: boolean; + first_name: string; + username?: string; +}; + +type TelegramChat = { + id: number; + type: string; + first_name?: string; + last_name?: string; + username?: string; + title?: string; +}; + +type TelegramMessage = { + message_id: number; + text?: string; + chat: TelegramChat; +}; + +type TelegramCallbackQuery = { + id: string; + data?: string; + message?: TelegramMessage; +}; + +type TelegramUpdate = { + update_id: number; + message?: TelegramMessage; + callback_query?: TelegramCallbackQuery; +}; + +type TelegramApiResponse = { + ok: boolean; + result?: T; + description?: string; + error_code?: number; +}; + +type TelegramAlertSettings = { + enabled: boolean; + notifyOnBlockFound: boolean; + notifyOnBestDifficulty: boolean; + notifyOnPoolChange: boolean; + notifyOnStatusChange: boolean; + notifyOnWorkerChange: boolean; + notifyOnRejectedShares: boolean; + summaryIntervalMinutes: number; +}; + +type SavedTelegramSettings = TelegramAlertSettings & { + version: 2; + botToken: string; + botUsername: string; + botName: string; + pairingCode: string | null; + chatId: number | null; + recipient: string | null; + lastUpdateId: number | null; +}; + +type LegacySavedTelegramSettings = { + version: 1; + botToken: string; + botUsername: string; + botName: string; + pairingCode: string | null; + chatId: number | null; + recipient: string | null; + enabled: boolean; + notifyOnStatusChange: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettings = TelegramAlertSettings & { + connected: boolean; + paired: boolean; + botUsername: string | null; + botName: string | null; + recipient: string | null; + pairingUrl: string | null; +}; + +export type TelegramSettingsUpdate = Partial; + +export type TelegramMiningChannel = { + key: string; + userIdentity: string; + blocksFound: number; + bestDifficulty: number; +}; + +export type TelegramActivitySnapshot = { + running: boolean; + poolName: string | null; + activePoolIndex: number | null; + hashrate: number | null; + workers: number | null; + sharesSubmitted: number | null; + sharesAccepted: number | null; + sharesRejected: number | null; + channels: TelegramMiningChannel[] | null; +}; + +type MonitoringPage = { + items: T[]; + total: number; +}; + +export async function collectPaginatedMonitoringItems( + fetchPage: (offset: number, limit: number) => Promise | null>, + pageSize = 100, +): Promise { + if (!Number.isInteger(pageSize) || pageSize <= 0) { + throw new RangeError('Monitoring page size must be a positive integer'); + } + + const items: T[] = []; + let offset = 0; + + while (true) { + const page = await fetchPage(offset, pageSize); + if (!page) return null; + + items.push(...page.items); + if (offset + pageSize >= page.total) return items; + offset += pageSize; + } +} + +export function getTelegramWorkerCount( + isJdMode: boolean, + sv1Clients: { total_clients: number } | null | undefined, + sv2Clients: { total_channels: number } | null | undefined, +): number | null { + return isJdMode + ? sv2Clients?.total_channels ?? null + : sv1Clients?.total_clients ?? null; +} + +export class TelegramConfigError extends Error {} + +export class TelegramApiError extends Error { + constructor( + message: string, + readonly statusCode: number + ) { + super(message); + } +} + +const DEFAULT_ALERT_SETTINGS: TelegramAlertSettings = { + enabled: true, + notifyOnBlockFound: true, + notifyOnBestDifficulty: true, + notifyOnPoolChange: true, + notifyOnStatusChange: false, + notifyOnWorkerChange: false, + notifyOnRejectedShares: false, + summaryIntervalMinutes: 0, +}; + +function getEmptySettings(): TelegramSettings { + return { + connected: false, + paired: false, + botUsername: null, + botName: null, + recipient: null, + pairingUrl: null, + ...DEFAULT_ALERT_SETTINGS, + enabled: false, + }; +} + +function toPublicSettings(settings: SavedTelegramSettings | null): TelegramSettings { + if (!settings) return getEmptySettings(); + + return { + connected: true, + paired: settings.chatId !== null, + botUsername: settings.botUsername, + botName: settings.botName, + recipient: settings.recipient, + pairingUrl: settings.pairingCode + ? `https://t.me/${settings.botUsername}?start=${settings.pairingCode}` + : null, + enabled: settings.enabled, + notifyOnBlockFound: settings.notifyOnBlockFound, + notifyOnBestDifficulty: settings.notifyOnBestDifficulty, + notifyOnPoolChange: settings.notifyOnPoolChange, + notifyOnStatusChange: settings.notifyOnStatusChange, + notifyOnWorkerChange: settings.notifyOnWorkerChange, + notifyOnRejectedShares: settings.notifyOnRejectedShares, + summaryIntervalMinutes: settings.summaryIntervalMinutes, + }; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasConnectionFields(settings: Record): boolean { + return typeof settings.botToken === 'string' && + typeof settings.botUsername === 'string' && + typeof settings.botName === 'string' && + (settings.pairingCode === null || typeof settings.pairingCode === 'string') && + (settings.chatId === null || typeof settings.chatId === 'number') && + (settings.recipient === null || typeof settings.recipient === 'string') && + typeof settings.enabled === 'boolean' && + typeof settings.notifyOnStatusChange === 'boolean' && + Number.isInteger(settings.summaryIntervalMinutes); +} + +function parseSavedSettings(value: unknown): SavedTelegramSettings | null { + if (!isObject(value) || !hasConnectionFields(value)) return null; + + if (value.version === 1) { + const legacy = value as LegacySavedTelegramSettings; + return { + ...legacy, + version: 2, + notifyOnBlockFound: true, + notifyOnBestDifficulty: true, + notifyOnPoolChange: true, + notifyOnWorkerChange: false, + notifyOnRejectedShares: false, + lastUpdateId: null, + }; + } + + if ( + value.version !== 2 || + typeof value.notifyOnBlockFound !== 'boolean' || + typeof value.notifyOnBestDifficulty !== 'boolean' || + typeof value.notifyOnPoolChange !== 'boolean' || + typeof value.notifyOnWorkerChange !== 'boolean' || + typeof value.notifyOnRejectedShares !== 'boolean' || + (value.lastUpdateId !== null && !Number.isInteger(value.lastUpdateId)) + ) { + return null; + } + + return value as SavedTelegramSettings; +} + +function getRecipientLabel(chat: TelegramChat): string { + if (chat.username) return `@${chat.username}`; + if (chat.title) return chat.title; + + const name = [chat.first_name, chat.last_name].filter(Boolean).join(' ').trim(); + return name || 'Telegram chat'; +} + +function getStartParameter(text: string | undefined): string | null { + if (!text) return null; + const match = text.trim().match(/^\/start(?:@[A-Za-z0-9_]+)?\s+([A-Za-z0-9_-]+)$/); + return match?.[1] ?? null; +} + +function getCommand(text: string | undefined): string | null { + if (!text) return null; + const match = text.trim().match(/^\/([a-z]+)(?:@[A-Za-z0-9_]+)?(?:\s|$)/i); + return match?.[1]?.toLowerCase() ?? null; +} + +function formatHashrate(hashrate: number | null): string | null { + if (hashrate === null || !Number.isFinite(hashrate)) return null; + + const units = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s', 'EH/s']; + let value = Math.max(0, hashrate); + let unitIndex = 0; + + while (value >= 1000 && unitIndex < units.length - 1) { + value /= 1000; + unitIndex += 1; + } + + const decimals = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(decimals)} ${units[unitIndex]}`; +} + +function formatDifficulty(difficulty: number): string { + if (!Number.isFinite(difficulty)) return 'Unknown'; + return new Intl.NumberFormat('en-US', { + maximumFractionDigits: difficulty >= 100 ? 0 : 2, + }).format(difficulty); +} + +function formatPoolPriority(index: number): string { + return index === 0 ? 'Primary' : `Fallback ${index}`; +} + +function getBestChannel(channels: TelegramMiningChannel[] | null): TelegramMiningChannel | null { + if (!channels?.length) return null; + return channels.reduce((best, channel) => + channel.bestDifficulty > best.bestDifficulty ? channel : best + ); +} + +export function formatTelegramStatus( + snapshot: TelegramActivitySnapshot, + heading = '⛏ SV2 mining status' +): string { + const lines = [ + heading, + `Status: ${snapshot.running ? 'Running' : 'Stopped'}`, + ]; + + if (snapshot.poolName) lines.push(`Pool: ${snapshot.poolName}`); + + const hashrate = formatHashrate(snapshot.hashrate); + if (hashrate) lines.push(`Hashrate: ${hashrate}`); + if (snapshot.workers !== null) lines.push(`Workers: ${snapshot.workers.toLocaleString()}`); + + if (snapshot.sharesSubmitted !== null) { + const shareParts = [`${snapshot.sharesSubmitted.toLocaleString()} submitted`]; + if (snapshot.sharesAccepted !== null) { + shareParts.push(`${snapshot.sharesAccepted.toLocaleString()} accepted`); + } + if (snapshot.sharesRejected !== null) { + shareParts.push(`${snapshot.sharesRejected.toLocaleString()} rejected`); + } + lines.push(`Shares: ${shareParts.join(' Β· ')}`); + } + + if (snapshot.channels) { + const blocksFound = snapshot.channels.reduce( + (total, channel) => total + channel.blocksFound, + 0 + ); + const bestChannel = getBestChannel(snapshot.channels); + lines.push(`Blocks found: ${blocksFound.toLocaleString()}`); + if (bestChannel) { + lines.push(`Best difficulty: ${formatDifficulty(bestChannel.bestDifficulty)}`); + } + } + + return lines.join('\n'); +} + +export function getStatusChangeMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string | null { + if (!previous.running && current.running) { + return formatTelegramStatus(current, '🟒 SV2 mining started'); + } + + if (previous.running && !current.running) { + return formatTelegramStatus(current, 'πŸ”΄ SV2 mining stopped'); + } + + if ( + current.running && + current.poolName !== null && + ( + previous.poolName !== current.poolName || + ( + previous.activePoolIndex !== null && + current.activePoolIndex !== null && + previous.activePoolIndex !== current.activePoolIndex + ) + ) + ) { + return formatTelegramStatus(current, 'πŸ” SV2 active pool changed'); + } + + return null; +} + +function getMiningStatusChangeMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string | null { + if (!previous.running && current.running) { + return formatTelegramStatus(current, '🟒 SV2 mining started'); + } + if (previous.running && !current.running) { + return formatTelegramStatus(current, 'πŸ”΄ SV2 mining stopped'); + } + return null; +} + +function getBlockFoundMessages( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot +): string[] { + if (!previous.channels || !current.channels) return []; + + const previousByKey = new Map(previous.channels.map((channel) => [channel.key, channel])); + return current.channels.flatMap((channel) => { + const before = previousByKey.get(channel.key); + if (!before || channel.blocksFound <= before.blocksFound) return []; + + const delta = channel.blocksFound - before.blocksFound; + const lines = ['πŸŽ‰ Block found!']; + if (current.poolName) lines.push(`Pool: ${current.poolName}`); + lines.push(`Worker: ${channel.userIdentity}`); + lines.push( + delta === 1 + ? `Channel total: ${channel.blocksFound.toLocaleString()}` + : `New blocks: ${delta.toLocaleString()} Β· Channel total: ${channel.blocksFound.toLocaleString()}` + ); + lines.push(`Best difficulty: ${formatDifficulty(channel.bestDifficulty)}`); + return [lines.join('\n')]; + }); +} + +function getBestDifficultyMessage( + previous: TelegramActivitySnapshot, + current: TelegramActivitySnapshot, + highWatermark: number +): string | null { + if (!previous.channels || !current.channels) return null; + + const previousByKey = new Map(previous.channels.map((channel) => [channel.key, channel])); + const improved = current.channels + .filter((channel) => { + const before = previousByKey.get(channel.key); + return before && + channel.bestDifficulty > before.bestDifficulty && + channel.bestDifficulty > highWatermark; + }) + .sort((left, right) => right.bestDifficulty - left.bestDifficulty)[0]; + + if (!improved) return null; + + const lines = ['πŸ† New best difficulty!']; + if (current.poolName) lines.push(`Pool: ${current.poolName}`); + lines.push(`Worker: ${improved.userIdentity}`); + lines.push(`Difficulty: ${formatDifficulty(improved.bestDifficulty)}`); + return lines.join('\n'); +} + +function formatSummaryInterval(minutes: number): string { + if (minutes === 0) return 'Off'; + if (minutes % 60 === 0) return `${minutes / 60}h`; + return `${minutes}m`; +} + +function getSettingsMessage(settings: SavedTelegramSettings): string { + return [ + 'βš™οΈ SV2 Telegram alerts', + '', + 'Tap a button to toggle an alert. Critical defaults are block found, new best difficulty, and pool failover.', + '', + `Notifications: ${settings.enabled ? 'ON' : 'OFF'}`, + `Summary: ${formatSummaryInterval(settings.summaryIntervalMinutes)}`, + ].join('\n'); +} + +function toggleButton(enabled: boolean, label: string, callbackData: string) { + return { + text: `${enabled ? 'βœ…' : '⬜'} ${label}`, + callback_data: callbackData, + }; +} + +function getSettingsKeyboard(settings: SavedTelegramSettings) { + return { + inline_keyboard: [ + [toggleButton(settings.enabled, 'Notifications', 'sv2:toggle:enabled')], + [ + toggleButton(settings.notifyOnBlockFound, 'Block found', 'sv2:toggle:block'), + toggleButton(settings.notifyOnBestDifficulty, 'Best difficulty', 'sv2:toggle:best'), + ], + [toggleButton(settings.notifyOnPoolChange, 'Pool failover', 'sv2:toggle:pool')], + [ + toggleButton(settings.notifyOnStatusChange, 'Mining status', 'sv2:toggle:status'), + toggleButton(settings.notifyOnWorkerChange, 'Workers', 'sv2:toggle:workers'), + ], + [toggleButton(settings.notifyOnRejectedShares, 'Rejected shares', 'sv2:toggle:rejected')], + [{ + text: `⏱ Summary: ${formatSummaryInterval(settings.summaryIntervalMinutes)}`, + callback_data: 'sv2:toggle:summary', + }], + ], + }; +} + +function getHelpMessage(): string { + return [ + '⛏ SV2 UI Telegram bot', + '', + '/settings β€” choose alerts', + '/status β€” current mining status', + '/help β€” show these commands', + ].join('\n'); +} + +function cycleSummaryInterval(current: number): number { + const currentIndex = SUMMARY_INTERVAL_OPTIONS.indexOf( + current as typeof SUMMARY_INTERVAL_OPTIONS[number] + ); + return SUMMARY_INTERVAL_OPTIONS[ + currentIndex === -1 ? 0 : (currentIndex + 1) % SUMMARY_INTERVAL_OPTIONS.length + ]; +} + +export class TelegramService { + private settings: SavedTelegramSettings | null = null; + private initialized = false; + private previousSnapshot: TelegramActivitySnapshot | null = null; + private channelBaselines = new Map(); + private bestDifficultyHighWatermark = 0; + private lastSummaryAt: number | null = null; + private lastKnownPool: { name: string; index: number } | null = null; + private pollInProgress = false; + + constructor( + private readonly settingsFile: string, + private readonly fetchImplementation: FetchImplementation = fetch + ) {} + + async initialize(): Promise { + if (this.initialized) return; + + try { + const raw = await fs.readFile(this.settingsFile, 'utf8'); + const parsed = parseSavedSettings(JSON.parse(raw) as unknown); + if (!parsed) { + throw new TelegramConfigError('Stored Telegram settings are invalid'); + } + this.settings = parsed; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT') { + console.warn( + 'Telegram settings could not be loaded:', + error instanceof Error ? error.message : 'Unknown error' + ); + } + this.settings = null; + } + + this.initialized = true; + } + + async getSettings(): Promise { + await this.initialize(); + return toPublicSettings(this.settings); + } + + async connectBot(botToken: string): Promise { + await this.initialize(); + const token = botToken.trim(); + + if (!token || token.length > 256 || /\s/.test(token)) { + throw new TelegramConfigError('Enter a valid Telegram bot token'); + } + + const bot = await this.callApi(token, 'getMe'); + if (!bot.is_bot || !bot.username) { + throw new TelegramConfigError('Telegram did not return a usable bot username'); + } + + this.settings = { + version: 2, + botToken: token, + botUsername: bot.username, + botName: bot.first_name, + pairingCode: `sv2_${randomBytes(18).toString('base64url')}`, + chatId: null, + recipient: null, + lastUpdateId: null, + ...DEFAULT_ALERT_SETTINGS, + enabled: false, + }; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async pairChat(): Promise { + await this.initialize(); + const settings = this.requireConnected(); + + if (settings.chatId !== null) { + return toPublicSettings(settings); + } + if (!settings.pairingCode) { + throw new TelegramConfigError('Create a new Telegram pairing link first'); + } + + const updates = await this.callApi( + settings.botToken, + 'getUpdates', + { + offset: -100, + limit: 100, + timeout: 0, + allowed_updates: ['message'], + } + ); + + const matchingUpdate = [...updates].reverse().find((update) => + update.message?.chat.type === 'private' && + getStartParameter(update.message.text) === settings.pairingCode + ); + const chat = matchingUpdate?.message?.chat; + + if (!chat) { + throw new TelegramConfigError( + `Open @${settings.botUsername} from the pairing link and press Start, then try again` + ); + } + + const pairedSettings: SavedTelegramSettings = { + ...settings, + pairingCode: null, + chatId: chat.id, + recipient: getRecipientLabel(chat), + lastUpdateId: updates.reduce( + (latest, update) => Math.max(latest, update.update_id), + matchingUpdate.update_id + ), + ...DEFAULT_ALERT_SETTINGS, + }; + + await this.sendMessage( + pairedSettings.botToken, + chat.id, + [ + 'βœ… SV2 UI is linked.', + '', + 'Block found, new best difficulty, and pool failover alerts are enabled.', + 'Use /settings to configure alerts or /status for a live summary.', + ].join('\n') + ); + + this.settings = pairedSettings; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async updateSettings(update: TelegramSettingsUpdate): Promise { + await this.initialize(); + const settings = this.requirePaired(); + const next = { + ...settings, + enabled: update.enabled ?? settings.enabled, + notifyOnBlockFound: update.notifyOnBlockFound ?? settings.notifyOnBlockFound, + notifyOnBestDifficulty: + update.notifyOnBestDifficulty ?? settings.notifyOnBestDifficulty, + notifyOnPoolChange: update.notifyOnPoolChange ?? settings.notifyOnPoolChange, + notifyOnStatusChange: update.notifyOnStatusChange ?? settings.notifyOnStatusChange, + notifyOnWorkerChange: update.notifyOnWorkerChange ?? settings.notifyOnWorkerChange, + notifyOnRejectedShares: + update.notifyOnRejectedShares ?? settings.notifyOnRejectedShares, + summaryIntervalMinutes: + update.summaryIntervalMinutes ?? settings.summaryIntervalMinutes, + }; + + const booleanKeys = [ + 'enabled', + 'notifyOnBlockFound', + 'notifyOnBestDifficulty', + 'notifyOnPoolChange', + 'notifyOnStatusChange', + 'notifyOnWorkerChange', + 'notifyOnRejectedShares', + ] as const; + if (booleanKeys.some((key) => typeof next[key] !== 'boolean')) { + throw new TelegramConfigError('Notification settings must be true or false'); + } + if ( + !Number.isInteger(next.summaryIntervalMinutes) || + (next.summaryIntervalMinutes !== 0 && + ( + next.summaryIntervalMinutes < MIN_SUMMARY_INTERVAL_MINUTES || + next.summaryIntervalMinutes > MAX_SUMMARY_INTERVAL_MINUTES + )) + ) { + throw new TelegramConfigError( + `Summary interval must be 0 (off) or ${MIN_SUMMARY_INTERVAL_MINUTES}-${MAX_SUMMARY_INTERVAL_MINUTES} minutes` + ); + } + + this.settings = next; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async sendTestMessage(): Promise { + await this.initialize(); + const settings = this.requirePaired(); + await this.sendMessage( + settings.botToken, + settings.chatId, + 'βœ… SV2 UI Telegram notifications are working.' + ); + } + + async disconnect(): Promise { + await this.initialize(); + this.settings = null; + this.resetMonitorState(); + + try { + await fs.unlink(this.settingsFile); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + return getEmptySettings(); + } + + async poll(snapshotProvider: () => Promise): Promise { + await this.initialize(); + if (this.pollInProgress) return; + + const initialSettings = this.settings; + if (!initialSettings || initialSettings.chatId === null) { + this.resetMonitorState(); + return; + } + + this.pollInProgress = true; + let snapshotPromise: Promise | null = null; + const getSnapshot = () => { + snapshotPromise ??= snapshotProvider(); + return snapshotPromise; + }; + + try { + await this.processBotUpdates( + initialSettings as SavedTelegramSettings & { chatId: number }, + getSnapshot + ); + const settings = this.requirePaired(); + if (!settings.enabled) { + this.resetMonitorState(); + return; + } + + const current = await getSnapshot(); + const now = Date.now(); + + if (!this.previousSnapshot) { + this.previousSnapshot = current; + this.updateChannelBaselines(current.channels); + this.updateLastKnownPool(current); + this.lastSummaryAt = now; + return; + } + + const messages: string[] = []; + const channelBaselineSnapshot = { + ...this.previousSnapshot, + channels: [...this.channelBaselines.values()], + }; + const blockMessages = settings.notifyOnBlockFound + ? getBlockFoundMessages(channelBaselineSnapshot, current) + : []; + messages.push(...blockMessages); + + if ( + settings.notifyOnBestDifficulty && + blockMessages.length === 0 + ) { + const bestDifficultyMessage = getBestDifficultyMessage( + channelBaselineSnapshot, + current, + this.bestDifficultyHighWatermark + ); + if (bestDifficultyMessage) messages.push(bestDifficultyMessage); + } + + const currentPool = current.poolName !== null && current.activePoolIndex !== null + ? { name: current.poolName, index: current.activePoolIndex } + : null; + if ( + settings.notifyOnPoolChange && + current.running && + this.lastKnownPool && + currentPool && + ( + this.lastKnownPool.index !== currentPool.index || + this.lastKnownPool.name !== currentPool.name + ) + ) { + const duplicateName = this.lastKnownPool.name === currentPool.name; + const previousLabel = duplicateName + ? `${this.lastKnownPool.name} (${formatPoolPriority(this.lastKnownPool.index)})` + : this.lastKnownPool.name; + const currentLabel = duplicateName + ? `${currentPool.name} (${formatPoolPriority(currentPool.index)})` + : currentPool.name; + messages.push([ + 'πŸ” Pool failover', + `From: ${previousLabel}`, + `To: ${currentLabel}`, + ].join('\n')); + } + + if (settings.notifyOnStatusChange) { + const statusMessage = getMiningStatusChangeMessage(this.previousSnapshot, current); + if (statusMessage) messages.push(statusMessage); + } + + if ( + settings.notifyOnWorkerChange && + this.previousSnapshot.workers !== null && + current.workers !== null && + this.previousSnapshot.workers !== current.workers + ) { + messages.push([ + current.workers > this.previousSnapshot.workers + ? '🟒 Worker connected' + : '🟠 Worker disconnected', + `Workers: ${this.previousSnapshot.workers.toLocaleString()} β†’ ${current.workers.toLocaleString()}`, + ].join('\n')); + } + + if ( + settings.notifyOnRejectedShares && + this.previousSnapshot.sharesRejected !== null && + current.sharesRejected !== null && + current.sharesRejected > this.previousSnapshot.sharesRejected + ) { + messages.push([ + '⚠️ Rejected shares increased', + `New rejected shares: ${(current.sharesRejected - this.previousSnapshot.sharesRejected).toLocaleString()}`, + `Total rejected: ${current.sharesRejected.toLocaleString()}`, + ].join('\n')); + } + + const summaryDue = settings.summaryIntervalMinutes > 0 && + this.lastSummaryAt !== null && + now - this.lastSummaryAt >= settings.summaryIntervalMinutes * 60_000; + + if (messages.length > 0) { + await this.sendMessage(settings.botToken, settings.chatId, messages.join('\n\n')); + this.lastSummaryAt = now; + } else if (summaryDue) { + await this.sendMessage( + settings.botToken, + settings.chatId, + formatTelegramStatus(current) + ); + this.lastSummaryAt = now; + } + + this.previousSnapshot = current.channels === null + ? { ...current, channels: this.previousSnapshot.channels } + : current; + this.updateChannelBaselines(current.channels); + this.updateLastKnownPool(current); + } finally { + this.pollInProgress = false; + } + } + + private async processBotUpdates( + settings: SavedTelegramSettings & { chatId: number }, + getSnapshot: () => Promise + ): Promise { + const updates = await this.callApi( + settings.botToken, + 'getUpdates', + { + offset: settings.lastUpdateId === null ? 0 : settings.lastUpdateId + 1, + limit: 100, + timeout: 0, + allowed_updates: ['message', 'callback_query'], + } + ); + + if (updates.length === 0) return; + + for (const update of [...updates].sort((left, right) => left.update_id - right.update_id)) { + if (this.settings) { + this.settings = { ...this.settings, lastUpdateId: update.update_id }; + await this.save(); + } + const currentSettings = this.requirePaired(); + const message = update.message; + if ( + message?.chat.type === 'private' && + message.chat.id === currentSettings.chatId + ) { + const command = getCommand(message.text); + if (command === 'settings' || command === 'alerts') { + await this.sendSettingsMessage(currentSettings); + } else if (command === 'status') { + await this.sendMessage( + currentSettings.botToken, + currentSettings.chatId, + formatTelegramStatus(await getSnapshot()) + ); + } else if (command === 'start' || command === 'help') { + await this.sendMessage( + currentSettings.botToken, + currentSettings.chatId, + getHelpMessage() + ); + } + } + + const callback = update.callback_query; + if ( + callback?.message?.chat.type === 'private' && + callback.message.chat.id === currentSettings.chatId && + callback.data?.startsWith('sv2:toggle:') + ) { + await this.handleToggle(callback); + } + } + } + + private async handleToggle(callback: TelegramCallbackQuery): Promise { + const settings = this.requirePaired(); + const key = callback.data?.slice('sv2:toggle:'.length); + const next = { ...settings }; + + switch (key) { + case 'enabled': + next.enabled = !next.enabled; + break; + case 'block': + next.notifyOnBlockFound = !next.notifyOnBlockFound; + break; + case 'best': + next.notifyOnBestDifficulty = !next.notifyOnBestDifficulty; + break; + case 'pool': + next.notifyOnPoolChange = !next.notifyOnPoolChange; + break; + case 'status': + next.notifyOnStatusChange = !next.notifyOnStatusChange; + break; + case 'workers': + next.notifyOnWorkerChange = !next.notifyOnWorkerChange; + break; + case 'rejected': + next.notifyOnRejectedShares = !next.notifyOnRejectedShares; + break; + case 'summary': + next.summaryIntervalMinutes = cycleSummaryInterval(next.summaryIntervalMinutes); + break; + default: + await this.callApi(settings.botToken, 'answerCallbackQuery', { + callback_query_id: callback.id, + text: 'This alert option is no longer available.', + }); + return; + } + + this.settings = next; + this.resetMonitorState(); + await this.save(); + await this.callApi(settings.botToken, 'answerCallbackQuery', { + callback_query_id: callback.id, + text: 'Alert settings updated.', + }); + + if (callback.message) { + await this.callApi(settings.botToken, 'editMessageText', { + chat_id: settings.chatId, + message_id: callback.message.message_id, + text: getSettingsMessage(next), + reply_markup: getSettingsKeyboard(next), + }); + } + } + + private async sendSettingsMessage(settings: SavedTelegramSettings): Promise { + await this.callApi(settings.botToken, 'sendMessage', { + chat_id: settings.chatId, + text: getSettingsMessage(settings), + reply_markup: getSettingsKeyboard(settings), + }); + } + + private requireConnected(): SavedTelegramSettings { + if (!this.settings) { + throw new TelegramConfigError('Connect a Telegram bot first'); + } + return this.settings; + } + + private requirePaired(): SavedTelegramSettings & { chatId: number } { + const settings = this.requireConnected(); + if (settings.chatId === null) { + throw new TelegramConfigError('Pair a Telegram chat first'); + } + return settings as SavedTelegramSettings & { chatId: number }; + } + + private async save(): Promise { + if (!this.settings) return; + + await fs.mkdir(path.dirname(this.settingsFile), { recursive: true }); + await fs.writeFile( + this.settingsFile, + JSON.stringify(this.settings, null, 2), + { mode: 0o600 } + ); + await fs.chmod(this.settingsFile, 0o600); + } + + private resetMonitorState(): void { + this.previousSnapshot = null; + this.channelBaselines.clear(); + this.bestDifficultyHighWatermark = 0; + this.lastSummaryAt = null; + this.lastKnownPool = null; + } + + private updateLastKnownPool(snapshot: TelegramActivitySnapshot): void { + if (snapshot.poolName !== null && snapshot.activePoolIndex !== null) { + this.lastKnownPool = { + name: snapshot.poolName, + index: snapshot.activePoolIndex, + }; + } + } + + private updateChannelBaselines(channels: TelegramMiningChannel[] | null): void { + if (!channels) return; + + for (const channel of channels) { + this.channelBaselines.set(channel.key, channel); + this.bestDifficultyHighWatermark = Math.max( + this.bestDifficultyHighWatermark, + channel.bestDifficulty + ); + } + } + + private async sendMessage(botToken: string, chatId: number, text: string): Promise { + await this.callApi(botToken, 'sendMessage', { chat_id: chatId, text }); + } + + private async callApi( + botToken: string, + method: string, + body: Record = {} + ): Promise { + let response: Response; + + try { + response = await this.fetchImplementation( + `https://api.telegram.org/bot${botToken}/${method}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + } + ); + } catch { + throw new TelegramApiError( + 'Could not reach Telegram. Check this machine’s internet connection.', + 502 + ); + } + + const payload = await response.json().catch(() => null) as TelegramApiResponse | null; + if (!response.ok || !payload?.ok || payload.result === undefined) { + const description = payload?.description?.replace(/^Bad Request:\s*/i, ''); + const message = description || 'Telegram rejected the request'; + throw new TelegramApiError(message, response.status >= 400 ? response.status : 502); + } + + return payload.result; + } +} diff --git a/shared/src/constants.ts b/shared/src/constants.ts index 7247dadc..ffabaec1 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -40,6 +40,7 @@ export const JDC_MONITORING_PORT = 9091; export const DEFAULT_SHARES_PER_MINUTE = 6; export const DEFAULT_DOWNSTREAM_EXTRANONCE2_SIZE = 4; export const DEFAULT_POOL_PORT = 34254; +export const MAX_FALLBACK_POOLS = 16; export function computeDefaultSocketPath(dataDir: string, network: BitcoinNetwork): string { return network === 'mainnet' ? `${dataDir}/node.sock` : `${dataDir}/${network}/node.sock`; diff --git a/src/components/ExperimentalTab.test.ts b/src/components/ExperimentalTab.test.ts new file mode 100644 index 00000000..6881a0aa --- /dev/null +++ b/src/components/ExperimentalTab.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isTelegramExperimentOpen } from './settings/ExperimentalTab'; + +test('keeps an unconfigured Telegram experiment closed until it is enabled', () => { + const settings = { + connected: false, + paired: false, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, null), false); + assert.equal(isTelegramExperimentOpen(settings, true), true); +}); + +test('opens an existing unpaired Telegram setup unless the user closes it', () => { + const settings = { + connected: true, + paired: false, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, null), true); + assert.equal(isTelegramExperimentOpen(settings, false), false); +}); + +test('uses the backend notification state after Telegram is paired', () => { + const settings = { + connected: true, + paired: true, + enabled: false, + }; + + assert.equal(isTelegramExperimentOpen(settings, true), false); + assert.equal(isTelegramExperimentOpen({ ...settings, enabled: true }, false), true); +}); diff --git a/src/components/pools/PoolPriorityEditor.tsx b/src/components/pools/PoolPriorityEditor.tsx index f274d1fc..62f29235 100644 --- a/src/components/pools/PoolPriorityEditor.tsx +++ b/src/components/pools/PoolPriorityEditor.tsx @@ -3,7 +3,8 @@ import { ArrowDown, ArrowUp, GripVertical, X } from 'lucide-react'; import { DEFAULT_POOL_PORT, type MiningMode, type PoolConfig } from '@sv2-ui/shared'; import { PoolIcon } from '@/components/ui/pool-icon'; import { - createEmptyCustomPool, + appendEmptyCustomPool, + canAddPool, isSamePool, knownPoolToConfig, type KnownPool, @@ -31,10 +32,10 @@ export function PoolPriorityEditor({ const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const draggedIndexRef = useRef(null); - const selectedCustomIndex = pools.findIndex((pool) => !getSelectedPreset(pool, presets)); const unselectedPresets = presets.filter((preset) => ( !pools.some((selectedPool) => isSamePool(selectedPool, preset)) )); + const poolLimitReached = !canAddPool(pools); const togglePreset = (preset: KnownPool) => { const selectedIndex = pools.findIndex((pool) => isSamePool(pool, preset)); @@ -43,7 +44,7 @@ export function PoolPriorityEditor({ return; } - if (preset.badge === 'coming-soon') return; + if (preset.badge === 'coming-soon' || poolLimitReached) return; onChange([ ...pools, @@ -55,20 +56,8 @@ export function PoolPriorityEditor({ ]); }; - const toggleCustomPool = () => { - if (selectedCustomIndex >= 0) { - onChange(pools.filter((_, index) => index !== selectedCustomIndex)); - return; - } - - onChange([ - ...pools, - withCompatiblePoolIdentity( - pools[0], - createEmptyCustomPool(), - miningMode, - ), - ]); + const addCustomPool = () => { + onChange(appendEmptyCustomPool(pools, miningMode)); }; const updatePool = (index: number, pool: PoolConfig) => { @@ -218,7 +207,7 @@ export function PoolPriorityEditor({ })} {unselectedPresets.map((preset) => { - const isDisabled = preset.badge === 'coming-soon'; + const isDisabled = preset.badge === 'coming-soon' || poolLimitReached; return ( - )} + ); } diff --git a/src/components/settings/ExperimentalTab.tsx b/src/components/settings/ExperimentalTab.tsx new file mode 100644 index 00000000..584cc3fb --- /dev/null +++ b/src/components/settings/ExperimentalTab.tsx @@ -0,0 +1,494 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { + Bot, + ExternalLink, + Loader2, + Send, + Unplug, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useTelegram, type TelegramSettings } from '@/hooks/useTelegram'; + +const TELEGRAM_EXPERIMENT_STORAGE_KEY = 'sv2-ui-experiment-telegram-enabled'; + +function readStoredTelegramExperimentState(): boolean | null { + if (typeof window === 'undefined') return null; + + const stored = window.localStorage.getItem(TELEGRAM_EXPERIMENT_STORAGE_KEY); + return stored === null ? null : stored === 'true'; +} + +function storeTelegramExperimentState(enabled: boolean): void { + if (typeof window === 'undefined') return; + window.localStorage.setItem(TELEGRAM_EXPERIMENT_STORAGE_KEY, String(enabled)); +} + +export function isTelegramExperimentOpen( + settings: Pick, + setupEnabled: boolean | null, +): boolean { + return settings.paired + ? settings.enabled + : setupEnabled ?? settings.connected; +} + +function runMutation(promise: Promise): void { + void promise.catch(() => { + // Mutation errors are rendered from the hook state. + }); +} + +export function ExperimentalTab() { + const { + settings, + isLoading, + retry, + isPending, + error, + testSent, + connect, + pair, + update, + sendTest, + disconnect, + clearError, + } = useTelegram(); + const [botToken, setBotToken] = useState(''); + const [summaryInterval, setSummaryInterval] = useState('60'); + const [telegramSetupEnabled, setTelegramSetupEnabled] = useState( + readStoredTelegramExperimentState, + ); + + useEffect(() => { + if (settings) { + setSummaryInterval(String(settings.summaryIntervalMinutes)); + } + }, [settings]); + + const handleConnect = async () => { + clearError(); + try { + await connect(botToken); + setBotToken(''); + } catch { + // Mutation errors are rendered from the hook state. + } + }; + + const handleUpdateSummary = async () => { + clearError(); + const value = Number(summaryInterval); + try { + await update({ summaryIntervalMinutes: value }); + } catch { + // Mutation errors are rendered from the hook state. + } + }; + + const openPairingLink = () => { + if (!settings?.pairingUrl) return; + window.open(settings.pairingUrl, '_blank', 'noopener,noreferrer'); + }; + + const handleTelegramExperimentChange = (enabled: boolean) => { + clearError(); + + if (settings?.paired) { + runMutation(update({ enabled })); + return; + } + + setTelegramSetupEnabled(enabled); + storeTelegramExperimentState(enabled); + }; + + if (isLoading) { + return ( +
+ + Loading experimental settings... +
+ ); + } + + if (!settings) { + return ( +
+

+ {error || 'Telegram settings could not be loaded.'} +

+ +
+ ); + } + + const telegramExperimentOpen = isTelegramExperimentOpen( + settings, + telegramSetupEnabled, + ); + + return ( +
+
+

Experiments

+

+ These features are functional but still being refined. Enable them to try new + capabilities early. +

+
+ +
+ +
+

Proof-of-concept flow

+

+ Create a dedicated bot with{' '} + + @BotFather + + , enter its token here, then press Start in the private bot chat on Telegram. +

+
+ + {!settings.connected && ( +
+
+ + setBotToken(event.target.value)} + placeholder="Paste the token from @BotFather" + /> +

+ The token controls the bot. SV2 UI stores it only in the local config volume with + owner-only file permissions and never returns it to the browser. +

+
+ + +
+ )} + + {settings.connected && !settings.paired && ( +
+
+
+ + {settings.botName} Β· @{settings.botUsername} +
+

+ Open the one-time link, press Start in Telegram, then come back and check the + pairing. This links only that private chat. +

+
+ +
+ + + +
+
+ )} + + {settings.paired && ( +
+
+
+

Paired with {settings.recipient}

+

+ @{settings.botUsername} sends updates from this local SV2 UI backend. Send{' '} + /settings to configure these alerts in Telegram. +

+
+ +
+ +
+
+
+ +

+ Notify immediately when a channel's block counter increases. +

+
+ { + clearError(); + runMutation(update({ notifyOnBlockFound })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when an existing miner channel sets a higher best share difficulty. +

+
+ { + clearError(); + runMutation(update({ notifyOnBestDifficulty })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when mining moves from one configured pool to another. +

+
+ { + clearError(); + runMutation(update({ notifyOnPoolChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when the local mining stack starts or stops. +

+
+ { + clearError(); + runMutation(update({ notifyOnStatusChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when the monitored worker count increases or decreases. +

+
+ { + clearError(); + runMutation(update({ notifyOnWorkerChange })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+
+ +

+ Notify when the upstream rejected-share counter increases. +

+
+ { + clearError(); + runMutation(update({ notifyOnRejectedShares })); + }} + disabled={isPending || !settings.enabled} + /> +
+ +
+ +
+ setSummaryInterval(event.target.value)} + disabled={isPending || !settings.enabled} + /> + +
+
+
+ +
+ +
+
+ )} + + {testSent && !error && ( +

+ Test update sent to Telegram. +

+ )} +
+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} + +function ExperimentToggleCard({ + id, + title, + description, + enabled, + disabled = false, + onEnabledChange, + children, +}: { + id: string; + title: string; + description: string; + enabled: boolean; + disabled?: boolean; + onEnabledChange: (enabled: boolean) => void; + children: ReactNode; +}) { + const switchId = `${id}-enabled`; + const labelId = `${id}-label`; + const contentId = `${id}-content`; + + return ( + +
+
+ +

{description}

+
+ +
+ + {enabled && ( +
+ {children} +
+ )} +
+ ); +} diff --git a/src/hooks/useTelegram.ts b/src/hooks/useTelegram.ts new file mode 100644 index 00000000..dbde2ae4 --- /dev/null +++ b/src/hooks/useTelegram.ts @@ -0,0 +1,161 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +export type TelegramSettings = { + connected: boolean; + paired: boolean; + botUsername: string | null; + botName: string | null; + recipient: string | null; + pairingUrl: string | null; + enabled: boolean; + notifyOnBlockFound: boolean; + notifyOnBestDifficulty: boolean; + notifyOnPoolChange: boolean; + notifyOnStatusChange: boolean; + notifyOnWorkerChange: boolean; + notifyOnRejectedShares: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettingsUpdate = { + enabled?: boolean; + notifyOnBlockFound?: boolean; + notifyOnBestDifficulty?: boolean; + notifyOnPoolChange?: boolean; + notifyOnStatusChange?: boolean; + notifyOnWorkerChange?: boolean; + notifyOnRejectedShares?: boolean; + summaryIntervalMinutes?: number; +}; + +type SuccessResponse = { + success: true; +}; + +async function parseResponse(response: Response): Promise { + const data = await response.json().catch(() => null) as { + error?: string; + } | null; + + if (!data) { + throw new Error( + 'Telegram settings API is unavailable. Restart the SV2 UI backend from this branch.' + ); + } + + if (!response.ok) { + throw new Error(data.error || `Request failed (${response.status})`); + } + + return data as T; +} + +async function fetchSettings(): Promise { + return parseResponse(await fetch('/api/telegram')); +} + +async function connectBot(botToken: string): Promise { + return parseResponse(await fetch('/api/telegram/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ botToken }), + })); +} + +async function pairChat(): Promise { + return parseResponse(await fetch('/api/telegram/pair', { + method: 'POST', + })); +} + +async function updateSettings(update: TelegramSettingsUpdate): Promise { + return parseResponse(await fetch('/api/telegram', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(update), + })); +} + +async function sendTestMessage(): Promise { + return parseResponse(await fetch('/api/telegram/test', { + method: 'POST', + })); +} + +async function disconnectBot(): Promise { + return parseResponse(await fetch('/api/telegram', { + method: 'DELETE', + })); +} + +export function useTelegram() { + const queryClient = useQueryClient(); + const queryKey = ['telegram-settings']; + + const settingsQuery = useQuery({ + queryKey, + queryFn: fetchSettings, + retry: false, + refetchInterval: 5000, + }); + + const updateCachedSettings = (settings: TelegramSettings) => { + queryClient.setQueryData(queryKey, settings); + }; + + const connectMutation = useMutation({ + mutationFn: connectBot, + onSuccess: updateCachedSettings, + }); + const pairMutation = useMutation({ + mutationFn: pairChat, + onSuccess: updateCachedSettings, + }); + const settingsMutation = useMutation({ + mutationFn: updateSettings, + onSuccess: updateCachedSettings, + }); + const testMutation = useMutation({ + mutationFn: sendTestMessage, + }); + const disconnectMutation = useMutation({ + mutationFn: disconnectBot, + onSuccess: updateCachedSettings, + }); + + const error = + connectMutation.error ?? + pairMutation.error ?? + settingsMutation.error ?? + testMutation.error ?? + disconnectMutation.error ?? + settingsQuery.error; + + const clearError = () => { + connectMutation.reset(); + pairMutation.reset(); + settingsMutation.reset(); + testMutation.reset(); + disconnectMutation.reset(); + }; + + return { + settings: settingsQuery.data, + isLoading: settingsQuery.isLoading, + retry: settingsQuery.refetch, + isPending: + connectMutation.isPending || + pairMutation.isPending || + settingsMutation.isPending || + testMutation.isPending || + disconnectMutation.isPending, + error: error instanceof Error ? error.message : null, + testSent: testMutation.isSuccess, + connect: connectMutation.mutateAsync, + pair: pairMutation.mutateAsync, + update: settingsMutation.mutateAsync, + sendTest: testMutation.mutateAsync, + disconnect: disconnectMutation.mutateAsync, + clearError, + }; +} diff --git a/src/lib/pools.test.ts b/src/lib/pools.test.ts index 58fb342a..a8cf168b 100644 --- a/src/lib/pools.test.ts +++ b/src/lib/pools.test.ts @@ -1,9 +1,48 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { SOLO_POOLS, knownPoolToConfig } from './pools'; +import { MAX_FALLBACK_POOLS, type PoolConfig } from '@sv2-ui/shared'; +import { + appendEmptyCustomPool, + canAddPool, + knownPoolToConfig, + SOLO_POOLS, +} from './pools'; import { isValidPoolAuthorityPubkey } from './utils'; +const PRIMARY_POOL: PoolConfig = { + name: 'Primary Pool', + address: 'pool.example.com', + port: 3333, + authority_public_key: 'primary-key', + user_identity: 'miner.worker', +}; + +test('appendEmptyCustomPool allows multiple custom fallback pools', () => { + const withFirstCustomPool = appendEmptyCustomPool([PRIMARY_POOL], 'pool'); + const withSecondCustomPool = appendEmptyCustomPool(withFirstCustomPool, 'pool'); + + assert.equal(withSecondCustomPool.length, 3); + assert.equal(withSecondCustomPool[1].name, 'Custom Pool'); + assert.equal(withSecondCustomPool[2].name, 'Custom Pool'); + assert.equal(withSecondCustomPool[1].user_identity, PRIMARY_POOL.user_identity); + assert.equal(withSecondCustomPool[2].user_identity, PRIMARY_POOL.user_identity); +}); + +test('pool additions stop at the configured fallback limit', () => { + const maximumPools = Array.from( + { length: MAX_FALLBACK_POOLS + 1 }, + (_, index) => ({ + ...PRIMARY_POOL, + address: `pool-${index}.example.com`, + }), + ); + + assert.equal(canAddPool(maximumPools.slice(0, -1)), true); + assert.equal(canAddPool(maximumPools), false); + assert.strictEqual(appendEmptyCustomPool(maximumPools, 'pool'), maximumPools); +}); + test('solo pool presets are sorted alphabetically', () => { const names = SOLO_POOLS.map((pool) => pool.name); diff --git a/src/lib/pools.ts b/src/lib/pools.ts index 7c781e55..cf21a3d5 100644 --- a/src/lib/pools.ts +++ b/src/lib/pools.ts @@ -1,7 +1,12 @@ /** * Shared pool preset definitions used by both the Setup Wizard and Settings. */ -import type { PoolConfig } from '@sv2-ui/shared'; +import { + MAX_FALLBACK_POOLS, + type MiningMode, + type PoolConfig, +} from '@sv2-ui/shared'; +import { withCompatiblePoolIdentity } from './miningIdentity'; export interface KnownPool { id: string; @@ -148,6 +153,26 @@ export function createEmptyCustomPool(userIdentity = ''): PoolConfig { }; } +export function appendEmptyCustomPool( + pools: PoolConfig[], + miningMode: MiningMode | null, +): PoolConfig[] { + if (!canAddPool(pools)) return pools; + + return [ + ...pools, + withCompatiblePoolIdentity( + pools[0], + createEmptyCustomPool(), + miningMode, + ), + ]; +} + +export function canAddPool(pools: PoolConfig[]): boolean { + return pools.length < MAX_FALLBACK_POOLS + 1; +} + export function isSamePool(a: Pick | null | undefined, b: Pick | null | undefined): boolean { if (!a || !b) return false; return a.address.trim().toLowerCase() === b.address.trim().toLowerCase() && a.port === b.port; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 5a5a6849..6e5aee4a 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -15,9 +15,10 @@ import { Upload, } from 'lucide-react'; import { ConfigurationTab } from '@/components/settings/ConfigurationTab'; +import { ExperimentalTab } from '@/components/settings/ExperimentalTab'; /** - * Settings page with Configuration and Appearance tabs. + * Settings page with Configuration, Logs, Appearance, and Experimental tabs. */ export function Settings() { const { config, updateConfig, resetConfig } = useUiConfig(); @@ -66,16 +67,17 @@ export function Settings() {

Settings

- Manage your configuration and appearance. + Manage your configuration, appearance, and experimental features.

- + Configuration Logs Appearance + Experimental @@ -183,6 +185,10 @@ export function Settings() { + + + +