Refactor bot startup process to auto-create configuration files, enhance update system, and add historical stats endpoints

- Updated README.md to reflect new bot setup and configuration process.
- Removed outdated installer README and integrated update logic directly into the bot.
- Implemented smart update for example configuration files, ensuring user files are not overwritten.
- Added FileBootstrap class to handle automatic creation of configuration files on first run.
- Enhanced BotController to manage stop requests and ensure graceful shutdown.
- Introduced new stats management features, including historical stats and activity breakdown endpoints.
- Updated API routes to include new statistics retrieval functionalities.
This commit is contained in:
2026-01-02 18:38:56 +01:00
parent 18d88a0071
commit e981a69095
10 changed files with 816 additions and 1408 deletions

View File

@@ -147,8 +147,8 @@
"enabled": true, // Enable automatic update checks
"method": "github-api", // Update method (github-api = recommended)
"dockerMode": "auto", // Docker detection ("auto", "force-docker", "force-host")
"autoUpdateConfig": false, // Automatically update config.jsonc (NOT RECOMMENDED - use config.example.jsonc)
"autoUpdateAccounts": false // Automatically update accounts.jsonc (NEVER enable - prevents data loss)
"autoUpdateConfig": true, // Automatically update config.jsonc
"autoUpdateAccounts": false // Automatically update accounts.jsonc
},
// === ERROR REPORTING ===
// Help improve the project by automatically reporting errors

View File

