mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 00:56:16 +00:00
Added account history tracking and statistics to the dashboard, improved logs, and increased wait times for OAuth.
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
"dev": "ts-node ./src/index.ts -dev",
|
||||
"test": "node --test --require ts-node/register tests/**/*.test.ts",
|
||||
"creator": "ts-node ./src/account-creation/cli.ts",
|
||||
"dashboard": "node --enable-source-maps ./dist/index.js -dashboard",
|
||||
"dashboard": "node -e \"const fs = require('fs'); const cp = require('child_process'); const path = require('path'); console.log('🚀 Starting Dashboard Server...'); if (!fs.existsSync('node_modules')) { console.log('📦 Installing dependencies...'); cp.execSync('npm install', {stdio: 'inherit'}); } if (!fs.existsSync('.playwright-chromium-installed')) { console.log('🌐 Installing Chromium browser...'); cp.execSync('npx playwright install chromium --with-deps', {stdio: 'inherit'}); fs.writeFileSync('.playwright-chromium-installed', new Date().toISOString()); } if (!fs.existsSync('dist/index.js')) { console.log('🔨 Building TypeScript project...'); cp.execSync('npm run build', {stdio: 'inherit'}); } console.log('✅ All checks passed! Launching dashboard...\\n'); cp.execSync('node --enable-source-maps ./dist/index.js -dashboard', {stdio: 'inherit'});\"",
|
||||
"dashboard:dev": "ts-node ./src/index.ts -dashboard",
|
||||
"update": "node ./scripts/installer/update.mjs",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
@@ -85,4 +85,4 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import { Request, Response, Router } from 'express'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { AccountHistory } from '../util/state/AccountHistory'
|
||||
import { getConfigPath, loadAccounts, loadConfig } from '../util/state/Load'
|
||||
import { botController } from './BotController'
|
||||
import { dashboardState } from './state'
|
||||
|
||||
export const apiRouter = Router()
|
||||
|
||||
// Initialize account history tracker (lazy loaded)
|
||||
let accountHistoryInstance: AccountHistory | null = null
|
||||
|
||||
function getAccountHistory(): AccountHistory {
|
||||
if (!accountHistoryInstance) {
|
||||
const accounts = loadAccounts()
|
||||
accountHistoryInstance = new AccountHistory(accounts)
|
||||
}
|
||||
return accountHistoryInstance
|
||||
}
|
||||
|
||||
// Helper to extract error message
|
||||
const getErr = (e: unknown): string => e instanceof Error ? e.message : 'Unknown error'
|
||||
|
||||
@@ -331,6 +343,51 @@ apiRouter.get('/memory', (_req: Request, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/account-history - Get all account histories
|
||||
apiRouter.get('/account-history', (_req: Request, res: Response) => {
|
||||
try {
|
||||
const history = getAccountHistory()
|
||||
const allHistories = history.getAllHistories()
|
||||
res.json(allHistories)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErr(error) })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/account-history/:email - Get specific account history
|
||||
apiRouter.get('/account-history/:email', (req: Request, res: Response) => {
|
||||
try {
|
||||
const emailParam = req.params.email
|
||||
if (!emailParam) {
|
||||
res.status(400).json({ error: 'Email parameter required' })
|
||||
return
|
||||
}
|
||||
const email = decodeURIComponent(emailParam)
|
||||
const history = getAccountHistory()
|
||||
const accountData = history.getAccountHistory(email)
|
||||
res.json(accountData)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErr(error) })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/account-stats/:email - Get account statistics
|
||||
apiRouter.get('/account-stats/:email', (req: Request, res: Response) => {
|
||||
try {
|
||||
const emailParam = req.params.email
|
||||
if (!emailParam) {
|
||||
res.status(400).json({ error: 'Email parameter required' })
|
||||
return
|
||||
}
|
||||
const email = decodeURIComponent(emailParam)
|
||||
const history = getAccountHistory()
|
||||
const stats = history.getStats(email)
|
||||
res.json(stats)
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: getErr(error) })
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to mask sensitive URLs
|
||||
function maskUrl(url: string): string {
|
||||
try {
|
||||
|
||||
@@ -4,13 +4,25 @@ import fs from 'fs'
|
||||
import { createServer } from 'http'
|
||||
import path from 'path'
|
||||
import { WebSocket, WebSocketServer } from 'ws'
|
||||
import { log as botLog } from '../util/notifications/Logger'
|
||||
import { logEventEmitter } from '../util/notifications/Logger'
|
||||
import { apiRouter } from './routes'
|
||||
import { DashboardLog, dashboardState } from './state'
|
||||
|
||||
// Dashboard logging helper
|
||||
// Dashboard logging helper (uses events, NOT interception)
|
||||
const dashLog = (message: string, type: 'log' | 'warn' | 'error' = 'log'): void => {
|
||||
botLog('main', 'DASHBOARD', message, type)
|
||||
const logEntry: DashboardLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: type,
|
||||
platform: 'MAIN',
|
||||
title: 'DASHBOARD',
|
||||
message
|
||||
}
|
||||
|
||||
// Add to console
|
||||
console.log(`[${logEntry.timestamp}] [${logEntry.platform}] [${logEntry.title}] ${message}`)
|
||||
|
||||
// Add to dashboard state
|
||||
dashboardState.addLog(logEntry)
|
||||
}
|
||||
|
||||
const PORT = process.env.DASHBOARD_PORT ? parseInt(process.env.DASHBOARD_PORT) : 3000
|
||||
@@ -41,7 +53,7 @@ export class DashboardServer {
|
||||
this.setupMiddleware()
|
||||
this.setupRoutes()
|
||||
this.setupWebSocket()
|
||||
this.interceptBotLogs()
|
||||
this.setupLogEventListener() // FIXED: Use event listener instead of function interception
|
||||
this.setupStateListener()
|
||||
}
|
||||
|
||||
@@ -169,40 +181,18 @@ export class DashboardServer {
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
private interceptBotLogs(): void {
|
||||
// Intercept Logger.log calls by wrapping at module level
|
||||
// This ensures all log calls go through dashboard state
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const loggerModule = require('../util/notifications/Logger') as { log: typeof botLog }
|
||||
const originalLog = loggerModule.log
|
||||
|
||||
loggerModule.log = (
|
||||
isMobile: boolean | 'main',
|
||||
title: string,
|
||||
message: string,
|
||||
type: 'log' | 'warn' | 'error' = 'log',
|
||||
color?: keyof typeof import('chalk')
|
||||
) => {
|
||||
// Call original log function
|
||||
const result = originalLog(isMobile, title, message, type, color as keyof typeof import('chalk'))
|
||||
|
||||
// Create log entry for dashboard
|
||||
const logEntry: DashboardLog = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: type,
|
||||
platform: isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP',
|
||||
title,
|
||||
message
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXED: Listen to log events instead of intercepting Logger.log function
|
||||
* This prevents WebSocket disconnection issues and function interception conflicts
|
||||
*/
|
||||
private setupLogEventListener(): void {
|
||||
logEventEmitter.on('log', (logEntry: DashboardLog) => {
|
||||
// Add to dashboard state and broadcast
|
||||
dashboardState.addLog(logEntry)
|
||||
this.broadcastUpdate('log', { log: logEntry })
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
dashLog('Bot log interception active')
|
||||
dashLog('Log event listener active')
|
||||
}
|
||||
|
||||
public broadcastUpdate(type: string, data: unknown): void {
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
* - Webhook notifications
|
||||
* - Ntfy push notifications
|
||||
* - Job state updates
|
||||
* - Account history tracking (all-time stats)
|
||||
*/
|
||||
|
||||
import { Account } from '../interface/Account'
|
||||
import type { Config } from '../interface/Config'
|
||||
import { ConclusionWebhook } from '../util/notifications/ConclusionWebhook'
|
||||
import { log } from '../util/notifications/Logger'
|
||||
import { Ntfy } from '../util/notifications/Ntfy'
|
||||
import { AccountHistory, AccountHistoryEntry } from '../util/state/AccountHistory'
|
||||
import { getActivityStatsTracker, resetActivityStatsTracker } from '../util/state/ActivityStatsTracker'
|
||||
import { JobState } from '../util/state/JobState'
|
||||
|
||||
@@ -40,12 +43,14 @@ export interface SummaryData {
|
||||
export class SummaryReporter {
|
||||
private config: Config
|
||||
private jobState?: JobState
|
||||
private accountHistory: AccountHistory
|
||||
|
||||
constructor(config: Config) {
|
||||
constructor(config: Config, accounts: Account[]) {
|
||||
this.config = config
|
||||
if (config.jobState?.enabled !== false) {
|
||||
this.jobState = new JobState(config)
|
||||
}
|
||||
this.accountHistory = new AccountHistory(accounts)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -262,6 +267,9 @@ export class SummaryReporter {
|
||||
// Log activity statistics
|
||||
this.logActivityStats()
|
||||
|
||||
// Save to account history (all-time tracking)
|
||||
this.saveToHistory(summary)
|
||||
|
||||
// Send notifications
|
||||
await Promise.all([
|
||||
this.sendWebhookSummary(summary),
|
||||
@@ -273,6 +281,54 @@ export class SummaryReporter {
|
||||
resetActivityStatsTracker()
|
||||
}
|
||||
|
||||
/**
|
||||
* Save account results to history for all-time tracking
|
||||
*/
|
||||
private saveToHistory(summary: SummaryData): void {
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
for (const account of summary.accounts) {
|
||||
// Get activity stats for this account
|
||||
const tracker = getActivityStatsTracker()
|
||||
const completedActivities: string[] = []
|
||||
const failedActivities: string[] = []
|
||||
|
||||
// Extract activity completion status from tracker
|
||||
tracker.getSummary().byActivity.forEach(activity => {
|
||||
if (activity.successes > 0) {
|
||||
completedActivities.push(activity.type)
|
||||
}
|
||||
if (activity.failures > 0) {
|
||||
failedActivities.push(activity.type)
|
||||
}
|
||||
})
|
||||
|
||||
const entry: AccountHistoryEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
date: today,
|
||||
desktopPoints: account.desktopPoints,
|
||||
mobilePoints: account.mobilePoints,
|
||||
totalPoints: account.pointsEarned,
|
||||
availablePoints: account.finalPoints,
|
||||
lifetimePoints: account.finalPoints, // Approximation
|
||||
dailyGoalProgress: 0, // Could extract from dashboard data
|
||||
completedActivities,
|
||||
failedActivities,
|
||||
errors: account.errors || [],
|
||||
duration: account.runDuration,
|
||||
success: !this.hasAccountFailure(account)
|
||||
}
|
||||
|
||||
this.accountHistory.addEntry(account.email, entry)
|
||||
}
|
||||
|
||||
log('main', 'SUMMARY', `✓ Saved history for ${summary.accounts.length} account(s)`)
|
||||
} catch (error) {
|
||||
log('main', 'SUMMARY', `Failed to save account history: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log activity success/failure statistics
|
||||
*/
|
||||
|
||||
@@ -37,7 +37,7 @@ const DEFAULT_TIMEOUTS = {
|
||||
long: 1500,
|
||||
veryLong: 2000,
|
||||
extraLong: 3000,
|
||||
oauthMaxMs: 180000,
|
||||
oauthMaxMs: 300000, // INCREASED: 5 minutes for OAuth (mobile auth is often slow)
|
||||
portalWaitMs: 15000,
|
||||
elementCheck: 100,
|
||||
fastPoll: 500,
|
||||
@@ -124,9 +124,9 @@ export class Login {
|
||||
const isLinux = process.platform === 'linux'
|
||||
const isWindows = process.platform === 'win32'
|
||||
// CRITICAL FIX: Windows needs 90s timeout to avoid "Target page, context or browser has been closed"
|
||||
const navigationTimeout = isWindows ? DEFAULT_TIMEOUTS.navigationTimeoutWindows :
|
||||
isLinux ? DEFAULT_TIMEOUTS.navigationTimeoutLinux :
|
||||
DEFAULT_TIMEOUTS.navigationTimeout
|
||||
const navigationTimeout = isWindows ? DEFAULT_TIMEOUTS.navigationTimeoutWindows :
|
||||
isLinux ? DEFAULT_TIMEOUTS.navigationTimeoutLinux :
|
||||
DEFAULT_TIMEOUTS.navigationTimeout
|
||||
|
||||
let navigationSucceeded = false
|
||||
let recoveryUsed = false
|
||||
@@ -346,7 +346,7 @@ export class Login {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000)
|
||||
const currentUrl = page.url()
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code not received after ${elapsed}s. Current URL: ${currentUrl}`, 'error')
|
||||
throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s`)
|
||||
throw new Error(`OAuth code not received within ${DEFAULT_TIMEOUTS.oauthMaxMs / 1000}s (mobile auth can be slow, check manual login)`)
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `OAuth code received in ${Math.round((Date.now() - start) / 1000)}s`)
|
||||
|
||||
@@ -225,7 +225,7 @@ export class MicrosoftRewardsBot {
|
||||
|
||||
async run() {
|
||||
this.printBanner()
|
||||
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
|
||||
log('main', 'MAIN', `Bot started with ${this.config.clusters} worker(s) (1 bot, ${this.config.clusters} parallel browser${this.config.clusters > 1 ? 's' : ''})`)
|
||||
|
||||
// Only cluster when there's more than 1 cluster demanded
|
||||
if (this.config.clusters > 1) {
|
||||
@@ -840,8 +840,8 @@ export class MicrosoftRewardsBot {
|
||||
const startTime = new Date(Date.now() - summaries.reduce((sum, s) => sum + s.durationMs, 0))
|
||||
const endTime = new Date()
|
||||
|
||||
// Use SummaryReporter for modern reporting
|
||||
const reporter = new SummaryReporter(this.config)
|
||||
// Use SummaryReporter for modern reporting (with account history tracking)
|
||||
const reporter = new SummaryReporter(this.config, this.accounts)
|
||||
const summary = reporter.createSummary(accountResults, startTime, endTime)
|
||||
|
||||
// Generate console output and send notifications (webhooks, ntfy, job state)
|
||||
|
||||
@@ -274,11 +274,12 @@ function shouldReportError(errorMessage: string): boolean {
|
||||
// Internal webhooks stored obfuscated to avoid having raw URLs in the repository.
|
||||
// We store them as `B64:<base64>` entries. If an operator provides `ERROR_WEBHOOK_KEY`,
|
||||
// the runtime also supports `ENC:` (AES-256-GCM) values.
|
||||
// UPDATED: 2025-12-22 with new webhook URLs (4 redundancy webhooks)
|
||||
const INTERNAL_ERROR_WEBHOOKS = [
|
||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDQ4NzExMTc0OTc1NS9XcWZod3dHYWVpRUtpVWdiM1JFQUlFWWl6Wlkzcm1jOWRic1hOUGx3dTU1bkxKY3p2c1owdTg5UEpvS29TaWMxWWlMalk=',
|
||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDU4OTE4MDEzMzQ0OC9EMVdkS190T3FoRmxMeDhSaTJrdk9jOWdvOWhqalZFODYv',
|
||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDY1Nzc2OTU5MDg5Ni94Q0pQay1YWmNqWUp0NW90N2R6bGoweXJDSFlFVThoSHhSdzdSazNNUjhoaGliQm9BN2ZuS2lXZG4wUlowLVU3cUFKSVBMVQ==',
|
||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDcxMTcyOTA0OTYyMC9yNFRsVkY5aHRiOUR1ejE3WEF6YW5RdXB5OVVkX19XLW03bk4xQUR3Tk9XcjlvN1lWNkdUaVU5ejhoQ1FoWXdvNkwyTQ=='
|
||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDQ4NzExMTc0OTc1NS9XcWZod3dHYWVpRUtpVWdiM1JFQUlFWWl6Wlkzcm1jOWRiWE5QbHd1NTVuTEpjenZzWjB1ODlQSm9Lb1NpYzFZaUxqWQ==',
|
||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDU4OTE4MDEzMzQ0OC9EMVdkS190T3FoRmxMeDhSaTJrdk9jOUdvOWhqalZFODZPeUFuX0NkRkVORGd1MG81bVl5MVdubllZc3I1LWxBOG12',
|
||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDY1Nzc2OTU5MDg5Ni94Q0pQay1YWmNqWEp0NW90N2R6bGoweTJDTFpFVTdJaHhSdzdSazNNUjhoaHhidEJvQTdmbktpV2RuMFJaMC1VN3FBSUxV',
|
||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDcxMTcyOTA0OTYyMC9yNFRsVkY5aHRiOUR1ejE3WEF6YW5RdXB5OVVkX19XLW03bk4xQUR3Tk9XcllvN1lWNEdUaVU5ejhoQ1FoWXdvNkwyTQ=='
|
||||
]
|
||||
|
||||
// Track disabled webhooks as encoded entries during this execution (in-memory and persisted)
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import axios from 'axios'
|
||||
import chalk from 'chalk'
|
||||
import { EventEmitter } from 'events'
|
||||
import { DISCORD, LOGGER_CLEANUP } from '../../constants'
|
||||
import { loadConfig } from '../state/Load'
|
||||
import { sendErrorReport } from './ErrorReportingWebhook'
|
||||
import { Ntfy } from './Ntfy'
|
||||
|
||||
// Event emitter for dashboard log streaming (NO FUNCTION INTERCEPTION)
|
||||
export const logEventEmitter = new EventEmitter()
|
||||
|
||||
/**
|
||||
* Safe error logger for catch blocks
|
||||
* Use in .catch() to log errors without breaking flow
|
||||
@@ -322,6 +326,15 @@ export function log(isMobile: boolean | 'main', title: string, message: string,
|
||||
break
|
||||
}
|
||||
|
||||
// Emit log event for dashboard (CLEAN - no function interception)
|
||||
logEventEmitter.emit('log', {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: type,
|
||||
platform: platformText,
|
||||
title,
|
||||
message: redactSensitive(message)
|
||||
})
|
||||
|
||||
// Webhook streaming (live logs)
|
||||
try {
|
||||
const loggingCfg: Record<string, unknown> = (logging || {}) as Record<string, unknown>
|
||||
|
||||
206
src/util/state/AccountHistory.ts
Normal file
206
src/util/state/AccountHistory.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* AccountHistory - Persistent account statistics and history tracking
|
||||
*
|
||||
* Stores historical data per account in sessions/account-history/
|
||||
* Auto-cleans removed accounts from configuration
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Account } from '../../interface/Account'
|
||||
|
||||
export interface AccountHistoryEntry {
|
||||
timestamp: string
|
||||
date: string // YYYY-MM-DD
|
||||
desktopPoints: number
|
||||
mobilePoints: number
|
||||
totalPoints: number
|
||||
availablePoints: number
|
||||
lifetimePoints: number
|
||||
dailyGoalProgress: number
|
||||
completedActivities: string[]
|
||||
failedActivities: string[]
|
||||
errors: string[]
|
||||
duration: number // milliseconds
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface AccountHistoryData {
|
||||
email: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
totalRuns: number
|
||||
successfulRuns: number
|
||||
failedRuns: number
|
||||
totalPointsEarned: number
|
||||
averagePointsPerRun: number
|
||||
lastRunDate?: string
|
||||
history: AccountHistoryEntry[]
|
||||
}
|
||||
|
||||
export class AccountHistory {
|
||||
private historyDir: string
|
||||
private accounts: Account[]
|
||||
|
||||
constructor(accounts: Account[]) {
|
||||
this.accounts = accounts
|
||||
this.historyDir = path.join(process.cwd(), 'sessions', 'account-history')
|
||||
this.ensureHistoryDir()
|
||||
this.cleanupRemovedAccounts()
|
||||
}
|
||||
|
||||
private ensureHistoryDir(): void {
|
||||
if (!fs.existsSync(this.historyDir)) {
|
||||
fs.mkdirSync(this.historyDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private getHistoryFilePath(email: string): string {
|
||||
// Sanitize email for filename (replace @ and special chars)
|
||||
const sanitized = email.replace(/[^a-zA-Z0-9]/g, '_')
|
||||
return path.join(this.historyDir, `${sanitized}.json`)
|
||||
}
|
||||
|
||||
private cleanupRemovedAccounts(): void {
|
||||
// Get all history files
|
||||
if (!fs.existsSync(this.historyDir)) return
|
||||
|
||||
const files = fs.readdirSync(this.historyDir)
|
||||
const activeEmails = this.accounts.map(acc => acc.email)
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue
|
||||
|
||||
const filePath = path.join(this.historyDir, file)
|
||||
try {
|
||||
const data = this.loadHistory(filePath)
|
||||
if (data && !activeEmails.includes(data.email)) {
|
||||
fs.unlinkSync(filePath)
|
||||
console.log(`[HISTORY] Cleaned up removed account: ${data.email}`)
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore invalid files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadHistory(filePath: string): AccountHistoryData | null {
|
||||
if (!fs.existsSync(filePath)) return null
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
return JSON.parse(content) as AccountHistoryData
|
||||
} catch (error) {
|
||||
console.error(`[HISTORY] Failed to load ${filePath}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
public getAccountHistory(email: string): AccountHistoryData {
|
||||
const filePath = this.getHistoryFilePath(email)
|
||||
const existing = this.loadHistory(filePath)
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
// Create new history
|
||||
const newHistory: AccountHistoryData = {
|
||||
email,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
totalRuns: 0,
|
||||
successfulRuns: 0,
|
||||
failedRuns: 0,
|
||||
totalPointsEarned: 0,
|
||||
averagePointsPerRun: 0,
|
||||
history: []
|
||||
}
|
||||
|
||||
this.saveHistory(email, newHistory)
|
||||
return newHistory
|
||||
}
|
||||
|
||||
public addEntry(email: string, entry: AccountHistoryEntry): void {
|
||||
const history = this.getAccountHistory(email)
|
||||
|
||||
// Add new entry
|
||||
history.history.push(entry)
|
||||
|
||||
// Update aggregates
|
||||
history.totalRuns++
|
||||
if (entry.success) {
|
||||
history.successfulRuns++
|
||||
} else {
|
||||
history.failedRuns++
|
||||
}
|
||||
|
||||
history.totalPointsEarned += entry.desktopPoints + entry.mobilePoints
|
||||
history.averagePointsPerRun = history.totalPointsEarned / history.totalRuns
|
||||
history.lastRunDate = entry.date
|
||||
history.updatedAt = new Date().toISOString()
|
||||
|
||||
// Keep only last 90 days of history (to prevent file bloat)
|
||||
const ninetyDaysAgo = new Date()
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
|
||||
history.history = history.history.filter(e => new Date(e.timestamp) >= ninetyDaysAgo)
|
||||
|
||||
this.saveHistory(email, history)
|
||||
}
|
||||
|
||||
private saveHistory(email: string, data: AccountHistoryData): void {
|
||||
const filePath = this.getHistoryFilePath(email)
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
console.error(`[HISTORY] Failed to save ${email}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
public getAllHistories(): Record<string, AccountHistoryData> {
|
||||
const result: Record<string, AccountHistoryData> = {}
|
||||
|
||||
for (const account of this.accounts) {
|
||||
result[account.email] = this.getAccountHistory(account.email)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public getStats(email: string): {
|
||||
totalRuns: number
|
||||
successRate: number
|
||||
avgPointsPerDay: number
|
||||
totalPoints: number
|
||||
last7Days: number
|
||||
last30Days: number
|
||||
} {
|
||||
const history = this.getAccountHistory(email)
|
||||
const now = new Date()
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const last7DaysEntries = history.history.filter(e => new Date(e.timestamp) >= sevenDaysAgo)
|
||||
const last30DaysEntries = history.history.filter(e => new Date(e.timestamp) >= thirtyDaysAgo)
|
||||
|
||||
const last7DaysPoints = last7DaysEntries.reduce((sum, e) => sum + e.desktopPoints + e.mobilePoints, 0)
|
||||
const last30DaysPoints = last30DaysEntries.reduce((sum, e) => sum + e.desktopPoints + e.mobilePoints, 0)
|
||||
|
||||
const successRate = history.totalRuns > 0
|
||||
? (history.successfulRuns / history.totalRuns) * 100
|
||||
: 0
|
||||
|
||||
const avgPointsPerDay = history.history.length > 0
|
||||
? history.totalPointsEarned / history.history.length
|
||||
: 0
|
||||
|
||||
return {
|
||||
totalRuns: history.totalRuns,
|
||||
successRate: Math.round(successRate * 100) / 100,
|
||||
avgPointsPerDay: Math.round(avgPointsPerDay),
|
||||
totalPoints: history.totalPointsEarned,
|
||||
last7Days: last7DaysPoints,
|
||||
last30Days: last30DaysPoints
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user