From 57e2bc392de32132d8121dad2a8b57cacd749e3e Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Tue, 4 Nov 2025 21:08:11 +0100 Subject: [PATCH] feat: add Buy Mode functionality for manual purchase monitoring and point tracking --- README.md | 24 +++++ docs/buy-mode.md | 139 +++++++++++++++++++++++++--- docs/config.md | 2 +- package-lock.json | 4 +- package.json | 3 +- src/index.ts | 83 +++++++++-------- src/util/BuyMode.ts | 217 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 414 insertions(+), 58 deletions(-) create mode 100644 src/util/BuyMode.ts diff --git a/README.md b/README.md index 062cc75..073a634 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ For detailed configuration, advanced features, and troubleshooting, visit our co | **[Getting Started](docs/getting-started.md)** | Detailed installation and first-run guide | | **[Configuration](docs/config.md)** | Complete configuration options reference | | **[Accounts & 2FA](docs/accounts.md)** | Setting up accounts with TOTP authentication | +| **[Buy Mode](docs/buy-mode.md)** | 💳 Manual purchase monitoring with point tracking | | **[Dashboard](src/dashboard/README.md)** | 🆕 Local web dashboard for monitoring and control | | **[External Scheduling](docs/schedule.md)** | Use OS schedulers for automation | | **[Docker Deployment](docs/docker.md)** | Running in containers | @@ -126,6 +127,29 @@ Access at `http://localhost:3000` to: --- +## 💳 Buy Mode + +Monitor your points in real-time while manually redeeming rewards: + +```bash +# Interactive account selection (recommended) +npm run buy + +# Or specify account directly +npm run buy your@email.com +npm run buy 1 # By account number +``` + +**What it does:** +- Opens browser with 2 tabs: monitoring + your browsing tab +- Tracks point changes every ~10 seconds +- Sends alerts when points are spent +- Completely passive - you control the redemption + +**[📖 Full Buy Mode Guide](docs/buy-mode.md)** + +--- + ## ⏰ Automatic Scheduling Configure automatic task scheduling directly from `config.jsonc` - **perfect for Raspberry Pi!** diff --git a/docs/buy-mode.md b/docs/buy-mode.md index 8f29b3f..68455bd 100644 --- a/docs/buy-mode.md +++ b/docs/buy-mode.md @@ -14,25 +14,85 @@ Launches browser and **passively monitors** your points balance while you manual ## ⚡ Quick Start +### Option 1: Interactive Selection (Recommended) ```bash -npm start -- -buy your@email.com +npm run buy +``` + +### Option 2: Direct Email Selection +```bash +npm run buy your@email.com +``` + +### Option 3: Numeric Account Index +```bash +npm run buy 1 ``` **What happens:** 1. Opens 2 browser tabs: - - **Monitor tab** — Background point tracking (auto-refresh) + - **Monitor tab** — Background point tracking (auto-refresh every ~10s) - **Your tab** — Use this for manual purchases -2. Monitors points every ~10 seconds -3. Alerts you when spending detected +2. Monitors points passively without clicking +3. Sends alerts when spending detected +4. Session runs for configured duration (default: 45 minutes) + +--- + +## 🎯 Account Selection Methods + +### 1. Interactive Selection (Easiest) +Run without arguments to see a menu of available accounts: +```bash +npm run buy +``` + +You'll see: +``` +Available accounts: +──────────────────────────────────────────────────────────── +[1] my***t@outlook.com Direct +[2] se***d@outlook.com 🔒 Proxy +[3] th***d@outlook.com Direct +──────────────────────────────────────────────────────────── + +Enter account number (1-3) or email: +``` + +**Features:** +- ✅ Masked emails for privacy +- ✅ Proxy status indication +- ✅ Only shows enabled accounts +- ✅ Validates selection before proceeding + +### 2. By Email Address +Specify the exact email address: +```bash +npm run buy myaccount@outlook.com +``` + +### 3. By Account Number +Use the account index (1-based): +```bash +npm run buy 1 # First enabled account +npm run buy 2 # Second enabled account +``` --- ## 🎯 Example Usage -### Redeem Gift Card +### Redeem Gift Card (Interactive) ```bash -npm start -- -buy myaccount@outlook.com +npm run buy +``` +Choose your account from the interactive menu. + +### Redeem Gift Card (Email) + +```bash +npm run buy myaccount@outlook.com ``` 1. Script opens Microsoft Rewards in browser @@ -40,6 +100,13 @@ npm start -- -buy myaccount@outlook.com 3. **Monitor tab** tracks your balance in background 4. Get notification when points decrease +### Redeem Gift Card (Index) + +```bash +npm run buy 1 +``` +Directly selects the first enabled account from your accounts list. + --- ## ⚙️ Configuration @@ -50,47 +117,86 @@ npm start -- -buy myaccount@outlook.com ```jsonc { "buyMode": { - "enabled": false, - "maxMinutes": 45 + "maxMinutes": 45 // Session duration (minimum: 10, default: 45) } } ``` +**Session behavior:** +- Monitor tab refreshes every ~10 seconds +- Session automatically ends after `maxMinutes` +- You can close the browser anytime +- Cookies are saved at the end + --- ## 🔔 Notifications -Buy mode sends alerts when: +Buy mode sends real-time alerts when: - 💳 **Points spent** — Shows amount and new balance - 📉 **Balance changes** — Tracks cumulative spending **Example alert:** ``` -💳 Spend detected (Buy Mode) +💳 Spend Detected (Buy Mode) Account: user@email.com Spent: -500 points Current: 12,500 points Session spent: 1,200 points ``` +**Alert channels:** Uses your configured webhooks (Discord, NTFY, etc.) + --- ## 🛠️ Troubleshooting | Problem | Solution | |---------|----------| -| **Monitor tab closes** | Script auto-reopens it | -| **No spending alerts** | Check webhook/NTFY config | +| **"No enabled accounts found"** | Enable at least one account in `accounts.jsonc` | +| **"Invalid account index"** | Check your account number (must be 1-N) | +| **"Account not found"** | Verify email spelling and that account is enabled | +| **Monitor tab closes** | Script auto-reopens it in background | +| **No spending alerts** | Check webhook/NTFY config in `config.jsonc` | | **Session too short** | Increase `maxMinutes` in config | +| **Interactive prompt not showing** | Run: `npm run buy` (no arguments) | --- ## ⚠️ Important Notes -- ✅ **Browser visible** — Always runs in visible mode -- ✅ **No automation** — Script only monitors, never clicks +- ✅ **Browser visible** — Always runs in visible mode (not headless) +- ✅ **No automation** — Script only monitors, never clicks or redeems - ✅ **Safe** — Use your browsing tab normally -- ✅ **Notifications** — Uses existing webhook/NTFY settings +- ✅ **Real-time tracking** — Immediate notifications on point changes +- ✅ **Multiple selection methods** — Interactive, email, or index +- ✅ **Privacy-friendly** — Emails are masked in interactive mode +- ⚠️ **Only enabled accounts** — Disabled accounts don't appear + +--- + +## 📊 Session Summary + +At the end of each buy mode session, you'll receive a summary: + +``` +Account: myaccount@outlook.com +Duration: 45m 12s +Initial points: 15,000 +Current points: 13,500 +Total spent: 1,500 +``` + +This summary is sent via your configured notification channels. + +--- + +## 💡 Pro Tips + +- **Use interactive mode** for the safest selection +- **Build first** if you modified code: `npm run build` +- **Multiple accounts?** Use numeric index for speed +- **Check your balance** before and after in the monitor tab --- @@ -100,6 +206,9 @@ Session spent: 1,200 points → **[Discord Webhooks](./conclusionwebhook.md)** → **[NTFY Push](./ntfy.md)** +**Manage multiple accounts?** +→ **[Accounts Guide](./accounts.md)** + **Back to automation?** → **[Getting Started](./getting-started.md)** diff --git a/docs/config.md b/docs/config.md index 4ba2262..35fc4f8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -53,7 +53,7 @@ When running inside Docker, you can instead rely on `update.docker: true` so the - **Risk management**: Leave `riskManagement.enabled` and `banPrediction` on unless you have a reason to reduce telemetry. Raising `riskThreshold` (>75) makes alerts rarer. - **Search pacing**: The delay window (`search.settings.delay.min` / `max`) accepts either numbers (ms) or strings like `"2min"`. Keep the range wide enough for natural behaviour. - **Dry run**: Set `dryRun: true` to test account rotation without performing tasks. Useful for validating login flow after configuration changes. -- **Buy mode**: The config entry simply caps the session length. Use `npm start -- -buy [email]` to launch it. +- **Buy mode**: The config entry simply caps the session length. Use `npm run buy` to launch it. --- diff --git a/package-lock.json b/package-lock.json index f138798..18ae829 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "microsoft-rewards-bot", - "version": "2.51.0", + "version": "2.55.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "microsoft-rewards-bot", - "version": "2.51.0", + "version": "2.55.0", "license": "CC-BY-NC-SA-4.0", "dependencies": { "axios": "^1.8.4", diff --git a/package.json b/package.json index 70b9fdd..6daab26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "microsoft-rewards-bot", - "version": "2.51.0", + "version": "2.55.0", "description": "Automate Microsoft Rewards points collection", "private": true, "main": "index.js", @@ -24,6 +24,7 @@ "start": "node --enable-source-maps ./dist/index.js", "ts-start": "node --loader ts-node/esm ./src/index.ts", "dev": "ts-node ./src/index.ts -dev", + "buy": "node --enable-source-maps ./dist/index.js -buy", "dashboard": "node --enable-source-maps ./dist/index.js -dashboard", "dashboard-dev": "ts-node ./src/index.ts -dashboard", "lint": "eslint \"src/**/*.{ts,tsx}\"", diff --git a/src/index.ts b/src/index.ts index 45013b9..743ab26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,7 @@ import JobState from './util/JobState' import { StartupValidator } from './util/StartupValidator' import { MobileRetryTracker } from './util/MobileRetryTracker' import { SchedulerManager } from './util/SchedulerManager' +import { BuyModeSelector, BuyModeMonitor } from './util/BuyMode' import { Login } from './functions/Login' import { Workers } from './functions/Workers' @@ -53,7 +54,8 @@ export class MicrosoftRewardsBot { private accounts: Account[] private workers: Workers private login = new Login(this) - private buyMode: { enabled: boolean; email?: string } = { enabled: false } + private buyModeEnabled: boolean = false + private buyModeArgument?: string // Summary collection (per process) private accountSummaries: AccountSummary[] = [] @@ -94,25 +96,23 @@ export class MicrosoftRewardsBot { // Buy mode: CLI args take precedence over config const idx = process.argv.indexOf('-buy') if (idx >= 0) { - const target = process.argv[idx + 1] - this.buyMode = target && /@/.test(target) - ? { enabled: true, email: target } - : { enabled: true } + this.buyModeEnabled = true + this.buyModeArgument = process.argv[idx + 1] } else { // Fallback to config if no CLI flag const buyModeConfig = this.config.buyMode as { enabled?: boolean } | undefined if (buyModeConfig?.enabled === true) { - this.buyMode.enabled = true + this.buyModeEnabled = true } } } public isBuyModeEnabled(): boolean { - return this.buyMode.enabled === true + return this.buyModeEnabled === true } public getBuyModeTarget(): string | undefined { - return this.buyMode.email + return this.buyModeArgument } async initialize() { @@ -168,10 +168,7 @@ export class MicrosoftRewardsBot { log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`) // If buy mode is enabled, run single-account interactive session without automation - if (this.buyMode.enabled) { - const targetInfo = this.buyMode.email ? ` for ${this.buyMode.email}` : '' - log('main', 'BUY-MODE', `Buy mode ENABLED${targetInfo}. We'll open 2 tabs: (1) a monitor tab that auto-refreshes to track points, (2) your browsing tab to redeem/purchase freely.`, 'log', 'green') - log('main', 'BUY-MODE', 'The monitor tab may refresh every ~10s. Use the other tab for your actions; monitoring is passive and non-intrusive.', 'log', 'yellow') + if (this.buyModeEnabled) { await this.runBuyMode() return } @@ -192,9 +189,22 @@ export class MicrosoftRewardsBot { private async runBuyMode() { try { await this.initialize() - const email = this.buyMode.email || (this.accounts[0]?.email) - const account = this.accounts.find(a => a.email === email) || this.accounts[0] - if (!account) throw new Error('No account available for buy mode') + + const buyModeConfig = this.config.buyMode as { maxMinutes?: number } | undefined + const maxMinutes = buyModeConfig?.maxMinutes ?? 45 + + const selector = new BuyModeSelector(this.accounts) + const selection = await selector.selectAccount(this.buyModeArgument, maxMinutes) + + if (!selection) { + log('main', 'BUY-MODE', 'Buy mode cancelled: no account selected', 'warn') + return + } + + const { account, maxMinutes: sessionMaxMinutes } = selection + + log('main', 'BUY-MODE', `Buy mode ENABLED for ${account.email}. Opening 2 tabs: (1) monitor tab (auto-refresh), (2) your browsing tab`, 'log', 'green') + log('main', 'BUY-MODE', `Session duration: ${sessionMaxMinutes} minutes. Monitor tab refreshes every ~10s. Use the other tab for your actions.`, 'log', 'yellow') this.isMobile = false this.axios = new Axios(account.proxy) @@ -232,22 +242,21 @@ export class MicrosoftRewardsBot { this.log(false, 'BUY-MODE', `Failed to send spend notice: ${e instanceof Error ? e.message : e}`, 'warn') } } + + // Get initial points let initial = 0 try { const data = await this.browser.func.getDashboardData(monitor) initial = data.userStatus.availablePoints || 0 } catch {/* ignore */} - this.log(false, 'BUY-MODE', `Logged in as ${account.email}. Buy mode is active: monitor tab auto-refreshes; user tab is free for your actions. We'll observe points passively.`) + const pointMonitor = new BuyModeMonitor(initial) + + this.log(false, 'BUY-MODE', `Logged in as ${account.email}. Starting passive point monitoring (session: ${sessionMaxMinutes} min)`) // Passive watcher: poll points periodically without clicking. const start = Date.now() - let last = initial - let spent = 0 - - const buyModeConfig = this.config.buyMode as { maxMinutes?: number } | undefined - const maxMinutes = Math.max(10, buyModeConfig?.maxMinutes ?? 45) - const endAt = start + maxMinutes * 60 * 1000 + const endAt = start + sessionMaxMinutes * 60 * 1000 while (Date.now() < endAt) { await this.utils.wait(10000) @@ -265,16 +274,11 @@ export class MicrosoftRewardsBot { try { const data = await this.browser.func.getDashboardData(monitor) const nowPts = data.userStatus.availablePoints || 0 - if (nowPts < last) { - // Points decreased -> likely spent - const delta = last - nowPts - spent += delta - last = nowPts - this.log(false, 'BUY-MODE', `Detected spend: -${delta} points (current: ${nowPts})`) - // Immediate spend notice - await sendSpendNotice(delta, nowPts, spent) - } else if (nowPts > last) { - last = nowPts + + const spendInfo = pointMonitor.checkSpending(nowPts) + if (spendInfo) { + this.log(false, 'BUY-MODE', `Detected spend: -${spendInfo.spent} points (current: ${spendInfo.current})`) + await sendSpendNotice(spendInfo.spent, spendInfo.current, spendInfo.total) } } catch (err) { // If we lost the page context, recreate the monitor tab and continue @@ -305,14 +309,15 @@ export class MicrosoftRewardsBot { } // Send a final minimal conclusion webhook for this manual session + const monitorSummary = pointMonitor.getSummary() const summary: AccountSummary = { email: account.email, - durationMs: Date.now() - start, + durationMs: monitorSummary.duration, desktopCollected: 0, mobileCollected: 0, - totalCollected: -spent, // negative indicates spend - initialTotal: initial, - endTotal: last, + totalCollected: -monitorSummary.spent, // negative indicates spend + initialTotal: monitorSummary.initial, + endTotal: monitorSummary.current, errors: [], banned: { status: false, reason: '' } } @@ -328,13 +333,13 @@ export class MicrosoftRewardsBot { if (this.config.clusters > 1 && !cluster.isPrimary) return const version = this.getVersion() - const mode = this.buyMode.enabled ? 'Manual Mode' : 'Automated Mode' + const mode = this.buyModeEnabled ? 'Manual Mode' : 'Automated Mode' log('main', 'BANNER', `Microsoft Rewards Bot v${version} - ${mode}`) log('main', 'BANNER', `PID: ${process.pid} | Workers: ${this.config.clusters}`) - if (this.buyMode.enabled) { - log('main', 'BANNER', `Target: ${this.buyMode.email || 'First account'}`) + if (this.buyModeEnabled) { + log('main', 'BANNER', `Target: ${this.buyModeArgument || 'Interactive selection'}`) } else { const upd = this.config.update || {} const updTargets: string[] = [] diff --git a/src/util/BuyMode.ts b/src/util/BuyMode.ts new file mode 100644 index 0000000..d04330e --- /dev/null +++ b/src/util/BuyMode.ts @@ -0,0 +1,217 @@ +import { createInterface } from 'readline' +import type { Account } from '../interface/Account' +import { log } from './Logger' + +export interface BuyModeSelection { + account: Account + maxMinutes: number +} + +export class BuyModeSelector { + private accounts: Account[] + + constructor(accounts: Account[]) { + this.accounts = accounts.filter(acc => acc.enabled !== false) + } + + /** + * Parse the buy mode argument from CLI. + * Supports: email, numeric index (1-based), or undefined for interactive selection. + */ + async selectAccount( + argument?: string, + maxMinutes: number = 45 + ): Promise { + if (this.accounts.length === 0) { + log('main', 'BUY-MODE', 'No enabled accounts found. Please enable at least one account in accounts.jsonc', 'error') + return null + } + + let selectedAccount: Account | null = null + + if (!argument) { + selectedAccount = await this.promptInteractiveSelection() + } else if (this.isNumericIndex(argument)) { + selectedAccount = this.selectByIndex(argument) + } else if (this.isEmail(argument)) { + selectedAccount = this.selectByEmail(argument) + } else { + log('main', 'BUY-MODE', `Invalid argument: "${argument}". Expected email or numeric index.`, 'error') + return null + } + + if (!selectedAccount) { + return null + } + + return { + account: selectedAccount, + maxMinutes: Math.max(10, maxMinutes) + } + } + + private isNumericIndex(value: string): boolean { + return /^\d+$/.test(value) + } + + private isEmail(value: string): boolean { + return /@/.test(value) + } + + private selectByIndex(indexStr: string): Account | null { + const index = parseInt(indexStr, 10) + + if (index < 1 || index > this.accounts.length) { + log('main', 'BUY-MODE', `Invalid account index: ${index}. Valid range: 1-${this.accounts.length}`, 'error') + this.displayAccountList() + return null + } + + const account = this.accounts[index - 1] + log('main', 'BUY-MODE', `Selected account #${index}: ${this.maskEmail(account!.email)}`, 'log', 'green') + return account! + } + + private selectByEmail(email: string): Account | null { + const account = this.accounts.find(acc => acc.email.toLowerCase() === email.toLowerCase()) + + if (!account) { + log('main', 'BUY-MODE', `Account not found: ${email}`, 'error') + this.displayAccountList() + return null + } + + log('main', 'BUY-MODE', `Selected account: ${this.maskEmail(account.email)}`, 'log', 'green') + return account + } + + private async promptInteractiveSelection(): Promise { + log('main', 'BUY-MODE', 'No account specified. Please select an account:', 'log', 'cyan') + this.displayAccountList() + + const rl = createInterface({ + input: process.stdin, + output: process.stdout + }) + + return new Promise((resolve) => { + rl.question('\nEnter account number (1-' + this.accounts.length + ') or email: ', (answer) => { + rl.close() + + const trimmed = answer.trim() + + if (!trimmed) { + log('main', 'BUY-MODE', 'No selection made. Exiting buy mode.', 'warn') + resolve(null) + return + } + + let selected: Account | null = null + + if (this.isNumericIndex(trimmed)) { + selected = this.selectByIndex(trimmed) + } else if (this.isEmail(trimmed)) { + selected = this.selectByEmail(trimmed) + } else { + log('main', 'BUY-MODE', `Invalid input: "${trimmed}". Expected number or email.`, 'error') + } + + resolve(selected) + }) + }) + } + + private displayAccountList(): void { + console.log('\nAvailable accounts:') + console.log('─'.repeat(60)) + + this.accounts.forEach((acc, idx) => { + const num = `[${idx + 1}]`.padEnd(5) + const email = this.maskEmail(acc.email).padEnd(35) + const proxy = acc.proxy?.url ? '🔒 Proxy' : 'Direct' + console.log(`${num} ${email} ${proxy}`) + }) + + console.log('─'.repeat(60)) + } + + private maskEmail(email: string): string { + const [local, domain] = email.split('@') + if (!local || !domain) return email + + if (local.length <= 3) { + return `${local[0]}***@${domain}` + } + + const visibleStart = local.slice(0, 2) + const visibleEnd = local.slice(-1) + return `${visibleStart}***${visibleEnd}@${domain}` + } +} + +export class BuyModeMonitor { + private initialPoints: number = 0 + private lastPoints: number = 0 + private totalSpent: number = 0 + private monitorStartTime: number = 0 + + constructor(initialPoints: number) { + this.initialPoints = initialPoints + this.lastPoints = initialPoints + this.monitorStartTime = Date.now() + } + + /** + * Update the current points and detect spending. + * Returns spending info if points decreased, null otherwise. + */ + checkSpending(currentPoints: number): { spent: number; current: number; total: number } | null { + if (currentPoints < this.lastPoints) { + const spent = this.lastPoints - currentPoints + this.totalSpent += spent + this.lastPoints = currentPoints + + return { + spent, + current: currentPoints, + total: this.totalSpent + } + } + + if (currentPoints > this.lastPoints) { + this.lastPoints = currentPoints + } + + return null + } + + getTotalSpent(): number { + return this.totalSpent + } + + getSessionDuration(): number { + return Date.now() - this.monitorStartTime + } + + getCurrentPoints(): number { + return this.lastPoints + } + + getInitialPoints(): number { + return this.initialPoints + } + + getSummary(): { + initial: number + current: number + spent: number + duration: number + } { + return { + initial: this.initialPoints, + current: this.lastPoints, + spent: this.totalSpent, + duration: this.getSessionDuration() + } + } +}