@@ -7,6 +7,8 @@ export class BotController {
private botInstance: MicrosoftRewardsBot | null = null
private startTime?: Date
private isStarting: boolean = false // Race condition protection
private stopRequested: boolean = false // Stop signal flag
private botProcess: Promise<void> | null = null // Track bot execution
constructor() {
process.on('exit', () => this.stop())
@@ -46,21 +48,34 @@ export class BotController {
dashboardState.setBotInstance(this.botInstance)
// Run bot asynchronously - don't block the API response
void (async () => {
this.botProcess = (async () => {
try {
this.log('✓ Bot initialized, starting execution...', 'log')
await this.botInstance!.initialize()
// Check for stop signal before running
if (this.stopRequested) {
this.log('⚠ Stop requested before execution - aborting', 'warn')
return
}
await this.botInstance!.run()
this.log('✓ Bot completed successfully', 'log')
} catch (error) {
this.log(`Bot error: ${getErrorMessage(error)}`, 'error')
// Check if error was due to stop signal
if (this.stopRequested) {
this.log('⚠ Bot stopped by user request', 'warn')
} else {
this.log(`Bot error: ${getErrorMessage(error)}`, 'error')
}
} finally {
this.cleanup()
}
})()
// Don't await - return immediately to unblock API
return { success: true, pid: process.pid }
} catch (error) {
@@ -73,20 +88,30 @@ export class BotController {
}
}
public stop(): { success: boolean; error?: string } {
public async stop(): Promise<{ success: boolean; error?: string }> {
if (!this.botInstance) {
return { success: false, error: 'Bot is not running' }
}
try {
this.log('🛑 Stopping bot...', 'warn')
this.log('⚠ Note: Bot will complete current task before stopping', 'warn')
this.log('⚠ Bot will complete current task before stopping', 'warn')
// Set stop flag
this.stopRequested = true
// Wait for bot process to finish (with timeout)
if (this.botProcess) {
const timeout = new Promise((resolve) => setTimeout(resolve, 10000)) // 10s timeout
await Promise.race([this.botProcess, timeout])
}
this.cleanup()
this.log('✓ Bot stopped successfully', 'log')
return { success: true }
} catch (error) {
const errorMsg = getErrorMessage(error)
const errorMsg = await getErrorMessage(error)
this.log(`Error stopping bot: ${errorMsg}`, 'error')
this.cleanup()
return { success: false, error: errorMsg }
@@ -96,7 +121,7 @@ export class BotController {
public async restart(): Promise<{ success: boolean; error?: string; pid?: number }> {
this.log('🔄 Restarting bot...', 'log')
const stopResult = this.stop()
const stopResult = await this.stop()
if (!stopResult.success && stopResult.error !== 'Bot is not running') {
return { success: false, error: `Failed to stop: ${stopResult.error}` }
}
@@ -143,14 +168,21 @@ export class BotController {
dashboardState.updateAccount(email, { status: 'running', errors: [] })
// Run bot asynchronously with single account
void (async () => {
this.botProcess = (async () => {
try {
this.log(`✓ Bot initialized for ${email}, starting execution...`, 'log')
await this.botInstance!.initialize()
// Override accounts to run only this one
; (this.botInstance as any).accounts = [targetAccount]
// Check for stop signal
if (this.stopRequested) {
this.log(`⚠ Stop requested for ${email} - aborting`, 'warn')
dashboardState.updateAccount(email, { status: 'idle' })
return
}
// Override accounts to run only this one
; (this.botInstance as any).accounts = [targetAccount]
await this.botInstance!.run()
@@ -158,11 +190,16 @@ export class BotController {
dashboardState.updateAccount(email, { status: 'completed' })
} catch (error) {
const errMsg = error instanceof Error ? error.message : String(error)
this.log(`Bot error for ${email}: ${errMsg}`, 'error')
dashboardState.updateAccount(email, {
status: 'error',
errors: [errMsg]
})
if (this.stopRequested) {
this.log(`⚠ Bot stopped for ${email} by user request`, 'warn')
dashboardState.updateAccount(email, { status: 'idle' })
} else {
this.log(`Bot error for ${email}: ${errMsg}`, 'error')
dashboardState.updateAccount(email, {
status: 'error',
errors: [errMsg]
})
}
} finally {
this.cleanup()
}
@@ -202,6 +239,8 @@ export class BotController {
private cleanup(): void {
this.botInstance = null
this.startTime = undefined
this.stopRequested = false
this.botProcess = null
dashboardState.setRunning(false)
dashboardState.setBotInstance(undefined)
}

View File

@@ -256,6 +256,68 @@ export class StatsManager {
console.error('[STATS] Failed to prune old stats:', error)
}
}
/**
* Get historical stats for charts (last N days)
*/
getHistoricalStats(days: number = 30): Record<string, number> {
const result: Record<string, number> = {}
const today = new Date()
for (let i = 0; i < days; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().slice(0, 10)
const stats = this.loadDailyStats(dateStr)
result[dateStr] = stats?.totalPoints || 0
}
return result
}
/**
* Get activity breakdown for last N days
*/
getActivityBreakdown(days: number = 7): Record<string, number> {
const breakdown: Record<string, number> = {
'Desktop Search': 0,
'Mobile Search': 0,
'Daily Set': 0,
'Quizzes': 0,
'Punch Cards': 0,
'Other': 0
}
const today = new Date()
for (let i = 0; i < days; i++) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().slice(0, 10)
const accountStats = this.getAccountStatsForDate(dateStr)
for (const account of accountStats) {
if (!account.desktopSearches) account.desktopSearches = 0
if (!account.mobileSearches) account.mobileSearches = 0
if (!account.activitiesCompleted) account.activitiesCompleted = 0
breakdown['Desktop Search']! += account.desktopSearches
breakdown['Mobile Search']! += account.mobileSearches
breakdown['Daily Set']! += Math.min(1, account.activitiesCompleted)
breakdown['Other']! += Math.max(0, account.activitiesCompleted - 3)
}
}
return breakdown
}
/**
* Get global stats
*/
getGlobalStats(): GlobalStats {
return this.loadGlobalStats()
}
}
// Singleton instance

View File

@@ -173,9 +173,9 @@ apiRouter.post('/start', async (_req: Request, res: Response): Promise<void> =>
})
// POST /api/stop - Stop bot
apiRouter.post('/stop', (_req: Request, res: Response): void => {
apiRouter.post('/stop', async (_req: Request, res: Response): Promise<void> => {
try {
const result = botController.stop()
const result = await botController.stop()
if (result.success) {
sendSuccess(res, { message: 'Bot stopped successfully' })
@@ -484,6 +484,38 @@ apiRouter.get('/account-stats/:email', (req: Request, res: Response) => {
}
})
// GET /api/stats/historical - Get historical point data
apiRouter.get('/stats/historical', (req: Request, res: Response) => {
try {
const days = parseInt(req.query.days as string) || 30
const historical = statsManager.getHistoricalStats(days)
res.json(historical)
} catch (error) {
res.status(500).json({ error: getErr(error) })
}
})
// GET /api/stats/activity-breakdown - Get activity breakdown
apiRouter.get('/stats/activity-breakdown', (req: Request, res: Response) => {
try {
const days = parseInt(req.query.days as string) || 7
const breakdown = statsManager.getActivityBreakdown(days)
res.json(breakdown)
} catch (error) {
res.status(500).json({ error: getErr(error) })
}
})
// GET /api/stats/global - Get global statistics
apiRouter.get('/stats/global', (_req: Request, res: Response) => {
try {
const global = statsManager.getGlobalStats()
res.json(global)
} catch (error) {
res.status(500).json({ error: getErr(error) })
}
})
// Helper to mask sensitive URLs
function maskUrl(url: string): string {
try {

View File

@@ -33,6 +33,7 @@ import { InternalScheduler } from './scheduler/InternalScheduler'
import { DISCORD, TIMEOUTS } from './constants'
import { Account } from './interface/Account'
import { FileBootstrap } from './util/core/FileBootstrap'
// Main bot class
@@ -1137,6 +1138,21 @@ async function main(): Promise<void> {
const bootstrap = async () => {
try {
// STEP 1: Bootstrap configuration files (copy .example.jsonc if needed)
log('main', 'BOOTSTRAP', 'Checking configuration files...', 'log', 'cyan')
const createdFiles = FileBootstrap.bootstrap()
if (createdFiles.length > 0) {
FileBootstrap.displayStartupMessage(createdFiles)
// If accounts file was just created, it will be empty
// User needs to configure before running
if (createdFiles.includes('Accounts')) {
log('main', 'BOOTSTRAP', 'Please configure your accounts in src/accounts.jsonc before running the bot.', 'warn', 'yellow')
process.exit(0)
}
}
// Check for updates BEFORE initializing and running tasks
const updateMarkerPath = path.join(process.cwd(), '.update-happened')
const isDocker = isDockerEnvironment()

View File

@@ -0,0 +1,116 @@
import fs from 'fs'
import path from 'path'
/**
* Bootstrap configuration files on startup
* Automatically copies .example.jsonc files to .jsonc if they don't exist
*
* This ensures first-time users have working config/accounts files
* without manual renaming steps
*/
export class FileBootstrap {
private static readonly FILES_TO_BOOTSTRAP = [
{
example: 'src/config.example.jsonc',
target: 'src/config.jsonc',
name: 'Configuration'
},
{
example: 'src/accounts.example.jsonc',
target: 'src/accounts.jsonc',
name: 'Accounts'
}
]
/**
* Bootstrap all necessary files
* @returns Array of files that were created
*/
public static bootstrap(): string[] {
const created: string[] = []
for (const file of this.FILES_TO_BOOTSTRAP) {
if (this.bootstrapFile(file.example, file.target, file.name)) {
created.push(file.name)
}
}
return created
}
/**
* Bootstrap a single file
* @returns true if file was created, false if it already existed
*/
private static bootstrapFile(examplePath: string, targetPath: string, name: string): boolean {
const rootDir = process.cwd()
const exampleFullPath = path.join(rootDir, examplePath)
const targetFullPath = path.join(rootDir, targetPath)
// Check if target already exists
if (fs.existsSync(targetFullPath)) {
return false
}
// Check if example exists
if (!fs.existsSync(exampleFullPath)) {
console.warn(`⚠️ Example file not found: ${examplePath}`)
return false
}
try {
// Copy example to target
fs.copyFileSync(exampleFullPath, targetFullPath)
console.log(`✅ Created ${name} file: ${targetPath}`)
return true
} catch (error) {
console.error(`❌ Failed to create ${name} file:`, error instanceof Error ? error.message : String(error))
return false
}
}
/**
* Check if all required files exist
* @returns true if all files exist
*/
public static checkFiles(): { allExist: boolean; missing: string[] } {
const missing: string[] = []
const rootDir = process.cwd()
for (const file of this.FILES_TO_BOOTSTRAP) {
const targetFullPath = path.join(rootDir, file.target)
if (!fs.existsSync(targetFullPath)) {
missing.push(file.name)
}
}
return {
allExist: missing.length === 0,
missing
}
}
/**
* Display startup message if files were bootstrapped
*/
public static displayStartupMessage(createdFiles: string[]): void {
if (createdFiles.length === 0) {
return
}
console.log('\n' + '='.repeat(70))
console.log('🎉 FIRST-TIME SETUP COMPLETE')
console.log('='.repeat(70))
console.log('\nThe following files have been created for you:')
for (const fileName of createdFiles) {
console.log(`${fileName}`)
}
console.log('\n📝 NEXT STEPS:')
console.log(' 1. Edit src/accounts.jsonc to add your Microsoft accounts')
console.log(' 2. (Optional) Customize src/config.jsonc settings')
console.log(' 3. Run the bot again with: npm start')
console.log('\n' + '='.repeat(70) + '\n')
}
}