feat(api, web): добавить поддержку уведомлений и журнал уведомлений
Docker / build (push) Has been cancelled

Добавлены новые функции для отправки уведомлений через Telegram и webhook, включая настройки для интервалов уведомлений и проверки uptime. Реализован журнал уведомлений для отслеживания статуса отправленных сообщений. Обновлены схемы и интерфейсы для поддержки новых полей и функционала.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-29 00:36:05 +07:00
co-authored by Cursor
parent 2c66d0c0ee
commit 7f91bc3624
28 changed files with 1046 additions and 437 deletions
+5 -1
View File
@@ -22,6 +22,7 @@ import { ratesProxyRoutes } from './routes/rates-proxy.js'
import { migrateRoutes } from './routes/migrate.js'
import { dashboardRoutes } from './routes/dashboard.js'
import { auditRoutes } from './routes/audit.js'
import { notificationsRoutes } from './routes/notifications.js'
import { startScheduler } from './services/scheduler.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -56,6 +57,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
await app.register(migrateRoutes)
await app.register(dashboardRoutes)
await app.register(auditRoutes)
await app.register(notificationsRoutes)
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
if (existsSync(staticDir)) {
@@ -88,4 +90,6 @@ async function start() {
}
}
void start()
if (!process.env.VITEST) {
void start()
}
+9
View File
@@ -0,0 +1,9 @@
import type { FastifyPluginAsync } from 'fastify'
import { notificationRepository } from '@cfdm/db/repositories/notifications'
export const notificationsRoutes: FastifyPluginAsync = async (app) => {
app.get<{ Querystring: { limit?: string } }>('/api/notifications/log', async (req) => {
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50))
return notificationRepository.listRecent(limit)
})
}
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { resetTestDb } from '@cfdm/db/test-setup'
import { buildApp } from '../index.js'
describe('settings telegram test', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
settingsRepository.upsert('settings-main', {
telegramBotToken: 'token',
telegramChatId: '123',
})
app = await buildApp()
})
afterEach(async () => {
await app.close()
vi.unstubAllGlobals()
closeDb()
})
it('returns telegram API error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({ ok: false, description: 'Bad Request: chat not found' }),
),
)
const res = await app.inject({ method: 'POST', url: '/api/settings/telegram/test' })
expect(res.statusCode).toBe(200)
const body = res.json() as { ok: boolean; error?: string }
expect(body.ok).toBe(false)
expect(body.error).toContain('chat not found')
})
})
+17 -2
View File
@@ -4,6 +4,7 @@ import { settingsSchema } from '@cfdm/shared/contracts/settings'
import { restartScheduler } from '../services/scheduler.js'
import { sendTelegramMessage } from '../services/telegram.js'
import { deliverWebhook } from '../services/notifications/channels.js'
export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/settings', async () => settingsRepository.list())
@@ -34,12 +35,26 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) {
return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
}
await sendTelegramMessage(
const result = await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
'✅ VPS Tracker: тестовое сообщение',
settings.telegramMessageThreadId,
)
return { ok: true }
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' }
})
app.post('/api/settings/webhook/test', async () => {
const settings = settingsRepository.getRow('settings-main')
if (!settings?.webhookEnabled) {
return { ok: false, error: 'Включите webhook в настройках' }
}
const result = await deliverWebhook(settings, {
event: 'test',
message: 'VPS Tracker: тестовое webhook-сообщение',
timestamp: new Date().toISOString(),
data: { source: 'settings_test' },
})
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка webhook' }
})
}
+5
View File
@@ -123,6 +123,11 @@ export function importJsonSnapshot(data: BackupPayload): void {
customFields: customFields != null ? String(customFields) : null,
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled ? 1 : 0,
notifySyncDigestEnabled: s.notifySyncDigestEnabled ? 1 : 0,
notifyVpsDownEnabled: s.notifyVpsDownEnabled ? 1 : 0,
webhookUrl: String(s.webhookUrl ?? ''),
webhookEnabled: s.webhookEnabled ? 1 : 0,
notifyIntervalMinutes: Number(s.notifyIntervalMinutes) || 60,
uptimeCheckIntervalMinutes: Number(s.uptimeCheckIntervalMinutes) || 5,
})
.run()
}
@@ -0,0 +1,40 @@
import { sendTelegramMessage } from '../telegram.js'
import type { WebhookPayload } from '../webhook.js'
import type { NotificationChannel, SettingsNotifyRow } from './types.js'
export async function deliverTelegram(
settings: SettingsNotifyRow,
messageHtml: string,
): Promise<{ ok: boolean; error?: string }> {
const token = settings.telegramBotToken?.trim()
const chatId = settings.telegramChatId?.trim()
if (!token || !chatId) return { ok: false, error: 'Telegram не настроен' }
return sendTelegramMessage(token, chatId, messageHtml, settings.telegramMessageThreadId)
}
export async function deliverWebhook(
settings: SettingsNotifyRow,
payload: WebhookPayload,
): Promise<{ ok: boolean; error?: string }> {
if (!settings.webhookEnabled) return { ok: false, error: 'Webhook выключен' }
const url = settings.webhookUrl?.trim()
if (!url) return { ok: false, error: 'Webhook URL не указан' }
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
export function activeChannels(settings: SettingsNotifyRow): NotificationChannel[] {
const channels: NotificationChannel[] = []
if (settings.telegramBotToken?.trim() && settings.telegramChatId?.trim()) channels.push('telegram')
if (settings.webhookEnabled && settings.webhookUrl?.trim()) channels.push('webhook')
return channels
}
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { notificationRepository } from '@cfdm/db/repositories/notifications'
import { resetTestDb } from '@cfdm/db/test-setup'
import { shouldSkipDedup, markDedupSent } from './dedup.js'
describe('notification dedup', () => {
beforeEach(() => {
resetTestDb()
})
afterEach(() => {
closeDb()
})
it('skips duplicate fingerprint mode', () => {
expect(shouldSkipDedup('sync_digest', 'fp-1', 'fingerprint')).toBe(false)
markDedupSent('sync_digest', 'fp-1', 'fingerprint')
expect(shouldSkipDedup('sync_digest', 'fp-1', 'fingerprint')).toBe(true)
expect(shouldSkipDedup('sync_digest', 'fp-2', 'fingerprint')).toBe(false)
})
it('skips state_transition when status unchanged', () => {
markDedupSent('vps_down', 'host-a', 'state_transition', 'vps_health:vps_down', 'host-a')
expect(
shouldSkipDedup('vps_down', 'host-a', 'state_transition', 'vps_health:vps_down', 'host-a'),
).toBe(true)
expect(
shouldSkipDedup('vps_down', 'host-a|host-b', 'state_transition', 'vps_health:vps_down', 'host-a|host-b'),
).toBe(false)
})
it('logs skipped entries via repository', () => {
notificationRepository.append({
event: 'test',
channel: 'webhook',
status: 'skipped',
fingerprint: 'x',
message: 'msg',
})
const rows = notificationRepository.listRecent(5)
expect(rows).toHaveLength(1)
expect(rows[0]?.status).toBe('skipped')
})
})
@@ -0,0 +1,49 @@
import { notificationRepository } from '@cfdm/db/repositories/notifications'
import type { DedupMode } from './types.js'
const DAY_MS = 24 * 60 * 60 * 1000
export function shouldSkipDedup(
event: string,
fingerprint: string,
mode: DedupMode,
stateKey?: string,
newStatus?: string,
): boolean {
const key = stateKey ?? event
const state = notificationRepository.getState(key)
const now = Date.now()
if (mode === 'state_transition') {
if (!newStatus) return false
if (state?.lastStatus === newStatus) return true
return false
}
if (mode === 'fingerprint') {
if (state?.lastFingerprint === fingerprint) return true
return false
}
// daily
if (state?.lastFingerprint === fingerprint && state.lastSentAt) {
const last = new Date(state.lastSentAt).getTime()
if (!Number.isNaN(last) && now - last < DAY_MS) return true
}
return false
}
export function markDedupSent(
event: string,
fingerprint: string,
mode: DedupMode,
stateKey?: string,
newStatus?: string,
): void {
const key = stateKey ?? event
notificationRepository.upsertState(key, {
lastFingerprint: fingerprint,
lastSentAt: new Date().toISOString(),
lastStatus: mode === 'state_transition' ? newStatus : undefined,
})
}
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb } from '@cfdm/db/test-setup'
import { publishNotification } from './engine.js'
import type { NotificationPayload } from './types.js'
describe('notification engine', () => {
beforeEach(() => {
resetTestDb()
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('ok', { status: 200 })),
)
})
afterEach(() => {
vi.unstubAllGlobals()
closeDb()
})
it('sends webhook without telegram configured', async () => {
const payload: NotificationPayload = {
event: 'payment_expiry',
fingerprint: 'fp-test',
messagePlain: 'test message',
dedup: 'fingerprint',
}
const result = await publishNotification(
{
notifyPaymentExpiryEnabled: true,
webhookEnabled: true,
webhookUrl: 'https://example.com/hook',
},
payload,
)
expect(result).toBe('sent')
expect(fetch).toHaveBeenCalled()
})
it('skips when event disabled', async () => {
const payload: NotificationPayload = {
event: 'payment_expiry',
fingerprint: 'fp-test',
messagePlain: 'test',
dedup: 'fingerprint',
}
const result = await publishNotification(
{
notifyPaymentExpiryEnabled: false,
webhookEnabled: true,
webhookUrl: 'https://example.com/hook',
},
payload,
)
expect(result).toBe('skipped')
expect(fetch).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,94 @@
import { notificationRepository } from '@cfdm/db/repositories/notifications'
import { activeChannels, deliverTelegram, deliverWebhook } from './channels.js'
import { markDedupSent, shouldSkipDedup } from './dedup.js'
import { eventEnabled, type NotificationPayload, type SettingsNotifyRow } from './types.js'
export async function publishNotification(
settings: SettingsNotifyRow,
payload: NotificationPayload,
): Promise<'sent' | 'skipped' | 'failed'> {
if (!eventEnabled(settings, payload.event)) return 'skipped'
const channels = activeChannels(settings)
if (channels.length === 0) return 'skipped'
if (
shouldSkipDedup(
payload.event,
payload.fingerprint,
payload.dedup,
payload.stateKey,
payload.newStatus,
)
) {
for (const channel of channels) {
notificationRepository.append({
event: payload.event,
channel,
status: 'skipped',
fingerprint: payload.fingerprint,
message: payload.messagePlain,
payload: payload.data,
})
}
return 'skipped'
}
let anySent = false
let anyFailed = false
for (const channel of channels) {
if (channel === 'telegram') {
const result = await deliverTelegram(settings, payload.messageHtml ?? payload.messagePlain)
notificationRepository.append({
event: payload.event,
channel,
status: result.ok ? 'sent' : 'failed',
fingerprint: payload.fingerprint,
message: payload.messagePlain,
payload: { ...payload.data, error: result.error },
})
if (result.ok) anySent = true
else anyFailed = true
} else {
const result = await deliverWebhook(settings, {
event: payload.event,
message: payload.messagePlain,
data: payload.data,
timestamp: new Date().toISOString(),
})
notificationRepository.append({
event: payload.event,
channel,
status: result.ok ? 'sent' : 'failed',
fingerprint: payload.fingerprint,
message: payload.messagePlain,
payload: { ...payload.data, error: result.error },
})
if (result.ok) anySent = true
else anyFailed = true
}
}
if (anySent) {
markDedupSent(
payload.event,
payload.fingerprint,
payload.dedup,
payload.stateKey,
payload.newStatus,
)
}
if (anySent) return 'sent'
if (anyFailed) return 'failed'
return 'skipped'
}
export async function publishMany(
settings: SettingsNotifyRow,
payloads: (NotificationPayload | null)[],
): Promise<void> {
for (const payload of payloads) {
if (payload) await publishNotification(settings, payload)
}
}
@@ -0,0 +1,130 @@
import { desc } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { getPaidUntilDate } from '@cfdm/shared/utils/paid-until'
import type { NotificationPayload } from './types.js'
const UPCOMING_DAYS = 7
type VpsRow = typeof schema.vps.$inferSelect
export function buildPaymentExpiryNotification(now = new Date()): NotificationPayload | null {
const db = getDb()
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
const providerAccounts = db.select().from(schema.providerAccounts).all()
const payments = db.select().from(schema.payments).all()
const balanceLedger = db.select().from(schema.balanceLedger).all()
const providers = db.select().from(schema.providers).all()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const ctx = { vps: vpsList, providerAccounts, payments, balanceLedger, now }
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
for (const vps of vpsList) {
if (vps.status !== 'active') continue
const paidUntil = getPaidUntilDate(vps, ctx)
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
const provider = providers.find((p) => p.id === vps.providerId)
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
}
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
if (upcoming.length === 0) return null
const lines = upcoming.slice(0, 10).map(({ vps, paidUntil, provider }) => {
const dateStr = paidUntil.toLocaleDateString('ru-RU')
return `${vps.dns || vps.ip} (${provider}) — до ${dateStr}`
})
const plain = `Истекает оплата (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
const html = `⚠️ <b>Истекает оплата</b> (ближайшие ${UPCOMING_DAYS} дней):\n\n${lines.join('\n')}`
const fingerprint = upcoming
.map(({ vps, paidUntil }) => `${vps.id}:${paidUntil.toISOString().slice(0, 10)}`)
.sort()
.join('|')
return {
event: 'payment_expiry',
fingerprint,
messagePlain: plain,
messageHtml: html,
data: { count: upcoming.length, vpsIds: upcoming.map((u) => u.vps.id) },
dedup: 'daily',
}
}
export function buildSyncDigestNotification(digestLines: string[]): NotificationPayload | null {
const changed = digestLines.filter((line) => !line.includes('без изменений'))
if (changed.length === 0) return null
const plain = `Синхронизация VPS:\n\n${changed.join('\n')}`
const html = `📋 <b>Синхронизация VPS</b>\n\n${changed.join('\n')}`
return {
event: 'sync_digest',
fingerprint: changed.sort().join('|'),
messagePlain: plain,
messageHtml: html,
data: { lines: changed },
dedup: 'fingerprint',
}
}
export function buildLowBalanceNotification(lines: string[]): NotificationPayload | null {
if (lines.length === 0) return null
const plain = `Низкий баланс:\n\n${lines.join('\n')}`
const html = `💰 <b>Низкий баланс</b>\n\n${lines.join('\n')}`
return {
event: 'low_balance',
fingerprint: lines.sort().join('|'),
messagePlain: plain,
messageHtml: html,
data: { lines },
dedup: 'daily',
}
}
export function buildNewTariffsNotification(
providerName: string,
tariffs: { name?: string | null; price?: string | null }[],
): NotificationPayload | null {
if (tariffs.length === 0) return null
const lines = tariffs.slice(0, 15).map((t) => `${t.name || '—'}${t.price || '—'}`)
const plain = `Новые тарифы (${providerName}):\n\n${lines.join('\n')}`
const html = `🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`
const fingerprint = tariffs
.map((t) => `${t.name}:${t.price}`)
.sort()
.join('|')
return {
event: 'new_tariffs',
fingerprint: `${providerName}:${fingerprint}`,
messagePlain: plain,
messageHtml: html,
data: { providerName, count: tariffs.length },
dedup: 'fingerprint',
}
}
export function buildVpsHealthNotification(
event: 'vps_down' | 'vps_up',
hosts: { id: string; label: string }[],
): NotificationPayload | null {
if (hosts.length === 0) return null
const lines = hosts.map((h) => `${h.label}`)
const title = event === 'vps_down' ? 'VPS недоступны' : 'VPS восстановлены'
const icon = event === 'vps_down' ? '🔴' : '🟢'
const plain = `${title}:\n\n${lines.join('\n')}`
const html = `${icon} <b>${title}</b>\n\n${lines.join('\n')}`
const fingerprint = hosts
.map((h) => h.id)
.sort()
.join('|')
return {
event,
fingerprint,
messagePlain: plain,
messageHtml: html,
data: { hosts: hosts.map((h) => h.id) },
dedup: 'state_transition',
stateKey: `vps_health:${event}`,
newStatus: fingerprint,
}
}
@@ -0,0 +1,60 @@
export const NOTIFICATION_EVENTS = [
'payment_expiry',
'sync_digest',
'low_balance',
'new_tariffs',
'vps_down',
'vps_up',
] as const
export type NotificationEvent = (typeof NOTIFICATION_EVENTS)[number]
export type NotificationChannel = 'telegram' | 'webhook'
export type DedupMode = 'daily' | 'fingerprint' | 'state_transition'
export interface NotificationPayload {
event: NotificationEvent
fingerprint: string
messagePlain: string
messageHtml?: string
data?: Record<string, unknown>
dedup: DedupMode
stateKey?: string
newStatus?: string
}
export interface SettingsNotifyRow {
telegramBotToken?: string | null
telegramChatId?: string | null
telegramMessageThreadId?: string | null
webhookUrl?: string | null
webhookEnabled?: number | boolean | null
notifyPaymentExpiryEnabled?: number | boolean | null
notifyNewTariffsEnabled?: number | boolean | null
notifyLowBalanceEnabled?: number | boolean | null
notifySyncDigestEnabled?: number | boolean | null
notifyVpsDownEnabled?: number | boolean | null
}
export function isNotifyFlagEnabled(flag: number | boolean | null | undefined): boolean {
return flag !== 0 && flag !== false
}
export function eventEnabled(settings: SettingsNotifyRow, event: NotificationEvent): boolean {
switch (event) {
case 'payment_expiry':
return isNotifyFlagEnabled(settings.notifyPaymentExpiryEnabled)
case 'sync_digest':
return isNotifyFlagEnabled(settings.notifySyncDigestEnabled)
case 'low_balance':
return isNotifyFlagEnabled(settings.notifyLowBalanceEnabled)
case 'new_tariffs':
return isNotifyFlagEnabled(settings.notifyNewTariffsEnabled)
case 'vps_down':
case 'vps_up':
return isNotifyFlagEnabled(settings.notifyVpsDownEnabled)
default:
return false
}
}
+66 -183
View File
@@ -1,24 +1,27 @@
import { and, desc, eq, sql } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { getDb, schema } from '@cfdm/db'
import { settingsRepository } from '@cfdm/db/repositories/settings'
import { billmanagerAccountRowForSync } from './billmanager/context.js'
import { runBillmanagerAccountSync } from './billmanager/sync-job.js'
import { sendTelegramMessage } from './telegram.js'
import { notifyWebhook } from './webhook.js'
import { runVpsUptimeChecks } from './uptime-check.js'
import { publishMany, publishNotification } from './notifications/engine.js'
import {
buildLowBalanceNotification,
buildNewTariffsNotification,
buildPaymentExpiryNotification,
buildSyncDigestNotification,
buildVpsHealthNotification,
} from './notifications/rules.js'
let syncIntervalId: ReturnType<typeof setInterval> | null = null
let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
let notifyIntervalId: ReturnType<typeof setInterval> | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
const UPCOMING_DAYS = 7
const SETTINGS_ID = 'settings-main'
type AccountRow = typeof schema.providerAccounts.$inferSelect
type VpsRow = typeof schema.vps.$inferSelect
type PaymentRow = typeof schema.payments.$inferSelect
type LedgerRow = typeof schema.balanceLedger.$inferSelect
function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAccountRowForSync>>[] {
const db = getDb()
@@ -37,127 +40,15 @@ function getBillmanagerAccounts(): NonNullable<ReturnType<typeof billmanagerAcco
.filter((a): a is NonNullable<typeof a> => a != null)
}
function getAccountBalance(
accountId: string,
providerAccounts: AccountRow[],
balanceLedger: LedgerRow[],
): number {
const account = providerAccounts.find((a) => a.id === accountId)
if (account?.balanceApi != null && Number.isFinite(Number(account.balanceApi))) {
return Number(account.balanceApi)
export async function runNotificationTick(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings) return
const payload = buildPaymentExpiryNotification()
if (payload) await publishNotification(settings, payload)
} catch (err) {
console.warn('Notification tick error:', err instanceof Error ? err.message : err)
}
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(
vps: VpsRow,
providerAccounts: AccountRow[],
payments: PaymentRow[],
balanceLedger: LedgerRow[],
now: Date,
): Date | null {
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: Date | null = 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 diffDays = Math.round((paidUntilFromApi.getTime() - today.getTime()) / (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 = getDb()
.select({ id: schema.vps.id })
.from(schema.vps)
.where(
and(eq(schema.vps.providerAccountId, vps.providerAccountId ?? ''), eq(schema.vps.status, 'active')),
)
.all().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(): Promise<void> {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (
!settings?.notifyPaymentExpiryEnabled ||
!settings.telegramBotToken?.trim() ||
!settings.telegramChatId?.trim()
) {
return
}
const db = getDb()
const vpsList = db.select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
const providerAccounts = db.select().from(schema.providerAccounts).all()
const payments = db.select().from(schema.payments).all()
const balanceLedger = db.select().from(schema.balanceLedger).all()
const providers = db.select().from(schema.providers).all()
const now = new Date()
const threshold = new Date(now)
threshold.setDate(threshold.getDate() + UPCOMING_DAYS)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const upcoming: { vps: VpsRow; paidUntil: Date; provider: string }[] = []
for (const vps of vpsList) {
if (vps.status !== 'active') continue
const paidUntil = getPaidUntilDate(vps, providerAccounts, payments, balanceLedger, now)
if (!paidUntil || paidUntil > threshold || paidUntil < todayStart) continue
const provider = providers.find((p) => p.id === vps.providerId)
upcoming.push({ vps, paidUntil, provider: provider?.name || '-' })
}
upcoming.sort((a, b) => a.paidUntil.getTime() - b.paidUntil.getTime())
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,
)
await notifyWebhook(settings, 'payment_expiry', text.replace(/<[^>]+>/g, ''), {
count: upcoming.length,
})
}
export async function runScheduledSync(): Promise<void> {
@@ -168,9 +59,6 @@ export async function runScheduledSync(): Promise<void> {
const accounts = getBillmanagerAccounts()
const digestLines: string[] = []
const lowBalanceLines: string[] = []
const token = settings.telegramBotToken?.trim()
const chatId = settings.telegramChatId?.trim()
const canTg = Boolean(token && chatId)
for (const account of accounts) {
try {
@@ -185,7 +73,6 @@ export async function runScheduledSync(): Promise<void> {
const apiBal = result.balance?.balance
const threshold = account.balanceAlertBelow
if (
canTg &&
settings.notifyLowBalanceEnabled &&
threshold != null &&
Number.isFinite(Number(threshold)) &&
@@ -202,20 +89,10 @@ export async function runScheduledSync(): Promise<void> {
}
}
if (canTg && settings.notifySyncDigestEnabled && digestLines.length > 0) {
const msg = `📋 <b>Синхронизация VPS</b>\n\n${digestLines.join('\n')}`
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
await notifyWebhook(settings, 'sync_digest', msg.replace(/<[^>]+>/g, ''), { lines: digestLines })
}
if (canTg && settings.notifyLowBalanceEnabled && lowBalanceLines.length > 0) {
const msg = `💰 <b>Низкий баланс</b>\n\n${lowBalanceLines.join('\n')}`
await sendTelegramMessage(token!, chatId!, msg, settings.telegramMessageThreadId)
await notifyWebhook(settings, 'low_balance', msg.replace(/<[^>]+>/g, ''), { lines: lowBalanceLines })
}
if (settings.notifyPaymentExpiryEnabled) {
await sendPaymentExpiryNotifications()
}
await publishMany(settings, [
buildSyncDigestNotification(digestLines),
buildLowBalanceNotification(lowBalanceLines),
])
} catch (err) {
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
}
@@ -233,21 +110,14 @@ export async function runScheduledSyncTariffs(): Promise<void> {
try {
const result = await runBillmanagerAccountSync(account, { skipVpsPayments: true })
const newTariffs = result.newTariffs || []
if (
newTariffs.length > 0 &&
settings.notifyNewTariffsEnabled &&
settings.telegramBotToken?.trim() &&
settings.telegramChatId?.trim()
) {
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
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 || '—'}`)
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
`🆕 <b>Новые тарифы</b> (${providerName}):\n\n${lines.join('\n')}`,
settings.telegramMessageThreadId,
const payload = buildNewTariffsNotification(
providerName,
newTariffs.map((t) => ({ name: t.name, price: t.price })),
)
if (payload) await publishNotification(settings, payload)
}
} catch (err) {
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
@@ -261,23 +131,19 @@ export async function runScheduledSyncTariffs(): Promise<void> {
export async function runScheduledUptimeChecks(): Promise<void> {
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
const { checked, down } = await runVpsUptimeChecks()
if (down > 0 && settings) {
const msg = `VPS недоступны: ${down} из ${checked}`
if (
settings.notifyVpsDownEnabled &&
settings.telegramBotToken?.trim() &&
settings.telegramChatId?.trim()
) {
await sendTelegramMessage(
settings.telegramBotToken,
settings.telegramChatId,
`🔴 <b>${msg}</b>`,
settings.telegramMessageThreadId,
)
}
await notifyWebhook(settings, 'vps_down', msg, { checked, down })
}
if (!settings) return
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
await publishMany(settings, [
buildVpsHealthNotification(
'vps_down',
newlyDown.map((h) => ({ id: h.id, label: h.label })),
),
buildVpsHealthNotification(
'vps_up',
newlyUp.map((h) => ({ id: h.id, label: h.label })),
),
])
} catch (err) {
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
}
@@ -288,22 +154,37 @@ export function startScheduler(): void {
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
if (notifyIntervalId) clearInterval(notifyIntervalId)
notifyIntervalId = null
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
uptimeIntervalId = null
try {
const settings = settingsRepository.getRow(SETTINGS_ID)
if (!settings?.syncEnabled) return
if (!settings) return
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
syncTariffsIntervalId = setInterval(() => void runScheduledSyncTariffs(), tariffsInterval * 60 * 1000)
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), 5 * 60 * 1000)
const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60)
const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5)
notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000)
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000)
void runNotificationTick()
void runScheduledUptimeChecks()
console.log(
`Scheduled sync enabled: VPS/payments every ${interval} min, tariffs every ${tariffsInterval} min, uptime every 5 min`,
)
const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`]
if (settings.syncEnabled) {
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
syncTariffsIntervalId = setInterval(
() => void runScheduledSyncTariffs(),
tariffsInterval * 60 * 1000,
)
parts.unshift(`sync every ${interval} min`, `tariffs every ${tariffsInterval} min`)
}
console.log(`Scheduler: ${parts.join(', ')}`)
} catch {
// ignore
}
@@ -314,6 +195,8 @@ export function stopScheduler(): void {
syncIntervalId = null
if (syncTariffsIntervalId) clearInterval(syncTariffsIntervalId)
syncTariffsIntervalId = null
if (notifyIntervalId) clearInterval(notifyIntervalId)
notifyIntervalId = null
if (uptimeIntervalId) clearInterval(uptimeIntervalId)
uptimeIntervalId = null
}
+23 -5
View File
@@ -2,20 +2,27 @@
* Telegram Bot API — отправка уведомлений
*/
export interface TelegramSendResult {
ok: boolean
error?: string
}
export async function sendTelegramMessage(
token: string,
chatIds: string | string[],
text: string,
messageThreadId?: string | number | null,
): Promise<void> {
if (!token?.trim() || !text?.trim()) return
): Promise<TelegramSendResult> {
if (!token?.trim() || !text?.trim()) {
return { ok: false, error: 'Пустой токен или текст' }
}
const ids = Array.isArray(chatIds)
? chatIds
: String(chatIds || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (ids.length === 0) return
if (ids.length === 0) return { ok: false, error: 'Не указан chat ID' }
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
const payload: Record<string, unknown> = {
@@ -26,6 +33,9 @@ export async function sendTelegramMessage(
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
const errors: string[] = []
let anyOk = false
for (const chatId of ids) {
try {
const res = await fetch(url, {
@@ -34,12 +44,20 @@ export async function sendTelegramMessage(
body: JSON.stringify({ ...payload, chat_id: chatId }),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string }
if (!data.ok) {
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, data.description || res.statusText)
if (data.ok) {
anyOk = true
} else {
const err = data.description || res.statusText || 'Unknown error'
errors.push(`${chatId}: ${err}`)
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
errors.push(`${chatId}: ${message}`)
console.warn(`Telegram sendMessage error for chat ${chatId}:`, message)
}
}
if (anyOk) return { ok: true }
return { ok: false, error: errors.join('; ') || 'Не удалось отправить' }
}
+28 -4
View File
@@ -5,6 +5,20 @@ import { getDb, schema } from '@cfdm/db'
const CHECK_TIMEOUT_MS = 5000
export interface VpsHealthTransition {
id: string
label: string
previousStatus: string | null
currentStatus: 'up' | 'down'
}
export interface UptimeCheckResult {
checked: number
down: number
newlyDown: VpsHealthTransition[]
newlyUp: VpsHealthTransition[]
}
function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
const started = Date.now()
return new Promise((resolve) => {
@@ -19,7 +33,7 @@ function tcpCheck(host: string, port: number): Promise<{ ok: boolean; latencyMs:
})
}
export async function runVpsUptimeChecks(): Promise<{ checked: number; down: number }> {
export async function runVpsUptimeChecks(): Promise<UptimeCheckResult> {
const db = getDb()
const rows = db
.select()
@@ -29,6 +43,8 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
let checked = 0
let down = 0
const newlyDown: VpsHealthTransition[] = []
const newlyUp: VpsHealthTransition[] = []
const now = new Date().toISOString()
for (const row of rows) {
@@ -38,8 +54,16 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
const port = Number(row.sshPort) || 22
const result = await tcpCheck(host, port)
checked++
const status = result.ok ? 'up' : 'down'
if (!result.ok) down++
const status: 'up' | 'down' = result.ok ? 'up' : 'down'
if (status === 'down') down++
const previous = row.lastHealthStatus
const label = row.dns || row.ip || row.id
if (status === 'down' && previous !== 'down') {
newlyDown.push({ id: row.id, label, previousStatus: previous, currentStatus: 'down' })
} else if (status === 'up' && previous === 'down') {
newlyUp.push({ id: row.id, label, previousStatus: previous, currentStatus: 'up' })
}
db.insert(schema.vpsHealthChecks)
.values({
@@ -61,7 +85,7 @@ export async function runVpsUptimeChecks(): Promise<{ checked: number; down: num
.run()
}
return { checked, down }
return { checked, down, newlyDown, newlyUp }
}
export function listRecentHealthChecks(vpsId: string, limit = 20) {