tg-tracker / src /lib /monitor.ts
iq7se2's picture
Upload 103 files
1af4bef verified
Raw History Blame Contribute Delete
10.5 kB
/**
* 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<MonitorStatus> {
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<RawAuction[]>[] = [];
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<typeof compilePattern> } => x !== null);
const seenThisCycle = new Set<string>();
// Per-page notified counts.
const pageNotifiedCount: Record<string, number> = {};
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<void> {
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<void> {
stopMonitor();
const pages = await listPages();
if (pages.some((p) => p.enabled)) {
await startMonitor();
}
}