From 1159c9807fd84c37cbf1e1ced1b0d37fdbe02e45 Mon Sep 17 00:00:00 2001 From: wario_is_here <117512622+warioishere@users.noreply.github.com> Date: Sat, 2 Aug 2025 13:50:43 +0200 Subject: [PATCH 1/8] feat: add Ntfy service configuration --- full-setup/blitzpool-example.env | 5 +++++ src/services/ntfy.service.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 src/services/ntfy.service.ts diff --git a/full-setup/blitzpool-example.env b/full-setup/blitzpool-example.env index 51745e86..3beadfa7 100644 --- a/full-setup/blitzpool-example.env +++ b/full-setup/blitzpool-example.env @@ -23,6 +23,11 @@ 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= + #optional discord bot #DISCORD_BOT_CLIENTID= #DISCORD_BOT_GUILD_ID= diff --git a/src/services/ntfy.service.ts b/src/services/ntfy.service.ts new file mode 100644 index 00000000..334c1b81 --- /dev/null +++ b/src/services/ntfy.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class NtfyService { + public readonly serverUrl?: string; + public readonly accessToken?: string; + public readonly topicPrefix?: string; + + constructor(private readonly configService: ConfigService) { + this.serverUrl = this.configService.get('NTFY_SERVER_URL'); + this.accessToken = this.configService.get('NTFY_ACCESS_TOKEN'); + this.topicPrefix = this.configService.get('NTFY_TOPIC_PREFIX'); + } +} From ae2fcf485da20460e798e0745245636bd7828c99 Mon Sep 17 00:00:00 2001 From: wario_is_here <117512622+warioishere@users.noreply.github.com> Date: Sat, 2 Aug 2025 14:15:31 +0200 Subject: [PATCH 2/8] feat: implement Ntfy command handling --- package-lock.json | 49 ++++++++++++ package.json | 2 + src/services/ntfy.service.ts | 139 ++++++++++++++++++++++++++++++++--- 3 files changed, 179 insertions(+), 11 deletions(-) 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/services/ntfy.service.ts b/src/services/ntfy.service.ts index 334c1b81..7f0d8f64 100644 --- a/src/services/ntfy.service.ts +++ b/src/services/ntfy.service.ts @@ -1,15 +1,132 @@ -import { Injectable } from '@nestjs/common'; +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 { 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'; @Injectable() -export class NtfyService { - public readonly serverUrl?: string; - public readonly accessToken?: string; - public readonly topicPrefix?: string; - - constructor(private readonly configService: ConfigService) { - this.serverUrl = this.configService.get('NTFY_SERVER_URL'); - this.accessToken = this.configService.get('NTFY_ACCESS_TOKEN'); - this.topicPrefix = this.configService.get('NTFY_TOPIC_PREFIX'); - } +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(); + + 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'); + } + + async onModuleInit(): Promise { + if (!this.serverUrl) { + return; + } + const addresses = await this.telegramSubscriptionsService.getAllAddresses(); + addresses.forEach(addr => 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`; + const headers: Record = {}; + if (this.accessToken) { + headers['Authorization'] = `Bearer ${this.accessToken}`; + } + const es = new EventSource(url, { headers }); + es.onmessage = async (event) => { + try { + const data = JSON.parse(event.data); + 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')) { + await this.sendStats(origin); + } else { + await this.publish(origin, 'Unknown command.'); + } + } + + private async sendStats(address: string) { + const workers = await this.clientService.getByAddress(address); + if (!workers || workers.length === 0) { + await this.publish(address, '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 totalShares = await this.clientStatisticsService.getTotalSharesForAddress(address); + const addressSettings = await this.addressSettingsService.getSettings(address, false); + const bestDiffRaw = addressSettings?.bestDifficulty ?? 0; + const bestDifficultyG = bestDiffRaw / 1e9; + const msg = + `πŸ“ˆ Stats for your address:\n` + + `- Current hashrate: ${totalHashrateTH.toFixed(2)} TH/s\n` + + `- Total shares: ${this.numberSuffix.to(totalShares)}\n` + + `- Last share: ${lastSeenSeconds} seconds ago\n` + + `- Best difficulty: ${bestDifficultyG.toFixed(2)} G`; + await this.publish(address, msg); + } + + 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' }; + 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); + } } + From 6999c22a7880f842e33aa73ea84f639938846136 Mon Sep 17 00:00:00 2001 From: wario_is_here <117512622+warioishere@users.noreply.github.com> Date: Sat, 2 Aug 2025 14:15:40 +0200 Subject: [PATCH 3/8] feat: subscribe to all client ntfy topics --- src/ORM/client/client.service.ts | 8 ++++++++ src/services/ntfy.service.ts | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) 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/services/ntfy.service.ts b/src/services/ntfy.service.ts index 7f0d8f64..78716caa 100644 --- a/src/services/ntfy.service.ts +++ b/src/services/ntfy.service.ts @@ -33,7 +33,11 @@ export class NtfyService implements OnModuleInit { if (!this.serverUrl) { return; } - const addresses = await this.telegramSubscriptionsService.getAllAddresses(); + const [telegramAddresses, clientAddresses] = await Promise.all([ + this.telegramSubscriptionsService.getAllAddresses(), + this.clientService.getAllAddresses(), + ]); + const addresses = Array.from(new Set([...telegramAddresses, ...clientAddresses])); addresses.forEach(addr => this.subscribe(addr)); } From 5faf1a0f34d39936973a463d8f3473fd4e6cb78b Mon Sep 17 00:00:00 2001 From: wario_is_here <117512622+warioishere@users.noreply.github.com> Date: Sat, 2 Aug 2025 14:15:46 +0200 Subject: [PATCH 4/8] feat: send Ntfy block and diff notifications --- full-setup/blitzpool-example.env | 1 + src/app.module.ts | 2 ++ src/services/notification.service.ts | 6 +++++- src/services/ntfy.service.ts | 30 +++++++++++++++++++++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/full-setup/blitzpool-example.env b/full-setup/blitzpool-example.env index 3beadfa7..6c0c06c9 100644 --- a/full-setup/blitzpool-example.env +++ b/full-setup/blitzpool-example.env @@ -27,6 +27,7 @@ TELEGRAM_DIFF_NOTIFICATIONS=true NTFY_SERVER_URL= NTFY_ACCESS_TOKEN= NTFY_TOPIC_PREFIX= +NTFY_DIFF_NOTIFICATIONS=false #optional discord bot #DISCORD_BOT_CLIENTID= 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/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 index 78716caa..fe0401c8 100644 --- a/src/services/ntfy.service.ts +++ b/src/services/ntfy.service.ts @@ -3,6 +3,7 @@ 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'; @@ -16,6 +17,8 @@ export class NtfyService implements OnModuleInit { 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, @@ -27,6 +30,7 @@ export class NtfyService implements OnModuleInit { 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 { @@ -38,7 +42,11 @@ export class NtfyService implements OnModuleInit { this.clientService.getAllAddresses(), ]); const addresses = Array.from(new Set([...telegramAddresses, ...clientAddresses])); - addresses.forEach(addr => this.subscribe(addr)); + 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 { @@ -132,5 +140,25 @@ export class NtfyService implements OnModuleInit { 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)}`); + } + } } From 4bc356f6c6cd050d1a2d12564a3c490c1f7f9caa Mon Sep 17 00:00:00 2001 From: wario_is_here <117512622+warioishere@users.noreply.github.com> Date: Sat, 2 Aug 2025 14:15:52 +0200 Subject: [PATCH 5/8] feat: centralize stats message logic --- src/services/common-command-handlers.ts | 43 +++++++++++++++++++++++++ src/services/ntfy.service.ts | 36 ++++++++------------- src/services/telegram.service.ts | 36 ++++++--------------- 3 files changed, 66 insertions(+), 49 deletions(-) create mode 100644 src/services/common-command-handlers.ts 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/ntfy.service.ts b/src/services/ntfy.service.ts index fe0401c8..dc5f31d6 100644 --- a/src/services/ntfy.service.ts +++ b/src/services/ntfy.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 NtfyService implements OnModuleInit { @@ -96,34 +97,23 @@ export class NtfyService implements OnModuleInit { this.subscribe(address); await this.publish(origin, `Subscribed to ${address}.`); } else if (text.startsWith('/stats')) { - await this.sendStats(origin); + 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 sendStats(address: string) { - const workers = await this.clientService.getByAddress(address); - if (!workers || workers.length === 0) { - await this.publish(address, '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 totalShares = await this.clientStatisticsService.getTotalSharesForAddress(address); - const addressSettings = await this.addressSettingsService.getSettings(address, false); - const bestDiffRaw = addressSettings?.bestDifficulty ?? 0; - const bestDifficultyG = bestDiffRaw / 1e9; - const msg = - `πŸ“ˆ Stats for your address:\n` + - `- Current hashrate: ${totalHashrateTH.toFixed(2)} TH/s\n` + - `- Total shares: ${this.numberSuffix.to(totalShares)}\n` + - `- Last share: ${lastSeenSeconds} seconds ago\n` + - `- Best difficulty: ${bestDifficultyG.toFixed(2)} G`; - await this.publish(address, msg); - } - private async publish(address: string, message: string) { if (!this.serverUrl) { return; 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, { From e7607e49f253ab463b03d6b146e4e3104019bb99 Mon Sep 17 00:00:00 2001 From: wario_is_here <117512622+warioishere@users.noreply.github.com> Date: Sun, 10 Aug 2025 10:15:18 +0200 Subject: [PATCH 6/8] fix(ntfy): import EventSource properly --- src/services/ntfy.service.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/services/ntfy.service.ts b/src/services/ntfy.service.ts index dc5f31d6..a81c83c7 100644 --- a/src/services/ntfy.service.ts +++ b/src/services/ntfy.service.ts @@ -1,7 +1,7 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; -import EventSource from 'eventsource'; +import { EventSource } from 'eventsource'; import { validate } from 'bitcoin-address-validation'; import { Block } from 'bitcoinjs-lib'; import { NumberSuffix } from '../utils/NumberSuffix'; @@ -60,11 +60,19 @@ export class NtfyService implements OnModuleInit { } const topic = this.topicFor(address); const url = `${this.serverUrl}/${topic}/sse`; - const headers: Record = {}; + let es: EventSource; if (this.accessToken) { - headers['Authorization'] = `Bearer ${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); } - const es = new EventSource(url, { headers }); es.onmessage = async (event) => { try { const data = JSON.parse(event.data); From ed8c27ec4252c7fb1921b2c5121c2b8b77fa5717 Mon Sep 17 00:00:00 2001 From: wario_is_here <117512622+warioishere@users.noreply.github.com> Date: Sun, 10 Aug 2025 10:51:54 +0200 Subject: [PATCH 7/8] docs: document NTFY usage --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) 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 From 89ce2aca7536952f48d51948a17e178a59512dcd Mon Sep 17 00:00:00 2001 From: wario_is_here <117512622+warioishere@users.noreply.github.com> Date: Sun, 10 Aug 2025 10:57:14 +0200 Subject: [PATCH 8/8] feat: tag NTFY bot messages --- src/services/ntfy.service.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/services/ntfy.service.ts b/src/services/ntfy.service.ts index a81c83c7..4902511e 100644 --- a/src/services/ntfy.service.ts +++ b/src/services/ntfy.service.ts @@ -76,6 +76,14 @@ export class NtfyService implements OnModuleInit { 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); @@ -128,7 +136,10 @@ export class NtfyService implements OnModuleInit { } const topic = this.topicFor(address); const url = `${this.serverUrl}/${topic}`; - const headers: Record = { 'Content-Type': 'text/plain' }; + const headers: Record = { + 'Content-Type': 'text/plain', + 'Tags': 'bot', + }; if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; }