From 1eb9040e7cd58cf1314c9de703801fd710c4b2f7 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:27:50 +0500 Subject: [PATCH 1/5] feat: prototype Telegram mining notifications Co-authored-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> --- server/src/index.ts | 259 ++++++++-- server/src/telegram.test.ts | 210 ++++++++ server/src/telegram.ts | 522 ++++++++++++++++++++ src/components/settings/ExperimentalTab.tsx | 314 ++++++++++++ src/hooks/useTelegram.ts | 143 ++++++ src/pages/Settings.tsx | 12 +- 6 files changed, 1425 insertions(+), 35 deletions(-) create mode 100644 server/src/telegram.test.ts create mode 100644 server/src/telegram.ts create mode 100644 src/components/settings/ExperimentalTab.tsx create mode 100644 src/hooks/useTelegram.ts diff --git a/server/src/index.ts b/server/src/index.ts index 49e76dcf..4c6be405 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -34,6 +34,15 @@ import { import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/diagnostics.js'; import { ActivePoolTracker } from './active-pool.js'; import { getPoolConfigError, MAX_FALLBACK_POOLS } from './pool-validation.js'; +import { + TelegramApiError, + TelegramConfigError, + TelegramService, +} from './telegram.js'; +import type { + TelegramActivitySnapshot, + TelegramSettingsUpdate, +} from './telegram.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -42,13 +51,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 = 30_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 +228,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 +277,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 +308,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 +792,82 @@ 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_hashrate: number } | null; +}; + +type MonitoringServerChannel = { + shares_submitted: number; + shares_acknowledged: number; + shares_rejected: number; +}; + +type MonitoringServerChannels = { + extended_channels: MonitoringServerChannel[]; + standard_channels: MonitoringServerChannel[]; +}; + +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; + } +} + +async function getTelegramActivitySnapshot(): Promise { + const status = await getCurrentStatus(); + + if (!status.running || !status.mode) { + return { + running: false, + poolName: status.poolName, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: 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, channels] = await Promise.all([ + fetchMonitoringJson(`${baseUrl}/global`), + fetchMonitoringJson( + `${baseUrl}/server/channels?offset=0&limit=100` + ), + ]); + + const clients = isJdMode ? global?.sv2_clients : global?.sv1_clients; + const serverChannels = channels + ? [...channels.extended_channels, ...channels.standard_channels] + : null; + + return { + running: true, + poolName: status.poolName, + hashrate: clients?.total_hashrate ?? global?.server?.total_hashrate ?? null, + workers: clients?.total_clients ?? null, + sharesSubmitted: serverChannels + ? serverChannels.reduce((sum, channel) => sum + channel.shares_submitted, 0) + : null, + sharesAccepted: serverChannels + ? serverChannels.reduce((sum, channel) => sum + channel.shares_acknowledged, 0) + : null, + sharesRejected: serverChannels + ? serverChannels.reduce((sum, channel) => sum + channel.shares_rejected, 0) + : null, + }; +} + /** * Proxy requests to Translator monitoring API * This avoids CORS issues when the frontend is served from a different port @@ -777,6 +949,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 +984,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 +1000,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/telegram.test.ts b/server/src/telegram.test.ts new file mode 100644 index 00000000..8ae0d25d --- /dev/null +++ b/server/src/telegram.test.ts @@ -0,0 +1,210 @@ +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 { + formatTelegramStatus, + getStatusChangeMessage, + TelegramConfigError, + TelegramService, +} from './telegram.js'; +import type { TelegramActivitySnapshot } from './telegram.js'; + +const BOT_TOKEN = '123456:test-token'; + +type FetchCall = { + url: string; + body: Record; +}; + +function createTelegramFetch(results: unknown[]) { + const calls: FetchCall[] = []; + const fetchImplementation = (async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(input), + body: init?.body ? JSON.parse(String(init.body)) as Record : {}, + }); + + const result = results.shift(); + 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 }; +} + +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', + hashrate: 125_000_000_000_000, + workers: 3, + sharesSubmitted: 42, + sharesAccepted: 40, + sharesRejected: 2, + ...update, + }; +} + +test('formats a compact mining summary', () => { + 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', + ].join('\n') + ); +}); + +test('reports only meaningful status changes', () => { + const stopped = snapshot({ + running: false, + poolName: null, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: 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('verifies a bot and pairs the private chat from a one-time deep link', async (t) => { + const settingsFile = await createSettingsFile(t); + const telegram = createTelegramFetch([ + { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', + }, + ]); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + const connected = await service.connectBot(BOT_TOKEN); + const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + assert.ok(pairingCode); + assert.equal(JSON.stringify(connected).includes(BOT_TOKEN), false); + + const pairingTelegram = createTelegramFetch([ + [{ + update_id: 77, + message: { + text: `/start ${pairingCode}`, + chat: { + id: 987, + type: 'private', + first_name: 'Miner', + username: 'miner_one', + }, + }, + }], + { message_id: 5 }, + ]); + const reloadedService = new TelegramService(settingsFile, pairingTelegram.fetchImplementation); + const paired = await reloadedService.pairChat(); + + assert.equal(paired.paired, true); + assert.equal(paired.enabled, true); + assert.equal(paired.recipient, '@miner_one'); + assert.equal(paired.pairingUrl, null); + assert.equal(pairingTelegram.calls.at(-1)?.body.chat_id, 987); + + 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([ + { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', + }, + [], + ]); + 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('the background poll establishes a baseline before sending a transition', async (t) => { + const settingsFile = await createSettingsFile(t); + const telegram = createTelegramFetch([ + { + id: 123456, + is_bot: true, + first_name: 'SV2 alerts', + username: 'sv2_alerts_bot', + }, + ]); + const service = new TelegramService(settingsFile, telegram.fetchImplementation); + const connected = await service.connectBot(BOT_TOKEN); + const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + + const pairedTelegram = createTelegramFetch([ + [{ + update_id: 77, + message: { + text: `/start ${pairingCode}`, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }], + { message_id: 5 }, + { message_id: 6 }, + ]); + const pairedService = new TelegramService(settingsFile, pairedTelegram.fetchImplementation); + await pairedService.pairChat(); + + const stopped = snapshot({ + running: false, + poolName: null, + hashrate: null, + workers: null, + sharesSubmitted: null, + sharesAccepted: null, + sharesRejected: null, + }); + await pairedService.poll(async () => stopped); + assert.equal(pairedTelegram.calls.length, 2); + + await pairedService.poll(async () => snapshot()); + assert.equal(pairedTelegram.calls.length, 3); + assert.match(String(pairedTelegram.calls[2].body.text), /mining started/); +}); diff --git a/server/src/telegram.ts b/server/src/telegram.ts new file mode 100644 index 00000000..337fa3e8 --- /dev/null +++ b/server/src/telegram.ts @@ -0,0 +1,522 @@ +import { randomBytes } from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; + +const DEFAULT_SUMMARY_INTERVAL_MINUTES = 60; +const MIN_SUMMARY_INTERVAL_MINUTES = 15; +const MAX_SUMMARY_INTERVAL_MINUTES = 24 * 60; + +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 TelegramUpdate = { + update_id: number; + message?: { + text?: string; + chat: TelegramChat; + }; +}; + +type TelegramApiResponse = { + ok: boolean; + result?: T; + description?: string; + error_code?: number; +}; + +type SavedTelegramSettings = { + 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 = { + connected: boolean; + paired: boolean; + botUsername: string | null; + botName: string | null; + recipient: string | null; + pairingUrl: string | null; + enabled: boolean; + notifyOnStatusChange: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettingsUpdate = { + enabled?: boolean; + notifyOnStatusChange?: boolean; + summaryIntervalMinutes?: number; +}; + +export type TelegramActivitySnapshot = { + running: boolean; + poolName: string | null; + hashrate: number | null; + workers: number | null; + sharesSubmitted: number | null; + sharesAccepted: number | null; + sharesRejected: number | null; +}; + +export class TelegramConfigError extends Error {} + +export class TelegramApiError extends Error { + constructor( + message: string, + readonly statusCode: number + ) { + super(message); + } +} + +function getEmptySettings(): TelegramSettings { + return { + connected: false, + paired: false, + botUsername: null, + botName: null, + recipient: null, + pairingUrl: null, + enabled: false, + notifyOnStatusChange: true, + summaryIntervalMinutes: DEFAULT_SUMMARY_INTERVAL_MINUTES, + }; +} + +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, + notifyOnStatusChange: settings.notifyOnStatusChange, + summaryIntervalMinutes: settings.summaryIntervalMinutes, + }; +} + +function isSavedSettings(value: unknown): value is SavedTelegramSettings { + if (!value || typeof value !== 'object') return false; + const settings = value as Partial; + + return settings.version === 1 && + 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 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 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]}`; +} + +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(' · ')}`); + } + + 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 && + previous.poolName !== current.poolName && + current.poolName !== null + ) { + return formatTelegramStatus(current, '🔁 SV2 active pool changed'); + } + + return null; +} + +export class TelegramService { + private settings: SavedTelegramSettings | null = null; + private initialized = false; + private previousSnapshot: TelegramActivitySnapshot | null = null; + private lastSummaryAt: 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: unknown = JSON.parse(raw); + if (!isSavedSettings(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: 1, + botToken: token, + botUsername: bot.username, + botName: bot.first_name, + pairingCode: `sv2_${randomBytes(18).toString('base64url')}`, + chatId: null, + recipient: null, + enabled: false, + notifyOnStatusChange: true, + summaryIntervalMinutes: DEFAULT_SUMMARY_INTERVAL_MINUTES, + }; + 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` + ); + } + + await this.sendMessage( + settings.botToken, + chat.id, + '✅ SV2 UI is linked. Telegram activity updates are now enabled.' + ); + + this.settings = { + ...settings, + pairingCode: null, + chatId: chat.id, + recipient: getRecipientLabel(chat), + enabled: true, + }; + this.resetMonitorState(); + await this.save(); + return toPublicSettings(this.settings); + } + + async updateSettings(update: TelegramSettingsUpdate): Promise { + await this.initialize(); + const settings = this.requirePaired(); + + const enabled = update.enabled ?? settings.enabled; + const notifyOnStatusChange = + update.notifyOnStatusChange ?? settings.notifyOnStatusChange; + const summaryIntervalMinutes = + update.summaryIntervalMinutes ?? settings.summaryIntervalMinutes; + + if (typeof enabled !== 'boolean' || typeof notifyOnStatusChange !== 'boolean') { + throw new TelegramConfigError('Notification settings must be true or false'); + } + if ( + !Number.isInteger(summaryIntervalMinutes) || + (summaryIntervalMinutes !== 0 && + ( + summaryIntervalMinutes < MIN_SUMMARY_INTERVAL_MINUTES || + 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 = { + ...settings, + enabled, + notifyOnStatusChange, + summaryIntervalMinutes, + }; + 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 settings = this.settings; + if (!settings?.enabled || settings.chatId === null) { + this.resetMonitorState(); + return; + } + + this.pollInProgress = true; + try { + const current = await snapshotProvider(); + const now = Date.now(); + + if (!this.previousSnapshot) { + this.previousSnapshot = current; + this.lastSummaryAt = now; + return; + } + + const statusMessage = settings.notifyOnStatusChange + ? getStatusChangeMessage(this.previousSnapshot, current) + : null; + const summaryDue = settings.summaryIntervalMinutes > 0 && + this.lastSummaryAt !== null && + now - this.lastSummaryAt >= settings.summaryIntervalMinutes * 60_000; + + if (statusMessage) { + await this.sendMessage(settings.botToken, settings.chatId, statusMessage); + } else if (summaryDue) { + await this.sendMessage( + settings.botToken, + settings.chatId, + formatTelegramStatus(current) + ); + this.lastSummaryAt = now; + } + + this.previousSnapshot = current; + } finally { + this.pollInProgress = false; + } + } + + 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.lastSummaryAt = null; + } + + 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/src/components/settings/ExperimentalTab.tsx b/src/components/settings/ExperimentalTab.tsx new file mode 100644 index 00000000..18f9cde8 --- /dev/null +++ b/src/components/settings/ExperimentalTab.tsx @@ -0,0 +1,314 @@ +import { useEffect, useState } from 'react'; +import { + Bot, + ExternalLink, + FlaskConical, + Loader2, + Send, + ShieldCheck, + Unplug, +} from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useTelegram } from '@/hooks/useTelegram'; + +function runMutation(promise: Promise): void { + void promise.catch(() => { + // Mutation errors are rendered from the hook state. + }); +} + +export function ExperimentalTab() { + const { + settings, + isLoading, + isPending, + error, + testSent, + connect, + pair, + update, + sendTest, + disconnect, + clearError, + } = useTelegram(); + const [botToken, setBotToken] = useState(''); + const [summaryInterval, setSummaryInterval] = useState('60'); + + 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'); + }; + + if (isLoading || !settings) { + return ( +
+ + Loading experimental settings... +
+ ); + } + + return ( +
+ + +
+
+ +
+
+ Telegram activity updates + + Experimental local-only notifications for mining status, pool changes, and summaries. + +
+
+
+ +
+

Proof-of-concept flow

+

+ Telegram does not let bots start a conversation. Create a dedicated bot with{' '} + + @BotFather + + , enter its token here, then press Start in the private bot chat. No SV2 cloud service + is involved. +

+
+ + {!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. +

+
+
+ +
+ +
+
+
+ +

+ The backend keeps checking even when the browser is closed. +

+
+ { + clearError(); + runMutation(update({ enabled })); + }} + disabled={isPending} + /> +
+ +
+
+ +

+ Notify when mining starts, stops, or switches to another configured pool. +

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

+ Use 0 to disable summaries, or choose 15-1440 minutes. Summaries include + hashrate, workers, and upstream share counts when monitoring data is available. +

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

+ {error} +

+ )} + + {testSent && !error && ( +

+ Test update sent to Telegram. +

+ )} +
+
+
+ ); +} diff --git a/src/hooks/useTelegram.ts b/src/hooks/useTelegram.ts new file mode 100644 index 00000000..4849b261 --- /dev/null +++ b/src/hooks/useTelegram.ts @@ -0,0 +1,143 @@ +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; + notifyOnStatusChange: boolean; + summaryIntervalMinutes: number; +}; + +export type TelegramSettingsUpdate = { + enabled?: boolean; + notifyOnStatusChange?: boolean; + summaryIntervalMinutes?: number; +}; + +type SuccessResponse = { + success: true; +}; + +async function parseResponse(response: Response): Promise { + const data = await response.json().catch(() => ({})) as { + error?: string; + }; + + 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, + }); + + 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, + 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/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() { + + + + From 35babde89f48bac7aa7186da6ad175767e01105f Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:59:19 +0500 Subject: [PATCH 2/5] feat: add configurable Telegram mining alerts Add block-found, best-difficulty, and pool-failover defaults, bot command controls, and monitoring-backed alert detection. Co-authored-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> --- server/src/index.ts | 88 ++- server/src/telegram.test.ts | 336 ++++++++--- server/src/telegram.ts | 617 ++++++++++++++++++-- src/components/settings/ExperimentalTab.tsx | 118 +++- src/hooks/useTelegram.ts | 22 +- 5 files changed, 1018 insertions(+), 163 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 4c6be405..eff25069 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -41,6 +41,7 @@ import { } from './telegram.js'; import type { TelegramActivitySnapshot, + TelegramMiningChannel, TelegramSettingsUpdate, } from './telegram.js'; @@ -54,7 +55,7 @@ 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 = 30_000; +const TELEGRAM_POLL_INTERVAL_MS = 5_000; type StackBusyReason = 'auto-start' | 'manual'; @@ -799,6 +800,10 @@ type MonitoringGlobal = { }; type MonitoringServerChannel = { + channel_id: number; + user_identity: string; + best_diff: number; + blocks_found: number; shares_submitted: number; shares_acknowledged: number; shares_rejected: number; @@ -809,6 +814,26 @@ type MonitoringServerChannels = { standard_channels: MonitoringServerChannel[]; }; +type MonitoringClient = { + client_id: number; +}; + +type MonitoringClients = { + items: MonitoringClient[]; +}; + +type MonitoringMiningChannel = { + channel_id: number; + user_identity: string; + best_diff: number; + blocks_found: number; +}; + +type MonitoringClientChannels = { + extended_channels: MonitoringMiningChannel[]; + standard_channels: MonitoringMiningChannel[]; +}; + async function fetchMonitoringJson(url: string): Promise { try { const response = await fetch(url, { @@ -832,6 +857,7 @@ async function getTelegramActivitySnapshot(): Promise sharesSubmitted: null, sharesAccepted: null, sharesRejected: null, + channels: null, }; } @@ -839,17 +865,70 @@ async function getTelegramActivitySnapshot(): Promise 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, channels] = await Promise.all([ + const [global, serverChannelResponse, clientResponse] = await Promise.all([ fetchMonitoringJson(`${baseUrl}/global`), fetchMonitoringJson( `${baseUrl}/server/channels?offset=0&limit=100` ), + isJdMode + ? fetchMonitoringJson(`${baseUrl}/clients?offset=0&limit=100`) + : Promise.resolve(null), ]); const clients = isJdMode ? global?.sv2_clients : global?.sv1_clients; - const serverChannels = channels - ? [...channels.extended_channels, ...channels.standard_channels] + const serverChannels = serverChannelResponse + ? [ + ...serverChannelResponse.extended_channels, + ...serverChannelResponse.standard_channels, + ] : null; + let miningChannels: TelegramMiningChannel[] | null = null; + + if (isJdMode && clientResponse) { + const downstreamResponses = await Promise.all( + clientResponse.items.map(async (client) => ({ + clientId: client.client_id, + channels: await fetchMonitoringJson( + `${baseUrl}/clients/${client.client_id}/channels?offset=0&limit=100` + ), + })) + ); + + if (downstreamResponses.every((response) => response.channels !== null)) { + miningChannels = downstreamResponses.flatMap(({ clientId, channels }) => { + if (!channels) return []; + return [ + ...channels.extended_channels.map((channel) => ({ + key: `jdc:${clientId}:extended:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })), + ...channels.standard_channels.map((channel) => ({ + key: `jdc:${clientId}:standard:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })), + ]; + }); + } + } else if (!isJdMode && serverChannelResponse) { + miningChannels = [ + ...serverChannelResponse.extended_channels.map((channel) => ({ + key: `translator:server:extended:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })), + ...serverChannelResponse.standard_channels.map((channel) => ({ + key: `translator:server:standard:${channel.channel_id}:${channel.user_identity}`, + userIdentity: channel.user_identity, + blocksFound: channel.blocks_found, + bestDifficulty: channel.best_diff, + })), + ]; + } return { running: true, @@ -865,6 +944,7 @@ async function getTelegramActivitySnapshot(): Promise sharesRejected: serverChannels ? serverChannels.reduce((sum, channel) => sum + channel.shares_rejected, 0) : null, + channels: miningChannels, }; } diff --git a/server/src/telegram.test.ts b/server/src/telegram.test.ts index 8ae0d25d..3a656477 100644 --- a/server/src/telegram.test.ts +++ b/server/src/telegram.test.ts @@ -13,21 +13,39 @@ import { 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(results: unknown[]) { +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) => { - calls.push({ - url: String(input), - body: init?.body ? JSON.parse(String(init.body)) as Record : {}, - }); + 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 result = results.shift(); + 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({ @@ -39,7 +57,18 @@ function createTelegramFetch(results: unknown[]) { }); }) as typeof fetch; - return { calls, fetchImplementation }; + 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 { @@ -61,11 +90,42 @@ function snapshot( sharesSubmitted: 42, sharesAccepted: 40, sharesRejected: 2, + channels: [{ + key: 'translator:server:extended:1:miner-one', + userIdentity: 'miner-one', + blocksFound: 0, + bestDifficulty: 1250, + }], ...update, }; } -test('formats a compact mining summary', () => { +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()), [ @@ -75,6 +135,8 @@ test('formats a compact mining summary', () => { 'Hashrate: 125 TH/s', 'Workers: 3', 'Shares: 42 submitted · 40 accepted · 2 rejected', + 'Blocks found: 0', + 'Best difficulty: 1,250', ].join('\n') ); }); @@ -88,6 +150,7 @@ test('reports only meaningful status changes', () => { sharesSubmitted: null, sharesAccepted: null, sharesRejected: null, + channels: null, }); assert.match(getStatusChangeMessage(stopped, snapshot()) ?? '', /mining started/); @@ -98,45 +161,26 @@ test('reports only meaningful status changes', () => { assert.equal(getStatusChangeMessage(snapshot(), snapshot()), null); }); -test('verifies a bot and pairs the private chat from a one-time deep link', async (t) => { - const settingsFile = await createSettingsFile(t); - const telegram = createTelegramFetch([ - { - id: 123456, - is_bot: true, - first_name: 'SV2 alerts', - username: 'sv2_alerts_bot', - }, - ]); - const service = new TelegramService(settingsFile, telegram.fetchImplementation); - const connected = await service.connectBot(BOT_TOKEN); - const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); - assert.ok(pairingCode); - assert.equal(JSON.stringify(connected).includes(BOT_TOKEN), false); - - const pairingTelegram = createTelegramFetch([ - [{ - update_id: 77, - message: { - text: `/start ${pairingCode}`, - chat: { - id: 987, - type: 'private', - first_name: 'Miner', - username: 'miner_one', - }, - }, - }], - { message_id: 5 }, - ]); - const reloadedService = new TelegramService(settingsFile, pairingTelegram.fetchImplementation); - const paired = await reloadedService.pairChat(); +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(pairingTelegram.calls.at(-1)?.body.chat_id, 987); + 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); @@ -144,15 +188,10 @@ test('verifies a bot and pairs the private chat from a one-time deep link', asyn test('does not pair a chat until the matching Start command arrives', async (t) => { const settingsFile = await createSettingsFile(t); - const telegram = createTelegramFetch([ - { - id: 123456, - is_bot: true, - first_name: 'SV2 alerts', - username: 'sv2_alerts_bot', - }, - [], - ]); + const telegram = createTelegramFetch({ + getMe: [BOT], + getUpdates: [[]], + }); const service = new TelegramService(settingsFile, telegram.fetchImplementation); await service.connectBot(BOT_TOKEN); @@ -164,47 +203,168 @@ test('does not pair a chat until the matching Start command arrives', async (t) ); }); -test('the background poll establishes a baseline before sending a transition', async (t) => { - const settingsFile = await createSettingsFile(t); - const telegram = createTelegramFetch([ - { - id: 123456, - is_bot: true, - first_name: 'SV2 alerts', - username: 'sv2_alerts_bot', +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 })); + assert.equal(telegram.callsFor('sendMessage').length, 1); + + await service.poll(async () => snapshot({ poolName: 'Fallback pool' })); + 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('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' }, }, - ]); - const service = new TelegramService(settingsFile, telegram.fetchImplementation); - const connected = await service.connectBot(BOT_TOKEN); - const pairingCode = new URL(connected.pairingUrl ?? '').searchParams.get('start'); + }]); + await service.poll(async () => snapshot()); - const pairedTelegram = createTelegramFetch([ - [{ - update_id: 77, - message: { - text: `/start ${pairingCode}`, - chat: { id: 987, type: 'private', first_name: 'Miner' }, + 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' }, + }, }, - }], - { message_id: 5 }, - { message_id: 6 }, + }, + { + update_id: 80, + callback_query: { + id: 'callback-2', + data: 'sv2:toggle:best', + message: { + message_id: 9, + chat: { id: 987, type: 'private', first_name: 'Miner' }, + }, + }, + }, ]); - const pairedService = new TelegramService(settingsFile, pairedTelegram.fetchImplementation); - await pairedService.pairChat(); + await service.poll(async () => snapshot()); - const stopped = snapshot({ - running: false, - poolName: null, - hashrate: null, - workers: null, - sharesSubmitted: null, - sharesAccepted: null, - sharesRejected: null, - }); - await pairedService.poll(async () => stopped); - assert.equal(pairedTelegram.calls.length, 2); + 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 pairedService.poll(async () => snapshot()); - assert.equal(pairedTelegram.calls.length, 3); - assert.match(String(pairedTelegram.calls[2].body.text), /mining started/); + 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 index 337fa3e8..bde71aa0 100644 --- a/server/src/telegram.ts +++ b/server/src/telegram.ts @@ -1,10 +1,10 @@ -import { randomBytes } from 'crypto'; -import fs from 'fs/promises'; -import path from 'path'; +import { randomBytes } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; -const DEFAULT_SUMMARY_INTERVAL_MINUTES = 60; 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; @@ -24,12 +24,22 @@ type TelegramChat = { 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?: { - text?: string; - chat: TelegramChat; - }; + message?: TelegramMessage; + callback_query?: TelegramCallbackQuery; }; type TelegramApiResponse = { @@ -39,7 +49,29 @@ type TelegramApiResponse = { error_code?: number; }; -type SavedTelegramSettings = { +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; @@ -52,22 +84,22 @@ type SavedTelegramSettings = { summaryIntervalMinutes: number; }; -export type TelegramSettings = { +export type TelegramSettings = TelegramAlertSettings & { connected: boolean; paired: boolean; botUsername: string | null; botName: string | null; recipient: string | null; pairingUrl: string | null; - enabled: boolean; - notifyOnStatusChange: boolean; - summaryIntervalMinutes: number; }; -export type TelegramSettingsUpdate = { - enabled?: boolean; - notifyOnStatusChange?: boolean; - summaryIntervalMinutes?: number; +export type TelegramSettingsUpdate = Partial; + +export type TelegramMiningChannel = { + key: string; + userIdentity: string; + blocksFound: number; + bestDifficulty: number; }; export type TelegramActivitySnapshot = { @@ -78,6 +110,7 @@ export type TelegramActivitySnapshot = { sharesSubmitted: number | null; sharesAccepted: number | null; sharesRejected: number | null; + channels: TelegramMiningChannel[] | null; }; export class TelegramConfigError extends Error {} @@ -91,6 +124,17 @@ export class TelegramApiError extends Error { } } +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, @@ -99,9 +143,8 @@ function getEmptySettings(): TelegramSettings { botName: null, recipient: null, pairingUrl: null, + ...DEFAULT_ALERT_SETTINGS, enabled: false, - notifyOnStatusChange: true, - summaryIntervalMinutes: DEFAULT_SUMMARY_INTERVAL_MINUTES, }; } @@ -118,17 +161,22 @@ function toPublicSettings(settings: SavedTelegramSettings | null): TelegramSetti ? `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 isSavedSettings(value: unknown): value is SavedTelegramSettings { - if (!value || typeof value !== 'object') return false; - const settings = value as Partial; +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} - return settings.version === 1 && - typeof settings.botToken === 'string' && +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') && @@ -139,6 +187,38 @@ function isSavedSettings(value: unknown): value is SavedTelegramSettings { 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; @@ -153,6 +233,12 @@ function getStartParameter(text: string | undefined): string | null { 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; @@ -169,6 +255,20 @@ function formatHashrate(hashrate: number | null): string | null { 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 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' @@ -195,6 +295,18 @@ export function formatTelegramStatus( 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'); } @@ -221,11 +333,143 @@ export function getStatusChangeMessage( 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 lastKnownPoolName: string | null = null; private pollInProgress = false; constructor( @@ -238,8 +482,8 @@ export class TelegramService { try { const raw = await fs.readFile(this.settingsFile, 'utf8'); - const parsed: unknown = JSON.parse(raw); - if (!isSavedSettings(parsed)) { + const parsed = parseSavedSettings(JSON.parse(raw) as unknown); + if (!parsed) { throw new TelegramConfigError('Stored Telegram settings are invalid'); } this.settings = parsed; @@ -276,16 +520,16 @@ export class TelegramService { } this.settings = { - version: 1, + 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, - notifyOnStatusChange: true, - summaryIntervalMinutes: DEFAULT_SUMMARY_INTERVAL_MINUTES, }; this.resetMonitorState(); await this.save(); @@ -326,19 +570,30 @@ export class TelegramService { ); } - await this.sendMessage( - settings.botToken, - chat.id, - '✅ SV2 UI is linked. Telegram activity updates are now enabled.' - ); - - this.settings = { + const pairedSettings: SavedTelegramSettings = { ...settings, pairingCode: null, chatId: chat.id, recipient: getRecipientLabel(chat), - enabled: true, + 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); @@ -347,22 +602,39 @@ export class TelegramService { 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 enabled = update.enabled ?? settings.enabled; - const notifyOnStatusChange = - update.notifyOnStatusChange ?? settings.notifyOnStatusChange; - const summaryIntervalMinutes = - update.summaryIntervalMinutes ?? settings.summaryIntervalMinutes; - - if (typeof enabled !== 'boolean' || typeof notifyOnStatusChange !== 'boolean') { + 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(summaryIntervalMinutes) || - (summaryIntervalMinutes !== 0 && + !Number.isInteger(next.summaryIntervalMinutes) || + (next.summaryIntervalMinutes !== 0 && ( - summaryIntervalMinutes < MIN_SUMMARY_INTERVAL_MINUTES || - summaryIntervalMinutes > MAX_SUMMARY_INTERVAL_MINUTES + next.summaryIntervalMinutes < MIN_SUMMARY_INTERVAL_MINUTES || + next.summaryIntervalMinutes > MAX_SUMMARY_INTERVAL_MINUTES )) ) { throw new TelegramConfigError( @@ -370,12 +642,7 @@ export class TelegramService { ); } - this.settings = { - ...settings, - enabled, - notifyOnStatusChange, - summaryIntervalMinutes, - }; + this.settings = next; this.resetMonitorState(); await this.save(); return toPublicSettings(this.settings); @@ -409,32 +676,116 @@ export class TelegramService { await this.initialize(); if (this.pollInProgress) return; - const settings = this.settings; - if (!settings?.enabled || settings.chatId === null) { + 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 { - const current = await snapshotProvider(); + 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.lastKnownPoolName = current.poolName; this.lastSummaryAt = now; return; } - const statusMessage = settings.notifyOnStatusChange - ? getStatusChangeMessage(this.previousSnapshot, current) - : null; + 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); + } + + if ( + settings.notifyOnPoolChange && + current.running && + this.lastKnownPoolName && + current.poolName && + this.lastKnownPoolName !== current.poolName + ) { + messages.push([ + '🔁 Pool failover', + `From: ${this.lastKnownPoolName}`, + `To: ${current.poolName}`, + ].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 (statusMessage) { - await this.sendMessage(settings.botToken, settings.chatId, statusMessage); + 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, @@ -444,12 +795,137 @@ export class TelegramService { this.lastSummaryAt = now; } - this.previousSnapshot = current; + this.previousSnapshot = current.channels === null + ? { ...current, channels: this.previousSnapshot.channels } + : current; + this.updateChannelBaselines(current.channels); + if (current.poolName) this.lastKnownPoolName = current.poolName; } 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'); @@ -479,7 +955,22 @@ export class TelegramService { private resetMonitorState(): void { this.previousSnapshot = null; + this.channelBaselines.clear(); + this.bestDifficultyHighWatermark = 0; this.lastSummaryAt = null; + this.lastKnownPoolName = null; + } + + 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 { diff --git a/src/components/settings/ExperimentalTab.tsx b/src/components/settings/ExperimentalTab.tsx index 18f9cde8..277ee467 100644 --- a/src/components/settings/ExperimentalTab.tsx +++ b/src/components/settings/ExperimentalTab.tsx @@ -26,6 +26,7 @@ export function ExperimentalTab() { const { settings, isLoading, + retry, isPending, error, testSent, @@ -70,7 +71,7 @@ export function ExperimentalTab() { window.open(settings.pairingUrl, '_blank', 'noopener,noreferrer'); }; - if (isLoading || !settings) { + if (isLoading) { return (
@@ -79,6 +80,19 @@ export function ExperimentalTab() { ); } + if (!settings) { + return ( +
+

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

+ +
+ ); + } + return (
@@ -90,7 +104,7 @@ export function ExperimentalTab() {
Telegram activity updates - Experimental local-only notifications for mining status, pool changes, and summaries. + Experimental local-only alerts for blocks, best difficulty, failover, and mining health.
@@ -196,7 +210,8 @@ export function ExperimentalTab() {

Paired with {settings.recipient}

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

@@ -235,9 +250,63 @@ export function ExperimentalTab() {
- + +

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

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

- Notify when mining starts, stops, or switches to another configured pool. + 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.

+
+
+ +

+ 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} + /> +
+
@@ -274,7 +379,8 @@ export function ExperimentalTab() {

Use 0 to disable summaries, or choose 15-1440 minutes. Summaries include - hashrate, workers, and upstream share counts when monitoring data is available. + hashrate, workers, upstream share counts, blocks found, and best difficulty when + monitoring data is available.

diff --git a/src/hooks/useTelegram.ts b/src/hooks/useTelegram.ts index 4849b261..dbde2ae4 100644 --- a/src/hooks/useTelegram.ts +++ b/src/hooks/useTelegram.ts @@ -8,13 +8,23 @@ export type TelegramSettings = { 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; }; @@ -23,9 +33,15 @@ type SuccessResponse = { }; async function parseResponse(response: Response): Promise { - const data = await response.json().catch(() => ({})) as { + 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})`); @@ -80,6 +96,7 @@ export function useTelegram() { queryKey, queryFn: fetchSettings, retry: false, + refetchInterval: 5000, }); const updateCachedSettings = (settings: TelegramSettings) => { @@ -125,6 +142,7 @@ export function useTelegram() { return { settings: settingsQuery.data, isLoading: settingsQuery.isLoading, + retry: settingsQuery.refetch, isPending: connectMutation.isPending || pairMutation.isPending || From c1f0d5f6f4596d4f117a25bd87bbd66db433541b Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:03:47 +0500 Subject: [PATCH 3/5] refine uo --- src/components/ExperimentalTab.test.ts | 37 ++++ src/components/settings/ExperimentalTab.tsx | 200 ++++++++++++++------ 2 files changed, 174 insertions(+), 63 deletions(-) create mode 100644 src/components/ExperimentalTab.test.ts 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/settings/ExperimentalTab.tsx b/src/components/settings/ExperimentalTab.tsx index 277ee467..584cc3fb 100644 --- a/src/components/settings/ExperimentalTab.tsx +++ b/src/components/settings/ExperimentalTab.tsx @@ -1,20 +1,41 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { Bot, ExternalLink, - FlaskConical, Loader2, Send, - ShieldCheck, Unplug, } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +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 } from '@/hooks/useTelegram'; +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(() => { @@ -39,6 +60,9 @@ export function ExperimentalTab() { } = useTelegram(); const [botToken, setBotToken] = useState(''); const [summaryInterval, setSummaryInterval] = useState('60'); + const [telegramSetupEnabled, setTelegramSetupEnabled] = useState( + readStoredTelegramExperimentState, + ); useEffect(() => { if (settings) { @@ -71,6 +95,18 @@ export function ExperimentalTab() { 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 (
@@ -93,27 +129,34 @@ export function ExperimentalTab() { ); } + const telegramExperimentOpen = isTelegramExperimentOpen( + settings, + telegramSetupEnabled, + ); + return (
- - -
-
- -
-
- Telegram activity updates - - Experimental local-only alerts for blocks, best difficulty, failover, and mining health. - -
-
-
- +
+

Experiments

+

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

+
+ +
+

Proof-of-concept flow

- Telegram does not let bots start a conversation. Create a dedicated bot with{' '} + Create a dedicated bot with{' '} @BotFather - , enter its token here, then press Start in the private bot chat. No SV2 cloud service - is involved. + , enter its token here, then press Start in the private bot chat on Telegram.

@@ -205,15 +247,12 @@ export function ExperimentalTab() { {settings.paired && (
-
- -
-

Paired with {settings.recipient}

-

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

-
+
+

Paired with {settings.recipient}

+

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

-

- Use 0 to disable summaries, or choose 15-1440 minutes. Summaries include - hashrate, workers, upstream share counts, blocks found, and best difficulty when - monitoring data is available. -

@@ -402,19 +418,77 @@ export function ExperimentalTab() {
)} - {error && ( -

- {error} -

- )} - {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} +
+ )} +
+ ); +} From 4168768a95845621bb3296c44cd4b52b495aaea6 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:54:12 +0500 Subject: [PATCH 4/5] fix: address Telegram review findings --- server/src/index.ts | 148 ++++++++++++-------- server/src/telegram.test.ts | 48 +++++++ server/src/telegram.ts | 36 +++++ src/components/pools/PoolPriorityEditor.tsx | 40 ++---- src/lib/pools.test.ts | 26 +++- src/lib/pools.ts | 17 ++- 6 files changed, 226 insertions(+), 89 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index eff25069..3a5e4ebb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -35,6 +35,8 @@ import { getLogDiagnostics, getLogStreams, readCollatedLogLines } from './logs/d import { ActivePoolTracker } from './active-pool.js'; import { getPoolConfigError, MAX_FALLBACK_POOLS } from './pool-validation.js'; import { + collectPaginatedMonitoringItems, + getTelegramWorkerCount, TelegramApiError, TelegramConfigError, TelegramService, @@ -796,7 +798,11 @@ function getContainerUrl(containerName: string, port: number): string { type MonitoringGlobal = { server?: { total_hashrate: number } | null; sv1_clients?: { total_clients: number; total_hashrate: number } | null; - sv2_clients?: { total_clients: number; total_hashrate: number } | null; + sv2_clients?: { + total_clients: number; + total_channels: number; + total_hashrate: number; + } | null; }; type MonitoringServerChannel = { @@ -809,17 +815,25 @@ type MonitoringServerChannel = { shares_rejected: number; }; -type MonitoringServerChannels = { - extended_channels: MonitoringServerChannel[]; - standard_channels: MonitoringServerChannel[]; +type MonitoringChannelsPage = { + total_extended: number; + total_standard: number; + extended_channels: T[]; + standard_channels: T[]; }; type MonitoringClient = { client_id: number; }; -type MonitoringClients = { - items: MonitoringClient[]; +type MonitoringItemsPage = { + total: number; + items: T[]; +}; + +type TaggedMonitoringChannel = { + kind: 'extended' | 'standard'; + channel: T; }; type MonitoringMiningChannel = { @@ -829,11 +843,6 @@ type MonitoringMiningChannel = { blocks_found: number; }; -type MonitoringClientChannels = { - extended_channels: MonitoringMiningChannel[]; - standard_channels: MonitoringMiningChannel[]; -}; - async function fetchMonitoringJson(url: string): Promise { try { const response = await fetch(url, { @@ -845,6 +854,47 @@ async function fetchMonitoringJson(url: string): Promise { } } +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(); @@ -865,31 +915,23 @@ async function getTelegramActivitySnapshot(): Promise 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, serverChannelResponse, clientResponse] = await Promise.all([ + const [global, serverChannels, monitoringClients] = await Promise.all([ fetchMonitoringJson(`${baseUrl}/global`), - fetchMonitoringJson( - `${baseUrl}/server/channels?offset=0&limit=100` - ), + fetchAllMonitoringChannels(`${baseUrl}/server/channels`), isJdMode - ? fetchMonitoringJson(`${baseUrl}/clients?offset=0&limit=100`) + ? fetchAllMonitoringItems(`${baseUrl}/clients`) : Promise.resolve(null), ]); const clients = isJdMode ? global?.sv2_clients : global?.sv1_clients; - const serverChannels = serverChannelResponse - ? [ - ...serverChannelResponse.extended_channels, - ...serverChannelResponse.standard_channels, - ] - : null; let miningChannels: TelegramMiningChannel[] | null = null; - if (isJdMode && clientResponse) { + if (isJdMode && monitoringClients) { const downstreamResponses = await Promise.all( - clientResponse.items.map(async (client) => ({ + monitoringClients.map(async (client) => ({ clientId: client.client_id, - channels: await fetchMonitoringJson( - `${baseUrl}/clients/${client.client_id}/channels?offset=0&limit=100` + channels: await fetchAllMonitoringChannels( + `${baseUrl}/clients/${client.client_id}/channels` ), })) ); @@ -897,52 +939,40 @@ async function getTelegramActivitySnapshot(): Promise if (downstreamResponses.every((response) => response.channels !== null)) { miningChannels = downstreamResponses.flatMap(({ clientId, channels }) => { if (!channels) return []; - return [ - ...channels.extended_channels.map((channel) => ({ - key: `jdc:${clientId}:extended:${channel.channel_id}:${channel.user_identity}`, - userIdentity: channel.user_identity, - blocksFound: channel.blocks_found, - bestDifficulty: channel.best_diff, - })), - ...channels.standard_channels.map((channel) => ({ - key: `jdc:${clientId}:standard:${channel.channel_id}:${channel.user_identity}`, - userIdentity: channel.user_identity, - blocksFound: channel.blocks_found, - bestDifficulty: channel.best_diff, - })), - ]; + 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 && serverChannelResponse) { - miningChannels = [ - ...serverChannelResponse.extended_channels.map((channel) => ({ - key: `translator:server:extended:${channel.channel_id}:${channel.user_identity}`, - userIdentity: channel.user_identity, - blocksFound: channel.blocks_found, - bestDifficulty: channel.best_diff, - })), - ...serverChannelResponse.standard_channels.map((channel) => ({ - key: `translator:server:standard:${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, hashrate: clients?.total_hashrate ?? global?.server?.total_hashrate ?? null, - workers: clients?.total_clients ?? null, + workers: getTelegramWorkerCount( + isJdMode, + global?.sv1_clients, + global?.sv2_clients, + ), sharesSubmitted: serverChannels - ? serverChannels.reduce((sum, channel) => sum + channel.shares_submitted, 0) + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_submitted, 0) : null, sharesAccepted: serverChannels - ? serverChannels.reduce((sum, channel) => sum + channel.shares_acknowledged, 0) + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_acknowledged, 0) : null, sharesRejected: serverChannels - ? serverChannels.reduce((sum, channel) => sum + channel.shares_rejected, 0) + ? serverChannels.reduce((sum, item) => sum + item.channel.shares_rejected, 0) : null, channels: miningChannels, }; diff --git a/server/src/telegram.test.ts b/server/src/telegram.test.ts index 3a656477..6af2009b 100644 --- a/server/src/telegram.test.ts +++ b/server/src/telegram.test.ts @@ -5,7 +5,9 @@ import path from 'node:path'; import test, { type TestContext } from 'node:test'; import { + collectPaginatedMonitoringItems, formatTelegramStatus, + getTelegramWorkerCount, getStatusChangeMessage, TelegramConfigError, TelegramService, @@ -100,6 +102,52 @@ function snapshot( }; } +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] }) diff --git a/server/src/telegram.ts b/server/src/telegram.ts index bde71aa0..1583d94c 100644 --- a/server/src/telegram.ts +++ b/server/src/telegram.ts @@ -113,6 +113,42 @@ export type TelegramActivitySnapshot = { 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 { diff --git a/src/components/pools/PoolPriorityEditor.tsx b/src/components/pools/PoolPriorityEditor.tsx index f274d1fc..64f16e7f 100644 --- a/src/components/pools/PoolPriorityEditor.tsx +++ b/src/components/pools/PoolPriorityEditor.tsx @@ -3,7 +3,7 @@ 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, isSamePool, knownPoolToConfig, type KnownPool, @@ -31,7 +31,6 @@ 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)) )); @@ -55,20 +54,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) => { @@ -265,18 +252,15 @@ export function PoolPriorityEditor({ ); })} - {selectedCustomIndex === -1 && ( - - )} + ); } diff --git a/src/lib/pools.test.ts b/src/lib/pools.test.ts index 58fb342a..d32d7f50 100644 --- a/src/lib/pools.test.ts +++ b/src/lib/pools.test.ts @@ -1,9 +1,33 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { SOLO_POOLS, knownPoolToConfig } from './pools'; +import type { PoolConfig } from '@sv2-ui/shared'; +import { + appendEmptyCustomPool, + 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('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..878abda8 100644 --- a/src/lib/pools.ts +++ b/src/lib/pools.ts @@ -1,7 +1,8 @@ /** * Shared pool preset definitions used by both the Setup Wizard and Settings. */ -import type { PoolConfig } from '@sv2-ui/shared'; +import type { MiningMode, PoolConfig } from '@sv2-ui/shared'; +import { withCompatiblePoolIdentity } from './miningIdentity'; export interface KnownPool { id: string; @@ -148,6 +149,20 @@ export function createEmptyCustomPool(userIdentity = ''): PoolConfig { }; } +export function appendEmptyCustomPool( + pools: PoolConfig[], + miningMode: MiningMode | null, +): PoolConfig[] { + return [ + ...pools, + withCompatiblePoolIdentity( + pools[0], + createEmptyCustomPool(), + miningMode, + ), + ]; +} + 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; From da7d350e9825cc48f8356b36dcfcfba815ac5158 Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:02:09 +0500 Subject: [PATCH 5/5] fix: distinguish pool failovers and enforce fallback limits --- server/src/index.ts | 5 +- server/src/pool-validation.ts | 1 - server/src/telegram.test.ts | 27 +++++++++- server/src/telegram.ts | 56 +++++++++++++++++---- shared/src/constants.ts | 1 + src/components/pools/PoolPriorityEditor.tsx | 10 ++-- src/lib/pools.test.ts | 17 ++++++- src/lib/pools.ts | 12 ++++- 8 files changed, 109 insertions(+), 20 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index 3a5e4ebb..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,7 @@ 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, @@ -902,6 +903,7 @@ async function getTelegramActivitySnapshot(): Promise return { running: false, poolName: status.poolName, + activePoolIndex: status.activePoolIndex, hashrate: null, workers: null, sharesSubmitted: null, @@ -959,6 +961,7 @@ async function getTelegramActivitySnapshot(): Promise return { running: true, poolName: status.poolName, + activePoolIndex: status.activePoolIndex, hashrate: clients?.total_hashrate ?? global?.server?.total_hashrate ?? null, workers: getTelegramWorkerCount( isJdMode, 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 index 6af2009b..4cf58d2b 100644 --- a/server/src/telegram.test.ts +++ b/server/src/telegram.test.ts @@ -87,6 +87,7 @@ function snapshot( return { running: true, poolName: 'Primary pool', + activePoolIndex: 0, hashrate: 125_000_000_000_000, workers: 3, sharesSubmitted: 42, @@ -193,6 +194,7 @@ test('reports only meaningful status changes', () => { const stopped = snapshot({ running: false, poolName: null, + activePoolIndex: null, hashrate: null, workers: null, sharesSubmitted: null, @@ -307,16 +309,37 @@ 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 })); + await service.poll(async () => snapshot({ + poolName: null, + activePoolIndex: null, + })); assert.equal(telegram.callsFor('sendMessage').length, 1); - await service.poll(async () => snapshot({ poolName: 'Fallback pool' })); + 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); diff --git a/server/src/telegram.ts b/server/src/telegram.ts index 1583d94c..52627415 100644 --- a/server/src/telegram.ts +++ b/server/src/telegram.ts @@ -105,6 +105,7 @@ export type TelegramMiningChannel = { export type TelegramActivitySnapshot = { running: boolean; poolName: string | null; + activePoolIndex: number | null; hashrate: number | null; workers: number | null; sharesSubmitted: number | null; @@ -298,6 +299,10 @@ function formatDifficulty(difficulty: number): string { }).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) => @@ -360,8 +365,15 @@ export function getStatusChangeMessage( if ( current.running && - previous.poolName !== current.poolName && - current.poolName !== null + current.poolName !== null && + ( + previous.poolName !== current.poolName || + ( + previous.activePoolIndex !== null && + current.activePoolIndex !== null && + previous.activePoolIndex !== current.activePoolIndex + ) + ) ) { return formatTelegramStatus(current, '🔁 SV2 active pool changed'); } @@ -505,7 +517,7 @@ export class TelegramService { private channelBaselines = new Map(); private bestDifficultyHighWatermark = 0; private lastSummaryAt: number | null = null; - private lastKnownPoolName: string | null = null; + private lastKnownPool: { name: string; index: number } | null = null; private pollInProgress = false; constructor( @@ -742,7 +754,7 @@ export class TelegramService { if (!this.previousSnapshot) { this.previousSnapshot = current; this.updateChannelBaselines(current.channels); - this.lastKnownPoolName = current.poolName; + this.updateLastKnownPool(current); this.lastSummaryAt = now; return; } @@ -769,17 +781,30 @@ export class TelegramService { 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.lastKnownPoolName && - current.poolName && - this.lastKnownPoolName !== current.poolName + 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: ${this.lastKnownPoolName}`, - `To: ${current.poolName}`, + `From: ${previousLabel}`, + `To: ${currentLabel}`, ].join('\n')); } @@ -835,7 +860,7 @@ export class TelegramService { ? { ...current, channels: this.previousSnapshot.channels } : current; this.updateChannelBaselines(current.channels); - if (current.poolName) this.lastKnownPoolName = current.poolName; + this.updateLastKnownPool(current); } finally { this.pollInProgress = false; } @@ -994,7 +1019,16 @@ export class TelegramService { this.channelBaselines.clear(); this.bestDifficultyHighWatermark = 0; this.lastSummaryAt = null; - this.lastKnownPoolName = 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 { 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/pools/PoolPriorityEditor.tsx b/src/components/pools/PoolPriorityEditor.tsx index 64f16e7f..62f29235 100644 --- a/src/components/pools/PoolPriorityEditor.tsx +++ b/src/components/pools/PoolPriorityEditor.tsx @@ -4,6 +4,7 @@ import { DEFAULT_POOL_PORT, type MiningMode, type PoolConfig } from '@sv2-ui/sha import { PoolIcon } from '@/components/ui/pool-icon'; import { appendEmptyCustomPool, + canAddPool, isSamePool, knownPoolToConfig, type KnownPool, @@ -34,6 +35,7 @@ export function PoolPriorityEditor({ 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)); @@ -42,7 +44,7 @@ export function PoolPriorityEditor({ return; } - if (preset.badge === 'coming-soon') return; + if (preset.badge === 'coming-soon' || poolLimitReached) return; onChange([ ...pools, @@ -205,7 +207,7 @@ export function PoolPriorityEditor({ })} {unselectedPresets.map((preset) => { - const isDisabled = preset.badge === 'coming-soon'; + const isDisabled = preset.badge === 'coming-soon' || poolLimitReached; return (