mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 00:56:16 +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
|
||||
|
||||
### 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
|
||||
**Version:** See `package.json` for current version
|
||||
**Maintainer:** LightZirconite + Community Contributors
|
||||
**Last Updated:** 2025-01-02
|
||||
|
||||
---
|
||||
|
||||
|
||||
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
|
||||
|
||||
> **⚠️ 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
|
||||
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
|
||||
- **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.
|
||||
- **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
|
||||
In src/config.jsonc:
|
||||
@@ -14,18 +17,21 @@ In src/config.jsonc:
|
||||
```jsonc
|
||||
{
|
||||
"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
|
||||
- Error message (sanitized)
|
||||
- Stack trace (truncated, paths removed)
|
||||
- Stack trace (truncated to 15 lines, paths removed)
|
||||
- Bot version
|
||||
- OS platform and architecture
|
||||
- Node.js version
|
||||
- Timestamp
|
||||
- Bot mode (DESKTOP, MOBILE, MAIN)
|
||||
|
||||
## What is filtered out
|
||||
- Login failures (your credentials are never sent)
|
||||
@@ -33,6 +39,24 @@ In src/config.jsonc:
|
||||
- Configuration errors (missing files, invalid settings)
|
||||
- Network timeouts
|
||||
- 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)**
|
||||
@@ -61,6 +61,7 @@
|
||||
"@types/node-cron": "3.0.11",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.17.0",
|
||||
"@vercel/node": "^3.2.25",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-modules-newline": "^0.0.6",
|
||||
"rimraf": "^6.0.1",
|
||||
@@ -85,4 +86,4 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,13 @@ export interface ConfigDashboard {
|
||||
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 {
|
||||
enabled?: boolean; // Enable automatic daily scheduling
|
||||
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 crypto from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DISCORD } from '../../constants'
|
||||
import { Config } from '../../interface/Config'
|
||||
|
||||
const ERROR_REPORTING_HARD_DISABLED = true
|
||||
|
||||
interface DiscordEmbed {
|
||||
title: string
|
||||
description: string
|
||||
color: number
|
||||
fields: Array<{ name: string; value: string; inline: boolean }>
|
||||
timestamp: string
|
||||
footer: { text: string; icon_url: string }
|
||||
}
|
||||
/**
|
||||
* Emergency kill switch for error reporting
|
||||
* Set to true to completely disable error reporting (bypasses all config)
|
||||
*/
|
||||
const ERROR_REPORTING_HARD_DISABLED = false
|
||||
|
||||
interface ErrorReportPayload {
|
||||
error: string
|
||||
@@ -25,8 +18,9 @@ interface ErrorReportPayload {
|
||||
arch: string
|
||||
nodeVersion: string
|
||||
timestamp: string
|
||||
botMode?: string // DESKTOP, MOBILE, or MAIN
|
||||
botMode?: string
|
||||
}
|
||||
additionalContext?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
@@ -274,279 +110,160 @@ function shouldReportError(errorMessage: string): boolean {
|
||||
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
|
||||
* Used when webhook is deleted (404) - no need to keep trying
|
||||
* Build the error report payload for Vercel API
|
||||
* Returns null if error should be filtered (prevents sending)
|
||||
*/
|
||||
export function disableErrorReportingTemporary(): void {
|
||||
// Disable all internal webhooks for this execution (persist encoded markers)
|
||||
for (const encoded of INTERNAL_ERROR_WEBHOOKS) {
|
||||
markTemporarilyDisabled(encoded)
|
||||
function buildErrorReportPayload(error: Error | string, additionalContext?: Record<string, unknown>): ErrorReportPayload | 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
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
* 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(
|
||||
config: Config,
|
||||
error: Error | string,
|
||||
additionalContext?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
// Error reporting not available as 12/26/2025 because of vulnerabilities
|
||||
// View here: https://ptb.discord.com/channels/1418201715009912866/1418201717098418249/1454198384813412534
|
||||
// Hard-disabled flag (emergency kill switch)
|
||||
if (ERROR_REPORTING_HARD_DISABLED) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Check if error reporting is enabled
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Check if error reporting is enabled in config
|
||||
if (config.errorReporting?.enabled === false) {
|
||||
process.stderr.write('[ErrorReporting] Disabled in config (errorReporting.enabled = false)\n')
|
||||
return
|
||||
}
|
||||
|
||||
// Log that error reporting is enabled
|
||||
process.stderr.write('[ErrorReporting] Enabled, processing error...\n')
|
||||
|
||||
|
||||
try {
|
||||
pruneExpiredDisabledWebhooks()
|
||||
// Build candidate webhook list:
|
||||
// - If config provides webhooks, prefer them (accepts plain or base64-encoded values)
|
||||
// - Else fall back to internal hardcoded list
|
||||
const candidateEncodedWebhooks: string[] = []
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build error report payload (with sanitization)
|
||||
const payload = buildErrorReportPayload(error, additionalContext)
|
||||
if (!payload) {
|
||||
process.stderr.write('[ErrorReporting] Error was filtered (expected/benign), skipping report\n')
|
||||
return
|
||||
}
|
||||
|
||||
if (candidateEncodedWebhooks.length === 0) {
|
||||
candidateEncodedWebhooks.push(...INTERNAL_ERROR_WEBHOOKS)
|
||||
// Determine API endpoint URL
|
||||
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
|
||||
let lastError: unknown = null
|
||||
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)
|
||||
}
|
||||
if (rateLimitSecret) {
|
||||
headers['X-Rate-Limit-Secret'] = rateLimitSecret
|
||||
}
|
||||
|
||||
for (const encoded of candidateEncodedWebhooks) {
|
||||
const webhookUrl = deobfuscateWebhookUrl(encoded)
|
||||
if (!webhookUrl || !webhookUrl.startsWith('https://discord.com/api/webhooks/')) {
|
||||
continue
|
||||
}
|
||||
// Send to Vercel API with timeout
|
||||
const response = await axios.post(apiUrl, payload, {
|
||||
headers,
|
||||
timeout: 15000 // 15 second timeout
|
||||
})
|
||||
|
||||
if (isTemporarilyDisabled(encoded)) {
|
||||
process.stderr.write(`[ErrorReporting] Skipping disabled webhook: ${webhookUrl}\n`)
|
||||
continue
|
||||
}
|
||||
|
||||
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 (response.status === 200) {
|
||||
process.stderr.write('[ErrorReporting] ✅ Error report sent successfully\n')
|
||||
} else {
|
||||
process.stderr.write(`[ErrorReporting] ⚠️ Unexpected response status: ${response.status}\n`)
|
||||
}
|
||||
|
||||
if (!sent) {
|
||||
// If none succeeded, fall back to logging the failure
|
||||
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
|
||||
} catch (apiError) {
|
||||
// Handle API errors gracefully (don't throw - error reporting is non-critical)
|
||||
let errorMsg = ''
|
||||
let httpStatus: number | null = null
|
||||
|
||||
if (webhookError && typeof webhookError === 'object' && 'response' in webhookError) {
|
||||
const axiosError = webhookError as { response?: { status: number } }
|
||||
if (apiError && typeof apiError === 'object' && 'response' in apiError) {
|
||||
const axiosError = apiError as { response?: { status: number; data?: unknown } }
|
||||
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
|
||||
if (httpStatus === 404) {
|
||||
// Webhook was deleted - disable error reporting for this execution
|
||||
errorMsg = 'Webhook not found (404) - was it deleted? Disabling error reporting for this run.'
|
||||
disableErrorReportingTemporary()
|
||||
process.stderr.write(`[ErrorReporting] ❌ ${errorMsg}\n`)
|
||||
// Handle specific HTTP status codes
|
||||
if (httpStatus === 429) {
|
||||
process.stderr.write(`[ErrorReporting] ⚠️ Rate limit exceeded (HTTP 429): ${errorMsg || 'Too many requests'}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (httpStatus === 401 || httpStatus === 403) {
|
||||
// Authentication/authorization error
|
||||
errorMsg = `Webhook authentication failed (HTTP ${httpStatus}) - check if webhook token is valid`
|
||||
disableErrorReportingTemporary()
|
||||
process.stderr.write(`[ErrorReporting] ❌ ${errorMsg}\n`)
|
||||
if (httpStatus === 400) {
|
||||
process.stderr.write(`[ErrorReporting] ❌ Invalid payload (HTTP 400): ${errorMsg || 'Check error report format'}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (httpStatus && httpStatus >= 500) {
|
||||
// Server error - may be temporary, log but don't disable
|
||||
errorMsg = `Discord server error (HTTP ${httpStatus}) - will retry on next error`
|
||||
process.stderr.write(`[ErrorReporting] ⚠️ ${errorMsg}\n`)
|
||||
if (httpStatus === 502 || (httpStatus && httpStatus >= 500)) {
|
||||
process.stderr.write(`[ErrorReporting] ⚠️ Server error (HTTP ${httpStatus}): ${errorMsg || 'Vercel or Discord webhook unavailable'}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
// Generic error message
|
||||
// Generic error logging
|
||||
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`)
|
||||
|
||||
// If it's a network error, provide additional context
|
||||
if (webhookError instanceof Error && (webhookError.message.includes('ENOTFOUND') || webhookError.message.includes('ECONNREFUSED'))) {
|
||||
// Network connectivity hints
|
||||
if (apiError instanceof Error && (apiError.message.includes('ENOTFOUND') || apiError.message.includes('ECONNREFUSED'))) {
|
||||
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
|
||||
* 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 {
|
||||
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