/** * Background monitor service. * * Each tick: fetches auctions once (shared), then for each ENABLED page checks * if the page should notify for that auction. Per-page notified tracking via * the PageNotified table ensures each page notifies independently — a new bid * triggers a new notification for every enabled page that wants it. * * The monitor interval runs if ANY page is enabled. Pages are started/stopped * individually via the dashboard (start clears the page's notified records for * a fresh start). */ import { db } from "@/lib/db"; import { getSettings, AppSettings, formatMessage } from "@/lib/settings"; import { listPages, pageShouldReceive, PageConfig } from "@/lib/pages"; import { fetchFragmentAuctions, fetchGetgemsAuctions, fetchMockAuctions, RawAuction } from "@/lib/fetchers"; import { compilePattern, matchesPattern } from "@/lib/pattern-engine"; import { formatCountdown } from "@/lib/image-gen"; export interface MonitorStatus { running: boolean; enabled: boolean; // any page enabled? lastRunAt: string | null; nextRunAt: string | null; lastRunDurationMs: number | null; lastError: string | null; lastRunStats: { fetched: number; matched: number; notified: number; errors: number } | null; intervalSeconds: number; enabledPageCount: number; } interface MonitorState { timer: NodeJS.Timeout | null; running: boolean; lastRunAt: Date | null; lastRunDurationMs: number | null; lastError: string | null; lastRunStats: { fetched: number; matched: number; notified: number; errors: number } | null; intervalSeconds: number; } const globalForMonitor = globalThis as unknown as { __fragmentMonitor?: MonitorState }; function getState(): MonitorState { if (!globalForMonitor.__fragmentMonitor) { globalForMonitor.__fragmentMonitor = { timer: null, running: false, lastRunAt: null, lastRunDurationMs: null, lastError: null, lastRunStats: null, intervalSeconds: 60, }; } return globalForMonitor.__fragmentMonitor; } export async function getMonitorStatus(): Promise { const s = getState(); const pages = await listPages(); const enabledCount = pages.filter((p) => p.enabled).length; const nextRunAt = s.timer && s.lastRunAt ? new Date(s.lastRunAt.getTime() + s.intervalSeconds * 1000) : null; return { running: s.running, enabled: enabledCount > 0, lastRunAt: s.lastRunAt ? s.lastRunAt.toISOString() : null, nextRunAt: nextRunAt ? nextRunAt.toISOString() : null, lastRunDurationMs: s.lastRunDurationMs, lastError: s.lastError, lastRunStats: s.lastRunStats, intervalSeconds: s.intervalSeconds, enabledPageCount: enabledCount, }; } export async function runMonitorCycle(): Promise<{ fetched: number; matched: number; notified: number; errors: number; error?: string; }> { const state = getState(); if (state.running) { return { fetched: 0, matched: 0, notified: 0, errors: 0, error: "already running" }; } state.running = true; const startedAt = new Date(); let fetched = 0; let matched = 0; let notified = 0; let errors = 0; try { const settings = await getSettings(); const pages = await listPages(); const enabledPages = pages.filter((p) => p.enabled); if (enabledPages.length === 0) { state.lastRunAt = startedAt; state.lastRunDurationMs = Date.now() - startedAt.getTime(); state.lastError = null; state.lastRunStats = { fetched: 0, matched: 0, notified: 0, errors: 0 }; return { fetched: 0, matched: 0, notified: 0, errors: 0 }; } // Fetch auctions from enabled sources. const sources: Promise[] = []; if (settings.sourceFragment) sources.push(fetchFragmentAuctions(settings.fetchSaleType).catch(() => [] as RawAuction[])); if (settings.sourceGetgems) sources.push(fetchGetgemsAuctions(100).catch(() => [] as RawAuction[])); if (settings.sourceMock) sources.push(fetchMockAuctions().catch(() => [] as RawAuction[])); const results = await Promise.all(sources); let allAuctions: RawAuction[] = results.flat(); if (settings.maxBidTon > 0) { allAuctions = allAuctions.filter((a) => a.bidAmount <= settings.maxBidTon); } fetched = allAuctions.length; // Load ALL patterns. const allPatterns = await db.pattern.findMany(); const compiledPatterns = allPatterns .map((p) => { try { return { pattern: p, compiled: compilePattern(p.expression) }; } catch { return null; } }) .filter((x): x is { pattern: (typeof allPatterns)[number]; compiled: ReturnType } => x !== null); const seenThisCycle = new Set(); // Per-page notified counts. const pageNotifiedCount: Record = {}; for (const p of enabledPages) pageNotifiedCount[p.id] = 0; for (const auction of allAuctions) { const key = `${auction.source}:${auction.saleType}:${auction.username}`; if (seenThisCycle.has(key)) continue; seenThisCycle.add(key); // Find first matching pattern (global — patterns shared across pages). let matchedPattern: { id: string; expression: string } | null = null; for (const entry of compiledPatterns) { if (matchesPattern(auction.username, entry.compiled)) { matchedPattern = { id: entry.pattern.id, expression: entry.pattern.expression }; break; } } const isMatch = matchedPattern !== null; if (isMatch) matched++; // For each enabled page, check if it should notify. for (const page of enabledPages) { if (!pageShouldReceive(page, auction, matchedPattern?.id ?? null, isMatch)) continue; // Check per-page notified record. const existing = await db.pageNotified.findUnique({ where: { pageId_username_source_saleType: { pageId: page.id, username: auction.username, source: auction.source, saleType: auction.saleType, }, }, }); const bidIncreased = !existing || auction.bidAmount > existing.bidAmount; if (!bidIncreased) continue; // already notified this bid to this page // Send notification. const res = await notifyPage(page, auction, matchedPattern, settings); notified += res.notified; errors += res.errors; if (res.notified > 0) { pageNotifiedCount[page.id] = (pageNotifiedCount[page.id] || 0) + 1; // Upsert the notified record. await db.pageNotified.upsert({ where: { pageId_username_source_saleType: { pageId: page.id, username: auction.username, source: auction.source, saleType: auction.saleType, }, }, create: { pageId: page.id, username: auction.username, source: auction.source, saleType: auction.saleType, bidAmount: auction.bidAmount, }, update: { bidAmount: auction.bidAmount, notifiedAt: new Date() }, }); } } } // Update page stats. for (const page of enabledPages) { await db.monitoringPage.update({ where: { id: page.id }, data: { lastRunAt: new Date(), lastNotified: pageNotifiedCount[page.id] || 0 }, }); } state.lastRunAt = startedAt; state.lastRunDurationMs = Date.now() - startedAt.getTime(); state.lastError = null; state.lastRunStats = { fetched, matched, notified, errors }; return { fetched, matched, notified, errors }; } catch (e: any) { state.lastError = e?.message ?? String(e); state.lastRunAt = startedAt; state.lastRunDurationMs = Date.now() - startedAt.getTime(); state.lastRunStats = { fetched, matched, notified, errors }; return { fetched, matched, notified, errors, error: state.lastError }; } finally { state.running = false; } } async function notifyPage( page: PageConfig, auction: RawAuction, matchedPattern: { id: string; expression: string } | null, settings: AppSettings ): Promise<{ notified: number; errors: number }> { const template = page.messageTemplate || settings.messageTemplate; const timeLeft = formatCountdown(auction.endTime); const text = formatMessage(template, { username: auction.username, bid: auction.bidAmount, source: auction.source, pattern: matchedPattern?.expression, url: auction.url, timeLeft, saleType: auction.saleType, }); // Web-only mode: log the notification to the database (no Telegram send) // The notification text is stored so it can be viewed in the Notifications tab. try { await db.notification.create({ data: { username: auction.username, pattern: matchedPattern?.expression ?? null, patternId: matchedPattern?.id ?? null, bidAmount: auction.bidAmount, source: auction.source, saleType: auction.saleType, recipient: page.recipient, pageId: page.id, pageName: page.name, status: "success", message: text, hasImage: false, error: null, }, }); return { notified: 1, errors: 0 }; } catch (e: any) { console.error(`[monitor] notification log failed: ${e?.message}`); return { notified: 0, errors: 1 }; } } /** * Start the monitor interval (runs if called — the dashboard starts it when * any page is enabled). */ export async function startMonitor(): Promise { const state = getState(); if (state.timer) return; const settings = await getSettings(); const interval = settings.realtimeMode ? Math.min(settings.intervalSeconds, 20) : settings.intervalSeconds; state.intervalSeconds = interval; console.log(`[monitor] started, interval=${interval}s (realtime=${settings.realtimeMode})`); state.timer = setInterval(() => { runMonitorCycle().catch((e) => console.error("[monitor] cycle error:", e)); }, interval * 1000); } export function stopMonitor(): void { const state = getState(); if (state.timer) { clearInterval(state.timer); state.timer = null; } } export async function restartMonitor(): Promise { stopMonitor(); const pages = await listPages(); if (pages.some((p) => p.enabled)) { await startMonitor(); } }