feat: add Buy Mode functionality for manual purchase monitoring and point tracking

This commit is contained in:
2025-11-04 21:08:11 +01:00
parent 8e1be00618
commit 57e2bc392d
7 changed files with 414 additions and 58 deletions

View File

@@ -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!**

View File

@@ -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)**

View File

@@ -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.
---

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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}\"",

View File

@@ -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[] = []

217
src/util/BuyMode.ts Normal file
View File

@@ -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<BuyModeSelection | null> {
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<Account | null> {
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<Account | null>((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()
}
}
}