mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-08 00:26:16 +00:00
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:
1473
.github/copilot-instructions.md
vendored
1473
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
10
README.md
10
README.md
@@ -25,14 +25,18 @@
|
||||
git clone https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
|
||||
cd Microsoft-Rewards-Bot
|
||||
|
||||
# 2. Setup accounts
|
||||
cp src/accounts.example.jsonc src/accounts.jsonc
|
||||
# 2. Run the bot (auto-creates config files on first run)
|
||||
npm start
|
||||
|
||||
# 3. Configure your accounts
|
||||
# Edit src/accounts.jsonc with your Microsoft account(s)
|
||||
|
||||
# 3. Run
|
||||
# 4. Run again
|
||||
npm start
|
||||
```
|
||||
|
||||
**Note:** Configuration files (`config.jsonc` and `accounts.jsonc`) are automatically created from `.example.jsonc` templates on first run.
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | Description |
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
# Update System
|
||||
|
||||
## Overview
|
||||
|
||||
The bot uses a **simplified GitHub API-based update system** that:
|
||||
- ✅ Downloads latest code as ZIP archive
|
||||
- ✅ No Git required
|
||||
- ✅ No merge conflicts
|
||||
- ✅ Preserves user files automatically
|
||||
- ✅ Automatic dependency installation
|
||||
- ✅ TypeScript rebuild
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Automatic Updates**: If enabled in `config.jsonc`, the bot checks for updates on every startup
|
||||
2. **Download**: Latest code is downloaded as ZIP from GitHub
|
||||
3. **Protection**: User files (accounts, config, sessions) are backed up
|
||||
4. **Update**: Code files are replaced selectively
|
||||
5. **Restore**: Protected files are restored
|
||||
6. **Install**: Dependencies are installed (`npm ci`)
|
||||
7. **Build**: TypeScript is compiled
|
||||
8. **Restart**: Bot restarts automatically with new version
|
||||
|
||||
## Configuration
|
||||
|
||||
In `src/config.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"update": {
|
||||
"enabled": true, // Enable/disable updates
|
||||
"autoUpdateAccounts": false, // Protect accounts files (recommended: false)
|
||||
"autoUpdateConfig": false // Protect config.jsonc (recommended: false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Protected Files
|
||||
|
||||
These files are **always protected** (never overwritten):
|
||||
- `sessions/` - Browser session data
|
||||
- `.playwright-chromium-installed` - Browser installation marker
|
||||
|
||||
These files are **conditionally protected** (based on config):
|
||||
- `src/accounts.jsonc` - Protected unless `autoUpdateAccounts: true`
|
||||
- `src/accounts.json` - Protected unless `autoUpdateAccounts: true`
|
||||
- `src/config.jsonc` - Protected unless `autoUpdateConfig: true`
|
||||
|
||||
## Manual Update
|
||||
|
||||
Run the update manually:
|
||||
|
||||
```bash
|
||||
node scripts/installer/update.mjs
|
||||
```
|
||||
|
||||
## Update Detection
|
||||
|
||||
The bot uses marker files to prevent restart loops:
|
||||
|
||||
- `.update-happened` - Created when files are actually updated
|
||||
- `.update-restart-count` - Tracks restart attempts (max 3)
|
||||
|
||||
If no updates are available, **no marker is created** and the bot won't restart.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Updates disabled
|
||||
```
|
||||
⚠️ Updates are disabled in config.jsonc
|
||||
```
|
||||
→ Set `update.enabled: true` in `src/config.jsonc`
|
||||
|
||||
### Download failed
|
||||
```
|
||||
❌ Download failed: [error]
|
||||
```
|
||||
→ Check your internet connection
|
||||
→ Verify GitHub is accessible
|
||||
|
||||
### Extraction failed
|
||||
```
|
||||
❌ Extraction failed: [error]
|
||||
```
|
||||
→ Ensure you have one of: `unzip`, `tar`, or PowerShell (Windows)
|
||||
|
||||
### Build failed
|
||||
```
|
||||
⚠️ Update completed with build warnings
|
||||
```
|
||||
→ Check TypeScript errors above
|
||||
→ May still work, but review errors
|
||||
|
||||
## Architecture
|
||||
|
||||
### File Structure
|
||||
```
|
||||
scripts/installer/
|
||||
├── update.mjs # Main update script (auto-updater)
|
||||
├── setup.mjs # Initial setup wizard
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Update Flow
|
||||
```
|
||||
Start
|
||||
↓
|
||||
Check config (enabled?)
|
||||
↓
|
||||
Read user preferences (autoUpdate flags)
|
||||
↓
|
||||
Backup protected files
|
||||
↓
|
||||
Download ZIP from GitHub
|
||||
↓
|
||||
Extract archive
|
||||
↓
|
||||
Copy files selectively (skip protected)
|
||||
↓
|
||||
Restore protected files
|
||||
↓
|
||||
Cleanup temporary files
|
||||
↓
|
||||
Create marker (.update-happened) if files changed
|
||||
↓
|
||||
Install dependencies (npm ci)
|
||||
↓
|
||||
Build TypeScript
|
||||
↓
|
||||
Exit (bot auto-restarts if marker exists)
|
||||
```
|
||||
|
||||
## Previous System
|
||||
|
||||
The old update system (799 lines) supported two methods:
|
||||
- Git method (required Git, had merge conflicts)
|
||||
- GitHub API method
|
||||
|
||||
**New system**: Only GitHub API method (simpler, more reliable)
|
||||
|
||||
## Anti-Loop Protection
|
||||
|
||||
The bot has built-in protection against infinite restart loops:
|
||||
|
||||
1. **Marker detection**: Bot only restarts if `.update-happened` exists
|
||||
2. **Restart counter**: Max 3 restart attempts (`.update-restart-count`)
|
||||
3. **Counter cleanup**: Removed after successful run without updates
|
||||
4. **No-update detection**: Marker NOT created if already up to date
|
||||
|
||||
This ensures the bot never gets stuck in an infinite update loop.
|
||||
|
||||
## Dependencies
|
||||
|
||||
No external dependencies required! The update system uses only Node.js built-in modules:
|
||||
- `node:child_process` - Run shell commands
|
||||
- `node:fs` - File system operations
|
||||
- `node:https` - Download files
|
||||
- `node:path` - Path manipulation
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- `0` - Success (updated or already up to date)
|
||||
- `1` - Error (download failed, extraction failed, etc.)
|
||||
|
||||
## NPM Scripts
|
||||
|
||||
- `npm run start` - Start bot (runs update check first if enabled)
|
||||
- `npm run dev` - Start in dev mode (skips update check)
|
||||
- `npm run build` - Build TypeScript manually
|
||||
|
||||
## Version Info
|
||||
|
||||
- Current version: **v2** (GitHub API only)
|
||||
- Previous version: v1 (Dual Git/GitHub API)
|
||||
- Lines of code: **468** (down from 799)
|
||||
- Complexity: **Simple** (down from Complex)
|
||||
@@ -264,85 +264,208 @@ function getUpdateMode(configData) {
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check if update is available by comparing versions
|
||||
* Uses GitHub API directly (no CDN cache issues, always fresh data)
|
||||
* Rate limit: 60 requests/hour (sufficient for bot updates)
|
||||
* Download a file from GitHub raw URL
|
||||
*/
|
||||
async function checkVersion() {
|
||||
try {
|
||||
// Read local version
|
||||
const localPkgPath = join(process.cwd(), 'package.json')
|
||||
if (!existsSync(localPkgPath)) {
|
||||
console.log('⚠️ Could not find local package.json')
|
||||
return { updateAvailable: false, localVersion: 'unknown', remoteVersion: 'unknown' }
|
||||
}
|
||||
async function downloadFromGitHub(url, dest) {
|
||||
console.log(`📥 Downloading: ${url}`)
|
||||
|
||||
const localPkg = JSON.parse(readFileSync(localPkgPath, 'utf8'))
|
||||
const localVersion = localPkg.version
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = createWriteStream(dest)
|
||||
|
||||
// Fetch remote version from GitHub API (no cache)
|
||||
const repoOwner = 'LightZirconite'
|
||||
const repoName = 'Microsoft-Rewards-Bot'
|
||||
const branch = 'main'
|
||||
|
||||
console.log('🔍 Checking for updates...')
|
||||
console.log(` Local: ${localVersion}`)
|
||||
|
||||
// Use GitHub API directly - no CDN cache, always fresh
|
||||
const apiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/package.json?ref=${branch}`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Microsoft-Rewards-Bot-Updater',
|
||||
'Accept': 'application/vnd.github.v3.raw', // Returns raw file content
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
httpsGet(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Microsoft-Rewards-Bot-Updater',
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
}, (response) => {
|
||||
// Handle redirects
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
file.close()
|
||||
rmSync(dest, { force: true })
|
||||
downloadFromGitHub(response.headers.location, dest).then(resolve).catch(reject)
|
||||
return
|
||||
}
|
||||
|
||||
const request = httpsGet(apiUrl, options, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log(` ⚠️ GitHub API returned HTTP ${res.statusCode}`)
|
||||
if (res.statusCode === 403) {
|
||||
console.log(' ℹ️ Rate limit may be exceeded (60/hour). Try again later.')
|
||||
}
|
||||
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
|
||||
return
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
file.close()
|
||||
rmSync(dest, { force: true })
|
||||
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`))
|
||||
return
|
||||
}
|
||||
|
||||
let data = ''
|
||||
res.on('data', chunk => data += chunk)
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const remotePkg = JSON.parse(data)
|
||||
const remoteVersion = remotePkg.version
|
||||
console.log(` Remote: ${remoteVersion}`)
|
||||
|
||||
// Any difference triggers update (upgrade or downgrade)
|
||||
const updateAvailable = localVersion !== remoteVersion
|
||||
resolve({ updateAvailable, localVersion, remoteVersion })
|
||||
} catch (err) {
|
||||
console.log(` ⚠️ Could not parse remote package.json: ${err.message}`)
|
||||
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
request.on('error', (err) => {
|
||||
console.log(` ⚠️ Network error: ${err.message}`)
|
||||
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
|
||||
})
|
||||
|
||||
request.setTimeout(10000, () => {
|
||||
request.destroy()
|
||||
console.log(' ⚠️ Request timeout (10s)')
|
||||
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
resolve()
|
||||
})
|
||||
}).on('error', (err) => {
|
||||
file.close()
|
||||
rmSync(dest, { force: true })
|
||||
reject(err)
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(`⚠️ Version check failed: ${err.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart update for config/accounts example files
|
||||
* Only updates if GitHub version has changed AND local user file matches old example
|
||||
*/
|
||||
async function smartUpdateExampleFiles(configData) {
|
||||
const files = []
|
||||
|
||||
// Check which files to update based on config
|
||||
if (configData?.update?.autoUpdateConfig === true) {
|
||||
files.push({
|
||||
example: 'src/config.example.jsonc',
|
||||
target: 'src/config.jsonc',
|
||||
name: 'Configuration',
|
||||
githubUrl: 'https://raw.githubusercontent.com/LightZirconite/Microsoft-Rewards-Bot/refs/heads/main/src/config.example.jsonc'
|
||||
})
|
||||
}
|
||||
|
||||
if (configData?.update?.autoUpdateAccounts === true) {
|
||||
files.push({
|
||||
example: 'src/accounts.example.jsonc',
|
||||
target: 'src/accounts.jsonc',
|
||||
name: 'Accounts',
|
||||
githubUrl: 'https://raw.githubusercontent.com/LightZirconite/Microsoft-Rewards-Bot/refs/heads/main/src/accounts.example.jsonc'
|
||||
})
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return // Nothing to update
|
||||
}
|
||||
|
||||
console.log('\n🔧 Checking for example file updates...')
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const examplePath = join(process.cwd(), file.example)
|
||||
const targetPath = join(process.cwd(), file.target)
|
||||
const tempPath = join(process.cwd(), `.update-${file.example.split('/').pop()}`)
|
||||
|
||||
// Download latest version from GitHub
|
||||
await downloadFromGitHub(file.githubUrl, tempPath)
|
||||
|
||||
// Read all versions
|
||||
const githubContent = readFileSync(tempPath, 'utf8')
|
||||
const localExampleContent = existsSync(examplePath) ? readFileSync(examplePath, 'utf8') : ''
|
||||
const userContent = existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : ''
|
||||
|
||||
// Check if GitHub version is different from local example
|
||||
if (githubContent === localExampleContent) {
|
||||
console.log(`✓ ${file.name}: No changes detected`)
|
||||
rmSync(tempPath, { force: true })
|
||||
continue
|
||||
}
|
||||
|
||||
// GitHub version is different - check if user has modified their file
|
||||
if (userContent === localExampleContent) {
|
||||
// User hasn't modified their file - safe to update
|
||||
console.log(`📝 ${file.name}: Updating to latest version...`)
|
||||
|
||||
// Update example file
|
||||
writeFileSync(examplePath, githubContent)
|
||||
|
||||
// Update user file (since they haven't customized it)
|
||||
writeFileSync(targetPath, githubContent)
|
||||
|
||||
console.log(`✅ ${file.name}: Updated successfully`)
|
||||
} else {
|
||||
// User has customized their file - DO NOT overwrite
|
||||
console.log(`⚠️ ${file.name}: User has custom changes, skipping auto-update`)
|
||||
console.log(` → Update available in: ${file.example}`)
|
||||
console.log(` → To disable this check: set "update.autoUpdate${file.name === 'Configuration' ? 'Config' : 'Accounts'}" to false`)
|
||||
|
||||
// Still update the example file for reference
|
||||
writeFileSync(examplePath, githubContent)
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
rmSync(tempPath, { force: true })
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update ${file.name}: ${error.message}`)
|
||||
// Continue with other files
|
||||
}
|
||||
}
|
||||
|
||||
console.log('')
|
||||
}
|
||||
try {
|
||||
// Read local version
|
||||
const localPkgPath = join(process.cwd(), 'package.json')
|
||||
if (!existsSync(localPkgPath)) {
|
||||
console.log('⚠️ Could not find local package.json')
|
||||
return { updateAvailable: false, localVersion: 'unknown', remoteVersion: 'unknown' }
|
||||
}
|
||||
|
||||
const localPkg = JSON.parse(readFileSync(localPkgPath, 'utf8'))
|
||||
const localVersion = localPkg.version
|
||||
|
||||
// Fetch remote version from GitHub API (no cache)
|
||||
const repoOwner = 'LightZirconite'
|
||||
const repoName = 'Microsoft-Rewards-Bot'
|
||||
const branch = 'main'
|
||||
|
||||
console.log('🔍 Checking for updates...')
|
||||
console.log(` Local: ${localVersion}`)
|
||||
|
||||
// Use GitHub API directly - no CDN cache, always fresh
|
||||
const apiUrl = `https://api.github.com/repos/${repoOwner}/${repoName}/contents/package.json?ref=${branch}`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Microsoft-Rewards-Bot-Updater',
|
||||
'Accept': 'application/vnd.github.v3.raw', // Returns raw file content
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
}
|
||||
|
||||
const request = httpsGet(apiUrl, options, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log(` ⚠️ GitHub API returned HTTP ${res.statusCode}`)
|
||||
if (res.statusCode === 403) {
|
||||
console.log(' ℹ️ Rate limit may be exceeded (60/hour). Try again later.')
|
||||
}
|
||||
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
|
||||
return
|
||||
}
|
||||
|
||||
let data = ''
|
||||
res.on('data', chunk => data += chunk)
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const remotePkg = JSON.parse(data)
|
||||
const remoteVersion = remotePkg.version
|
||||
console.log(` Remote: ${remoteVersion}`)
|
||||
|
||||
// Any difference triggers update (upgrade or downgrade)
|
||||
const updateAvailable = localVersion !== remoteVersion
|
||||
resolve({ updateAvailable, localVersion, remoteVersion })
|
||||
} catch (err) {
|
||||
console.log(` ⚠️ Could not parse remote package.json: ${err.message}`)
|
||||
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
request.on('error', (err) => {
|
||||
console.log(` ⚠️ Network error: ${err.message}`)
|
||||
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
|
||||
})
|
||||
|
||||
request.setTimeout(10000, () => {
|
||||
request.destroy()
|
||||
console.log(' ⚠️ Request timeout (10s)')
|
||||
resolve({ updateAvailable: false, localVersion, remoteVersion: 'unknown' })
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.log(`⚠️ Version check failed: ${err.message}`)
|
||||
return { updateAvailable: false, localVersion: 'unknown', remoteVersion: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,6 +738,9 @@ async function performUpdate() {
|
||||
|
||||
process.stdout.write(' ✓\n')
|
||||
|
||||
// Step 10.5: Smart update example files (config/accounts) if enabled
|
||||
await smartUpdateExampleFiles(configData)
|
||||
|
||||
// Step 11: Verify integrity (check if critical files exist AND were recently updated)
|
||||
process.stdout.write('🔍 Verifying integrity...')
|
||||
const criticalPaths = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
16
src/index.ts
16
src/index.ts
@@ -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()
|
||||
|
||||
116
src/util/core/FileBootstrap.ts
Normal file
116
src/util/core/FileBootstrap.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user