diff --git a/README.md b/README.md index 30f739aa..e6b1b2d9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,27 @@ Use our Encryption tool for btc worker addresses here: https://github.com/warioishere/blitzpool-message-encryptor-for-TG +### 📢 NTFY Notifications + +BlitzPool can mirror its Telegram bot interactions over [ntfy](https://ntfy.sh/) topics. +Enable the service by setting the following optional environment variables: + +``` +NTFY_SERVER_URL= +NTFY_ACCESS_TOKEN= +NTFY_TOPIC_PREFIX= +NTFY_DIFF_NOTIFICATIONS=true # publish best-diff alerts +``` + +On startup the pool subscribes to topics for all known BTC addresses using the +`
` convention. Post commands like `/subscribe` or `/stats` to the +topic of your address and the service will reply on the same channel: + +``` +curl -d /stats $NTFY_SERVER_URL/myPrefix1ABC... +curl -d "/subscribe 1DEF..." $NTFY_SERVER_URL/myPrefix1ABC... +``` + #### 🛠️ Extra Services - Integrated `blockTemplateInterval` configuration - Hashrate corrections and updated statistics endpoints diff --git a/full-setup/blitzpool-example.env b/full-setup/blitzpool-example.env index 51745e86..6c0c06c9 100644 --- a/full-setup/blitzpool-example.env +++ b/full-setup/blitzpool-example.env @@ -23,6 +23,12 @@ DIFFICULTY_CHECK_INTERVAL_MS=60000 TELEGRAM_BOT_TOKEN="xxx" TELEGRAM_DIFF_NOTIFICATIONS=true +#optional ntfy notification service +NTFY_SERVER_URL= +NTFY_ACCESS_TOKEN= +NTFY_TOPIC_PREFIX= +NTFY_DIFF_NOTIFICATIONS=false + #optional discord bot #DISCORD_BOT_CLIENTID= #DISCORD_BOT_GUILD_ID= diff --git a/package-lock.json b/package-lock.json index a954f0fd..2dae46f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "discord.js": "^14.11.0", + "eventsource": "^4.0.0", "merkle-lib": "^2.0.10", "node-telegram-bot-api": "^0.61.0", "reflect-metadata": "^0.1.13", @@ -45,6 +46,7 @@ "@nestjs/testing": "^9.0.0", "@types/big.js": "^6.1.6", "@types/cron": "^2.0.1", + "@types/eventsource": "^1.1.15", "@types/express": "^4.17.13", "@types/jest": "29.5.1", "@types/node": "^18.16.12", @@ -2295,6 +2297,13 @@ "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", "dev": true }, + "node_modules/@types/eventsource": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", + "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", @@ -5155,6 +5164,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.0.0.tgz", + "integrity": "sha512-fvIkb9qZzdMxgZrEQDyll+9oJsyaVvY92I2Re+qK0qEJ+w5s0X3dtz+M0VAPOjP1gtU3iqWyjQ0G3nvd5CLZ2g==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", + "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -13404,6 +13434,12 @@ "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", "dev": true }, + "@types/eventsource": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@types/eventsource/-/eventsource-1.1.15.tgz", + "integrity": "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA==", + "dev": true + }, "@types/express": { "version": "4.17.17", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", @@ -15619,6 +15655,19 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" }, + "eventsource": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.0.0.tgz", + "integrity": "sha512-fvIkb9qZzdMxgZrEQDyll+9oJsyaVvY92I2Re+qK0qEJ+w5s0X3dtz+M0VAPOjP1gtU3iqWyjQ0G3nvd5CLZ2g==", + "requires": { + "eventsource-parser": "^3.0.1" + } + }, + "eventsource-parser": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", + "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==" + }, "evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", diff --git a/package.json b/package.json index 17c68031..73b3ec3e 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "discord.js": "^14.11.0", + "eventsource": "^4.0.0", "merkle-lib": "^2.0.10", "node-telegram-bot-api": "^0.61.0", "reflect-metadata": "^0.1.13", @@ -54,6 +55,7 @@ "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.0.0", + "@types/eventsource": "^1.1.15", "@types/big.js": "^6.1.6", "@types/cron": "^2.0.1", "@types/express": "^4.17.13", diff --git a/src/ORM/client/client.service.ts b/src/ORM/client/client.service.ts index f49cd5c9..1ec5acdf 100644 --- a/src/ORM/client/client.service.ts +++ b/src/ORM/client/client.service.ts @@ -182,4 +182,12 @@ export class ClientService { return result; } + public async getAllAddresses(): Promise { + const rows = await this.clientRepository + .createQueryBuilder('client') + .select('DISTINCT client.address', 'address') + .getRawMany(); + return rows.map(r => r.address); + } + } \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index ae75f9cb..f714cde4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ import { NotificationService } from './services/notification.service'; import { StratumV1JobsService } from './services/stratum-v1-jobs.service'; import { StratumV1Service } from './services/stratum-v1.service'; import { TelegramService } from './services/telegram.service'; +import { NtfyService } from './services/ntfy.service'; import { ExternalSharesService } from './services/external-shares.service'; import { ExternalShareController } from './controllers/external-share/external-share.controller'; import { ExternalSharesModule } from './ORM/external-shares/external-shares.module'; @@ -73,6 +74,7 @@ const ORMModules = [ AppService, StratumV1Service, TelegramService, + NtfyService, BitcoinRpcService, NotificationService, BitcoinAddressValidator, diff --git a/src/services/common-command-handlers.ts b/src/services/common-command-handlers.ts new file mode 100644 index 00000000..982d7407 --- /dev/null +++ b/src/services/common-command-handlers.ts @@ -0,0 +1,43 @@ +import { ClientService } from '../ORM/client/client.service'; +import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; +import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; +import { NumberSuffix } from '../utils/NumberSuffix'; + +export interface StatsMessages { + de: string; + en: string; +} + +export async function buildStatsMessage( + address: string, + clientService: ClientService, + addressSettingsService: AddressSettingsService, + clientStatisticsService: ClientStatisticsService, + numberSuffix: NumberSuffix +): Promise { + const workers = await clientService.getByAddress(address); + if (!workers || workers.length === 0) { + return null; + } + const totalHashrate = workers.reduce((sum, w) => sum + (w.hashRate ?? 0), 0); + const totalHashrateTH = totalHashrate / 1e12; + const lastSeenSeconds = Math.floor((Date.now() - new Date(workers[0].updatedAt).getTime()) / 1000); + const totalShares = await clientStatisticsService.getTotalSharesForAddress(address); + const addressSettings = await addressSettingsService.getSettings(address, false); + const bestDiffRaw = addressSettings?.bestDifficulty ?? 0; + const bestDifficultyG = bestDiffRaw / 1e9; + + return { + de: `📈 Stats für deine Adresse:\n` + + `- Aktuelle Hashrate: ${totalHashrateTH.toFixed(2)} TH/s\n` + + `- Gesamt-Shares: ${numberSuffix.to(totalShares)}\n` + + `- Letzter Share: vor ${lastSeenSeconds} Sekunden\n` + + `- Beste Difficulty: ${bestDifficultyG.toFixed(2)} G`, + en: `📈 Stats for your address:\n` + + `- Current hashrate: ${totalHashrateTH.toFixed(2)} TH/s\n` + + `- Total shares: ${numberSuffix.to(totalShares)}\n` + + `- Last share: ${lastSeenSeconds} seconds ago\n` + + `- Best difficulty: ${bestDifficultyG.toFixed(2)} G`, + }; +} + diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts index a48eeafe..68c9f11b 100644 --- a/src/services/notification.service.ts +++ b/src/services/notification.service.ts @@ -3,6 +3,7 @@ import { Block } from 'bitcoinjs-lib'; import { DiscordService } from './discord.service'; import { TelegramService } from './telegram.service'; +import { NtfyService } from './ntfy.service'; @Injectable() @@ -10,7 +11,8 @@ export class NotificationService implements OnModuleInit { constructor( private readonly telegramService: TelegramService, - private readonly discordService: DiscordService + private readonly discordService: DiscordService, + private readonly ntfyService: NtfyService, ) { } async onModuleInit(): Promise { @@ -20,10 +22,12 @@ export class NotificationService implements OnModuleInit { public async notifySubscribersBlockFound(address: string, height: number, block: Block, message: string) { await this.discordService.notifySubscribersBlockFound(height, block, message); await this.telegramService.notifySubscribersBlockFound(address, height, block, message); + await this.ntfyService.notifySubscribersBlockFound(address, height, block, message); } public async notifySubscribersBestDiff(address: string, submissionDifficulty: number) { await this.discordService.notifySubscribersBestDiff(submissionDifficulty); await this.telegramService.notifySubscribersBestDiff(address, submissionDifficulty); + await this.ntfyService.notifySubscribersBestDiff(address, submissionDifficulty); } } \ No newline at end of file diff --git a/src/services/ntfy.service.ts b/src/services/ntfy.service.ts new file mode 100644 index 00000000..4902511e --- /dev/null +++ b/src/services/ntfy.service.ts @@ -0,0 +1,173 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { EventSource } from 'eventsource'; +import { validate } from 'bitcoin-address-validation'; +import { Block } from 'bitcoinjs-lib'; +import { NumberSuffix } from '../utils/NumberSuffix'; +import { TelegramSubscriptionsService } from '../ORM/telegram-subscriptions/telegram-subscriptions.service'; +import { ClientService } from '../ORM/client/client.service'; +import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; +import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; +import { buildStatsMessage } from './common-command-handlers'; + +@Injectable() +export class NtfyService implements OnModuleInit { + private readonly serverUrl?: string; + private readonly accessToken?: string; + private readonly topicPrefix?: string; + private readonly numberSuffix = new NumberSuffix(); + private sources: Map = new Map(); + private readonly diffNotifications: boolean; + private bestDiffCache: Map = new Map(); + + constructor( + private readonly configService: ConfigService, + private readonly telegramSubscriptionsService: TelegramSubscriptionsService, + private readonly clientService: ClientService, + private readonly addressSettingsService: AddressSettingsService, + private readonly clientStatisticsService: ClientStatisticsService, + ) { + this.serverUrl = this.configService.get('NTFY_SERVER_URL'); + this.accessToken = this.configService.get('NTFY_ACCESS_TOKEN'); + this.topicPrefix = this.configService.get('NTFY_TOPIC_PREFIX'); + this.diffNotifications = (this.configService.get('NTFY_DIFF_NOTIFICATIONS')?.toLowerCase() === 'true') || false; + } + + async onModuleInit(): Promise { + if (!this.serverUrl) { + return; + } + const [telegramAddresses, clientAddresses] = await Promise.all([ + this.telegramSubscriptionsService.getAllAddresses(), + this.clientService.getAllAddresses(), + ]); + const addresses = Array.from(new Set([...telegramAddresses, ...clientAddresses])); + const bests = await Promise.all(addresses.map(a => this.addressSettingsService.getSettings(a, false))); + addresses.forEach((addr, idx) => { + this.bestDiffCache.set(addr, bests[idx]?.bestDifficulty ?? 0); + this.subscribe(addr); + }); + } + + private topicFor(address: string): string { + return this.topicPrefix ? `${this.topicPrefix}${address}` : address; + } + + private subscribe(address: string) { + if (!this.serverUrl || this.sources.has(address)) { + return; + } + const topic = this.topicFor(address); + const url = `${this.serverUrl}/${topic}/sse`; + let es: EventSource; + if (this.accessToken) { + const fetchWithAuth = (input: string | URL, init: any) => { + init.headers = { + ...(init.headers || {}), + Authorization: `Bearer ${this.accessToken}`, + }; + return fetch(input, init); + }; + es = new EventSource(url, { fetch: fetchWithAuth } as any); + } else { + es = new EventSource(url); + } + es.onmessage = async (event) => { + try { + const data = JSON.parse(event.data); + const tags: string[] = Array.isArray(data.tags) + ? data.tags + : typeof data.tags === 'string' + ? data.tags.split(',') + : []; + if (tags.includes('bot')) { + return; + } + const text: string | undefined = data.message?.trim(); + if (text) { + await this.handleCommand(address, text); + } + } catch (err) { + console.error('NTFY parse error', err); + } + }; + es.onerror = (err) => { + console.error('NTFY connection error', err); + }; + this.sources.set(address, es); + } + + private async handleCommand(origin: string, text: string) { + if (text.startsWith('/subscribe')) { + const raw = text.replace('/subscribe', '').trim(); + if (!raw) { + await this.publish(origin, 'Please provide an address.'); + return; + } + const address = raw; + if (!validate(address)) { + await this.publish(origin, 'Invalid address.'); + return; + } + this.subscribe(address); + await this.publish(origin, `Subscribed to ${address}.`); + } else if (text.startsWith('/stats')) { + const messages = await buildStatsMessage( + origin, + this.clientService, + this.addressSettingsService, + this.clientStatisticsService, + this.numberSuffix + ); + if (!messages) { + await this.publish(origin, 'No active workers found for this address.'); + } else { + await this.publish(origin, messages.en); + } + } else { + await this.publish(origin, 'Unknown command.'); + } + } + + private async publish(address: string, message: string) { + if (!this.serverUrl) { + return; + } + const topic = this.topicFor(address); + const url = `${this.serverUrl}/${topic}`; + const headers: Record = { + 'Content-Type': 'text/plain', + 'Tags': 'bot', + }; + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + await axios.post(url, message, { headers }); + } + + public async notify(address: string, message: string) { + await this.publish(address, message); + } + + public async notifySubscribersBlockFound(address: string, height: number, _block: any, message: string) { + await this.publish(address, `Block found! Result: ${message}, Height: ${height}`); + } + + public async notifySubscribersBestDiff(address: string, submissionDifficulty: number) { + if (!this.diffNotifications) return; + + let currentBest = this.bestDiffCache.get(address); + if (currentBest === undefined) { + const settings = await this.addressSettingsService.getSettings(address, false); + currentBest = settings?.bestDifficulty ?? 0; + this.bestDiffCache.set(address, currentBest); + } + + if (submissionDifficulty > currentBest) { + this.bestDiffCache.set(address, submissionDifficulty); + await this.publish(address, `\uD83C\uDFC6 New best difficulty!\nValue: ${this.numberSuffix.to(submissionDifficulty)}`); + } + } +} + diff --git a/src/services/telegram.service.ts b/src/services/telegram.service.ts index 59145202..8810a585 100644 --- a/src/services/telegram.service.ts +++ b/src/services/telegram.service.ts @@ -9,6 +9,7 @@ import { TelegramSubscriptionsService } from '../ORM/telegram-subscriptions/tele import { ClientService } from '../ORM/client/client.service'; import { AddressSettingsService } from '../ORM/address-settings/address-settings.service'; import { ClientStatisticsService } from '../ORM/client-statistics/client-statistics.service'; +import { buildStatsMessage } from './common-command-handlers'; @Injectable() export class TelegramService implements OnModuleInit { @@ -387,38 +388,21 @@ I will decrypt it and respond just like with plain text. 🔒` } try { - const workers = await this.clientService.getByAddress(address); - const addressSettings = await this.addressSettingsService.getSettings(address, false); - const totalShares = await this.clientStatisticsService.getTotalSharesForAddress(address); - - if (!workers || workers.length === 0) { + const messages = await buildStatsMessage( + address, + this.clientService, + this.addressSettingsService, + this.clientStatisticsService, + this.numberSuffix + ); + if (!messages) { this.reply(chatId, { de: 'Keine aktiven Worker für diese Adresse gefunden.', en: 'No active workers found for this address.' }); return; } - - const totalHashrate = workers.reduce((sum, w) => sum + (w.hashRate ?? 0), 0); - const totalHashrateTH = totalHashrate / 1e12; - - const lastSeenSeconds = Math.floor((Date.now() - new Date(workers[0].updatedAt).getTime()) / 1000); - - const bestDiffRaw = addressSettings?.bestDifficulty ?? 0; - const bestDifficultyG = bestDiffRaw / 1e9; - - this.reply(chatId, { - de: `📈 Stats für deine Adresse: -- Aktuelle Hashrate: ${totalHashrateTH.toFixed(2)} TH/s -- Gesamt-Shares: ${this.numberSuffix.to(totalShares)} -- Letzter Share: vor ${lastSeenSeconds} Sekunden -- Beste Difficulty: ${bestDifficultyG.toFixed(2)} G`, - en: `📈 Stats for your address: -- Current hashrate: ${totalHashrateTH.toFixed(2)} TH/s -- Total shares: ${this.numberSuffix.to(totalShares)} -- Last share: ${lastSeenSeconds} seconds ago -- Best difficulty: ${bestDifficultyG.toFixed(2)} G` - }); + this.reply(chatId, messages); } catch (err) { console.error("Fehler bei /stats:", err); this.reply(chatId, {