Implement Telegram notification features for payment expiry and new tariffs

- Added functionality to send Telegram notifications for upcoming payment expirations and new tariffs.
- Enhanced `runScheduledSync` and `runScheduledSyncTariffs` to trigger notifications based on settings.
- Updated database schema to include Telegram settings and notification preferences.
- Introduced a test endpoint for verifying Telegram notification setup in the settings page.
- Improved user interface in the settings page to manage Telegram bot token and notification preferences.
This commit is contained in:
Denozordec
2026-03-20 22:32:34 +07:00
parent 8ca276c65c
commit 7ce754ca90
10 changed files with 512 additions and 49 deletions
+8 -1
View File
@@ -172,7 +172,11 @@ export async function syncFromBillmanager(account, db, opts = {}) {
}
let tariffsCount = 0
let newTariffs = []
if (fetchTariffs) {
const existingTariffIds = new Set(
db.prepare('SELECT id FROM active_tariffs WHERE providerAccountId = ?').all(accountId).map((r) => r.id),
)
const syncedAt = new Date().toISOString()
db.run('DELETE FROM active_tariffs WHERE providerAccountId = ?', accountId)
const tariffInsertSql = `INSERT INTO active_tariffs (id, providerAccountId, providerId, externalId, datacenterKey, datacenterName, name, desc, vcpu, ramGb, diskGb, diskType, virtualization, channel, location, country, cpuModel, orderAvailable, price, syncedAt)
@@ -182,6 +186,9 @@ export async function syncFromBillmanager(account, db, opts = {}) {
const dcKey = t.datacenterKey ?? ''
const dcName = t.datacenterName ?? ''
const id = dcKey ? `tariff-bm-${accountId}-${t.externalId}-${dcKey}` : `tariff-bm-${accountId}-${t.externalId}`
if (!existingTariffIds.has(id)) {
newTariffs.push({ name: t.name || '', price: t.price || '', providerId })
}
db.run(tariffInsertSql,
id,
accountId,
@@ -220,5 +227,5 @@ export async function syncFromBillmanager(account, db, opts = {}) {
}
}
return { vpsCount, paymentsCount, tariffsCount, balance: dashboardInfo }
return { vpsCount, paymentsCount, tariffsCount, newTariffs, balance: dashboardInfo }
}
+35
View File
@@ -138,4 +138,39 @@ export const MIGRATIONS = [
}
},
},
{
name: 'settings_telegram',
run(db) {
try {
db.exec('ALTER TABLE settings ADD COLUMN telegramBotToken TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE settings ADD COLUMN telegramChatId TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE settings ADD COLUMN notifyPaymentExpiryEnabled INTEGER')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
try {
db.exec('ALTER TABLE settings ADD COLUMN notifyNewTariffsEnabled INTEGER')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
{
name: 'settings_telegram_thread',
run(db) {
try {
db.exec('ALTER TABLE settings ADD COLUMN telegramMessageThreadId TEXT')
} catch (e) {
if (!e.message?.includes('duplicate column')) throw e
}
},
},
]
+52 -6
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'
import { getDb } from '../db.js'
import { startScheduler } from '../sync-scheduler.js'
import { sendTelegramMessage } from '../telegram.js'
const router = Router()
@@ -14,14 +15,33 @@ export function rowToSettings(row) {
customFields = []
}
}
const { telegramBotToken, ...rest } = row
return {
...row,
...rest,
telegramBotTokenSet: Boolean(telegramBotToken?.trim()),
autoConvert: Boolean(row.autoConvert),
syncEnabled: Boolean(row.syncEnabled),
notifyPaymentExpiryEnabled: Boolean(row.notifyPaymentExpiryEnabled),
notifyNewTariffsEnabled: Boolean(row.notifyNewTariffsEnabled),
customFields: Array.isArray(customFields) ? customFields : [],
}
}
router.post('/telegram/test', async (req, res) => {
try {
const db = getDb()
const row = db.prepare('SELECT telegramBotToken, telegramChatId, telegramMessageThreadId FROM settings WHERE id = ?').get('settings-main')
if (!row?.telegramBotToken?.trim() || !row?.telegramChatId?.trim()) {
return res.status(400).json({ ok: false, error: 'Укажите токен бота и Chat ID в настройках' })
}
const text = '✅ <b>Тестовое уведомление</b>\n\nVPS Tracker — уведомления настроены корректно.'
await sendTelegramMessage(row.telegramBotToken, row.telegramChatId, text, row.telegramMessageThreadId || undefined)
res.json({ ok: true })
} catch (err) {
res.status(500).json({ ok: false, error: err.message || 'Ошибка отправки' })
}
})
router.get('/', (req, res) => {
try {
const db = getDb()
@@ -48,11 +68,17 @@ router.put('/:id', (req, res) => {
const syncEnabled = r.syncEnabled !== undefined ? (r.syncEnabled ? 1 : 0) : (existing?.syncEnabled ? 1 : 0)
const syncIntervalMinutes = r.syncIntervalMinutes !== undefined ? Math.max(15, Number(r.syncIntervalMinutes) || 60) : (existing?.syncIntervalMinutes ?? 60)
const syncTariffsIntervalMinutes = r.syncTariffsIntervalMinutes !== undefined ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : (existing?.syncTariffsIntervalMinutes ?? 1440)
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled !== undefined ? (r.notifyPaymentExpiryEnabled ? 1 : 0) : (existing?.notifyPaymentExpiryEnabled ? 1 : 0)
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled !== undefined ? (r.notifyNewTariffsEnabled ? 1 : 0) : (existing?.notifyNewTariffsEnabled ? 1 : 0)
const telegramBotToken = r.telegramBotToken !== undefined ? (r.telegramBotToken || '') : (existing?.telegramBotToken ?? '')
const telegramChatId = r.telegramChatId !== undefined ? (r.telegramChatId || '') : (existing?.telegramChatId ?? '')
const telegramMessageThreadId = r.telegramMessageThreadId !== undefined ? (r.telegramMessageThreadId || '') : (existing?.telegramMessageThreadId ?? '')
const customFields = serializeCustomFields(r.customFields ?? existing?.customFields)
if (existing) {
db.prepare(`
UPDATE settings SET
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?, customFields = ?
baseCurrency = ?, ratesUrl = ?, autoConvert = ?, ratesUpdatedAt = ?, syncEnabled = ?, syncIntervalMinutes = ?, syncTariffsIntervalMinutes = ?,
telegramBotToken = ?, telegramChatId = ?, telegramMessageThreadId = ?, notifyPaymentExpiryEnabled = ?, notifyNewTariffsEnabled = ?, customFields = ?
WHERE id = ?
`).run(
r.baseCurrency ?? existing.baseCurrency ?? 'RUB',
@@ -62,13 +88,18 @@ router.put('/:id', (req, res) => {
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
id,
)
} else {
db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.baseCurrency ?? 'RUB',
@@ -78,6 +109,11 @@ router.put('/:id', (req, res) => {
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
)
}
@@ -97,10 +133,15 @@ router.post('/', (req, res) => {
const syncEnabled = r.syncEnabled ? 1 : 0
const syncIntervalMinutes = Math.max(15, Number(r.syncIntervalMinutes) || 60)
const syncTariffsIntervalMinutes = Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
const notifyPaymentExpiryEnabled = r.notifyPaymentExpiryEnabled ? 1 : 0
const notifyNewTariffsEnabled = r.notifyNewTariffsEnabled ? 1 : 0
const telegramBotToken = r.telegramBotToken ?? ''
const telegramChatId = r.telegramChatId ?? ''
const telegramMessageThreadId = r.telegramMessageThreadId ?? ''
const customFields = serializeCustomFields(r.customFields)
db.prepare(`
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO settings (id, baseCurrency, ratesUrl, autoConvert, ratesUpdatedAt, syncEnabled, syncIntervalMinutes, syncTariffsIntervalMinutes, telegramBotToken, telegramChatId, telegramMessageThreadId, notifyPaymentExpiryEnabled, notifyNewTariffsEnabled, customFields)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
r.baseCurrency ?? 'RUB',
@@ -110,6 +151,11 @@ router.post('/', (req, res) => {
syncEnabled,
syncIntervalMinutes,
syncTariffsIntervalMinutes,
telegramBotToken,
telegramChatId,
telegramMessageThreadId,
notifyPaymentExpiryEnabled,
notifyNewTariffsEnabled,
customFields,
)
startScheduler()
+123 -6
View File
@@ -1,10 +1,110 @@
import { getDb } from './db.js'
import { syncFromBillmanager } from './adapters/billmanager/index.js'
import { sendTelegramMessage } from './telegram.js'
let syncIntervalId = null
let syncTariffsIntervalId = null
export function runScheduledSync() {
const UPCOMING_DAYS = 7
function getAccountBalance(accountId, providerAccounts, balanceLedger) {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balance_api != null && Number.isFinite(Number(account.balance_api))) {
return Number(account.balance_api)
}
const rows = balanceLedger.filter((row) => row.providerAccountId === accountId)
const credits = rows
.filter((row) => row.direction === 'credit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
const debits = rows
.filter((row) => row.direction === 'debit')
.reduce((acc, row) => acc + Number(row.amount || 0), 0)
return credits - debits
}
function getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now) {
if (vps.status !== 'active') return null
const account = providerAccounts.find((a) => a.id === vps.providerAccountId)
const tariffType = vps.tariffType || (Number(vps.dailyRate || 0) > 0 ? 'daily' : 'monthly')
const isDailyBilling = tariffType === 'daily' || account?.billingMode === 'daily'
let paidUntilFromApi = null
if (vps.paidUntil) {
const d = new Date(vps.paidUntil)
paidUntilFromApi = Number.isNaN(d.getTime()) ? null : d
}
const isPaidUntilNextDay =
paidUntilFromApi &&
(() => {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffMs = paidUntilFromApi - today
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
return diffDays >= 0 && diffDays <= 2
})()
const shouldCalculateFromBalance = isDailyBilling || isPaidUntilNextDay
if (!shouldCalculateFromBalance && paidUntilFromApi) return paidUntilFromApi
const dailyRate = Number(vps.dailyRate || 0)
const monthlyRate = Number(vps.monthlyRate || 0)
const burnRate = tariffType === 'daily' ? dailyRate : monthlyRate / 30
if (!Number.isFinite(burnRate) || burnRate <= 0) return paidUntilFromApi
const accountBalance = getAccountBalance(vps.providerAccountId, providerAccounts, balanceLedger)
const activeInAccount = db
.prepare('SELECT id FROM vps WHERE providerAccountId = ? AND status = ?')
.all(vps.providerAccountId, 'active').length
const allocatedBalance = activeInAccount > 0 ? Math.max(0, accountBalance) / activeInAccount : 0
const directPayments = payments
.filter((p) => p.vpsId === vps.id && p.type === 'direct_vps_payment')
.reduce((acc, p) => acc + Number(p.amount || 0), 0)
const funds = directPayments + allocatedBalance
const coveredDays = Math.floor(funds / burnRate)
if (!Number.isFinite(coveredDays) || coveredDays <= 0) return paidUntilFromApi
const paidUntil = new Date(now)
paidUntil.setDate(paidUntil.getDate() + coveredDays)
return paidUntil
}
async function sendPaymentExpiryNotifications(db) {
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
if (!settings?.notifyPaymentExpiryEnabled || !settings?.telegramBotToken?.trim() || !settings?.telegramChatId?.trim()) {
return
}
const vpsList = db.prepare('SELECT * FROM vps ORDER BY createdAt DESC').all()
const providerAccounts = db.prepare('SELECT * FROM provider_accounts ORDER BY name').all()
const payments = db.prepare('SELECT * FROM payments ORDER BY date DESC').all()
const balanceLedger = db.prepare('SELECT * FROM balance_ledger ORDER BY date DESC').all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
const now = new Date()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
const upcoming = []
for (const vps of vpsList) {
if (vps.status !== 'active') continue
const paidUntil = getPaidUntilDate(db, vps, providerAccounts, payments, balanceLedger, now)
if (!paidUntil || paidUntil > threshold || paidUntil < new Date(now.getFullYear(), now.getMonth(), now.getDate())) continue
const provider = providers.find((p) => p.id === vps.providerId)
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
}
upcoming.sort((a, b) => a.paidUntil - b.paidUntil)
if (upcoming.length === 0) return
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
const dateStr = paidUntil.toLocaleDateString('ru-RU')
return `${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
})
const text = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
await sendTelegramMessage(settings.telegramBotToken, settings.telegramChatId, text, settings.telegramMessageThreadId)
}
export async function runScheduledSync() {
try {
const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
@@ -14,16 +114,21 @@ export function runScheduledSync() {
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
`).all()
for (const account of accounts) {
syncFromBillmanager(account, db, { skipTariffs: true }).catch((err) => {
try {
await syncFromBillmanager(account, db, { skipTariffs: true })
} catch (err) {
console.warn(`Sync VPS/payments failed for account ${account.id}:`, err.message)
})
}
}
if (settings?.notifyPaymentExpiryEnabled) {
await sendPaymentExpiryNotifications(db)
}
} catch (err) {
console.warn('Scheduled sync error:', err.message)
}
}
export function runScheduledSyncTariffs() {
export async function runScheduledSyncTariffs() {
try {
const db = getDb()
const settings = db.prepare('SELECT * FROM settings WHERE id = ?').get('settings-main')
@@ -32,10 +137,22 @@ export function runScheduledSyncTariffs() {
SELECT * FROM provider_accounts
WHERE apiType = 'billmanager' AND apiBaseUrl IS NOT NULL AND apiBaseUrl != '' AND apiCredentials IS NOT NULL AND apiCredentials != ''
`).all()
const providers = db.prepare('SELECT * FROM providers ORDER BY name').all()
for (const account of accounts) {
syncFromBillmanager(account, db, { skipVpsPayments: true }).catch((err) => {
try {
const result = await syncFromBillmanager(account, db, { skipVpsPayments: true })
const newTariffs = result?.newTariffs || []
if (newTariffs.length > 0 && settings?.notifyNewTariffsEnabled && settings?.telegramBotToken?.trim() && settings?.telegramChatId?.trim()) {
const provider = providers.find((p) => p.id === account.providerId)
const providerName = provider?.name || account.name || '-'
const lines = newTariffs.slice(0, 15).map((t) => `${t.name || '—'}${t.price || '—'}`)
const text = `🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`
await sendTelegramMessage(settings.telegramBotToken, settings.telegramChatId, text, settings.telegramMessageThreadId)
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err.message)
})
}
}
} catch (err) {
console.warn('Scheduled sync tariffs error:', err.message)
+49
View File
@@ -0,0 +1,49 @@
/**
* Telegram Bot API — отправка уведомлений
*/
/**
* @param {string} token - Bot token from @BotFather
* @param {string|string[]} chatIds - Chat ID(s), comma-separated string or array
* @param {string} text - Message text
* @param {string|number} [messageThreadId] - ID топика в SuperGroup (для отправки в цепочку сообщений)
* @returns {Promise<void>}
*/
export async function sendTelegramMessage(token, chatIds, text, messageThreadId) {
if (!token?.trim() || !text?.trim()) return
const ids = Array.isArray(chatIds)
? chatIds
: String(chatIds || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (ids.length === 0) return
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
const payload = {
text,
parse_mode: 'HTML',
disable_web_page_preview: true,
}
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
for (const chatId of ids) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
chat_id: chatId,
}),
})
const data = await res.json().catch(() => ({}))
if (!data.ok) {
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
}
} catch (err) {
console.warn(`Telegram sendMessage error for chat ${chatId}:`, err.message)
}
}
}