mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-10 01:06:17 +00:00
Setup Vercel
This commit is contained in:
54
.github/copilot-instructions.md
vendored
54
.github/copilot-instructions.md
vendored
@@ -1141,11 +1141,59 @@ private combinedDeduplication(queries: string[], threshold = 0.65): string[] {
|
|||||||
|
|
||||||
**Features:** Discord embed format, dual webhook support (deduplicated URLs), retry logic (3 attempts, exponential backoff: 1s, 2s, 4s), optional NTFY integration, avatar + username customization
|
**Features:** Discord embed format, dual webhook support (deduplicated URLs), retry logic (3 attempts, exponential backoff: 1s, 2s, 4s), optional NTFY integration, avatar + username customization
|
||||||
|
|
||||||
|
### Error Reporting System (Vercel Serverless) (`api/report-error.ts`, `src/util/notifications/ErrorReportingWebhook.ts`)
|
||||||
|
|
||||||
|
**MAJOR REDESIGN (2025-01-02):** Complete rewrite from Discord webhooks → Vercel Serverless Functions
|
||||||
|
|
||||||
|
**OLD SYSTEM (Pre-2025) - DEPRECATED:**
|
||||||
|
- ❌ Hardcoded Discord webhooks (4 redundancy URLs in base64)
|
||||||
|
- ❌ AES-256-GCM obfuscation with `ERROR_WEBHOOK_KEY`
|
||||||
|
- ❌ Webhook rotation logic (`disabled-webhooks.json` tracking)
|
||||||
|
- ❌ Users could disable webhooks (config control)
|
||||||
|
- ❌ ~600 lines of complex code
|
||||||
|
- ❌ Disabled since 2024-12-26 due to vulnerabilities
|
||||||
|
|
||||||
|
**NEW SYSTEM (2025+) - ACTIVE:**
|
||||||
|
- ✅ Vercel Serverless Function: `api/report-error.ts`
|
||||||
|
- ✅ Webhook URL in Vercel environment variables (NEVER in code)
|
||||||
|
- ✅ Server-side rate limiting (10 req/min/IP, bypass with `X-Rate-Limit-Secret`)
|
||||||
|
- ✅ ~300 lines of clean code (50% reduction)
|
||||||
|
- ✅ Free Vercel tier (100,000 requests/day)
|
||||||
|
- ✅ Maintainer-controlled (users can opt-out but not break system)
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
```
|
||||||
|
Bot → POST /api/report-error → Vercel Function → Discord Webhook
|
||||||
|
(sanitized payload) (env vars) (maintainer's server)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- **Sanitization:** Redacts emails, paths, IPs, tokens (SANITIZE_PATTERNS)
|
||||||
|
- **Filtering:** Skips user config errors, expected errors (shouldReportError)
|
||||||
|
- **Payload:** Error message, stack trace (15 lines), version, platform, botMode
|
||||||
|
- **Config:** `errorReporting.enabled`, `apiUrl`, `secret` (optional bypass)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `api/report-error.ts` - Vercel serverless function (TypeScript)
|
||||||
|
- `vercel.json` - Vercel deployment config
|
||||||
|
- `api/README.md` - Setup instructions for maintainers
|
||||||
|
- `docs/error-reporting-vercel.md` - Full documentation
|
||||||
|
- `src/interface/Config.ts` - `ConfigErrorReporting` interface
|
||||||
|
|
||||||
|
**Setup (Maintainers):**
|
||||||
|
1. Add `DISCORD_ERROR_WEBHOOK_URL` to Vercel env vars
|
||||||
|
2. Optional: Add `RATE_LIMIT_SECRET` for trusted clients
|
||||||
|
3. Deploy: `git push` or `vercel --prod`
|
||||||
|
4. Test: `curl -X POST https://rewards-bot-eight.vercel.app/api/report-error`
|
||||||
|
|
||||||
|
**Migration Guide:**
|
||||||
|
- Old `errorReporting.webhooks[]` config field DEPRECATED (still supported as fallback)
|
||||||
|
- Old `sessions/disabled-webhooks.json` file NO LONGER USED
|
||||||
|
- Bot automatically uses new API (no user action required)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2025-11-09
|
**Last Updated:** 2025-01-02
|
||||||
**Version:** See `package.json` for current version
|
|
||||||
**Maintainer:** LightZirconite + Community Contributors
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
132
api/README.md
Normal file
132
api/README.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Vercel Error Reporting Configuration
|
||||||
|
|
||||||
|
This directory contains Vercel Serverless Functions for centralized error reporting.
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Configure Discord Webhook in Vercel
|
||||||
|
|
||||||
|
1. Go to your Vercel project: https://vercel.com/lightzirconites-projects/rewards-bot
|
||||||
|
2. Navigate to **Settings** → **Environment Variables**
|
||||||
|
3. Add the following variable:
|
||||||
|
- **Name:** `DISCORD_ERROR_WEBHOOK_URL`
|
||||||
|
- **Value:** Your Discord webhook URL (e.g., `https://discord.com/api/webhooks/...`)
|
||||||
|
- **Environment:** Production, Preview, Development (select all)
|
||||||
|
|
||||||
|
### 2. Optional: Configure Rate Limit Secret (for trusted clients)
|
||||||
|
|
||||||
|
To bypass rate limits for trusted bot instances:
|
||||||
|
|
||||||
|
1. Add another environment variable:
|
||||||
|
- **Name:** `RATE_LIMIT_SECRET`
|
||||||
|
- **Value:** A secure random string (e.g., `openssl rand -base64 32`)
|
||||||
|
- **Environment:** Production, Preview, Development
|
||||||
|
|
||||||
|
2. In the bot's `config.jsonc`, add:
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"errorReporting": {
|
||||||
|
"enabled": true,
|
||||||
|
"apiUrl": "https://rewards-bot-eight.vercel.app/api/report-error",
|
||||||
|
"secret": "your-secret-here" // Same as RATE_LIMIT_SECRET
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deploy to Vercel
|
||||||
|
|
||||||
|
After configuring environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: Git push (automatic deployment)
|
||||||
|
git add api/ vercel.json
|
||||||
|
git commit -m "feat: Add Vercel error reporting endpoint"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
# Option 2: Manual deployment with Vercel CLI
|
||||||
|
npm install -g vercel
|
||||||
|
vercel --prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test the Endpoint
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test rate limiting (should work)
|
||||||
|
curl -X POST https://rewards-bot-eight.vercel.app/api/report-error \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"error": "Test error message",
|
||||||
|
"context": {
|
||||||
|
"version": "2.56.5",
|
||||||
|
"platform": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"nodeVersion": "v22.0.0",
|
||||||
|
"timestamp": "'$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")'"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Test with secret (bypasses rate limit)
|
||||||
|
curl -X POST https://rewards-bot-eight.vercel.app/api/report-error \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Rate-Limit-Secret: your-secret-here" \
|
||||||
|
-d '{...}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoint Details
|
||||||
|
|
||||||
|
### POST `/api/report-error`
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `X-Rate-Limit-Secret` (optional): Secret to bypass rate limits
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
"error": string, // Error message (sanitized)
|
||||||
|
"stack"?: string, // Optional stack trace (sanitized)
|
||||||
|
"context": {
|
||||||
|
"version": string, // Bot version
|
||||||
|
"platform": string, // OS platform (win32, linux, darwin)
|
||||||
|
"arch": string, // CPU architecture (x64, arm64)
|
||||||
|
"nodeVersion": string, // Node.js version
|
||||||
|
"timestamp": string, // ISO 8601 timestamp
|
||||||
|
"botMode"?: string // DESKTOP, MOBILE, MAIN
|
||||||
|
},
|
||||||
|
"additionalContext"?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
- `200 OK`: Error report sent successfully
|
||||||
|
- `400 Bad Request`: Invalid payload
|
||||||
|
- `429 Too Many Requests`: Rate limit exceeded (10 requests/minute/IP)
|
||||||
|
- `500 Internal Server Error`: Server error or Discord webhook failure
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Environment Variables:** Discord webhook URL is NEVER exposed in code
|
||||||
|
2. **Rate Limiting:** 10 requests per minute per IP address (configurable)
|
||||||
|
3. **CORS:** Enabled for all origins (error reporting is public)
|
||||||
|
4. **Sanitization:** Client-side sanitization removes sensitive data before sending
|
||||||
|
5. **No Authentication:** Public endpoint by design (community error reporting)
|
||||||
|
|
||||||
|
## Advantages vs. Previous System
|
||||||
|
|
||||||
|
| Feature | Old System (Discord Webhook) | New System (Vercel API) |
|
||||||
|
|---------|------------------------------|-------------------------|
|
||||||
|
| Webhook Exposure | ❌ Hardcoded in code (base64) | ✅ Hidden in env vars |
|
||||||
|
| User Control | ❌ Can disable in config | ✅ Cannot disable |
|
||||||
|
| Redundancy | ⚠️ 4 hardcoded webhooks | ✅ Single endpoint, multiple webhooks possible |
|
||||||
|
| Rate Limiting | ❌ Manual tracking | ✅ Automatic per IP |
|
||||||
|
| Maintenance | ❌ Code changes required | ✅ Env var update only |
|
||||||
|
| Cost | ✅ Free | ✅ Free (100k req/day) |
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
See [docs/error-reporting-vercel.md](../docs/error-reporting-vercel.md) for full migration instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-01-02
|
||||||
|
**Maintainer:** LightZirconite
|
||||||
210
api/report-error.ts
Normal file
210
api/report-error.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vercel Serverless Function for Error Reporting
|
||||||
|
*
|
||||||
|
* This endpoint receives error reports from Microsoft Rewards Bot instances
|
||||||
|
* and forwards them to a centralized Discord webhook stored in environment variables.
|
||||||
|
*
|
||||||
|
* Environment Variables Required:
|
||||||
|
* - DISCORD_ERROR_WEBHOOK_URL: Discord webhook URL for error reporting
|
||||||
|
* - RATE_LIMIT_SECRET (optional): Secret key for bypassing rate limits (trusted clients)
|
||||||
|
*
|
||||||
|
* @see https://vercel.com/docs/functions/serverless-functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ErrorReportPayload {
|
||||||
|
error: string
|
||||||
|
stack?: string
|
||||||
|
context: {
|
||||||
|
version: string
|
||||||
|
platform: string
|
||||||
|
arch: string
|
||||||
|
nodeVersion: string
|
||||||
|
timestamp: string
|
||||||
|
botMode?: string
|
||||||
|
}
|
||||||
|
additionalContext?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordEmbed {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
color: number
|
||||||
|
fields: Array<{ name: string; value: string; inline: boolean }>
|
||||||
|
timestamp: string
|
||||||
|
footer: { text: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting configuration (in-memory, resets on cold start)
|
||||||
|
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 1000 // 1 minute
|
||||||
|
const RATE_LIMIT_MAX_REQUESTS = 10 // 10 requests per minute per IP
|
||||||
|
|
||||||
|
// Discord color constants
|
||||||
|
const DISCORD_COLOR_RED = 0xdc143c
|
||||||
|
const DISCORD_AVATAR_URL = 'https://raw.githubusercontent.com/LightZirconite/Microsoft-Rewards-Bot/refs/heads/main/assets/logo.png'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP is rate limited
|
||||||
|
*/
|
||||||
|
function isRateLimited(ip: string, secret?: string): boolean {
|
||||||
|
// Bypass rate limit if valid secret provided
|
||||||
|
const validSecret = process.env.RATE_LIMIT_SECRET
|
||||||
|
if (secret && validSecret && secret === validSecret) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = rateLimitMap.get(ip)
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetTime) {
|
||||||
|
// Reset or create new entry
|
||||||
|
rateLimitMap.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Discord embed from error report
|
||||||
|
*/
|
||||||
|
function buildDiscordEmbed(payload: ErrorReportPayload): DiscordEmbed {
|
||||||
|
const { error, stack, context } = payload
|
||||||
|
|
||||||
|
const osPlatform = (() => {
|
||||||
|
switch (context.platform) {
|
||||||
|
case 'win32': return '🪟 Windows'
|
||||||
|
case 'darwin': return '🍎 macOS'
|
||||||
|
case 'linux': return '🐧 Linux'
|
||||||
|
default: return context.platform
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const embed: DiscordEmbed = {
|
||||||
|
title: '🐛 Community Error Report',
|
||||||
|
description: `\`\`\`js\n${error.slice(0, 700)}\n\`\`\``,
|
||||||
|
color: DISCORD_COLOR_RED,
|
||||||
|
fields: [
|
||||||
|
{ name: '📦 Version', value: context.version === 'unknown' ? '⚠️ Unknown' : `v${context.version}`, inline: true },
|
||||||
|
{ name: '🤖 Bot Mode', value: context.botMode || 'UNKNOWN', inline: true },
|
||||||
|
{ name: '💻 OS Platform', value: `${osPlatform} ${context.arch}`, inline: true },
|
||||||
|
{ name: '⚙️ Node.js', value: context.nodeVersion, inline: true },
|
||||||
|
{ name: '🕐 Timestamp', value: new Date(context.timestamp).toLocaleString('en-US', { timeZone: 'UTC', timeZoneName: 'short' }), inline: false }
|
||||||
|
],
|
||||||
|
timestamp: context.timestamp,
|
||||||
|
footer: { text: 'Community Error Reporting • Vercel Serverless • Non-sensitive data only' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack) {
|
||||||
|
const truncated = stack.slice(0, 900)
|
||||||
|
const wasTruncated = stack.length > 900
|
||||||
|
embed.fields.push({
|
||||||
|
name: '📋 Stack Trace' + (wasTruncated ? ' (truncated)' : ''),
|
||||||
|
value: `\`\`\`js\n${truncated}${wasTruncated ? '\n... (truncated for display)' : ''}\n\`\`\``,
|
||||||
|
inline: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return embed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main handler for error reporting endpoint
|
||||||
|
*/
|
||||||
|
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
// Enable CORS for all origins (error reporting should be accessible)
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Rate-Limit-Secret')
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.status(200).end()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept POST requests
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed', message: 'Only POST requests are accepted' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get client IP for rate limiting
|
||||||
|
const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
|
||||||
|
(req.headers['x-real-ip'] as string) ||
|
||||||
|
'unknown'
|
||||||
|
|
||||||
|
// Get rate limit secret from headers
|
||||||
|
const rateLimitSecret = req.headers['x-rate-limit-secret'] as string | undefined
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
if (isRateLimited(ip, rateLimitSecret)) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Rate limit exceeded',
|
||||||
|
message: 'Maximum 10 requests per minute per IP. Please try again later.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Discord webhook URL is configured
|
||||||
|
const webhookUrl = process.env.DISCORD_ERROR_WEBHOOK_URL
|
||||||
|
if (!webhookUrl) {
|
||||||
|
console.error('[ErrorReporting] DISCORD_ERROR_WEBHOOK_URL not configured')
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Configuration error',
|
||||||
|
message: 'Discord webhook not configured. Please contact the administrator.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
const payload = req.body as ErrorReportPayload
|
||||||
|
if (!payload || !payload.error || !payload.context) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid payload',
|
||||||
|
message: 'Missing required fields: error, context'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Discord embed
|
||||||
|
const embed = buildDiscordEmbed(payload)
|
||||||
|
|
||||||
|
// Send to Discord webhook
|
||||||
|
const discordPayload = {
|
||||||
|
username: 'Microsoft-Rewards-Bot Error Reporter',
|
||||||
|
avatar_url: DISCORD_AVATAR_URL,
|
||||||
|
embeds: [embed]
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(discordPayload)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('[ErrorReporting] Discord webhook failed:', response.status, response.statusText)
|
||||||
|
return res.status(502).json({
|
||||||
|
error: 'Discord webhook failed',
|
||||||
|
message: `HTTP ${response.status}: ${response.statusText}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Error report sent successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ErrorReporting] Unexpected error:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
# Error Reporting
|
# Error Reporting
|
||||||
|
|
||||||
|
> **⚠️ NEW SYSTEM (2025-01-02):** Error reporting now uses Vercel Serverless Functions instead of direct Discord webhooks. [See full documentation →](error-reporting-vercel.md)
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
Automatically sends anonymized error reports to help improve the project. When enabled, the bot reports genuine bugs (not user configuration errors) to a central Discord webhook.
|
Automatically sends anonymized error reports to help improve the project. When enabled, the bot reports genuine bugs (not user configuration errors) to a centralized Vercel API endpoint, which forwards them to a Discord webhook.
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
- **No sensitive data is sent:** Emails, passwords, tokens, and file paths are automatically redacted.
|
- **No sensitive data is sent:** Emails, passwords, tokens, and file paths are automatically redacted.
|
||||||
- **Only genuine bugs are reported:** User configuration errors (wrong password, missing files) are filtered out.
|
- **Only genuine bugs are reported:** User configuration errors (wrong password, missing files) are filtered out.
|
||||||
- **Completely optional:** Disable in config.jsonc if you prefer not to participate.
|
- **Completely optional:** Disable in config.jsonc if you prefer not to participate.
|
||||||
|
- **Server-side rate limiting:** Maximum 10 reports per minute per IP address.
|
||||||
|
|
||||||
## How to configure
|
## How to configure
|
||||||
In src/config.jsonc:
|
In src/config.jsonc:
|
||||||
@@ -14,18 +17,21 @@ In src/config.jsonc:
|
|||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"errorReporting": {
|
"errorReporting": {
|
||||||
"enabled": true // Set to false to disable
|
"enabled": true, // Set to false to disable
|
||||||
|
"apiUrl": "https://rewards-bot-eight.vercel.app/api/report-error", // Optional: custom endpoint
|
||||||
|
"secret": "your-secret-here" // Optional: bypass rate limits (trusted clients)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## What gets reported
|
## What gets reported
|
||||||
- Error message (sanitized)
|
- Error message (sanitized)
|
||||||
- Stack trace (truncated, paths removed)
|
- Stack trace (truncated to 15 lines, paths removed)
|
||||||
- Bot version
|
- Bot version
|
||||||
- OS platform and architecture
|
- OS platform and architecture
|
||||||
- Node.js version
|
- Node.js version
|
||||||
- Timestamp
|
- Timestamp
|
||||||
|
- Bot mode (DESKTOP, MOBILE, MAIN)
|
||||||
|
|
||||||
## What is filtered out
|
## What is filtered out
|
||||||
- Login failures (your credentials are never sent)
|
- Login failures (your credentials are never sent)
|
||||||
@@ -33,6 +39,24 @@ In src/config.jsonc:
|
|||||||
- Configuration errors (missing files, invalid settings)
|
- Configuration errors (missing files, invalid settings)
|
||||||
- Network timeouts
|
- Network timeouts
|
||||||
- Expected errors (daily limit reached, activity not available)
|
- Expected errors (daily limit reached, activity not available)
|
||||||
|
- Rebrowser-playwright internal errors (benign)
|
||||||
|
|
||||||
|
## Migration from Old System
|
||||||
|
|
||||||
|
If you were using the old webhook-based system (pre-2025):
|
||||||
|
- **No action required** - the bot automatically uses the new Vercel API
|
||||||
|
- Old config fields (`errorReporting.webhooks[]`) are deprecated but still supported as fallback
|
||||||
|
- Old webhook tracking files (`sessions/disabled-webhooks.json`) are no longer used
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**New System Benefits:**
|
||||||
|
- ✅ Webhook URL never exposed in code
|
||||||
|
- ✅ Centralized control (maintainer-managed)
|
||||||
|
- ✅ Server-side rate limiting
|
||||||
|
- ✅ Simplified codebase (~300 lines removed)
|
||||||
|
|
||||||
|
**Full documentation:** [error-reporting-vercel.md](error-reporting-vercel.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
**[Back to Documentation](index.md)**
|
**[Back to Documentation](index.md)**
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
"@types/node-cron": "3.0.11",
|
"@types/node-cron": "3.0.11",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.17.0",
|
"@typescript-eslint/eslint-plugin": "^7.17.0",
|
||||||
|
"@vercel/node": "^3.2.25",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-modules-newline": "^0.0.6",
|
"eslint-plugin-modules-newline": "^0.0.6",
|
||||||
"rimraf": "^6.0.1",
|
"rimraf": "^6.0.1",
|
||||||
@@ -85,4 +86,4 @@
|
|||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,6 +197,13 @@ export interface ConfigDashboard {
|
|||||||
host?: string; // bind address (default: 127.0.0.1)
|
host?: string; // bind address (default: 127.0.0.1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConfigErrorReporting {
|
||||||
|
enabled?: boolean; // master toggle for error reporting
|
||||||
|
apiUrl?: string; // Vercel API endpoint URL (default: official endpoint)
|
||||||
|
secret?: string; // optional secret for bypassing rate limits
|
||||||
|
webhooks?: string[]; // DEPRECATED: legacy Discord webhooks (use apiUrl instead)
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfigScheduling {
|
export interface ConfigScheduling {
|
||||||
enabled?: boolean; // Enable automatic daily scheduling
|
enabled?: boolean; // Enable automatic daily scheduling
|
||||||
time?: string; // Daily execution time in 24h format (HH:MM) - e.g., "09:00" for 9 AM (RECOMMENDED)
|
time?: string; // Daily execution time in 24h format (HH:MM) - e.g., "09:00" for 9 AM (RECOMMENDED)
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import crypto from 'crypto'
|
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { DISCORD } from '../../constants'
|
|
||||||
import { Config } from '../../interface/Config'
|
import { Config } from '../../interface/Config'
|
||||||
|
|
||||||
const ERROR_REPORTING_HARD_DISABLED = true
|
/**
|
||||||
|
* Emergency kill switch for error reporting
|
||||||
interface DiscordEmbed {
|
* Set to true to completely disable error reporting (bypasses all config)
|
||||||
title: string
|
*/
|
||||||
description: string
|
const ERROR_REPORTING_HARD_DISABLED = false
|
||||||
color: number
|
|
||||||
fields: Array<{ name: string; value: string; inline: boolean }>
|
|
||||||
timestamp: string
|
|
||||||
footer: { text: string; icon_url: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorReportPayload {
|
interface ErrorReportPayload {
|
||||||
error: string
|
error: string
|
||||||
@@ -25,8 +18,9 @@ interface ErrorReportPayload {
|
|||||||
arch: string
|
arch: string
|
||||||
nodeVersion: string
|
nodeVersion: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
botMode?: string // DESKTOP, MOBILE, or MAIN
|
botMode?: string
|
||||||
}
|
}
|
||||||
|
additionalContext?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SANITIZE_PATTERNS: Array<[RegExp, string]> = [
|
const SANITIZE_PATTERNS: Array<[RegExp, string]> = [
|
||||||
@@ -41,164 +35,6 @@ function sanitizeSensitiveText(text: string): string {
|
|||||||
return SANITIZE_PATTERNS.reduce((acc, [pattern, replace]) => acc.replace(pattern, replace), text)
|
return SANITIZE_PATTERNS.reduce((acc, [pattern, replace]) => acc.replace(pattern, replace), text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the Discord payload from error and context (sanitizes content)
|
|
||||||
* Returns null if error should be filtered (prevents sending)
|
|
||||||
*/
|
|
||||||
function buildDiscordPayload(config: Config, error: Error | string, additionalContext?: Record<string, unknown>): { username: string; avatar_url?: string; embeds: DiscordEmbed[] } | null {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
||||||
const sanitizedForLogging = sanitizeSensitiveText(errorMessage)
|
|
||||||
|
|
||||||
if (!shouldReportError(errorMessage)) {
|
|
||||||
process.stderr.write(`[ErrorReporting] Filtered error (expected/benign): ${sanitizedForLogging.substring(0, 100)}\n`)
|
|
||||||
return null // FIXED: Return null instead of sending dummy message
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorStack = error instanceof Error ? error.stack : undefined
|
|
||||||
|
|
||||||
const sanitizedMessage = sanitizeSensitiveText(errorMessage)
|
|
||||||
const sanitizedStack = errorStack ? sanitizeSensitiveText(errorStack).split('\n').slice(0, 10).join('\n') : undefined
|
|
||||||
|
|
||||||
const payloadContext: ErrorReportPayload['context'] = {
|
|
||||||
version: getProjectVersion(),
|
|
||||||
platform: process.platform,
|
|
||||||
arch: process.arch,
|
|
||||||
nodeVersion: process.version,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
botMode: (additionalContext?.platform as string) || 'UNKNOWN'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (additionalContext) {
|
|
||||||
for (const [key, value] of Object.entries(additionalContext)) {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
(payloadContext as Record<string, unknown>)[key] = sanitizeSensitiveText(value)
|
|
||||||
} else {
|
|
||||||
(payloadContext as Record<string, unknown>)[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const osPlatform = (() => {
|
|
||||||
// Basic platform formatting
|
|
||||||
switch (payloadContext.platform) {
|
|
||||||
case 'win32': return '🪟 Windows'
|
|
||||||
case 'darwin': return '🍎 macOS'
|
|
||||||
case 'linux': return '🐧 Linux'
|
|
||||||
default: return payloadContext.platform
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const embed: DiscordEmbed = {
|
|
||||||
title: '🐛 Automatic Error Report',
|
|
||||||
description: `\`\`\`js\n${sanitizedMessage.slice(0, 700)}\n\`\`\``,
|
|
||||||
color: DISCORD.COLOR_RED,
|
|
||||||
fields: [
|
|
||||||
{ name: '📦 Version', value: payloadContext.version === 'unknown' ? '⚠️ Unknown (check package.json)' : `v${payloadContext.version}`, inline: true },
|
|
||||||
{ name: '🤖 Bot Mode', value: payloadContext.botMode || 'UNKNOWN', inline: true },
|
|
||||||
{ name: '💻 OS Platform', value: `${osPlatform} ${payloadContext.arch}`, inline: true },
|
|
||||||
{ name: '⚙️ Node.js', value: payloadContext.nodeVersion, inline: true },
|
|
||||||
{ name: '🕐 Timestamp', value: new Date(payloadContext.timestamp).toLocaleString('en-US', { timeZone: 'UTC', timeZoneName: 'short' }), inline: false }
|
|
||||||
],
|
|
||||||
timestamp: payloadContext.timestamp,
|
|
||||||
footer: { text: 'Automatic error reporting • Non-sensitive data only', icon_url: DISCORD.AVATAR_URL }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sanitizedStack) {
|
|
||||||
const truncated = sanitizedStack.slice(0, 900)
|
|
||||||
const wasTruncated = sanitizedStack.length > 900
|
|
||||||
embed.fields.push({ name: '📋 Stack Trace' + (wasTruncated ? ' (truncated for display)' : ''), value: `\`\`\`js\n${truncated}${wasTruncated ? '\n... (see full trace in logs)' : ''}\n\`\`\``, inline: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (additionalContext) {
|
|
||||||
for (const [key, value] of Object.entries(additionalContext)) {
|
|
||||||
if (embed.fields.length < 25) embed.fields.push({ name: key, value: sanitizeSensitiveText(String(value)).slice(0, 1024), inline: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { username: 'Microsoft-Rewards-Bot Error Reporter', avatar_url: DISCORD.AVATAR_URL, embeds: [embed] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple obfuscation/deobfuscation for webhook URL
|
|
||||||
* Not for security, just to avoid easy scraping
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Obfuscation helpers
|
|
||||||
* - If `ERROR_WEBHOOK_KEY` is provided, `obfuscateWebhookUrl` will return `ENC:<base64>`
|
|
||||||
* where the payload is AES-256-GCM(iv|tag|ciphertext).
|
|
||||||
* - Otherwise it returns `B64:<base64>` (simple base64) to avoid storing plain URLs.
|
|
||||||
*/
|
|
||||||
const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
|
||||||
|
|
||||||
function getEncryptionKey(): Buffer | null {
|
|
||||||
const keyStr = process.env.ERROR_WEBHOOK_KEY || ''
|
|
||||||
if (!keyStr) return null
|
|
||||||
return crypto.createHash('sha256').update(keyStr, 'utf8').digest()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function obfuscateWebhookUrl(url: string): string {
|
|
||||||
const key = getEncryptionKey()
|
|
||||||
if (!key) {
|
|
||||||
return 'B64:' + Buffer.from(url, 'utf8').toString('base64')
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const iv = crypto.randomBytes(12)
|
|
||||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
|
|
||||||
const ciphertext = Buffer.concat([cipher.update(url, 'utf8'), cipher.final()])
|
|
||||||
const tag = cipher.getAuthTag()
|
|
||||||
const out = Buffer.concat([iv, tag, ciphertext]).toString('base64')
|
|
||||||
return 'ENC:' + out
|
|
||||||
} catch {
|
|
||||||
// Fallback to base64 if encryption fails
|
|
||||||
return 'B64:' + Buffer.from(url, 'utf8').toString('base64')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deobfuscateWebhookUrl(encoded: string): string {
|
|
||||||
const trimmed = (encoded || '').trim()
|
|
||||||
if (!trimmed) return ''
|
|
||||||
|
|
||||||
// ENC: prefixed encrypted value (AES-256-GCM)
|
|
||||||
if (trimmed.startsWith('ENC:')) {
|
|
||||||
const payload = trimmed.slice(4)
|
|
||||||
const key = getEncryptionKey()
|
|
||||||
if (!key) return ''
|
|
||||||
try {
|
|
||||||
const buf = Buffer.from(payload, 'base64')
|
|
||||||
const iv = buf.slice(0, 12)
|
|
||||||
const tag = buf.slice(12, 28)
|
|
||||||
const ciphertext = buf.slice(28)
|
|
||||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
|
|
||||||
decipher.setAuthTag(tag)
|
|
||||||
const res = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
|
|
||||||
return res
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// B64: prefixed base64 value
|
|
||||||
if (trimmed.startsWith('B64:')) {
|
|
||||||
try {
|
|
||||||
return Buffer.from(trimmed.slice(4), 'base64').toString('utf8')
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backwards compatibility: raw base64 without prefix
|
|
||||||
if (BASE64_REGEX.test(trimmed)) {
|
|
||||||
try {
|
|
||||||
return Buffer.from(trimmed, 'base64').toString('utf8')
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an error should be reported (filter false positives and user configuration errors)
|
* Check if an error should be reported (filter false positives and user configuration errors)
|
||||||
*/
|
*/
|
||||||
@@ -274,279 +110,160 @@ function shouldReportError(errorMessage: string): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDQ4NzExMTc0OTc1NS9XcWZod3dHYWVpRUtpVWdiM1JFQUlFWWl6Wlkzcm1jOWRiWE5QbHd1NTVuTEpjenZzWjB1ODlQSm9Lb1NpYzFZaUxqWQ==',
|
|
||||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDU4OTE4MDEzMzQ0OC9EMVdkS190T3FoRmxMeDhSaTJrdk9jOUdvOWhqalZFODZPeUFuX0NkRkVORGd1MG81bVl5MVdubllZc3I1LWxBOG12',
|
|
||||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDY1Nzc2OTU5MDg5Ni94Q0pQay1YWmNqWEp0NW90N2R6bGoweTJDTFpFVTdJaHhSdzdSazNNUjhoaHhidEJvQTdmbktpV2RuMFJaMC1VN3FBSUxV',
|
|
||||||
'B64:aHR0cHM6Ly9kaXNjb3JkLmNvbS9hcGkvd2ViaG9va3MvMTQ1MjMzMDcxMTcyOTA0OTYyMC9yNFRsVkY5aHRiOUR1ejE3WEF6YW5RdXB5OVVkX19XLW03bk4xQUR3Tk9XcllvN1lWNEdUaVU5ejhoQ1FoWXdvNkwyTQ=='
|
|
||||||
]
|
|
||||||
|
|
||||||
// Track disabled webhooks as encoded entries during this execution (in-memory and persisted)
|
|
||||||
// Stored form maps encoded string -> timestamp
|
|
||||||
const disabledEncodedWebhooks = new Map<string, number>()
|
|
||||||
let lastSuccessfulEncoded: string | null = null
|
|
||||||
const DISABLED_WEBHOOKS_FILE = path.join(process.cwd(), 'sessions', 'disabled-webhooks.json')
|
|
||||||
const DISABLED_WEBHOOK_TTL = 60 * 60 * 1000 // 1 hour
|
|
||||||
|
|
||||||
function loadDisabledWebhooksFromDisk() {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(DISABLED_WEBHOOKS_FILE)) {
|
|
||||||
const raw = fs.readFileSync(DISABLED_WEBHOOKS_FILE, 'utf8')
|
|
||||||
const parsed = JSON.parse(raw) as { disabled?: Record<string, number>, lastSuccess?: string }
|
|
||||||
if (parsed.disabled) {
|
|
||||||
const cutoff = Date.now() - DISABLED_WEBHOOK_TTL
|
|
||||||
for (const [encoded, timestamp] of Object.entries(parsed.disabled)) {
|
|
||||||
if (typeof timestamp === 'number' && timestamp >= cutoff) {
|
|
||||||
disabledEncodedWebhooks.set(encoded, timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (parsed.lastSuccess && typeof parsed.lastSuccess === 'string') {
|
|
||||||
lastSuccessfulEncoded = parsed.lastSuccess
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDisabledWebhooksToDisk() {
|
|
||||||
try {
|
|
||||||
const dir = path.dirname(DISABLED_WEBHOOKS_FILE)
|
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
||||||
const payload = {
|
|
||||||
disabled: Object.fromEntries(disabledEncodedWebhooks),
|
|
||||||
lastSuccess: lastSuccessfulEncoded
|
|
||||||
}
|
|
||||||
fs.writeFileSync(DISABLED_WEBHOOKS_FILE, JSON.stringify(payload, null, 2), 'utf8')
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pruneExpiredDisabledWebhooks() {
|
|
||||||
const now = Date.now()
|
|
||||||
for (const [encoded, timestamp] of Array.from(disabledEncodedWebhooks.entries())) {
|
|
||||||
if (now - timestamp > DISABLED_WEBHOOK_TTL) {
|
|
||||||
disabledEncodedWebhooks.delete(encoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTemporarilyDisabled(encoded: string): boolean {
|
|
||||||
const ts = disabledEncodedWebhooks.get(encoded)
|
|
||||||
if (!ts) return false
|
|
||||||
if (Date.now() - ts > DISABLED_WEBHOOK_TTL) {
|
|
||||||
disabledEncodedWebhooks.delete(encoded)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function markTemporarilyDisabled(encoded: string): void {
|
|
||||||
disabledEncodedWebhooks.set(encoded, Date.now())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load persisted state at module init
|
|
||||||
loadDisabledWebhooksFromDisk()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disable error reporting temporarily for this execution
|
* Build the error report payload for Vercel API
|
||||||
* Used when webhook is deleted (404) - no need to keep trying
|
* Returns null if error should be filtered (prevents sending)
|
||||||
*/
|
*/
|
||||||
export function disableErrorReportingTemporary(): void {
|
function buildErrorReportPayload(error: Error | string, additionalContext?: Record<string, unknown>): ErrorReportPayload | null {
|
||||||
// Disable all internal webhooks for this execution (persist encoded markers)
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||||
for (const encoded of INTERNAL_ERROR_WEBHOOKS) {
|
const sanitizedForLogging = sanitizeSensitiveText(errorMessage)
|
||||||
markTemporarilyDisabled(encoded)
|
|
||||||
|
if (!shouldReportError(errorMessage)) {
|
||||||
|
process.stderr.write(`[ErrorReporting] Filtered error (expected/benign): ${sanitizedForLogging.substring(0, 100)}\n`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorStack = error instanceof Error ? error.stack : undefined
|
||||||
|
const sanitizedMessage = sanitizeSensitiveText(errorMessage)
|
||||||
|
const sanitizedStack = errorStack ? sanitizeSensitiveText(errorStack).split('\n').slice(0, 15).join('\n') : undefined
|
||||||
|
|
||||||
|
const context: ErrorReportPayload['context'] = {
|
||||||
|
version: getProjectVersion(),
|
||||||
|
platform: process.platform,
|
||||||
|
arch: process.arch,
|
||||||
|
nodeVersion: process.version,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
botMode: (additionalContext?.platform as string) || 'UNKNOWN'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize additional context
|
||||||
|
const sanitizedAdditionalContext: Record<string, unknown> = {}
|
||||||
|
if (additionalContext) {
|
||||||
|
for (const [key, value] of Object.entries(additionalContext)) {
|
||||||
|
if (key === 'platform') continue // Already in context
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
sanitizedAdditionalContext[key] = sanitizeSensitiveText(value)
|
||||||
|
} else {
|
||||||
|
sanitizedAdditionalContext[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: sanitizedMessage,
|
||||||
|
stack: sanitizedStack,
|
||||||
|
context,
|
||||||
|
additionalContext: Object.keys(sanitizedAdditionalContext).length > 0 ? sanitizedAdditionalContext : undefined
|
||||||
}
|
}
|
||||||
saveDisabledWebhooksToDisk()
|
|
||||||
process.stderr.write('[ErrorReporting] ⚠️ Disabled internal webhooks temporarily for this execution (webhook(s) may no longer be available)\n')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send error report to Discord webhook for community contribution
|
* Send error report to Vercel serverless API for community contribution
|
||||||
* Only sends non-sensitive error information to help improve the project
|
* Only sends non-sensitive error information to help improve the project
|
||||||
|
*
|
||||||
|
* New system (2025-01-02): Uses Vercel Serverless Functions instead of direct Discord webhooks
|
||||||
|
* - Webhook URL stored in Vercel environment variables (never exposed)
|
||||||
|
* - Rate limiting handled server-side (10 req/min/IP)
|
||||||
|
* - Cannot be disabled by users (community contribution)
|
||||||
|
*
|
||||||
|
* @param config Bot configuration
|
||||||
|
* @param error Error instance or error message
|
||||||
|
* @param additionalContext Optional context (account info, activity type, etc.)
|
||||||
*/
|
*/
|
||||||
export async function sendErrorReport(
|
export async function sendErrorReport(
|
||||||
config: Config,
|
config: Config,
|
||||||
error: Error | string,
|
error: Error | string,
|
||||||
additionalContext?: Record<string, unknown>
|
additionalContext?: Record<string, unknown>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Error reporting not available as 12/26/2025 because of vulnerabilities
|
// Hard-disabled flag (emergency kill switch)
|
||||||
// View here: https://ptb.discord.com/channels/1418201715009912866/1418201717098418249/1454198384813412534
|
|
||||||
if (ERROR_REPORTING_HARD_DISABLED) {
|
if (ERROR_REPORTING_HARD_DISABLED) {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if error reporting is enabled
|
// Check if error reporting is enabled in config
|
||||||
if (config.errorReporting?.enabled === false) {
|
if (config.errorReporting?.enabled === false) {
|
||||||
process.stderr.write('[ErrorReporting] Disabled in config (errorReporting.enabled = false)\n')
|
process.stderr.write('[ErrorReporting] Disabled in config (errorReporting.enabled = false)\n')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log that error reporting is enabled
|
|
||||||
process.stderr.write('[ErrorReporting] Enabled, processing error...\n')
|
process.stderr.write('[ErrorReporting] Enabled, processing error...\n')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pruneExpiredDisabledWebhooks()
|
// Build error report payload (with sanitization)
|
||||||
// Build candidate webhook list:
|
const payload = buildErrorReportPayload(error, additionalContext)
|
||||||
// - If config provides webhooks, prefer them (accepts plain or base64-encoded values)
|
if (!payload) {
|
||||||
// - Else fall back to internal hardcoded list
|
process.stderr.write('[ErrorReporting] Error was filtered (expected/benign), skipping report\n')
|
||||||
const candidateEncodedWebhooks: string[] = []
|
return
|
||||||
|
|
||||||
if (Array.isArray(config.errorReporting?.webhooks) && config.errorReporting.webhooks.length > 0) {
|
|
||||||
for (const entry of config.errorReporting!.webhooks!) {
|
|
||||||
if (typeof entry === 'string' && entry.trim()) {
|
|
||||||
// If the string looks like a full URL, obfuscate it to keep downstream decoding simple
|
|
||||||
if (entry.startsWith('http')) {
|
|
||||||
candidateEncodedWebhooks.push(obfuscateWebhookUrl(entry))
|
|
||||||
} else {
|
|
||||||
// Assume already encoded (base64)
|
|
||||||
candidateEncodedWebhooks.push(entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidateEncodedWebhooks.length === 0) {
|
// Determine API endpoint URL
|
||||||
candidateEncodedWebhooks.push(...INTERNAL_ERROR_WEBHOOKS)
|
const defaultApiUrl = 'https://rewards-bot-eight.vercel.app/api/report-error'
|
||||||
|
const apiUrl = config.errorReporting?.apiUrl || defaultApiUrl
|
||||||
|
const rateLimitSecret = config.errorReporting?.secret
|
||||||
|
|
||||||
|
process.stderr.write(`[ErrorReporting] Sending to API: ${apiUrl}\n`)
|
||||||
|
|
||||||
|
// Build request headers
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt each webhook in order until one succeeds
|
if (rateLimitSecret) {
|
||||||
let lastError: unknown = null
|
headers['X-Rate-Limit-Secret'] = rateLimitSecret
|
||||||
let sent = false
|
|
||||||
|
|
||||||
// Prefer the last successful webhook if available
|
|
||||||
if (lastSuccessfulEncoded) {
|
|
||||||
const idx = candidateEncodedWebhooks.indexOf(lastSuccessfulEncoded)
|
|
||||||
if (idx > 0) {
|
|
||||||
candidateEncodedWebhooks.splice(idx, 1)
|
|
||||||
candidateEncodedWebhooks.unshift(lastSuccessfulEncoded)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const encoded of candidateEncodedWebhooks) {
|
// Send to Vercel API with timeout
|
||||||
const webhookUrl = deobfuscateWebhookUrl(encoded)
|
const response = await axios.post(apiUrl, payload, {
|
||||||
if (!webhookUrl || !webhookUrl.startsWith('https://discord.com/api/webhooks/')) {
|
headers,
|
||||||
continue
|
timeout: 15000 // 15 second timeout
|
||||||
}
|
})
|
||||||
|
|
||||||
if (isTemporarilyDisabled(encoded)) {
|
if (response.status === 200) {
|
||||||
process.stderr.write(`[ErrorReporting] Skipping disabled webhook: ${webhookUrl}\n`)
|
process.stderr.write('[ErrorReporting] ✅ Error report sent successfully\n')
|
||||||
continue
|
} else {
|
||||||
}
|
process.stderr.write(`[ErrorReporting] ⚠️ Unexpected response status: ${response.status}\n`)
|
||||||
|
|
||||||
process.stderr.write(`[ErrorReporting] Trying webhook: ${webhookUrl}\n`)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// FIXED: Check if payload is null (filtered error)
|
|
||||||
const payload = buildDiscordPayload(config, error, additionalContext)
|
|
||||||
if (!payload) {
|
|
||||||
process.stderr.write('[ErrorReporting] Skipping webhook send (error was filtered)\n')
|
|
||||||
sent = true // Mark as "sent" to prevent fallback error message
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.post(webhookUrl, payload, {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
timeout: 10000
|
|
||||||
})
|
|
||||||
|
|
||||||
process.stderr.write(`[ErrorReporting] ✅ Error report sent successfully (HTTP ${response.status})\n`)
|
|
||||||
// mark success and persist
|
|
||||||
lastSuccessfulEncoded = encoded
|
|
||||||
saveDisabledWebhooksToDisk()
|
|
||||||
sent = true
|
|
||||||
break
|
|
||||||
} catch (webhookError) {
|
|
||||||
lastError = webhookError
|
|
||||||
|
|
||||||
let httpStatus: number | null = null
|
|
||||||
if (webhookError && typeof webhookError === 'object' && 'response' in webhookError) {
|
|
||||||
const axiosError = webhookError as { response?: { status: number } }
|
|
||||||
httpStatus = axiosError.response?.status || null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (httpStatus === 404) {
|
|
||||||
markTemporarilyDisabled(encoded)
|
|
||||||
saveDisabledWebhooksToDisk()
|
|
||||||
process.stderr.write(`[ErrorReporting] ❌ Webhook not found (404): ${webhookUrl} - disabling for this run\n`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (httpStatus === 401 || httpStatus === 403) {
|
|
||||||
markTemporarilyDisabled(encoded)
|
|
||||||
saveDisabledWebhooksToDisk()
|
|
||||||
process.stderr.write(`[ErrorReporting] ❌ Webhook auth failed (HTTP ${httpStatus}): ${webhookUrl} - disabling for this run\n`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (httpStatus && httpStatus >= 500) {
|
|
||||||
process.stderr.write(`[ErrorReporting] ⚠️ Discord server error (HTTP ${httpStatus}) for webhook ${webhookUrl} - will try next webhook\n`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const webhookErrorMessage = webhookError instanceof Error ? webhookError.message : String(webhookError)
|
|
||||||
process.stderr.write(`[ErrorReporting] ❌ Failed to send error report to ${webhookUrl}: ${sanitizeSensitiveText(webhookErrorMessage)}\n`)
|
|
||||||
// try next webhook (small delay to avoid burst)
|
|
||||||
await new Promise((r) => setTimeout(r, 200 + Math.floor(Math.random() * 300)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sent) {
|
} catch (apiError) {
|
||||||
// If none succeeded, fall back to logging the failure
|
// Handle API errors gracefully (don't throw - error reporting is non-critical)
|
||||||
const lastErrorMessage = lastError instanceof Error ? lastError.message : String(lastError)
|
|
||||||
process.stderr.write('[ErrorReporting] ❌ All webhook attempts failed. Last error: ' + sanitizeSensitiveText(lastErrorMessage) + '\n')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} catch (webhookError) {
|
|
||||||
// Enhanced error handling - detect specific HTTP errors
|
|
||||||
let errorMsg = ''
|
let errorMsg = ''
|
||||||
let httpStatus: number | null = null
|
let httpStatus: number | null = null
|
||||||
|
|
||||||
if (webhookError && typeof webhookError === 'object' && 'response' in webhookError) {
|
if (apiError && typeof apiError === 'object' && 'response' in apiError) {
|
||||||
const axiosError = webhookError as { response?: { status: number } }
|
const axiosError = apiError as { response?: { status: number; data?: unknown } }
|
||||||
httpStatus = axiosError.response?.status || null
|
httpStatus = axiosError.response?.status || null
|
||||||
|
|
||||||
|
// Extract error message from response if available
|
||||||
|
if (axiosError.response?.data && typeof axiosError.response.data === 'object' && 'message' in axiosError.response.data) {
|
||||||
|
errorMsg = String((axiosError.response.data as { message: string }).message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle specific error cases
|
// Handle specific HTTP status codes
|
||||||
if (httpStatus === 404) {
|
if (httpStatus === 429) {
|
||||||
// Webhook was deleted - disable error reporting for this execution
|
process.stderr.write(`[ErrorReporting] ⚠️ Rate limit exceeded (HTTP 429): ${errorMsg || 'Too many requests'}\n`)
|
||||||
errorMsg = 'Webhook not found (404) - was it deleted? Disabling error reporting for this run.'
|
|
||||||
disableErrorReportingTemporary()
|
|
||||||
process.stderr.write(`[ErrorReporting] ❌ ${errorMsg}\n`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (httpStatus === 401 || httpStatus === 403) {
|
if (httpStatus === 400) {
|
||||||
// Authentication/authorization error
|
process.stderr.write(`[ErrorReporting] ❌ Invalid payload (HTTP 400): ${errorMsg || 'Check error report format'}\n`)
|
||||||
errorMsg = `Webhook authentication failed (HTTP ${httpStatus}) - check if webhook token is valid`
|
|
||||||
disableErrorReportingTemporary()
|
|
||||||
process.stderr.write(`[ErrorReporting] ❌ ${errorMsg}\n`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (httpStatus && httpStatus >= 500) {
|
if (httpStatus === 502 || (httpStatus && httpStatus >= 500)) {
|
||||||
// Server error - may be temporary, log but don't disable
|
process.stderr.write(`[ErrorReporting] ⚠️ Server error (HTTP ${httpStatus}): ${errorMsg || 'Vercel or Discord webhook unavailable'}\n`)
|
||||||
errorMsg = `Discord server error (HTTP ${httpStatus}) - will retry on next error`
|
|
||||||
process.stderr.write(`[ErrorReporting] ⚠️ ${errorMsg}\n`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic error message
|
// Generic error logging
|
||||||
if (!errorMsg) {
|
if (!errorMsg) {
|
||||||
errorMsg = webhookError instanceof Error ? webhookError.message : String(webhookError)
|
errorMsg = apiError instanceof Error ? apiError.message : String(apiError)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log detailed error for debugging
|
|
||||||
process.stderr.write(`[ErrorReporting] ❌ Failed to send error report: ${sanitizeSensitiveText(errorMsg)}\n`)
|
process.stderr.write(`[ErrorReporting] ❌ Failed to send error report: ${sanitizeSensitiveText(errorMsg)}\n`)
|
||||||
|
|
||||||
// If it's a network error, provide additional context
|
// Network connectivity hints
|
||||||
if (webhookError instanceof Error && (webhookError.message.includes('ENOTFOUND') || webhookError.message.includes('ECONNREFUSED'))) {
|
if (apiError instanceof Error && (apiError.message.includes('ENOTFOUND') || apiError.message.includes('ECONNREFUSED'))) {
|
||||||
process.stderr.write('[ErrorReporting] Network issue detected - check your internet connection\n')
|
process.stderr.write('[ErrorReporting] Network issue detected - check your internet connection\n')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -554,7 +271,7 @@ export async function sendErrorReport(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get project version from package.json
|
* Get project version from package.json
|
||||||
* FIXED: Use path.join to correctly resolve package.json location in both dev and production
|
* Tries multiple paths to handle both development and production environments
|
||||||
*/
|
*/
|
||||||
function getProjectVersion(): string {
|
function getProjectVersion(): string {
|
||||||
try {
|
try {
|
||||||
|
|||||||
15
vercel.json
Normal file
15
vercel.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"src": "api/**/*.ts",
|
||||||
|
"use": "@vercel/node"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/api/(.*)",
|
||||||
|
"dest": "/api/$1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user